From 56cdcd38b55a79f91755cd5503e36c373ead2d90 Mon Sep 17 00:00:00 2001 From: Niranjan Kumar Date: Thu, 25 Jun 2026 13:19:26 +0530 Subject: [PATCH] implement missing platform and tenant admin dashboard flows --- .../(auth)/oauth/[provider]/callback/page.tsx | 274 +-- src/app/(dashboard)/platform/layout.tsx | 224 ++- .../platform/oidc-clients/page.tsx | 579 +++++++ .../platform/service-keys/page.tsx | 835 +++++---- .../platform/tenants/[id]/page.tsx | 829 +++++---- .../tenant/communications/page.tsx | 1543 +++++++++++------ src/app/(dashboard)/tenant/roles/page.tsx | 878 ++++++---- src/app/(dashboard)/tenant/settings/page.tsx | 659 ++++--- src/app/(dashboard)/tenant/social/page.tsx | 4 +- src/app/(dashboard)/tenant/users/page.tsx | 1157 +++++++----- src/lib/types.ts | 365 ++-- 11 files changed, 4802 insertions(+), 2545 deletions(-) create mode 100644 src/app/(dashboard)/platform/oidc-clients/page.tsx diff --git a/src/app/(auth)/oauth/[provider]/callback/page.tsx b/src/app/(auth)/oauth/[provider]/callback/page.tsx index e90ac68..4ca0cec 100644 --- a/src/app/(auth)/oauth/[provider]/callback/page.tsx +++ b/src/app/(auth)/oauth/[provider]/callback/page.tsx @@ -11,148 +11,150 @@ import { toast } from "sonner"; import { use } from "react"; import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import Link from "next/link"; interface OAuthCallbackParams { - params: Promise<{ - provider: string; - }> + params: Promise<{ + provider: string; + }>; } export default function OAuthCallbackPage({ params }: OAuthCallbackParams) { - const router = useRouter(); - const searchParams = useSearchParams(); - const { provider } = use(params); - - const code = searchParams.get("code"); - const state = searchParams.get("state"); - const errorParam = searchParams.get("error"); - const errorDescription = searchParams.get("error_description"); - - const { setTokens, setUser } = useAuthStore(); - const handledRef = useRef(false); - - const { data, error, isPending } = useQuery({ - queryKey: ["oauthCallback", provider, code, state], - queryFn: async () => { - // If the provider returned an error, throw it immediately so the query fails - if (errorParam) { - throw new Error(errorDescription || errorParam); - } - - if (!code || !state) { - throw new Error("Missing authorization code or state token"); - } - - // 1. Exchange the code/state for auth tokens - const { data: authData } = await apiClient.get( - `/auth/oauth/${provider}/callback?code=${code}&state=${state}` - ); - - // 2. Auto-fetch the user details - const { data: userData } = await apiClient.get("/me/", { - headers: { - Authorization: `Bearer ${authData.access_token}`, - }, - }); - - return { - tokens: authData, - user: userData, - isNewUser: authData.is_new_user, - }; - }, - // Only run if we actually have the required callback params OR an error from the provider - enabled: !!provider && (!!(code && state) || !!errorParam), - retry: false, - }); - - useEffect(() => { - if (!data || handledRef.current) return; - handledRef.current = true; - - setTokens(data.tokens.access_token, data.tokens.refresh_token); - setUser(data.user); - - if (data.isNewUser) { - toast.success("Account created successfully!"); - } else { - toast.success("Successfully logged in!"); - } - - router.push("/me"); - }, [data, router, setTokens, setUser]); - - // If no params and no error, this page shouldn't really be visited directly - if (!code && !state && !errorParam) { - return ( -
- - -
- -
- Invalid Request - - Missing OAuth callback parameters. - -
- - - -
-
- ); - } - - return ( -
- - - {isPending && ( -
- -
- )} - {error && ( -
- -
- )} - - - {isPending && "Authenticating..."} - {error && "Authentication Failed"} - {data && "Success!"} - - - {isPending && "Please wait while we securely log you in."} - {error && - getApiErrorMessage( - error, - "Failed to authenticate with provider. Please try again." - )} - {data && "You have been securely signed in. Redirecting..."} - -
- - {error && ( - - - - )} -
-
- ); + const router = useRouter(); + const searchParams = useSearchParams(); + const { provider } = use(params); + + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const errorParam = searchParams.get("error"); + const errorDescription = searchParams.get("error_description"); + + const { setTokens, setUser } = useAuthStore(); + const handledRef = useRef(false); + + const { data, error, isPending } = useQuery({ + queryKey: ["oauthCallback", provider, code, state], + queryFn: async () => { + // If the provider returned an error, throw it immediately so the query fails + if (errorParam) { + throw new Error(errorDescription || errorParam); + } + + if (!code || !state) { + throw new Error("Missing authorization code or state token"); + } + + // 1. Exchange the code/state for auth tokens + const { data: authData } = await apiClient.get( + `/auth/oauth/${provider}/callback?code=${code}&state=${state}`, + ); + + // 2. Auto-fetch the user details + const { data: userData } = await apiClient.get("/me/", { + headers: { + Authorization: `Bearer ${authData.access_token}`, + }, + }); + + return { + tokens: authData, + user: userData, + isNewUser: authData.is_new_user, + }; + }, + // Only run if we actually have the required callback params OR an error from the provider + enabled: !!provider && (!!(code && state) || !!errorParam), + retry: false, + }); + + useEffect(() => { + if (!data || handledRef.current) return; + handledRef.current = true; + + setTokens(data.tokens.access_token, data.tokens.refresh_token); + setUser(data.user); + + if (data.isNewUser) { + toast.success("Account created successfully!"); + } else { + toast.success("Successfully logged in!"); + } + + router.push("/tenant"); + }, [data, router, setTokens, setUser]); + + // If no params and no error, this page shouldn't really be visited directly + if (!code && !state && !errorParam) { + return ( +
+ + +
+ +
+ + Invalid Request + + + Missing OAuth callback parameters. + +
+ + + +
+
+ ); + } + + return ( +
+ + + {isPending && ( +
+ +
+ )} + {error && ( +
+ +
+ )} + + + {isPending && "Authenticating..."} + {error && "Authentication Failed"} + {data && "Success!"} + + + {isPending && "Please wait while we securely log you in."} + {error && + getApiErrorMessage( + error, + "Failed to authenticate with provider. Please try again.", + )} + {data && "You have been securely signed in. Redirecting..."} + +
+ + {error && ( + + + + )} +
+
+ ); } diff --git a/src/app/(dashboard)/platform/layout.tsx b/src/app/(dashboard)/platform/layout.tsx index aa3ce6c..740c83f 100644 --- a/src/app/(dashboard)/platform/layout.tsx +++ b/src/app/(dashboard)/platform/layout.tsx @@ -4,113 +4,149 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; import { - LineChart, - Building2, - Users2, - ShieldAlert, - ScrollText, - ChevronRight, - Key, - Inbox, + LineChart, + Building2, + Users2, + ShieldAlert, + ScrollText, + ChevronRight, + Key, + Inbox, + Fingerprint, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/stores/auth-store"; import { Badge } from "@/components/ui/badge"; const navItems = [ - { - title: "Platform Overview", - href: "/platform", - icon: LineChart, - }, - { - title: "Tenants Directory", - href: "/platform/tenants", - icon: Building2, - }, - { - title: "Global Users", - href: "/platform/users", - icon: Users2, - }, - { - title: "System Roles", - href: "/platform/roles", - icon: ShieldAlert, - }, - { - title: "Service Keys", - href: "/platform/service-keys", - icon: Key, - }, - { - title: "Contact Leads", - href: "/platform/leads", - icon: Inbox, - }, - { - title: "Audit Explorer", - href: "/platform/audit", - icon: ScrollText, - }, + { + title: "Platform Overview", + href: "/platform", + icon: LineChart, + }, + { + title: "Tenants Directory", + href: "/platform/tenants", + icon: Building2, + }, + { + title: "Global Users", + href: "/platform/users", + icon: Users2, + }, + { + title: "System Roles", + href: "/platform/roles", + icon: ShieldAlert, + }, + { + title: "Service Keys", + href: "/platform/service-keys", + icon: Key, + }, + { + title: "OIDC Clients", + href: "/platform/oidc-clients", + icon: Fingerprint, + }, + { + title: "Contact Leads", + href: "/platform/leads", + icon: Inbox, + }, + { + title: "Audit Explorer", + href: "/platform/audit", + icon: ScrollText, + }, ]; export default function PlatformLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const pathname = usePathname(); - const router = useRouter(); - const { user } = useAuthStore(); + const pathname = usePathname(); + const router = useRouter(); + const { user } = useAuthStore(); - const hasPlatformScope = - user?.roles?.some((assignment) => assignment.role.scope === "PLATFORM") ?? false; + const hasPlatformScope = + user?.roles?.some((assignment) => assignment.role.scope === "PLATFORM") ?? + false; + const canManageOidcClients = + user?.roles?.some((assignment) => + assignment.role.permissions?.some( + (permission) => permission.name === "platform.tenants.manage", + ), + ) ?? false; + const visibleNavItems = navItems.filter( + (item) => item.href !== "/platform/oidc-clients" || canManageOidcClients, + ); - useEffect(() => { - if (!hasPlatformScope) { - router.replace("/tenant"); - } - }, [hasPlatformScope, router]); + useEffect(() => { + if (!hasPlatformScope) { + router.replace("/tenant"); + return; + } - return ( -
- {/* Sidebar Navigation */} - + if ( + pathname.startsWith("/platform/oidc-clients") && + !canManageOidcClients + ) { + router.replace("/platform"); + } + }, [canManageOidcClients, hasPlatformScope, pathname, router]); - {/* Content Area */} -
-
- {children} -
-
-
- ); + return ( +
+ {/* Sidebar Navigation */} + + + {/* Content Area */} +
+
+ {children} +
+
+
+ ); } diff --git a/src/app/(dashboard)/platform/oidc-clients/page.tsx b/src/app/(dashboard)/platform/oidc-clients/page.tsx new file mode 100644 index 0000000..aa0f99f --- /dev/null +++ b/src/app/(dashboard)/platform/oidc-clients/page.tsx @@ -0,0 +1,579 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { + AlertTriangle, + CheckCircle2, + Copy, + ExternalLink, + Fingerprint, + Loader2, + Plus, +} from "lucide-react"; +import { toast } from "sonner"; + +import { useRouter } from "next/navigation"; + +import { apiClient } from "@/lib/api-client"; +import { getApiErrorMessage } from "@/lib/errors"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + 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 { useAuthStore } from "@/stores/auth-store"; + +interface OidcRegistrationPayload { + client_name: string; + redirect_uris: string[]; + token_endpoint_auth_method: + | "client_secret_basic" + | "client_secret_post" + | "private_key_jwt"; + jwks_uri?: string | null; + subject_type: "public" | "pairwise"; + sector_identifier_uri?: string | null; +} + +interface OidcRegistrationResponse { + client_id: string; + client_secret?: string | null; + client_id_issued_at: number; + client_secret_expires_at?: number | null; + client_name?: string | null; + redirect_uris: string[]; + response_types?: string[] | null; + grant_types?: string[] | null; + token_endpoint_auth_method?: string | null; + jwks_uri?: string | null; + subject_type?: string | null; + sector_identifier_uri?: string | null; +} + +const DISCOVERY_PATH = "/.well-known/openid-configuration"; + +export default function PlatformOidcClientsPage() { + const router = useRouter(); + const { user } = useAuthStore(); + const canManageOidcClients = + user?.roles?.some((assignment) => + assignment.role.permissions?.some( + (permission) => permission.name === "platform.tenants.manage", + ), + ) ?? false; + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [showSecrets, setShowSecrets] = useState(true); + const [createdClient, setCreatedClient] = + useState(null); + const [form, setForm] = useState({ + client_name: "", + redirect_uris: "", + token_endpoint_auth_method: + "client_secret_basic" as OidcRegistrationPayload["token_endpoint_auth_method"], + jwks_uri: "", + subject_type: "public" as OidcRegistrationPayload["subject_type"], + sector_identifier_uri: "", + }); + + const parsedRedirectUris = useMemo( + () => + form.redirect_uris + .split("\n") + .map((value) => value.trim()) + .filter(Boolean), + [form.redirect_uris], + ); + + useEffect(() => { + if (!canManageOidcClients) { + router.replace("/platform"); + } + }, [canManageOidcClients, router]); + + const createMutation = useMutation({ + mutationFn: async (payload: OidcRegistrationPayload) => { + const { data } = await apiClient.post( + "/oidc/register", + payload, + ); + return data; + }, + onSuccess: (data) => { + setCreatedClient(data); + setShowSecrets(true); + toast.success( + "OIDC client registered. Copy the credentials now — the secret may not be shown again.", + ); + }, + onError: (error: unknown) => { + toast.error(getApiErrorMessage(error, "Failed to register OIDC client")); + }, + }); + + const resetForm = () => { + setForm({ + client_name: "", + redirect_uris: "", + token_endpoint_auth_method: "client_secret_basic", + jwks_uri: "", + subject_type: "public", + sector_identifier_uri: "", + }); + setCreatedClient(null); + setShowSecrets(true); + }; + + const copyToClipboard = async (value: string, label: string) => { + await navigator.clipboard.writeText(value); + toast.success(`${label} copied`); + }; + + const handleCreate = () => { + createMutation.mutate({ + client_name: form.client_name.trim(), + redirect_uris: parsedRedirectUris, + token_endpoint_auth_method: form.token_endpoint_auth_method, + jwks_uri: + form.token_endpoint_auth_method === "private_key_jwt" + ? form.jwks_uri.trim() || null + : null, + subject_type: form.subject_type, + sector_identifier_uri: + form.subject_type === "pairwise" + ? form.sector_identifier_uri.trim() || null + : null, + }); + }; + + if (!canManageOidcClients) { + return null; + } + + return ( +
+
+
+

OIDC Clients

+

+ Register relying-party applications that will use AuthEngine as an + OpenID Connect provider. This UI uses the backend dynamic + registration endpoint and is intended for platform operators. +

+
+ + { + setIsCreateOpen(open); + if (!open) resetForm(); + }} + > + + + + + + + {createdClient + ? "OIDC client registered" + : "Register a new OIDC client"} + + + {createdClient + ? "Copy these credentials now and store them securely." + : "Create client credentials for a relying-party application that will redirect users to AuthEngine."} + + + + {createdClient ? ( +
+
+ +
+

+ Save the secret now +

+

+ The backend only returns the raw client secret at + registration time. Store it in your application secrets + manager before closing this dialog. +

+
+
+ + + + + {createdClient.client_secret ? ( +
+ +
+ ) : null} + +
+ + +
+ +
+ +
+ {createdClient.redirect_uris.map((uri) => ( +
+ {uri} +
+ ))} +
+
+ + {createdClient.jwks_uri ? ( + + ) : null} + {createdClient.sector_identifier_uri ? ( + + ) : null} +
+ ) : ( +
+
+ + + setForm((current) => ({ + ...current, + client_name: event.target.value, + })) + } + /> +
+ +
+ +