Merge pull request #4 from jamesii-b/gatehouse/secuird-CA-merge-v2.01

Gatehouse/secuird ca merge v2.01
This commit is contained in:
2026-04-03 12:02:33 +10:30
committed by GitHub
50 changed files with 2654 additions and 573 deletions

Before

Width:  |  Height:  |  Size: 661 B

After

Width:  |  Height:  |  Size: 661 B

+7 -1
View File
@@ -36,6 +36,7 @@ import UserSecurityPage from "@/pages/user/SecurityPage";
import LinkedAccountsPage from "@/pages/user/LinkedAccountsPage";
import ActivityPage from "@/pages/user/ActivityPage";
import SSHKeysPage from "@/pages/user/SSHKeysPage";
import CLIGuidePage from "@/pages/user/CLIGuidePage";
// Organization pages
import OrgOverviewPage from "@/pages/org/OrgOverviewPage";
@@ -47,10 +48,12 @@ 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";
import AccessPage from "@/pages/org/AccessPage";
import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage";
import SystemAuditPage from "@/pages/admin/SystemAuditPage";
import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage";
import OrgSetupPage from "@/pages/auth/OrgSetupPage";
@@ -170,10 +173,11 @@ function AppRoutes() {
<Route element={<ProtectedLayout />}>
{/* User routes */}
<Route path="/profile" element={<ProfilePage />} />
<Route path="/security" element={<SecurityPage />} />
<Route path="/account/security" element={<UserSecurityPage />} />
<Route path="/linked-accounts" element={<LinkedAccountsPage />} />
<Route path="/activity" element={<ActivityPage />} />
<Route path="/ssh-keys" element={<SSHKeysPage />} />
<Route path="/cli-guide" element={<CLIGuidePage />} />
{/* Organization routes — org members: overview + own memberships only */}
<Route path="/org" element={<RequireOrgMember><OrgOverviewPage /></RequireOrgMember>} />
@@ -184,6 +188,7 @@ function AppRoutes() {
<Route path="/org/members" element={<RequireAdmin><MembersPage /></RequireAdmin>} />
<Route path="/org/departments" element={<RequireAdmin><DepartmentsPage /></RequireAdmin>} />
<Route path="/org/principals" element={<RequireAdmin><PrincipalsPage /></RequireAdmin>} />
<Route path="/org/api-keys" element={<RequireAdmin><ApiKeysPage /></RequireAdmin>} />
<Route path="/org/policies" element={<RequireAdmin><PoliciesPage /></RequireAdmin>} />
<Route path="/org/policies/compliance" element={<RequireAdmin><CompliancePage /></RequireAdmin>} />
<Route path="/org/audit" element={<RequireAdmin><OrgAuditPage /></RequireAdmin>} />
@@ -191,6 +196,7 @@ function AppRoutes() {
<Route path="/org/cas" element={<RequireAdmin><CAsPage /></RequireAdmin>} />
<Route path="/org/zerotier/networks" element={<RequireAdmin><NetworksPage /></RequireAdmin>} />
<Route path="/org/zerotier/access" element={<RequireAdmin><AccessPage /></RequireAdmin>} />
<Route path="/org/zerotier/config" element={<RequireAdmin><ZeroTierConfigPage /></RequireAdmin>} />
{/* Admin routes — org admin/owner only */}
<Route path="/admin/audit" element={<RequireAdmin><SystemAuditPage /></RequireAdmin>} />
@@ -1,21 +1,21 @@
import { cn } from "@/lib/utils";
interface GatehouseLogoProps {
interface SecuirdLogoProps {
size?: "sm" | "md" | "lg";
variant?: "default" | "light";
className?: string;
}
/**
* Gatehouse Logo - Abstract gate/doorway mark
* Secuird Logo - Abstract gate/doorway mark
* Represents controlled entry and policy enforcement
* Two vertical pillars forming a gateway with negative space
*/
export function GatehouseLogo({
export function SecuirdLogo({
size = "md",
variant = "default",
className
}: GatehouseLogoProps) {
}: SecuirdLogoProps) {
const sizeClasses = {
sm: "w-8 h-8",
md: "w-9 h-9",
+6 -6
View File
@@ -65,9 +65,9 @@ const isDev = import.meta.env.DEV;
const originalFetch = window.fetch;
// Avoid patching multiple times during HMR
const globalAny = window as unknown as { __gatehouseFetchPatched?: boolean };
if (isDev && !globalAny.__gatehouseFetchPatched) {
globalAny.__gatehouseFetchPatched = true;
const globalAny = window as unknown as { __secuirdFetchPatched?: boolean };
if (isDev && !globalAny.__secuirdFetchPatched) {
globalAny.__secuirdFetchPatched = true;
try {
window.fetch = async function (input, init) {
@@ -165,9 +165,9 @@ if (isDev && !globalAny.__gatehouseFetchPatched) {
};
} catch (patchError) {
// Log any errors during fetch patching with full stack trace
console.error("[Gatehouse DevTools] Failed to patch fetch:", patchError);
console.error("[Secuird DevTools] Failed to patch fetch:", patchError);
if (patchError instanceof Error) {
console.error("[Gatehouse DevTools] Stack trace:", patchError.stack);
console.error("[Secuird DevTools] Stack trace:", patchError.stack);
}
}
}
@@ -220,7 +220,7 @@ export default function ApiDevTools() {
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-700 bg-slate-800">
<div className="flex items-center gap-3">
<span className="font-semibold text-sm">Gatehouse API DevTools</span>
<span className="font-semibold text-sm">Secuird API DevTools</span>
<Badge variant="outline" className="text-xs border-slate-600">
{logs.length} requests
</Badge>
+1 -1
View File
@@ -1,5 +1,5 @@
import { Link, Outlet, useLocation } from "react-router-dom";
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
import { SecuirdLogo as GatehouseLogo } from "@/components/branding/SecuirdLogo";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
@@ -7,6 +7,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { AddPasskeyWizard } from '@/components/security/AddPasskeyWizard';
import { TotpEnrollmentWizard } from '@/components/security/TotpEnrollmentWizard';
import { api } from '@/lib/api';
import { formatDate } from '@/lib/date';
export default function MfaEnforcementLayout() {
const navigate = useNavigate();
@@ -97,7 +98,7 @@ export default function MfaEnforcementLayout() {
<header className="h-14 border-b border-border bg-card flex items-center justify-between px-4 flex-shrink-0">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-primary" />
<span className="font-semibold text-foreground">Gatehouse</span>
<span className="font-semibold text-foreground">Secuird</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
@@ -124,7 +125,7 @@ export default function MfaEnforcementLayout() {
{mfaCompliance?.deadline_at && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-center">
<p className="text-sm font-medium text-destructive">
Deadline: {new Date(mfaCompliance.deadline_at).toLocaleDateString()}
Deadline: {formatDate(mfaCompliance.deadline_at)}
</p>
</div>
)}
+4 -4
View File
@@ -1,5 +1,5 @@
import { Outlet, Link } from "react-router-dom";
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
import { SecuirdLogo } from "@/components/branding/SecuirdLogo";
export default function PublicLayout() {
return (
@@ -11,8 +11,8 @@ export default function PublicLayout() {
<header className="relative z-10 w-full py-6 px-4">
<div className="max-w-md mx-auto">
<Link to="/" className="flex items-center gap-2.5 justify-center">
<GatehouseLogo size="md" />
<span className="text-xl font-semibold text-foreground tracking-tight">Gatehouse</span>
<SecuirdLogo size="md" />
<span className="text-xl font-semibold text-foreground tracking-tight">Secuird</span>
</Link>
</div>
</header>
@@ -28,7 +28,7 @@ export default function PublicLayout() {
<footer className="relative z-10 py-6 px-4">
<div className="max-w-md mx-auto text-center">
<p className="text-sm text-muted-foreground">
© {new Date().getFullYear()} Gatehouse. Identity & Access.
© {new Date().getFullYear()} Secuird. Identity & Access.
</p>
</div>
</footer>
+13 -8
View File
@@ -17,8 +17,9 @@ import {
Network,
Monitor,
ShieldAlert,
BookOpen,
} from "lucide-react";
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
import { SecuirdLogo } from "@/components/branding/SecuirdLogo";
import { NavLink } from "@/components/NavLink";
import { useAuth } from "@/contexts/AuthContext";
import {
@@ -38,10 +39,11 @@ import { cn } from "@/lib/utils";
const userNavItems = [
{ title: "Profile", url: "/profile", icon: User },
{ title: "Security", url: "/security", icon: Shield },
{ title: "Security", url: "/account/security", icon: Shield },
{ title: "SSH Keys", url: "/ssh-keys", icon: Terminal },
{ title: "Linked Accounts", url: "/linked-accounts", icon: Link2 },
{ title: "Activity", url: "/activity", icon: Activity },
{ title: "CLI Guide", url: "/cli-guide", icon: BookOpen },
];
// Visible to ALL org members
@@ -57,23 +59,26 @@ 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 },
{ title: "ZeroTier Config", url: "/org/zerotier/config", icon: Settings },
];
const adminNavItems = [
{ title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck },
{ title: "OIDC Clients", url: "/org/clients", icon: Key },
{ title: "Org Audit Log", url: "/org/audit", icon: FileText },
{ title: "System Logs", url: "/admin/audit", icon: ScrollText },
];
const systemLogNavItem = { title: "System Logs", url: "/admin/audit", icon: ScrollText };
export function AppSidebar() {
const { state } = useSidebar();
const collapsed = state === "collapsed";
const location = useLocation();
const { isOrgAdmin, isOrgMember } = useAuth();
const { isOrgAdmin, isOrgMember, canViewSystemLogs } = useAuth();
const isActive = (path: string) => location.pathname === path;
const isOrgActive = orgAdminNavItems.some((item) => isActive(item.url)) || adminNavItems.some((item) => isActive(item.url));
@@ -90,10 +95,10 @@ export function AppSidebar() {
{/* Logo */}
<SidebarHeader className="p-4 border-b border-sidebar-border">
<div className="flex items-center gap-3">
<GatehouseLogo size="sm" variant="light" />
<SecuirdLogo size="sm" variant="light" />
{!collapsed && (
<span className="text-lg font-semibold text-sidebar-foreground tracking-tight">
Gatehouse
Secuird
</span>
)}
</div>
@@ -180,7 +185,7 @@ export function AppSidebar() {
)}
<SidebarGroupContent>
<SidebarMenu>
{adminNavItems.map((item) => (
{[...adminNavItems, ...(canViewSystemLogs ? [systemLogNavItem] : [])].map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<NavLink
@@ -210,7 +215,7 @@ export function AppSidebar() {
<SidebarFooter className="p-4 border-t border-sidebar-border">
{!collapsed && (
<div className="text-xs text-sidebar-muted">
v1.0.0 Self-hosted
{import.meta.env.VITE_APP_VERSION ?? 'Secuird'}
</div>
)}
</SidebarFooter>
@@ -275,7 +275,7 @@ export function TotpEnrollmentWizard({
</div>
<p className="text-xs text-muted-foreground">
Open your authenticator app and enter the 6-digit code shown for Gatehouse.
Open your authenticator app and enter the 6-digit code shown for Secuird.
</p>
<div className="flex justify-end gap-2 pt-2">
+2 -2
View File
@@ -1,4 +1,4 @@
// Gatehouse Configuration
// Secuird Configuration
// Environment-specific settings for the application
export const config = {
@@ -9,7 +9,7 @@ export const config = {
// App metadata
app: {
name: "Gatehouse",
name: "Secuird",
description: "Identity & Access Platform",
},
+12 -2
View File
@@ -17,6 +17,8 @@ interface AuthContextType {
isAuthenticated: boolean;
isOrgAdmin: boolean;
isOrgMember: boolean;
/** True when the current user is allowed to view the system-wide audit log. */
canViewSystemLogs: boolean;
mfaCompliance: MfaComplianceSummary | null;
requiresMfaEnrollment: boolean;
login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise<LoginResult>;
@@ -32,7 +34,7 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType | null>(null);
// LocalStorage key for MFA compliance persistence
const MFA_COMPLIANCE_KEY = 'gatehouse_mfa_compliance';
const MFA_COMPLIANCE_KEY = 'secuird_mfa_compliance';
// Helper to persist MFA compliance to localStorage
function persistMfaCompliance(compliance: MfaComplianceSummary | null): void {
@@ -265,7 +267,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await checkOrgAdmin();
if (!skipNavigate) {
navigate('/profile');
const orgsData = await api.users.organizations();
const hasOrg = orgsData.organizations && orgsData.organizations.length > 0;
if (hasOrg) {
navigate('/profile');
} else {
navigate('/org-setup');
}
}
}, [navigate, checkOrgAdmin]);
@@ -291,6 +300,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isAuthenticated: !!user,
isOrgAdmin,
isOrgMember,
canViewSystemLogs: user?.can_view_system_logs ?? false,
mfaCompliance,
requiresMfaEnrollment,
login,
+46 -45
View File
@@ -4,79 +4,80 @@
@tailwind components;
@tailwind utilities;
/* Gatehouse Design System - Enterprise Identity & Access Platform
/* Secuird Design System - Enterprise Identity & Access Platform
Authoritative, infrastructure-grade aesthetic with slate/charcoal/muted blue palette
Colors are HSL for theming flexibility
*/
@layer base {
:root {
/* Core palette - Deep slate with teal accent */
--background: 210 20% 98%;
--foreground: 222 47% 11%;
/* Core palette - Light blue-gray with teal accent */
--background: 216 22% 94%; /* cool blue-gray — cards lift clearly off this */
--foreground: 222 47% 9%; /* near-black navy */
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--card: 0 0% 100%; /* pure white — 6% lightness gap over bg */
--card-foreground: 222 47% 9%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--popover-foreground: 222 47% 9%;
/* Primary - Deep navy for trust */
--primary: 222 47% 20%;
--primary-foreground: 210 40% 98%;
/* Primary — teal, fully saturated, dark enough to read on white */
--primary: 173 65% 36%;
--primary-foreground: 0 0% 100%;
/* Secondary - Soft slate */
--secondary: 215 20% 95%;
--secondary-foreground: 222 47% 20%;
/* Secondary — cool blue-gray, clearly darker than bg */
--secondary: 216 20% 91%;
--secondary-foreground: 222 47% 18%;
/* Muted - Subtle backgrounds */
--muted: 215 20% 96%;
--muted-foreground: 215 16% 47%;
/* Muted — noticeably darker than secondary, used for section bg */
--muted: 216 18% 88%;
--muted-foreground: 222 18% 42%;
/* Accent - Teal for actions and highlights */
--accent: 173 58% 39%;
/* Accent — same teal as primary */
--accent: 173 65% 36%;
--accent-foreground: 0 0% 100%;
/* Semantic colors */
--destructive: 0 72% 51%;
/* Semantic */
--destructive: 0 72% 48%;
--destructive-foreground: 0 0% 100%;
--success: 152 69% 31%;
--success: 152 60% 30%;
--success-foreground: 0 0% 100%;
--warning: 38 92% 50%;
--warning: 38 90% 48%;
--warning-foreground: 0 0% 100%;
--info: 199 89% 48%;
--info: 199 80% 44%;
--info-foreground: 0 0% 100%;
/* UI elements */
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 173 58% 39%;
/* UI chrome */
--border: 216 18% 84%; /* clearly visible on white card */
--input: 216 18% 92%;
--ring: 173 65% 36%;
--radius: 0.5rem;
/* Sidebar - Darker for visual hierarchy */
--sidebar-background: 222 47% 11%;
--sidebar-foreground: 215 20% 85%;
--sidebar-primary: 173 58% 45%;
/* Sidebar */
--sidebar-background: 222 30% 95%;
--sidebar-foreground: 222 47% 18%;
--sidebar-primary: 173 65% 36%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 222 40% 18%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 222 40% 20%;
--sidebar-ring: 173 58% 45%;
--sidebar-muted: 215 16% 55%;
--sidebar-accent: 216 20% 88%;
--sidebar-accent-foreground: 222 47% 9%;
--sidebar-border: 216 18% 84%;
--sidebar-ring: 173 65% 36%;
--sidebar-muted: 222 20% 48%;
/* Custom gradients and effects */
--gradient-brand: linear-gradient(135deg, hsl(222 47% 20%), hsl(222 47% 11%));
--gradient-accent: linear-gradient(135deg, hsl(173 58% 39%), hsl(173 58% 32%));
--gradient-subtle: linear-gradient(135deg, hsl(210 20% 98%), hsl(215 20% 96%));
--shadow-sm: 0 1px 2px 0 hsl(222 47% 11% / 0.05);
--shadow-md: 0 4px 6px -1px hsl(222 47% 11% / 0.1), 0 2px 4px -2px hsl(222 47% 11% / 0.1);
--shadow-lg: 0 10px 15px -3px hsl(222 47% 11% / 0.1), 0 4px 6px -4px hsl(222 47% 11% / 0.1);
--shadow-card: 0 1px 3px 0 hsl(222 47% 11% / 0.06), 0 1px 2px -1px hsl(222 47% 11% / 0.06);
/* Gradients */
--gradient-brand: linear-gradient(135deg, hsl(173 65% 36%), hsl(173 65% 28%));
--gradient-accent: linear-gradient(135deg, hsl(173 65% 36%), hsl(173 65% 28%));
--gradient-subtle: linear-gradient(135deg, hsl(216 28% 97%), hsl(216 18% 93%));
/* Shadows — stronger alpha so cards lift off the bg */
--shadow-sm: 0 1px 2px 0 hsl(222 47% 9% / 0.10);
--shadow-md: 0 4px 6px -1px hsl(222 47% 9% / 0.14), 0 2px 4px -2px hsl(222 47% 9% / 0.10);
--shadow-lg: 0 10px 15px -3px hsl(222 47% 9% / 0.14), 0 4px 6px -4px hsl(222 47% 9% / 0.10);
--shadow-card: 0 2px 6px 0 hsl(222 47% 9% / 0.10), 0 1px 2px -1px hsl(222 47% 9% / 0.08);
}
.dark {
+159 -16
View File
@@ -1,4 +1,4 @@
// API Client for Gatehouse Backend
// API Client for Secuird Backend
// Uses Bearer token authentication
import { config } from '@/config';
@@ -33,6 +33,10 @@ export interface User {
has_password?: boolean;
totp_enabled?: boolean;
linked_providers?: string[];
/** Session-derived group memberships (from OIDC claims or session device_info). */
groups?: string[];
/** Whether the current user is allowed to access the system-wide audit log. */
can_view_system_logs?: boolean;
}
export interface Organization {
@@ -244,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;
@@ -259,8 +295,8 @@ class ApiError extends Error {
}
// Token storage keys
const TOKEN_KEY = 'gatehouse_token';
const TOKEN_EXPIRY_KEY = 'gatehouse_token_expiry';
const TOKEN_KEY = 'secuird_token';
const TOKEN_EXPIRY_KEY = 'secuird_token_expiry';
// Token management
export const tokenManager = {
@@ -938,7 +974,7 @@ export const api = {
// Get organization audit logs
getAuditLogs: (orgId: string, params?: Record<string, string>, requestConfig?: RequestConfig) =>
request<{ audit_logs: AuditLogEntry[]; count: number }>(
request<{ audit_logs: AuditLogEntry[]; count: number; page: number; per_page: number; pages: number }>(
`/organizations/${orgId}/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`,
{},
true,
@@ -950,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),
@@ -1081,6 +1117,13 @@ export const api = {
method: 'DELETE',
}, true, requestConfig),
// Update OIDC client (name and/or redirect_uris)
updateClient: (orgId: string, clientId: string, data: { name?: string; redirect_uris?: string[] }, requestConfig?: RequestConfig) =>
request<{ client: OIDCClient }>(`/organizations/${orgId}/clients/${clientId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}, true, requestConfig),
// Send MFA reminder to a member
sendMfaReminder: (orgId: string, userId: string, requestConfig?: RequestConfig) =>
request<{ message: string }>(`/organizations/${orgId}/members/${userId}/send-mfa-reminder`, {
@@ -1126,6 +1169,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<string, string>, 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: {
@@ -1301,6 +1377,14 @@ export const api = {
{ method: "DELETE" }, true, requestConfig,
),
/** List all ZeroTier networks from the org's controller/account, annotated
* with whether each is already managed as a portal network. */
listAvailableZtNetworks: (orgId: string, requestConfig?: RequestConfig) =>
request<{ networks: AvailableZtNetwork[]; count: number; zt_error?: string }>(
`/organizations/${orgId}/zerotier/available-networks`,
{}, true, requestConfig,
),
getNetworkMembers: (orgId: string, networkId: string, requestConfig?: RequestConfig) =>
request<{ memberships: DeviceNetworkMembership[]; count: number }>(
`/organizations/${orgId}/networks/${networkId}/members`,
@@ -1373,6 +1457,12 @@ export const api = {
`/organizations/${orgId}/approvals`, {}, true, requestConfig,
),
adminListAllApprovals: (orgId: string, networkId?: string, state?: string, requestConfig?: RequestConfig) =>
request<{ approvals: UserNetworkApproval[]; count: number }>(
`/organizations/${orgId}/admin/approvals${networkId || state ? `?${new URLSearchParams(Object.fromEntries(Object.entries({ network_id: networkId, state }).filter(([, v]) => v != null) as [string, string][]))}` : ""}`,
{}, true, requestConfig,
),
listPendingApprovals: (orgId: string, networkId?: string, requestConfig?: RequestConfig) =>
request<{ approvals: UserNetworkApproval[]; count: number }>(
`/organizations/${orgId}/approvals/pending${networkId ? `?network_id=${networkId}` : ""}`,
@@ -1489,25 +1579,25 @@ export const api = {
true, requestConfig,
),
// ── ZeroTier Controller (admin) ──────────────────────────────────────────
getZtStatus: (requestConfig?: RequestConfig) =>
// ── ZeroTier Controller (org-scoped admin) ─────────────────────────────────
getZtStatus: (orgId: string, requestConfig?: RequestConfig) =>
request<{ status: Record<string, unknown> }>(
"/admin/zerotier/status", {}, true, requestConfig,
`/admin/zerotier/status?org_id=${orgId}`, {}, true, requestConfig,
),
listZtNetworks: (requestConfig?: RequestConfig) =>
listZtNetworks: (orgId: string, requestConfig?: RequestConfig) =>
request<{ networks: ZeroTierNetwork[]; count: number }>(
"/admin/zerotier/networks", {}, true, requestConfig,
`/admin/zerotier/networks?org_id=${orgId}`, {}, true, requestConfig,
),
getZtNetwork: (networkId: string, requestConfig?: RequestConfig) =>
getZtNetwork: (orgId: string, networkId: string, requestConfig?: RequestConfig) =>
request<{ network: ZeroTierNetwork }>(
`/admin/zerotier/networks/${networkId}`, {}, true, requestConfig,
`/admin/zerotier/networks/${networkId}?org_id=${orgId}`, {}, true, requestConfig,
),
listZtMembers: (networkId: string, requestConfig?: RequestConfig) =>
listZtMembers: (orgId: string, networkId: string, requestConfig?: RequestConfig) =>
request<{ members: ZeroTierMember[]; count: number }>(
`/admin/zerotier/networks/${networkId}/members`, {}, true, requestConfig,
`/admin/zerotier/networks/${networkId}/members?org_id=${orgId}`, {}, true, requestConfig,
),
triggerReconciliation: (requestConfig?: RequestConfig) =>
@@ -1515,6 +1605,26 @@ export const api = {
"/admin/zerotier/reconcile",
{ method: "POST" }, true, requestConfig,
),
// ── Per-org ZeroTier config ──────────────────────────────────────────────
getOrgZtConfig: (orgId: string, requestConfig?: RequestConfig) =>
request<{ zerotier_config: ZeroTierOrgConfig }>(
`/organizations/${orgId}/zerotier-config`,
{}, true, requestConfig,
),
setOrgZtConfig: (orgId: string, data: ZeroTierOrgConfigInput, requestConfig?: RequestConfig) =>
request<{ zerotier_config: ZeroTierOrgConfig; connectivity_test: { ok: boolean; error: string | null } }>(
`/organizations/${orgId}/zerotier-config`,
{ method: "PUT", body: JSON.stringify(data) },
true, requestConfig,
),
deleteOrgZtConfig: (orgId: string, requestConfig?: RequestConfig) =>
request<{ message: string }>(
`/organizations/${orgId}/zerotier-config`,
{ method: "DELETE" }, true, requestConfig,
),
},
};
@@ -1553,6 +1663,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;
@@ -1824,6 +1935,21 @@ export interface PortalNetwork {
active_membership_count?: number;
}
/** A ZeroTier network returned from the controller, annotated with whether
* it is already managed as a portal network in Secuird. */
export interface AvailableZtNetwork {
id: string;
name: string;
description: string | null;
owner_id: string | null;
online_member_count: number;
authorized_member_count: number;
total_member_count: number;
already_managed: boolean;
portal_network_id: string | null;
portal_network_name: string | null;
}
export interface Device {
id: string;
user_id: string;
@@ -1968,4 +2094,21 @@ export interface ZeroTierNetwork {
ip_assignment_pools: Record<string, unknown>[];
routes: Record<string, unknown>[];
};
}
/** Current per-org ZeroTier config as returned by GET /organizations/:id/zerotier-config */
export interface ZeroTierOrgConfig {
/** Whether an API token has been saved (the actual value is never returned). */
zt_api_token_set: boolean;
/** Custom controller / Central base URL, or null when server default is used. */
zt_api_url: string | null;
/** "central" | "controller", or null when server default is used. */
zt_api_mode: "central" | "controller" | null;
}
/** Body for PUT /organizations/:id/zerotier-config */
export interface ZeroTierOrgConfigInput {
zt_api_token: string;
zt_api_url: string;
zt_api_mode: "central" | "controller";
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Date/time formatting utilities.
*
* All timestamps from the API are stored in UTC (ISO 8601, e.g.
* "2026-03-06T14:30:00Z" or "2026-03-06T14:30:00.000Z"). This module
* provides helpers that always parse those strings as UTC and then render
* them in the *user's local timezone* as reported by the browser.
*
* Usage
* -----
* import { formatDateTime, formatDate, formatRelative } from "@/lib/date";
*
* formatDateTime("2026-03-06T14:30:00Z")
* // → "Mar 6, 2026, 2:30:00 PM" (user's local tz)
*
* formatDate("2026-03-06T14:30:00Z")
* // → "Mar 6, 2026"
*
* formatRelative("2026-03-06T14:30:00Z")
* // → "5 minutes ago" (via Intl.RelativeTimeFormat)
*/
/**
* Ensure the raw API string is treated as UTC.
* The backend BaseModel serialises with a trailing "Z", but we handle
* the "+00:00" form and bare ISO strings (YYYY-MM-DDTHH:MM:SS) too.
*/
function toUtcDate(raw: string | null | undefined): Date | null {
if (!raw) return null;
// Already ends with Z or has an offset → parse directly
if (raw.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(raw)) {
return new Date(raw);
}
// Bare ISO string without timezone → treat as UTC
return new Date(raw + "Z");
}
/**
* Format a UTC timestamp as a human-readable date + time in the user's
* local timezone.
*
* @param raw - ISO 8601 UTC string from the API
* @param opts - Optional Intl.DateTimeFormatOptions overrides
*/
export function formatDateTime(
raw: string | null | undefined,
opts?: Intl.DateTimeFormatOptions
): string {
const d = toUtcDate(raw);
if (!d || isNaN(d.getTime())) return "—";
return new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: false,
...opts,
}).format(d);
}
/**
* Format a UTC timestamp as a date-only string in the user's local timezone.
*/
export function formatDate(
raw: string | null | undefined,
opts?: Intl.DateTimeFormatOptions
): string {
const d = toUtcDate(raw);
if (!d || isNaN(d.getTime())) return "—";
return new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "numeric",
...opts,
}).format(d);
}
/**
* Format a UTC timestamp as a concise time-ago string.
* Falls back to formatDateTime for dates older than 30 days.
*/
export function formatRelative(raw: string | null | undefined): string {
const d = toUtcDate(raw);
if (!d || isNaN(d.getTime())) return "—";
const diffMs = d.getTime() - Date.now();
const diffSec = Math.round(diffMs / 1000);
const absSec = Math.abs(diffSec);
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
if (absSec < 60) return rtf.format(diffSec, "second");
if (absSec < 3600) return rtf.format(Math.round(diffSec / 60), "minute");
if (absSec < 86400) return rtf.format(Math.round(diffSec / 3600), "hour");
if (absSec < 86400 * 30) return rtf.format(Math.round(diffSec / 86400), "day");
return formatDateTime(raw);
}
/**
* Return a Date object for a UTC ISO string (or null on invalid input).
* Useful when you need to compare timestamps.
*/
export function parseUtcDate(raw: string | null | undefined): Date | null {
return toUtcDate(raw);
}
+2 -1
View File
@@ -61,7 +61,8 @@ import { useAuth } from "@/contexts/AuthContext";
function formatDate(d: string | null) {
if (!d) return "—";
return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
const raw = !(d.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(d)) ? d + "Z" : d;
return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(new Date(raw));
}
function capitalize(s: string) {
+57 -35
View File
@@ -6,23 +6,21 @@ import {
ChevronLeft,
ChevronRight,
LogIn,
LogOut,
Key,
UserPlus,
Shield,
Settings,
AlertTriangle,
Fingerprint,
Smartphone,
Terminal,
Loader2,
CheckCircle2,
XCircle,
Globe,
Lock,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
@@ -31,25 +29,28 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api, AuditLogEntry } from "@/lib/api";
import { api, AuditLogEntry, ApiError } from "@/lib/api";
import { formatDateTime } from "@/lib/date";
// ─── category helpers ────────────────────────────────────────────────────────
type Category = "auth" | "ssh" | "org" | "user" | "security" | "token" | "other";
type Category = "auth" | "ssh" | "org" | "user" | "security" | "token" | "admin" | "other";
const getCategory = (action: string): Category => {
const a = action.toLowerCase();
if (a.startsWith("session") || a.includes("login") || a.includes("logout") || a.includes("external_auth"))
if (a.startsWith("session") || a === "user.login" || a === "user.logout" || a.startsWith("external_auth.login"))
return "auth";
if (a.startsWith("ssh"))
return "ssh";
if (a.startsWith("admin."))
return "admin";
if (a.startsWith("org") || a.includes("member") || a.includes("department") || a.includes("invite"))
return "org";
if (a.startsWith("user"))
return "user";
if (a.includes("mfa") || a.includes("totp") || a.includes("webauthn") || a.includes("passkey") || a.includes("password"))
return "security";
if (a.includes("token") || a.includes("oidc") || a.includes("client"))
if (a.includes("token") || a.includes("oidc") || a.includes("client") || a.startsWith("external_auth"))
return "token";
return "other";
};
@@ -61,6 +62,7 @@ const CATEGORY_META: Record<Category, { label: string; color: string }> = {
user: { label: "User", color: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
security: { label: "Security", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400" },
token: { label: "Token", color: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400" },
admin: { label: "Admin", color: "bg-red-500/10 text-red-600 dark:text-red-400" },
other: { label: "Other", color: "bg-muted text-muted-foreground" },
};
@@ -73,6 +75,7 @@ const getCategoryIcon = (category: Category) => {
case "user": return <UserPlus className={cls} />;
case "security": return <Shield className={cls} />;
case "token": return <Key className={cls} />;
case "admin": return <Lock className={cls} />;
default: return <Globe className={cls} />;
}
};
@@ -86,26 +89,42 @@ const getActionLabel = (action: string) =>
// ─── component ───────────────────────────────────────────────────────────────
const ACTION_FILTER_OPTIONS = [
{ value: "all", label: "All actions" },
{ value: "SESSION_CREATE", label: "Login" },
{ value: "SESSION_REVOKE", label: "Logout" },
{ value: "EXTERNAL_AUTH_LOGIN", label: "OAuth Login" },
{ value: "EXTERNAL_AUTH_LOGIN_FAILED", label: "OAuth Failed" },
{ value: "USER_REGISTER", label: "Register" },
{ value: "SSH_KEY_ADDED", label: "SSH Key Added" },
{ value: "SSH_KEY_VERIFIED", label: "SSH Key Verified" },
{ value: "SSH_CERT_ISSUED", label: "SSH Cert Issued" },
{ value: "SSH_CERT_REVOKED", label: "SSH Cert Revoked" },
{ value: "SSH_CERT_FAILED", label: "SSH Cert Failed" },
{ value: "ORG_CREATE", label: "Org Created" },
{ value: "ORG_MEMBER_ADD", label: "Member Added" },
{ value: "ORG_MEMBER_ROLE_CHANGE", label: "Role Changed" },
{ value: "all", label: "All actions" },
{ value: "session.create", label: "Login" },
{ value: "session.revoke", label: "Logout" },
{ value: "external_auth.login", label: "OAuth Login" },
{ value: "external_auth.login.failed", label: "OAuth Failed" },
{ value: "external_auth.link.completed", label: "OAuth Account Linked" },
{ value: "external_auth.unlink", label: "OAuth Account Unlinked" },
{ value: "user.register", label: "Register" },
{ value: "ssh.key.added", label: "SSH Key Added" },
{ value: "ssh.key.verified", label: "SSH Key Verified" },
{ value: "ssh.key.deleted", label: "SSH Key Deleted" },
{ value: "ssh.cert.issued", label: "SSH Cert Issued" },
{ value: "ssh.cert.revoked", label: "SSH Cert Revoked" },
{ value: "ssh.cert.failed", label: "SSH Cert Failed" },
{ value: "org.create", label: "Org Created" },
{ value: "org.member.add", label: "Member Added" },
{ value: "org.member.remove", label: "Member Removed" },
{ value: "org.member.role_change", label: "Role Changed" },
{ value: "org.security_policy.update", label: "Security Policy Updated" },
{ value: "admin.mfa.remove", label: "MFA Removed (Admin)" },
{ value: "admin.oauth.unlink", label: "OAuth Unlinked (Admin)" },
{ value: "admin.password.set", label: "Password Set (Admin)" },
{ value: "totp.enroll.completed", label: "TOTP Enrolled" },
{ value: "totp.disabled", label: "TOTP Disabled" },
{ value: "webauthn.register.completed", label: "Passkey Registered" },
{ value: "webauthn.credential.deleted", label: "Passkey Removed" },
{ value: "user.password_change", label: "Password Changed" },
{ value: "user.password_reset", label: "Password Reset" },
{ value: "user.suspend", label: "User Suspended" },
];
export default function SystemAuditPage() {
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState(false);
const [isAdminView, setIsAdminView] = useState(false);
// filters
@@ -129,6 +148,7 @@ export default function SystemAuditPage() {
const fetchLogs = useCallback(async () => {
setIsLoading(true);
setError(null);
setAccessDenied(false);
try {
const params: Record<string, string> = {
page: String(page),
@@ -144,8 +164,12 @@ export default function SystemAuditPage() {
setTotalPages(resp.pages ?? 1);
setIsAdminView(resp.is_admin_view ?? false);
} catch (err) {
console.error("Failed to fetch system audit logs:", err);
setError("Failed to load audit logs. Please try again.");
if (err instanceof ApiError && err.code === 403) {
setAccessDenied(true);
} else {
console.error("Failed to fetch system audit logs:", err);
setError("Failed to load audit logs. Please try again.");
}
} finally {
setIsLoading(false);
}
@@ -160,17 +184,7 @@ export default function SystemAuditPage() {
setPage(1);
}, [actionFilter, successFilter, debouncedSearch]);
const formatDate = (dateString: string) => {
const d = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(d);
};
const formatDate = (dateString: string) => formatDateTime(dateString);
const formatUserAgent = (ua: string | null) => {
if (!ua) return null;
@@ -244,6 +258,14 @@ export default function SystemAuditPage() {
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading</span>
</div>
) : accessDenied ? (
<div className="py-16 text-center text-muted-foreground">
<Lock className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
<p className="font-medium text-base">Access Restricted</p>
<p className="text-sm mt-1 max-w-sm mx-auto">
You don't have permission to view system-wide audit logs. Contact your administrator to request access.
</p>
</div>
) : error ? (
<div className="py-12 text-center text-destructive">
<AlertTriangle className="w-8 h-8 mx-auto mb-2" />
+2 -2
View File
@@ -3,7 +3,7 @@ import { useSearchParams, useNavigate } from "react-router-dom";
import { CheckCircle, XCircle, Loader2, Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
import { SecuirdLogo } from "@/components/branding/SecuirdLogo";
import { api, ApiError } from "@/lib/api";
type Status = "loading" | "success" | "error" | "missing";
@@ -42,7 +42,7 @@ export default function ActivatePage() {
<div className="w-full max-w-md space-y-6">
{/* Logo */}
<div className="flex justify-center">
<GatehouseLogo size="md" />
<SecuirdLogo size="md" />
</div>
<Card>
+2 -2
View File
@@ -61,7 +61,7 @@ export default function InviteAcceptPage() {
const result = await api.invites.accept(token, name || undefined, inviteData?.user_exists ? undefined : password);
if (result.token) {
// Store the token manually since we're not using the normal login flow
localStorage.setItem("gatehouse_token", result.token);
localStorage.setItem("secuird_token", result.token);
}
navigate("/profile");
} catch (err: unknown) {
@@ -127,7 +127,7 @@ export default function InviteAcceptPage() {
<CheckCircle className="w-5 h-5 text-accent flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-foreground">Account found</p>
<p className="text-muted-foreground">You already have a Gatehouse account. Click below to join the organization.</p>
<p className="text-muted-foreground">You already have a Secuird account. Click below to join the organization.</p>
</div>
</div>
) : (
+34 -18
View File
@@ -27,8 +27,8 @@ import { OAuthProvider } from "@/lib/oauth";
type LoginStep = 'credentials' | 'totp' | 'webauthn' | 'passkey-email' | 'mfa-enrollment' | 'mfa';
const GATEHOUSE_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1';
const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, '');
const SECUIRD_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1';
const SECUIRD_OIDC = SECUIRD_API.replace(/\/api\/v1\/?$/, '');
/**
* Complete an OIDC authorization flow after the user has authenticated.
@@ -36,7 +36,7 @@ const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, '');
* the auth code and returns the redirect URL for the calling application.
*/
async function completeOidcFlow(oidcSessionId: string, token: string): Promise<string> {
const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, {
const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oidc_session_id: oidcSessionId, token }),
@@ -49,7 +49,7 @@ async function completeOidcFlow(oidcSessionId: string, token: string): Promise<s
}
export default function LoginPage() {
const { login, verifyTotp, refreshUser, user, isLoading: authLoading } = useAuth();
const { login, verifyTotp, refreshUser, user, isLoading: authLoading, checkOrgAdmin } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
const [searchParams] = useSearchParams();
@@ -64,13 +64,13 @@ export default function LoginPage() {
const [mfaToken, setMfaToken] = useState<string | null>(null);
// OIDC bridge: if oidc_session_id is in the URL, we're acting as the
// login UI for an OIDC authorization flow (e.g. SecuIRD → Gatehouse).
// login UI for an OIDC authorization flow (e.g. SecuIRD → Secuird).
// After successful login, call /oidc/complete and redirect to the client app.
const oidcSessionId = searchParams.get('oidc_session_id');
const oidcError = searchParams.get('error');
// CLI bridge: if cli_token or cli_redirect is present the login was triggered
// by the Gatehouse CLI tool. After successful auth the token is delivered
// by the Secuird CLI tool. After successful auth the token is delivered
// directly to the CLI's local callback server.
const cliToken = searchParams.get('cli_token');
const cliRedirectParam = searchParams.get('cli_redirect');
@@ -81,7 +81,7 @@ export default function LoginPage() {
useEffect(() => {
if (!cliToken || cliFetchedRef.current) return;
cliFetchedRef.current = true;
fetch(`${GATEHOUSE_API}/cli/redirect-url?token=${encodeURIComponent(cliToken)}`)
fetch(`${SECUIRD_API}/cli/redirect-url?token=${encodeURIComponent(cliToken)}`)
.then((r) => r.json())
.then((body) => {
if (body?.data?.redirect_url) {
@@ -165,7 +165,7 @@ export default function LoginPage() {
// MFA enrollment required - will be handled by ProtectedLayout
// Navigation happens in AuthContext (MFA path always navigates)
} else if (oidcSessionId) {
// OIDC bridge: send token back to the Gatehouse backend to complete the flow
// OIDC bridge: send token back to the Secuird backend to complete the flow
const token = tokenManager.getToken();
if (token) await finishOidcFlow(token);
} else if (cliRedirectUrl) {
@@ -176,7 +176,7 @@ export default function LoginPage() {
// Normal login: navigation already handled by AuthContext (skipNavigate=false)
} catch (error) {
if (import.meta.env.DEV) {
console.error("[Gatehouse] Login failed:", error);
console.error("[Secuird] Login failed:", error);
}
const message = error instanceof ApiError
@@ -230,7 +230,14 @@ export default function LoginPage() {
finishCliFlow(response.token);
} else {
await refreshUser();
navigate('/profile');
const orgsData = await api.users.organizations();
const hasOrg = orgsData.organizations && orgsData.organizations.length > 0;
if (hasOrg) {
navigate('/profile');
} else {
navigate('/org-setup');
}
}
} else {
// Fallback to regular TOTP verification
@@ -246,7 +253,7 @@ export default function LoginPage() {
}
} catch (error) {
if (import.meta.env.DEV) {
console.error("[Gatehouse] MFA verification failed:", error);
console.error("[Secuird] MFA verification failed:", error);
}
const message = error instanceof ApiError
@@ -294,7 +301,7 @@ export default function LoginPage() {
// Normal login: navigation already handled by AuthContext (skipNavigate=false)
} catch (error) {
if (import.meta.env.DEV) {
console.error("[Gatehouse] TOTP verification failed:", error);
console.error("[Secuird] TOTP verification failed:", error);
}
const message = error instanceof ApiError
@@ -347,6 +354,8 @@ export default function LoginPage() {
// Token is stored by completeLogin, refresh user and navigate
await refreshUser();
await checkOrgAdmin();
if (oidcSessionId) {
const token = tokenManager.getToken();
if (token) await finishOidcFlow(token);
@@ -354,7 +363,10 @@ export default function LoginPage() {
const token = tokenManager.getToken();
if (token) finishCliFlow(token);
} else {
navigate('/profile');
// Verify org membership before navigating to prevent showing org-setup briefly
const orgsData = await api.users.organizations();
const hasOrg = orgsData.organizations && orgsData.organizations.length > 0;
navigate(hasOrg ? '/profile' : '/org-setup');
}
toast({
@@ -363,7 +375,7 @@ export default function LoginPage() {
});
} catch (error) {
if (import.meta.env.DEV) {
console.error("[Gatehouse] Passkey login failed:", error);
console.error("[Secuird] Passkey login failed:", error);
}
let message = "Failed to sign in with passkey";
@@ -430,7 +442,11 @@ export default function LoginPage() {
if (token) finishCliFlow(token);
} else {
await refreshUser();
navigate('/profile');
await checkOrgAdmin();
// Verify org membership before navigating to prevent showing org-setup briefly
const orgsData = await api.users.organizations();
const hasOrg = orgsData.organizations && orgsData.organizations.length > 0;
navigate(hasOrg ? '/profile' : '/org-setup');
toast({
title: "Welcome back",
description: `Signed in as ${result.user.email}`,
@@ -438,7 +454,7 @@ export default function LoginPage() {
}
} catch (error) {
if (import.meta.env.DEV) {
console.error("[Gatehouse] WebAuthn verification failed:", error);
console.error("[Secuird] WebAuthn verification failed:", error);
}
let message = "Failed to verify passkey";
@@ -518,7 +534,7 @@ export default function LoginPage() {
} catch (error) {
if (import.meta.env.DEV) {
console.error("[Gatehouse] OAuth login failed:", error);
console.error("[Secuird] OAuth login failed:", error);
}
let message = `Failed to initiate ${provider} sign in`;
@@ -939,7 +955,7 @@ export default function LoginPage() {
</h1>
<p className="text-muted-foreground mt-2">
{cliRedirectUrl
? "Sign in to grant the Gatehouse CLI access to your account"
? "Sign in to grant the Secuird CLI access to your account"
: oidcSessionId
? "An application is requesting access to your account"
: "Sign in to your account to continue"}
+6 -6
View File
@@ -9,11 +9,11 @@ import { useToast } from "@/hooks/use-toast";
type CallbackState = 'loading' | 'success' | 'error';
const GATEHOUSE_API = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1') as string;
const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, '');
const SECUIRD_API = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1') as string;
const SECUIRD_OIDC = SECUIRD_API.replace(/\/api\/v1\/?$/, '');
async function completeOidcFlow(oidcSessionId: string, token: string): Promise<string> {
const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, {
const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oidc_session_id: oidcSessionId, token }),
@@ -24,7 +24,7 @@ async function completeOidcFlow(oidcSessionId: string, token: string): Promise<s
}
/**
* OAuth callback page that handles the redirect from the Gatehouse backend
* OAuth callback page that handles the redirect from the Secuird backend
* after a successful (or failed) OAuth provider authentication.
*
* The backend exchanges the provider code for a session token and then
@@ -134,7 +134,7 @@ export default function OAuthCallbackPage() {
return;
} catch (oidcErr) {
if (import.meta.env.DEV) {
console.error("[Gatehouse] OIDC completion failed after OAuth:", oidcErr);
console.error("[Secuird] OIDC completion failed after OAuth:", oidcErr);
}
// Fall through to normal flow on failure — user is still logged in
}
@@ -163,7 +163,7 @@ export default function OAuthCallbackPage() {
setStatus('error');
setError("Failed to load your profile. Please try signing in again.");
if (import.meta.env.DEV) {
console.error("[Gatehouse] OAuth callback refreshUser failed:", err);
console.error("[Secuird] OAuth callback refreshUser failed:", err);
}
}
};
+4 -4
View File
@@ -6,8 +6,8 @@ import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { tokenManager } from "@/lib/api";
const GATEHOUSE_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1';
const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, '');
const SECUIRD_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1';
const SECUIRD_OIDC = SECUIRD_API.replace(/\/api\/v1\/?$/, '');
const SCOPE_META: Record<string, { icon: typeof Shield; label: string; description: string }> = {
openid: { icon: Shield, label: "OpenID", description: "Verify your identity" },
@@ -41,7 +41,7 @@ export default function OIDCConsentPage() {
(async () => {
try {
const res = await fetch(`${GATEHOUSE_OIDC}/oidc/begin`, {
const res = await fetch(`${SECUIRD_OIDC}/oidc/begin`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oidc_session_id: oidcSessionId }),
@@ -67,7 +67,7 @@ export default function OIDCConsentPage() {
navigate(`/login?oidc_session_id=${context.oidc_session_id}`);
return;
}
const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, {
const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oidc_session_id: context.oidc_session_id, token }),
+5 -5
View File
@@ -1,7 +1,7 @@
/**
* OIDCLoginPage Standalone OIDC proxy login UI
*
* Unified entry point for OIDC authorization flows via the Gatehouse OIDC bridge.
* Unified entry point for OIDC authorization flows via the Secuird OIDC bridge.
* Handles:
* 1. Unauthenticated users shows an email/password login form
* 2. Already-authenticated users shows a consent/approval screen directly
@@ -9,7 +9,7 @@
* Route: /oidc-login?oidc_session_id=<id>
*
* Configure your oauth2-proxy / OIDC client's login_url to:
* https://<gatehouse-ui>/oidc-login
* https://<secuird-ui>/oidc-login
*/
import { useState, useEffect, useCallback } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
@@ -37,7 +37,7 @@ 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")
const SECUIRD_OIDC = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1")
.replace(/\/api\/v1\/?$/, "");
// ── Scope display metadata ────────────────────────────────────────────────────
@@ -62,7 +62,7 @@ type PageStep = "loading" | "login" | "consent" | "error";
// ── API helpers ───────────────────────────────────────────────────────────────
async function fetchOIDCContext(oidcSessionId: string): Promise<OIDCContext> {
const res = await fetch(`${GATEHOUSE_OIDC}/oidc/begin`, {
const res = await fetch(`${SECUIRD_OIDC}/oidc/begin`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oidc_session_id: oidcSessionId }),
@@ -75,7 +75,7 @@ async function fetchOIDCContext(oidcSessionId: string): Promise<OIDCContext> {
}
async function completeOIDCFlow(oidcSessionId: string, token: string): Promise<string> {
const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, {
const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oidc_session_id: oidcSessionId, token }),
+3
View File
@@ -8,6 +8,7 @@
*/
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Building2, Plus, ArrowRight, Loader2, Mail, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -33,6 +34,7 @@ interface LocationState {
export default function OrgSetupPage() {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const { refreshUser, checkOrgAdmin, isOrgMember, isLoading } = useAuth();
// If the user already belongs to an org (e.g. they bookmarked /org-setup),
@@ -100,6 +102,7 @@ export default function OrgSetupPage() {
const done = async () => {
await refreshUser();
await checkOrgAdmin();
queryClient.invalidateQueries({ queryKey: ['organizations'] });
navigate("/profile", { replace: true });
};
+1 -1
View File
@@ -108,7 +108,7 @@ export default function RegisterPage() {
Create your account
</h1>
<p className="text-muted-foreground mt-2">
Get started with Gatehouse in seconds
Get started with Secuird in seconds
</p>
</div>
+25 -22
View File
@@ -227,29 +227,32 @@ return (
</section>
{/* CTA */}
<section className="py-16 lg:py-24 bg-muted/30">
<section className="py-16 lg:py-24">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-2xl mx-auto">
<h2 className="text-3xl font-bold text-foreground mb-4">
Ready to Try It Yourself?
</h2>
<p className="text-lg text-muted-foreground mb-8">
Start your free trial today. No credit card required. Full access to all features.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register">
<Button size="lg" className="gap-2">
Start Free Trial
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link to="/pricing">
<Button variant="outline" size="lg">
View Pricing
</Button>
</Link>
</div>
</div>
<Card className="bg-gradient-to-br from-primary to-primary/80 border-0 overflow-hidden relative">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiNmZmYiIGZpbGwtb3BhY2l0eT0iMC4wNSI+PHBhdGggZD0iTTM2IDM0djItSDI0djJoMTJ6Ii8+PC9nPjwvZz48L3N2Zz4=')] opacity-50" />
<CardContent className="p-12 text-center relative">
<h2 className="text-3xl sm:text-4xl font-bold text-primary-foreground mb-4">
Ready to Try It Yourself?
</h2>
<p className="text-lg text-primary-foreground/80 max-w-2xl mx-auto mb-8">
Start your free trial today. No credit card required. Full access to all features.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register">
<Button size="lg" variant="secondary" className="gap-2 bg-white text-primary hover:bg-white/90 font-semibold">
Start Free Trial
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link to="/pricing">
<Button size="lg" className="bg-transparent border-2 border-white text-white hover:bg-white/15 font-semibold">
View Pricing
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
</section>
</>
+2 -2
View File
@@ -509,13 +509,13 @@ return (
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register">
<Button size="lg" variant="secondary" className="gap-2">
<Button size="lg" variant="secondary" className="gap-2 bg-white text-primary hover:bg-white/90 font-semibold">
Start Free Trial
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link to="/demo">
<Button size="lg" variant="outline" className="text-primary-foreground border-primary-foreground/30 hover:bg-primary-foreground/10">
<Button size="lg" className="bg-transparent border-2 border-white text-white hover:bg-white/15 font-semibold">
Watch Demo
</Button>
</Link>
+5 -8
View File
@@ -80,14 +80,11 @@ export default function HomePage() {
return (
<>
{/* Hero Section */}
<section className="relative overflow-hidden">
{/* Background gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-background to-accent/5 pointer-events-none" />
<section className="relative overflow-hidden bg-card">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
<div className="text-center max-w-4xl mx-auto">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-accent/10 text-accent text-sm font-medium mb-6">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium mb-6 border border-primary/20">
<ShieldCheck className="h-4 w-4" />
Security-first identity platform
</div>
@@ -95,7 +92,7 @@ return (
{/* Headline */}
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-foreground mb-6">
Enterprise Authentication,
<span className="text-accent block mt-2">Without the Enterprise Complexity</span>
<span className="text-primary block mt-2">Without the Enterprise Complexity</span>
</h1>
{/* Subheadline */}
@@ -423,13 +420,13 @@ return (
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register">
<Button size="lg" variant="secondary" className="gap-2">
<Button size="lg" variant="secondary" className="gap-2 bg-white text-primary hover:bg-white/90 font-semibold">
Start Free Trial
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link to="/pricing">
<Button size="lg" variant="outline" className="text-primary-foreground border-primary-foreground/30 hover:bg-primary-foreground/10">
<Button size="lg" className="bg-transparent border-2 border-white text-white hover:bg-white/15 font-semibold">
View Pricing
</Button>
</Link>
+25 -22
View File
@@ -328,29 +328,32 @@ return (
</section>
{/* CTA */}
<section className="py-16 lg:py-24 bg-muted/30">
<section className="py-16 lg:py-24">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-2xl mx-auto">
<h2 className="text-3xl font-bold text-foreground mb-4">
Start Your Free Trial Today
</h2>
<p className="text-lg text-muted-foreground mb-8">
Try Secuird free for 14 days. No credit card required. Full access to all Business features.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register">
<Button size="lg" className="gap-2">
Start Free Trial
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link to="/demo">
<Button variant="outline" size="lg">
Watch Demo
</Button>
</Link>
</div>
</div>
<Card className="bg-gradient-to-br from-primary to-primary/80 border-0 overflow-hidden relative">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiNmZmYiIGZpbGwtb3BhY2l0eT0iMC4wNSI+PHBhdGggZD0iTTM2IDM0djItSDI0djJoMTJ6Ii8+PC9nPjwvZz48L3N2Zz4=')] opacity-50" />
<CardContent className="p-12 text-center relative">
<h2 className="text-3xl sm:text-4xl font-bold text-primary-foreground mb-4">
Start Your Free Trial Today
</h2>
<p className="text-lg text-primary-foreground/80 max-w-2xl mx-auto mb-8">
Try Secuird free for 14 days. No credit card required. Full access to all Business features.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register">
<Button size="lg" variant="secondary" className="gap-2 bg-white text-primary hover:bg-white/90 font-semibold">
Start Free Trial
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link to="/demo">
<Button size="lg" className="bg-transparent border-2 border-white text-white hover:bg-white/15 font-semibold">
Watch Demo
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
</section>
</>
+2 -2
View File
@@ -435,13 +435,13 @@ $ systemctl restart sshd`}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register">
<Button size="lg" variant="secondary" className="gap-2">
<Button size="lg" variant="secondary" className="gap-2 bg-white text-primary hover:bg-white/90 font-semibold">
Start Free Trial
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link to="/demo">
<Button size="lg" variant="outline" className="text-primary-foreground border-primary-foreground/30 hover:bg-primary-foreground/10">
<Button size="lg" className="bg-transparent border-2 border-white text-white hover:bg-white/15 font-semibold">
Watch Demo
</Button>
</Link>
+2 -2
View File
@@ -464,12 +464,12 @@ return (
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register">
<Button size="lg" variant="secondary" className="gap-2">
<Button size="lg" variant="secondary" className="gap-2 bg-white text-primary hover:bg-white/90 font-semibold">
Start Free Trial
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Button size="lg" variant="outline" className="text-primary-foreground border-primary-foreground/30 hover:bg-primary-foreground/10">
<Button size="lg" className="bg-transparent border-2 border-white text-white hover:bg-white/15 font-semibold">
Contact Security Team
</Button>
</div>
+1 -1
View File
@@ -163,7 +163,7 @@ export default function AccessPage() {
try {
const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([
api.zerotier.listPendingApprovals(orgId),
api.zerotier.listMyApprovals(orgId),
api.zerotier.adminListAllApprovals(orgId),
api.zerotier.listSessions(orgId),
api.zerotier.listNetworks(orgId),
api.organizations.getMembers(orgId),
+428
View File
@@ -0,0 +1,428 @@
import { useState, useEffect, useRef } from "react";
import {
Plus, Copy, Trash2, Loader2, AlertCircle, CheckCircle, MoreHorizontal, Edit2, Check
} from "lucide-react";
import { Button } from "@/components/ui/button";
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,
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<NewApiKeyState | null>(null);
const [editingKey, setEditingKey] = useState<EditingKey | null>(null);
const [showKey, setShowKey] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
const editNameRef = useRef<HTMLInputElement>(null);
const editDescriptionRef = useRef<HTMLTextAreaElement>(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 (
<div className="page-container">
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
return (
<div className="page-container">
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title">API Keys</h1>
<p className="page-description">Manage API keys for programmatic access to your organization.</p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)} className="gap-2">
<Plus className="w-4 h-4" />
Create API Key
</Button>
</div>
{/* New key reveal banner */}
{newSecret && (
<div className="mb-4 rounded-lg border border-green-500/40 bg-green-500/5 p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4" />
API key created copy it now, you won't see it again.
</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-muted px-3 py-2 rounded break-all font-mono">
{newSecret.key}
</code>
<Button variant="outline" size="sm" className="shrink-0 gap-1.5" onClick={() => copy(newSecret.key)}>
{copied ? <><Check className="w-3.5 h-3.5" /> Copied</> : <><Copy className="w-3.5 h-3.5" /> Copy</>}
</Button>
</div>
<button onClick={() => setNewSecret(null)} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
Dismiss
</button>
</div>
)}
{/* Key list */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Loading...</span>
</div>
) : apiKeys.length === 0 ? (
<div className="p-12 text-center">
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-3 opacity-40" />
<p className="text-sm font-medium text-foreground mb-1">No API keys yet</p>
<p className="text-xs text-muted-foreground mb-4">Create one to enable external integrations.</p>
<Button variant="outline" size="sm" onClick={() => setIsCreateDialogOpen(true)} className="gap-2">
<Plus className="w-4 h-4" /> Create API Key
</Button>
</div>
) : (
<div className="divide-y">
{activeKeys.map((key) => (
<div key={key.id} className="flex items-start gap-4 p-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm text-foreground">{key.name}</span>
{key.last_used_at && (
<Badge variant="secondary" className="text-xs">
Last used {formatDate(key.last_used_at)}
</Badge>
)}
</div>
{key.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{key.description}</p>
)}
<p className="text-xs text-muted-foreground mt-1">Created {formatDate(key.created_at)}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEditKey(key)} className="cursor-pointer">
<Edit2 className="w-4 h-4 mr-2" /> Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDeleteKey(key.id)}
className="text-destructive cursor-pointer"
disabled={isDeletingKey}
>
<Trash2 className="w-4 h-4 mr-2" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
{revokedKeys.length > 0 && (
<>
<div className="px-4 py-2 bg-muted/30">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Revoked</span>
</div>
{revokedKeys.map((key) => (
<div key={key.id} className="flex items-center gap-4 px-4 py-3 opacity-50">
<div className="flex-1 min-w-0">
<p className="text-sm text-muted-foreground line-through">{key.name}</p>
<p className="text-xs text-muted-foreground">
Revoked {formatDate(key.revoked_at || '')}
{key.revoke_reason && `${key.revoke_reason}`}
</p>
</div>
</div>
))}
</>
)}
</div>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
<DialogDescription>
Create a new API key for external integrations. The key will be displayed only once.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="key-name">Key Name</Label>
<Input
id="key-name"
ref={nameRef}
placeholder="e.g., Production Integration"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="key-description">Description (Optional)</Label>
<Textarea
id="key-description"
ref={descriptionRef}
placeholder="What is this key for?"
className="mt-1 resize-none h-20"
/>
</div>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
disabled={isCreating || isCreatingKey}
>
Cancel
</Button>
<Button
onClick={handleCreateKey}
disabled={isCreating || isCreatingKey}
>
{isCreatingKey ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
'Create Key'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit API Key</DialogTitle>
<DialogDescription>
Update the name and description of this API key.
</DialogDescription>
</DialogHeader>
{editingKey && (
<div className="space-y-4">
<div>
<Label htmlFor="edit-key-name">Key Name</Label>
<Input
id="edit-key-name"
ref={editNameRef}
defaultValue={editingKey.name}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="edit-key-description">Description (Optional)</Label>
<Textarea
id="edit-key-description"
ref={editDescriptionRef}
defaultValue={editingKey.description || ''}
className="mt-1 resize-none h-20"
/>
</div>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setIsEditDialogOpen(false)}
disabled={isUpdatingKey}
>
Cancel
</Button>
<Button
onClick={handleUpdateKey}
disabled={isUpdatingKey}
>
{isUpdatingKey ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating...
</>
) : (
'Update Key'
)}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
+1 -1
View File
@@ -261,7 +261,7 @@ export default function CAsPage() {
<div>
<h1 className="page-title">Certificate Authorities</h1>
<p className="page-description">
Manage your organization's SSH CAs with <code>Gatehouse</code>
Manage your organization's SSH CAs with <code>Secuird</code>
</p>
</div>
</div>
+2 -1
View File
@@ -10,6 +10,7 @@ import { api, OrgComplianceMember, create403Handler } from "@/lib/api";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { useOrg } from "@/contexts/OrgContext";
import { formatDate } from "@/lib/date";
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof Clock }> = {
compliant: {
@@ -231,7 +232,7 @@ export default function CompliancePage() {
{member.deadline_at && member.status !== 'compliant' && member.status !== 'not_applicable' && (
<div className="text-sm text-muted-foreground">
<span className="hidden md:inline">Deadline: </span>
{new Date(member.deadline_at).toLocaleDateString()}
{formatDate(member.deadline_at)}
</div>
)}
+39 -8
View File
@@ -390,7 +390,7 @@ export default function DepartmentsPage() {
const [selectedPrincipalId, setSelectedPrincipalId] = useState("");
const [isLinking, setIsLinking] = useState(false);
const [editingDept, setEditingDept] = useState<Department | null>(null);
const [formData, setFormData] = useState({ name: "", description: "" });
const [formData, setFormData] = useState({ name: "", description: "", can_sudo: false });
const [expandedPolicies, setExpandedPolicies] = useState<Set<string>>(new Set());
const [expandedMembers, setExpandedMembers] = useState<Set<string>>(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
</p>
</div>
<Button onClick={() => { setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
<Button onClick={() => { setFormData({ name: "", description: "", can_sudo: false }); setIsCreateDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-2" />
Create Department
</Button>
@@ -615,10 +617,15 @@ export default function DepartmentsPage() {
<Users className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-foreground">
{dept.name}
</p>
{dept.can_sudo && (
<Badge variant="secondary" className="text-xs bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300">
Sudo enabled
</Badge>
)}
</div>
{dept.description && (
<p className="mt-1 text-sm text-muted-foreground">
@@ -751,6 +758,18 @@ export default function DepartmentsPage() {
rows={3}
/>
</div>
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/30">
<div>
<Label className="text-base font-medium cursor-pointer">Allow sudo access</Label>
<p className="text-xs text-muted-foreground mt-1">Members of this department can use sudo</p>
</div>
<input
type="checkbox"
checked={formData.can_sudo}
onChange={(e) => setFormData({ ...formData, can_sudo: e.target.checked })}
className="w-4 h-4 cursor-pointer"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
@@ -792,6 +811,18 @@ export default function DepartmentsPage() {
rows={3}
/>
</div>
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/30">
<div>
<Label className="text-base font-medium cursor-pointer">Allow sudo access</Label>
<p className="text-xs text-muted-foreground mt-1">Members of this department can use sudo</p>
</div>
<input
type="checkbox"
checked={formData.can_sudo}
onChange={(e) => setFormData({ ...formData, can_sudo: e.target.checked })}
className="w-4 h-4 cursor-pointer"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
+2 -5
View File
@@ -84,11 +84,8 @@ const getInitials = (name: string | null | undefined): string => {
function formatDate(d: string | null | undefined) {
if (!d) return "—";
return new Date(d).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
const raw = typeof d === "string" && !(d.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(d)) ? d + "Z" : d;
return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(new Date(raw));
}
function capitalize(s: string) {
+168
View File
@@ -17,6 +17,9 @@ import {
XCircle,
Ban,
Zap,
Download,
RefreshCw,
AlertCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -53,10 +56,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/hooks/use-toast";
import {
api,
ApiError,
AvailableZtNetwork,
PortalNetwork,
DeviceNetworkMembership,
UserNetworkApproval,
@@ -149,6 +154,13 @@ export default function NetworksPage() {
const [deleteNetwork, setDeleteNetwork] = useState<PortalNetwork | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// ZeroTier network picker
const [showZtPicker, setShowZtPicker] = useState(false);
const [ztNetworks, setZtNetworks] = useState<AvailableZtNetwork[]>([]);
const [isLoadingZtNetworks, setIsLoadingZtNetworks] = useState(false);
const [ztNetworksError, setZtNetworksError] = useState<string | null>(null);
const [ztPickerSearch, setZtPickerSearch] = useState("");
const fetchNetworks = useCallback(async () => {
if (!orgId) { setIsLoading(false); return; }
setIsLoading(true);
@@ -193,6 +205,37 @@ export default function NetworksPage() {
setNetworkRequests([]);
};
const openZtPicker = async () => {
if (!orgId) return;
setShowZtPicker(true);
setZtPickerSearch("");
setZtNetworksError(null);
setIsLoadingZtNetworks(true);
try {
const res = await api.zerotier.listAvailableZtNetworks(orgId);
setZtNetworks(res.networks || []);
if (res.zt_error) {
setZtNetworksError(`ZeroTier API error: ${res.zt_error}`);
}
} catch (err) {
setZtNetworksError(
err instanceof ApiError ? err.message : "Failed to load ZeroTier networks.",
);
setZtNetworks([]);
} finally {
setIsLoadingZtNetworks(false);
}
};
/** Pre-fill the Create Network dialog with data from a ZT network and close the picker. */
const importZtNetwork = (ztNet: AvailableZtNetwork) => {
setCreateZtId(ztNet.id);
setCreateName(ztNet.name && ztNet.name !== ztNet.id ? ztNet.name : "");
setCreateDesc(ztNet.description ?? "");
setShowZtPicker(false);
setShowCreate(true);
};
const handleCreate = async () => {
if (!orgId) return;
setCreateError(null);
@@ -297,6 +340,9 @@ export default function NetworksPage() {
className="pl-10"
/>
</div>
<Button variant="outline" onClick={openZtPicker} className="gap-2">
<Download className="w-4 h-4" /> Import from ZeroTier
</Button>
<Button onClick={() => setShowCreate(true)} className="gap-2">
<Plus className="w-4 h-4" /> Add Network
</Button>
@@ -387,6 +433,128 @@ export default function NetworksPage() {
</CardContent>
</Card>
{/* ZeroTier Network Picker */}
<Sheet open={showZtPicker} onOpenChange={(open) => { if (!open) setShowZtPicker(false); }}>
<SheetContent className="w-full sm:max-w-xl overflow-y-auto flex flex-col">
<SheetHeader className="mb-4">
<SheetTitle className="flex items-center gap-2">
<Download className="w-5 h-5 text-primary" />
Import from ZeroTier
</SheetTitle>
<SheetDescription>
Networks found in your ZeroTier account. Click one to import it into Secuird.
</SheetDescription>
</SheetHeader>
{/* Search + refresh */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search ZeroTier networks…"
value={ztPickerSearch}
onChange={(e) => setZtPickerSearch(e.target.value)}
className="pl-10"
/>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={openZtPicker} disabled={isLoadingZtNetworks}>
<RefreshCw className={cn("w-4 h-4", isLoadingZtNetworks && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh list</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{isLoadingZtNetworks ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground mr-2" />
<span className="text-muted-foreground">Loading ZeroTier networks</span>
</div>
) : ztNetworksError ? (
<div className="flex flex-col items-center gap-3 py-12 text-center px-4">
<AlertCircle className="w-8 h-8 text-destructive" />
<p className="text-sm text-destructive font-medium">Could not load ZeroTier networks</p>
<p className="text-xs text-muted-foreground">{ztNetworksError}</p>
<p className="text-xs text-muted-foreground mt-1">
Make sure your ZeroTier credentials are configured under{" "}
<strong>Settings ZeroTier Configuration</strong>.
</p>
</div>
) : ztNetworks.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-12 text-center text-muted-foreground">
<Network className="w-8 h-8" />
<p className="text-sm font-medium">No ZeroTier networks found</p>
<p className="text-xs">Your ZeroTier account has no networks yet.</p>
</div>
) : (
<div className="space-y-2 flex-1 overflow-y-auto">
{ztNetworks
.filter((n) => {
const q = ztPickerSearch.toLowerCase();
return !q || n.name.toLowerCase().includes(q) || n.id.toLowerCase().includes(q);
})
.map((ztNet) => (
<div
key={ztNet.id}
className={cn(
"flex items-center gap-3 p-3 border rounded-lg",
ztNet.already_managed
? "bg-muted/40 opacity-70"
: "hover:bg-accent/50 cursor-pointer transition-colors",
)}
onClick={() => !ztNet.already_managed && importZtNetwork(ztNet)}
role={ztNet.already_managed ? undefined : "button"}
tabIndex={ztNet.already_managed ? undefined : 0}
onKeyDown={(e) => {
if (!ztNet.already_managed && (e.key === "Enter" || e.key === " ")) {
importZtNetwork(ztNet);
}
}}
>
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Network className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-sm truncate">{ztNet.name}</p>
{ztNet.already_managed && (
<Badge className="text-xs bg-green-500/10 text-green-700 border-green-200">
<CheckCircle className="w-3 h-3 mr-1" />
{ztNet.portal_network_name
? `Managed as "${ztNet.portal_network_name}"`
: "Already managed"}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono">{ztNet.id}</p>
{(ztNet.online_member_count > 0 || ztNet.total_member_count > 0) && (
<p className="text-xs text-muted-foreground mt-0.5">
{ztNet.online_member_count} online · {ztNet.total_member_count} total members
</p>
)}
</div>
{!ztNet.already_managed && (
<Button
size="sm"
variant="outline"
className="flex-shrink-0 gap-1"
onClick={(e) => { e.stopPropagation(); importZtNetwork(ztNet); }}
>
<Plus className="w-3 h-3" />
Import
</Button>
)}
</div>
))}
</div>
)}
</SheetContent>
</Sheet>
{/* Create Network Dialog */}
<Dialog open={showCreate} onOpenChange={(open) => { if (!open) setShowCreate(false); }}>
<DialogContent className="sm:max-w-lg">
+217 -34
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react";
import {
Plus, Key, MoreHorizontal, Copy, Trash2, Loader2,
AlertCircle, CheckCircle, Network, Terminal, Check,
ChevronDown, Globe, RefreshCw, Info,
Globe, RefreshCw, Info, Pencil,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -39,18 +39,45 @@ import { useOrg } from "@/contexts/OrgContext";
const ISSUER_URL = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1")
.replace(/\/api\/v1\/?$/, "");
function buildProxyConfig(clientId: string, clientSecret: string, proxyHost: string) {
return `provider = "oidc"
oidc_issuer_url = "${ISSUER_URL}"
client_id = "${clientId}"
client_secret = "${clientSecret}"
redirect_url = "http://${proxyHost}/oauth2/callback"
scope = "openid profile email"
cookie_secret = "$(openssl rand -base64 32 | head -c 32)"
cookie_secure = false
upstream = "http://127.0.0.1:8080/"
set_authorization_header = true
set_x_auth_request_header = true`;
/** Generate a cryptographically random 32-byte base64url cookie secret. */
function generateCookieSecret(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
// Standard base64, then make it URL-safe (oauth2-proxy accepts both)
return btoa(String.fromCharCode(...bytes));
}
function buildProxyConfig(
clientId: string,
clientSecret: string,
proxyHost: string,
upstream: string,
setAuthHeader: boolean,
setXAuthHeader: boolean,
cookieSecret: string,
) {
// Normalise the proxy host — add https:// if no scheme given
const normalizedHost = /^https?:\/\//i.test(proxyHost)
? proxyHost.replace(/\/$/, "")
: `https://${proxyHost.replace(/\/$/, "")}`;
// cookie_secure must be true for https, false for plain http
const cookieSecure = normalizedHost.startsWith("https://");
const lines = [
`provider = "oidc"`,
`oidc_issuer_url = "${ISSUER_URL}"`,
`client_id = "${clientId}"`,
`client_secret = "${clientSecret}"`,
`redirect_url = "${normalizedHost}/oauth2/callback"`,
`scope = "openid profile email"`,
`cookie_secret = "${cookieSecret}"`,
`cookie_secure = ${cookieSecure}`,
`upstream = "${upstream || "http://127.0.0.1:8080/"}"`,
];
if (setAuthHeader) lines.push(`set_authorization_header = true`);
if (setXAuthHeader) lines.push(`set_x_auth_request_header = true`);
return lines.join("\n");
}
function useCopyButton() {
@@ -70,6 +97,10 @@ interface NewSecretState {
clientId: string;
secret: string;
proxyHost?: string;
proxyUpstream?: string;
proxySetAuthHeader?: boolean;
proxySetXAuthHeader?: boolean;
proxyCookieSecret?: string;
isProxy: boolean;
}
@@ -92,6 +123,15 @@ export default function OIDCClientsPage() {
// Proxy form
const proxyNameRef = useRef<HTMLInputElement>(null);
const proxyHostRef = useRef<HTMLInputElement>(null);
const proxyUpstreamRef = useRef<HTMLInputElement>(null);
const [proxySetAuthHeader, setProxySetAuthHeader] = useState(true);
const [proxySetXAuthHeader, setProxySetXAuthHeader] = useState(true);
// Edit state
const [editingClient, setEditingClient] = useState<OIDCClient | null>(null);
const [editName, setEditName] = useState("");
const [editUris, setEditUris] = useState("");
const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => {
if (!orgId) { setIsLoading(false); return; }
@@ -117,7 +157,11 @@ export default function OIDCClientsPage() {
name = proxyNameRef.current?.value.trim() ?? "";
proxyHost = proxyHostRef.current?.value.trim() ?? "";
if (!name || !proxyHost) return;
uris = [`http://${proxyHost}/oauth2/callback`];
// Normalise scheme for the registered redirect URI (must match config)
const normalizedHost = /^https?:\/\//i.test(proxyHost)
? proxyHost.replace(/\/$/, "")
: `https://${proxyHost.replace(/\/$/, "")}`;
uris = [`${normalizedHost}/oauth2/callback`];
}
setIsCreating(true);
@@ -129,6 +173,10 @@ export default function OIDCClientsPage() {
clientId: created.client_id,
secret: created.client_secret,
proxyHost,
proxyUpstream: proxyUpstreamRef.current?.value.trim() || "http://127.0.0.1:8080/",
proxySetAuthHeader,
proxySetXAuthHeader,
proxyCookieSecret: dialogMode === "proxy" ? generateCookieSecret() : undefined,
isProxy: dialogMode === "proxy",
});
setDialogMode(null);
@@ -150,8 +198,43 @@ export default function OIDCClientsPage() {
}
};
const openEditDialog = (client: OIDCClient) => {
setEditingClient(client);
setEditName(client.name);
setEditUris((client.redirect_uris ?? []).join("\n"));
};
const handleSaveEdit = async () => {
if (!orgId || !editingClient) return;
const name = editName.trim();
const uris = editUris.split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
if (!name || !uris.length) return;
setIsSavingEdit(true);
try {
const result = await api.organizations.updateClient(orgId, editingClient.id, { name, redirect_uris: uris });
setClients((prev) =>
prev.map((c) => (c.id === editingClient.id ? result.client : c))
);
setEditingClient(null);
toast({ title: "Client updated" });
} catch {
toast({ title: "Error", description: "Failed to update client.", variant: "destructive" });
} finally {
setIsSavingEdit(false);
}
};
const proxyConfig = newSecret?.isProxy && newSecret.proxyHost
? buildProxyConfig(newSecret.clientId, newSecret.secret, newSecret.proxyHost)
? buildProxyConfig(
newSecret.clientId,
newSecret.secret,
newSecret.proxyHost,
newSecret.proxyUpstream ?? "http://127.0.0.1:8080/",
newSecret.proxySetAuthHeader ?? true,
newSecret.proxySetXAuthHeader ?? true,
newSecret.proxyCookieSecret ?? generateCookieSecret(),
)
: null;
return (
@@ -160,7 +243,7 @@ export default function OIDCClientsPage() {
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title">OIDC Clients</h1>
<p className="page-description">Applications that authenticate via Gatehouse</p>
<p className="page-description">Applications that authenticate via Secuird</p>
</div>
<Button onClick={() => setDialogMode("generic")}>
<Plus className="w-4 h-4 mr-2" />
@@ -235,7 +318,7 @@ export default function OIDCClientsPage() {
<Network className="w-10 h-10 text-muted-foreground/40" />
<div>
<p className="font-medium text-muted-foreground">No OIDC clients yet</p>
<p className="text-sm text-muted-foreground/70">Register an app to let it authenticate via Gatehouse</p>
<p className="text-sm text-muted-foreground/70">Register an app to let it authenticate via Secuird</p>
</div>
<div className="flex gap-2 flex-wrap justify-center">
<Button variant="outline" onClick={() => setDialogMode("generic")}>
@@ -292,6 +375,10 @@ export default function OIDCClientsPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEditDialog(client)}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
@@ -320,7 +407,7 @@ export default function OIDCClientsPage() {
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add OIDC Client</DialogTitle>
<DialogDescription>Register an application to authenticate via Gatehouse</DialogDescription>
<DialogDescription>Register an application to authenticate via Secuird</DialogDescription>
</DialogHeader>
<Tabs
@@ -361,16 +448,56 @@ export default function OIDCClientsPage() {
<Input id="proxyName" placeholder="My Protected App" ref={proxyNameRef} />
</div>
<div className="space-y-2">
<Label htmlFor="proxyHost">Proxy host</Label>
<Input id="proxyHost" placeholder="app.example.com" ref={proxyHostRef} />
<Label htmlFor="proxyHost">Proxy public URL</Label>
<Input id="proxyHost" placeholder="https://app.example.com" ref={proxyHostRef} />
<p className="text-xs text-muted-foreground">
The hostname where oauth2-proxy runs. Redirect URI will be set to{" "}
<code className="bg-muted px-1 rounded">http://{"<host>"}/oauth2/callback</code> automatically.
Full URL where oauth2-proxy is exposed.{" "}
<code className="bg-muted px-1 rounded">/oauth2/callback</code> will be appended as the redirect URI.
<br />
<span className="text-amber-500/80">Use <code className="bg-muted px-1 rounded">https://</code> in production — <code className="bg-muted px-1 rounded">cookie_secure</code> is set automatically.</span>
</p>
</div>
<div className="rounded-md bg-muted/50 border px-3 py-2 text-xs text-muted-foreground">
After creating, you'll get a ready-to-paste config snippet for oauth2-proxy.
<div className="space-y-2">
<Label htmlFor="proxyUpstream">Upstream (your app)</Label>
<Input
id="proxyUpstream"
placeholder="http://127.0.0.1:8080/"
ref={proxyUpstreamRef}
/>
<p className="text-xs text-muted-foreground">
The backend app oauth2-proxy forwards authenticated requests to.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm">Headers forwarded to upstream</Label>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={proxySetAuthHeader}
onChange={(e) => setProxySetAuthHeader(e.target.checked)}
className="w-4 h-4 accent-primary rounded"
/>
<span className="text-sm">
<code className="bg-muted px-1 rounded text-xs">set_authorization_header</code>
<span className="text-muted-foreground ml-1.5 text-xs"> forwards <code className="bg-muted px-1 rounded">Authorization: Bearer </code></span>
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={proxySetXAuthHeader}
onChange={(e) => setProxySetXAuthHeader(e.target.checked)}
className="w-4 h-4 accent-primary rounded"
/>
<span className="text-sm">
<code className="bg-muted px-1 rounded text-xs">set_x_auth_request_header</code>
<span className="text-muted-foreground ml-1.5 text-xs"> forwards <code className="bg-muted px-1 rounded">X-Auth-Request-User</code> / <code className="bg-muted px-1 rounded">X-Auth-Request-Email</code></span>
</span>
</label>
</div>
</div>
</TabsContent>
</Tabs>
@@ -389,6 +516,56 @@ export default function OIDCClientsPage() {
</DialogContent>
</Dialog>
{/* Edit dialog */}
<Dialog open={editingClient !== null} onOpenChange={(open) => { if (!open) setEditingClient(null); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit OIDC Client</DialogTitle>
<DialogDescription>Update the client name and redirect URIs.</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="editName">Client name</Label>
<Input
id="editName"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="My Application"
/>
</div>
<div className="space-y-2">
<Label htmlFor="editUris">Redirect URIs</Label>
<Textarea
id="editUris"
value={editUris}
onChange={(e) => setEditUris(e.target.value)}
placeholder={"https://myapp.example.com/callback\nhttps://myapp.example.com/auth/callback"}
className="min-h-[80px] font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">One URI per line</p>
</div>
{editingClient && (
<div className="rounded-md bg-muted/50 border px-3 py-2 space-y-1">
<p className="text-xs text-muted-foreground font-medium">Client ID (read-only)</p>
<code className="text-xs font-mono text-foreground">{editingClient.client_id}</code>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setEditingClient(null)} disabled={isSavingEdit}>
Cancel
</Button>
<Button onClick={handleSaveEdit} disabled={isSavingEdit || !editName.trim()}>
{isSavingEdit ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Saving</>
) : (
"Save changes"
)}
</Button>
</div>
</DialogContent>
</Dialog>
{/* ── Reference ─────────────────────────────────────────── */}
<div className="mt-8">
<div className="flex items-center gap-2 mb-3 text-sm font-medium text-muted-foreground">
@@ -503,21 +680,24 @@ export default function OIDCClientsPage() {
<div className="space-y-1">
<p className="text-xs font-medium">1 Create a client (use the dialog above)</p>
<p className="text-xs text-muted-foreground">
Set the redirect URI to <code className="bg-muted px-1 rounded">http://&lt;your-proxy-host&gt;/oauth2/callback</code>.
Set the proxy public URL to the address where oauth2-proxy is exposed, e.g.{" "}
<code className="bg-muted px-1 rounded">https://app.example.com</code>. The redirect URI{" "}
<code className="bg-muted px-1 rounded">https://app.example.com/oauth2/callback</code> is registered automatically.
</p>
</div>
{/* Step 2 */}
<div className="space-y-1">
<p className="text-xs font-medium">2 Minimal config</p>
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto whitespace-pre">{`provider = "oidc"
oidc_issuer_url = "${ISSUER_URL}"
client_id = "<your-client-id>"
client_secret = "<your-client-secret>"
redirect_url = "http://<proxy-host>/oauth2/callback"
scope = "openid profile email"
cookie_secret = "$(openssl rand -base64 32 | head -c 32)"
upstream = "http://127.0.0.1:8080/"
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto whitespace-pre">{`provider = "oidc"
oidc_issuer_url = "${ISSUER_URL}"
client_id = "<your-client-id>"
client_secret = "<your-client-secret>"
redirect_url = "https://<proxy-host>/oauth2/callback"
scope = "openid profile email"
cookie_secret = "$(openssl rand -base64 32 | head -c 32)"
cookie_secure = true
upstream = "http://127.0.0.1:8080/"
set_authorization_header = true
set_x_auth_request_header = true`}</pre>
</div>
@@ -558,8 +738,11 @@ set_x_auth_request_header = true`}</pre>
OAUTH2_PROXY_CLIENT_ID: \${OIDC_CLIENT_ID}
OAUTH2_PROXY_CLIENT_SECRET: \${OIDC_CLIENT_SECRET}
OAUTH2_PROXY_COOKIE_SECRET: \${COOKIE_SECRET}
OAUTH2_PROXY_COOKIE_SECURE: "true"
OAUTH2_PROXY_UPSTREAM: http://app:8080/
OAUTH2_PROXY_REDIRECT_URL: http://localhost:4180/oauth2/callback`}</pre>
OAUTH2_PROXY_REDIRECT_URL: https://<your-proxy-host>/oauth2/callback
OAUTH2_PROXY_SET_AUTHORIZATION_HEADER: "true"
OAUTH2_PROXY_SET_XAUTHREQUEST: "true"`}</pre>
</div>
{/* Kubernetes snippet */}
+333 -134
View File
@@ -1,204 +1,403 @@
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 {
Search, Filter, RefreshCw, ChevronLeft, ChevronRight,
LogIn, Key, UserPlus, Shield, Settings,
AlertTriangle, Terminal, Loader2,
CheckCircle2, XCircle, Link2, UserCog,
} from "lucide-react";
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 {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { api, AuditLogEntry } from "@/lib/api";
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
import { formatDateTime } from "@/lib/date";
const getEventIcon = (action: string) => {
if (action.includes("member") || action.includes("MEMBER")) {
return <UserPlus className="w-4 h-4" />;
}
if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) {
return <Settings className="w-4 h-4" />;
}
if (action.includes("delete") || action.includes("DELETE") || action.includes("disable")) {
return <AlertTriangle className="w-4 h-4" />;
}
if (action.includes("client") || action.includes("oidc") || action.includes("key")) {
return <Key className="w-4 h-4" />;
}
return <User className="w-4 h-4" />;
};
// ─── category / display helpers ──────────────────────────────────────────────
const getEventTitle = (action: string) => {
const parts = action.split(".");
return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
};
type Category = "auth" | "ssh" | "admin" | "member" | "policy" | "security" | "oauth" | "other";
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";
const getCategory = (action: string): Category => {
const a = action.toLowerCase();
if (a.startsWith("session") || a === "user.login" || a === "user.logout") return "auth";
if (a.startsWith("ssh")) return "ssh";
if (a.startsWith("admin.")) return "admin";
if (a.includes("member") || a.includes("invite") || a.startsWith("org.member")) return "member";
if (a.includes("policy") || a.includes("mfa.policy") || a.startsWith("org.security")) return "policy";
if (a.includes("mfa") || a.includes("totp") || a.includes("webauthn") || a.includes("passkey") || a.includes("password")) return "security";
if (a.startsWith("external_auth")) return "oauth";
return "other";
};
const CATEGORY_META: Record<Category, { label: string; color: string }> = {
auth: { label: "Auth", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400" },
ssh: { label: "SSH", color: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" },
admin: { label: "Admin", color: "bg-red-500/10 text-red-600 dark:text-red-400" },
member: { label: "Member", color: "bg-violet-500/10 text-violet-600 dark:text-violet-400" },
policy: { label: "Policy", color: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
security: { label: "Security", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400" },
oauth: { label: "OAuth", color: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400" },
other: { label: "Other", color: "bg-muted text-muted-foreground" },
};
const getCategoryIcon = (cat: Category) => {
const cls = "w-4 h-4";
switch (cat) {
case "auth": return <LogIn className={cls} />;
case "ssh": return <Terminal className={cls} />;
case "admin": return <UserCog className={cls} />;
case "member": return <UserPlus className={cls} />;
case "policy": return <Settings className={cls} />;
case "security": return <Shield className={cls} />;
case "oauth": return <Link2 className={cls} />;
default: return <Key className={cls} />;
}
};
const ACTION_LABELS: Record<string, string> = {
// Sessions
"session.create": "Signed in",
"session.revoke": "Signed out",
"user.login": "Signed in",
"user.logout": "Signed out",
// Members
"org.member.add": "Member added",
"org.member.remove": "Member removed",
"org.member.role_change": "Member role changed",
"org.ownership.transferred": "Ownership transferred",
// Admin actions
"admin.mfa.remove": "MFA removed by admin",
"admin.oauth.unlink": "OAuth unlinked by admin",
"admin.password.set": "Password set by admin",
"admin.email.verify": "Email verified by admin",
// Security / policy
"org.security_policy.update": "Security policy updated",
"user.security_policy.override_update":"User policy override updated",
"mfa.policy.user_suspended": "User suspended (MFA policy)",
"mfa.policy.user_compliant": "User MFA compliant",
// Password
"user.password_change": "Password changed",
"user.password_reset": "Password reset",
// SSH
"ssh.key.added": "SSH key added",
"ssh.key.verified": "SSH key verified",
"ssh.key.deleted": "SSH key removed",
"ssh.cert.requested": "SSH certificate requested",
"ssh.cert.issued": "SSH certificate issued",
"ssh.cert.failed": "SSH certificate request failed",
"ssh.cert.revoked": "SSH certificate revoked",
// WebAuthn / Passkey
"webauthn.register.completed": "Passkey registered",
"webauthn.credential.deleted": "Passkey removed",
"webauthn.login.success": "Signed in with passkey",
"webauthn.login.failed": "Passkey login failed",
// TOTP
"totp.enroll.completed": "TOTP enrolled",
"totp.disabled": "TOTP disabled",
"totp.verify.failed": "TOTP verification failed",
// External auth
"external_auth.link.completed": "OAuth account linked",
"external_auth.unlink": "OAuth account unlinked",
"external_auth.login": "Signed in via OAuth",
"external_auth.login.failed": "OAuth login failed",
// Org
"org.create": "Organisation created",
"org.update": "Organisation updated",
"org.delete": "Organisation deleted",
// User lifecycle
"user.register": "User registered",
"user.suspend": "User suspended",
"user.unsuspend":"User unsuspended",
"user.delete": "User deleted",
};
const getActionLabel = (action: string) =>
ACTION_LABELS[action] ??
action.replace(/[._]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
// ─── action filter options (value = enum dot-notation) ───────────────────────
const ACTION_FILTER_OPTIONS = [
{ value: "all", label: "All actions" },
// Auth
{ value: "session.create", label: "Sign in" },
{ value: "session.revoke", label: "Sign out" },
{ value: "external_auth.login", label: "OAuth login" },
// Members
{ value: "org.member.add", label: "Member added" },
{ value: "org.member.remove", label: "Member removed" },
{ value: "org.member.role_change", label: "Role changed" },
// Admin actions
{ value: "admin.mfa.remove", label: "MFA removed (admin)" },
{ value: "admin.oauth.unlink", label: "OAuth unlinked (admin)" },
{ value: "admin.password.set", label: "Password set (admin)" },
// Security / policy
{ value: "org.security_policy.update", label: "Security policy changed" },
{ value: "user.password_change", label: "Password changed" },
{ value: "user.password_reset", label: "Password reset" },
// SSH
{ value: "ssh.key.added", label: "SSH key added" },
{ value: "ssh.key.verified", label: "SSH key verified" },
{ value: "ssh.cert.issued", label: "SSH cert issued" },
{ value: "ssh.cert.revoked", label: "SSH cert revoked" },
// MFA
{ value: "totp.enroll.completed", label: "TOTP enrolled" },
{ value: "totp.disabled", label: "TOTP disabled" },
{ value: "webauthn.register.completed", label: "Passkey registered" },
{ value: "webauthn.credential.deleted", label: "Passkey removed" },
// User lifecycle
{ value: "user.register", label: "User registered" },
{ value: "user.suspend", label: "User suspended" },
];
const PER_PAGE = 50;
// ─── cert metadata detail ─────────────────────────────────────────────────────
function CertDetail({ metadata }: { metadata?: Record<string, unknown> | null }) {
if (!metadata) return null;
const principal = metadata.principal as string | undefined;
const principals = metadata.principals as string[] | undefined;
const serial = metadata.serial_number ?? metadata.serial ?? metadata.cert_serial;
const principalList = principal ? [principal] : Array.isArray(principals) ? principals : [];
if (!principalList.length && !serial) return null;
return (
<span className="text-xs text-muted-foreground ml-2">
{principalList.length > 0 && <>principal: <span className="font-mono">{principalList.join(", ")}</span></>}
{principalList.length > 0 && serial && " · "}
{serial != null && <>serial: <span className="font-mono">{String(serial)}</span></>}
</span>
);
}
// ─── component ────────────────────────────────────────────────────────────────
export default function OrgAuditPage() {
const params = useParams<{ orgId?: string }>();
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
const orgId = params.orgId || fallbackOrgId;
const { orgId } = useCurrentOrganizationId();
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [actionFilter, setActionFilter] = useState("all");
const [successFilter, setSuccessFilter] = useState("all");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAuditLogs = useCallback(async (currentOrgId: string) => {
// debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
// reset page on filter change
useEffect(() => { setPage(1); }, [actionFilter, successFilter, debouncedSearch]);
const fetchLogs = useCallback(async () => {
if (!orgId) { setIsLoading(false); return; }
setIsLoading(true);
setError(null);
try {
setIsLoading(true);
setError(null);
const response = await api.organizations.getAuditLogs(currentOrgId);
setAuditLogs(response.audit_logs || []);
const params: Record<string, string> = {
page: String(page),
per_page: String(PER_PAGE),
};
if (actionFilter !== "all") params.action = actionFilter;
if (successFilter !== "all") params.success = successFilter;
if (debouncedSearch) params.q = debouncedSearch;
const resp = await api.organizations.getAuditLogs(orgId, params);
setAuditLogs(resp.audit_logs ?? []);
setTotalCount(resp.count ?? 0);
setTotalPages(resp.pages ?? 1);
} catch (err) {
console.error("Failed to fetch audit logs:", err);
console.error("Failed to fetch org audit logs:", err);
setError("Failed to load audit logs. Please try again.");
} finally {
setIsLoading(false);
}
}, []);
}, [orgId, page, actionFilter, successFilter, debouncedSearch]);
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);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).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;
});
useEffect(() => { fetchLogs(); }, [fetchLogs]);
return (
<div className="page-container">
{/* Header */}
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title">Audit Log</h1>
<h1 className="page-title">Org Audit Log</h1>
<p className="page-description">
View all administrative actions and changes
All organisation activity user events, admin actions, policy changes
{totalCount > 0 && ` · ${totalCount.toLocaleString()} total`}
</p>
</div>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Export
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={isLoading}>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
<div className="flex flex-col sm:flex-row gap-4 mb-4">
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search events..."
placeholder="Search descriptions…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[180px]">
<Select value={actionFilter} onValueChange={setActionFilter}>
<SelectTrigger className="w-[210px]">
<Filter className="w-4 h-4 mr-2" />
<SelectValue placeholder="Filter by type" />
<SelectValue placeholder="Filter by action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All events</SelectItem>
<SelectItem value="members">Member changes</SelectItem>
<SelectItem value="policies">Policy changes</SelectItem>
<SelectItem value="clients">OIDC clients</SelectItem>
{ACTION_FILTER_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={successFilter} onValueChange={setSuccessFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="true">Success only</SelectItem>
<SelectItem value="false">Failures only</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading audit logs...</span>
<span className="ml-2 text-muted-foreground">Loading</span>
</div>
) : error ? (
<div className="p-8 text-center text-destructive">
{error}
<div className="py-12 text-center text-destructive">
<AlertTriangle className="w-8 h-8 mx-auto mb-2" />
<p>{error}</p>
</div>
) : filteredLogs.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No audit events found
) : auditLogs.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
No audit events match the current filters.
</div>
) : (
<div className="divide-y">
{filteredLogs.map((log) => (
<div key={log.id} className="p-4 flex items-start gap-4">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
!log.success
? "bg-destructive/10 text-destructive"
: "bg-accent/10 text-accent"
}`}
>
{getEventIcon(log.action)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-foreground">
{getEventTitle(log.action)}
</p>
{log.resource_type && (
<Badge variant="secondary" className="text-xs">
{log.resource_type}
</Badge>
)}
{!log.success && (
<Badge variant="destructive" className="text-xs">
Failed
</Badge>
)}
{auditLogs.map((log) => {
const cat = getCategory(log.action);
const meta = CATEGORY_META[cat];
const isCert = log.action.startsWith("ssh.cert");
return (
<div key={log.id} className="flex items-start gap-4 px-4 py-3 hover:bg-muted/30 transition-colors">
{/* Icon */}
<div
className={`mt-0.5 w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
log.success ? meta.color : "bg-destructive/10 text-destructive"
}`}
>
{log.success ? getCategoryIcon(cat) : <XCircle className="w-4 h-4" />}
</div>
<div className="mt-1 text-sm text-muted-foreground">
<span>by {log.user?.full_name || log.user?.email || "System"}</span>
{/* Body */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm text-foreground">
{getActionLabel(log.action)}
</span>
<Badge variant="secondary" className={`text-xs px-1.5 py-0 ${meta.color}`}>
{meta.label}
</Badge>
{!log.success && (
<Badge variant="destructive" className="text-xs px-1.5 py-0">Failed</Badge>
)}
</div>
{/* Description */}
{log.description && (
<>
<span className="mx-2"></span>
<span>{log.description}</span>
</>
<p className="mt-0.5 text-sm text-muted-foreground">
{log.description}
{isCert && <CertDetail metadata={log.metadata} />}
</p>
)}
{log.error_message && (
<p className="mt-0.5 text-xs text-destructive">{log.error_message}</p>
)}
{/* Actor / meta row */}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
{log.user?.email ? (
<span className="font-medium text-foreground/70">{log.user.email}</span>
) : log.user_id ? (
<span className="font-mono">{log.user_id.slice(0, 8)}</span>
) : (
<span className="italic">System</span>
)}
{log.ip_address && (
<span className="font-mono">{log.ip_address}</span>
)}
{log.resource_type && (
<Badge variant="outline" className="text-xs px-1.5 py-0 font-mono">
{log.resource_type}
</Badge>
)}
</div>
</div>
{/* Timestamp */}
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<p className="text-xs text-muted-foreground whitespace-nowrap">
{formatDateTime(log.created_at)}
</p>
{log.success ? (
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
) : (
<XCircle className="w-3.5 h-3.5 text-destructive" />
)}
</div>
</div>
<p className="text-sm text-muted-foreground whitespace-nowrap">
{formatDate(log.created_at)}
</p>
</div>
))}
);
})}
</div>
)}
</CardContent>
</Card>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages} &nbsp;·&nbsp; {totalCount.toLocaleString()} events
</p>
<div className="flex items-center gap-2">
<Button
variant="outline" size="sm"
disabled={page <= 1 || isLoading}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="w-4 h-4" /> Prev
</Button>
<Button
variant="outline" size="sm"
disabled={page >= totalPages || isLoading}
onClick={() => setPage((p) => p + 1)}
>
Next <ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
);
}
}
+414
View File
@@ -0,0 +1,414 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Loader2,
CheckCircle2,
XCircle,
Eye,
EyeOff,
Trash2,
Save,
Info,
Lock,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import { api, ApiError, ZeroTierOrgConfig } from "@/lib/api";
import { useOrg } from "@/contexts/OrgContext";
type Mode = "central" | "controller";
const MODE_HELP: Record<Mode, { label: string; defaultUrl: string; description: string }> = {
central: {
label: "ZeroTier Central (SaaS)",
defaultUrl: "https://api.zerotier.com/api/v1",
description:
"Managed by ZeroTier Inc. Get your API token at my.zerotier.com → Account → API Tokens.",
},
controller: {
label: "Self-hosted Controller",
defaultUrl: "http://localhost:9994",
description:
"Your own zerotier-one daemon. Find the token in /var/lib/zerotier-one/authtoken.secret on the controller host.",
},
};
export default function ZeroTierConfigPage() {
const { selectedOrg } = useOrg();
const { toast } = useToast();
const queryClient = useQueryClient();
const orgId = selectedOrg?.id ?? "";
// ── form state ──────────────────────────────────────────────────────────────
const [token, setToken] = useState("");
const [showToken, setShowToken] = useState(false);
const [mode, setMode] = useState<Mode | "">("");
const [url, setUrl] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// ── query: load current config ──────────────────────────────────────────────
const { data, isLoading, isError } = useQuery({
queryKey: ["org", orgId, "ztConfig"],
queryFn: () => api.zerotier.getOrgZtConfig(orgId),
enabled: !!orgId,
// Pre-populate form fields once data arrives
select: (resp) => resp.zerotier_config,
});
const cfg: ZeroTierOrgConfig | undefined = data;
// ── mutation: save ──────────────────────────────────────────────────────────
const saveMutation = useMutation({
mutationFn: () => {
const resolvedMode = mode || cfg?.zt_api_mode;
const resolvedUrl =
resolvedMode === "central"
? MODE_HELP.central.defaultUrl
: url.trim();
return api.zerotier.setOrgZtConfig(orgId, {
zt_api_token: token,
zt_api_mode: resolvedMode as "central" | "controller",
zt_api_url: resolvedUrl,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["org", orgId, "ztConfig"] });
setToken("");
toast({
title: "ZeroTier config saved",
description: "Credentials saved and connectivity verified ✓",
});
},
onError: (err: Error) => {
let title = "Save failed";
let description = err.message;
if (err instanceof ApiError && err.details?.connectivity_test) {
const conn = err.details.connectivity_test as { ok: boolean; error: string | null };
const raw = conn.error ?? "";
if (raw.includes("401") || raw.includes("403")) {
title = "Authentication failed";
description =
"The ZeroTier controller rejected the token (HTTP 401). " +
"Make sure you're using the controller's authtoken.secret — " +
"ztnet / Central API keys are different from the controller token.\n\n" +
"Credentials were NOT saved.";
} else if (raw.includes("Connection") || raw.includes("timed out")) {
title = "Controller unreachable";
description =
`Could not connect to the controller URL. ${raw}\n\n` +
"Credentials were NOT saved.";
} else {
title = "Connectivity test failed";
description = `${raw || "Unknown error"}\n\nCredentials were NOT saved.`;
}
}
toast({ title, description, variant: "destructive" });
},
});
// ── mutation: delete ────────────────────────────────────────────────────────
const deleteMutation = useMutation({
mutationFn: () => api.zerotier.deleteOrgZtConfig(orgId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["org", orgId, "ztConfig"] });
setToken("");
setMode("");
setUrl("");
setDeleteDialogOpen(false);
toast({
title: "ZeroTier config removed",
description: "ZeroTier features are disabled until new credentials are configured.",
});
},
onError: (err: Error) => {
toast({ title: "Failed to remove", description: err.message, variant: "destructive" });
},
});
// ── helpers ─────────────────────────────────────────────────────────────────
const handleSave = () => {
const resolvedMode = mode || cfg?.zt_api_mode;
if (!resolvedMode) {
toast({ title: "Mode required", description: "Please select Central or Controller mode.", variant: "destructive" });
return;
}
if (!token) {
toast({ title: "Token required", description: "Please enter a ZeroTier API token.", variant: "destructive" });
return;
}
if (resolvedMode !== "central" && !url.trim()) {
toast({ title: "Controller URL required", description: "Please enter the URL for your self-hosted ZeroTier controller (e.g. http://host:9993).", variant: "destructive" });
return;
}
saveMutation.mutate();
};
const selectedMode = (mode || cfg?.zt_api_mode || null) as Mode | null;
const modeHelp = selectedMode ? MODE_HELP[selectedMode] : null;
const canSave = !!token && !!selectedMode && (selectedMode === "central" || !!url.trim());
// ── render ──────────────────────────────────────────────────────────────────
return (
<div className="container max-w-2xl py-8 space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">ZeroTier Configuration</h1>
<p className="text-sm text-muted-foreground mt-1">
Configure your organization's ZeroTier credentials.
</p>
</div>
{/* Configure form */}
<Card>
<CardHeader>
<CardTitle className="text-base">
{cfg?.zt_api_token_set ? "Update Credentials" : "Set Credentials"}
</CardTitle>
<CardDescription>
{cfg?.zt_api_token_set
? "Enter a new token to replace the existing one. Leave token blank to cancel."
: "Configure a ZeroTier API token for this organization."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{/* Mode */}
<div className="space-y-1.5">
<Label htmlFor="zt-mode">Mode <span className="text-xs text-destructive font-medium">(required)</span></Label>
<Select
value={mode || cfg?.zt_api_mode || ""}
onValueChange={(v) => {
const m = v as Mode;
setMode(m);
// Central always uses a fixed URL — lock it in.
// Controller: clear so the user can supply their own.
setUrl(m === "central" ? MODE_HELP.central.defaultUrl : "");
}}
>
<SelectTrigger id="zt-mode">
<SelectValue placeholder="Select mode (required)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="central">ZeroTier Central (SaaS)</SelectItem>
<SelectItem value="controller">Self-hosted Controller</SelectItem>
</SelectContent>
</Select>
{modeHelp && (
<p className="text-xs text-muted-foreground">{modeHelp.description}</p>
)}
</div>
{/* Token */}
<div className="space-y-1.5">
<Label htmlFor="zt-token">
API Token
{cfg?.zt_api_token_set && (
<span className="ml-2 text-xs text-muted-foreground">(leave blank to keep existing)</span>
)}
</Label>
<div className="relative">
<Input
id="zt-token"
type={showToken ? "text" : "password"}
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={
cfg?.zt_api_token_set
? "•••••••• (enter new token to replace)"
: selectedMode === "central"
? "zts1…"
: selectedMode === "controller"
? "authtoken.secret contents"
: "Enter ZeroTier API token"
}
className="pr-10 font-mono text-sm"
autoComplete="off"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowToken((v) => !v)}
>
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{/* Controller URL */}
<div className="space-y-1.5">
<Label htmlFor="zt-url">
{selectedMode === "central" ? "API URL" : "Controller URL"}
{selectedMode !== "central" && (
<span className="ml-1.5 text-xs text-destructive font-medium">
(required)
</span>
)}
</Label>
<div className="relative">
<Input
id="zt-url"
type="url"
value={
selectedMode === "central"
? MODE_HELP.central.defaultUrl
: url
}
onChange={(e) => {
if (selectedMode !== "central") setUrl(e.target.value);
}}
readOnly={selectedMode === "central"}
disabled={selectedMode === "central"}
placeholder={modeHelp?.defaultUrl ?? "https://api.zerotier.com/api/v1"}
className={`font-mono text-sm pr-10 ${
selectedMode === "central"
? "bg-muted text-muted-foreground cursor-not-allowed select-all"
: ""
}`}
/>
{selectedMode === "central" && (
<Lock className="absolute right-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
)}
</div>
{selectedMode === "central" && (
<p className="text-xs text-muted-foreground">
ZeroTier Central always uses this fixed endpoint it cannot be changed.
</p>
)}
</div>
{/* Info alert */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="text-xs">
A connectivity test runs automatically when you save. Credentials are only persisted
if the test passes bad tokens or unreachable URLs will be rejected.
</AlertDescription>
</Alert>
{/* Connectivity test result from last save */}
{saveMutation.isSuccess && (
<ConnectivityResult
ok={saveMutation.data.connectivity_test.ok}
error={saveMutation.data.connectivity_test.error}
/>
)}
{/* Persistent inline error after a failed save */}
{saveMutation.isError && (
<div className="flex items-start gap-2 text-sm text-red-800 bg-red-50 border border-red-200 rounded-md px-3 py-2">
<XCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">Save failed credentials were NOT saved</p>
<p className="text-xs mt-0.5 opacity-80">{(() => {
const err = saveMutation.error;
if (err instanceof ApiError && err.details?.connectivity_test) {
const conn = err.details.connectivity_test as { ok: boolean; error: string | null };
const raw = conn.error ?? "";
if (raw.includes("401") || raw.includes("403"))
return "The controller rejected the API token (401 Unauthorized). Make sure you are using the controller's authtoken.secret, not a ztnet or Central API key.";
if (raw.includes("Connection") || raw.includes("timed out"))
return `Could not reach the controller at the specified URL. ${raw}`;
return raw || "Connectivity test failed.";
}
return err?.message ?? "Unknown error";
})()}</p>
</div>
</div>
)}
<div className="flex items-center justify-between pt-1">
<Button
onClick={handleSave}
disabled={saveMutation.isPending || !canSave}
>
{saveMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving</>
) : (
<><Save className="h-4 w-4 mr-2" /> Save & Test</>
)}
</Button>
{cfg?.zt_api_token_set && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Remove
</Button>
)}
</div>
</CardContent>
</Card>
{/* Delete confirm */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove ZeroTier config for {selectedOrg?.name}?</AlertDialogTitle>
<AlertDialogDescription>
This will clear all ZeroTier credentials for this organization. All ZeroTier
network operations will be disabled until new credentials are configured.
Existing networks and device memberships are not deleted but will stop working.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Remove"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ── sub-component ─────────────────────────────────────────────────────────────
function ConnectivityResult({ ok, error }: { ok: boolean; error: string | null }) {
if (!ok) return null; // failures are shown via the error toast — don't double-display
return (
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<span>Connectivity verified ZeroTier is reachable with these credentials.</span>
</div>
);
}
+9 -12
View File
@@ -47,11 +47,11 @@ export function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardPro
const isSystem = !!ca.is_system;
// ── User CA: server trusts this public key so it accepts user certs ──────
const userCaServerSnippet = `# On each SSH server — trust Gatehouse-issued user certificates:
echo '${ca.public_key.trim()}' >> /etc/ssh/trusted_user_ca_keys
const userCaServerSnippet = `# On each SSH server — trust Secuird-issued user certificates:
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/<unix-user> containing one principal per line.`;
@@ -63,7 +63,7 @@ AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
# Server side (separate step)
# 1. Collect the server's HOST public key:
# cat /etc/ssh/ssh_host_ed25519_key.pub
# 2. Submit it to Gatehouse "Issue Host Certificate" to get a signed cert.
# 2. Submit it to Secuird "Issue Host Certificate" to get a signed cert.
# 3. Install the cert on the server:
# /etc/ssh/sshd_config:
# HostKey /etc/ssh/ssh_host_ed25519_key
@@ -144,7 +144,7 @@ AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
<CardContent className="space-y-4">
{/* Stats row — hidden for system CAs */}
{!isSystem && (
<div className="grid grid-cols-4 gap-3 text-center">
<div className="grid grid-cols-3 gap-3 text-center">
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.active_certs}</p>
<p className="text-xs text-muted-foreground">Active certs</p>
@@ -157,10 +157,7 @@ AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
<p className="text-lg font-semibold">{ca.default_cert_validity_hours}h</p>
<p className="text-xs text-muted-foreground">Default validity</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.next_serial_number ?? "—"}</p>
<p className="text-xs text-muted-foreground">Next serial</p>
</div>
</div>
)}
@@ -200,8 +197,8 @@ AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
<span className="flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5" />
{isUser
? "Server setup — trust Gatehouse user certificates"
: "Client setup — trust Gatehouse host certificates"}
? "Server setup — trust Secuird user certificates"
: "Client setup — trust Secuird host certificates"}
</span>
</AccordionTrigger>
<AccordionContent className="pb-3">
@@ -209,7 +206,7 @@ AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
<div className="mb-2 rounded border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/40 px-2 py-1.5 text-xs text-amber-800 dark:text-amber-300">
<strong>Two separate steps:</strong> (1) Put this CA public key in client{" "}
<code className="font-mono">known_hosts</code>. (2) Issue a host certificate
for each server via Gatehouse and install it as{" "}
for each server via Secuird and install it as{" "}
<code className="font-mono">HostCertificate</code>.
</div>
)}
+1 -1
View File
@@ -127,7 +127,7 @@ export function CASection({
<p>
Certificates are being signed by a CA key loaded from the server
configuration, not managed through this UI. Generate a managed key below to
take full control of certificate issuance from Gatehouse.
take full control of certificate issuance from Secuird.
</p>
</div>
<Button
+1 -1
View File
@@ -176,7 +176,7 @@ ssh-keygen -L -f /etc/ssh/ssh_host_ed25519_key-cert.pub`
</p>
<p>
<strong>Step 2 (here):</strong> For each server, collect its host public key,
paste it below, and Gatehouse will sign it. Install the resulting certificate
paste it below, and Secuird will sign it. Install the resulting certificate
as <code className="font-mono">HostCertificate</code> in{" "}
<code className="font-mono">sshd_config</code>.
</p>
+249 -113
View File
@@ -1,118 +1,224 @@
import { useState, useEffect } from "react";
import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, Loader2, RefreshCw, Users } from "lucide-react";
import { useState, useEffect, useCallback } from "react";
import {
LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle,
Loader2, RefreshCw, Link2, Terminal, CheckCircle2, XCircle,
ChevronLeft, ChevronRight, Search,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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";
import { formatDateTime } from "@/lib/date";
// Map audit log action strings to display info
const getEventDisplay = (action: string) => {
// ─── event display mapping ────────────────────────────────────────────────────
interface EventDisplay {
icon: React.ReactNode;
title: string;
}
const getEventDisplay = (action: string): EventDisplay => {
const a = action.toLowerCase();
if (a.includes("login") && a.includes("fail")) {
return { icon: <AlertTriangle className="w-4 h-4" />, title: "Failed login attempt", failed: true };
}
if (a.includes("login") || a.includes("authenticate")) {
return { icon: <LogIn className="w-4 h-4" />, title: "Signed in", failed: false };
}
if (a.includes("logout") || a.includes("sign_out")) {
return { icon: <LogOut className="w-4 h-4" />, title: "Signed out", failed: false };
}
if (a.includes("passkey") || a.includes("webauthn")) {
return { icon: <Fingerprint className="w-4 h-4" />, title: "Passkey event", failed: false };
}
if (a.includes("mfa") || a.includes("totp") || a.includes("2fa")) {
return { icon: <Smartphone className="w-4 h-4" />, title: "MFA event", failed: false };
}
if (a.includes("ssh")) {
return { icon: <Key className="w-4 h-4" />, title: "SSH key event", failed: false };
}
return { icon: <Key className="w-4 h-4" />, title: action.replace(/_/g, " "), failed: !action.includes("success") && a.includes("fail") };
// Sessions
if (a === "session.create") return { icon: <LogIn className="w-4 h-4" />, title: "Signed in" };
if (a === "session.revoke") return { icon: <LogOut className="w-4 h-4" />, title: "Signed out" };
if (a === "user.login") return { icon: <LogIn className="w-4 h-4" />, title: "Signed in" };
if (a === "user.logout") return { icon: <LogOut className="w-4 h-4" />, title: "Signed out" };
// OAuth / external auth
if (a === "external_auth.link.completed") return { icon: <Link2 className="w-4 h-4" />, title: "OAuth account linked" };
if (a === "external_auth.link.initiated") return { icon: <Link2 className="w-4 h-4" />, title: "OAuth link started" };
if (a === "external_auth.link.failed") return { icon: <Link2 className="w-4 h-4" />, title: "OAuth link failed" };
if (a === "external_auth.unlink") return { icon: <Link2 className="w-4 h-4" />, title: "OAuth account unlinked" };
if (a === "external_auth.login") return { icon: <LogIn className="w-4 h-4" />, title: "Signed in via OAuth" };
if (a === "external_auth.login.failed") return { icon: <LogIn className="w-4 h-4" />, title: "OAuth login failed" };
// SSH keys
if (a === "ssh.key.added") return { icon: <Key className="w-4 h-4" />, title: "SSH key added" };
if (a === "ssh.key.verified") return { icon: <Key className="w-4 h-4" />, title: "SSH key verified" };
if (a === "ssh.key.deleted") return { icon: <Key className="w-4 h-4" />, title: "SSH key removed" };
if (a === "ssh.key.validation.failed")return { icon: <Key className="w-4 h-4" />, title: "SSH key validation failed" };
if (a === "ssh.cert.requested") return { icon: <Terminal className="w-4 h-4" />, title: "SSH certificate requested" };
if (a === "ssh.cert.issued") return { icon: <Terminal className="w-4 h-4" />, title: "SSH certificate issued" };
if (a === "ssh.cert.failed") return { icon: <Terminal className="w-4 h-4" />, title: "SSH certificate request failed" };
if (a === "ssh.cert.revoked") return { icon: <Terminal className="w-4 h-4" />, title: "SSH certificate revoked" };
// WebAuthn / Passkey
if (a === "webauthn.register.completed") return { icon: <Fingerprint className="w-4 h-4" />, title: "Passkey registered" };
if (a === "webauthn.register.initiated") return { icon: <Fingerprint className="w-4 h-4" />, title: "Passkey registration started" };
if (a === "webauthn.register.failed") return { icon: <Fingerprint className="w-4 h-4" />, title: "Passkey registration failed" };
if (a === "webauthn.login.success") return { icon: <Fingerprint className="w-4 h-4" />, title: "Signed in with passkey" };
if (a === "webauthn.login.failed") return { icon: <Fingerprint className="w-4 h-4" />, title: "Passkey login failed" };
if (a === "webauthn.credential.deleted") return { icon: <Fingerprint className="w-4 h-4" />, title: "Passkey removed" };
if (a === "webauthn.credential.renamed") return { icon: <Fingerprint className="w-4 h-4" />, title: "Passkey renamed" };
// TOTP / MFA
if (a === "totp.enroll.completed") return { icon: <Smartphone className="w-4 h-4" />, title: "TOTP authenticator enrolled" };
if (a === "totp.enroll.initiated") return { icon: <Smartphone className="w-4 h-4" />, title: "TOTP enrolment started" };
if (a === "totp.verify.success") return { icon: <Smartphone className="w-4 h-4" />, title: "TOTP code verified" };
if (a === "totp.verify.failed") return { icon: <Smartphone className="w-4 h-4" />, title: "TOTP verification failed" };
if (a === "totp.disabled") return { icon: <Smartphone className="w-4 h-4" />, title: "TOTP disabled" };
if (a === "totp.backup_code.used") return { icon: <Smartphone className="w-4 h-4" />, title: "TOTP backup code used" };
if (a === "totp.backup_codes.regenerated")return { icon: <Smartphone className="w-4 h-4" />, title: "TOTP backup codes regenerated" };
// Password
if (a === "user.password_change") return { icon: <Key className="w-4 h-4" />, title: "Password changed" };
if (a === "user.password_reset") return { icon: <Key className="w-4 h-4" />, title: "Password reset" };
// Generic fallback
return {
icon: <Key className="w-4 h-4" />,
title: action.replace(/[._]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
};
};
// ─── cert metadata detail row ─────────────────────────────────────────────────
function CertDetail({ metadata }: { metadata?: Record<string, unknown> | null }) {
if (!metadata) return null;
const principal = metadata.principal as string | undefined;
const principals = metadata.principals as string[] | undefined;
const serial = metadata.serial_number ?? metadata.serial ?? metadata.cert_serial;
const expiry = metadata.expiry ?? metadata.expires_at ?? metadata.valid_until;
const principalList = principal
? [principal]
: Array.isArray(principals)
? principals
: [];
if (!principalList.length && !serial) return null;
return (
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
{principalList.length > 0 && (
<span className="text-muted-foreground">
Principal{principalList.length > 1 ? "s" : ""}:{" "}
<span className="font-mono text-foreground/80">{principalList.join(", ")}</span>
</span>
)}
{serial != null && (
<span className="text-muted-foreground">
Serial: <span className="font-mono text-foreground/80">{String(serial)}</span>
</span>
)}
{expiry && (
<span className="text-muted-foreground">
Expires: <span className="font-mono text-foreground/80">{new Date(String(expiry)).toLocaleDateString()}</span>
</span>
)}
</div>
);
}
// ─── filter options ────────────────────────────────────────────────────────────
const FILTER_OPTIONS = [
{ value: "all", label: "All events" },
{ value: "session.create", label: "Signed in" },
{ value: "session.revoke", label: "Signed out" },
{ value: "external_auth.login", label: "OAuth login" },
{ value: "external_auth.link.completed", label: "OAuth linked" },
{ value: "external_auth.unlink", label: "OAuth unlinked" },
{ value: "ssh.key.added", label: "SSH key added" },
{ value: "ssh.key.verified", label: "SSH key verified" },
{ value: "ssh.cert.issued", label: "SSH cert issued" },
{ value: "ssh.cert.failed", label: "SSH cert failed" },
{ value: "webauthn.register.completed", label: "Passkey registered" },
{ value: "totp.enroll.completed", label: "TOTP enrolled" },
{ value: "user.password_change", label: "Password changed" },
];
const PER_PAGE = 50;
// ─── component ────────────────────────────────────────────────────────────────
export default function ActivityPage() {
const { isOrgAdmin } = useAuth();
const [filter, setFilter] = useState("all");
const [view, setView] = useState<"mine" | "org">("mine");
const [actionFilter, setActionFilter] = useState("all");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [events, setEvents] = useState<AuditLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const loadEvents = () => {
// debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
// reset page when filters change
useEffect(() => { setPage(1); }, [actionFilter, debouncedSearch]);
const loadEvents = useCallback(async () => {
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 ?? []);
try {
const params: Record<string, string> = {
page: String(page),
per_page: String(PER_PAGE),
};
if (actionFilter !== "all") params.action = actionFilter;
if (debouncedSearch) params.q = debouncedSearch;
req
.then((logs) => setEvents(logs))
.catch(() => setError("Failed to load activity. Please try again."))
.finally(() => setIsLoading(false));
};
const data = await api.users.auditLogs(params);
setEvents(data.audit_logs ?? []);
setTotalCount(data.count ?? 0);
setTotalPages(data.pages ?? 1);
} catch {
setError("Failed to load activity. Please try again.");
} finally {
setIsLoading(false);
}
}, [page, actionFilter, debouncedSearch]);
useEffect(() => { loadEvents(); }, [view]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadEvents(); }, [loadEvents]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).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;
});
const formatDate = (dateString: string) =>
formatDateTime(dateString, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
return (
<div className="page-container">
{/* Header */}
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title">Activity</h1>
<p className="page-description">
{view === "org" ? "Organization-wide audit log" : "Your recent account activity and security events"}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
{isOrgAdmin && (
<Tabs value={view} onValueChange={(v) => setView(v as "mine" | "org")}>
<TabsList>
<TabsTrigger value="mine">My Activity</TabsTrigger>
<TabsTrigger value="org">
<Users className="w-3.5 h-3.5 mr-1" />
Org Logs
</TabsTrigger>
</TabsList>
</Tabs>
)}
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Filter events" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All events</SelectItem>
<SelectItem value="logins">Logins only</SelectItem>
<SelectItem value="security">Security changes</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={loadEvents} disabled={isLoading}>
<RefreshCw className={`w-4 h-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
<h1 className="page-title">My Activity</h1>
<p className="page-description">Your recent account activity and security events</p>
</div>
<Button variant="outline" size="icon" onClick={loadEvents} disabled={isLoading}>
<RefreshCw className={`w-4 h-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search activity…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select value={actionFilter} onValueChange={setActionFilter}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filter by event" />
</SelectTrigger>
<SelectContent>
{FILTER_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Log list */}
<Card>
<CardContent className="p-0">
{isLoading ? (
@@ -124,19 +230,20 @@ export default function ActivityPage() {
<AlertTriangle className="w-8 h-8 mx-auto mb-2 text-destructive" />
<p>{error}</p>
</div>
) : filteredEvents.length === 0 ? (
) : events.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No activity events found.</p>
</div>
) : (
<div className="divide-y">
{filteredEvents.map((event) => {
{events.map((event) => {
const display = getEventDisplay(event.action);
const isCert = event.action.startsWith("ssh.cert");
return (
<div key={event.id} className="p-4 flex items-start gap-4">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
display.failed || !event.success
!event.success
? "bg-destructive/10 text-destructive"
: "bg-accent/10 text-accent"
}`}
@@ -145,33 +252,37 @@ export default function ActivityPage() {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-foreground capitalize">
{display.title}
</p>
{(!event.success || display.failed) && (
<Badge variant="destructive" className="text-xs">
Failed
</Badge>
<p className="font-medium text-foreground">{display.title}</p>
{!event.success && (
<Badge variant="destructive" className="text-xs">Failed</Badge>
)}
</div>
<div className="mt-1 text-sm text-muted-foreground space-y-0.5">
{view === "org" && event.user_id && (
<p className="font-medium text-xs text-foreground/70">User: {event.user_id}</p>
{event.description && (
<p className="mt-0.5 text-sm text-muted-foreground">{event.description}</p>
)}
{/* Cert-specific: principal + serial */}
{isCert && <CertDetail metadata={event.metadata} />}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
{event.ip_address && (
<span className="font-mono">{event.ip_address}</span>
)}
{event.user_agent && (
<span className="truncate max-w-[220px]" title={event.user_agent}>
{event.user_agent.match(/\(([^)]+)\)/)?.[1]?.split(";")[0]?.trim() ?? event.user_agent.slice(0, 40)}
</span>
)}
{event.description && <p>{event.description}</p>}
<div className="flex items-center gap-2 flex-wrap">
{event.ip_address && (
<span className="font-mono text-xs">{event.ip_address}</span>
)}
{event.user_agent && (
<span className="truncate max-w-[200px]">{event.user_agent}</span>
)}
</div>
</div>
</div>
<p className="text-sm text-muted-foreground whitespace-nowrap">
{formatDate(event.created_at)}
</p>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<p className="text-xs text-muted-foreground whitespace-nowrap">
{formatDate(event.created_at)}
</p>
{event.success ? (
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
) : (
<XCircle className="w-3.5 h-3.5 text-destructive" />
)}
</div>
</div>
);
})}
@@ -179,6 +290,31 @@ export default function ActivityPage() {
)}
</CardContent>
</Card>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages} &nbsp;·&nbsp; {totalCount.toLocaleString()} events
</p>
<div className="flex items-center gap-2">
<Button
variant="outline" size="sm"
disabled={page <= 1 || isLoading}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="w-4 h-4" /> Prev
</Button>
<Button
variant="outline" size="sm"
disabled={page >= totalPages || isLoading}
onClick={() => setPage((p) => p + 1)}
>
Next <ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useState } from "react";
import { Terminal, Copy, CheckCircle, ChevronDown, ChevronRight } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
const SIGN_URL = "https://api.secuird.tech";
// ── Code block with copy button ────────────────────────────────────────────────
function CodeBlock({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
toast({ title: "Copied!" });
setTimeout(() => setCopied(false), 2000);
} catch {
toast({ variant: "destructive", title: "Copy failed" });
}
};
return (
<div className="relative rounded-md border border-zinc-700 bg-zinc-950 my-2 group">
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded text-zinc-500 hover:text-zinc-200 transition-colors"
aria-label="Copy"
>
{copied
? <CheckCircle className="w-3.5 h-3.5 text-green-400" />
: <Copy className="w-3.5 h-3.5" />}
</button>
<pre className="p-4 pr-10 text-sm text-green-300 font-mono overflow-x-auto whitespace-pre leading-relaxed">
<code>{code}</code>
</pre>
</div>
);
}
// ── Numbered step ──────────────────────────────────────────────────────────────
function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
return (
<div className="flex gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold flex items-center justify-center mt-0.5">
{n}
</div>
<div className="flex-1 space-y-1.5">
<p className="font-medium text-sm">{title}</p>
{children}
</div>
</div>
);
}
// ── Collapsible FAQ item ───────────────────────────────────────────────────────
function FaqItem({ q, children }: { q: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left py-2.5 text-sm hover:text-primary transition-colors">
{open
? <ChevronDown className="w-3.5 h-3.5 flex-shrink-0 text-primary" />
: <ChevronRight className="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground" />}
<span>{q}</span>
</CollapsibleTrigger>
<CollapsibleContent className="pb-3 pl-5 text-sm text-muted-foreground space-y-2">
{children}
</CollapsibleContent>
</Collapsible>
);
}
// ── Main page ──────────────────────────────────────────────────────────────────
export default function CLIGuidePage() {
return (
<div className="page-container">
{/* Header */}
<div className="page-header">
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-primary" />
<h1 className="page-title">Secuird CLI</h1>
</div>
<p className="page-description">
Sign your SSH key from the command line. Browser login happens once token is cached.
</p>
</div>
<div className="max-w-2xl space-y-10">
{/* Setup steps */}
<div className="space-y-6">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Setup</p>
<Step n={1} title="Download the CLI script">
<CodeBlock code="curl -o ~/.secuird/secuird-cli.py --create-dirs https://raw.githubusercontent.com/CoryHawkless/gatehouse-api/main/client/gatehouse-cli.py" />
</Step>
<Step n={2} title="Set up Python venv">
<p className="text-xs text-muted-foreground">
Creates an isolated virtualenv so nothing pollutes your system Python.
</p>
<p className="text-sm font-medium mt-2">Install dependencies</p>
<CodeBlock code={`python3 -m venv ~/.secuird/venv\n~/.secuird/venv/bin/pip install requests PyJWT pytz python-dotenv sshkey-tools coloredlogs`} />
<p className="text-sm font-medium">Create the <code className="bg-muted px-1 rounded text-xs">secuird</code> command</p>
<CodeBlock code={`mkdir -p ~/.local/bin\n\ncat > ~/.local/bin/secuird << 'EOF'\n#!/usr/bin/env bash\nexec ~/.secuird/venv/bin/python ~/.secuird/secuird-cli.py "$@"\nEOF\n\nchmod +x ~/.local/bin/secuird\n\necho 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc\nsource ~/.bashrc`} />
</Step>
<Step n={3} title="Set your server URL">
<CodeBlock code={`echo 'SIGN_URL=${SIGN_URL}' > ~/.secuird/.env`} />
</Step>
<Step n={4} title="Register your SSH key (once)">
<CodeBlock code="secuird --add-key -k ~/.ssh/id_ed25519.pub" />
<p className="text-xs text-muted-foreground">Your browser will open for login. Token is cached after first login.</p>
</Step>
<Step n={5} title="Request a signed certificate">
<CodeBlock code="secuird --request-cert" />
<p className="text-xs text-muted-foreground">Certificate saved to <code className="bg-muted px-1 rounded text-xs">/tmp/ssh-cert</code>. Re-run when it expires.</p>
</Step>
<Step n={6} title="SSH in">
<CodeBlock code="ssh user@your-server -o CertificateFile=/tmp/ssh-cert" />
</Step>
</div>
<hr className="border-border/50" />
{/* Commands reference */}
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">Commands</p>
<div className="divide-y divide-border/50">
{[
["--request-cert", "-r", "Request / renew a signed SSH certificate"],
["--add-key -k <file>", "-a", "Upload & verify an SSH public key"],
["--list-keys", "", "List your registered SSH keys"],
["--remove-key [id]", "", "Remove a key (interactive if no ID)"],
["--check-cert", "-c", "Exit 0 if cert valid, 1 if expired/missing"],
["--force", "-f", "Force renewal even if cert is still valid"],
["--clear-cache", "", "Delete cached auth token"],
].map(([flag, short, desc]) => (
<div key={flag} className="flex items-baseline gap-3 py-2">
<code className="text-primary text-xs font-mono w-44 shrink-0">{flag}</code>
{short
? <code className="text-xs text-muted-foreground w-6 shrink-0">{short}</code>
: <span className="w-6 shrink-0" />}
<span className="text-xs text-muted-foreground">{desc}</span>
</div>
))}
</div>
</div>
<hr className="border-border/50" />
{/* FAQ */}
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-1">FAQ</p>
<div className="divide-y divide-border/50">
<FaqItem q="Do I need to log in every time?">
<p>No the token is cached at <code>~/.secuird/token_cache.json</code> and reused until it expires.</p>
</FaqItem>
<FaqItem q="My browser opened but nothing happened.">
<p>The CLI listens on port <strong>8250</strong> locally. Make sure nothing else is using that port and complete the login before closing the tab.</p>
</FaqItem>
<FaqItem q="'No verified SSH keys found' error.">
<p>Run <code>secuird --add-key -k ~/.ssh/id_ed25519.pub</code> then check with <code>secuird --list-keys</code>.</p>
</FaqItem>
<FaqItem q="Command not found after install.">
<CodeBlock code={`echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc`} />
</FaqItem>
<FaqItem q="Auto-renew with cron.">
<p>You can use a cron job to automatically renew your certificate before it expires. Run <code>secuird --request-cert</code> interactively at least once first so a cached token exists.</p>
</FaqItem>
</div>
</div>
{/* Footer */}
<p className="text-xs text-muted-foreground">
<a
href="https://github.com/CoryHawkless/gatehouse-api/blob/main/client/gatehouse-cli.py"
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary underline underline-offset-2"
>
View source on GitHub
</a>
{" · "}
<a href="/ssh-keys" className="hover:text-primary underline underline-offset-2">
Manage SSH keys in the UI
</a>
</p>
</div>
</div>
);
}
+1 -1
View File
@@ -172,7 +172,7 @@ export default function LinkedAccountsPage() {
<Alert className="mb-6">
<AlertCircle className="w-4 h-4" />
<AlertDescription>
Linked accounts can only be used to sign in to an existing Gatehouse account.
Linked accounts can only be used to sign in to an existing Secuird account.
They cannot be used to create new accounts.
</AlertDescription>
</Alert>
+20 -20
View File
@@ -48,6 +48,7 @@ import {
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api";
import { formatDate as _formatDate } from "@/lib/date";
// ──────────────────────────────────────────────────────────────────────────────
// Helpers
@@ -55,11 +56,7 @@ import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg
function formatDate(dateStr: string | null): string {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
return _formatDate(dateStr);
}
function CopyButton({ text }: { text: string }) {
@@ -658,7 +655,7 @@ export default function SSHKeysPage() {
CA Public Key
</CardTitle>
<CardDescription>
Add this key to <code>TrustedUserCAKeys</code> on your servers so they accept certificates issued by Gatehouse.
Add this key to <code>TrustedUserCAKeys</code> on your servers so they accept certificates issued by Secuird.
</CardDescription>
</CardHeader>
<CardContent>
@@ -692,9 +689,9 @@ export default function SSHKeysPage() {
</p>
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
{`# On each SSH server:
echo '<ca_public_key>' >> /etc/ssh/trusted_user_ca_keys
echo '<ca_public_key>' >> /etc/ssh/trusted_user_ca
# In /etc/ssh/sshd_config:
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys`}
TrustedUserCAKeys /etc/ssh/trusted_user_ca`}
</pre>
</div>
</div>
@@ -800,7 +797,10 @@ TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys`}
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{verifyError}</div>
)}
<div className="space-y-2">
<Label>Step 1 Save this challenge text to a file</Label>
<Label>Step 1 Save the challenge text to a file</Label>
<p className="text-xs text-muted-foreground">
Copy the <strong>entire</strong> text below (not just the hex) and save it to a file.
</p>
<div className="relative">
<Textarea
readOnly
@@ -813,18 +813,18 @@ TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys`}
</div>
</div>
<div className="rounded-lg bg-muted p-3 space-y-1">
<p className="text-xs font-semibold flex items-center gap-1">
<div className="space-y-2">
<Label className="flex items-center gap-1">
<Terminal className="w-3 h-3" /> Step 2 Sign with ssh-keygen
</p>
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
{`echo '<challenge_text>' > /tmp/challenge.txt
ssh-keygen -Y sign \\
-f ~/.ssh/id_ed25519 \\
-n gatehouse \\
/tmp/challenge.txt
cat /tmp/challenge.txt.sig | base64 -w0`}
</pre>
</Label>
<div className="relative">
<Textarea
readOnly
value={`echo '${challengeText}' > /tmp/challenge.txt\nssh-keygen -Y sign \\\n -f ~/.ssh/id_ed25519 \\\n -n file \\\n /tmp/challenge.txt\ncat /tmp/challenge.txt.sig | base64 -w0`}
className="font-mono text-xs pr-10"
rows={6}
/>
</div>
</div>
<div className="space-y-2">
+2 -2
View File
@@ -246,13 +246,13 @@ export default function SecurityPage() {
const formatLastUsed = (date: string | null) => {
if (!date) return "Never";
const d = new Date(date);
const d = new Date(date.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(date) ? date : date + "Z");
const now = new Date();
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays} days ago`;
return d.toLocaleDateString();
return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(d);
};
return (
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/NavLink.tsx","./src/components/auth/BannerAlert.tsx","./src/components/auth/ComplianceBanner.tsx","./src/components/auth/PasswordStrengthMeter.tsx","./src/components/branding/GatehouseLogo.tsx","./src/components/dev/ApiDevTools.tsx","./src/components/layouts/AuthenticatedLayout.tsx","./src/components/layouts/MfaEnforcementLayout.tsx","./src/components/layouts/ProtectedLayout.tsx","./src/components/layouts/PublicLayout.tsx","./src/components/navigation/AppSidebar.tsx","./src/components/navigation/TopBar.tsx","./src/components/security/AddPasskeyWizard.tsx","./src/components/security/TotpEnrollmentWizard.tsx","./src/components/security/TotpRemoveDialog.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/aspect-ratio.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input-otp.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/menubar.tsx","./src/components/ui/navigation-menu.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/resizable.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/components/ui/use-toast.ts","./src/contexts/AuthContext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/useOrganizations.ts","./src/lib/api.ts","./src/lib/encoding.ts","./src/lib/oauth.ts","./src/lib/utils.ts","./src/lib/webauthn.ts","./src/pages/Index.tsx","./src/pages/NotFound.tsx","./src/pages/auth/ForgotPasswordPage.tsx","./src/pages/auth/InviteAcceptPage.tsx","./src/pages/auth/LoginPage.tsx","./src/pages/auth/OAuthCallbackPage.tsx","./src/pages/auth/OIDCConsentPage.tsx","./src/pages/auth/OIDCErrorPage.tsx","./src/pages/auth/RegisterPage.tsx","./src/pages/auth/ResetPasswordPage.tsx","./src/pages/auth/VerifyEmailPage.tsx","./src/pages/org/CompliancePage.tsx","./src/pages/org/MembersPage.tsx","./src/pages/org/OIDCClientsPage.tsx","./src/pages/org/OrgAuditPage.tsx","./src/pages/org/OrgOverviewPage.tsx","./src/pages/org/PoliciesPage.tsx","./src/pages/user/ActivityPage.tsx","./src/pages/user/LinkedAccountsPage.tsx","./src/pages/user/ProfilePage.tsx","./src/pages/user/SecurityPage.tsx"],"errors":true,"version":"5.8.3"}
{"root":["./src/App.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/NavLink.tsx","./src/components/auth/BannerAlert.tsx","./src/components/auth/ComplianceBanner.tsx","./src/components/auth/PasswordStrengthMeter.tsx","./src/components/branding/SecuirdLogo.tsx","./src/components/dev/ApiDevTools.tsx","./src/components/layouts/AuthenticatedLayout.tsx","./src/components/layouts/MfaEnforcementLayout.tsx","./src/components/layouts/ProtectedLayout.tsx","./src/components/layouts/PublicLayout.tsx","./src/components/navigation/AppSidebar.tsx","./src/components/navigation/TopBar.tsx","./src/components/security/AddPasskeyWizard.tsx","./src/components/security/TotpEnrollmentWizard.tsx","./src/components/security/TotpRemoveDialog.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/aspect-ratio.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input-otp.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/menubar.tsx","./src/components/ui/navigation-menu.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/resizable.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/components/ui/use-toast.ts","./src/contexts/AuthContext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/useOrganizations.ts","./src/lib/api.ts","./src/lib/encoding.ts","./src/lib/oauth.ts","./src/lib/utils.ts","./src/lib/webauthn.ts","./src/pages/Index.tsx","./src/pages/NotFound.tsx","./src/pages/auth/ForgotPasswordPage.tsx","./src/pages/auth/InviteAcceptPage.tsx","./src/pages/auth/LoginPage.tsx","./src/pages/auth/OAuthCallbackPage.tsx","./src/pages/auth/OIDCConsentPage.tsx","./src/pages/auth/OIDCErrorPage.tsx","./src/pages/auth/RegisterPage.tsx","./src/pages/auth/ResetPasswordPage.tsx","./src/pages/auth/VerifyEmailPage.tsx","./src/pages/org/CompliancePage.tsx","./src/pages/org/MembersPage.tsx","./src/pages/org/OIDCClientsPage.tsx","./src/pages/org/OrgAuditPage.tsx","./src/pages/org/OrgOverviewPage.tsx","./src/pages/org/PoliciesPage.tsx","./src/pages/user/ActivityPage.tsx","./src/pages/user/LinkedAccountsPage.tsx","./src/pages/user/ProfilePage.tsx","./src/pages/user/SecurityPage.tsx"],"errors":true,"version":"5.8.3"}
+20 -14
View File
@@ -1,19 +1,25 @@
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { componentTagger } from "lovable-tagger";
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
server: {
host: "::",
port: 8080,
allowedHosts: process.env.VITE_ALLOWED_HOSTS?.split(",") || ["ui.webauthn.local","gatehouse-ui.hawkvelt.tech"],
},
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
server: {
host: "::",
port: 8080,
allowedHosts: env.VITE_ALLOWED_HOSTS?.split(",") || [
"ui.webauthn.local",
"gatehouse-ui.hawkvelt.tech",
],
},
},
}));
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
};
});