Skip to content

Commit d89ce82

Browse files
authored
feat: frontend SSO support (#3582)
1 parent b362a9f commit d89ce82

26 files changed

Lines changed: 1937 additions & 23 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Button } from '@/components/v1/ui/button';
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogHeader,
6+
DialogTitle,
7+
DialogDescription,
8+
DialogFooter,
9+
} from '@/components/v1/ui/dialog';
10+
import { useSsoDeleteConfirmation } from '@/hooks/sso/SsoSetupHooks';
11+
import { Loader2 } from 'lucide-react';
12+
13+
export function SsoDeleteConfirmationDialog() {
14+
const { open, onOpenChange, onConfirm, deleting, providerName } =
15+
useSsoDeleteConfirmation();
16+
return (
17+
<Dialog open={open} onOpenChange={onOpenChange}>
18+
<DialogContent>
19+
<DialogHeader>
20+
<DialogTitle>Delete SSO Configuration?</DialogTitle>
21+
<DialogDescription>
22+
This will permanently delete the SSO configuration for{' '}
23+
<strong>{providerName}</strong>. This action cannot be undone.
24+
</DialogDescription>
25+
</DialogHeader>
26+
<DialogFooter>
27+
<Button
28+
variant="outline"
29+
onClick={() => onOpenChange(false)}
30+
disabled={deleting}
31+
>
32+
Cancel
33+
</Button>
34+
<Button variant="destructive" onClick={onConfirm} disabled={deleting}>
35+
{deleting ? (
36+
<>
37+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
38+
Deleting...
39+
</>
40+
) : (
41+
'Delete Configuration'
42+
)}
43+
</Button>
44+
</DialogFooter>
45+
</DialogContent>
46+
</Dialog>
47+
);
48+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Alert, AlertDescription } from '@/components/v1/ui/alert';
2+
import { useSsoErrorAlert } from '@/hooks/sso/SsoSetupHooks';
3+
4+
export function SsoErrorAlert() {
5+
const { message } = useSsoErrorAlert();
6+
7+
if (!message) {
8+
return null;
9+
}
10+
11+
return (
12+
<Alert variant="destructive">
13+
<AlertDescription>{message}</AlertDescription>
14+
</Alert>
15+
);
16+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import React from 'react';
2+
3+
export function SsoErrorText({ children }: { children?: React.ReactNode }) {
4+
if (!children) {
5+
return null;
6+
}
7+
return <p className="text-xs text-destructive">{children}</p>;
8+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Label } from '@/components/v1/ui/label';
2+
3+
export function SsoField({
4+
label,
5+
htmlFor,
6+
children,
7+
required,
8+
}: {
9+
label: string;
10+
htmlFor?: string;
11+
children: React.ReactNode;
12+
required?: boolean;
13+
}) {
14+
return (
15+
<div className="grid gap-1.5">
16+
<Label htmlFor={htmlFor}>
17+
{label} {required && <span className="text-destructive">*</span>}
18+
</Label>
19+
{children}
20+
</div>
21+
);
22+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { SsoErrorText } from './SsoErrorText';
2+
import { Input } from '@/components/v1/ui/input';
3+
4+
export function SsoFormInput({
5+
id,
6+
type = 'text',
7+
placeholder,
8+
value,
9+
onChange,
10+
error,
11+
readOnly,
12+
autoComplete,
13+
...dataAttributes
14+
}: {
15+
id: string;
16+
type?: string;
17+
placeholder?: string;
18+
value: string;
19+
onChange: (value: string) => void;
20+
error?: string;
21+
readOnly?: boolean;
22+
autoComplete?: string;
23+
} & Record<`data-${string}`, string | boolean | undefined>) {
24+
return (
25+
<>
26+
<Input
27+
id={id}
28+
type={type}
29+
placeholder={placeholder}
30+
value={value}
31+
onChange={(e) => onChange(e.target.value)}
32+
readOnly={readOnly}
33+
autoComplete={autoComplete}
34+
{...dataAttributes}
35+
/>
36+
{error && <SsoErrorText>{error}</SsoErrorText>}
37+
</>
38+
);
39+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Button } from '@/components/v1/ui/button';
2+
import { useSsoIdpPicker } from '@/hooks/sso/SsoSetupHooks';
3+
import { PROVIDER_CONFIG } from '@/lib/sso/sso-constants';
4+
import { ProviderKey } from '@/lib/sso/sso-types';
5+
6+
export function SsoIdpPicker() {
7+
const { onProviderSelect } = useSsoIdpPicker();
8+
const providers = Object.keys(PROVIDER_CONFIG) as ProviderKey[];
9+
10+
return (
11+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
12+
{providers.map((p) => (
13+
<Button
14+
key={p}
15+
type="button"
16+
onClick={() => onProviderSelect(p)}
17+
className="cursor-pointer"
18+
>
19+
<span className="font-medium">{PROVIDER_CONFIG[p].displayName}</span>
20+
</Button>
21+
))}
22+
</div>
23+
);
24+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Loader2 } from 'lucide-react';
2+
3+
export function SsoLoadingState() {
4+
return (
5+
<div className="mx-auto max-w-3xl">
6+
<div className="flex h-32 items-center justify-center text-muted-foreground">
7+
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading…
8+
</div>
9+
</div>
10+
);
11+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Switch } from '@/components/v1/ui/switch';
2+
3+
export function SsoPkceRow({
4+
checked,
5+
onChange,
6+
}: {
7+
checked: boolean;
8+
onChange: (v: boolean) => void;
9+
}) {
10+
return (
11+
<div className="mt-2 flex items-center justify-between rounded-xl border p-3">
12+
<div className="space-y-0.5">
13+
<div className="text-sm font-medium">Use PKCE</div>
14+
<p className="text-xs text-muted-foreground">Recommended.</p>
15+
</div>
16+
<Switch checked={checked} onCheckedChange={onChange} />
17+
</div>
18+
);
19+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { SsoField } from './SsoField';
2+
import { SsoFormInput } from './SsoFormInput';
3+
import { SsoPkceRow } from './SsoPkceRow';
4+
import { useSsoProviderForm } from '@/hooks/sso/SsoSetupHooks';
5+
6+
export function SsoProviderForm() {
7+
const { form, errors, onChange, isEditMode } = useSsoProviderForm();
8+
// Common fields for all providers
9+
const commonSsoFields = (
10+
<>
11+
<SsoField label="Client ID" htmlFor="clientId" required>
12+
<SsoFormInput
13+
id="clientId"
14+
placeholder="client_id"
15+
value={form.clientId}
16+
onChange={(v) => onChange('clientId', v)}
17+
error={errors.clientId}
18+
readOnly={isEditMode}
19+
autoComplete="off"
20+
data-1p-ignore
21+
data-bwignore
22+
data-lpignore="true"
23+
data-protonpass-ignore="true"
24+
data-form-type="other"
25+
/>
26+
</SsoField>
27+
<SsoField
28+
label="Client Secret"
29+
htmlFor="clientSecret"
30+
required={!isEditMode}
31+
>
32+
<SsoFormInput
33+
id="clientSecret"
34+
type="password"
35+
placeholder={isEditMode ? 'Leave empty to keep existing' : '••••••••'}
36+
value={form.clientSecret}
37+
onChange={(v) => onChange('clientSecret', v)}
38+
error={errors.clientSecret}
39+
autoComplete="off"
40+
data-1p-ignore
41+
data-bwignore
42+
data-lpignore="true"
43+
data-protonpass-ignore="true"
44+
data-form-type="other"
45+
/>
46+
</SsoField>
47+
</>
48+
);
49+
50+
if (form.provider === 'Okta') {
51+
return (
52+
<>
53+
<SsoField label="SSO Domain" htmlFor="ssoDomain" required>
54+
<SsoFormInput
55+
id="ssoDomain"
56+
placeholder="example.okta.com"
57+
value={form.ssoDomain || ''}
58+
onChange={(v) => onChange('ssoDomain', v)}
59+
error={errors.ssoDomain}
60+
/>
61+
</SsoField>
62+
{commonSsoFields}
63+
<SsoPkceRow
64+
checked={form.usesPkce}
65+
onChange={(v) => onChange('usesPkce', v)}
66+
/>
67+
</>
68+
);
69+
}
70+
71+
if (form.provider === 'MicrosoftEntra') {
72+
return (
73+
<>
74+
<SsoField label="Tenant ID" htmlFor="tenantId" required>
75+
<SsoFormInput
76+
id="tenantId"
77+
placeholder="00000000-0000-0000-0000-000000000000"
78+
value={form.tenantId || ''}
79+
onChange={(v) => onChange('tenantId', v)}
80+
error={errors.tenantId}
81+
/>
82+
</SsoField>
83+
{commonSsoFields}
84+
<SsoPkceRow
85+
checked={form.usesPkce}
86+
onChange={(v) => onChange('usesPkce', v)}
87+
/>
88+
</>
89+
);
90+
}
91+
92+
// Generic providers (Google, OneLogin, JumpCloud, Generic)
93+
return (
94+
<>
95+
{commonSsoFields}
96+
<SsoField label="Authorize URL" htmlFor="authUrl" required>
97+
<SsoFormInput
98+
id="authUrl"
99+
placeholder="https://.../authorize"
100+
value={form.authUrl || ''}
101+
onChange={(v) => onChange('authUrl', v)}
102+
error={errors.authUrl}
103+
/>
104+
</SsoField>
105+
<SsoField label="Token URL" htmlFor="tokenUrl" required>
106+
<SsoFormInput
107+
id="tokenUrl"
108+
placeholder="https://.../token"
109+
value={form.tokenUrl || ''}
110+
onChange={(v) => onChange('tokenUrl', v)}
111+
error={errors.tokenUrl}
112+
/>
113+
</SsoField>
114+
<SsoField label="Userinfo URL" htmlFor="userinfoUrl" required>
115+
<SsoFormInput
116+
id="userinfoUrl"
117+
placeholder="https://.../userinfo"
118+
value={form.userinfoUrl || ''}
119+
onChange={(v) => onChange('userinfoUrl', v)}
120+
error={errors.userinfoUrl}
121+
/>
122+
</SsoField>
123+
<SsoPkceRow
124+
checked={form.usesPkce}
125+
onChange={(v) => onChange('usesPkce', v)}
126+
/>
127+
</>
128+
);
129+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { SsoField } from './SsoField';
2+
import { Button } from '@/components/v1/ui/button';
3+
import { Input } from '@/components/v1/ui/input';
4+
import { copySsoToClipboard } from '@/lib/sso/sso-utils';
5+
import { Check, Copy } from 'lucide-react';
6+
import { useState } from 'react';
7+
8+
export function SsoRedirectUriField({ redirectUrl }: { redirectUrl: string }) {
9+
const [copied, setCopied] = useState(false);
10+
11+
return (
12+
<SsoField label="Redirect / Callback URL">
13+
<div className="flex items-center gap-2">
14+
<Input readOnly value={redirectUrl} tabIndex={-1} />
15+
<Button
16+
type="button"
17+
size="sm"
18+
onClick={() => {
19+
copySsoToClipboard(redirectUrl, () => {
20+
setCopied(true);
21+
setTimeout(() => setCopied(false), 500);
22+
});
23+
}}
24+
className="shrink-0 cursor-pointer"
25+
>
26+
{copied ? (
27+
<Check className="h-4 w-4" />
28+
) : (
29+
<Copy className="h-4 w-4" />
30+
)}
31+
</Button>
32+
</div>
33+
</SsoField>
34+
);
35+
}

0 commit comments

Comments
 (0)