-
-
-
+ {/* Show new client secret once */}
+ {newSecret && (
+
+
+
+
+
Client created — save your secret now
+
This secret will not be shown again.
+
+ {newSecret.secret}
+ copyToClipboard(newSecret.secret)}>
+
+
+
+
+ setNewSecret(null)}>×
+
+
+ )}
+
+ {isLoading ? (
+
+
+
+ ) : clients.length === 0 ? (
+
+
+
+ No OIDC clients configured yet.
+ setIsCreateOpen(true)}>
+
+ Add your first client
+
+
+
+ ) : (
+
+ {clients.map((client) => (
+
+
+
+
+
+
+
+
+
{client.name}
+
+
+ {client.client_id}
+
+ copyToClipboard(client.client_id)}>
+
+
+
+
+ {(client.scopes ?? []).map((scope) => (
+
+ {scope}
+
+ ))}
+
+
-
-
{client.name}
-
-
- {client.clientId}
-
-
-
+
+
+
+
-
-
- {client.scopes.map((scope) => (
-
- {scope}
-
- ))}
-
+
+
+
+ handleDelete(client.id)}
+ >
+
+ Delete client
+
+
+
+
+
+
+ Created {new Date(client.created_at).toLocaleDateString()}
+
+
+ {(client.redirect_uris ?? []).length} redirect URI{(client.redirect_uris ?? []).length !== 1 ? "s" : ""}
-
-
-
-
-
-
-
-
-
- View details
-
-
-
- Rotate secret
-
-
-
-
- Delete client
-
-
-
-
-
-
- Created {client.createdAt}
- •
- Last used {client.lastUsed}
-
-
- {client.redirectUris.length} redirect URI{client.redirectUris.length > 1 ? "s" : ""}
-
-
-
-
- ))}
-
+
+
+ ))}
+
+ )}
);
}
diff --git a/src/pages/org/OrgAuditPage.tsx b/src/pages/org/OrgAuditPage.tsx
index 2105d69..a62bee4 100644
--- a/src/pages/org/OrgAuditPage.tsx
+++ b/src/pages/org/OrgAuditPage.tsx
@@ -1,90 +1,77 @@
-import { useState } from "react";
-import { Search, Filter, Download, User, Settings, Key, UserPlus, AlertTriangle } from "lucide-react";
+import { useState, useEffect, useCallback } from "react";
+import { Search, Filter, Download, User, Settings, Key, UserPlus, AlertTriangle, Loader2 } from "lucide-react";
+import { useParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { api, AuditLogEntry } from "@/lib/api";
+import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
-const auditEvents = [
- {
- id: "1",
- type: "member_invited",
- actor: "John Doe",
- target: "alice@example.com",
- timestamp: "2024-01-15T10:30:00Z",
- details: "Invited as member",
- },
- {
- id: "2",
- type: "policy_changed",
- actor: "John Doe",
- target: "Password Policy",
- timestamp: "2024-01-15T09:00:00Z",
- details: "Minimum length changed from 8 to 12",
- },
- {
- id: "3",
- type: "member_disabled",
- actor: "Jane Smith",
- target: "bob@example.com",
- timestamp: "2024-01-14T15:45:00Z",
- details: "Account disabled",
- },
- {
- id: "4",
- type: "client_created",
- actor: "John Doe",
- target: "GitLab",
- timestamp: "2024-01-14T12:00:00Z",
- details: "OIDC client created",
- },
- {
- id: "5",
- type: "role_changed",
- actor: "John Doe",
- target: "jane@example.com",
- timestamp: "2024-01-13T09:00:00Z",
- details: "Role changed from member to admin",
- },
-];
-
-const getEventIcon = (type: string) => {
- switch (type) {
- case "member_invited":
- case "role_changed":
- return
;
- case "policy_changed":
- return
;
- case "member_disabled":
- return
;
- case "client_created":
- return
;
- default:
- return
;
+const getEventIcon = (action: string) => {
+ if (action.includes("member") || action.includes("MEMBER")) {
+ return
;
}
+ if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) {
+ return
;
+ }
+ if (action.includes("delete") || action.includes("DELETE") || action.includes("disable")) {
+ return
;
+ }
+ if (action.includes("client") || action.includes("oidc") || action.includes("key")) {
+ return
;
+ }
+ return
;
};
-const getEventTitle = (type: string) => {
- switch (type) {
- case "member_invited":
- return "Member invited";
- case "policy_changed":
- return "Policy changed";
- case "member_disabled":
- return "Member disabled";
- case "client_created":
- return "OIDC client created";
- case "role_changed":
- return "Role changed";
- default:
- return "Event";
- }
+const getEventTitle = (action: string) => {
+ const parts = action.split(".");
+ return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
+};
+
+const getActionCategory = (action: string): string => {
+ if (action.includes("member") || action.includes("MEMBER")) return "members";
+ if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) return "policies";
+ if (action.includes("client") || action.includes("OIDC")) return "clients";
+ return "other";
};
export default function OrgAuditPage() {
+ const params = useParams<{ orgId?: string }>();
+ const { orgId: fallbackOrgId } = useCurrentOrganizationId();
+ const orgId = params.orgId || fallbackOrgId;
+
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
+ const [auditLogs, setAuditLogs] = useState
([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchAuditLogs = useCallback(async (currentOrgId: string) => {
+ try {
+ setIsLoading(true);
+ setError(null);
+ const response = await api.organizations.getAuditLogs(currentOrgId);
+ setAuditLogs(response.audit_logs || []);
+ } catch (err) {
+ console.error("Failed to fetch audit logs:", err);
+ setError("Failed to load audit logs. Please try again.");
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ setError(null);
+ setAuditLogs([]);
+ if (!orgId) {
+ setIsLoading(false);
+ return;
+ }
+ fetchAuditLogs(orgId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [orgId]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
@@ -96,6 +83,20 @@ export default function OrgAuditPage() {
}).format(date);
};
+ const filteredLogs = auditLogs.filter((log) => {
+ const matchesSearch =
+ search === "" ||
+ log.description?.toLowerCase().includes(search.toLowerCase()) ||
+ log.action.toLowerCase().includes(search.toLowerCase()) ||
+ log.user?.email.toLowerCase().includes(search.toLowerCase());
+
+ const matchesFilter =
+ typeFilter === "all" ||
+ getActionCategory(log.action) === typeFilter;
+
+ return matchesSearch && matchesFilter;
+ });
+
return (
@@ -137,39 +138,65 @@ export default function OrgAuditPage() {
-
- {auditEvents.map((event) => (
-
-
- {getEventIcon(event.type)}
-
-
-
-
- {getEventTitle(event.type)}
-
-
- {event.target}
-
+ {isLoading ? (
+
+
+ Loading audit logs...
+
+ ) : error ? (
+
+ {error}
+
+ ) : filteredLogs.length === 0 ? (
+
+ No audit events found
+
+ ) : (
+
+ {filteredLogs.map((log) => (
+
+
+ {getEventIcon(log.action)}
-
-
by {event.actor}
-
•
-
{event.details}
+
+
+
+ {getEventTitle(log.action)}
+
+ {log.resource_type && (
+
+ {log.resource_type}
+
+ )}
+ {!log.success && (
+
+ Failed
+
+ )}
+
+
+ by {log.user?.full_name || log.user?.email || "System"}
+ {log.description && (
+ <>
+ •
+ {log.description}
+ >
+ )}
+
+
+ {formatDate(log.created_at)}
+
-
- {formatDate(event.timestamp)}
-
-
- ))}
-
+ ))}
+
+ )}
diff --git a/src/pages/org/OrgOverviewPage.tsx b/src/pages/org/OrgOverviewPage.tsx
index c009095..19f34fb 100644
--- a/src/pages/org/OrgOverviewPage.tsx
+++ b/src/pages/org/OrgOverviewPage.tsx
@@ -1,19 +1,81 @@
-import { Building2, Users, Shield, Key, ArrowRight, TrendingUp } from "lucide-react";
-import { Link } from "react-router-dom";
+import { useEffect, useState } from "react";
+import { Building2, Users, Shield, Key, ArrowRight, TrendingUp, Loader2, Trash2, AlertTriangle, ArrowLeftRight } from "lucide-react";
+import { Link, useNavigate } from "react-router-dom";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { api, OIDCClient, ApiError } from "@/lib/api";
+import { useOrg } from "@/contexts/OrgContext";import { useOrganizations } from "@/hooks/useOrganizations";
+import { toast } from "@/hooks/use-toast";
export default function OrgOverviewPage() {
- // Mock organization data
- const org = {
- name: "Acme Corp",
- createdAt: "January 2024",
- stats: {
- totalMembers: 24,
- activeToday: 18,
- pendingInvites: 3,
- oidcClients: 5,
- },
+ const navigate = useNavigate();
+ const { selectedOrg, selectOrg } = useOrg();
+ const { refetch: refetchOrgs } = useOrganizations();
+ const [memberCount, setMemberCount] = useState
(0);
+ const [clientCount, setClientCount] = useState(0);
+ const [isLoading, setIsLoading] = useState(true);
+
+ // Delete org dialog state
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const isOwner = selectedOrg?.role === "owner";
+
+ useEffect(() => {
+ if (!selectedOrg) return;
+ setIsLoading(true);
+ Promise.allSettled([
+ api.organizations.getMembers(selectedOrg.id),
+ api.organizations.getClients(selectedOrg.id),
+ ]).then(([membersResp, clientsResp]) => {
+ if (membersResp.status === "fulfilled") setMemberCount(membersResp.value.count);
+ if (clientsResp.status === "fulfilled") setClientCount((clientsResp.value as { clients: OIDCClient[]; count: number }).count);
+ }).catch(console.error)
+ .finally(() => setIsLoading(false));
+ }, [selectedOrg?.id]);
+
+ const handleDeleteOrg = async () => {
+ if (!selectedOrg) return;
+ setIsDeleting(true);
+ try {
+ await api.organizations.deleteOrganization(selectedOrg.id);
+ toast({ title: "Organization deleted", description: `"${selectedOrg.name}" has been deleted.` });
+ setDeleteDialogOpen(false);
+ // Refresh org list; context will auto-select next available org
+ const result = await refetchOrgs();
+ const remaining = result.data ?? [];
+ if (remaining.length > 0) {
+ selectOrg(remaining[0]);
+ navigate("/org");
+ } else {
+ navigate("/org-setup");
+ }
+ } catch (err) {
+ if (err instanceof ApiError && err.type === "ORG_HAS_MEMBERS") {
+ toast({
+ title: "Cannot delete organization",
+ description: "This organization still has other members. Transfer ownership or remove all members first.",
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ title: "Deletion failed",
+ description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
+ variant: "destructive",
+ });
+ }
+ setDeleteDialogOpen(false);
+ } finally {
+ setIsDeleting(false);
+ }
};
const quickLinks = [
@@ -37,6 +99,19 @@ export default function OrgOverviewPage() {
},
];
+ if (isLoading && !selectedOrg) {
+ return (
+
+
+
+ );
+ }
+
+ const org = selectedOrg;
+ const createdAt = org?.created_at
+ ? new Date(org.created_at).toLocaleDateString("en-US", { month: "long", year: "numeric" })
+ : "";
+
return (
@@ -45,42 +120,20 @@ export default function OrgOverviewPage() {
-
{org.name}
-
Created {org.createdAt}
+
{org?.name ?? "Organization"}
+ {createdAt &&
Created {createdAt}
}
{/* Stats */}
-
+
Total Members
-
{org.stats.totalMembers}
-
-
-
-
-
-
-
-
-
-
Active Today
-
{org.stats.activeToday}
-
-
-
-
-
-
-
-
-
-
Pending Invites
-
{org.stats.pendingInvites}
+
{memberCount}
@@ -91,17 +144,28 @@ export default function OrgOverviewPage() {
OIDC Clients
-
{org.stats.oidcClients}
+
{clientCount}
+
+
+
+
+
Org ID
+
{org?.id ?? "—"}
+
+
+
+
+
{/* Quick Links */}
Quick Actions
-
+
{quickLinks.map((link) => (
@@ -119,6 +183,92 @@ export default function OrgOverviewPage() {
))}
+
+ {/* Danger Zone — owners only */}
+ {isOwner && (
+
+
+
+
+ Danger Zone
+
+
+ Irreversible actions for this organization. Proceed with caution.
+
+
+
+ {/* Transfer ownership hint */}
+
+
+
Transfer Ownership
+
+ Pass ownership to another member before deleting the organization.
+
+
+
navigate("/org/members")}
+ >
+
+ Go to Members
+
+
+
+ {/* Delete organization */}
+
+
+
Delete Organization
+
+ Permanently deletes this organization.{" "}
+ {memberCount > 1
+ ? `You must remove all ${memberCount - 1} other member${memberCount > 2 ? "s" : ""} first.`
+ : "This action cannot be undone."}
+
+
+
setDeleteDialogOpen(true)}
+ disabled={memberCount > 1}
+ >
+
+ Delete
+
+
+
+
+ )}
+
+ {/* Delete confirmation dialog */}
+
+
+
+
+
+ Delete "{org?.name}"?
+
+
+ This will permanently delete the organization and all associated
+ data. This action cannot be undone .
+
+
+
+
+ You are about to delete
{org?.name} . All settings,
+ policies, OIDC clients, and CA configurations will be lost.
+
+
+ setDeleteDialogOpen(false)} disabled={isDeleting}>
+ Cancel
+
+
+ {isDeleting && }
+ Yes, delete organization
+
+
+
+
);
}
diff --git a/src/pages/org/PoliciesPage.tsx b/src/pages/org/PoliciesPage.tsx
index ee6ce72..c7e19c3 100644
--- a/src/pages/org/PoliciesPage.tsx
+++ b/src/pages/org/PoliciesPage.tsx
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { api, OrgPolicyResponse, UpdateOrgPolicyDto, create403Handler } from "@/lib/api";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
+import { useOrg } from "@/contexts/OrgContext";
const MFA_MODE_LABELS: Record
= {
disabled: {
@@ -40,8 +41,8 @@ export default function PoliciesPage() {
const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
- const [currentOrgId, setCurrentOrgId] = useState(null);
-
+ const { selectedOrgId: currentOrgId } = useOrg();
+
// Local form state for unsaved changes
const [formData, setFormData] = useState({
mfa_policy_mode: '',
@@ -50,20 +51,6 @@ export default function PoliciesPage() {
});
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
- // Fetch organizations to get current org
- const { data: orgsData, isLoading: orgsLoading } = useQuery({
- queryKey: ['organizations'],
- queryFn: () => api.users.organizations({
- on403: create403Handler(toast),
- }),
- });
-
- useEffect(() => {
- if (orgsData?.organizations && orgsData.organizations.length > 0) {
- setCurrentOrgId(orgsData.organizations[0].id);
- }
- }, [orgsData]);
-
// Fetch org policy
const { data: policy, isLoading: policyLoading } = useQuery({
queryKey: ['org-policy', currentOrgId],
@@ -184,7 +171,7 @@ export default function PoliciesPage() {
}
};
- if (orgsLoading || policyLoading) {
+ if (policyLoading) {
return (
diff --git a/src/pages/org/PrincipalsPage.tsx b/src/pages/org/PrincipalsPage.tsx
new file mode 100644
index 0000000..6a27801
--- /dev/null
+++ b/src/pages/org/PrincipalsPage.tsx
@@ -0,0 +1,426 @@
+import { useState, useEffect, useCallback } from "react";
+import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2, Link as LinkIcon, X } from "lucide-react";
+import { useParams } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { api, Principal, Department } from "@/lib/api";
+import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
+import { useToast } from "@/hooks/use-toast";
+
+export default function PrincipalsPage() {
+ const params = useParams<{ orgId?: string }>();
+ const { orgId: fallbackOrgId } = useCurrentOrganizationId();
+ const orgId = params.orgId || fallbackOrgId;
+ const { toast } = useToast();
+
+ const [search, setSearch] = useState("");
+ const [principals, setPrincipals] = useState
([]);
+ const [departments, setDepartments] = useState([]);
+ const [linkedDepts, setLinkedDepts] = useState>({});
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
+ const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false);
+ const [editingPrincipal, setEditingPrincipal] = useState(null);
+ const [selectedPrincipalForLink, setSelectedPrincipalForLink] = useState(null);
+ const [selectedDepartmentId, setSelectedDepartmentId] = useState("");
+ const [isLinking, setIsLinking] = useState(false);
+ const [unlinkingKey, setUnlinkingKey] = useState(null);
+ const [formData, setFormData] = useState({ name: "", description: "" });
+
+ const fetchLinkedDepts = useCallback(async (currentOrgId: string, principalList: Principal[]) => {
+ const entries = await Promise.all(
+ principalList.map(async (p) => {
+ try {
+ const res = await api.organizations.getPrincipalDepartments(currentOrgId, p.id);
+ return [p.id, res.departments] as [string, Department[]];
+ } catch {
+ return [p.id, []] as [string, Department[]];
+ }
+ })
+ );
+ setLinkedDepts(Object.fromEntries(entries));
+ }, []);
+
+ const fetchData = useCallback(async (currentOrgId: string) => {
+ try {
+ setIsLoading(true);
+ setError(null);
+ const [principalsRes, deptRes] = await Promise.all([
+ api.organizations.getPrincipals(currentOrgId),
+ api.organizations.getDepartments(currentOrgId),
+ ]);
+ const pList = principalsRes.principals || [];
+ setPrincipals(pList);
+ setDepartments(deptRes.departments || []);
+ await fetchLinkedDepts(currentOrgId, pList);
+ } catch (err) {
+ console.error("Failed to fetch data:", err);
+ setError("Failed to load data. Please try again.");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [fetchLinkedDepts]);
+
+ useEffect(() => {
+ setError(null);
+ setPrincipals([]);
+ setDepartments([]);
+ setLinkedDepts({});
+ if (!orgId) { setIsLoading(false); return; }
+ fetchData(orgId);
+ }, [orgId, fetchData]);
+
+ const handleCreatePrincipal = async () => {
+ if (!orgId || !formData.name.trim()) return;
+ try {
+ await api.organizations.createPrincipal(orgId, formData.name, formData.description || undefined);
+ setFormData({ name: "", description: "" });
+ setIsCreateDialogOpen(false);
+ await fetchData(orgId);
+ } catch {
+ toast({ variant: "destructive", title: "Failed to create principal" });
+ }
+ };
+
+ const handleUpdatePrincipal = async () => {
+ if (!orgId || !editingPrincipal || !formData.name.trim()) return;
+ try {
+ await api.organizations.updatePrincipal(orgId, editingPrincipal.id, {
+ name: formData.name,
+ description: formData.description || undefined,
+ });
+ setFormData({ name: "", description: "" });
+ setEditingPrincipal(null);
+ setIsEditDialogOpen(false);
+ await fetchData(orgId);
+ } catch {
+ toast({ variant: "destructive", title: "Failed to update principal" });
+ }
+ };
+
+ const handleDeletePrincipal = async (principalId: string) => {
+ if (!orgId || !confirm("Are you sure you want to delete this principal?")) return;
+ try {
+ await api.organizations.deletePrincipal(orgId, principalId);
+ await fetchData(orgId);
+ } catch {
+ toast({ variant: "destructive", title: "Failed to delete principal" });
+ }
+ };
+
+ const handleLinkPrincipal = async () => {
+ if (!orgId || !selectedPrincipalForLink || !selectedDepartmentId) return;
+ setIsLinking(true);
+ try {
+ await api.organizations.linkPrincipalToDepartment(orgId, selectedPrincipalForLink.id, selectedDepartmentId);
+ toast({ title: "Principal linked to department" });
+ setSelectedPrincipalForLink(null);
+ setSelectedDepartmentId("");
+ setIsLinkDialogOpen(false);
+ await fetchData(orgId);
+ } catch {
+ toast({ variant: "destructive", title: "Failed to link principal to department" });
+ } finally {
+ setIsLinking(false);
+ }
+ };
+
+ const handleUnlink = async (principalId: string, deptId: string) => {
+ if (!orgId) return;
+ const key = `${principalId}:${deptId}`;
+ setUnlinkingKey(key);
+ try {
+ await api.organizations.unlinkPrincipalFromDepartment(orgId, principalId, deptId);
+ toast({ title: "Unlinked from department" });
+ setLinkedDepts((prev) => ({
+ ...prev,
+ [principalId]: (prev[principalId] || []).filter((d) => d.id !== deptId),
+ }));
+ } catch {
+ toast({ variant: "destructive", title: "Failed to unlink" });
+ } finally {
+ setUnlinkingKey(null);
+ }
+ };
+
+ const openEditDialog = (principal: Principal) => {
+ setEditingPrincipal(principal);
+ setFormData({ name: principal.name, description: principal.description || "" });
+ setIsEditDialogOpen(true);
+ };
+
+ const openLinkDialog = (principal: Principal) => {
+ setSelectedPrincipalForLink(principal);
+ setSelectedDepartmentId("");
+ setIsLinkDialogOpen(true);
+ };
+
+ const filteredPrincipals = principals.filter((p) => {
+ const s = search.toLowerCase();
+ return p.name.toLowerCase().includes(s) || (p.description?.toLowerCase().includes(s) ?? false);
+ });
+
+ // Only show departments not already linked
+ const availableToLink = selectedPrincipalForLink
+ ? departments.filter((d) => !(linkedDepts[selectedPrincipalForLink.id] || []).some((ld) => ld.id === d.id))
+ : departments;
+
+ return (
+
+
+
+
Principals
+
Manage principals and link them to departments
+
+
{ setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
+
+ Create Principal
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="pl-10 max-w-sm"
+ />
+
+
+
+ {error &&
{error}
}
+
+
+
+ {isLoading ? (
+
+
+ Loading principals...
+
+ ) : filteredPrincipals.length === 0 ? (
+ No principals found
+ ) : (
+
+ {filteredPrincipals.map((principal) => {
+ const linked = linkedDepts[principal.id] || [];
+ return (
+
+
+
+
+
+
{principal.name}
+ {principal.description && (
+
{principal.description}
+ )}
+
+ {/* Linked department tags */}
+
+ {linked.length === 0 ? (
+ Not linked to any department
+ ) : linked.map((dept) => {
+ const key = `${principal.id}:${dept.id}`;
+ const busy = unlinkingKey === key;
+ return (
+
+ {dept.name}
+ handleUnlink(principal.id, dept.id)}
+ disabled={busy}
+ className="rounded-full p-0.5 hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50 transition-colors"
+ title="Unlink from department"
+ >
+ {busy ? : }
+
+
+ );
+ })}
+
+
+
+ Created {new Date(principal.created_at).toLocaleDateString()}
+
+
+
+
+
+
+
+
+
+
+ openEditDialog(principal)}>
+
+ Edit
+
+ openLinkDialog(principal)}>
+
+ Link to Department
+
+
+ handleDeletePrincipal(principal.id)}
+ >
+
+ Delete
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* Create Principal Dialog */}
+
+
+
+ Create Principal
+ Create a new principal to manage access and permissions
+
+
+
+ Principal Name
+ setFormData({ ...formData, name: e.target.value })}
+ />
+
+
+ Description
+
+
+
+ setIsCreateDialogOpen(false)}>Cancel
+ Create Principal
+
+
+
+
+ {/* Edit Principal Dialog */}
+
+
+
+ Edit Principal
+ Update principal information
+
+
+
+ Principal Name
+ setFormData({ ...formData, name: e.target.value })}
+ />
+
+
+ Description
+
+
+
+ setIsEditDialogOpen(false)}>Cancel
+ Save Changes
+
+
+
+
+ {/* Link to Department Dialog */}
+
{
+ if (!open) { setSelectedPrincipalForLink(null); setSelectedDepartmentId(""); }
+ setIsLinkDialogOpen(open);
+ }}
+ >
+
+
+ Link to Department
+
+ Link {selectedPrincipalForLink?.name} to a department
+
+
+
+
+
Department
+ {availableToLink.length === 0 ? (
+
Already linked to all available departments.
+ ) : (
+
+
+
+
+
+ {availableToLink.map((dept) => (
+ {dept.name}
+ ))}
+
+
+ )}
+
+
+
+ setIsLinkDialogOpen(false)}>Cancel
+
+ {isLinking && }
+ Link
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/user/ActivityPage.tsx b/src/pages/user/ActivityPage.tsx
index a68c42c..09705cd 100644
--- a/src/pages/user/ActivityPage.tsx
+++ b/src/pages/user/ActivityPage.tsx
@@ -1,104 +1,60 @@
-import { useState } from "react";
-import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, CheckCircle, MapPin } from "lucide-react";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { useState, useEffect } from "react";
+import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, Loader2, RefreshCw, Users } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { api, AuditLogEntry } from "@/lib/api";
+import { useAuth } from "@/contexts/AuthContext";
-const activityEvents = [
- {
- id: "1",
- type: "login_success",
- method: "password",
- timestamp: "2024-01-15T10:30:00Z",
- location: "San Francisco, CA",
- device: "Chrome on macOS",
- ip: "192.168.1.1",
- },
- {
- id: "2",
- type: "login_success",
- method: "passkey",
- timestamp: "2024-01-14T15:45:00Z",
- location: "San Francisco, CA",
- device: "Safari on iOS",
- ip: "192.168.1.2",
- },
- {
- id: "3",
- type: "login_failed",
- method: "password",
- timestamp: "2024-01-14T12:00:00Z",
- location: "Unknown",
- device: "Firefox on Windows",
- ip: "10.0.0.5",
- },
- {
- id: "4",
- type: "mfa_enabled",
- method: "totp",
- timestamp: "2024-01-13T09:00:00Z",
- location: "San Francisco, CA",
- device: "Chrome on macOS",
- ip: "192.168.1.1",
- },
- {
- id: "5",
- type: "passkey_added",
- method: "passkey",
- timestamp: "2024-01-12T14:30:00Z",
- location: "San Francisco, CA",
- device: "Safari on macOS",
- ip: "192.168.1.1",
- },
-];
-
-const getEventIcon = (type: string, method: string) => {
- switch (type) {
- case "login_success":
- return method === "passkey" ? (
-
- ) : (
-
- );
- case "login_failed":
- return ;
- case "mfa_enabled":
- return ;
- case "passkey_added":
- return ;
- case "logout":
- return ;
- default:
- return ;
+// Map audit log action strings to display info
+const getEventDisplay = (action: string) => {
+ const a = action.toLowerCase();
+ if (a.includes("login") && a.includes("fail")) {
+ return { icon: , title: "Failed login attempt", failed: true };
}
-};
-
-const getEventTitle = (type: string, method: string) => {
- switch (type) {
- case "login_success":
- return `Signed in with ${method}`;
- case "login_failed":
- return "Failed login attempt";
- case "mfa_enabled":
- return "Two-factor authentication enabled";
- case "passkey_added":
- return "Passkey added";
- case "logout":
- return "Signed out";
- default:
- return "Security event";
+ if (a.includes("login") || a.includes("authenticate")) {
+ return { icon: , title: "Signed in", failed: false };
}
-};
-
-const getEventStatus = (type: string) => {
- if (type === "login_failed") {
- return { variant: "destructive" as const, label: "Failed" };
+ if (a.includes("logout") || a.includes("sign_out")) {
+ return { icon: , title: "Signed out", failed: false };
}
- return { variant: "default" as const, label: "Success" };
+ if (a.includes("passkey") || a.includes("webauthn")) {
+ return { icon: , title: "Passkey event", failed: false };
+ }
+ if (a.includes("mfa") || a.includes("totp") || a.includes("2fa")) {
+ return { icon: , title: "MFA event", failed: false };
+ }
+ if (a.includes("ssh")) {
+ return { icon: , title: "SSH key event", failed: false };
+ }
+ return { icon: , title: action.replace(/_/g, " "), failed: !action.includes("success") && a.includes("fail") };
};
export default function ActivityPage() {
+ const { isOrgAdmin } = useAuth();
const [filter, setFilter] = useState("all");
+ const [view, setView] = useState<"mine" | "org">("mine");
+ const [events, setEvents] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const loadEvents = () => {
+ setIsLoading(true);
+ setError("");
+ const req =
+ view === "org" && isOrgAdmin
+ ? api.admin.getAuditLogs({ per_page: "100" }).then((d) => d.audit_logs ?? [])
+ : api.users.auditLogs({ per_page: "50" }).then((d) => d.audit_logs ?? []);
+
+ req
+ .then((logs) => setEvents(logs))
+ .catch(() => setError("Failed to load activity. Please try again."))
+ .finally(() => setIsLoading(false));
+ };
+
+ useEffect(() => { loadEvents(); }, [view]); // eslint-disable-line react-hooks/exhaustive-deps
const formatDate = (dateString: string) => {
const date = new Date(dateString);
@@ -110,71 +66,117 @@ export default function ActivityPage() {
}).format(date);
};
+ const filteredEvents = events.filter((e) => {
+ if (filter === "all") return true;
+ const a = e.action.toLowerCase();
+ if (filter === "logins")
+ return a.includes("session_create") || a.includes("session_revoke") || a.includes("external_auth") || a.includes("login") || a.includes("logout");
+ if (filter === "security")
+ return a.includes("mfa") || a.includes("passkey") || a.includes("ssh") || a.includes("totp") || a.includes("password") || a.includes("webauthn");
+ return true;
+ });
+
return (
Activity
- Your recent account activity and security events
+ {view === "org" ? "Organization-wide audit log" : "Your recent account activity and security events"}
-
-
-
-
-
- All events
- Logins only
- Security changes
-
-
+
+ {isOrgAdmin && (
+ setView(v as "mine" | "org")}>
+
+ My Activity
+
+
+ Org Logs
+
+
+
+ )}
+
+
+
+
+
+ All events
+ Logins only
+ Security changes
+
+
+
+
+
+
-
- {activityEvents.map((event) => {
- const status = getEventStatus(event.type);
- return (
-
-
- {getEventIcon(event.type, event.method)}
-
-
-
-
- {getEventTitle(event.type, event.method)}
-
- {event.type === "login_failed" && (
-
- Failed
-
- )}
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+ ) : filteredEvents.length === 0 ? (
+
+
No activity events found.
+
+ ) : (
+
+ {filteredEvents.map((event) => {
+ const display = getEventDisplay(event.action);
+ return (
+
+
+ {display.icon}
-
-
{event.device}
-
-
-
{event.location}
-
•
-
{event.ip}
+
+
+
+ {display.title}
+
+ {(!event.success || display.failed) && (
+
+ Failed
+
+ )}
+
+
+ {view === "org" && event.user_id && (
+
User: {event.user_id}
+ )}
+ {event.description &&
{event.description}
}
+
+ {event.ip_address && (
+ {event.ip_address}
+ )}
+ {event.user_agent && (
+ {event.user_agent}
+ )}
+
+
+ {formatDate(event.created_at)}
+
-
- {formatDate(event.timestamp)}
-
-
- );
- })}
-
+ );
+ })}
+
+ )}
diff --git a/src/pages/user/ProfilePage.tsx b/src/pages/user/ProfilePage.tsx
index cea7865..60e6db1 100644
--- a/src/pages/user/ProfilePage.tsx
+++ b/src/pages/user/ProfilePage.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
-import { Mail, Building2, Upload, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
+import { Mail, Upload, CheckCircle, AlertCircle, Loader2, Bell, AlertTriangle, Trash2, Building2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -7,10 +7,18 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { useAuth } from "@/contexts/AuthContext";
-import { api, Organization, ApiError } from "@/lib/api";
-import { useOrganizations } from "@/hooks/useOrganizations";
+import { api, ApiError, PendingInvite } from "@/lib/api";
import { toast } from "@/hooks/use-toast";
+import { useNavigate } from "react-router-dom";
function ProfileSkeleton() {
return (
@@ -52,38 +60,22 @@ function ProfileSkeleton() {
-
- {/* Organizations Skeleton */}
-
-
-
-
-
-
-
-
-
-
);
}
export default function ProfilePage() {
- const { user, isLoading: authLoading, refreshUser } = useAuth();
+ const { user, isLoading: authLoading, refreshUser, logout } = useAuth();
+ const navigate = useNavigate();
const [name, setName] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
-
- // Use React Query hook for organizations with automatic caching and deduplication
- const { data: organizations = [], isLoading: orgsLoading, error: orgsError } = useOrganizations();
+ const [pendingInvites, setPendingInvites] = useState
([]);
- // Debug logging
- console.log('[ProfilePage] organizations data:', organizations);
- console.log('[ProfilePage] organizations is array:', Array.isArray(organizations));
-
- // Ensure organizations is always an array (defensive check)
- const organizationsArray = Array.isArray(organizations) ? organizations : [];
+ // Delete account dialog state
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
// Sync local name state with user data
useEffect(() => {
@@ -92,16 +84,13 @@ export default function ProfilePage() {
}
}, [user?.full_name]);
- // Handle 403 errors for organizations
+ // Fetch pending invitations for this user
useEffect(() => {
- if (orgsError instanceof ApiError && orgsError.code === 403) {
- toast({
- title: "Access Denied",
- description: "You don't have permission to view organizations. Please contact your organization administrator.",
- variant: "destructive",
- });
- }
- }, [orgsError]);
+ if (!user) return;
+ api.users.getMyInvites()
+ .then((res) => setPendingInvites(res.invites ?? []))
+ .catch(() => { /* silently ignore */ });
+ }, [user]);
const getInitials = (fullName: string | null) => {
if (!fullName) return "?";
@@ -113,6 +102,35 @@ export default function ProfilePage() {
.slice(0, 2);
};
+ const handleDeleteAccount = async () => {
+ setIsDeleting(true);
+ try {
+ await api.users.deleteMe();
+ toast({ title: "Account deleted", description: "Your account has been deleted." });
+ setDeleteDialogOpen(false);
+ await logout();
+ navigate("/login");
+ } catch (err) {
+ if (err instanceof ApiError && err.type === "USER_IS_SOLE_OWNER") {
+ const orgs: string[] = (err.details?.organizations as string[]) ?? [];
+ toast({
+ title: "Cannot delete account",
+ description: `You are the sole owner of: ${orgs.join(", ")}. Transfer ownership or delete those organizations first.`,
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ title: "Deletion failed",
+ description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
+ variant: "destructive",
+ });
+ }
+ setDeleteDialogOpen(false);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
const handleSave = async () => {
if (!name.trim()) {
toast({
@@ -159,11 +177,53 @@ export default function ProfilePage() {
Profile
- Manage your personal information and organization memberships
+ Manage your personal information and account settings
+ {/* Account Suspended Banner */}
+ {(user.status === "suspended" || user.status === "compliance_suspended") && (
+
+
+
+
Account suspended
+
+ Your account has been suspended. You cannot perform most actions.
+ Please contact your administrator to resolve this.
+
+
+
+ )}
+
+ {/* Pending Invitations Banner */}
+ {pendingInvites.length > 0 && (
+
+
+
+ You have {pendingInvites.length} pending invitation{pendingInvites.length > 1 ? "s" : ""}
+
+ {pendingInvites.map((invite) => (
+
+
+
{invite.organization.name}
+
+ Invited as {invite.role}
+
+
+
+ Accept →
+
+
+ ))}
+
+ )}
{/* Profile Photo & Name */}
@@ -248,59 +308,74 @@ export default function ProfilePage() {
- {/* Organizations */}
-
+ {/* Danger Zone */}
+
- Organizations
- Organizations you're a member of
+
+
+ Danger Zone
+
+
+ Irreversible actions for your account. Proceed with caution.
+
- {orgsLoading ? (
-
-
-
+
+
+
Delete Account
+
+ Permanently deletes your profile and all associated data. If you own
+ any organizations you must transfer ownership or delete them first.
+
- ) : organizationsArray.length === 0 ? (
-
- You're not a member of any organizations yet.
-
- ) : (
-
- {organizationsArray.map((org) => (
-
-
- {org.logo_url ? (
-
-
-
-
-
-
- ) : (
-
-
-
- )}
-
{org.name}
-
-
- {(org.role === 'owner' || org.role === 'admin') && (
-
- Admin
-
- )}
- {org.role}
-
-
- ))}
-
- )}
+
setDeleteDialogOpen(true)}
+ >
+
+ Delete account
+
+
+
+ {/* Delete account confirmation dialog */}
+
+
+
+
+
+ Delete your account?
+
+
+ Your profile, SSH keys, linked accounts, and session data will be
+ permanently deleted. This action cannot be undone .
+
+
+
+
+
+ Organization ownership check
+
+
+ If you are the sole owner of any organization that has other members,
+ you must transfer ownership to another member or delete
+ those organizations before proceeding.
+
+
+
+ setDeleteDialogOpen(false)} disabled={isDeleting}>
+ Cancel
+
+
+ {isDeleting && }
+ Yes, delete my account
+
+
+
+
);
}
diff --git a/src/pages/user/SSHKeysPage.tsx b/src/pages/user/SSHKeysPage.tsx
new file mode 100644
index 0000000..cb27afd
--- /dev/null
+++ b/src/pages/user/SSHKeysPage.tsx
@@ -0,0 +1,1097 @@
+import { useState, useEffect, useCallback } from "react";
+import {
+ Key,
+ Plus,
+ Trash2,
+ CheckCircle,
+ XCircle,
+ Copy,
+ Loader2,
+ Terminal,
+ Award,
+ AlertTriangle,
+ Pencil,
+ ShieldOff,
+ Server,
+ Clock,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useToast } from "@/hooks/use-toast";
+import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api";
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ──────────────────────────────────────────────────────────────────────────────
+
+function formatDate(dateStr: string | null): string {
+ if (!dateStr) return "—";
+ return new Date(dateStr).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+function CopyButton({ text }: { text: string }) {
+ const [copied, setCopied] = useState(false);
+ const { toast } = useToast();
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ toast({ title: "Copied to clipboard" });
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ toast({ variant: "destructive", title: "Copy failed" });
+ }
+ };
+
+ return (
+
+ {copied ? : }
+
+ );
+}
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Main page component
+// ──────────────────────────────────────────────────────────────────────────────
+
+export default function SSHKeysPage() {
+ const { toast } = useToast();
+
+ // Key list state
+ const [keys, setKeys] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ // Add key modal
+ const [showAdd, setShowAdd] = useState(false);
+ const [addPublicKey, setAddPublicKey] = useState("");
+ const [addDescription, setAddDescription] = useState("");
+ const [isAdding, setIsAdding] = useState(false);
+ const [addError, setAddError] = useState(null);
+
+ // Delete confirmation
+ const [deletingKey, setDeletingKey] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ // Inline description editing
+ const [editingKeyId, setEditingKeyId] = useState(null);
+ const [editingDescription, setEditingDescription] = useState("");
+
+ // Verify (challenge/sign) wizard
+ const [verifyingKey, setVerifyingKey] = useState(null);
+ const [verifyStep, setVerifyStep] = useState<"challenge" | "submit" | "done">("challenge");
+ const [challengeText, setChallengeText] = useState("");
+ const [signatureInput, setSignatureInput] = useState("");
+ const [isVerifying, setIsVerifying] = useState(false);
+ const [verifyError, setVerifyError] = useState(null);
+
+ // Sign certificate modal
+ const [signingKey, setSigningKey] = useState(null);
+ const [certResult, setCertResult] = useState(null);
+ const [isSigning, setIsSigning] = useState(false);
+ const [signError, setSignError] = useState(null);
+ const [certType, setCertType] = useState<'user' | 'host'>('user');
+ const [expiryHours, setExpiryHours] = useState('');
+ const [deptCertPolicy, setDeptCertPolicy] = useState(null);
+
+ // Principal selection (populated when sign dialog opens)
+ const [principalOrgs, setPrincipalOrgs] = useState([]);
+ const [availablePrincipals, setAvailablePrincipals] = useState([]);
+ const [selectedPrincipalNames, setSelectedPrincipalNames] = useState>(new Set());
+ const [isLoadingPrincipals, setIsLoadingPrincipals] = useState(false);
+ const [isAdminMode, setIsAdminMode] = useState(false);
+
+ // Certificates tab
+ const [certs, setCerts] = useState([]);
+ const [isCertsLoading, setIsCertsLoading] = useState(false);
+ const [revokingCertId, setRevokingCertId] = useState(null);
+ const [isRevoking, setIsRevoking] = useState(false);
+
+ // CA public key
+ const [caPublicKey, setCaPublicKey] = useState(null);
+ const [caName, setCaName] = useState(null);
+ const [isCaLoading, setIsCaLoading] = useState(false);
+
+ // ── Fetch keys ──────────────────────────────────────────────────────────────
+ const fetchKeys = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const data = await api.ssh.listKeys();
+ setKeys(data.keys);
+ } catch (err) {
+ const errorMsg = err instanceof ApiError ? `${err.message} (${err.type})` : String(err);
+ console.error("Failed to load SSH keys:", errorMsg, err);
+ toast({
+ variant: "destructive",
+ title: "Failed to load SSH keys",
+ description: errorMsg,
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ }, [toast]);
+
+ useEffect(() => {
+ fetchKeys();
+ }, [fetchKeys]);
+
+ const fetchCerts = useCallback(async () => {
+ setIsCertsLoading(true);
+ try {
+ const data = await api.ssh.listCertificates();
+ setCerts(data.certificates);
+ } catch (err) {
+ console.error("Failed to load certificates:", err);
+ } finally {
+ setIsCertsLoading(false);
+ }
+ }, []);
+
+ const fetchCaPublicKey = useCallback(async () => {
+ setIsCaLoading(true);
+ try {
+ const data = await api.ssh.getCaPublicKey();
+ setCaPublicKey(data.public_key);
+ setCaName(data.ca_name);
+ } catch {
+ // No CA configured — silently ignore
+ } finally {
+ setIsCaLoading(false);
+ }
+ }, []);
+
+ // ── Add key ─────────────────────────────────────────────────────────────────
+ const handleAdd = async () => {
+ setAddError(null);
+ if (!addPublicKey.trim()) {
+ setAddError("Public key is required");
+ return;
+ }
+ setIsAdding(true);
+ try {
+ await api.ssh.addKey(addPublicKey.trim(), addDescription.trim() || undefined);
+ toast({ title: "SSH key added" });
+ setShowAdd(false);
+ setAddPublicKey("");
+ setAddDescription("");
+ fetchKeys();
+ } catch (err) {
+ console.error("Failed to add SSH key:", err);
+ setAddError(err instanceof ApiError ? err.message : "Failed to add key");
+ } finally {
+ setIsAdding(false);
+ }
+ };
+
+ // ── Delete key ──────────────────────────────────────────────────────────────
+ const handleDelete = async () => {
+ if (!deletingKey) return;
+ setIsDeleting(true);
+ try {
+ await api.ssh.deleteKey(deletingKey.id);
+ setKeys((prev) => prev.filter((k) => k.id !== deletingKey.id));
+ toast({ title: "SSH key deleted" });
+ } catch (err) {
+ toast({
+ variant: "destructive",
+ title: "Failed to delete SSH key",
+ description: err instanceof ApiError ? err.message : "An error occurred",
+ });
+ } finally {
+ setIsDeleting(false);
+ setDeletingKey(null);
+ }
+ };
+
+ // ── Rename description ──────────────────────────────────────────────────────
+ const handleRenameCommit = async (key: SSHKey) => {
+ if (!editingDescription.trim() || editingDescription === (key.description ?? "")) {
+ setEditingKeyId(null);
+ return;
+ }
+ try {
+ const updated = await api.ssh.updateKeyDescription(key.id, editingDescription.trim());
+ setKeys((prev) => prev.map((k) => (k.id === key.id ? updated : k)));
+ toast({ title: "Description updated" });
+ } catch (err) {
+ toast({
+ variant: "destructive",
+ title: "Failed to update description",
+ description: err instanceof ApiError ? err.message : "An error occurred",
+ });
+ } finally {
+ setEditingKeyId(null);
+ }
+ };
+
+ // ── Verify wizard ────────────────────────────────────────────────────────────
+ const startVerify = async (key: SSHKey) => {
+ setVerifyingKey(key);
+ setVerifyStep("challenge");
+ setChallengeText("");
+ setSignatureInput("");
+ setVerifyError(null);
+ setIsVerifying(true);
+ try {
+ const data = await api.ssh.getChallenge(key.id);
+ setChallengeText(data.challenge_text);
+ setVerifyStep("submit");
+ } catch (err) {
+ setVerifyError(err instanceof ApiError ? err.message : "Failed to fetch challenge");
+ } finally {
+ setIsVerifying(false);
+ }
+ };
+
+ const handleVerifySubmit = async () => {
+ if (!verifyingKey) return;
+ setVerifyError(null);
+ if (!signatureInput.trim()) {
+ setVerifyError("Signature is required");
+ return;
+ }
+ setIsVerifying(true);
+ try {
+ const result = await api.ssh.verifyKey(verifyingKey.id, signatureInput.trim());
+ if (result.verified) {
+ setVerifyStep("done");
+ setKeys((prev) =>
+ prev.map((k) =>
+ k.id === verifyingKey.id
+ ? { ...k, verified: true, verified_at: new Date().toISOString() }
+ : k
+ )
+ );
+ } else {
+ setVerifyError("Signature verification failed. Please check the signed data and try again.");
+ }
+ } catch (err) {
+ setVerifyError(err instanceof ApiError ? err.message : "Verification failed");
+ } finally {
+ setIsVerifying(false);
+ }
+ };
+
+ // ── Sign certificate ─────────────────────────────────────────────────────────
+ const startSign = async (key: SSHKey) => {
+ setSigningKey(key);
+ setCertResult(null);
+ setSignError(null);
+ setSelectedPrincipalNames(new Set());
+ setAvailablePrincipals([]);
+ setPrincipalOrgs([]);
+ setIsAdminMode(false);
+ setCertType('user');
+ setExpiryHours('');
+ setDeptCertPolicy(null);
+ setIsLoadingPrincipals(true);
+
+ // Fetch dept cert policy in parallel
+ api.ssh.getMyDeptCertPolicy().then((data) => {
+ setDeptCertPolicy(data.policy);
+ }).catch(() => {/* non-fatal */});
+
+ try {
+ const data = await api.users.myPrincipals();
+ setPrincipalOrgs(data.orgs);
+
+ // Determine admin mode: user is admin in at least one org
+ const adminOrg = data.orgs.find(o => o.is_admin);
+ const isAdmin = !!adminOrg;
+ setIsAdminMode(isAdmin);
+
+ // Collect available options: admins see all_principals (full org list),
+ // regular users see only my_principals (their assigned ones)
+ const opts: PrincipalOption[] = [];
+ const seen = new Set();
+ for (const org of data.orgs) {
+ const list = isAdmin ? org.all_principals : org.my_principals;
+ for (const p of list) {
+ if (!seen.has(p.name)) {
+ seen.add(p.name);
+ opts.push(p);
+ }
+ }
+ }
+ setAvailablePrincipals(opts);
+
+ // Pre-select all assigned principals (my_principals) regardless of admin status
+ const preselected = new Set();
+ for (const org of data.orgs) {
+ for (const p of org.my_principals) preselected.add(p.name);
+ }
+ setSelectedPrincipalNames(preselected);
+ } catch (err) {
+ setSignError(err instanceof ApiError ? err.message : "Failed to load principals");
+ } finally {
+ setIsLoadingPrincipals(false);
+ }
+ };
+
+ const togglePrincipal = (name: string) => {
+ setSelectedPrincipalNames(prev => {
+ const next = new Set(prev);
+ if (next.has(name)) next.delete(name);
+ else next.add(name);
+ return next;
+ });
+ };
+
+ const handleSign = async () => {
+ if (!signingKey) return;
+ setSignError(null);
+ setIsSigning(true);
+ try {
+ const principals = Array.from(selectedPrincipalNames);
+ const parsedExpiry = expiryHours.trim() ? parseInt(expiryHours, 10) : undefined;
+ const result = await api.ssh.signCertificate(
+ signingKey.id,
+ principals.length > 0 ? principals : undefined,
+ certType,
+ parsedExpiry,
+ );
+ setCertResult(result.certificate);
+ fetchCerts(); // refresh certs list
+ } catch (err) {
+ setSignError(err instanceof ApiError ? err.message : "Certificate signing failed");
+ } finally {
+ setIsSigning(false);
+ }
+ };
+
+ // ── Revoke certificate ───────────────────────────────────────────────────────
+ const handleRevoke = async () => {
+ if (!revokingCertId) return;
+ setIsRevoking(true);
+ try {
+ await api.ssh.revokeCertificate(revokingCertId);
+ setCerts((prev) =>
+ prev.map((c) => (c.id === revokingCertId ? { ...c, revoked: true, status: "revoked" } : c))
+ );
+ toast({ title: "Certificate revoked" });
+ } catch (err) {
+ toast({
+ variant: "destructive",
+ title: "Failed to revoke certificate",
+ description: err instanceof ApiError ? err.message : "An error occurred",
+ });
+ } finally {
+ setIsRevoking(false);
+ setRevokingCertId(null);
+ }
+ };
+
+ // ──────────────────────────────────────────────────────────────────────────────
+ // Render
+ // ──────────────────────────────────────────────────────────────────────────────
+
+ return (
+
+
+
+
+
SSH Keys
+
+ Manage your SSH public keys and request signed certificates
+
+
+
setShowAdd(true)}>
+
+ Add SSH key
+
+
+
+
+
{
+ if (v === "certs") fetchCerts();
+ if (v === "ca") fetchCaPublicKey();
+ }}>
+
+ Public Keys
+ Certificates
+ CA Public Key
+
+
+ {/* ── Keys tab ──────────────────────────────────────────────────────── */}
+
+
+
+
+
+ Your SSH Keys
+
+
+ Public keys associated with your account. Verify a key to enable certificate signing.
+
+
+
+ {isLoading ? (
+
+
+
+ ) : keys.length === 0 ? (
+
+
+
No SSH keys yet
+
Add your first public key to get started
+
+ ) : (
+
+ {keys.map((key) => (
+
+
+ {/* Description row */}
+
+ {editingKeyId === key.id ? (
+
setEditingDescription(e.target.value)}
+ onBlur={() => handleRenameCommit(key)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleRenameCommit(key);
+ if (e.key === "Escape") setEditingKeyId(null);
+ }}
+ className="h-7 text-sm max-w-xs"
+ autoFocus
+ />
+ ) : (
+
+ {key.description || No description }
+
+ )}
+
{
+ setEditingKeyId(key.id);
+ setEditingDescription(key.description ?? "");
+ }}
+ >
+
+
+ {key.verified ? (
+
+
+ Verified
+
+ ) : (
+
+
+ Unverified
+
+ )}
+
+
+ {/* Key fingerprint / type */}
+
+ {key.key_type && (
+
+ {key.key_type}
+
+ )}
+ {key.fingerprint ?? key.public_key.slice(0, 64) + "…"}
+
+
+ {/* Dates */}
+
+ Added {formatDate(key.created_at)}
+ {key.verified_at && · Verified {formatDate(key.verified_at)} }
+
+
+
+ {/* Actions */}
+
+ {!key.verified && (
+
startVerify(key)}
+ >
+ Verify
+
+ )}
+ {key.verified && (
+
startSign(key)}
+ >
+
+ Sign cert
+
+ )}
+
setDeletingKey(key)}
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* ── Certificates tab ───────────────────────────────────────────────── */}
+
+
+
+
+
+ Issued Certificates
+
+
+ SSH certificates issued to your keys. Active certificates can be used to authenticate to servers.
+
+
+
+ {isCertsLoading ? (
+
+
+
+ ) : certs.length === 0 ? (
+
+
+
No certificates yet
+
Verify a key and click "Sign cert" to get your first certificate
+
+ ) : (
+
+ {certs.map((cert) => {
+ // Ensure the date string is treated as UTC regardless of whether
+ // the backend emits a trailing Z (older rows may lack it).
+ const validBeforeStr = cert.valid_before.endsWith("Z") || cert.valid_before.includes("+")
+ ? cert.valid_before
+ : cert.valid_before + "Z";
+ const isExpired = new Date(validBeforeStr) < new Date();
+ const isRevoked = cert.revoked;
+ return (
+
+
+
+
+ {cert.principals.join(", ")}
+
+ {isRevoked ? (
+ Revoked
+ ) : isExpired ? (
+ Expired
+ ) : (
+ Active
+ )}
+
+
+ Valid {formatDate(cert.valid_after)} → {formatDate(cert.valid_before)}
+ {cert.serial != null && · Serial #{cert.serial} }
+
+
+ {!isRevoked && !isExpired && (
+
setRevokingCertId(cert.id)}
+ >
+
+ Revoke
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* ── CA Public Key tab ──────────────────────────────────────────────── */}
+
+
+
+
+
+ CA Public Key
+
+
+ Add this key to TrustedUserCAKeys on your servers so they accept certificates issued by Gatehouse.
+
+
+
+ {isCaLoading ? (
+
+
+
+ ) : !caPublicKey ? (
+
+
+
No CA configured for your organization
+
+ ) : (
+
+ {caName && (
+
CA: {caName}
+ )}
+
+
+
+ Server setup
+
+
+{`# On each SSH server:
+echo '' >> /etc/ssh/trusted_user_ca_keys
+# In /etc/ssh/sshd_config:
+TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys`}
+
+
+
+ )}
+
+
+
+
+
+ {/* ── Add key dialog ────────────────────────────────────────────────────── */}
+
{ setShowAdd(open); setAddError(null); }}>
+
+
+ Add SSH Public Key
+
+ Paste your SSH public key (e.g. the contents of ~/.ssh/id_ed25519.pub).
+
+
+
+ {addError && (
+
{addError}
+ )}
+
+ Public key
+
+
+ Description (optional)
+ setAddDescription(e.target.value)}
+ disabled={isAdding}
+ />
+
+
+
+ setShowAdd(false)} disabled={isAdding}>
+ Cancel
+
+
+ {isAdding && }
+ Add key
+
+
+
+
+
+ {/* ── Delete confirmation ───────────────────────────────────────────────── */}
+
setDeletingKey(null)}>
+
+
+ Delete SSH key?
+
+ Are you sure you want to delete{" "}
+ {deletingKey?.description || "this key"} ? This cannot be undone and any certificates signed with it will stop working.
+
+
+
+ Cancel
+
+ {isDeleting && }
+ Delete
+
+
+
+
+
+ {/* ── Verify wizard dialog ──────────────────────────────────────────────── */}
+
{ if (!open) setVerifyingKey(null); }}>
+
+
+ Verify SSH Key Ownership
+
+ Prove you own this key by signing a challenge with it.
+
+
+
+ {verifyStep === "challenge" && isVerifying && (
+
+
+ Fetching challenge…
+
+ )}
+
+ {verifyStep === "challenge" && !isVerifying && verifyError && (
+ {verifyError}
+ )}
+
+ {verifyStep === "submit" && (
+
+ {verifyError && (
+
{verifyError}
+ )}
+
+
Step 1 — Save this challenge text to a file
+
+
+
+
+
+ Step 2 — Sign with ssh-keygen
+
+
+{`echo '' > /tmp/challenge.txt
+ssh-keygen -Y sign \\
+ -f ~/.ssh/id_ed25519 \\
+ -n gatehouse \\
+ /tmp/challenge.txt
+cat /tmp/challenge.txt.sig | base64 -w0`}
+
+
+
+
+ Step 3 — Paste the base64-encoded signature
+
+
+ )}
+
+ {verifyStep === "done" && (
+
+
+
Key verified!
+
+ You can now use this key to request SSH certificates.
+
+
+ )}
+
+
+ {verifyStep !== "done" ? (
+ <>
+ setVerifyingKey(null)}
+ disabled={isVerifying}
+ >
+ Cancel
+
+ {verifyStep === "submit" && (
+
+ {isVerifying && }
+ Verify signature
+
+ )}
+ >
+ ) : (
+ setVerifyingKey(null)}>Done
+ )}
+
+
+
+
+ {/* ── Sign certificate dialog ───────────────────────────────────────────── */}
+
{ if (!open) { setSigningKey(null); setCertResult(null); setSignError(null); } }}>
+
+
+ Sign SSH Certificate
+
+ Request a signed certificate for{" "}
+ {signingKey?.description || "this key"} .
+
+
+
+ {signError && (
+ {signError}
+ )}
+
+ {!certResult ? (
+
+ {isLoadingPrincipals ? (
+
+
+ Loading principals…
+
+ ) : availablePrincipals.length === 0 ? (
+
+ You have no principals assigned. Ask an admin to add you to a principal before requesting a certificate.
+
+ ) : (
+
+
+
+ {isAdminMode ? "Select principals" : "Select principals"}
+
+
+ setSelectedPrincipalNames(new Set(availablePrincipals.map(p => p.name)))}
+ >
+ All
+
+ ·
+ setSelectedPrincipalNames(new Set())}
+ >
+ None
+
+
+
+
+ {availablePrincipals.map((p) => {
+ const checked = selectedPrincipalNames.has(p.name);
+ // For regular users, my_principals are the ones they're assigned
+ const isAssigned = principalOrgs.some(o => o.my_principals.some(mp => mp.name === p.name));
+ return (
+
+ togglePrincipal(p.name)}
+ />
+
+
+ {p.name}
+ {isAssigned && !isAdminMode && (
+ assigned
+ )}
+ {isAdminMode && isAssigned && (
+ your assignment
+ )}
+
+ {p.description && (
+
{p.description}
+ )}
+
+
+ );
+ })}
+
+
+ {selectedPrincipalNames.size === 0
+ ? "Select at least one principal"
+ : `${selectedPrincipalNames.size} principal${selectedPrincipalNames.size !== 1 ? "s" : ""} selected`}
+
+
+ )}
+
+ {/* Expiry — controlled by dept cert policy */}
+
+
+
+ Validity (hours)
+
+ {deptCertPolicy?.allow_user_expiry ? (
+
+
setExpiryHours(e.target.value)}
+ className="w-40"
+ />
+
+ {isAdminMode
+ ? deptCertPolicy.max_expiry_hours < 8760
+ ? <>Capped at {deptCertPolicy.max_expiry_hours}h by department policy. Leave blank for default ({deptCertPolicy.default_expiry_hours}h).>
+ : <>Leave blank to use default ({deptCertPolicy.default_expiry_hours}h).>
+ : <>Max allowed: {deptCertPolicy.max_expiry_hours}h . Leave blank for default ({deptCertPolicy.default_expiry_hours}h).>
+ }
+
+
+ ) : deptCertPolicy ? (
+
+
+ Expiry set by policy: {deptCertPolicy.default_expiry_hours} hours
+
+ ) : (
+
+
setExpiryHours(e.target.value)}
+ className="w-36"
+ />
+
Leave blank to use CA default.
+
+ )}
+
+
+ {/* Extensions granted (informational) */}
+ {deptCertPolicy && deptCertPolicy.all_extensions?.length > 0 && (
+
+
Extensions granted
+
+ {deptCertPolicy.all_extensions?.map((ext) => (
+ {ext}
+ ))}
+
+
+ )}
+
+ ) : (
+
+
+ Certificate
+
+
+
+
+
+ How to use
+
+
+{`# Save next to your private key, e.g.:
+echo '' > ~/.ssh/id_ed25519-cert.pub
+ssh -i ~/.ssh/id_ed25519 user@host`}
+
+
+
+ )}
+
+
+ { setSigningKey(null); setCertResult(null); }}>
+ Close
+
+ {!certResult && (
+
+ {isSigning && }
+ Sign certificate
+
+ )}
+
+
+
+
+ {/* ── Revoke certificate confirmation ───────────────────────────────────── */}
+
setRevokingCertId(null)}>
+
+
+ Revoke certificate?
+
+ This will permanently revoke the certificate. Any active SSH sessions using it will not be affected immediately, but no new authentications will be allowed.
+
+
+
+ Cancel
+
+ {isRevoking && }
+ Revoke
+
+
+
+
+
+ );
+}