From 58929fbfeff22062e45fadddf752ada378ba6905 Mon Sep 17 00:00:00 2001
From: James Bhattarai
Date: Sun, 8 Mar 2026 18:08:42 +0545
Subject: [PATCH] Feat: Implemented SUDO Department & API Key
---
src/App.tsx | 2 +
src/components/navigation/AppSidebar.tsx | 1 +
src/lib/api.ts | 72 +++-
src/pages/org/ApiKeysPage.tsx | 496 +++++++++++++++++++++++
src/pages/org/DepartmentsPage.tsx | 47 ++-
src/pages/org/ca/CADetailCard.tsx | 4 +-
src/pages/user/SSHKeysPage.tsx | 4 +-
7 files changed, 611 insertions(+), 15 deletions(-)
create mode 100644 src/pages/org/ApiKeysPage.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 3d68c6b..2b3a544 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -47,6 +47,7 @@ import OIDCClientsPage from "@/pages/org/OIDCClientsPage";
import CAsPage from "@/pages/org/CAsPage";
import DepartmentsPage from "@/pages/org/DepartmentsPage";
import PrincipalsPage from "@/pages/org/PrincipalsPage";
+import ApiKeysPage from "@/pages/org/ApiKeysPage";
import MyMembershipsPage from "@/pages/org/MyMembershipsPage";
import NetworksPage from "@/pages/org/NetworksPage";
import DevicesPage from "@/pages/org/DevicesPage";
@@ -184,6 +185,7 @@ function AppRoutes() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx
index 4b68272..2c3307f 100644
--- a/src/components/navigation/AppSidebar.tsx
+++ b/src/components/navigation/AppSidebar.tsx
@@ -57,6 +57,7 @@ const orgAdminNavItems = [
{ title: "Members", url: "/org/members", icon: Users },
{ title: "Departments", url: "/org/departments", icon: Layers },
{ title: "Principals", url: "/org/principals", icon: GitBranch },
+ { title: "API Keys", url: "/org/api-keys", icon: Key },
{ title: "Policies", url: "/org/policies", icon: Settings },
{ title: "ZeroTier Networks", url: "/org/zerotier/networks", icon: Network },
{ title: "ZeroTier Access", url: "/org/zerotier/access", icon: ShieldAlert },
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 23f3f5b..9322886 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -248,6 +248,38 @@ export interface LinkAccountResponse {
linked_account: LinkedAccount;
}
+export interface OrganizationApiKey {
+ id: string;
+ organization_id: string;
+ name: string;
+ description: string | null;
+ key_hash?: string; // Usually excluded from responses for security
+ last_used_at: string | null;
+ is_revoked: boolean;
+ revoked_at: string | null;
+ revoke_reason: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CertificateAuditLog {
+ id: string;
+ action: string;
+ certificate_serial: string;
+ key_id: string;
+ principals: string[];
+ user_id: string;
+ user_email: string | null;
+ issued_at: string;
+ valid_after: string;
+ valid_before: string;
+ ip_address: string | null;
+ user_agent: string | null;
+ message: string | null;
+ success: boolean;
+ created_at: string;
+}
+
class ApiError extends Error {
code: number;
type: string;
@@ -954,14 +986,14 @@ export const api = {
request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/departments`, {}, true, requestConfig),
// Create department
- createDepartment: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) =>
+ createDepartment: (orgId: string, name: string, description?: string, canSudo?: boolean, requestConfig?: RequestConfig) =>
request<{ department: Department }>(`/organizations/${orgId}/departments`, {
method: 'POST',
- body: JSON.stringify({ name, description }),
+ body: JSON.stringify({ name, description, can_sudo: canSudo }),
}, true, requestConfig),
// Update department
- updateDepartment: (orgId: string, deptId: string, data: { name?: string; description?: string }, requestConfig?: RequestConfig) =>
+ updateDepartment: (orgId: string, deptId: string, data: { name?: string; description?: string; can_sudo?: boolean }, requestConfig?: RequestConfig) =>
request<{ department: Department }>(`/organizations/${orgId}/departments/${deptId}`, {
method: 'PATCH',
body: JSON.stringify(data),
@@ -1130,6 +1162,39 @@ export const api = {
request<{ ca_id: string }>(`/organizations/${orgId}/cas/${caId}`, {
method: 'DELETE',
}, true, requestConfig),
+
+ // Get API keys for organization
+ getApiKeys: (orgId: string, requestConfig?: RequestConfig) =>
+ request<{ api_keys: OrganizationApiKey[]; count: number }>(`/organizations/${orgId}/api-keys`, {}, true, requestConfig),
+
+ // Create new API key
+ createApiKey: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) =>
+ request<{ api_key: OrganizationApiKey & { key?: string } }>(`/organizations/${orgId}/api-keys`, {
+ method: 'POST',
+ body: JSON.stringify({ name, description }),
+ }, true, requestConfig),
+
+ // Update API key
+ updateApiKey: (orgId: string, keyId: string, data: { name?: string; description?: string }, requestConfig?: RequestConfig) =>
+ request<{ api_key: OrganizationApiKey }>(`/organizations/${orgId}/api-keys/${keyId}`, {
+ method: 'PATCH',
+ body: JSON.stringify(data),
+ }, true, requestConfig),
+
+ // Delete API key
+ deleteApiKey: (orgId: string, keyId: string, requestConfig?: RequestConfig) =>
+ request<{ message: string }>(`/organizations/${orgId}/api-keys/${keyId}`, {
+ method: 'DELETE',
+ }, true, requestConfig),
+
+ // Get certificate audit logs for organization
+ getCertificateAuditLogs: (orgId: string, params?: Record, requestConfig?: RequestConfig) =>
+ request<{ audit_logs: CertificateAuditLog[]; count: number; page: number; per_page: number; pages: number }>(
+ `/organizations/${orgId}/certificates/audit${params ? '?' + new URLSearchParams(params).toString() : ''}`,
+ {},
+ true,
+ requestConfig
+ ),
},
invites: {
@@ -1557,6 +1622,7 @@ export interface Department {
organization_id: string;
name: string;
description: string | null;
+ can_sudo: boolean;
created_at: string;
updated_at: string;
deleted_at: string | null;
diff --git a/src/pages/org/ApiKeysPage.tsx b/src/pages/org/ApiKeysPage.tsx
new file mode 100644
index 0000000..f4d07cc
--- /dev/null
+++ b/src/pages/org/ApiKeysPage.tsx
@@ -0,0 +1,496 @@
+import { useState, useEffect, useRef } from "react";
+import {
+ Plus, Copy, Trash2, Loader2, AlertCircle, CheckCircle, Eye, EyeOff, MoreHorizontal, Edit2, Check
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { api, OrganizationApiKey } from "@/lib/api";
+import { useToast } from "@/hooks/use-toast";
+import { useOrg } from "@/contexts/OrgContext";
+import { formatDate } from "@/lib/date";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+
+interface NewApiKeyState {
+ key: string;
+ name: string;
+ description?: string;
+ createdAt: string;
+}
+
+interface EditingKey {
+ id: string;
+ name: string;
+ description: string | null;
+}
+
+function useCopyButton() {
+ const [copied, setCopied] = useState(false);
+ const copy = (text: string) => {
+ navigator.clipboard.writeText(text).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+ return { copied, copy };
+}
+
+export default function ApiKeysPage() {
+ const { toast } = useToast();
+ const { selectedOrgId: orgId } = useOrg();
+ const queryClient = useQueryClient();
+ const { copy, copied } = useCopyButton();
+
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
+ const [newSecret, setNewSecret] = useState(null);
+ const [editingKey, setEditingKey] = useState(null);
+ const [showKey, setShowKey] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+
+ const nameRef = useRef(null);
+ const descriptionRef = useRef(null);
+ const editNameRef = useRef(null);
+ const editDescriptionRef = useRef(null);
+
+ // Fetch API keys
+ const { data: apiKeysData, isLoading } = useQuery({
+ queryKey: ['api-keys', orgId],
+ queryFn: () => orgId ? api.organizations.getApiKeys(orgId) : null,
+ enabled: !!orgId,
+ });
+
+ // Create API key mutation
+ const { mutate: createKey, isPending: isCreatingKey } = useMutation({
+ mutationFn: () => {
+ if (!orgId) throw new Error('Organization ID not set');
+ const name = nameRef.current?.value;
+ const description = descriptionRef.current?.value;
+ if (!name) throw new Error('Name is required');
+ return api.organizations.createApiKey(orgId, name, description);
+ },
+ onSuccess: (data) => {
+ const apiKey = data.api_key;
+ setNewSecret({
+ key: apiKey.key || '',
+ name: apiKey.name,
+ description: apiKey.description || undefined,
+ createdAt: apiKey.created_at,
+ });
+ setIsCreateDialogOpen(false);
+ if (nameRef.current) nameRef.current.value = '';
+ if (descriptionRef.current) descriptionRef.current.value = '';
+ queryClient.invalidateQueries({ queryKey: ['api-keys', orgId] });
+ toast({
+ title: 'API Key Created',
+ description: 'Store the key value securely - you won\'t be able to see it again.',
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Failed to create API key',
+ description: 'Please try again.',
+ variant: 'destructive',
+ });
+ },
+ });
+
+ // Update API key mutation
+ const { mutate: updateKey, isPending: isUpdatingKey } = useMutation({
+ mutationFn: () => {
+ if (!orgId || !editingKey) throw new Error('Required data missing');
+ return api.organizations.updateApiKey(orgId, editingKey.id, {
+ name: editNameRef.current?.value,
+ description: editDescriptionRef.current?.value,
+ });
+ },
+ onSuccess: () => {
+ setIsEditDialogOpen(false);
+ queryClient.invalidateQueries({ queryKey: ['api-keys', orgId] });
+ toast({
+ title: 'API Key Updated',
+ description: 'Changes saved successfully.',
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Failed to update API key',
+ description: 'Please try again.',
+ variant: 'destructive',
+ });
+ },
+ });
+
+ // Delete API key mutation
+ const { mutate: deleteKey, isPending: isDeletingKey } = useMutation({
+ mutationFn: (keyId: string) => {
+ if (!orgId) throw new Error('Organization ID not set');
+ return api.organizations.deleteApiKey(orgId, keyId);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['api-keys', orgId] });
+ toast({
+ title: 'API Key Deleted',
+ description: 'The API key has been permanently removed.',
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Failed to delete API key',
+ description: 'Please try again.',
+ variant: 'destructive',
+ });
+ },
+ });
+
+ const handleCreateKey = () => {
+ setIsCreating(true);
+ createKey();
+ setIsCreating(false);
+ };
+
+ const handleEditKey = (key: OrganizationApiKey) => {
+ setEditingKey({
+ id: key.id,
+ name: key.name,
+ description: key.description,
+ });
+ setIsEditDialogOpen(true);
+ };
+
+ const handleUpdateKey = () => {
+ updateKey();
+ };
+
+ const handleDeleteKey = (keyId: string) => {
+ if (confirm('Are you sure you want to delete this API key? This action cannot be undone.')) {
+ deleteKey(keyId);
+ }
+ };
+
+ const apiKeys = apiKeysData?.api_keys || [];
+ const activeKeys = apiKeys.filter(k => !k.is_revoked);
+ const revokedKeys = apiKeys.filter(k => k.is_revoked);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
API Keys
+
+ Manage API keys for external integrations and programmatic access to your organization.
+
+
+
+ {/* New key notification */}
+ {newSecret && (
+
+
+
+
+ New API Key Created
+
+
+ Store this key securely. You won't be able to see it again.
+
+
+
+
+
Key Name
+
{newSecret.name}
+
+
+
+ API Key Value
+ copy(newSecret.key)}
+ className="h-auto p-0 text-xs"
+ >
+ {copied ? (
+
+ Copied
+
+ ) : (
+
+ Copy
+
+ )}
+
+
+
+ {newSecret.key}
+
+
+ setNewSecret(null)}
+ variant="outline"
+ className="w-full"
+ >
+ Close
+
+
+
+ )}
+
+ {/* Create button */}
+
+
setIsCreateDialogOpen(true)}
+ className="gap-2"
+ >
+
+ Create API Key
+
+
+
+ {/* Active Keys */}
+ {activeKeys.length > 0 && (
+
+
Active Keys
+
+ {activeKeys.map((key) => (
+
+
+
+
+
+
{key.name}
+ {key.last_used_at && (
+
+ Last used: {formatDate(key.last_used_at)}
+
+ )}
+
+ {key.description && (
+
+ {key.description}
+
+ )}
+
+ Created {formatDate(key.created_at)}
+
+
+
+
+
+
+
+
+
+ handleEditKey(key)}
+ className="cursor-pointer"
+ >
+
+ Edit
+
+
+ handleDeleteKey(key.id)}
+ className="text-destructive cursor-pointer"
+ disabled={isDeletingKey}
+ >
+
+ Delete
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Revoked Keys */}
+ {revokedKeys.length > 0 && (
+
+
Revoked Keys
+
+ {revokedKeys.map((key) => (
+
+
+
+
+
+ {key.name}
+
+
+ Revoked {formatDate(key.revoked_at || '')}
+ {key.revoke_reason && ` - ${key.revoke_reason}`}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Empty state */}
+ {apiKeys.length === 0 && (
+
+
+
+ No API Keys
+
+ Create your first API key to enable external integrations.
+
+ setIsCreateDialogOpen(true)}
+ variant="outline"
+ className="gap-2"
+ >
+
+ Create API Key
+
+
+
+ )}
+
+ {/* Create Dialog */}
+
+
+
+ Create API Key
+
+ Create a new API key for external integrations. The key will be displayed only once.
+
+
+
+
+ Key Name
+
+
+
+ Description (Optional)
+
+
+
+ setIsCreateDialogOpen(false)}
+ disabled={isCreating || isCreatingKey}
+ >
+ Cancel
+
+
+ {isCreatingKey ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ 'Create Key'
+ )}
+
+
+
+
+
+
+ {/* Edit Dialog */}
+
+
+
+ Edit API Key
+
+ Update the name and description of this API key.
+
+
+ {editingKey && (
+
+
+ Key Name
+
+
+
+ Description (Optional)
+
+
+
+ setIsEditDialogOpen(false)}
+ disabled={isUpdatingKey}
+ >
+ Cancel
+
+
+ {isUpdatingKey ? (
+ <>
+
+ Updating...
+ >
+ ) : (
+ 'Update Key'
+ )}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/org/DepartmentsPage.tsx b/src/pages/org/DepartmentsPage.tsx
index b0cdc94..d61e54d 100644
--- a/src/pages/org/DepartmentsPage.tsx
+++ b/src/pages/org/DepartmentsPage.tsx
@@ -390,7 +390,7 @@ export default function DepartmentsPage() {
const [selectedPrincipalId, setSelectedPrincipalId] = useState("");
const [isLinking, setIsLinking] = useState(false);
const [editingDept, setEditingDept] = useState(null);
- const [formData, setFormData] = useState({ name: "", description: "" });
+ const [formData, setFormData] = useState({ name: "", description: "", can_sudo: false });
const [expandedPolicies, setExpandedPolicies] = useState>(new Set());
const [expandedMembers, setExpandedMembers] = useState>(new Set());
@@ -502,12 +502,13 @@ export default function DepartmentsPage() {
const handleCreateDepartment = async () => {
if (!orgId || !formData.name.trim()) return;
try {
- await api.organizations.createDepartment(
+ const dept = await api.organizations.createDepartment(
orgId,
formData.name,
- formData.description || undefined
+ formData.description || undefined,
+ formData.can_sudo
);
- setFormData({ name: "", description: "" });
+ setFormData({ name: "", description: "", can_sudo: false });
setIsCreateDialogOpen(false);
await fetchDepartments(orgId);
} catch (err) {
@@ -522,8 +523,9 @@ export default function DepartmentsPage() {
await api.organizations.updateDepartment(orgId, editingDept.id, {
name: formData.name,
description: formData.description || undefined,
+ can_sudo: formData.can_sudo,
});
- setFormData({ name: "", description: "" });
+ setFormData({ name: "", description: "", can_sudo: false });
setEditingDept(null);
setIsEditDialogOpen(false);
await fetchDepartments(orgId);
@@ -546,7 +548,7 @@ export default function DepartmentsPage() {
const openEditDialog = (dept: Department) => {
setEditingDept(dept);
- setFormData({ name: dept.name, description: dept.description || "" });
+ setFormData({ name: dept.name, description: dept.description || "", can_sudo: dept.can_sudo || false });
setIsEditDialogOpen(true);
};
@@ -572,7 +574,7 @@ export default function DepartmentsPage() {
Manage departments and organize team members
- { setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
+ { setFormData({ name: "", description: "", can_sudo: false }); setIsCreateDialogOpen(true); }}>
Create Department
@@ -615,10 +617,15 @@ export default function DepartmentsPage() {
-
+
{dept.name}
+ {dept.can_sudo && (
+
+ Sudo enabled
+
+ )}
{dept.description && (
@@ -751,6 +758,18 @@ export default function DepartmentsPage() {
rows={3}
/>
+
+
+
Allow sudo access
+
Members of this department can use sudo
+
+
setFormData({ ...formData, can_sudo: e.target.checked })}
+ className="w-4 h-4 cursor-pointer"
+ />
+
setIsCreateDialogOpen(false)}>
@@ -792,6 +811,18 @@ export default function DepartmentsPage() {
rows={3}
/>
+
+
+
Allow sudo access
+
Members of this department can use sudo
+
+
setFormData({ ...formData, can_sudo: e.target.checked })}
+ className="w-4 h-4 cursor-pointer"
+ />
+
setIsEditDialogOpen(false)}>
diff --git a/src/pages/org/ca/CADetailCard.tsx b/src/pages/org/ca/CADetailCard.tsx
index c9400be..0ad0f08 100644
--- a/src/pages/org/ca/CADetailCard.tsx
+++ b/src/pages/org/ca/CADetailCard.tsx
@@ -48,10 +48,10 @@ export function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardPro
// ── User CA: server trusts this public key so it accepts user certs ──────
const userCaServerSnippet = `# On each SSH server — trust Secuird-issued user certificates:
-echo '${ca.public_key.trim()}' >> /etc/ssh/trusted_user_ca_keys
+echo '${ca.public_key.trim()}' >> /etc/ssh/trusted_user_ca
# /etc/ssh/sshd_config (add once, then reload sshd):
-TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
+TrustedUserCAKeys /etc/ssh/trusted_user_ca
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
# Create /etc/ssh/auth_principals/ containing one principal per line.`;
diff --git a/src/pages/user/SSHKeysPage.tsx b/src/pages/user/SSHKeysPage.tsx
index d7ffc57..9b45212 100644
--- a/src/pages/user/SSHKeysPage.tsx
+++ b/src/pages/user/SSHKeysPage.tsx
@@ -689,9 +689,9 @@ export default function SSHKeysPage() {
{`# On each SSH server:
-echo '' >> /etc/ssh/trusted_user_ca_keys
+echo '' >> /etc/ssh/trusted_user_ca
# In /etc/ssh/sshd_config:
-TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys`}
+TrustedUserCAKeys /etc/ssh/trusted_user_ca`}