From b97937f080e07f2415d08269103877592b877306 Mon Sep 17 00:00:00 2001 From: James Bhattarai Date: Mon, 2 Mar 2026 23:55:47 +0545 Subject: [PATCH] Feat(Fix): Multi Org, Suspension, User Detail Multi Org switch, members suspend/unsuspend status, delete account, next serial, show email in user member search --- src/App.tsx | 15 +- src/components/navigation/AppSidebar.tsx | 1 - src/components/navigation/TopBar.tsx | 20 +- src/contexts/OrgContext.tsx | 76 ++ src/hooks/useCurrentOrganization.ts | 17 +- src/lib/api.ts | 28 + src/pages/admin/AdminUsersPage.tsx | 113 +- src/pages/auth/OIDCLoginPage.tsx | 455 ++++++++ src/pages/org/CAsPage.tsx | 6 +- src/pages/org/CompliancePage.tsx | 15 +- src/pages/org/DepartmentsPage.tsx | 17 +- src/pages/org/MembersPage.tsx | 1202 ++++++++++++++++++---- src/pages/org/OIDCClientsPage.tsx | 16 +- src/pages/org/OrgOverviewPage.tsx | 182 +++- src/pages/org/PoliciesPage.tsx | 17 +- src/pages/user/ProfilePage.tsx | 129 ++- 16 files changed, 2011 insertions(+), 298 deletions(-) create mode 100644 src/contexts/OrgContext.tsx create mode 100644 src/pages/auth/OIDCLoginPage.tsx diff --git a/src/App.tsx b/src/App.tsx index d2e33ff..0596091 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import ResetPasswordPage from "@/pages/auth/ResetPasswordPage"; import InviteAcceptPage from "@/pages/auth/InviteAcceptPage"; import OIDCConsentPage from "@/pages/auth/OIDCConsentPage"; import OIDCErrorPage from "@/pages/auth/OIDCErrorPage"; +import OIDCLoginPage from "@/pages/auth/OIDCLoginPage"; import OAuthCallbackPage from "@/pages/auth/OAuthCallbackPage"; import ActivatePage from "@/pages/auth/ActivatePage"; @@ -40,7 +41,6 @@ import DepartmentsPage from "@/pages/org/DepartmentsPage"; import PrincipalsPage from "@/pages/org/PrincipalsPage"; import MyMembershipsPage from "@/pages/org/MyMembershipsPage"; import SystemAuditPage from "@/pages/admin/SystemAuditPage"; -import AdminUsersPage from "@/pages/admin/AdminUsersPage"; import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage"; import OrgSetupPage from "@/pages/auth/OrgSetupPage"; @@ -76,17 +76,19 @@ const App = () => ( // Separate component so AuthProvider can use useNavigate import { AuthProvider, useAuth } from "@/contexts/AuthContext"; +import { OrgProvider } from "@/contexts/OrgContext"; import { Navigate } from "react-router-dom"; /** Redirects already-authenticated users away from guest-only pages (e.g. /login). */ function GuestRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isOrgMember, isLoading } = useAuth(); - // Allow authenticated users through to /login when it's a CLI auth request — - // LoginPage will immediately forward the existing token to the CLI callback. + // Allow authenticated users through to /login when it's a CLI auth request or + // an OIDC session — LoginPage will immediately forward the existing token. const params = new URLSearchParams(window.location.search); const isCli = params.has('cli_token') || params.has('cli_redirect'); + const isOidcBridge = params.has('oidc_session_id'); if (isLoading) return null; // wait for auth state to resolve - if (isAuthenticated && !isCli) { + if (isAuthenticated && !isCli && !isOidcBridge) { // If the user hasn't set up an org yet, send them there first return ; } @@ -123,6 +125,7 @@ function RequireAuth({ children }: { children: React.ReactNode }) { function AppRoutes() { return ( + {/* Index redirect */} } /> @@ -136,6 +139,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -169,7 +173,7 @@ function AppRoutes() { {/* Admin routes — org admin/owner only */} } /> - } /> + } /> } /> @@ -179,6 +183,7 @@ function AppRoutes() { {/* Dev tools - only shown in development */} + ); } diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index d96fa98..0ba10a0 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -56,7 +56,6 @@ const orgAdminNavItems = [ ]; const adminNavItems = [ - { title: "Users", url: "/admin/users", icon: Users }, { 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 }, diff --git a/src/components/navigation/TopBar.tsx b/src/components/navigation/TopBar.tsx index c48941a..a92393c 100644 --- a/src/components/navigation/TopBar.tsx +++ b/src/components/navigation/TopBar.tsx @@ -1,4 +1,3 @@ -import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Menu, ChevronDown, LogOut, User, Shield, Building2, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -13,28 +12,21 @@ import { } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useAuth } from "@/contexts/AuthContext"; -import { Organization } from "@/lib/api"; +import { useOrg } from "@/contexts/OrgContext"; import { useOrganizations } from "@/hooks/useOrganizations"; import { ComplianceBanner } from "@/components/auth/ComplianceBanner"; export function TopBar() { const navigate = useNavigate(); - const { user, isAuthenticated, mfaCompliance, logout } = useAuth(); - const [currentOrg, setCurrentOrg] = useState(null); - + const { user, mfaCompliance, logout } = useAuth(); + const { selectedOrg, selectOrg } = useOrg(); + // Use React Query hook for organizations with automatic caching and deduplication const { data: organizations = [], isLoading: orgsLoading } = useOrganizations(); // Ensure organizations is always an array (defensive check) const organizationsArray = Array.isArray(organizations) ? organizations : []; - // Set initial currentOrg when organizations are loaded - useEffect(() => { - if (organizationsArray.length > 0 && !currentOrg) { - setCurrentOrg(organizationsArray[0]); - } - }, [organizationsArray, currentOrg]); - const handleLogout = async () => { await logout(); }; @@ -64,7 +56,7 @@ export function TopBar() { - {orgsLoading ? "Loading..." : (currentOrg?.name || "No Organization")} + {orgsLoading ? "Loading..." : (selectedOrg?.name || "No Organization")} @@ -86,7 +78,7 @@ export function TopBar() { organizationsArray.map((org) => ( setCurrentOrg(org)} + onClick={() => selectOrg(org)} className="flex items-center justify-between" >
diff --git a/src/contexts/OrgContext.tsx b/src/contexts/OrgContext.tsx new file mode 100644 index 0000000..3dd3479 --- /dev/null +++ b/src/contexts/OrgContext.tsx @@ -0,0 +1,76 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + ReactNode, +} from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Organization } from "@/lib/api"; +import { useOrganizations } from "@/hooks/useOrganizations"; +import { useAuth } from "@/contexts/AuthContext"; + +interface OrgContextType { + /** The currently selected organisation (null while loading or no memberships). */ + selectedOrg: Organization | null; + /** Programmatically switch the active organisation and invalidate all org-scoped queries. */ + selectOrg: (org: Organization) => void; + /** Convenience accessor for the selected org's ID. */ + selectedOrgId: string | null; +} + +const OrgContext = createContext(null); + +export function OrgProvider({ children }: { children: ReactNode }) { + const { isAuthenticated } = useAuth(); + const { data: organizations = [] } = useOrganizations(); + const queryClient = useQueryClient(); + const [selectedOrg, setSelectedOrg] = useState(null); + + // Auto-select the first org once the list arrives (or when the list changes + // and the previously selected org is no longer present, e.g. after deletion). + useEffect(() => { + if (!isAuthenticated) { + setSelectedOrg(null); + return; + } + if (organizations.length === 0) return; + + setSelectedOrg((prev) => { + // Keep the current selection if it still exists in the updated list. + if (prev && organizations.some((o) => o.id === prev.id)) { + // Refresh the object in case name/role changed. + return organizations.find((o) => o.id === prev.id) ?? prev; + } + return organizations[0]; + }); + }, [organizations, isAuthenticated]); + + const selectOrg = useCallback( + (org: Organization) => { + setSelectedOrg(org); + // Invalidate all organisation-scoped React Query caches so every page + // immediately re-fetches data for the newly selected org. + queryClient.invalidateQueries({ queryKey: ["organizations"] }); + // Invalidate any queries keyed by the previous org id. The broadest + // approach is to remove all non-organisations queries so pages reload. + queryClient.invalidateQueries(); + }, + [queryClient], + ); + + return ( + + {children} + + ); +} + +export function useOrg(): OrgContextType { + const ctx = useContext(OrgContext); + if (!ctx) throw new Error("useOrg must be used inside "); + return ctx; +} diff --git a/src/hooks/useCurrentOrganization.ts b/src/hooks/useCurrentOrganization.ts index 3cec019..1851a14 100644 --- a/src/hooks/useCurrentOrganization.ts +++ b/src/hooks/useCurrentOrganization.ts @@ -1,28 +1,31 @@ import { useParams } from "react-router-dom"; import { useOrganizations } from "@/hooks/useOrganizations"; +import { useOrg } from "@/contexts/OrgContext"; /** - * Custom hook to get the current organization from URL params or first available org. - * This helps with backward compatibility if routes don't include orgId. + * Custom hook to get the current organization from URL params, or the + * globally-selected org from OrgContext (set via the TopBar org switcher). */ export function useCurrentOrganization() { const params = useParams<{ orgId?: string }>(); const { data: organizations = [], isLoading } = useOrganizations(); + const { selectedOrg } = useOrg(); - // If orgId is in params, use that + // If orgId is in the URL params, use that specific org. if (params.orgId) { return { - org: organizations.find((org) => org.id === params.orgId) || organizations[0] || null, + org: organizations.find((org) => org.id === params.orgId) ?? organizations[0] ?? null, isLoading, }; } - // Otherwise, return the first organization (default) - return { org: organizations[0] || null, isLoading }; + // Otherwise use the org selected via the TopBar switcher (falls back to + // organizations[0] when OrgContext hasn't initialised yet). + return { org: selectedOrg ?? organizations[0] ?? null, isLoading }; } /** - * Get the organization ID from URL params or first available org. + * Get the organization ID from URL params or the globally-selected org. * Also returns isLoading so callers can distinguish "no org" from "still loading". */ export function useCurrentOrganizationId(): { orgId: string | null; isLoading: boolean } { diff --git a/src/lib/api.ts b/src/lib/api.ts index ee725e0..08a2c8d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -455,6 +455,10 @@ export const api = { body: JSON.stringify(data), }), + // Delete the current user's own account (soft delete) + deleteMe: (requestConfig?: RequestConfig) => + request<{ message: string }>('/users/me', { method: 'DELETE' }, true, requestConfig), + organizations: (requestConfig?: RequestConfig) => request('/users/me/organizations', {}, true, requestConfig), @@ -548,6 +552,15 @@ export const api = { unsuspendUser: (userId: string, requestConfig?: RequestConfig) => request<{ user: User }>(`/admin/users/${userId}/unsuspend`, { method: 'POST' }, true, requestConfig), + // Permanently delete a user — revokes certs, cascades DB delete, unrecoverable + hardDeleteUser: (userId: string, requestConfig?: RequestConfig) => + request<{ deleted_user_id: string; deleted_user_email: string; ssh_keys_deleted: number; certs_revoked: number }>( + `/admin/users/${userId}/delete`, + { method: 'POST', body: JSON.stringify({ confirm: true }) }, + true, + requestConfig, + ), + // Get the cert policy for a department getDeptCertPolicy: (orgId: string, deptId: string, requestConfig?: RequestConfig) => request<{ cert_policy: DeptCertPolicy }>(`/organizations/${orgId}/departments/${deptId}/cert-policy`, {}, true, requestConfig), @@ -801,6 +814,10 @@ export const api = { getById: (orgId: string, requestConfig?: RequestConfig) => request<{ organization: Organization; member_count: number }>(`/organizations/${orgId}`, {}, true, requestConfig), + // Delete an organization (owner only; must have no other members) + deleteOrganization: (orgId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}`, { method: 'DELETE' }, true, requestConfig), + // Get organization members getMembers: (orgId: string, requestConfig?: RequestConfig) => request<{ members: OrganizationMember[]; count: number }>(`/organizations/${orgId}/members`, {}, true, requestConfig), @@ -976,6 +993,15 @@ export const api = { method: 'POST', }, true, requestConfig), + // Transfer organization ownership to another member + transferOwnership: (orgId: string, newOwnerUserId: string, requestConfig?: RequestConfig) => + request<{ previous_owner: OrganizationMember; new_owner: OrganizationMember }>( + `/organizations/${orgId}/transfer-ownership`, + { method: 'POST', body: JSON.stringify({ new_owner_user_id: newOwnerUserId }) }, + true, + requestConfig, + ), + // List Certificate Authorities for an org getCAs: (orgId: string, requestConfig?: RequestConfig) => request<{ cas: OrgCA[]; count: number }>(`/organizations/${orgId}/cas`, {}, true, requestConfig), @@ -1364,6 +1390,8 @@ export interface OrgCA { total_certs: number; active_certs: number; revoked_certs: number; + /** Next serial number that will be assigned when a certificate is issued. */ + next_serial_number: number | null; created_at: string | null; updated_at: string | null; /** Set when the key was last rotated. */ diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx index 3589da5..8a80188 100644 --- a/src/pages/admin/AdminUsersPage.tsx +++ b/src/pages/admin/AdminUsersPage.tsx @@ -13,6 +13,7 @@ import { Ban, UserCheck, AlertTriangle, + Trash2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -123,6 +124,11 @@ export default function AdminUsersPage() { const [isSuspending, setIsSuspending] = useState(false); const [showSuspendConfirm, setShowSuspendConfirm] = useState(false); + // Hard delete + const [showHardDelete, setShowHardDelete] = useState(false); + const [hardDeleteConfirmEmail, setHardDeleteConfirmEmail] = useState(""); + const [isHardDeleting, setIsHardDeleting] = useState(false); + // ── Fetch users ───────────────────────────────────────────────────────────── const fetchUsers = useCallback(async (q: string, pg: number) => { setIsLoading(true); @@ -233,7 +239,16 @@ export default function AdminUsersPage() { setShowSuspendConfirm(false); toast({ title: "User suspended", description: `${selectedUser.full_name || selectedUser.email} has been suspended.` }); } catch (err) { - toast({ variant: "destructive", title: "Failed to suspend user", description: err instanceof ApiError ? err.message : "Something went wrong" }); + setShowSuspendConfirm(false); + if (err instanceof ApiError && err.type === "OWNER_PROTECTION") { + toast({ + variant: "destructive", + title: "Cannot suspend organization owner", + description: "Transfer ownership to another member before suspending this account.", + }); + } else { + toast({ variant: "destructive", title: "Failed to suspend user", description: err instanceof ApiError ? err.message : "Something went wrong" }); + } } finally { setIsSuspending(false); } @@ -255,6 +270,31 @@ export default function AdminUsersPage() { } }; + // ── Hard delete user ───────────────────────────────────────────────────────── + const handleHardDelete = async () => { + if (!selectedUser) return; + setIsHardDeleting(true); + try { + const result = await api.admin.hardDeleteUser(selectedUser.id); + setUsers((prev) => prev.filter((u) => u.id !== selectedUser.id)); + setTotal((t) => t - 1); + setShowHardDelete(false); + setSelectedUser(null); + toast({ + title: "User permanently deleted", + description: `${result.deleted_user_email} — ${result.certs_revoked} cert(s) revoked, ${result.ssh_keys_deleted} key(s) deleted.`, + }); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to delete user", + description: err instanceof ApiError ? err.message : "Something went wrong", + }); + } finally { + setIsHardDeleting(false); + } + }; + // Filter by role client-side const filteredUsers = users.filter((u) => { if (roleFilter === "all") return true; @@ -540,6 +580,28 @@ export default function AdminUsersPage() {
)} + + {/* Danger zone — Hard delete */} + {selectedUser.id !== currentUser?.id && ( +
+

+ + Danger Zone +

+

+ Permanently delete this account. This cannot be undone — all SSH keys and certificates will be revoked immediately. +

+ +
+ )} )} @@ -617,6 +679,55 @@ export default function AdminUsersPage() { + + {/* ── Hard delete confirmation ──────────────────────────────────────────── */} + { setShowHardDelete(open); if (!open) setHardDeleteConfirmEmail(""); }} + > + + + + + Permanently delete account? + + + This will permanently delete{" "} + {selectedUser?.full_name || selectedUser?.email}, + revoke all their SSH certificates, and remove all their SSH keys. This action cannot be undone. + + +
+ + setHardDeleteConfirmEmail(e.target.value)} + placeholder={selectedUser?.email ?? ""} + disabled={isHardDeleting} + className="font-mono" + /> +
+ + + + +
+
); } diff --git a/src/pages/auth/OIDCLoginPage.tsx b/src/pages/auth/OIDCLoginPage.tsx new file mode 100644 index 0000000..4f8434c --- /dev/null +++ b/src/pages/auth/OIDCLoginPage.tsx @@ -0,0 +1,455 @@ +/** + * OIDCLoginPage — Standalone OIDC proxy login UI + * + * Unified entry point for OIDC authorization flows via the Gatehouse OIDC bridge. + * Handles: + * 1. Unauthenticated users → shows an email/password login form + * 2. Already-authenticated users → shows a consent/approval screen directly + * + * Route: /oidc-login?oidc_session_id= + * + * Configure your oauth2-proxy / OIDC client's login_url to: + * https:///oidc-login + */ +import { useState, useEffect, useCallback } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { + Shield, + Mail, + Lock, + ArrowRight, + Loader2, + XCircle, + CheckCircle, + AlertTriangle, + User, + Building2, + Key, + LogOut, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { useAuth } from "@/contexts/AuthContext"; +import { ApiError, tokenManager } from "@/lib/api"; + +// ── Configuration ───────────────────────────────────────────────────────────── +const GATEHOUSE_OIDC = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1") + .replace(/\/api\/v1\/?$/, ""); + +// ── Scope display metadata ──────────────────────────────────────────────────── +const SCOPE_META: Record = { + openid: { icon: Shield, label: "Identity", description: "Verify your identity" }, + profile: { icon: User, label: "Profile", description: "Your name and username" }, + email: { icon: Mail, label: "Email", description: "Your email address" }, + groups: { icon: Building2, label: "Groups", description: "Your organization memberships" }, + roles: { icon: Shield, label: "Roles", description: "Your roles in the organization" }, + offline_access: { icon: Key, label: "Offline Access", description: "Access data while you're away" }, +}; + +// ── Types ───────────────────────────────────────────────────────────────────── +interface OIDCContext { + oidc_session_id: string; + client_name: string; + scopes: string[]; + redirect_uri: string; +} + +type PageStep = "loading" | "login" | "consent" | "error"; + +// ── API helpers ─────────────────────────────────────────────────────────────── +async function fetchOIDCContext(oidcSessionId: string): Promise { + const res = await fetch(`${GATEHOUSE_OIDC}/oidc/begin`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ oidc_session_id: oidcSessionId }), + }); + const body = await res.json(); + if (!res.ok || !body.success) { + throw new Error(body.message || "Failed to load authorization context."); + } + return body.data as OIDCContext; +} + +async function completeOIDCFlow(oidcSessionId: string, token: string): Promise { + const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), + }); + const body = await res.json(); + if (!res.ok || !body.success) { + throw new Error(body.message || "Authorization failed."); + } + return body.data.redirect_url as string; +} + +// ── Main component ──────────────────────────────────────────────────────────── +export default function OIDCLoginPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { user, isLoading: authLoading, login, logout } = useAuth(); + + const oidcSessionId = searchParams.get("oidc_session_id"); + + const [step, setStep] = useState("loading"); + const [context, setContext] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [isCompleting, setIsCompleting] = useState(false); + + // Login form state + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [loginError, setLoginError] = useState(null); + + // ── Load OIDC context on mount ───────────────────────────────────────────── + useEffect(() => { + if (!oidcSessionId) { + setErrorMsg("Missing oidc_session_id. This page must be accessed from an OIDC authorization flow."); + setStep("error"); + return; + } + + fetchOIDCContext(oidcSessionId) + .then((ctx) => { + setContext(ctx); + // Determine initial step once we have context and auth state is known + }) + .catch((err: Error) => { + setErrorMsg(err.message); + setStep("error"); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [oidcSessionId]); + + // ── Determine step once both context and auth state are ready ─────────────── + useEffect(() => { + if (authLoading || !context || step !== "loading") return; + const token = tokenManager.getToken(); + setStep(token ? "consent" : "login"); + }, [authLoading, context, step]); + + // ── Complete OIDC flow ────────────────────────────────────────────────────── + const handleComplete = useCallback(async () => { + if (!context) return; + const token = tokenManager.getToken(); + if (!token) { + setStep("login"); + return; + } + setIsCompleting(true); + try { + const redirectUrl = await completeOIDCFlow(context.oidc_session_id, token); + window.location.href = redirectUrl; + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : "Could not complete authorization."); + setStep("error"); + } finally { + setIsCompleting(false); + } + }, [context]); + + // ── Login form submit ─────────────────────────────────────────────────────── + const handleLoginSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!context) return; + setLoginError(null); + setIsSubmitting(true); + + try { + // skipNavigate=true so we control post-login navigation + const result = await login(email, password, false, true); + if (result.requiresTotp || result.requiresWebAuthn) { + // MFA required — hand off to main login page which already handles this + navigate(`/login?oidc_session_id=${context.oidc_session_id}`); + return; + } + // Login succeeded — move to consent step + setStep("consent"); + } catch (err) { + const message = + err instanceof ApiError ? err.message + : err instanceof Error ? err.message + : "Invalid email or password."; + setLoginError(message); + } finally { + setIsSubmitting(false); + } + }; + + // ── Deny / cancel ─────────────────────────────────────────────────────────── + const handleDeny = () => { + if (context?.redirect_uri) { + window.location.href = `${context.redirect_uri}?error=access_denied&error_description=User+denied+access`; + } else { + navigate("/"); + } + }; + + // ── Switch account ────────────────────────────────────────────────────────── + const handleSwitchAccount = async () => { + await logout(); + setStep("login"); + setEmail(""); + setPassword(""); + setLoginError(null); + }; + + // ── Render: loading ────────────────────────────────────────────────────────── + if (step === "loading") { + return ( +
+ +

Loading authorization request…

+
+ ); + } + + // ── Render: error ───────────────────────────────────────────────────────────── + if (step === "error") { + return ( +
+
+ +
+

Authorization Error

+

{errorMsg}

+ {context?.redirect_uri && ( + + )} + +
+ ); + } + + // ── Render: login form ──────────────────────────────────────────────────────── + if (step === "login") { + return ( +
+ {/* Header */} +
+
+ +
+
+

+ Sign in to continue +

+ {context && ( +

+ {context.client_name} + {" "}is requesting access to your account +

+ )} +
+
+ + {/* Requested scopes preview */} + {context && context.scopes.length > 0 && ( + +

This application will access:

+
+ {context.scopes.map((scope) => { + const meta = SCOPE_META[scope]; + return ( + + {meta?.label ?? scope} + + ); + })} +
+
+ )} + + {/* Login form */} +
+ {loginError && ( +
+ +

{loginError}

+
+ )} + +
+ +
+ + setEmail(e.target.value)} + autoComplete="email" + autoFocus + required + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+
+ + +
+ + + +
+

+ Need an account?{" "} + Register here +

+

+ Forgot your password?{" "} + Reset it +

+
+ +
+ +
+
+ ); + } + + // ── Render: consent screen (user is authenticated) ──────────────────────────── + if (step === "consent" && context) { + return ( +
+ {/* Header */} +
+
+ +
+
+

+ Authorize {context.client_name} +

+

+ This application is requesting access to your account +

+
+
+ + {/* Signed-in-as banner */} + {user && ( + +
+
+ +
+
+

{user.email}

+

Signed in

+
+
+ +
+ )} + + {/* Requested permissions */} + +

+ {context.client_name} is requesting: +

+
    + {context.scopes.map((scope) => { + const meta = SCOPE_META[scope]; + const Icon = meta?.icon ?? Key; + return ( +
  • +
    + +
    +
    +

    + {meta?.label ?? scope} +

    +

    + {meta?.description ?? scope} +

    +
    +
  • + ); + })} +
+
+ + {/* Action buttons */} +
+ + +
+ +

+ By allowing, you agree to share the above information with{" "} + {context.client_name}. +

+
+ ); + } + + return null; +} diff --git a/src/pages/org/CAsPage.tsx b/src/pages/org/CAsPage.tsx index a62f641..ddfe44f 100644 --- a/src/pages/org/CAsPage.tsx +++ b/src/pages/org/CAsPage.tsx @@ -128,7 +128,7 @@ function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardProps) { {/* Stats — hidden for system CAs (we have no cert records for them) */} {!isSystem && ( -
+

{ca.active_certs}

Active certs

@@ -141,6 +141,10 @@ function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardProps) {

{ca.default_cert_validity_hours}h

Default validity

+
+

{ca.next_serial_number ?? '—'}

+

Next serial

+
)} diff --git a/src/pages/org/CompliancePage.tsx b/src/pages/org/CompliancePage.tsx index 9138c14..e9b069b 100644 --- a/src/pages/org/CompliancePage.tsx +++ b/src/pages/org/CompliancePage.tsx @@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { api, OrgComplianceMember, create403Handler } from "@/lib/api"; import { useQuery, useMutation } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; -import { useOrganizations } from "@/hooks/useOrganizations"; +import { useOrg } from "@/contexts/OrgContext"; const STATUS_CONFIG: Record = { compliant: { @@ -47,19 +47,10 @@ const STATUS_CONFIG: Record(null); + const { selectedOrgId: currentOrgId } = useOrg(); const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); - // Fetch organizations to get current org - const { data: organizations, isLoading: orgsLoading } = useOrganizations(); - - useEffect(() => { - if (organizations && organizations.length > 0) { - setCurrentOrgId(organizations[0].id); - } - }, [organizations]); - // Fetch compliance data const { data: complianceData, isLoading: complianceLoading } = useQuery({ queryKey: ['org-compliance', currentOrgId], @@ -101,7 +92,7 @@ export default function CompliancePage() { suspended: complianceData?.members?.filter(m => m.status === 'suspended').length || 0, }; - if (orgsLoading || complianceLoading) { + if (complianceLoading) { return (
diff --git a/src/pages/org/DepartmentsPage.tsx b/src/pages/org/DepartmentsPage.tsx index bddd25b..72b5eef 100644 --- a/src/pages/org/DepartmentsPage.tsx +++ b/src/pages/org/DepartmentsPage.tsx @@ -344,11 +344,18 @@ function DepartmentMembersPanel({ orgId, deptId }: { orgId: string; deptId: stri className="flex-1 h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" > - {available.map((m) => ( - - ))} + {available.map((m) => { + const email = m.user?.email || ""; + const name = m.user?.full_name; + const label = name && email + ? `${name} (${email})` + : email || m.user_id; + return ( + + ); + })}
- {/* Actions — hide for self and for owners (can't modify owner here) */} - {member.user?.id !== currentUser?.id && (member.role || "").toLowerCase() !== "owner" && ( - - - - - - { - setChangeRoleMember(member); - setNewRole((member.role || "member").toLowerCase()); - }} + {/* Row action menu — hide for owners and self */} + {!isSelf && !isOwner && ( + + e.stopPropagation()} > - - Change role - - - handleRemoveMember(member)} - > - Remove member - - - - )} -
- ))} + + + + { + e.stopPropagation(); + setChangeRoleMember(member); + setNewRole((member.role || "member").toLowerCase()); + }} + > + + Change role + + + { + e.stopPropagation(); + setRemoveMember(member); + }} + > + Remove member + + + + )} + + + ); + })}
)}
+ {/* ── Invitations tab ─────────────────────────────────────────────────── */}
+ {/* Pending invites list */}

Pending invitations

- {isInvitesLoading ? 'Loading...' : `${invites.length}`} + + {isInvitesLoading ? "Loading…" : invites.length} +
{isInvitesLoading ? ( -
Loading invites...
+
Loading invites…
) : invites.length === 0 ? (
No pending invitations
) : ( @@ -346,25 +672,36 @@ export default function MembersPage() {
{inv.email}
-
Role: {inv.role} • Expires: {new Date(inv.expires_at).toLocaleString()}
-
-
- +
+ Role: {inv.role} · Expires: {new Date(inv.expires_at).toLocaleString()} +
+
))}
@@ -372,13 +709,12 @@ export default function MembersPage() { + {/* Invite form */} -
-
- -

Send an invitation

-
+
+ +

Send an invitation

@@ -405,7 +741,11 @@ export default function MembersPage() { {inviteError && (

{inviteError}

)} - +
+ ) : ( +
+

+ Suspending blocks this user from requesting SSH certificates. +

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

+ + Organization Role +

+ {(selectedMember.role || "").toLowerCase() === "owner" ? ( +

+ Owner role cannot be changed here. Transfer ownership from organization settings. +

+ ) : ( +
+ + {isUpdatingRole && } +
+ )} +
+ )} + + {/* Transfer Ownership — shown when current user is owner and target is not */} + {selectedMember.user?.id !== currentUser?.id && + (selectedMember.role || "").toLowerCase() !== "owner" && + members.some( + (m) => m.user?.id === currentUser?.id && (m.role || "").toLowerCase() === "owner" + ) && ( +
+

+ + Transfer Ownership +

+

+ Make this member the new organization owner. You will be demoted to admin. +

+ +
+ )} + + {/* Danger zone — Hard delete */} + {selectedMember.user?.id !== currentUser?.id && ( +
+

+ + Danger Zone +

+

+ Permanently delete this user account. This cannot be undone — all SSH keys and certificates will be revoked. +

+ +
+ )} + + {/* SSH Keys */} +
+
+

+ + SSH Keys +

+ +
+ {userSshKeys.length === 0 ? ( +
+ No SSH keys registered +
+ ) : ( +
+ {userSshKeys.map((k) => ( +
+
+ + {k.description || No description} + + {k.verified ? ( + + Verified + + ) : ( + + Unverified + + )} +
+
+ {k.fingerprint ?? (k.public_key.slice(0, 64) + "…")} +
+
+ Added {formatDate(k.created_at)} +
+
+ ))} +
+ )} +
+ + )} + + )} + + + + {/* ── Admin add SSH key dialog ──────────────────────────────────────────── */} + { + setShowAddKey(open); + if (!open) { setAddKeyError(null); setAddKeyPublicKey(""); setAddKeyDescription(""); } + }} + > + - - - Share this invite link - + Add SSH Key for {selectedMember?.user?.email} - Email delivery is not configured. Share this link directly with {inviteLinkEmail}. + Add an SSH public key on behalf of this user (admin action). -
-
- {inviteLink} - +
+ {addKeyError && ( +
{addKeyError}
+ )} +
+ +