mirror of
https://github.com/kennethnym/aris.git
synced 2026-04-01 23:51:17 +01:00
feat: add admin dashboard app (#91)
* feat: add admin dashboard app - React + Vite + TanStack Router + TanStack Query - Auth with better-auth (login, session, admin guard) - Source config management (WeatherKit credentials, user config) - Feed query panel - Location push card - General settings with health check - CORS middleware for cross-origin auth - Disable CSRF check in dev mode - Sonner toasts for mutation feedback Co-authored-by: Ona <no-reply@ona.com> * fix: use useQuery instead of getQueryData Co-authored-by: Ona <no-reply@ona.com> * refactor: remove backend changes from dashboard PR Backend CORS/CSRF changes moved to #92. Source registry removed (sources hardcoded in frontend). Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
464
apps/admin-dashboard/src/components/source-config-panel.tsx
Normal file
464
apps/admin-dashboard/src/components/source-config-panel.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { Info, Loader2, MapPin, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { ConfigFieldDef, SourceDefinition } from "@/lib/api"
|
||||
import { fetchSourceConfig, pushLocation, replaceSource, updateProviderConfig } from "@/lib/api"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
interface SourceConfigPanelProps {
|
||||
source: SourceDefinition
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [dirty, setDirty] = useState<Record<string, unknown>>({})
|
||||
|
||||
const { data: serverConfig, isLoading } = useQuery({
|
||||
queryKey: ["sourceConfig", source.id],
|
||||
queryFn: () => fetchSourceConfig(source.id),
|
||||
})
|
||||
|
||||
const enabled = serverConfig?.enabled ?? false
|
||||
const serverValues = buildInitialValues(source.fields, serverConfig?.config)
|
||||
const formValues = { ...serverValues, ...dirty }
|
||||
|
||||
function isCredentialField(field: ConfigFieldDef): boolean {
|
||||
return !!(field.secret && field.required)
|
||||
}
|
||||
|
||||
function getUserConfig(): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [name, value] of Object.entries(formValues)) {
|
||||
const field = source.fields[name]
|
||||
if (field && !isCredentialField(field)) {
|
||||
result[name] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getCredentialFields(): Record<string, unknown> {
|
||||
const creds: Record<string, unknown> = {}
|
||||
for (const [name, value] of Object.entries(formValues)) {
|
||||
const field = source.fields[name]
|
||||
if (field && isCredentialField(field)) {
|
||||
creds[name] = value
|
||||
}
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
queryClient.invalidateQueries({ queryKey: ["sourceConfig", source.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ["configs"] })
|
||||
onUpdate()
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const promises: Promise<void>[] = [
|
||||
replaceSource(source.id, { enabled, config: getUserConfig() }),
|
||||
]
|
||||
|
||||
const credentialFields = getCredentialFields()
|
||||
const hasCredentials = Object.values(credentialFields).some(
|
||||
(v) => typeof v === "string" && v.length > 0,
|
||||
)
|
||||
if (hasCredentials) {
|
||||
promises.push(
|
||||
updateProviderConfig(source.id, { credentials: credentialFields }),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
},
|
||||
onSuccess() {
|
||||
setDirty({})
|
||||
invalidate()
|
||||
toast.success("Configuration saved")
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (checked: boolean) =>
|
||||
replaceSource(source.id, { enabled: checked, config: getUserConfig() }),
|
||||
onSuccess(_data, checked) {
|
||||
invalidate()
|
||||
toast.success(`Source ${checked ? "enabled" : "disabled"}`)
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => replaceSource(source.id, { enabled: false, config: {} }),
|
||||
onSuccess() {
|
||||
setDirty({})
|
||||
invalidate()
|
||||
toast.success("Configuration deleted")
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
function handleFieldChange(fieldName: string, value: unknown) {
|
||||
setDirty((prev) => ({ ...prev, [fieldName]: value }))
|
||||
}
|
||||
|
||||
const fieldEntries = Object.entries(source.fields)
|
||||
const hasFields = fieldEntries.length > 0
|
||||
const busy = saveMutation.isPending || toggleMutation.isPending || deleteMutation.isPending
|
||||
|
||||
const requiredFields = fieldEntries.filter(([, f]) => f.required)
|
||||
const optionalFields = fieldEntries.filter(([, f]) => !f.required)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{source.name}</h2>
|
||||
{source.alwaysEnabled ? (
|
||||
<Badge variant="secondary">Always on</Badge>
|
||||
) : enabled ? (
|
||||
<Badge className="bg-primary/10 text-primary">Enabled</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Disabled</Badge>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{source.description}</p>
|
||||
</div>
|
||||
{!source.alwaysEnabled && (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate(checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config form */}
|
||||
{hasFields && !source.alwaysEnabled && (
|
||||
<>
|
||||
{/* Required fields */}
|
||||
{requiredFields.length > 0 && (
|
||||
<Card className="-mx-4">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-sm">Credentials</CardTitle>
|
||||
<CardDescription>Required fields to connect this source.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{requiredFields.map(([name, field]) => (
|
||||
<FieldInput
|
||||
key={name}
|
||||
name={name}
|
||||
field={field}
|
||||
value={formValues[name]}
|
||||
onChange={(v) => handleFieldChange(name, v)}
|
||||
disabled={busy}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Optional fields */}
|
||||
{optionalFields.length > 0 && (
|
||||
<Card className="-mx-4">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-sm">Options</CardTitle>
|
||||
<CardDescription>Optional configuration for this source.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`grid gap-4 ${optionalFields.length > 1 ? "grid-cols-2" : ""}`}>
|
||||
{optionalFields.map(([name, field]) => (
|
||||
<FieldInput
|
||||
key={name}
|
||||
name={name}
|
||||
field={field}
|
||||
value={formValues[name]}
|
||||
onChange={(v) => handleFieldChange(name, v)}
|
||||
disabled={busy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{serverConfig && (
|
||||
<Button
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={busy}
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
{deleteMutation.isPending ? "Deleting…" : "Delete configuration"}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => saveMutation.mutate()} disabled={busy}>
|
||||
{saveMutation.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
{saveMutation.isPending ? "Saving…" : "Save configuration"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Always-on sources */}
|
||||
{source.alwaysEnabled && source.id !== "aelis.location" && (
|
||||
<>
|
||||
<Separator />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This source is always enabled and requires no configuration.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{source.id === "aelis.location" && <LocationCard />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocationCard() {
|
||||
const [lat, setLat] = useState("")
|
||||
const [lng, setLng] = useState("")
|
||||
|
||||
const locationMutation = useMutation({
|
||||
mutationFn: (coords: { lat: number; lng: number }) =>
|
||||
pushLocation({ lat: coords.lat, lng: coords.lng, accuracy: 10 }),
|
||||
onSuccess() {
|
||||
toast.success("Location updated")
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
function handlePush() {
|
||||
const latNum = parseFloat(lat)
|
||||
const lngNum = parseFloat(lng)
|
||||
if (isNaN(latNum) || isNaN(lngNum)) return
|
||||
locationMutation.mutate({ lat: latNum, lng: lngNum })
|
||||
}
|
||||
|
||||
function handleUseDevice() {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setLat(String(pos.coords.latitude))
|
||||
setLng(String(pos.coords.longitude))
|
||||
locationMutation.mutate({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
})
|
||||
},
|
||||
(err) => {
|
||||
locationMutation.reset()
|
||||
alert(`Geolocation error: ${err.message}`)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="-mx-4">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-sm">Push Location</CardTitle>
|
||||
<CardDescription>Send a location update to the backend.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loc-lat" className="text-xs font-medium">Latitude</Label>
|
||||
<Input
|
||||
id="loc-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
value={lat}
|
||||
onChange={(e) => setLat(e.target.value)}
|
||||
placeholder="51.5074"
|
||||
disabled={locationMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loc-lng" className="text-xs font-medium">Longitude</Label>
|
||||
<Input
|
||||
id="loc-lng"
|
||||
type="number"
|
||||
step="any"
|
||||
value={lng}
|
||||
onChange={(e) => setLng(e.target.value)}
|
||||
placeholder="-0.1278"
|
||||
disabled={locationMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleUseDevice}
|
||||
disabled={locationMutation.isPending}
|
||||
>
|
||||
<MapPin className="size-3.5" />
|
||||
Use device location
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePush}
|
||||
disabled={locationMutation.isPending || !lat || !lng}
|
||||
>
|
||||
{locationMutation.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Push
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldInput({
|
||||
name,
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
name: string
|
||||
field: ConfigFieldDef
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const labelContent = (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{field.label}</span>
|
||||
{field.required && <span className="text-destructive">*</span>}
|
||||
{field.description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="size-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||
{field.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (field.type === "select" && field.options) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name} className="text-xs font-medium">
|
||||
{labelContent}
|
||||
</Label>
|
||||
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger id={name}>
|
||||
<SelectValue placeholder={`Select ${field.label.toLowerCase()}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name} className="text-xs font-medium">
|
||||
{labelContent}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type="number"
|
||||
value={value === undefined || value === null ? "" : String(value)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
onChange(v === "" ? undefined : Number(v))
|
||||
}}
|
||||
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name} className="text-xs font-medium">
|
||||
{labelContent}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
type={field.secret ? "password" : "text"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildInitialValues(
|
||||
fields: Record<string, ConfigFieldDef>,
|
||||
saved: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {}
|
||||
for (const [name, field] of Object.entries(fields)) {
|
||||
if (saved && name in saved) {
|
||||
values[name] = saved[name]
|
||||
} else if (field.defaultValue !== undefined) {
|
||||
values[name] = field.defaultValue
|
||||
} else {
|
||||
values[name] = field.type === "number" ? undefined : ""
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
Reference in New Issue
Block a user