mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 21:00:06 +01:00
feat: sdk migration in progress
This commit is contained in:
119
ui/src/pages/auth/login.tsx
Normal file
119
ui/src/pages/auth/login.tsx
Normal 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
505
ui/src/pages/me/me-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
216
ui/src/pages/org/api-keys.tsx
Normal file
216
ui/src/pages/org/api-keys.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
262
ui/src/pages/org/members.tsx
Normal file
262
ui/src/pages/org/members.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
ui/src/pages/org/settings.tsx
Normal file
147
ui/src/pages/org/settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
821
ui/src/pages/servers/server-page.tsx
Normal file
821
ui/src/pages/servers/server-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
475
ui/src/pages/ssh/ssh-page.tsx
Normal file
475
ui/src/pages/ssh/ssh-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
424
ui/src/pages/taints/taints-page.tsx
Normal file
424
ui/src/pages/taints/taints-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user