From de869ec1f2b19fa624a448a3ef7e0f2cd21ad4b0 Mon Sep 17 00:00:00 2001 From: James Bhattarai Date: Wed, 4 Mar 2026 18:43:12 +0545 Subject: [PATCH] Feat(Chore, Fix): Admin Privilege Added OIDC Web Page Flow Admin can add/reset password Admin can remove users'/members mfa/2fa, unlink account from oauth provider Chore: Text changes (Forgot Pass, CA) --- src/components/navigation/AppSidebar.tsx | 6 +- src/components/security/TotpRemoveDialog.tsx | 53 +- src/lib/api.ts | 99 +++- src/pages/admin/AdminUsersPage.tsx | 451 ++++++++++++++- src/pages/admin/OAuthProvidersPage.tsx | 8 +- src/pages/auth/ForgotPasswordPage.tsx | 6 - src/pages/org/CAsPage.tsx | 3 +- src/pages/org/MembersPage.tsx | 349 +++++++++++- src/pages/org/OIDCClientsPage.tsx | 563 +++++++++++++++---- src/pages/user/SecurityPage.tsx | 110 +++- 10 files changed, 1464 insertions(+), 184 deletions(-) diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index 0ba10a0..714830f 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -13,6 +13,7 @@ import { ScrollText, Terminal, ShieldCheck, + Key, } from "lucide-react"; import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; import { NavLink } from "@/components/NavLink"; @@ -57,8 +58,9 @@ const orgAdminNavItems = [ const adminNavItems = [ { title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck }, - { title: "Org Audit Log", url: "/org/audit", icon: FileText }, - { title: "System Logs", url: "/admin/audit", icon: ScrollText }, + { title: "OIDC Clients", url: "/org/clients", icon: Key }, + { title: "Org Audit Log", url: "/org/audit", icon: FileText }, + { title: "System Logs", url: "/admin/audit", icon: ScrollText }, ]; export function AppSidebar() { diff --git a/src/components/security/TotpRemoveDialog.tsx b/src/components/security/TotpRemoveDialog.tsx index 69098d2..14ce0aa 100644 --- a/src/components/security/TotpRemoveDialog.tsx +++ b/src/components/security/TotpRemoveDialog.tsx @@ -18,6 +18,7 @@ interface TotpRemoveDialogProps { onOpenChange: (open: boolean) => void; onSuccess: () => void; isRequired?: boolean; + hasPassword?: boolean; } export function TotpRemoveDialog({ @@ -25,6 +26,7 @@ export function TotpRemoveDialog({ onOpenChange, onSuccess, isRequired = false, + hasPassword = true, }: TotpRemoveDialogProps) { const [isLoading, setIsLoading] = useState(false); const [password, setPassword] = useState(""); @@ -45,7 +47,7 @@ export function TotpRemoveDialog({ }; const handleRemove = async () => { - if (!password) { + if (hasPassword && !password) { setError("Password is required to disable TOTP"); return; } @@ -54,7 +56,7 @@ export function TotpRemoveDialog({ setError(null); try { - await api.totp.disable(password); + await api.totp.disable(hasPassword ? password : null); toast({ title: "Two-factor authentication disabled", @@ -80,7 +82,7 @@ export function TotpRemoveDialog({ }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && password) { + if (e.key === "Enter" && (!hasPassword || password)) { handleRemove(); } }; @@ -109,25 +111,30 @@ export function TotpRemoveDialog({
-
- - { - setPassword(e.target.value); - setError(null); - }} - onKeyDown={handleKeyDown} - disabled={isLoading} - autoFocus - /> - {error && ( -

{error}

- )} -
+ {hasPassword && ( +
+ + { + setPassword(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + disabled={isLoading} + autoFocus + /> + {error && ( +

{error}

+ )} +
+ )} + {!hasPassword && error && ( +

{error}

+ )}
+
+ )} + {isSuspended(selectedUser.status) ? (

@@ -532,6 +717,168 @@ export default function AdminUsersPage() {

)} + {/* ── MFA Methods section ────────────────────────────────────────── */} + {selectedUser.id !== currentUser?.id && ( +
+
+

+ + MFA Methods +

+ {userMfaMethods.length > 1 && ( + + )} +
+ + {isMfaLoading ? ( +
+ +
+ ) : userMfaMethods.length === 0 ? ( +

+ No MFA methods configured. +

+ ) : ( +
+ {userMfaMethods.map((method) => ( +
+
+ {method.type === "totp" ? ( + + ) : ( + + )} +
+

{method.name}

+ {method.last_used_at && ( +

+ Last used: {formatDate(method.last_used_at)} +

+ )} +
+
+ +
+ ))} +
+ )} +

+ Remove an MFA method if the user has lost access (e.g. lost phone or passkey). + The user will be able to re-enroll after removal. +

+
+ )} + + {/* ── Linked Accounts section ────────────────────────────────── */} + {selectedUser.id !== currentUser?.id && ( +
+

+ + Linked OAuth Accounts +

+ + {userLinkedAccounts.length === 0 ? ( +

+ No OAuth providers linked. +

+ ) : ( +
+ {userLinkedAccounts.map((account) => { + const isOnlyMethod = totalAuthMethods <= 1; + return ( +
+
+ +
+

{account.provider_type}

+ {account.email && ( +

{account.email}

+ )} + {account.linked_at && ( +

+ Linked: {formatDate(account.linked_at)} +

+ )} +
+
+ +
+ ); + })} +
+ )} +

+ Unlink an OAuth provider to prevent sign-in via that provider. + Cannot unlink if it is the user's only sign-in method. +

+
+ )} + + {/* ── Admin Password Reset section ──────────────────────────── */} + {selectedUser.id !== currentUser?.id && ( +
+

+ + Password +

+

+ Set a new password for this user. Use this when a user is locked out or needs a password added to their account. +

+ +
+ )} + {/* SSH Keys section */}
@@ -680,6 +1027,37 @@ export default function AdminUsersPage() { + {/* ── Remove All MFA confirmation ───────────────────────────────────────── */} + + + + + + Remove all MFA methods? + + + All MFA methods for{" "} + {selectedUser?.full_name || selectedUser?.email} will + be removed. They will be able to re-enroll after this action. Use this + when the user has lost access to their authenticator app or passkey. + + + + + + + + + {/* ── Hard delete confirmation ──────────────────────────────────────────── */} + {/* ── Admin password reset dialog ───────────────────────────────────── */} + { + setShowPasswordReset(open); + if (!open) { setNewPassword(""); setNewPasswordConfirm(""); setPasswordResetError(null); } + }} + > + + + + + Set password for {selectedUser?.email} + + + The user will be able to log in with this password immediately. This does not affect their existing OAuth logins. + + +
+ {passwordResetError && ( +
{passwordResetError}
+ )} +
+ + { setNewPassword(e.target.value); setPasswordResetError(null); }} + disabled={isResettingPassword} + autoFocus + /> +
+
+ + { setNewPasswordConfirm(e.target.value); setPasswordResetError(null); }} + disabled={isResettingPassword} + onKeyDown={(e) => { if (e.key === "Enter" && newPassword && newPasswordConfirm) handlePasswordReset(); }} + /> +
+
+ + + + +
+
); } diff --git a/src/pages/admin/OAuthProvidersPage.tsx b/src/pages/admin/OAuthProvidersPage.tsx index 20be9a9..bec814f 100644 --- a/src/pages/admin/OAuthProvidersPage.tsx +++ b/src/pages/admin/OAuthProvidersPage.tsx @@ -42,18 +42,20 @@ const PROVIDER_LOGOS: Record = { microsoft: "https://www.microsoft.com/favicon.ico", }; +const API_BASE = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1') as string; + const PROVIDER_HELP: Record = { google: { docsUrl: "https://console.cloud.google.com/apis/credentials", - callbackNote: "Authorized redirect URI: http://localhost:5000/api/v1/auth/external/google/callback", + callbackNote: `Authorized redirect URI: ${API_BASE}/auth/external/google/callback`, }, github: { docsUrl: "https://github.com/settings/applications/new", - callbackNote: "Authorization callback URL: http://localhost:5000/api/v1/auth/external/github/callback", + callbackNote: `Authorization callback URL: ${API_BASE}/auth/external/github/callback`, }, microsoft: { docsUrl: "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps", - callbackNote: "Redirect URI: http://localhost:5000/api/v1/auth/external/microsoft/callback", + callbackNote: `Redirect URI: ${API_BASE}/auth/external/microsoft/callback`, }, }; diff --git a/src/pages/auth/ForgotPasswordPage.tsx b/src/pages/auth/ForgotPasswordPage.tsx index 2d8bd89..85f4aef 100644 --- a/src/pages/auth/ForgotPasswordPage.tsx +++ b/src/pages/auth/ForgotPasswordPage.tsx @@ -42,12 +42,6 @@ export default function ForgotPasswordPage() { you'll receive a password reset link shortly.

- -
diff --git a/src/pages/org/MembersPage.tsx b/src/pages/org/MembersPage.tsx index 957d2f6..726bd37 100644 --- a/src/pages/org/MembersPage.tsx +++ b/src/pages/org/MembersPage.tsx @@ -20,6 +20,12 @@ import { XCircle, Crown, Trash2, + ShieldOff, + Link2, + Unlink, + Smartphone, + KeyRound, + Lock, } from "lucide-react"; import { useParams } from "react-router-dom"; import { Button } from "@/components/ui/button"; @@ -60,7 +66,7 @@ import { } from "@/components/ui/sheet"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useToast } from "@/hooks/use-toast"; -import { api, OrganizationMember, ApiError, OrgInvite, SSHKey, User as ApiUser } from "@/lib/api"; +import { api, OrganizationMember, ApiError, OrgInvite, SSHKey, User as ApiUser, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api"; import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; import { useAuth } from "@/contexts/AuthContext"; @@ -85,6 +91,10 @@ function formatDate(d: string | null | undefined) { }); } +function capitalize(s: string) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + function isSuspended(status: string | undefined) { return status === "suspended" || status === "compliance_suspended"; } @@ -133,6 +143,24 @@ export default function MembersPage() { const [userSshKeys, setUserSshKeys] = useState([]); const [isDrawerLoading, setIsDrawerLoading] = useState(false); + // ── MFA management (drawer) ────────────────────────────────────────────────── + const [userMfaMethods, setUserMfaMethods] = useState([]); + const [removingMfaId, setRemovingMfaId] = useState(null); + const [showRemoveAllMfa, setShowRemoveAllMfa] = useState(false); + const [isRemovingAllMfa, setIsRemovingAllMfa] = useState(false); + + // ── Linked OAuth accounts (drawer) ────────────────────────────────────────── + const [userLinkedAccounts, setUserLinkedAccounts] = useState([]); + const [totalAuthMethods, setTotalAuthMethods] = useState(0); + const [unlinkingProvider, setUnlinkingProvider] = useState(null); + + // ── Admin set / change password (drawer) ──────────────────────────────────── + const [adminPwNew, setAdminPwNew] = useState(""); + const [adminPwConfirm, setAdminPwConfirm] = useState(""); + const [adminPwError, setAdminPwError] = useState(null); + const [isSettingPw, setIsSettingPw] = useState(false); + const [adminPwSuccess, setAdminPwSuccess] = useState(false); + // ── Suspend / Unsuspend ────────────────────────────────────────────────────── const [isSuspending, setIsSuspending] = useState(false); const [showSuspendConfirm, setShowSuspendConfirm] = useState(false); @@ -156,13 +184,94 @@ export default function MembersPage() { const [removeMember, setRemoveMember] = useState(null); const [isRemoving, setIsRemoving] = useState(false); + // ── Remove MFA (drawer) ───────────────────────────────────────────────────────── + const handleRemoveMfaMethod = async (method: AdminMfaMethod) => { + if (!selectedMember) return; + setRemovingMfaId(method.id); + try { + const methodType = method.type as 'totp' | 'webauthn'; + const credId = method.type === 'webauthn' ? method.id : undefined; + await api.admin.removeUserMfa(selectedMember.user_id, methodType, credId); + const refreshed = await api.admin.getUserMfa(selectedMember.user_id); + setUserMfaMethods(refreshed.mfa_methods); + toast({ title: 'MFA method removed', description: `${method.name} has been removed for ${selectedMember.user?.email}.` }); + } catch (err) { + toast({ variant: 'destructive', title: 'Failed to remove MFA method', description: err instanceof ApiError ? err.message : 'Something went wrong.' }); + } finally { + setRemovingMfaId(null); + } + }; + + const handleRemoveAllMfa = async () => { + if (!selectedMember) return; + setIsRemovingAllMfa(true); + try { + await api.admin.removeUserMfa(selectedMember.user_id, 'all'); + setUserMfaMethods([]); + setShowRemoveAllMfa(false); + toast({ title: 'All MFA methods removed', description: `All MFA methods for ${selectedMember.user?.email} have been cleared.` }); + } catch (err) { + toast({ variant: 'destructive', title: 'Failed to remove MFA methods', description: err instanceof ApiError ? err.message : 'Something went wrong.' }); + } finally { + setIsRemovingAllMfa(false); + } + }; + + // ── Unlink OAuth provider (drawer) ──────────────────────────────────────────── + const handleUnlinkProvider = async (account: AdminLinkedAccount) => { + if (!selectedMember) return; + setUnlinkingProvider(account.id); + try { + await api.admin.adminUnlinkUserProvider(selectedMember.user_id, account.provider_type); + setUserLinkedAccounts((prev) => prev.filter((a) => a.id !== account.id)); + setTotalAuthMethods((prev) => prev - 1); + toast({ title: 'Provider unlinked', description: `${capitalize(account.provider_type)} has been unlinked from ${selectedMember.user?.email}.` }); + } catch (err) { + toast({ variant: 'destructive', title: 'Failed to unlink provider', description: err instanceof ApiError ? err.message : 'Something went wrong.' }); + } finally { + setUnlinkingProvider(null); + } + }; + + // ── Admin set / change password ────────────────────────────────────────────── + const handleAdminSetPassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedMember) return; + setAdminPwError(null); + setAdminPwSuccess(false); + if (adminPwNew.length < 8) { + setAdminPwError("Password must be at least 8 characters."); + return; + } + if (adminPwNew !== adminPwConfirm) { + setAdminPwError("Passwords do not match."); + return; + } + setIsSettingPw(true); + try { + await api.admin.adminSetUserPassword(selectedMember.user_id, adminPwNew); + setAdminPwNew(""); + setAdminPwConfirm(""); + setAdminPwSuccess(true); + // Refresh detailUser so has_password reflects the new state + const refreshed = await api.admin.getUser(selectedMember.user_id); + setDetailUser(refreshed.user); + toast({ title: detailUser?.has_password ? "Password updated" : "Password set", description: `Password ${detailUser?.has_password ? "changed" : "created"} for ${selectedMember.user?.email}.` }); + } catch (err) { + setAdminPwError(err instanceof ApiError ? err.message : "Failed to update password."); + } finally { + setIsSettingPw(false); + } + }; + + // ── Invite ─────────────────────────────────────────────────────────────────── const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState("member"); const [isInviting, setIsInviting] = useState(false); const [inviteError, setInviteError] = useState(null); - // Invite link dialog (when SMTP not configured) + // Invite link dialog const [inviteLink, setInviteLink] = useState(null); const [inviteLinkEmail, setInviteLinkEmail] = useState(""); const [linkCopied, setLinkCopied] = useState(false); @@ -226,10 +335,24 @@ export default function MembersPage() { setDetailUser(null); setUserSshKeys([]); setIsDrawerLoading(true); + setUserMfaMethods([]); + setUserLinkedAccounts([]); + setTotalAuthMethods(0); try { - const data = await api.admin.getUser(member.user_id); - setDetailUser(data.user); - setUserSshKeys(data.ssh_keys); + const [userData, mfaData, linkedData] = await Promise.allSettled([ + api.admin.getUser(member.user_id), + api.admin.getUserMfa(member.user_id), + api.admin.getUserLinkedAccounts(member.user_id), + ]); + if (userData.status === 'fulfilled') { + setDetailUser(userData.value.user); + setUserSshKeys(userData.value.ssh_keys); + } + if (mfaData.status === 'fulfilled') setUserMfaMethods(mfaData.value.mfa_methods); + if (linkedData.status === 'fulfilled') { + setUserLinkedAccounts(linkedData.value.linked_accounts); + setTotalAuthMethods(linkedData.value.total_auth_methods); + } } catch { // Non-fatal — drawer still shows member info } finally { @@ -241,6 +364,13 @@ export default function MembersPage() { setSelectedMember(null); setDetailUser(null); setUserSshKeys([]); + setUserMfaMethods([]); + setUserLinkedAccounts([]); + setTotalAuthMethods(0); + setAdminPwNew(""); + setAdminPwConfirm(""); + setAdminPwError(null); + setAdminPwSuccess(false); }; // ── Role change (drawer inline select) ────────────────────────────────────── @@ -948,6 +1078,190 @@ export default function MembersPage() {
)} + {/* MFA Methods */} + {selectedMember.user?.id !== currentUser?.id && ( +
+
+

+ + MFA / 2FA Methods +

+ {userMfaMethods.length > 1 && ( + + )} +
+ {isDrawerLoading ? ( +
+ +
+ ) : userMfaMethods.length === 0 ? ( +

No MFA methods configured.

+ ) : ( +
+ {userMfaMethods.map((method) => ( +
+
+ {method.type === "totp" ? ( + + ) : ( + + )} +
+

{method.name}

+ {method.last_used_at && ( +

+ Last used: {formatDate(method.last_used_at)} +

+ )} +
+
+ +
+ ))} +
+ )} +

+ Remove an MFA method if the user has lost access (e.g. lost phone or passkey). They can re-enroll after removal. +

+
+ )} + + {/* Linked OAuth Accounts */} + {selectedMember.user?.id !== currentUser?.id && ( +
+

+ + Linked OAuth Accounts +

+ {isDrawerLoading ? ( +
+ +
+ ) : userLinkedAccounts.length === 0 ? ( +

No OAuth providers linked.

+ ) : ( +
+ {userLinkedAccounts.map((account) => { + const isOnlyMethod = totalAuthMethods <= 1; + return ( +
+
+ +
+

{account.provider_type}

+ {account.email && ( +

{account.email}

+ )} + {account.linked_at && ( +

+ Linked: {formatDate(account.linked_at)} +

+ )} +
+
+ +
+ ); + })} +
+ )} +

+ Unlink a provider to prevent sign-in via that provider. Cannot unlink if it is the user's only sign-in method. +

+
+ )} + + {/* Password Management */} + {selectedMember.user?.id !== currentUser?.id && ( +
+

+ + {detailUser?.has_password ? "Reset Password" : "Set Password"} +

+

+ {detailUser?.has_password + ? "Override this user's current password. They will need to use the new password on next login." + : "This user has no password configured (sign-in via OIDC/OAuth only). Set one to enable email/password login."} +

+
+ { setAdminPwNew(e.target.value); setAdminPwError(null); setAdminPwSuccess(false); }} + disabled={isSettingPw} + autoComplete="new-password" + /> + { setAdminPwConfirm(e.target.value); setAdminPwError(null); setAdminPwSuccess(false); }} + disabled={isSettingPw} + autoComplete="new-password" + /> + {adminPwError && ( +

+ + {adminPwError} +

+ )} + {adminPwSuccess && ( +

+ + Password updated successfully. +

+ )} + +
+
+ )} + {/* SSH Keys */}
@@ -1053,6 +1367,31 @@ export default function MembersPage() { + {/* ── Remove all MFA confirmation dialog ───────────────────────────── */} + + + + + + Remove all MFA methods? + + + This will remove all MFA methods (TOTP and passkeys) for{" "} + {selectedMember?.user?.email}. They will be able to re-enroll after this action. + + + + + + + + + {/* ── Suspend confirmation dialog ───────────────────────────────────────── */} diff --git a/src/pages/org/OIDCClientsPage.tsx b/src/pages/org/OIDCClientsPage.tsx index 4b4424c..33439a2 100644 --- a/src/pages/org/OIDCClientsPage.tsx +++ b/src/pages/org/OIDCClientsPage.tsx @@ -1,8 +1,18 @@ import { useState, useEffect, useRef } from "react"; -import { Plus, Key, MoreHorizontal, Copy, Trash2, Loader2, AlertCircle, CheckCircle } from "lucide-react"; +import { + Plus, Key, MoreHorizontal, Copy, Trash2, Loader2, + AlertCircle, CheckCircle, Network, Terminal, Check, + ChevronDown, Globe, RefreshCw, Info, +} from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; import { DropdownMenu, DropdownMenuContent, @@ -16,53 +26,112 @@ import { DialogDescription, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { api, OIDCClient, OIDCClientWithSecret } from "@/lib/api"; import { useToast } from "@/hooks/use-toast"; import { useOrg } from "@/contexts/OrgContext"; +// Derive issuer base URL from the API base +const ISSUER_URL = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1") + .replace(/\/api\/v1\/?$/, ""); + +function buildProxyConfig(clientId: string, clientSecret: string, proxyHost: string) { + return `provider = "oidc" +oidc_issuer_url = "${ISSUER_URL}" +client_id = "${clientId}" +client_secret = "${clientSecret}" +redirect_url = "http://${proxyHost}/oauth2/callback" +scope = "openid profile email" +cookie_secret = "$(openssl rand -base64 32 | head -c 32)" +cookie_secure = false +upstream = "http://127.0.0.1:8080/" +set_authorization_header = true +set_x_auth_request_header = true`; +} + +function useCopyButton() { + const [copied, setCopied] = useState(false); + const copy = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + return { copied, copy }; +} + +type DialogMode = "generic" | "proxy" | null; + +interface NewSecretState { + clientId: string; + secret: string; + proxyHost?: string; + isProxy: boolean; +} + export default function OIDCClientsPage() { const { toast } = useToast(); const { selectedOrgId: orgId } = useOrg(); + const { copy: copySecret, copied: secretCopied } = useCopyButton(); + const { copy: copyConfig, copied: configCopied } = useCopyButton(); + const [clients, setClients] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [isCreateOpen, setIsCreateOpen] = useState(false); + const [dialogMode, setDialogMode] = useState(null); const [isCreating, setIsCreating] = useState(false); - const [newSecret, setNewSecret] = useState<{ clientId: string; secret: string } | null>(null); + const [newSecret, setNewSecret] = useState(null); + // Generic form const nameRef = useRef(null); const urisRef = useRef(null); - const loadData = (id: string) => { - api.organizations.getClients(id) - .then((data) => setClients(data.clients)) - .catch(() => toast({ title: "Error", description: "Failed to load OIDC clients.", variant: "destructive" })) - .finally(() => setIsLoading(false)); - }; + // Proxy form + const proxyNameRef = useRef(null); + const proxyHostRef = useRef(null); useEffect(() => { if (!orgId) { setIsLoading(false); return; } setIsLoading(true); - loadData(orgId); + api.organizations.getClients(orgId) + .then((data) => setClients(data.clients)) + .catch(() => toast({ title: "Error", description: "Failed to load OIDC clients.", variant: "destructive" })) + .finally(() => setIsLoading(false)); }, [orgId]); const handleCreate = async () => { - if (!orgId || !nameRef.current || !urisRef.current) return; - const name = nameRef.current.value.trim(); - const uris = urisRef.current.value.trim().split(/[\n,]+/).map((u) => u.trim()).filter(Boolean); - if (!name || !uris.length) return; + if (!orgId) return; + + let name: string; + let uris: string[]; + let proxyHost: string | undefined; + + if (dialogMode === "generic") { + name = nameRef.current?.value.trim() ?? ""; + uris = (urisRef.current?.value ?? "").split(/[\n,]+/).map((u) => u.trim()).filter(Boolean); + if (!name || !uris.length) return; + } else { + name = proxyNameRef.current?.value.trim() ?? ""; + proxyHost = proxyHostRef.current?.value.trim() ?? ""; + if (!name || !proxyHost) return; + uris = [`http://${proxyHost}/oauth2/callback`]; + } setIsCreating(true); try { const result = await api.organizations.createClient(orgId, name, uris); const created = result.client as OIDCClientWithSecret; setClients((prev) => [...prev, created]); - setNewSecret({ clientId: created.client_id, secret: created.client_secret }); - setIsCreateOpen(false); + setNewSecret({ + clientId: created.client_id, + secret: created.client_secret, + proxyHost, + isProxy: dialogMode === "proxy", + }); + setDialogMode(null); } catch { toast({ title: "Error", description: "Failed to create client.", variant: "destructive" }); } finally { @@ -75,127 +144,139 @@ export default function OIDCClientsPage() { try { await api.organizations.deleteClient(orgId, clientId); setClients((prev) => prev.filter((c) => c.id !== clientId)); - toast({ title: "Client deleted", description: "OIDC client deactivated successfully." }); + toast({ title: "Client deleted" }); } catch { toast({ title: "Error", description: "Failed to delete client.", variant: "destructive" }); } }; - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text).then(() => - toast({ title: "Copied", description: "Copied to clipboard." }) - ); - }; + const proxyConfig = newSecret?.isProxy && newSecret.proxyHost + ? buildProxyConfig(newSecret.clientId, newSecret.secret, newSecret.proxyHost) + : null; return (
+ {/* Header */}

OIDC Clients

-

- Manage applications that authenticate via Gatehouse -

+

Applications that authenticate via Gatehouse

- - - - - - - Create OIDC Client - - Register a new application to authenticate via Gatehouse - - -
-
- - -
-
- -