File
Blob: src/client/components/admin/client-form.tsx
| 1 | import { useState } from "react"; |
| 2 | |
| 3 | import { FormActions } from "@/client/components/ui/form-actions"; |
| 4 | import { Input } from "@/client/components/ui/input"; |
| 5 | |
| 6 | export interface ClientFormValues { |
| 7 | name: string; |
| 8 | redirectUris: string[]; |
| 9 | uri: string; |
| 10 | skipConsent: boolean; |
| 11 | } |
| 12 | |
| 13 | interface ClientFormProps { |
| 14 | mode: "create" | "edit"; |
| 15 | initial?: Partial<ClientFormValues>; |
| 16 | pending: boolean; |
| 17 | error?: string | null; |
| 18 | onDismissError?: () => void; |
| 19 | onSubmit: (values: ClientFormValues) => Promise<void>; |
| 20 | onCancel: () => void; |
| 21 | } |
| 22 | |
| 23 | const SkipConsentNote = () => ( |
| 24 | <span className="block text-xs text-zinc-500"> |
| 25 | For first-party RPs (anvil, bland) only. Bypasses the per-user grant prompt; tokens are issued straight from{" "} |
| 26 | <code className="font-mono">/authorize</code>. Leave off for third-party clients. |
| 27 | </span> |
| 28 | ); |
| 29 | |
| 30 | export const ClientForm = ({ mode, initial, pending, error, onDismissError, onSubmit, onCancel }: ClientFormProps) => { |
| 31 | const [name, setName] = useState(initial?.name ?? ""); |
| 32 | const [redirectUrisText, setRedirectUrisText] = useState((initial?.redirectUris ?? []).join("\n")); |
| 33 | const [uri, setUri] = useState(initial?.uri ?? ""); |
| 34 | const [skipConsent, setSkipConsent] = useState(initial?.skipConsent ?? false); |
| 35 | const [localError, setLocalError] = useState<string | null>(null); |
| 36 | const isEdit = mode === "edit"; |
| 37 | |
| 38 | const handleSubmit = async (e: React.FormEvent) => { |
| 39 | e.preventDefault(); |
| 40 | if (pending) return; |
| 41 | const redirectUris = redirectUrisText |
| 42 | .split(/\r?\n/) |
| 43 | .map((s) => s.trim()) |
| 44 | .filter(Boolean); |
| 45 | if (!isEdit && redirectUris.length === 0) { |
| 46 | setLocalError("At least one redirect URI is required."); |
| 47 | return; |
| 48 | } |
| 49 | setLocalError(null); |
| 50 | await onSubmit({ |
| 51 | name: name.trim(), |
| 52 | // In edit mode the redirect_uris field is read-only and the worker |
| 53 | // ignores anything beyond name/skipConsent/uri, so the original list |
| 54 | // is sent back unchanged for completeness only. |
| 55 | redirectUris: isEdit ? (initial?.redirectUris ?? []) : redirectUris, |
| 56 | uri: uri.trim(), |
| 57 | skipConsent, |
| 58 | }); |
| 59 | }; |
| 60 | |
| 61 | return ( |
| 62 | <form onSubmit={handleSubmit} className="flex flex-col gap-4"> |
| 63 | <Input label="Client name" value={name} onChange={(e) => setName(e.target.value)} required autoFocus={!isEdit} /> |
| 64 | <div className="flex flex-col gap-1.5"> |
| 65 | <label className="text-sm font-medium text-zinc-200" htmlFor="client-form-redirect-uris"> |
| 66 | Redirect URIs (one per line) |
| 67 | </label> |
| 68 | <textarea |
| 69 | id="client-form-redirect-uris" |
| 70 | value={redirectUrisText} |
| 71 | onChange={(e) => setRedirectUrisText(e.target.value)} |
| 72 | rows={4} |
| 73 | readOnly={isEdit} |
| 74 | required={!isEdit} |
| 75 | placeholder="https://anvil.limic.dev/auth/callback" |
| 76 | className={`w-full rounded-xl border border-zinc-700/60 bg-zinc-800/80 px-4 py-2.5 font-mono text-xs text-zinc-100 focus:border-accent-500/50 focus:outline-none focus:ring-1 focus:ring-accent-500/30 ${ |
| 77 | isEdit ? "cursor-not-allowed opacity-70" : "" |
| 78 | }`} |
| 79 | /> |
| 80 | {isEdit ? ( |
| 81 | <p className="text-xs text-zinc-500">Redirect URIs are immutable. Register a new client to change them.</p> |
| 82 | ) : null} |
| 83 | </div> |
| 84 | <Input |
| 85 | label="Homepage URL (optional)" |
| 86 | placeholder="https://anvil.limic.dev" |
| 87 | helper="Stored as the OIDC client_uri. Surfaces on the consent screen and the connected-apps page." |
| 88 | value={uri} |
| 89 | onChange={(e) => setUri(e.target.value)} |
| 90 | type="url" |
| 91 | /> |
| 92 | <label className="flex items-start gap-3 rounded-xl border border-zinc-800/60 bg-zinc-900/40 p-3 text-sm text-zinc-200 hover:border-zinc-700/60"> |
| 93 | <input |
| 94 | type="checkbox" |
| 95 | checked={skipConsent} |
| 96 | onChange={(e) => setSkipConsent(e.target.checked)} |
| 97 | className="mt-0.5 h-4 w-4 shrink-0 rounded border-zinc-600 bg-zinc-800 accent-accent-500" |
| 98 | /> |
| 99 | <span className="min-w-0"> |
| 100 | <span className="block font-medium text-zinc-100">Skip consent screen</span> |
| 101 | <SkipConsentNote /> |
| 102 | </span> |
| 103 | </label> |
| 104 | <FormActions |
| 105 | pending={pending} |
| 106 | error={localError ?? error ?? null} |
| 107 | onDismissError={() => { |
| 108 | setLocalError(null); |
| 109 | onDismissError?.(); |
| 110 | }} |
| 111 | submitLabel={isEdit ? "Save changes" : "Register client"} |
| 112 | onCancel={onCancel} |
| 113 | /> |
| 114 | </form> |
| 115 | ); |
| 116 | }; |