Skip to content

Commit 2b4742d

Browse files
authored
feat: add manual validation for SSO (#94)
* feat: add manual validation for SSO * fix: test snapshot * fix: add GetMe in user service instead of using a DB_URL conditions...
1 parent afd3568 commit 2b4742d

22 files changed

Lines changed: 630 additions & 93 deletions

apps/dashboard/src/components/app-sidebar.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
Users,
1616
} from 'lucide-react';
1717
import clsx from 'clsx';
18+
import { useQuery } from '@tanstack/react-query';
19+
import { api } from '@/lib/api';
1820
import { Combobox } from '@/components/Combobox';
1921
import { useSelectedApp } from '@/lib/SelectedAppContext';
2022
import { CreateAppModal } from '@/components/app-creation-modal';
@@ -62,6 +64,16 @@ const EnterpriseNavBadge = () => (
6264
</span>
6365
);
6466

67+
// Counts the accounts waiting for an admin to approve them. Without it nobody
68+
// notices there is anything to approve, and new members sit blocked in silence.
69+
const PendingUsersBadge = ({ count }: { count: number }) => (
70+
<span
71+
className="ml-auto rounded-full border border-amber-200/80 bg-amber-50/80 px-1.5 py-px text-[10px] font-medium text-amber-700"
72+
title={`${count} account${count > 1 ? 's' : ''} waiting for approval`}>
73+
{count}
74+
</span>
75+
);
76+
6577
const SectionLabel = ({ children }: { children: React.ReactNode }) => (
6678
<p className="px-3 pb-1.5 pt-5 text-[11px] font-medium uppercase tracking-widest text-muted-foreground/70">
6779
{children}
@@ -74,6 +86,15 @@ export function AppSidebar() {
7486
const { apps, selectedAppId, setSelectedAppId, refreshApps, isLoading } = useSelectedApp();
7587
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
7688

89+
// Same query key as the Users page, so react-query serves both from one
90+
// request and approving an account refreshes the badge on its own.
91+
const usersQuery = useQuery({
92+
queryKey: ['users'],
93+
queryFn: () => api.getUsers(),
94+
enabled: CONTROL_PLANE_ENABLED && isAdmin,
95+
});
96+
const pendingUsersCount = (usersQuery.data ?? []).filter(user => !user.enabled).length;
97+
7798
const handleAppCreated = async (newAppId: string) => {
7899
await refreshApps();
79100
setSelectedAppId(newAppId);
@@ -166,7 +187,14 @@ export function AppSidebar() {
166187
<>
167188
<SectionLabel>Access & Security</SectionLabel>
168189
<div className="space-y-0.5">
169-
<NavLink to="/users" icon={Users}>
190+
<NavLink
191+
to="/users"
192+
icon={Users}
193+
badge={
194+
pendingUsersCount > 0 ? (
195+
<PendingUsersBadge count={pendingUsersCount} />
196+
) : undefined
197+
}>
170198
Users
171199
</NavLink>
172200
<NavLink to="/sso" icon={Fingerprint} badge={<EnterpriseNavBadge />}>

apps/dashboard/src/ee/components/SsoConfigCard.tsx

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type SsoFormState = {
3232
allowedEmailDomains: string[];
3333
allowedGroups: string[];
3434
trustUnverifiedEmail: boolean;
35+
manualUserValidation: boolean;
3536
};
3637

3738
const DEFAULT_SCOPES = 'openid profile email';
@@ -47,6 +48,7 @@ const emptyForm: SsoFormState = {
4748
allowedEmailDomains: [],
4849
allowedGroups: [],
4950
trustUnverifiedEmail: false,
51+
manualUserValidation: false,
5052
};
5153

5254
const formFromSettings = (settings: SsoSettings): SsoFormState => ({
@@ -61,6 +63,7 @@ const formFromSettings = (settings: SsoSettings): SsoFormState => ({
6163
allowedEmailDomains: settings.allowedEmailDomains,
6264
allowedGroups: settings.allowedGroups,
6365
trustUnverifiedEmail: settings.trustUnverifiedEmail,
66+
manualUserValidation: settings.manualUserValidation,
6467
});
6568

6669
// Signature of the stored values the form is populated from. The live enabled
@@ -78,6 +81,7 @@ const settingsFormSignature = (settings: SsoSettings | null): string | null =>
7881
settings.allowedEmailDomains,
7982
settings.allowedGroups,
8083
settings.trustUnverifiedEmail,
84+
settings.manualUserValidation,
8185
])
8286
: null;
8387

@@ -241,6 +245,7 @@ export const SsoConfigCard = () => {
241245
allowedGroups: form.allowedGroups,
242246
groupsClaim: form.groupsClaim.trim(),
243247
trustUnverifiedEmail: form.trustUnverifiedEmail,
248+
manualUserValidation: form.manualUserValidation,
244249
});
245250
queryClient.setQueryData(['ssoSettings'], saved);
246251
queryClient.invalidateQueries({ queryKey: ['ssoPublicConfig'] });
@@ -278,6 +283,7 @@ export const SsoConfigCard = () => {
278283
allowedGroups: settings.allowedGroups,
279284
groupsClaim: settings.groupsClaim,
280285
trustUnverifiedEmail: settings.trustUnverifiedEmail,
286+
manualUserValidation: settings.manualUserValidation,
281287
});
282288
queryClient.setQueryData(['ssoSettings'], saved);
283289
queryClient.invalidateQueries({ queryKey: ['ssoPublicConfig'] });
@@ -364,7 +370,10 @@ export const SsoConfigCard = () => {
364370
<AlertTitle>Could not load the SSO configuration</AlertTitle>
365371
<AlertDescription className="flex flex-col items-start gap-3">
366372
<span className="break-words">
367-
{describeApiError(settingsQuery.error, 'The server could not be reached.').description}
373+
{
374+
describeApiError(settingsQuery.error, 'The server could not be reached.')
375+
.description
376+
}
368377
</span>
369378
<Button
370379
variant="outline"
@@ -428,12 +437,16 @@ export const SsoConfigCard = () => {
428437
size="icon"
429438
onClick={handleCopyRedirectUri}
430439
title="Copy the redirect URI">
431-
{copied ? <Check className="h-4 w-4 text-emerald-600" /> : <Copy className="h-4 w-4" />}
440+
{copied ? (
441+
<Check className="h-4 w-4 text-emerald-600" />
442+
) : (
443+
<Copy className="h-4 w-4" />
444+
)}
432445
</Button>
433446
</div>
434447
<FieldHint>
435-
Derived from BASE_URL. Register it as a web redirect URI in your identity
436-
provider's app registration before saving.
448+
Derived from BASE_URL. Register it as a web redirect URI in your identity provider's
449+
app registration before saving.
437450
</FieldHint>
438451
</div>
439452

@@ -448,8 +461,10 @@ export const SsoConfigCard = () => {
448461
spellCheck={false}
449462
/>
450463
<FieldHint>
451-
The OpenID Connect issuer of your provider. Saving fetches
452-
{' '}<code className="rounded bg-muted px-1 py-0.5 text-[11px]">/.well-known/openid-configuration</code>{' '}
464+
The OpenID Connect issuer of your provider. Saving fetches{' '}
465+
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">
466+
/.well-known/openid-configuration
467+
</code>{' '}
453468
from it and reports any error right here.
454469
</FieldHint>
455470
</div>
@@ -547,6 +562,24 @@ export const SsoConfigCard = () => {
547562
onCheckedChange={checked => setField('trustUnverifiedEmail', checked)}
548563
/>
549564
</div>
565+
566+
<div className="flex items-start justify-between gap-4 border-t pt-4">
567+
<div className="space-y-0.5">
568+
<Label htmlFor="sso-manual-validation">Require admin approval</Label>
569+
<FieldHint>
570+
New accounts are created on their first sign-in but cannot enter until an admin
571+
approves them on the Users page, where they show up as pending. Use this when
572+
your provider authenticates more people than should reach this dashboard and
573+
does not send group claims to filter on, which is the case for Google Workspace.
574+
Accounts that already exist keep their access.
575+
</FieldHint>
576+
</div>
577+
<Switch
578+
aria-label="Require admin approval"
579+
checked={form.manualUserValidation}
580+
onCheckedChange={checked => setField('manualUserValidation', checked)}
581+
/>
582+
</div>
550583
</div>
551584

552585
<div className="space-y-4">

apps/dashboard/src/lib/api.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ export type UserRecord = {
111111
id: string;
112112
email: string;
113113
isAdmin: boolean;
114+
// False for an account an admin revoked, or one awaiting approval under SSO
115+
// manual validation. Disabled accounts cannot sign in.
116+
enabled: boolean;
114117
createdAt?: string;
115118
lastConnectedAt?: string;
116119
};
@@ -154,6 +157,9 @@ export type SsoSettings = {
154157
// Whether the server accepts an email the provider did not verify
155158
// (email_verified false or absent) for account lookup and authorization.
156159
trustUnverifiedEmail: boolean;
160+
// Whether accounts discovered on their first SSO sign-in are provisioned
161+
// disabled, waiting for an admin to approve them on the Users page.
162+
manualUserValidation: boolean;
157163
redirectUri: string;
158164
};
159165

@@ -169,6 +175,7 @@ export type SaveSsoSettingsPayload = {
169175
allowedGroups: string[];
170176
groupsClaim: string;
171177
trustUnverifiedEmail: boolean;
178+
manualUserValidation: boolean;
172179
};
173180

174181
// Mirror of the server's SettingsEnv payload (/api/settings). Field names are
@@ -350,6 +357,14 @@ export class ApiClient {
350357
});
351358
}
352359

360+
public async updateUserEnabled(userId: string, enabled: boolean) {
361+
return this.request<void>(`/api/users/${encodeURIComponent(userId)}`, {
362+
method: 'PATCH',
363+
headers: { 'Content-Type': 'application/json' },
364+
body: JSON.stringify({ enabled }),
365+
});
366+
}
367+
353368
public async deleteUser(userId: string) {
354369
return this.request<void>(`/api/users/${encodeURIComponent(userId)}`, {
355370
method: 'DELETE',

apps/dashboard/src/pages/Login/index.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,18 @@ const FormSchema = z.object({
2626
// underlying cause stays in the server logs; the code alone reaches the URL.
2727
const SSO_ERROR_MESSAGES: Record<string, string> = {
2828
sso_denied: 'Sign-in was cancelled at the identity provider.',
29-
sso_license: 'Single sign-on requires an active enterprise license. Sign in with your password instead.',
29+
sso_license:
30+
'Single sign-on requires an active enterprise license. Sign in with your password instead.',
3031
sso_email_missing:
3132
'Your identity provider did not return an email address for your account. Contact your administrator.',
3233
sso_email_unverified:
3334
'Your identity provider did not verify your email address. Contact your administrator.',
34-
sso_forbidden: 'Your account is not allowed to access this dashboard. Contact your administrator.',
35-
sso_failed: 'SSO sign-in failed. Try again, and contact your administrator if it keeps happening.',
35+
sso_forbidden:
36+
'Your account is not allowed to access this dashboard. Contact your administrator.',
37+
sso_pending:
38+
'Your account has been created and is waiting for an administrator to approve it. You will be able to sign in once it is approved.',
39+
sso_failed:
40+
'SSO sign-in failed. Try again, and contact your administrator if it keeps happening.',
3641
};
3742

3843
export const Login = () => {
@@ -44,7 +49,10 @@ export const Login = () => {
4449
},
4550
});
4651
const navigate = useNavigate();
47-
const [ssoError, setSsoError] = useState<string | null>(null);
52+
// Shared by both sign-in paths: the SSO callback's error codes and the
53+
// password path's 403s (SSO enforced, account awaiting approval). Both are
54+
// account-state messages rather than credential mistakes.
55+
const [signInNotice, setSignInNotice] = useState<string | null>(null);
4856

4957
// The SSO callback lands here with the session pair (or an error code) in
5058
// the URL fragment: fragments never reach a server, so tokens cannot end up
@@ -70,7 +78,7 @@ export const Login = () => {
7078
navigate('/');
7179
return;
7280
}
73-
setSsoError(SSO_ERROR_MESSAGES[errorCode ?? ''] ?? SSO_ERROR_MESSAGES.sso_failed);
81+
setSignInNotice(SSO_ERROR_MESSAGES[errorCode ?? ''] ?? SSO_ERROR_MESSAGES.sso_failed);
7482
}, [navigate]);
7583

7684
const onSubmit = useCallback(
@@ -80,6 +88,14 @@ export const Login = () => {
8088
setTokens(response.token, response.refreshToken);
8189
navigate('/');
8290
} catch (error) {
91+
// A 403 means the credentials were right but the account may not use
92+
// this door: SSO is enforced for it, or it is waiting for approval.
93+
// That is not a password mistake, so it belongs in the notice above
94+
// the form rather than under the password field.
95+
if (error instanceof ApiProblemError && error.status === 403) {
96+
setSignInNotice(error.detail);
97+
return;
98+
}
8399
// Only an actual 401 means wrong credentials. A misconfigured server
84100
// (e.g. ADMIN_EMAIL not set in stateless mode) answers with an
85101
// actionable detail, and a fetch that never reached the server must
@@ -121,10 +137,10 @@ export const Login = () => {
121137
</div>
122138
</div>
123139

124-
{ssoError && (
140+
{signInNotice && (
125141
<Alert variant="destructive" className="mb-5">
126142
<TriangleAlert className="h-4 w-4" />
127-
<AlertDescription>{ssoError}</AlertDescription>
143+
<AlertDescription>{signInNotice}</AlertDescription>
128144
</Alert>
129145
)}
130146

apps/dashboard/src/pages/Users/index.tsx

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useCurrentUser } from '@/lib/CurrentUserContext';
77
import { useToast } from '@/hooks/use-toast';
88
import { Button } from '@/components/ui/button';
99
import { Badge } from '@/components/ui/badge';
10+
import { Switch } from '@/components/ui/switch';
1011
import { PageHeader } from '@/components/PageHeader';
1112
import { DataTable } from '@/components/DataTable';
1213
import { TimestampCell } from '@/components/ui/timestamp-cell';
@@ -23,6 +24,7 @@ export const Users = () => {
2324
const [userToDelete, setUserToDelete] = useState<UserRecord | null>(null);
2425
const [isDeleting, setIsDeleting] = useState(false);
2526
const [togglingUserId, setTogglingUserId] = useState<string | null>(null);
27+
const [togglingEnabledId, setTogglingEnabledId] = useState<string | null>(null);
2628

2729
const usersQuery = useQuery({
2830
queryKey: ['users'],
@@ -49,13 +51,34 @@ export const Users = () => {
4951
}
5052
};
5153

54+
const handleToggleEnabled = async (user: UserRecord) => {
55+
setTogglingEnabledId(user.id);
56+
try {
57+
await api.updateUserEnabled(user.id, !user.enabled);
58+
queryClient.invalidateQueries({ queryKey: ['users'] });
59+
toast({
60+
title: user.enabled ? 'Access revoked' : 'Account approved',
61+
description: user.enabled
62+
? `"${user.email}" can no longer sign in.`
63+
: `"${user.email}" can now sign in.`,
64+
});
65+
} catch (error) {
66+
notifyError(error, 'Error updating user');
67+
} finally {
68+
setTogglingEnabledId(null);
69+
}
70+
};
71+
5272
const handleExecuteDeletion = async () => {
5373
if (!userToDelete) return;
5474
setIsDeleting(true);
5575
try {
5676
await api.deleteUser(userToDelete.id);
5777
queryClient.invalidateQueries({ queryKey: ['users'] });
58-
toast({ title: 'User deleted', description: `"${userToDelete.email}" can no longer sign in.` });
78+
toast({
79+
title: 'User deleted',
80+
description: `"${userToDelete.email}" can no longer sign in.`,
81+
});
5982
setUserToDelete(null);
6083
} catch (error) {
6184
notifyError(error, 'Deletion failed');
@@ -67,10 +90,7 @@ export const Users = () => {
6790
if (!CONTROL_PLANE_ENABLED) {
6891
return (
6992
<div className="w-full">
70-
<PageHeader
71-
title="Users"
72-
description="User accounts for this dashboard."
73-
/>
93+
<PageHeader title="Users" description="User accounts for this dashboard." />
7494
<div className="rounded-xl border border-dashed bg-muted/30 p-8 text-center text-sm text-muted-foreground">
7595
On a stateless deployment there is a single account, configured through the ADMIN_EMAIL
7696
and ADMIN_PASSWORD environment variables.
@@ -140,6 +160,36 @@ export const Users = () => {
140160
<Badge variant="secondary">Member</Badge>
141161
),
142162
},
163+
{
164+
header: 'Access',
165+
accessorKey: 'enabled',
166+
cell: ({ row }) => {
167+
const { enabled, lastConnectedAt } = row.original;
168+
// A disabled account that never connected is one nobody has
169+
// approved yet; one that did connect had its access revoked.
170+
// Same flag, but the two read very differently to an admin.
171+
const label = enabled ? 'Active' : lastConnectedAt ? 'Disabled' : 'Pending';
172+
const badge = enabled ? (
173+
<Badge variant="secondary">Active</Badge>
174+
) : (
175+
<Badge variant="destructive">{label}</Badge>
176+
);
177+
// The server refuses disabling your own account, so do not
178+
// offer the switch on your own row.
179+
if (row.original.id === currentUser?.id) return badge;
180+
return (
181+
<div className="flex items-center gap-2">
182+
<Switch
183+
checked={enabled}
184+
onCheckedChange={() => handleToggleEnabled(row.original)}
185+
disabled={togglingEnabledId === row.original.id}
186+
aria-label={enabled ? 'Revoke access' : 'Approve account'}
187+
/>
188+
{badge}
189+
</div>
190+
);
191+
},
192+
},
143193
{
144194
header: 'Created',
145195
accessorKey: 'createdAt',

0 commit comments

Comments
 (0)