Skip to content
File

Blob: src/client/components/admin/client-form.tsx

typescript117 lines
1import { useState } from "react";
2 
3import { FormActions } from "@/client/components/ui/form-actions";
4import { Input } from "@/client/components/ui/input";
5 
6export interface ClientFormValues {
7 name: string;
8 redirectUris: string[];
9 uri: string;
10 skipConsent: boolean;
11}
12 
13interface 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 
23const 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 
30export 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};