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 ──────────────────────────────────────────── */}
+
);
}
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 */}
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // ── 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 (
+
+ );
+ })}