feat: sdk migration in progress

This commit is contained in:
allanice001
2025-11-02 13:19:30 +00:00
commit 0d10d42442
492 changed files with 71067 additions and 0 deletions

119
ui/src/pages/auth/login.tsx Normal file
View File

@@ -0,0 +1,119 @@
import { useEffect, useMemo } from "react"
import { authStore, type TokenPair } from "@/auth/store.ts"
import { API_BASE } from "@/sdkClient.ts"
import { useLocation, useNavigate } from "react-router-dom"
import { cn } from "@/lib/utils.ts"
import { Button } from "@/components/ui/button.tsx"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
function openPopup(url: string, name = "gsot-auth", w = 520, h = 640) {
const y = window.top!.outerHeight / 2 + window.top!.screenY - h / 2
const x = window.top!.outerWidth / 2 + window.top!.screenX - w / 2
return window.open(
url,
name,
`toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=${w},height=${h},top=${y},left=${x}`
)
}
async function startAuth(provider: "google" | "github") {
const params = new URLSearchParams({
mode: "spa",
origin: window.location.origin,
})
const res = await fetch(`${API_BASE}/auth/${provider}/start?` + params, {
method: "POST",
})
if (!res.ok) throw new Error("Failed to start auth")
const data = await res.json()
return data.auth_url as string
}
export const Login = () => {
const navigate = useNavigate()
const loc = useLocation()
const to = useMemo(() => {
const p = new URLSearchParams(loc.search).get("to") || "/me"
try {
// prevent absolute URLs & open redirects
const url = new URL(p, window.location.origin)
return url.origin === window.location.origin ? url.pathname + url.search : "/me"
} catch {
return "/me"
}
}, [loc.search])
useEffect(() => {
if (authStore.get()?.access_token) {
navigate(to, { replace: true })
}
}, [navigate, to])
useEffect(() => {
const onMsg = (ev: MessageEvent) => {
const okType = typeof ev.data === "object" && ev.data?.type === "autoglue:auth"
if (!okType) return
const tokens: TokenPair = ev.data.payload
authStore.set(tokens)
navigate(to, { replace: true })
}
window.addEventListener("message", onMsg)
return () => window.removeEventListener("message", onMsg)
}, [navigate, to])
const login = async (provider: "google" | "github") => {
const url = await startAuth(provider)
const win = openPopup(url)
if (!win) alert("Please allow popups to sign in.")
}
return (
<div className="mx-auto flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-lg md:text-xl">Sign In</CardTitle>
<CardDescription className="text-xs md:text-sm">
Continue with a provider below to access your account.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<div className={cn("flex w-full items-center gap-2", "flex-col justify-between")}>
<Button variant="outline" className="w-full gap-2" onClick={() => login("google")}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="0.98em"
height="1em"
viewBox="0 0 256 262"
aria-hidden="true"
focusable="false"
>
<path
fill="#4285F4"
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
></path>
<path
fill="#34A853"
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
></path>
<path
fill="#FBBC05"
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"
></path>
<path
fill="#EB4335"
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
></path>
</svg>
Sign in with Google
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

505
ui/src/pages/me/me-page.tsx Normal file
View File

@@ -0,0 +1,505 @@
import { useMemo, useState } from "react"
import { meApi } from "@/api/me.ts"
import { withRefresh } from "@/api/with-refresh.ts"
import { makeOrgsApi } from "@/sdkClient.ts"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog.tsx"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import { Label } from "@/components/ui/label.tsx"
import { Separator } from "@/components/ui/separator.tsx"
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
const orgsApi = makeOrgsApi()
const orgApi = {
create: (body: { name: string; domain?: string }) =>
withRefresh(async () => orgsApi.createOrg({ body })), // POST /orgs
}
const profileSchema = z.object({
display_name: z.string().min(2, "Too short").max(100, "Too long"),
})
type ProfileForm = z.infer<typeof profileSchema>
const createKeySchema = z.object({
name: z.string().min(2, "Too short").max(100, "Too long"),
expires_in_hours: z.number().min(1).max(43800),
})
type CreateKeyForm = z.infer<typeof createKeySchema>
const createOrgSchema = z.object({
name: z.string().min(2, "Too short").max(100, "Too long"),
domain: z
.string()
.trim()
.toLowerCase()
.optional()
.or(z.literal(""))
.refine((v) => !v || /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(v), "Invalid domain (e.g. example.com)"),
})
type CreateOrgForm = z.infer<typeof createOrgSchema>
export const MePage = () => {
const qc = useQueryClient()
const meQ = useQuery({
queryKey: ["me"],
queryFn: () => meApi.getMe(),
})
const form = useForm<ProfileForm>({
resolver: zodResolver(profileSchema),
defaultValues: {
display_name: "",
},
values: meQ.data ? { display_name: meQ.data.display_name ?? "" } : undefined,
})
const updateMut = useMutation({
mutationFn: (values: ProfileForm) => meApi.updateMe(values),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["me"] })
toast.success("Profile updated")
},
onError: (e: any) => toast.error(e?.message ?? "Update failed"),
})
const keysQ = useQuery({
queryKey: ["me", "api-keys"],
queryFn: () => meApi.listKeys(),
})
const [createOpen, setCreateOpen] = useState(false)
const [justCreated, setJustCreated] = useState<ReturnType<typeof Object> | null>(null)
const createForm = useForm<CreateKeyForm>({
resolver: zodResolver(createKeySchema),
defaultValues: {
name: "",
expires_in_hours: 720,
},
})
const createMut = useMutation({
mutationFn: (v: CreateKeyForm) =>
meApi.createKey({
name: v.name,
expires_in_hours: v.expires_in_hours,
} as CreateKeyForm),
onSuccess: (resp: any) => {
setJustCreated(resp)
setCreateOpen(false)
void qc.invalidateQueries({ queryKey: ["me", "api-keys"] })
toast.success("API key created")
},
onError: (e: any) => toast.error(e?.message ?? "Failed to create key"),
})
const [deleteId, setDeleteId] = useState<string | null>(null)
const delMut = useMutation({
mutationFn: (id: string) => meApi.deleteKey(id),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["me", "api-keys"] })
setDeleteId(null)
toast.success("Key deleted")
},
onError: (e: any) => toast.error(e?.message ?? "Failed to delete key"),
})
const primaryEmail = useMemo(
() => meQ.data?.emails?.find((e) => e.is_primary)?.email ?? meQ.data?.primary_email ?? "",
[meQ.data]
)
// --- Create Org dialog + mutation ---
const [orgOpen, setOrgOpen] = useState<boolean>(false)
const orgForm = useForm<CreateOrgForm>({
resolver: zodResolver(createOrgSchema),
defaultValues: {
name: "",
domain: "",
},
})
const orgCreateMut = useMutation({
mutationFn: (v: CreateOrgForm) =>
orgApi.create({
name: v.name.trim(),
domain: v.domain?.trim() ? v.domain.trim().toLowerCase() : undefined,
}),
onSuccess: () => {
setOrgOpen(false)
orgForm.reset()
void qc.invalidateQueries({ queryKey: ["me"] })
toast.success("Organization created")
},
onError: (e: any) => toast.error(e?.message ?? "Failed to create organization"),
})
if (meQ.isLoading) return <div className="p-6">Loading</div>
if (meQ.error) return <div className="text-destructive p-6">Failed to load profile</div>
return (
<div className="space-y-6 p-6">
{/* Profile */}
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Manage your personal information.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-4">
<div>
<Label>Email</Label>
<div className="text-muted-foreground mt-1 text-sm">{primaryEmail || "—"}</div>
</div>
<div>
<Label>ID</Label>
<div className="text-muted-foreground mt-1 text-sm">{meQ.data?.id || "—"}</div>
<div className="text-muted-foreground mt-1 text-sm">
Share this ID with the organization owner of the Organization to join
</div>
</div>
<Form {...form}>
<form
className="space-y-4"
onSubmit={form.handleSubmit((v) => updateMut.mutate(v))}
>
<FormField
control={form.control}
name="display_name"
render={({ field }) => (
<FormItem>
<FormLabel>Display name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={updateMut.isPending}>
Save
</Button>
</form>
</Form>
</div>
{/* Organizations + Create Org */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Organizations</Label>
<Dialog open={orgOpen} onOpenChange={setOrgOpen}>
<DialogTrigger asChild>
<Button size="sm">New Organization</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create organization</DialogTitle>
<DialogDescription>
Give it a name, and optionally assign your company domain.
</DialogDescription>
</DialogHeader>
<Form {...orgForm}>
<form
className="space-y-4"
onSubmit={orgForm.handleSubmit((v) => orgCreateMut.mutate(v))}
>
<FormField
control={orgForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Acme Inc." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={orgForm.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>Domain (optional)</FormLabel>
<FormControl>
<Input placeholder="acme.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button type="submit" disabled={orgCreateMut.isPending}>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Domain</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{meQ.data?.organizations?.map((o) => (
<TableRow key={o.id}>
<TableCell>{o.name}</TableCell>
<TableCell>{(o as any).domain ?? "—"}</TableCell>
</TableRow>
))}
{(!meQ.data?.organizations || meQ.data.organizations.length === 0) && (
<TableRow>
<TableCell colSpan={2} className="text-muted-foreground">
No organizations
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
</CardContent>
</Card>
<Separator />
{/* API Keys (unchanged) */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>User API Keys</CardTitle>
<CardDescription>Personal keys for API access.</CardDescription>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button>New Key</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
<DialogDescription>Give it a label and expiry.</DialogDescription>
</DialogHeader>
<Form {...createForm}>
<form
className="space-y-4"
onSubmit={createForm.handleSubmit((v) => createMut.mutate(v))}
>
<FormField
control={createForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Label</FormLabel>
<FormControl>
<Input placeholder="CI script, local dev, ..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="expires_in_hours"
render={({ field }) => (
<FormItem>
<FormLabel>Expires in hours</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
placeholder="e.g. 720"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button type="submit" disabled={createMut.isPending}>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table className="text-sm">
<TableCaption>Your user-scoped API keys.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Label</TableHead>
<TableHead>Created</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Last used</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{keysQ.data?.map((k) => (
<TableRow key={k.id}>
<TableCell>{k.name ?? "—"}</TableCell>
<TableCell>{new Date(k.created_at!).toLocaleString()}</TableCell>
<TableCell>
{k.expires_at ? new Date(k.expires_at).toLocaleString() : "—"}
</TableCell>
<TableCell>
{k.last_used_at ? new Date(k.last_used_at).toLocaleString() : "—"}
</TableCell>
<TableCell className="text-right">
<AlertDialog
open={deleteId === k.id}
onOpenChange={(o) => !o && setDeleteId(null)}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteId(k.id!)}
>
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this key?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. Requests using this key will stop
working.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => delMut.mutate(k.id!)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
{(!keysQ.data || keysQ.data.length === 0) && (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
No API keys yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Plaintext key shown once */}
<Dialog open={!!justCreated} onOpenChange={(o) => !o && setJustCreated(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Copy your new API key</DialogTitle>
<DialogDescription>This is only shown once. Store it securely.</DialogDescription>
</DialogHeader>
<div className="rounded-md border p-3 font-mono text-sm break-all">
{(justCreated as any)?.plain ?? "—"}
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => {
const val = (justCreated as any)?.plain
if (val) {
navigator.clipboard.writeText(val)
toast.success("Copied")
}
}}
>
Copy
</Button>
<Button onClick={() => setJustCreated(null)}>Done</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,216 @@
import { useState } from "react"
import { withRefresh } from "@/api/with-refresh.ts"
import { orgStore } from "@/auth/org.ts"
import { makeOrgsApi } from "@/sdkClient.ts"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button.tsx"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
const createSchema = z.object({
name: z.string(),
expires_in_hours: z.number().min(1).max(43800),
})
type CreateValues = z.infer<typeof createSchema>
export const OrgApiKeys = () => {
const api = makeOrgsApi()
const qc = useQueryClient()
const orgId = orgStore.get()
const q = useQuery({
enabled: !!orgId,
queryKey: ["org:keys", orgId],
queryFn: () => withRefresh(() => api.listOrgKeys({ id: orgId! })),
})
const form = useForm<CreateValues>({
resolver: zodResolver(createSchema),
defaultValues: {
name: "",
expires_in_hours: 720,
},
})
const [showSecret, setShowSecret] = useState<{
key?: string
secret?: string
} | null>(null)
const createMut = useMutation({
mutationFn: (v: CreateValues) => api.createOrgKey({ id: orgId!, body: v }),
onSuccess: (resp) => {
void qc.invalidateQueries({ queryKey: ["org:keys", orgId] })
setShowSecret({ key: resp.org_key, secret: resp.org_secret })
toast.success("Key created")
form.reset({ name: "", expires_in_hours: undefined })
},
onError: (e: any) => toast.error(e?.message ?? "Failed to create key"),
})
const deleteMut = useMutation({
mutationFn: (keyId: string) => api.deleteOrgKey({ id: orgId!, keyId }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["org:keys", orgId] })
toast.success("Key deleted")
},
onError: (e: any) => toast.error(e?.message ?? "Failed to delete key"),
})
if (!orgId) return <p className="text-muted-foreground">Pick an organization.</p>
if (q.isLoading) return <p>Loading...</p>
if (q.error) return <p className="text-destructive">Failed to load keys.</p>
return (
<Card>
<CardHeader>
<CardTitle>Org API Keys</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit((v) => createMut.mutate(v))}
className="grid grid-cols-1 items-end gap-3 md:grid-cols-12"
>
<div className="md:col-span-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="automation-bot" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="md:col-span-4">
<FormField
control={form.control}
name="expires_in_hours"
render={({ field }) => (
<FormItem>
<FormLabel>Expires In (hours)</FormLabel>
<FormControl>
<Input placeholder="e.g. 720" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="md:col-span-2">
<Button type="submit" className="w-full" disabled={createMut.isPending}>
Create
</Button>
</div>
</form>
</Form>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Scope</TableHead>
<TableHead>Created</TableHead>
<TableHead>Expires</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{q.data?.map((k) => (
<TableRow key={k.id}>
<TableCell>{k.name ?? "-"}</TableCell>
<TableCell>{k.scope}</TableCell>
<TableCell>{new Date(k.created_at!).toLocaleString()}</TableCell>
<TableCell>
{k.expires_at ? new Date(k.expires_at).toLocaleString() : "-"}
</TableCell>
<TableCell className="text-right">
<Button variant="destructive" size="sm" onClick={() => deleteMut.mutate(k.id!)}>
Delete
</Button>
</TableCell>
</TableRow>
))}
{q.data?.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground p-4">
No keys.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Show once dialog with key/secret */}
<Dialog open={!!showSecret} onOpenChange={(o) => !o && setShowSecret(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Copy your credentials</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<div>
<div className="text-muted-foreground mb-1 text-xs">Org Key</div>
<Input
readOnly
value={showSecret?.key ?? ""}
onFocus={(e) => e.currentTarget.select()}
/>
</div>
<div>
<div className="text-muted-foreground mb-1 text-xs">Org Secret</div>
<Input
readOnly
value={showSecret?.secret ?? ""}
onFocus={(e) => e.currentTarget.select()}
/>
</div>
<p className="text-muted-foreground text-xs">
This secret is shown once. Store it securely.
</p>
</div>
<DialogFooter>
<Button onClick={() => setShowSecret(null)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,262 @@
import { useMemo, useState } from "react"
import { withRefresh } from "@/api/with-refresh.ts"
import { orgStore } from "@/auth/org.ts"
import { makeOrgsApi } from "@/sdkClient.ts"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Loader2 } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button.tsx"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
const addSchema = z.object({
user_id: z.uuid("Invalid UUID"),
role: z.enum(["owner", "admin", "member"]),
})
type AddValues = z.infer<typeof addSchema>
export const OrgMembers = () => {
const api = makeOrgsApi()
const qc = useQueryClient()
const orgId = orgStore.get()
const [updatingId, setUpdatingId] = useState<string | null>(null)
const q = useQuery({
enabled: !!orgId,
queryKey: ["org:members", orgId],
queryFn: () => withRefresh(() => api.listMembers({ id: orgId! })),
})
const ownersCount = useMemo(
() => (q.data ?? []).filter((m) => m.role === "owner").length,
[q.data]
)
const form = useForm<AddValues>({
resolver: zodResolver(addSchema),
defaultValues: {
user_id: "",
role: "member",
},
})
const addMut = useMutation({
mutationFn: (v: AddValues) => api.addOrUpdateMember({ id: orgId!, body: v }),
onSuccess: () => {
toast.success("Member added/updated")
void qc.invalidateQueries({ queryKey: ["org:members", orgId] })
form.reset({ user_id: "", role: "member" })
},
onError: (e: any) => toast.error(e?.message ?? "Failed"),
})
const removeMut = useMutation({
mutationFn: (userId: string) => api.removeMember({ id: orgId!, userId }),
onSuccess: () => {
toast.success("Member removed")
void qc.invalidateQueries({ queryKey: ["org:members", orgId] })
},
onError: (e: any) => toast.error(e?.message ?? "Failed"),
})
const roleMut = useMutation({
mutationFn: ({ userId, role }: { userId: string; role: "owner" | "admin" | "member" }) =>
api.addOrUpdateMember({ id: orgId!, body: { user_id: userId, role } }),
onMutate: async ({ userId, role }) => {
setUpdatingId(userId)
// cancel queries and snapshot previous
await qc.cancelQueries({ queryKey: ["org:members", orgId] })
const prev = qc.getQueryData<any>(["org:members", orgId])
// optimistic update
qc.setQueryData(["org:members", orgId], (old: any[] = []) =>
old.map((m) => (m.user_id === userId ? { ...m, role } : m))
)
return { prev }
},
onError: (e, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(["org:members", orgId], ctx.prev)
toast.error((e as any)?.message ?? "Failed to update role")
},
onSuccess: () => {
toast.success("Role updated")
},
onSettled: () => {
setUpdatingId(null)
void qc.invalidateQueries({ queryKey: ["org:members", orgId] })
},
})
const canDowngrade = (m: any) => !(m.role === "owner" && ownersCount <= 1)
if (!orgId) return <p className="text-muted-foreground">Pick an organization.</p>
if (q.isLoading) return <p>Loading...</p>
if (q.error) return <p className="text-destructive">Failed to load members.</p>
return (
<Card>
<CardHeader>
<CardTitle>Members</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Add/Update */}
<Form {...form}>
<form
className="grid grid-cols-1 items-end gap-3 md:grid-cols-12"
onSubmit={form.handleSubmit((v) => addMut.mutate(v))}
>
<div className="md:col-span-6">
<FormField
control={form.control}
name="user_id"
render={({ field }) => (
<FormItem>
<FormLabel>User ID</FormLabel>
<FormControl>
<Input placeholder="UUID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="md:col-span-4">
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">member</SelectItem>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="owner">owner</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="md:col-span-2">
<Button type="submit" className="w-full" disabled={addMut.isPending}>
Save
</Button>
</div>
</form>
</Form>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Id</TableHead>
<TableHead>User</TableHead>
<TableHead>Role</TableHead>
<TableHead className="w-28"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{q.data?.map((m) => {
const isRowPending = updatingId === m.user_id
return (
<TableRow key={m.user_id} className="align-middle">
<TableCell className="font-mono text-xs">{m.user_id}</TableCell>
<TableCell>{m.email}</TableCell>
{/* Inline role select */}
<TableCell className="capitalize">
<div className="flex items-center gap-2">
<Select
value={m.role}
onValueChange={(next) => {
if (m.role === next) return
if (m.role === "owner" && next !== "owner" && !canDowngrade(m)) {
toast.error("You cannot demote the last owner.")
return
}
roleMut.mutate({
userId: m.user_id!,
role: next as any,
})
}}
disabled={isRowPending}
>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">member</SelectItem>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="owner">owner</SelectItem>
</SelectContent>
</Select>
{isRowPending && <Loader2 className="h-4 w-4 animate-spin" />}
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="sm"
onClick={() => removeMut.mutate(m.user_id!)}
disabled={m.role === "owner" && ownersCount <= 1}
title={
m.role === "owner" && ownersCount <= 1
? "Cannot remove the last owner"
: ""
}
>
Remove
</Button>
</TableCell>
</TableRow>
)
})}
{q.data?.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-muted-foreground p-4">
No members.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,147 @@
import { useEffect } from "react"
import { withRefresh } from "@/api/with-refresh.ts"
import { orgStore } from "@/auth/org.ts"
import { makeOrgsApi } from "@/sdkClient.ts"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button.tsx"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
const schema = z.object({
name: z.string().min(1, "Required"),
domain: z.string().optional(),
})
type Values = z.infer<typeof schema>
export const OrgSettings = () => {
const api = makeOrgsApi()
const qc = useQueryClient()
const orgId = orgStore.get()
const q = useQuery({
enabled: !!orgId,
queryKey: ["org", orgId],
queryFn: () => withRefresh(() => api.getOrg({ id: orgId! })),
})
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
domain: "",
},
})
useEffect(() => {
if (q.data) {
form.reset({
name: q.data.name ?? "",
domain: q.data.domain ?? "",
})
}
}, [q.data])
const updateMut = useMutation({
mutationFn: (v: Partial<Values>) => api.updateOrg({ id: orgId!, body: v }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["org", orgId] })
toast.success("Organization updated")
},
onError: (e: any) => toast.error(e?.message ?? "Update failed"),
})
const deleteMut = useMutation({
mutationFn: () => api.deleteOrg({ id: orgId! }),
onSuccess: () => {
toast.success("Organization deleted")
orgStore.set("")
void qc.invalidateQueries({ queryKey: ["orgs:mine"] })
},
onError: (e: any) => toast.error(e?.message ?? "Delete failed"),
})
if (!orgId) {
return <p className="text-muted-foreground">Pick an organization.</p>
}
if (q.isLoading) return <p>Loading...</p>
if (q.error) return <p className="text-destructive">Failed to load.</p>
const onSubmit = (v: Values) => {
const delta: Partial<Values> = {}
if (v.name !== q.data?.name) delta.name = v.name
const normDomain = v.domain?.trim() || undefined
if ((normDomain ?? null) !== (q.data?.domain ?? null)) delta.domain = normDomain
if (Object.keys(delta).length === 0) return
updateMut.mutate(delta)
}
return (
<Card>
<CardHeader>
<CardTitle>Organization Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>Domain (optional)</FormLabel>
<FormControl>
<Input placeholder="acme.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button type="submit" disabled={updateMut.isPending}>
Save
</Button>
<Button
type="button"
variant="destructive"
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
>
Delete Org
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,821 @@
import { useMemo, useState } from "react"
import { serversApi } from "@/api/servers.ts"
import { sshApi } from "@/api/ssh.ts"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { formatDistanceToNow } from "date-fns"
import { Plus, Search } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { cn } from "@/lib/utils"
import { truncateMiddle } from "@/lib/utils.ts"
import { Badge } from "@/components/ui/badge.tsx"
import { Button } from "@/components/ui/button.tsx"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
import { TooltipProvider } from "@/components/ui/tooltip.tsx"
const ROLE_OPTIONS = ["master", "worker", "bastion"] as const
type Role = (typeof ROLE_OPTIONS)[number]
const STATUS = ["pending", "provisioning", "ready", "failed"] as const
type Status = (typeof STATUS)[number]
const createServerSchema = z
.object({
hostname: z.string().trim().max(60, "Max 60 chars"),
public_ip_address: z.string().trim().optional().or(z.literal("")),
private_ip_address: z.string().trim().min(1, "Private IP address required"),
role: z.enum(ROLE_OPTIONS),
ssh_key_id: z.string().uuid("Pick a valid SSH key"),
ssh_user: z.string().trim().min(1, "SSH user is required"),
status: z.enum(STATUS).default("pending"),
})
.refine(
(v) => v.role !== "bastion" || (v.public_ip_address && v.public_ip_address.trim() !== ""),
{ message: "Public IP required for bastion", path: ["public_ip_address"] }
)
type CreateServerInput = z.input<typeof createServerSchema>
const updateServerSchema = createServerSchema.partial()
type UpdateServerValues = z.infer<typeof updateServerSchema>
function StatusBadge({ status }: { status: Status }) {
const v =
status === "ready"
? "default"
: status === "provisioning"
? "secondary"
: status === "failed"
? "destructive"
: "outline"
return (
<Badge variant={v as any} className="capitalize">
{status}
</Badge>
)
}
export const ServerPage = () => {
const [filter, setFilter] = useState<string>("")
const [createOpen, setCreateOpen] = useState<boolean>(false)
const [updateOpen, setUpdateOpen] = useState<boolean>(false)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [statusFilter, setStatusFilter] = useState<Status | "">("")
const [roleFilter, setRoleFilter] = useState<Role | "">("")
const [editingId, setEditingId] = useState<string | null>(null)
const qc = useQueryClient()
const serverQ = useQuery({
queryKey: ["servers"],
queryFn: () => serversApi.listServers(),
})
const sshQ = useQuery({
queryKey: ["ssh_keys"],
queryFn: () => sshApi.listSshKeys(),
})
// Map of ssh_key_id -> label
const sshLabelById = useMemo(() => {
const m = new Map<string, string>()
for (const k of sshQ.data ?? []) {
const name = k.name ? k.name : "Unnamed key"
const fp = k.fingerprint ? truncateMiddle(k.fingerprint, 8) : ""
m.set(k.id!, fp ? `${name}${fp}` : name)
}
return m
}, [sshQ.data])
// --- Create ---
const createForm = useForm<CreateServerInput>({
resolver: zodResolver(createServerSchema),
defaultValues: {
hostname: "",
private_ip_address: "",
public_ip_address: "",
role: "worker",
ssh_key_id: "" as unknown as string,
ssh_user: "",
status: "pending",
},
mode: "onChange",
})
const roleIsBastion = createForm.watch("role") === "bastion"
const pubCreate = createForm.watch("public_ip_address")?.trim() ?? ""
const needPubCreate = roleIsBastion && pubCreate === ""
const createMut = useMutation({
mutationFn: (values: CreateServerInput) => serversApi.createServer(values as any),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["servers"] })
createForm.reset()
setCreateOpen(false)
toast.success("Server created successfully")
},
onError: (err: any) => {
toast.error(err?.message ?? "Failed to create server")
},
})
// --- Update ---
const updateForm = useForm<UpdateServerValues>({
resolver: zodResolver(updateServerSchema),
defaultValues: {},
mode: "onChange",
})
const roleIsBastionU = updateForm.watch("role") === "bastion"
const pubUpdate = updateForm.watch("public_ip_address")?.trim() ?? ""
const needPubUpdate = roleIsBastionU && pubUpdate === ""
const updateMut = useMutation({
mutationFn: ({ id, values }: { id: string; values: UpdateServerValues }) =>
serversApi.updateServer(id, values as any),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["servers"] })
setUpdateOpen(false)
setEditingId(null)
toast.success("Server updated successfully")
},
onError: (err: any) => {
toast.error(err?.message ?? "Failed to update server")
},
})
// --- Delete ---
const deleteMut = useMutation({
mutationFn: (id: string) => serversApi.deleteServer(id),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["servers"] })
setDeleteId(null)
toast.success("Server deleted successfully")
},
onError: (err: any) => {
toast.error(err?.message ?? "Failed to delete server")
},
})
const filtered = useMemo(() => {
const data = serverQ.data ?? []
const q = filter.trim().toLowerCase()
const textFiltered = q
? data.filter((k: any) => {
return (
k.hostname?.toLowerCase().includes(q) ||
k.public_ip_address?.toLowerCase().includes(q) ||
k.private_ip_address?.toLowerCase().includes(q) ||
k.role?.toLowerCase().includes(q) ||
k.ssh_user?.toLowerCase().includes(q)
)
})
: data
const roleFiltered = roleFilter
? textFiltered.filter((k: any) => k.role === roleFilter)
: textFiltered
const statusFiltered = statusFilter
? roleFiltered.filter((k: any) => k.status === statusFilter)
: roleFiltered
return statusFiltered
}, [filter, roleFilter, statusFilter, serverQ.data])
const onCreateSubmit = (values: CreateServerInput) => {
createMut.mutate(values)
}
const openEdit = (srv: any) => {
setEditingId(srv.id)
updateForm.reset({
hostname: srv.hostname ?? "",
public_ip_address: srv.public_ip_address ?? "",
private_ip_address: srv.private_ip_address ?? "",
role: (srv.role as Role) ?? "worker",
ssh_key_id: srv.ssh_key_id ?? "",
ssh_user: srv.ssh_user ?? "",
status: (srv.status as Status) ?? "pending",
})
setUpdateOpen(true)
}
if (sshQ.data?.length === 0)
return <div className="p-6">Please create an SSH key for your organization first.</div>
if (serverQ.isLoading) return <div className="p-6">Loading servers</div>
if (serverQ.error) return <div className="p-6 text-red-500">Error loading servers.</div>
return (
<TooltipProvider>
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="mb-4 text-2xl font-bold">Servers</h1>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute top-2.5 left-2 h-4 w-4 opacity-60" />
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search hostname, Public IP, Private IP, role, user…"
className="w-64 pl-8"
/>
</div>
<Select
value={roleFilter || "all"} // map "" -> "all" for the UI
onValueChange={(v) => setRoleFilter(v === "all" ? "" : (v as Role))}
>
<SelectTrigger className="w-36">
<SelectValue placeholder="Role (all)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All roles</SelectItem>
{ROLE_OPTIONS.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={statusFilter || "all"} // map "" -> "all" for the UI
onValueChange={(v) => setStatusFilter(v === "all" ? "" : (v as Status))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status (all)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem> {/* sentinel */}
{STATUS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Server
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create server</DialogTitle>
</DialogHeader>
<Form {...createForm}>
<form className="space-y-4" onSubmit={createForm.handleSubmit(onCreateSubmit)}>
<FormField
control={createForm.control}
name="hostname"
render={({ field }) => (
<FormItem>
<FormLabel>Hostname</FormLabel>
<FormControl>
<Input placeholder="worker-01" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={createForm.control}
name="public_ip_address"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
<span>Public IP Address</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
roleIsBastion
? "bg-amber-100 text-amber-900"
: "bg-muted text-muted-foreground"
)}
>
{roleIsBastion ? "Required for bastion" : "Optional"}
</span>
</FormLabel>
<FormControl>
<Input
placeholder={
roleIsBastion
? "Required for bastion (e.g. 34.12.56.78)"
: "34.12.56.78"
}
aria-required={roleIsBastion}
aria-invalid={
needPubCreate || !!createForm.formState.errors.public_ip_address
}
required={roleIsBastion}
{...field}
className={cn(
needPubCreate &&
"border-destructive focus-visible:ring-destructive"
)}
/>
</FormControl>
{roleIsBastion && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-900">
Bastion nodes must have a{" "}
<span className="font-medium">Public IP</span>.
</div>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="private_ip_address"
render={({ field }) => (
<FormItem>
<FormLabel>Private IP Address</FormLabel>
<FormControl>
<Input placeholder="192.168.10.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={createForm.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={(v) =>
createForm.setValue("role", v as Role, {
shouldDirty: true,
shouldValidate: true,
})
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="master">master</SelectItem>
<SelectItem value="worker">worker</SelectItem>
<SelectItem value="bastion">
bastion requires Public IP
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="ssh_user"
render={({ field }) => (
<FormItem>
<FormLabel>SSH user</FormLabel>
<FormControl>
<Input placeholder="ubuntu" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={createForm.control}
name="ssh_key_id"
render={({ field }) => (
<FormItem>
<FormLabel>SSH key</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
sshQ.data?.length ? "Select SSH key" : "No SSH keys found"
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{sshQ.data!.map((k) => (
<SelectItem key={k.id} value={k.id!}>
{k.name ? k.name : "Unnamed key"} {" "}
{truncateMiddle(k.fingerprint!, 8)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Initial status</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="pending" />
</SelectTrigger>
</FormControl>
<SelectContent>
{STATUS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={
createMut.isPending ||
createForm.formState.isSubmitting ||
!createForm.formState.isValid
}
>
{createMut.isPending ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
<div className="bg-background overflow-hidden rounded-2xl border shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Hostname</TableHead>
<TableHead>IP address</TableHead>
<TableHead>Role</TableHead>
<TableHead>SSH user</TableHead>
<TableHead>SSH key</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[220px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="text-muted-foreground py-10 text-center text-sm"
>
No servers found.
</TableCell>
</TableRow>
) : (
filtered.map((k: any) => (
<TableRow key={k.id}>
<TableCell className="font-medium">{k.hostname}</TableCell>
<TableCell>
<div className="flex flex-col">
<span
className={cn(
"tabular-nums",
!k.public_ip_address && "text-muted-foreground"
)}
>
{k.public_ip_address || "—"}
</span>
<span className="text-muted-foreground tabular-nums">
{k.private_ip_address}
</span>
</div>
</TableCell>
<TableCell className="capitalize">
<span
className={cn(k.role === "bastion" && "rounded bg-amber-50 px-2 py-0.5")}
>
{k.role}
</span>
</TableCell>
<TableCell className="tabular-nums">{k.ssh_user}</TableCell>
<TableCell className="truncate">
{sshLabelById.get(k.ssh_key_id) ?? "—"}
</TableCell>
<TableCell>
<StatusBadge status={(k.status ?? "pending") as Status} />
</TableCell>
<TableCell title={k.created_at}>
{k.created_at
? `${formatDistanceToNow(new Date(k.created_at), { addSuffix: true })}`
: "—"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(k)}>
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteId(k.id)}
disabled={deleteMut.isPending && deleteId === k.id}
>
{deleteMut.isPending && deleteId === k.id ? "Deleting…" : "Delete"}
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
{/* Update dialog */}
<Dialog open={updateOpen} onOpenChange={setUpdateOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit server</DialogTitle>
</DialogHeader>
<Form {...updateForm}>
<form
className="space-y-4"
onSubmit={updateForm.handleSubmit((values) => {
if (!editingId) return
updateMut.mutate({ id: editingId, values })
})}
>
<FormField
control={updateForm.control}
name="hostname"
render={({ field }) => (
<FormItem>
<FormLabel>Hostname</FormLabel>
<FormControl>
<Input placeholder="worker-01" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={updateForm.control}
name="public_ip_address"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
<span>Public IP Address</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
roleIsBastionU
? "bg-amber-100 text-amber-900"
: "bg-muted text-muted-foreground"
)}
>
{roleIsBastionU ? "Required for bastion" : "Optional"}
</span>
</FormLabel>
<FormControl>
<Input
placeholder={
roleIsBastionU
? "Required for bastion (e.g. 34.12.56.78)"
: "34.12.56.78"
}
aria-required={roleIsBastionU}
aria-invalid={
needPubUpdate || !!updateForm.formState.errors.public_ip_address
}
required={roleIsBastionU}
{...field}
className={cn(
needPubUpdate && "border-destructive focus-visible:ring-destructive"
)}
/>
</FormControl>
{roleIsBastionU && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-900">
Bastion nodes must have a <span className="font-medium">Public IP</span>.
</div>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateForm.control}
name="private_ip_address"
render={({ field }) => (
<FormItem>
<FormLabel>Private IP Address</FormLabel>
<FormControl>
<Input placeholder="192.168.10.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={updateForm.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={(v) =>
updateForm.setValue("role", v as Role, {
shouldDirty: true,
shouldValidate: true,
})
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="master">master</SelectItem>
<SelectItem value="worker">worker</SelectItem>
<SelectItem value="bastion">bastion requires Public IP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateForm.control}
name="ssh_user"
render={({ field }) => (
<FormItem>
<FormLabel>SSH user</FormLabel>
<FormControl>
<Input placeholder="ubuntu" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={updateForm.control}
name="ssh_key_id"
render={({ field }) => (
<FormItem>
<FormLabel>SSH key</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select SSH key" />
</SelectTrigger>
</FormControl>
<SelectContent>
{sshQ.data!.map((k) => (
<SelectItem key={k.id} value={k.id!}>
{k.name ? k.name : "Unnamed key"} {truncateMiddle(k.fingerprint!, 8)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateForm.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="pending" />
</SelectTrigger>
</FormControl>
<SelectContent>
{STATUS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setUpdateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={updateMut.isPending}>
{updateMut.isPending ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Delete confirm dialog */}
<Dialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete server</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground text-sm">
This action cannot be undone. Are you sure you want to delete this server?
</p>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && deleteMut.mutate(deleteId)}
disabled={deleteMut.isPending}
>
{deleteMut.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,475 @@
import { useMemo, useState } from "react"
import { sshApi } from "@/api/ssh.ts"
import type { DtoCreateSSHRequest, DtoSshRevealResponse } from "@/sdk"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Download, Eye, Loader2, Plus, Trash2 } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { truncateMiddle } from "@/lib/utils.ts"
import { Badge } from "@/components/ui/badge.tsx"
import { Button } from "@/components/ui/button.tsx"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
import { Textarea } from "@/components/ui/textarea.tsx"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx"
const createKeySchema = z.object({
name: z.string().trim().min(1, "Name is required").max(100, "Max 100 characters"),
comment: z.string().trim().min(1, "Comment is required").max(100, "Max 100 characters"),
bits: z.enum(["2048", "3072", "4096"]).optional(),
type: z.enum(["rsa", "ed25519"]).optional(),
})
type CreateKeyInput = z.input<typeof createKeySchema>
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
function copy(text: string, label = "Copied") {
navigator.clipboard
.writeText(text)
.then(() => toast.success(label))
.catch(() => toast.error("Copy failed"))
}
function getKeyType(publicKey: string) {
return publicKey?.split(/\s+/)?.[0] ?? "ssh-key"
}
export const SshPage = () => {
const [filter, setFilter] = useState<string>("")
const [createOpen, setCreateOpen] = useState<boolean>(false)
const [revealFor, setRevealFor] = useState<DtoSshRevealResponse | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const qc = useQueryClient()
const sshQ = useQuery({
queryKey: ["ssh"],
queryFn: () => sshApi.listSshKeys(),
})
const form = useForm<CreateKeyInput>({
resolver: zodResolver(createKeySchema),
defaultValues: {
name: "",
comment: "",
type: "rsa",
bits: "4096",
},
})
const createMutation = useMutation({
mutationFn: async (values: CreateKeyInput) => {
const payload: DtoCreateSSHRequest = {
name: values.name,
comment: values.comment,
// Only send bits for RSA
bits: values.type === "rsa" && values.bits ? Number(values.bits) : undefined,
// Only send type if present
type: values.type,
}
return await sshApi.createSshKey(payload)
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["ssh"] })
setCreateOpen(false)
form.reset({ name: "", comment: "", type: "rsa", bits: "4096" })
toast.success("SSH Key created")
},
onError: (e: any) => toast.error(e?.message ?? "SSH Key creation failed"),
})
const revealMutation = useMutation({
mutationFn: (id: string) => sshApi.revealSshKeyById(id),
onSuccess: (data) => setRevealFor(data),
onError: (e: any) => toast.error(e?.message ?? "Failed to reveal key"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => sshApi.deleteSshKey(id),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["ssh"] })
setDeleteId(null)
toast.success("SSH Key deleted")
},
onError: (e: any) => toast.error(e?.message ?? "Delete failed"),
})
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase()
if (!q) return sshQ.data ?? []
return (sshQ.data ?? []).filter((k) => {
return (
k.name?.toLowerCase().includes(q) ||
k.fingerprint?.toLowerCase().includes(q) ||
k.public_key?.toLowerCase().includes(q)
)
})
}, [filter, sshQ.data])
if (sshQ.isLoading) return <div className="p-6">Loading SSH Keys</div>
if (sshQ.error) return <div className="p-6 text-red-500">Error Loading SSH Keys</div>
return (
<TooltipProvider>
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-bold">SSH Keys</h1>
<div className="w-full max-w-sm">
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search by name, fingerprint or key"
/>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create New Keypair
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create SSH Keypair</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => createMutation.mutate(values))}
className="space-y-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="e.g., CI deploy key" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="comment"
render={({ field }) => (
<FormItem>
<FormLabel>Comment</FormLabel>
<FormControl>
<Input placeholder="e.g., deploy@autoglue" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={(v) => {
field.onChange(v)
if (v === "ed25519") {
// bits not applicable
form.setValue("bits", undefined)
} else {
form.setValue("bits", "4096")
}
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a ssh key type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="rsa">RSA</SelectItem>
<SelectItem value="ed25519">ED25519</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bits"
render={({ field }) => (
<FormItem>
<FormLabel>Key size</FormLabel>
<FormControl>
<Select
value={field.value}
disabled={form.watch("type") === "ed25519"}
onValueChange={field.onChange}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="RSA only" />
</SelectTrigger>
<SelectContent>
<SelectItem value="2048">2048</SelectItem>
<SelectItem value="3072">3072</SelectItem>
<SelectItem value="4096">4096</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
disabled={createMutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating
</>
) : (
"Create"
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<div className="bg-background overflow-hidden rounded-2xl border shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="min-w-[360px]">Public Key</TableHead>
<TableHead>Fingerprint</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[160px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((k) => {
const keyType = getKeyType(k.public_key!)
const truncated = truncateMiddle(k.public_key!, 18)
return (
<TableRow key={k.id}>
<TableCell className="font-medium">{k.name || "—"}</TableCell>
<TableCell className="max-w-[560px] truncate">
<div className="flex items-start gap-2">
<Badge variant="secondary" className="whitespace-nowrap">
{keyType}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<span className="font-mono text-xs">{truncated}</span>
</TooltipTrigger>
<TooltipContent className="max-w-[70vw]">
<div className="max-w-full">
<p className="font-mono text-xs break-all">{k.public_key}</p>
</div>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell className="font-mono text-xs">{k.fingerprint}</TableCell>
<TableCell>
{k.created_at
? new Date(k.created_at).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "—"}
</TableCell>
<TableCell className="space-x-2 text-right">
<Button
size="sm"
variant="ghost"
onClick={() => copy(k.public_key ?? "", "Public key copied")}
>
Copy Pub
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => copy(k.fingerprint ?? "", "Fingerprint copied")}
>
Copy FP
</Button>
<Button
size="sm"
variant="outline"
onClick={() => revealMutation.mutate(k.id!)}
>
<Eye className="mr-1 h-4 w-4" />
Reveal
</Button>
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
const { filename, blob } = await sshApi.downloadBlob(k.id!, "both")
saveBlob(blob, filename)
} catch (e: any) {
toast.error(e?.message ?? "Download failed")
}
}}
>
<Download className="mr-1 h-4 w-4" />
Download
</Button>
<Button size="sm" variant="destructive" onClick={() => setDeleteId(k.id!)}>
<Trash2 className="mr-1 h-4 w-4" />
Delete
</Button>
</TableCell>
</TableRow>
)
})}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground py-10 text-center">
No SSH Keys
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Reveal modal */}
<Dialog open={!!revealFor} onOpenChange={(o) => !o && setRevealFor(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Private Key (read-only)</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="text-sm">
<div className="font-medium">{revealFor?.name ?? "SSH key"}</div>
<div className="text-muted-foreground font-mono text-xs">
{revealFor?.fingerprint}
</div>
<Textarea
readOnly
className="h-64 w-full rounded-md border p-3 font-mono text-xs"
value={revealFor?.private_key ?? ""}
/>
<div className="flex justify-end">
<Button
onClick={() =>
revealFor?.private_key && copy(revealFor.private_key, "Private key copied")
}
>
Copy
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete confirm */}
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete SSH Key</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground text-sm">
This will permanently delete the keypair. This action cannot be undone.
</p>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting
</>
) : (
"Delete"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,424 @@
import { useMemo, useState } from "react"
import { taintsApi } from "@/api/taints.ts"
import type { DtoTaintResponse } from "@/sdk"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { CircleSlash2, Pencil, Plus, Search, Tags } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { truncateMiddle } from "@/lib/utils.ts"
import { Badge } from "@/components/ui/badge.tsx"
import { Button } from "@/components/ui/button.tsx"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
const EFFECTS = ["NoSchedule", "PreferNoSchedule", "NoExecute"] as const
const createTaintSchema = z.object({
key: z.string().trim().min(1, "Key is required").max(120, "Max 120 chars"),
value: z.string().trim().optional(),
effect: z.enum(EFFECTS),
})
type CreateTaintInput = z.input<typeof createTaintSchema>
const updateTaintSchema = createTaintSchema.partial()
type UpdateTaintValues = z.infer<typeof updateTaintSchema>
function TaintBadge({ t }: { t: Pick<DtoTaintResponse, "key" | "value" | "effect"> }) {
const label = `${t.key}${t.value ? `=${t.value}` : ""}${t.effect ? `:${t.effect}` : ""}`
return (
<Badge variant="secondary" className="font-mono text-xs">
<Tags className="mr-1 h-3 w-3" />
{label}
</Badge>
)
}
export const TaintsPage = () => {
const [filter, setFilter] = useState<string>("")
const [createOpen, setCreateOpen] = useState<boolean>(false)
const [updateOpen, setUpdateOpen] = useState<boolean>(false)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const qc = useQueryClient()
const taintsQ = useQuery({
queryKey: ["taints"],
queryFn: () => taintsApi.listTaints(),
})
// --- Create
const createForm = useForm<CreateTaintInput>({
resolver: zodResolver(createTaintSchema),
defaultValues: {
key: "",
value: "",
effect: undefined,
},
})
const createMut = useMutation({
mutationFn: (values: CreateTaintInput) => taintsApi.createTaint(values),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["taints"] })
createForm.reset()
setCreateOpen(false)
toast.success("Taint Created Successfully.")
},
onError: (err) => {
toast.error(err.message ?? "There was an error while creating Taint")
},
})
const onCreateSubmit = (values: CreateTaintInput) => {
createMut.mutate(values)
}
// --- Update
const updateForm = useForm<UpdateTaintValues>({
resolver: zodResolver(updateTaintSchema),
defaultValues: {},
})
const updateMut = useMutation({
mutationFn: ({ id, values }: { id: string; values: UpdateTaintValues }) =>
taintsApi.updateTaint(id, values),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["taints"] })
updateForm.reset()
setUpdateOpen(false)
toast.success("Taint Updated Successfully.")
},
onError: (err) => {
toast.error(err.message ?? "There was an error while updating Taint")
},
})
const openEdit = (taint: any) => {
setEditingId(taint.id)
updateForm.reset({
key: taint.key,
value: taint.value,
effect: taint.effect,
})
setUpdateOpen(true)
}
// --- Delete ---
const deleteMut = useMutation({
mutationFn: (id: string) => taintsApi.deleteTaint(id),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["taints"] })
setDeleteId(null)
toast.success("Taint Deleted Successfully.")
},
onError: (err) => {
toast.error(err.message ?? "There was an error while deleting Taint")
},
})
const filtered = useMemo(() => {
const data = taintsQ.data ?? []
const q = filter.trim().toLowerCase()
return q
? data.filter((k: any) => {
return (
k.key?.toLowerCase().includes(q) ||
k.value?.toLowerCase().includes(q) ||
k.effect?.toLowerCase().includes(q)
)
})
: data
}, [filter, taintsQ.data])
if (taintsQ.isLoading) return <div className="p-6">Loading taints</div>
if (taintsQ.error) return <div className="p-6 text-red-500">Error loading taints.</div>
return (
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="mb-4 text-2xl font-bold">Taints</h1>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute top-2.5 left-2 h-4 w-4 opacity-60" />
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search taints"
className="w-64 pl-8"
/>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Create Taint
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create taint</DialogTitle>
</DialogHeader>
<Form {...createForm}>
<form className="space-y-4" onSubmit={createForm.handleSubmit(onCreateSubmit)}>
<FormField
control={createForm.control}
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Key</FormLabel>
<FormControl>
<Input placeholder="dedicated" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>Value (optional)</FormLabel>
<FormControl>
<Input placeholder="gpu" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="effect"
render={({ field }) => (
<FormItem>
<FormLabel>Effect</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select effect" />
</SelectTrigger>
</FormControl>
<SelectContent>
{EFFECTS.map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={createForm.formState.isSubmitting}>
{createForm.formState.isSubmitting ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
<div className="bg-background overflow-hidden rounded-2xl border shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Taint</TableHead>
<TableHead className="w-[180px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((t) => (
<TableRow key={t.id}>
<TableCell>
<div className="flex items-center gap-2">
<TaintBadge t={t} />
<code className="text-muted-foreground text-xs">
{truncateMiddle(t.id!, 6)}
</code>
</div>
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(t)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteId(t.id!)}
disabled={deleteMut.isPending && deleteId === t.id}
>
{deleteMut.isPending && deleteId === t.id ? "Deleting…" : "Delete"}
</Button>
</div>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-muted-foreground py-10 text-center">
<CircleSlash2 className="mx-auto mb-2 h-6 w-6 opacity-60" />
No taints match your search.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Update dialog */}
<Dialog open={updateOpen} onOpenChange={setUpdateOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit taint</DialogTitle>
</DialogHeader>
<Form {...updateForm}>
<form
className="space-y-4"
onSubmit={updateForm.handleSubmit((values) => {
if (!editingId) return
updateMut.mutate({ id: editingId, values })
})}
>
<FormField
control={updateForm.control}
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Key</FormLabel>
<FormControl>
<Input placeholder="dedicated" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateForm.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>Value (optional)</FormLabel>
<FormControl>
<Input placeholder="gpu" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateForm.control}
name="effect"
render={({ field }) => (
<FormItem>
<FormLabel>Effect</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select effect" />
</SelectTrigger>
</FormControl>
<SelectContent>
{EFFECTS.map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setUpdateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={updateMut.isPending}>
{updateMut.isPending ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Delete confirm dialog */}
<Dialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete taint</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground text-sm">
This action cannot be undone. Are you sure you want to delete this taint?
</p>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && deleteMut.mutate(deleteId)}
disabled={deleteMut.isPending}
>
{deleteMut.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}