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 LinkedAccountsPage from "@/pages/user/LinkedAccountsPage";
import ActivityPage from "@/pages/user/ActivityPage"; import ActivityPage from "@/pages/user/ActivityPage";
import SSHKeysPage from "@/pages/user/SSHKeysPage"; import SSHKeysPage from "@/pages/user/SSHKeysPage";
import CLIGuidePage from "@/pages/user/CLIGuidePage";
// Organization pages // Organization pages
import OrgOverviewPage from "@/pages/org/OrgOverviewPage"; import OrgOverviewPage from "@/pages/org/OrgOverviewPage";
@@ -47,10 +48,12 @@ import OIDCClientsPage from "@/pages/org/OIDCClientsPage";
import CAsPage from "@/pages/org/CAsPage"; import CAsPage from "@/pages/org/CAsPage";
import DepartmentsPage from "@/pages/org/DepartmentsPage"; import DepartmentsPage from "@/pages/org/DepartmentsPage";
import PrincipalsPage from "@/pages/org/PrincipalsPage"; import PrincipalsPage from "@/pages/org/PrincipalsPage";
import ApiKeysPage from "@/pages/org/ApiKeysPage";
import MyMembershipsPage from "@/pages/org/MyMembershipsPage"; import MyMembershipsPage from "@/pages/org/MyMembershipsPage";
import NetworksPage from "@/pages/org/NetworksPage"; import NetworksPage from "@/pages/org/NetworksPage";
import DevicesPage from "@/pages/org/DevicesPage"; import DevicesPage from "@/pages/org/DevicesPage";
import AccessPage from "@/pages/org/AccessPage"; import AccessPage from "@/pages/org/AccessPage";
import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage";
import SystemAuditPage from "@/pages/admin/SystemAuditPage"; import SystemAuditPage from "@/pages/admin/SystemAuditPage";
import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage"; import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage";
import OrgSetupPage from "@/pages/auth/OrgSetupPage"; import OrgSetupPage from "@/pages/auth/OrgSetupPage";
@@ -170,10 +173,11 @@ function AppRoutes() {
<Route element={<ProtectedLayout />}> <Route element={<ProtectedLayout />}>
{/* User routes */} {/* User routes */}
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/security" element={<SecurityPage />} /> <Route path="/account/security" element={<UserSecurityPage />} />
<Route path="/linked-accounts" element={<LinkedAccountsPage />} /> <Route path="/linked-accounts" element={<LinkedAccountsPage />} />
<Route path="/activity" element={<ActivityPage />} /> <Route path="/activity" element={<ActivityPage />} />
<Route path="/ssh-keys" element={<SSHKeysPage />} /> <Route path="/ssh-keys" element={<SSHKeysPage />} />
<Route path="/cli-guide" element={<CLIGuidePage />} />
{/* Organization routes — org members: overview + own memberships only */} {/* Organization routes — org members: overview + own memberships only */}
<Route path="/org" element={<RequireOrgMember><OrgOverviewPage /></RequireOrgMember>} /> <Route path="/org" element={<RequireOrgMember><OrgOverviewPage /></RequireOrgMember>} />
@@ -184,6 +188,7 @@ function AppRoutes() {
<Route path="/org/members" element={<RequireAdmin><MembersPage /></RequireAdmin>} /> <Route path="/org/members" element={<RequireAdmin><MembersPage /></RequireAdmin>} />
<Route path="/org/departments" element={<RequireAdmin><DepartmentsPage /></RequireAdmin>} /> <Route path="/org/departments" element={<RequireAdmin><DepartmentsPage /></RequireAdmin>} />
<Route path="/org/principals" element={<RequireAdmin><PrincipalsPage /></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" element={<RequireAdmin><PoliciesPage /></RequireAdmin>} />
<Route path="/org/policies/compliance" element={<RequireAdmin><CompliancePage /></RequireAdmin>} /> <Route path="/org/policies/compliance" element={<RequireAdmin><CompliancePage /></RequireAdmin>} />
<Route path="/org/audit" element={<RequireAdmin><OrgAuditPage /></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/cas" element={<RequireAdmin><CAsPage /></RequireAdmin>} />
<Route path="/org/zerotier/networks" element={<RequireAdmin><NetworksPage /></RequireAdmin>} /> <Route path="/org/zerotier/networks" element={<RequireAdmin><NetworksPage /></RequireAdmin>} />
<Route path="/org/zerotier/access" element={<RequireAdmin><AccessPage /></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 */} {/* Admin routes — org admin/owner only */}
<Route path="/admin/audit" element={<RequireAdmin><SystemAuditPage /></RequireAdmin>} /> <Route path="/admin/audit" element={<RequireAdmin><SystemAuditPage /></RequireAdmin>} />
@@ -1,21 +1,21 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface GatehouseLogoProps { interface SecuirdLogoProps {
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
variant?: "default" | "light"; variant?: "default" | "light";
className?: string; className?: string;
} }
/** /**
* Gatehouse Logo - Abstract gate/doorway mark * Secuird Logo - Abstract gate/doorway mark
* Represents controlled entry and policy enforcement * Represents controlled entry and policy enforcement
* Two vertical pillars forming a gateway with negative space * Two vertical pillars forming a gateway with negative space
*/ */
export function GatehouseLogo({ export function SecuirdLogo({
size = "md", size = "md",
variant = "default", variant = "default",
className className
}: GatehouseLogoProps) { }: SecuirdLogoProps) {
const sizeClasses = { const sizeClasses = {
sm: "w-8 h-8", sm: "w-8 h-8",
md: "w-9 h-9", md: "w-9 h-9",
+6 -6
View File
@@ -65,9 +65,9 @@ const isDev = import.meta.env.DEV;
const originalFetch = window.fetch; const originalFetch = window.fetch;
// Avoid patching multiple times during HMR // Avoid patching multiple times during HMR
const globalAny = window as unknown as { __gatehouseFetchPatched?: boolean }; const globalAny = window as unknown as { __secuirdFetchPatched?: boolean };
if (isDev && !globalAny.__gatehouseFetchPatched) { if (isDev && !globalAny.__secuirdFetchPatched) {
globalAny.__gatehouseFetchPatched = true; globalAny.__secuirdFetchPatched = true;
try { try {
window.fetch = async function (input, init) { window.fetch = async function (input, init) {
@@ -165,9 +165,9 @@ if (isDev && !globalAny.__gatehouseFetchPatched) {
}; };
} catch (patchError) { } catch (patchError) {
// Log any errors during fetch patching with full stack trace // 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) { 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 */} {/* 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 justify-between px-4 py-2 border-b border-slate-700 bg-slate-800">
<div className="flex items-center gap-3"> <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"> <Badge variant="outline" className="text-xs border-slate-600">
{logs.length} requests {logs.length} requests
</Badge> </Badge>
+1 -1
View File
@@ -1,5 +1,5 @@
import { Link, Outlet, useLocation } from "react-router-dom"; 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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -7,6 +7,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { AddPasskeyWizard } from '@/components/security/AddPasskeyWizard'; import { AddPasskeyWizard } from '@/components/security/AddPasskeyWizard';
import { TotpEnrollmentWizard } from '@/components/security/TotpEnrollmentWizard'; import { TotpEnrollmentWizard } from '@/components/security/TotpEnrollmentWizard';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatDate } from '@/lib/date';
export default function MfaEnforcementLayout() { export default function MfaEnforcementLayout() {
const navigate = useNavigate(); 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"> <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"> <div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-primary" /> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
@@ -124,7 +125,7 @@ export default function MfaEnforcementLayout() {
{mfaCompliance?.deadline_at && ( {mfaCompliance?.deadline_at && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-center"> <div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-center">
<p className="text-sm font-medium text-destructive"> <p className="text-sm font-medium text-destructive">
Deadline: {new Date(mfaCompliance.deadline_at).toLocaleDateString()} Deadline: {formatDate(mfaCompliance.deadline_at)}
</p> </p>
</div> </div>
)} )}
+4 -4
View File
@@ -1,5 +1,5 @@
import { Outlet, Link } from "react-router-dom"; import { Outlet, Link } from "react-router-dom";
import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; import { SecuirdLogo } from "@/components/branding/SecuirdLogo";
export default function PublicLayout() { export default function PublicLayout() {
return ( return (
@@ -11,8 +11,8 @@ export default function PublicLayout() {
<header className="relative z-10 w-full py-6 px-4"> <header className="relative z-10 w-full py-6 px-4">
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<Link to="/" className="flex items-center gap-2.5 justify-center"> <Link to="/" className="flex items-center gap-2.5 justify-center">
<GatehouseLogo size="md" /> <SecuirdLogo size="md" />
<span className="text-xl font-semibold text-foreground tracking-tight">Gatehouse</span> <span className="text-xl font-semibold text-foreground tracking-tight">Secuird</span>
</Link> </Link>
</div> </div>
</header> </header>
@@ -28,7 +28,7 @@ export default function PublicLayout() {
<footer className="relative z-10 py-6 px-4"> <footer className="relative z-10 py-6 px-4">
<div className="max-w-md mx-auto text-center"> <div className="max-w-md mx-auto text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
© {new Date().getFullYear()} Gatehouse. Identity & Access. © {new Date().getFullYear()} Secuird. Identity & Access.
</p> </p>
</div> </div>
</footer> </footer>
+13 -8
View File
@@ -17,8 +17,9 @@ import {
Network, Network,
Monitor, Monitor,
ShieldAlert, ShieldAlert,
BookOpen,
} from "lucide-react"; } from "lucide-react";
import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; import { SecuirdLogo } from "@/components/branding/SecuirdLogo";
import { NavLink } from "@/components/NavLink"; import { NavLink } from "@/components/NavLink";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { import {
@@ -38,10 +39,11 @@ import { cn } from "@/lib/utils";
const userNavItems = [ const userNavItems = [
{ title: "Profile", url: "/profile", icon: User }, { 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: "SSH Keys", url: "/ssh-keys", icon: Terminal },
{ title: "Linked Accounts", url: "/linked-accounts", icon: Link2 }, { title: "Linked Accounts", url: "/linked-accounts", icon: Link2 },
{ title: "Activity", url: "/activity", icon: Activity }, { title: "Activity", url: "/activity", icon: Activity },
{ title: "CLI Guide", url: "/cli-guide", icon: BookOpen },
]; ];
// Visible to ALL org members // Visible to ALL org members
@@ -57,23 +59,26 @@ const orgAdminNavItems = [
{ title: "Members", url: "/org/members", icon: Users }, { title: "Members", url: "/org/members", icon: Users },
{ title: "Departments", url: "/org/departments", icon: Layers }, { title: "Departments", url: "/org/departments", icon: Layers },
{ title: "Principals", url: "/org/principals", icon: GitBranch }, { title: "Principals", url: "/org/principals", icon: GitBranch },
{ title: "API Keys", url: "/org/api-keys", icon: Key },
{ title: "Policies", url: "/org/policies", icon: Settings }, { title: "Policies", url: "/org/policies", icon: Settings },
{ title: "ZeroTier Networks", url: "/org/zerotier/networks", icon: Network }, { title: "ZeroTier Networks", url: "/org/zerotier/networks", icon: Network },
{ title: "ZeroTier Access", url: "/org/zerotier/access", icon: ShieldAlert }, { title: "ZeroTier Access", url: "/org/zerotier/access", icon: ShieldAlert },
{ title: "ZeroTier Config", url: "/org/zerotier/config", icon: Settings },
]; ];
const adminNavItems = [ const adminNavItems = [
{ title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck }, { title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck },
{ title: "OIDC Clients", url: "/org/clients", icon: Key }, { title: "OIDC Clients", url: "/org/clients", icon: Key },
{ title: "Org Audit Log", url: "/org/audit", icon: FileText }, { 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() { export function AppSidebar() {
const { state } = useSidebar(); const { state } = useSidebar();
const collapsed = state === "collapsed"; const collapsed = state === "collapsed";
const location = useLocation(); const location = useLocation();
const { isOrgAdmin, isOrgMember } = useAuth(); const { isOrgAdmin, isOrgMember, canViewSystemLogs } = useAuth();
const isActive = (path: string) => location.pathname === path; const isActive = (path: string) => location.pathname === path;
const isOrgActive = orgAdminNavItems.some((item) => isActive(item.url)) || adminNavItems.some((item) => isActive(item.url)); const isOrgActive = orgAdminNavItems.some((item) => isActive(item.url)) || adminNavItems.some((item) => isActive(item.url));
@@ -90,10 +95,10 @@ export function AppSidebar() {
{/* Logo */} {/* Logo */}
<SidebarHeader className="p-4 border-b border-sidebar-border"> <SidebarHeader className="p-4 border-b border-sidebar-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<GatehouseLogo size="sm" variant="light" /> <SecuirdLogo size="sm" variant="light" />
{!collapsed && ( {!collapsed && (
<span className="text-lg font-semibold text-sidebar-foreground tracking-tight"> <span className="text-lg font-semibold text-sidebar-foreground tracking-tight">
Gatehouse Secuird
</span> </span>
)} )}
</div> </div>
@@ -180,7 +185,7 @@ export function AppSidebar() {
)} )}
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{adminNavItems.map((item) => ( {[...adminNavItems, ...(canViewSystemLogs ? [systemLogNavItem] : [])].map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<NavLink <NavLink
@@ -210,7 +215,7 @@ export function AppSidebar() {
<SidebarFooter className="p-4 border-t border-sidebar-border"> <SidebarFooter className="p-4 border-t border-sidebar-border">
{!collapsed && ( {!collapsed && (
<div className="text-xs text-sidebar-muted"> <div className="text-xs text-sidebar-muted">
v1.0.0 Self-hosted {import.meta.env.VITE_APP_VERSION ?? 'Secuird'}
</div> </div>
)} )}
</SidebarFooter> </SidebarFooter>
@@ -275,7 +275,7 @@ export function TotpEnrollmentWizard({
</div> </div>
<p className="text-xs text-muted-foreground"> <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> </p>
<div className="flex justify-end gap-2 pt-2"> <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 // Environment-specific settings for the application
export const config = { export const config = {
@@ -9,7 +9,7 @@ export const config = {
// App metadata // App metadata
app: { app: {
name: "Gatehouse", name: "Secuird",
description: "Identity & Access Platform", description: "Identity & Access Platform",
}, },
+11 -1
View File
@@ -17,6 +17,8 @@ interface AuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
isOrgAdmin: boolean; isOrgAdmin: boolean;
isOrgMember: boolean; isOrgMember: boolean;
/** True when the current user is allowed to view the system-wide audit log. */
canViewSystemLogs: boolean;
mfaCompliance: MfaComplianceSummary | null; mfaCompliance: MfaComplianceSummary | null;
requiresMfaEnrollment: boolean; requiresMfaEnrollment: boolean;
login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise<LoginResult>; login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise<LoginResult>;
@@ -32,7 +34,7 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType | null>(null); const AuthContext = createContext<AuthContextType | null>(null);
// LocalStorage key for MFA compliance persistence // 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 // Helper to persist MFA compliance to localStorage
function persistMfaCompliance(compliance: MfaComplianceSummary | null): void { function persistMfaCompliance(compliance: MfaComplianceSummary | null): void {
@@ -265,7 +267,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await checkOrgAdmin(); await checkOrgAdmin();
if (!skipNavigate) { if (!skipNavigate) {
const orgsData = await api.users.organizations();
const hasOrg = orgsData.organizations && orgsData.organizations.length > 0;
if (hasOrg) {
navigate('/profile'); navigate('/profile');
} else {
navigate('/org-setup');
}
} }
}, [navigate, checkOrgAdmin]); }, [navigate, checkOrgAdmin]);
@@ -291,6 +300,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isAuthenticated: !!user, isAuthenticated: !!user,
isOrgAdmin, isOrgAdmin,
isOrgMember, isOrgMember,
canViewSystemLogs: user?.can_view_system_logs ?? false,
mfaCompliance, mfaCompliance,
requiresMfaEnrollment, requiresMfaEnrollment,
login, login,
+45 -44
View File
@@ -4,79 +4,80 @@
@tailwind components; @tailwind components;
@tailwind utilities; @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 Authoritative, infrastructure-grade aesthetic with slate/charcoal/muted blue palette
Colors are HSL for theming flexibility Colors are HSL for theming flexibility
*/ */
@layer base { @layer base {
:root { :root {
/* Core palette - Deep slate with teal accent */ /* Core palette - Light blue-gray with teal accent */
--background: 210 20% 98%; --background: 216 22% 94%; /* cool blue-gray — cards lift clearly off this */
--foreground: 222 47% 11%; --foreground: 222 47% 9%; /* near-black navy */
--card: 0 0% 100%; --card: 0 0% 100%; /* pure white — 6% lightness gap over bg */
--card-foreground: 222 47% 11%; --card-foreground: 222 47% 9%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222 47% 11%; --popover-foreground: 222 47% 9%;
/* Primary - Deep navy for trust */ /* Primary — teal, fully saturated, dark enough to read on white */
--primary: 222 47% 20%; --primary: 173 65% 36%;
--primary-foreground: 210 40% 98%; --primary-foreground: 0 0% 100%;
/* Secondary - Soft slate */ /* Secondary — cool blue-gray, clearly darker than bg */
--secondary: 215 20% 95%; --secondary: 216 20% 91%;
--secondary-foreground: 222 47% 20%; --secondary-foreground: 222 47% 18%;
/* Muted - Subtle backgrounds */ /* Muted — noticeably darker than secondary, used for section bg */
--muted: 215 20% 96%; --muted: 216 18% 88%;
--muted-foreground: 215 16% 47%; --muted-foreground: 222 18% 42%;
/* Accent - Teal for actions and highlights */ /* Accent — same teal as primary */
--accent: 173 58% 39%; --accent: 173 65% 36%;
--accent-foreground: 0 0% 100%; --accent-foreground: 0 0% 100%;
/* Semantic colors */ /* Semantic */
--destructive: 0 72% 51%; --destructive: 0 72% 48%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--success: 152 69% 31%; --success: 152 60% 30%;
--success-foreground: 0 0% 100%; --success-foreground: 0 0% 100%;
--warning: 38 92% 50%; --warning: 38 90% 48%;
--warning-foreground: 0 0% 100%; --warning-foreground: 0 0% 100%;
--info: 199 89% 48%; --info: 199 80% 44%;
--info-foreground: 0 0% 100%; --info-foreground: 0 0% 100%;
/* UI elements */ /* UI chrome */
--border: 214 32% 91%; --border: 216 18% 84%; /* clearly visible on white card */
--input: 214 32% 91%; --input: 216 18% 92%;
--ring: 173 58% 39%; --ring: 173 65% 36%;
--radius: 0.5rem; --radius: 0.5rem;
/* Sidebar - Darker for visual hierarchy */ /* Sidebar */
--sidebar-background: 222 47% 11%; --sidebar-background: 222 30% 95%;
--sidebar-foreground: 215 20% 85%; --sidebar-foreground: 222 47% 18%;
--sidebar-primary: 173 58% 45%; --sidebar-primary: 173 65% 36%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 222 40% 18%; --sidebar-accent: 216 20% 88%;
--sidebar-accent-foreground: 210 40% 98%; --sidebar-accent-foreground: 222 47% 9%;
--sidebar-border: 222 40% 20%; --sidebar-border: 216 18% 84%;
--sidebar-ring: 173 58% 45%; --sidebar-ring: 173 65% 36%;
--sidebar-muted: 215 16% 55%; --sidebar-muted: 222 20% 48%;
/* Custom gradients and effects */ /* Gradients */
--gradient-brand: linear-gradient(135deg, hsl(222 47% 20%), hsl(222 47% 11%)); --gradient-brand: linear-gradient(135deg, hsl(173 65% 36%), hsl(173 65% 28%));
--gradient-accent: linear-gradient(135deg, hsl(173 58% 39%), hsl(173 58% 32%)); --gradient-accent: linear-gradient(135deg, hsl(173 65% 36%), hsl(173 65% 28%));
--gradient-subtle: linear-gradient(135deg, hsl(210 20% 98%), hsl(215 20% 96%)); --gradient-subtle: linear-gradient(135deg, hsl(216 28% 97%), hsl(216 18% 93%));
--shadow-sm: 0 1px 2px 0 hsl(222 47% 11% / 0.05); /* Shadows — stronger alpha so cards lift off the bg */
--shadow-md: 0 4px 6px -1px hsl(222 47% 11% / 0.1), 0 2px 4px -2px hsl(222 47% 11% / 0.1); --shadow-sm: 0 1px 2px 0 hsl(222 47% 9% / 0.10);
--shadow-lg: 0 10px 15px -3px hsl(222 47% 11% / 0.1), 0 4px 6px -4px hsl(222 47% 11% / 0.1); --shadow-md: 0 4px 6px -1px hsl(222 47% 9% / 0.14), 0 2px 4px -2px hsl(222 47% 9% / 0.10);
--shadow-card: 0 1px 3px 0 hsl(222 47% 11% / 0.06), 0 1px 2px -1px hsl(222 47% 11% / 0.06); --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 { .dark {
+159 -16
View File
@@ -1,4 +1,4 @@
// API Client for Gatehouse Backend // API Client for Secuird Backend
// Uses Bearer token authentication // Uses Bearer token authentication
import { config } from '@/config'; import { config } from '@/config';
@@ -33,6 +33,10 @@ export interface User {
has_password?: boolean; has_password?: boolean;
totp_enabled?: boolean; totp_enabled?: boolean;
linked_providers?: string[]; 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 { export interface Organization {
@@ -244,6 +248,38 @@ export interface LinkAccountResponse {
linked_account: LinkedAccount; 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 { class ApiError extends Error {
code: number; code: number;
type: string; type: string;
@@ -259,8 +295,8 @@ class ApiError extends Error {
} }
// Token storage keys // Token storage keys
const TOKEN_KEY = 'gatehouse_token'; const TOKEN_KEY = 'secuird_token';
const TOKEN_EXPIRY_KEY = 'gatehouse_token_expiry'; const TOKEN_EXPIRY_KEY = 'secuird_token_expiry';
// Token management // Token management
export const tokenManager = { export const tokenManager = {
@@ -938,7 +974,7 @@ export const api = {
// Get organization audit logs // Get organization audit logs
getAuditLogs: (orgId: string, params?: Record<string, string>, requestConfig?: RequestConfig) => 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() : ''}`, `/organizations/${orgId}/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`,
{}, {},
true, true,
@@ -950,14 +986,14 @@ export const api = {
request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/departments`, {}, true, requestConfig), request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/departments`, {}, true, requestConfig),
// Create department // 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`, { request<{ department: Department }>(`/organizations/${orgId}/departments`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, description }), body: JSON.stringify({ name, description, can_sudo: canSudo }),
}, true, requestConfig), }, true, requestConfig),
// Update department // 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}`, { request<{ department: Department }>(`/organizations/${orgId}/departments/${deptId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -1081,6 +1117,13 @@ export const api = {
method: 'DELETE', method: 'DELETE',
}, true, requestConfig), }, 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 // Send MFA reminder to a member
sendMfaReminder: (orgId: string, userId: string, requestConfig?: RequestConfig) => sendMfaReminder: (orgId: string, userId: string, requestConfig?: RequestConfig) =>
request<{ message: string }>(`/organizations/${orgId}/members/${userId}/send-mfa-reminder`, { 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}`, { request<{ ca_id: string }>(`/organizations/${orgId}/cas/${caId}`, {
method: 'DELETE', method: 'DELETE',
}, true, requestConfig), }, 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: { invites: {
@@ -1301,6 +1377,14 @@ export const api = {
{ method: "DELETE" }, true, requestConfig, { 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) => getNetworkMembers: (orgId: string, networkId: string, requestConfig?: RequestConfig) =>
request<{ memberships: DeviceNetworkMembership[]; count: number }>( request<{ memberships: DeviceNetworkMembership[]; count: number }>(
`/organizations/${orgId}/networks/${networkId}/members`, `/organizations/${orgId}/networks/${networkId}/members`,
@@ -1373,6 +1457,12 @@ export const api = {
`/organizations/${orgId}/approvals`, {}, true, requestConfig, `/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) => listPendingApprovals: (orgId: string, networkId?: string, requestConfig?: RequestConfig) =>
request<{ approvals: UserNetworkApproval[]; count: number }>( request<{ approvals: UserNetworkApproval[]; count: number }>(
`/organizations/${orgId}/approvals/pending${networkId ? `?network_id=${networkId}` : ""}`, `/organizations/${orgId}/approvals/pending${networkId ? `?network_id=${networkId}` : ""}`,
@@ -1489,25 +1579,25 @@ export const api = {
true, requestConfig, true, requestConfig,
), ),
// ── ZeroTier Controller (admin) ────────────────────────────────────────── // ── ZeroTier Controller (org-scoped admin) ─────────────────────────────────
getZtStatus: (requestConfig?: RequestConfig) => getZtStatus: (orgId: string, requestConfig?: RequestConfig) =>
request<{ status: Record<string, unknown> }>( 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 }>( 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 }>( 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 }>( 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) => triggerReconciliation: (requestConfig?: RequestConfig) =>
@@ -1515,6 +1605,26 @@ export const api = {
"/admin/zerotier/reconcile", "/admin/zerotier/reconcile",
{ method: "POST" }, true, requestConfig, { 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; organization_id: string;
name: string; name: string;
description: string | null; description: string | null;
can_sudo: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deleted_at: string | null; deleted_at: string | null;
@@ -1824,6 +1935,21 @@ export interface PortalNetwork {
active_membership_count?: number; 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 { export interface Device {
id: string; id: string;
user_id: string; user_id: string;
@@ -1969,3 +2095,20 @@ export interface ZeroTierNetwork {
routes: 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) { function formatDate(d: string | null) {
if (!d) return "—"; 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) { function capitalize(s: string) {
+54 -32
View File
@@ -6,23 +6,21 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
LogIn, LogIn,
LogOut,
Key, Key,
UserPlus, UserPlus,
Shield, Shield,
Settings, Settings,
AlertTriangle, AlertTriangle,
Fingerprint,
Smartphone,
Terminal, Terminal,
Loader2, Loader2,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Globe, Globe,
Lock,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { Badge } from "@/components/ui/badge";
import { import {
Select, Select,
@@ -31,25 +29,28 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { api, AuditLogEntry } from "@/lib/api"; import { api, AuditLogEntry, ApiError } from "@/lib/api";
import { formatDateTime } from "@/lib/date";
// ─── category helpers ──────────────────────────────────────────────────────── // ─── 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 getCategory = (action: string): Category => {
const a = action.toLowerCase(); 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"; return "auth";
if (a.startsWith("ssh")) if (a.startsWith("ssh"))
return "ssh"; return "ssh";
if (a.startsWith("admin."))
return "admin";
if (a.startsWith("org") || a.includes("member") || a.includes("department") || a.includes("invite")) if (a.startsWith("org") || a.includes("member") || a.includes("department") || a.includes("invite"))
return "org"; return "org";
if (a.startsWith("user")) if (a.startsWith("user"))
return "user"; return "user";
if (a.includes("mfa") || a.includes("totp") || a.includes("webauthn") || a.includes("passkey") || a.includes("password")) if (a.includes("mfa") || a.includes("totp") || a.includes("webauthn") || a.includes("passkey") || a.includes("password"))
return "security"; 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 "token";
return "other"; 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" }, 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" }, 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" }, 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" }, other: { label: "Other", color: "bg-muted text-muted-foreground" },
}; };
@@ -73,6 +75,7 @@ const getCategoryIcon = (category: Category) => {
case "user": return <UserPlus className={cls} />; case "user": return <UserPlus className={cls} />;
case "security": return <Shield className={cls} />; case "security": return <Shield className={cls} />;
case "token": return <Key className={cls} />; case "token": return <Key className={cls} />;
case "admin": return <Lock className={cls} />;
default: return <Globe className={cls} />; default: return <Globe className={cls} />;
} }
}; };
@@ -87,25 +90,41 @@ const getActionLabel = (action: string) =>
const ACTION_FILTER_OPTIONS = [ const ACTION_FILTER_OPTIONS = [
{ value: "all", label: "All actions" }, { value: "all", label: "All actions" },
{ value: "SESSION_CREATE", label: "Login" }, { value: "session.create", label: "Login" },
{ value: "SESSION_REVOKE", label: "Logout" }, { value: "session.revoke", label: "Logout" },
{ value: "EXTERNAL_AUTH_LOGIN", label: "OAuth Login" }, { value: "external_auth.login", label: "OAuth Login" },
{ value: "EXTERNAL_AUTH_LOGIN_FAILED", label: "OAuth Failed" }, { value: "external_auth.login.failed", label: "OAuth Failed" },
{ value: "USER_REGISTER", label: "Register" }, { value: "external_auth.link.completed", label: "OAuth Account Linked" },
{ value: "SSH_KEY_ADDED", label: "SSH Key Added" }, { value: "external_auth.unlink", label: "OAuth Account Unlinked" },
{ value: "SSH_KEY_VERIFIED", label: "SSH Key Verified" }, { value: "user.register", label: "Register" },
{ value: "SSH_CERT_ISSUED", label: "SSH Cert Issued" }, { value: "ssh.key.added", label: "SSH Key Added" },
{ value: "SSH_CERT_REVOKED", label: "SSH Cert Revoked" }, { value: "ssh.key.verified", label: "SSH Key Verified" },
{ value: "SSH_CERT_FAILED", label: "SSH Cert Failed" }, { value: "ssh.key.deleted", label: "SSH Key Deleted" },
{ value: "ORG_CREATE", label: "Org Created" }, { value: "ssh.cert.issued", label: "SSH Cert Issued" },
{ value: "ORG_MEMBER_ADD", label: "Member Added" }, { value: "ssh.cert.revoked", label: "SSH Cert Revoked" },
{ value: "ORG_MEMBER_ROLE_CHANGE", label: "Role Changed" }, { 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() { export default function SystemAuditPage() {
const [logs, setLogs] = useState<AuditLogEntry[]>([]); const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState(false);
const [isAdminView, setIsAdminView] = useState(false); const [isAdminView, setIsAdminView] = useState(false);
// filters // filters
@@ -129,6 +148,7 @@ export default function SystemAuditPage() {
const fetchLogs = useCallback(async () => { const fetchLogs = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setAccessDenied(false);
try { try {
const params: Record<string, string> = { const params: Record<string, string> = {
page: String(page), page: String(page),
@@ -144,8 +164,12 @@ export default function SystemAuditPage() {
setTotalPages(resp.pages ?? 1); setTotalPages(resp.pages ?? 1);
setIsAdminView(resp.is_admin_view ?? false); setIsAdminView(resp.is_admin_view ?? false);
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.code === 403) {
setAccessDenied(true);
} else {
console.error("Failed to fetch system audit logs:", err); console.error("Failed to fetch system audit logs:", err);
setError("Failed to load audit logs. Please try again."); setError("Failed to load audit logs. Please try again.");
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -160,17 +184,7 @@ export default function SystemAuditPage() {
setPage(1); setPage(1);
}, [actionFilter, successFilter, debouncedSearch]); }, [actionFilter, successFilter, debouncedSearch]);
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => formatDateTime(dateString);
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 formatUserAgent = (ua: string | null) => { const formatUserAgent = (ua: string | null) => {
if (!ua) return null; if (!ua) return null;
@@ -244,6 +258,14 @@ export default function SystemAuditPage() {
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading</span> <span className="ml-2 text-muted-foreground">Loading</span>
</div> </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 ? ( ) : error ? (
<div className="py-12 text-center text-destructive"> <div className="py-12 text-center text-destructive">
<AlertTriangle className="w-8 h-8 mx-auto mb-2" /> <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 { CheckCircle, XCircle, Loader2, Mail } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; 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"; import { api, ApiError } from "@/lib/api";
type Status = "loading" | "success" | "error" | "missing"; type Status = "loading" | "success" | "error" | "missing";
@@ -42,7 +42,7 @@ export default function ActivatePage() {
<div className="w-full max-w-md space-y-6"> <div className="w-full max-w-md space-y-6">
{/* Logo */} {/* Logo */}
<div className="flex justify-center"> <div className="flex justify-center">
<GatehouseLogo size="md" /> <SecuirdLogo size="md" />
</div> </div>
<Card> <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); const result = await api.invites.accept(token, name || undefined, inviteData?.user_exists ? undefined : password);
if (result.token) { if (result.token) {
// Store the token manually since we're not using the normal login flow // 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"); navigate("/profile");
} catch (err: unknown) { } 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" /> <CheckCircle className="w-5 h-5 text-accent flex-shrink-0 mt-0.5" />
<div className="text-sm"> <div className="text-sm">
<p className="font-medium text-foreground">Account found</p> <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>
</div> </div>
) : ( ) : (
+33 -17
View File
@@ -27,8 +27,8 @@ import { OAuthProvider } from "@/lib/oauth";
type LoginStep = 'credentials' | 'totp' | 'webauthn' | 'passkey-email' | 'mfa-enrollment' | 'mfa'; 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 SECUIRD_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1';
const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); const SECUIRD_OIDC = SECUIRD_API.replace(/\/api\/v1\/?$/, '');
/** /**
* Complete an OIDC authorization flow after the user has authenticated. * 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. * the auth code and returns the redirect URL for the calling application.
*/ */
async function completeOidcFlow(oidcSessionId: string, token: string): Promise<string> { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), 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() { 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 navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -64,13 +64,13 @@ export default function LoginPage() {
const [mfaToken, setMfaToken] = useState<string | null>(null); const [mfaToken, setMfaToken] = useState<string | null>(null);
// OIDC bridge: if oidc_session_id is in the URL, we're acting as the // 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. // After successful login, call /oidc/complete and redirect to the client app.
const oidcSessionId = searchParams.get('oidc_session_id'); const oidcSessionId = searchParams.get('oidc_session_id');
const oidcError = searchParams.get('error'); const oidcError = searchParams.get('error');
// CLI bridge: if cli_token or cli_redirect is present the login was triggered // 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. // directly to the CLI's local callback server.
const cliToken = searchParams.get('cli_token'); const cliToken = searchParams.get('cli_token');
const cliRedirectParam = searchParams.get('cli_redirect'); const cliRedirectParam = searchParams.get('cli_redirect');
@@ -81,7 +81,7 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (!cliToken || cliFetchedRef.current) return; if (!cliToken || cliFetchedRef.current) return;
cliFetchedRef.current = true; 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((r) => r.json())
.then((body) => { .then((body) => {
if (body?.data?.redirect_url) { if (body?.data?.redirect_url) {
@@ -165,7 +165,7 @@ export default function LoginPage() {
// MFA enrollment required - will be handled by ProtectedLayout // MFA enrollment required - will be handled by ProtectedLayout
// Navigation happens in AuthContext (MFA path always navigates) // Navigation happens in AuthContext (MFA path always navigates)
} else if (oidcSessionId) { } 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(); const token = tokenManager.getToken();
if (token) await finishOidcFlow(token); if (token) await finishOidcFlow(token);
} else if (cliRedirectUrl) { } else if (cliRedirectUrl) {
@@ -176,7 +176,7 @@ export default function LoginPage() {
// Normal login: navigation already handled by AuthContext (skipNavigate=false) // Normal login: navigation already handled by AuthContext (skipNavigate=false)
} catch (error) { } catch (error) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.error("[Gatehouse] Login failed:", error); console.error("[Secuird] Login failed:", error);
} }
const message = error instanceof ApiError const message = error instanceof ApiError
@@ -230,7 +230,14 @@ export default function LoginPage() {
finishCliFlow(response.token); finishCliFlow(response.token);
} else { } else {
await refreshUser(); await refreshUser();
const orgsData = await api.users.organizations();
const hasOrg = orgsData.organizations && orgsData.organizations.length > 0;
if (hasOrg) {
navigate('/profile'); navigate('/profile');
} else {
navigate('/org-setup');
}
} }
} else { } else {
// Fallback to regular TOTP verification // Fallback to regular TOTP verification
@@ -246,7 +253,7 @@ export default function LoginPage() {
} }
} catch (error) { } catch (error) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.error("[Gatehouse] MFA verification failed:", error); console.error("[Secuird] MFA verification failed:", error);
} }
const message = error instanceof ApiError const message = error instanceof ApiError
@@ -294,7 +301,7 @@ export default function LoginPage() {
// Normal login: navigation already handled by AuthContext (skipNavigate=false) // Normal login: navigation already handled by AuthContext (skipNavigate=false)
} catch (error) { } catch (error) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.error("[Gatehouse] TOTP verification failed:", error); console.error("[Secuird] TOTP verification failed:", error);
} }
const message = error instanceof ApiError const message = error instanceof ApiError
@@ -347,6 +354,8 @@ export default function LoginPage() {
// Token is stored by completeLogin, refresh user and navigate // Token is stored by completeLogin, refresh user and navigate
await refreshUser(); await refreshUser();
await checkOrgAdmin();
if (oidcSessionId) { if (oidcSessionId) {
const token = tokenManager.getToken(); const token = tokenManager.getToken();
if (token) await finishOidcFlow(token); if (token) await finishOidcFlow(token);
@@ -354,7 +363,10 @@ export default function LoginPage() {
const token = tokenManager.getToken(); const token = tokenManager.getToken();
if (token) finishCliFlow(token); if (token) finishCliFlow(token);
} else { } 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({ toast({
@@ -363,7 +375,7 @@ export default function LoginPage() {
}); });
} catch (error) { } catch (error) {
if (import.meta.env.DEV) { 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"; let message = "Failed to sign in with passkey";
@@ -430,7 +442,11 @@ export default function LoginPage() {
if (token) finishCliFlow(token); if (token) finishCliFlow(token);
} else { } else {
await refreshUser(); 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({ toast({
title: "Welcome back", title: "Welcome back",
description: `Signed in as ${result.user.email}`, description: `Signed in as ${result.user.email}`,
@@ -438,7 +454,7 @@ export default function LoginPage() {
} }
} catch (error) { } catch (error) {
if (import.meta.env.DEV) { 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"; let message = "Failed to verify passkey";
@@ -518,7 +534,7 @@ export default function LoginPage() {
} catch (error) { } catch (error) {
if (import.meta.env.DEV) { 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`; let message = `Failed to initiate ${provider} sign in`;
@@ -939,7 +955,7 @@ export default function LoginPage() {
</h1> </h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
{cliRedirectUrl {cliRedirectUrl
? "Sign in to grant the Gatehouse CLI access to your account" ? "Sign in to grant the Secuird CLI access to your account"
: oidcSessionId : oidcSessionId
? "An application is requesting access to your account" ? "An application is requesting access to your account"
: "Sign in to your account to continue"} : "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'; type CallbackState = 'loading' | 'success' | 'error';
const GATEHOUSE_API = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1') as string; const SECUIRD_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_OIDC = SECUIRD_API.replace(/\/api\/v1\/?$/, '');
async function completeOidcFlow(oidcSessionId: string, token: string): Promise<string> { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), 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. * after a successful (or failed) OAuth provider authentication.
* *
* The backend exchanges the provider code for a session token and then * The backend exchanges the provider code for a session token and then
@@ -134,7 +134,7 @@ export default function OAuthCallbackPage() {
return; return;
} catch (oidcErr) { } catch (oidcErr) {
if (import.meta.env.DEV) { 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 // Fall through to normal flow on failure — user is still logged in
} }
@@ -163,7 +163,7 @@ export default function OAuthCallbackPage() {
setStatus('error'); setStatus('error');
setError("Failed to load your profile. Please try signing in again."); setError("Failed to load your profile. Please try signing in again.");
if (import.meta.env.DEV) { 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 { Separator } from "@/components/ui/separator";
import { tokenManager } from "@/lib/api"; import { tokenManager } from "@/lib/api";
const GATEHOUSE_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'; const SECUIRD_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1';
const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); const SECUIRD_OIDC = SECUIRD_API.replace(/\/api\/v1\/?$/, '');
const SCOPE_META: Record<string, { icon: typeof Shield; label: string; description: string }> = { const SCOPE_META: Record<string, { icon: typeof Shield; label: string; description: string }> = {
openid: { icon: Shield, label: "OpenID", description: "Verify your identity" }, openid: { icon: Shield, label: "OpenID", description: "Verify your identity" },
@@ -41,7 +41,7 @@ export default function OIDCConsentPage() {
(async () => { (async () => {
try { try {
const res = await fetch(`${GATEHOUSE_OIDC}/oidc/begin`, { const res = await fetch(`${SECUIRD_OIDC}/oidc/begin`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oidc_session_id: oidcSessionId }), body: JSON.stringify({ oidc_session_id: oidcSessionId }),
@@ -67,7 +67,7 @@ export default function OIDCConsentPage() {
navigate(`/login?oidc_session_id=${context.oidc_session_id}`); navigate(`/login?oidc_session_id=${context.oidc_session_id}`);
return; return;
} }
const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oidc_session_id: context.oidc_session_id, token }), 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 * 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: * Handles:
* 1. Unauthenticated users shows an email/password login form * 1. Unauthenticated users shows an email/password login form
* 2. Already-authenticated users shows a consent/approval screen directly * 2. Already-authenticated users shows a consent/approval screen directly
@@ -9,7 +9,7 @@
* Route: /oidc-login?oidc_session_id=<id> * Route: /oidc-login?oidc_session_id=<id>
* *
* Configure your oauth2-proxy / OIDC client's login_url to: * 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 { useState, useEffect, useCallback } from "react";
import { useSearchParams, useNavigate } from "react-router-dom"; import { useSearchParams, useNavigate } from "react-router-dom";
@@ -37,7 +37,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { ApiError, tokenManager } from "@/lib/api"; import { ApiError, tokenManager } from "@/lib/api";
// ── Configuration ───────────────────────────────────────────────────────────── // ── 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\/?$/, ""); .replace(/\/api\/v1\/?$/, "");
// ── Scope display metadata ──────────────────────────────────────────────────── // ── Scope display metadata ────────────────────────────────────────────────────
@@ -62,7 +62,7 @@ type PageStep = "loading" | "login" | "consent" | "error";
// ── API helpers ─────────────────────────────────────────────────────────────── // ── API helpers ───────────────────────────────────────────────────────────────
async function fetchOIDCContext(oidcSessionId: string): Promise<OIDCContext> { 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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oidc_session_id: oidcSessionId }), 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> { 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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), body: JSON.stringify({ oidc_session_id: oidcSessionId, token }),
+3
View File
@@ -8,6 +8,7 @@
*/ */
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom"; 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 { Building2, Plus, ArrowRight, Loader2, Mail, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -33,6 +34,7 @@ interface LocationState {
export default function OrgSetupPage() { export default function OrgSetupPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const queryClient = useQueryClient();
const { refreshUser, checkOrgAdmin, isOrgMember, isLoading } = useAuth(); const { refreshUser, checkOrgAdmin, isOrgMember, isLoading } = useAuth();
// If the user already belongs to an org (e.g. they bookmarked /org-setup), // 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 () => { const done = async () => {
await refreshUser(); await refreshUser();
await checkOrgAdmin(); await checkOrgAdmin();
queryClient.invalidateQueries({ queryKey: ['organizations'] });
navigate("/profile", { replace: true }); navigate("/profile", { replace: true });
}; };
+1 -1
View File
@@ -108,7 +108,7 @@ export default function RegisterPage() {
Create your account Create your account
</h1> </h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Get started with Gatehouse in seconds Get started with Secuird in seconds
</p> </p>
</div> </div>
+10 -7
View File
@@ -227,29 +227,32 @@ return (
</section> </section>
{/* CTA */} {/* 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="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-2xl mx-auto"> <Card className="bg-gradient-to-br from-primary to-primary/80 border-0 overflow-hidden relative">
<h2 className="text-3xl font-bold text-foreground mb-4"> <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? Ready to Try It Yourself?
</h2> </h2>
<p className="text-lg text-muted-foreground mb-8"> <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. Start your free trial today. No credit card required. Full access to all features.
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register"> <Link to="/register">
<Button size="lg" className="gap-2"> <Button size="lg" variant="secondary" className="gap-2 bg-white text-primary hover:bg-white/90 font-semibold">
Start Free Trial Start Free Trial
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<Link to="/pricing"> <Link to="/pricing">
<Button variant="outline" size="lg"> <Button size="lg" className="bg-transparent border-2 border-white text-white hover:bg-white/15 font-semibold">
View Pricing View Pricing
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </CardContent>
</Card>
</div> </div>
</section> </section>
</> </>
+2 -2
View File
@@ -509,13 +509,13 @@ return (
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register"> <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 Start Free Trial
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<Link to="/demo"> <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 Watch Demo
</Button> </Button>
</Link> </Link>
+5 -8
View File
@@ -80,14 +80,11 @@ export default function HomePage() {
return ( return (
<> <>
{/* Hero Section */} {/* Hero Section */}
<section className="relative overflow-hidden"> <section className="relative overflow-hidden bg-card">
{/* Background gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-background to-accent/5 pointer-events-none" />
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24 lg:py-32"> <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"> <div className="text-center max-w-4xl mx-auto">
{/* Badge */} {/* 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" /> <ShieldCheck className="h-4 w-4" />
Security-first identity platform Security-first identity platform
</div> </div>
@@ -95,7 +92,7 @@ return (
{/* Headline */} {/* Headline */}
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-foreground mb-6"> <h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-foreground mb-6">
Enterprise Authentication, 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> </h1>
{/* Subheadline */} {/* Subheadline */}
@@ -423,13 +420,13 @@ return (
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register"> <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 Start Free Trial
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<Link to="/pricing"> <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 View Pricing
</Button> </Button>
</Link> </Link>
+10 -7
View File
@@ -328,29 +328,32 @@ return (
</section> </section>
{/* CTA */} {/* 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="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-2xl mx-auto"> <Card className="bg-gradient-to-br from-primary to-primary/80 border-0 overflow-hidden relative">
<h2 className="text-3xl font-bold text-foreground mb-4"> <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 Start Your Free Trial Today
</h2> </h2>
<p className="text-lg text-muted-foreground mb-8"> <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. Try Secuird free for 14 days. No credit card required. Full access to all Business features.
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register"> <Link to="/register">
<Button size="lg" className="gap-2"> <Button size="lg" variant="secondary" className="gap-2 bg-white text-primary hover:bg-white/90 font-semibold">
Start Free Trial Start Free Trial
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<Link to="/demo"> <Link to="/demo">
<Button variant="outline" size="lg"> <Button size="lg" className="bg-transparent border-2 border-white text-white hover:bg-white/15 font-semibold">
Watch Demo Watch Demo
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </CardContent>
</Card>
</div> </div>
</section> </section>
</> </>
+2 -2
View File
@@ -435,13 +435,13 @@ $ systemctl restart sshd`}
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register"> <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 Start Free Trial
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<Link to="/demo"> <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 Watch Demo
</Button> </Button>
</Link> </Link>
+2 -2
View File
@@ -464,12 +464,12 @@ return (
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/register"> <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 Start Free Trial
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Button> </Button>
</Link> </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 Contact Security Team
</Button> </Button>
</div> </div>
+1 -1
View File
@@ -163,7 +163,7 @@ export default function AccessPage() {
try { try {
const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([ const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([
api.zerotier.listPendingApprovals(orgId), api.zerotier.listPendingApprovals(orgId),
api.zerotier.listMyApprovals(orgId), api.zerotier.adminListAllApprovals(orgId),
api.zerotier.listSessions(orgId), api.zerotier.listSessions(orgId),
api.zerotier.listNetworks(orgId), api.zerotier.listNetworks(orgId),
api.organizations.getMembers(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> <div>
<h1 className="page-title">Certificate Authorities</h1> <h1 className="page-title">Certificate Authorities</h1>
<p className="page-description"> <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> </p>
</div> </div>
</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 { useQuery, useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useOrg } from "@/contexts/OrgContext"; import { useOrg } from "@/contexts/OrgContext";
import { formatDate } from "@/lib/date";
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof Clock }> = { const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof Clock }> = {
compliant: { compliant: {
@@ -231,7 +232,7 @@ export default function CompliancePage() {
{member.deadline_at && member.status !== 'compliant' && member.status !== 'not_applicable' && ( {member.deadline_at && member.status !== 'compliant' && member.status !== 'not_applicable' && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
<span className="hidden md:inline">Deadline: </span> <span className="hidden md:inline">Deadline: </span>
{new Date(member.deadline_at).toLocaleDateString()} {formatDate(member.deadline_at)}
</div> </div>
)} )}
+39 -8
View File
@@ -390,7 +390,7 @@ export default function DepartmentsPage() {
const [selectedPrincipalId, setSelectedPrincipalId] = useState(""); const [selectedPrincipalId, setSelectedPrincipalId] = useState("");
const [isLinking, setIsLinking] = useState(false); const [isLinking, setIsLinking] = useState(false);
const [editingDept, setEditingDept] = useState<Department | null>(null); 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 [expandedPolicies, setExpandedPolicies] = useState<Set<string>>(new Set());
const [expandedMembers, setExpandedMembers] = useState<Set<string>>(new Set()); const [expandedMembers, setExpandedMembers] = useState<Set<string>>(new Set());
@@ -502,12 +502,13 @@ export default function DepartmentsPage() {
const handleCreateDepartment = async () => { const handleCreateDepartment = async () => {
if (!orgId || !formData.name.trim()) return; if (!orgId || !formData.name.trim()) return;
try { try {
await api.organizations.createDepartment( const dept = await api.organizations.createDepartment(
orgId, orgId,
formData.name, formData.name,
formData.description || undefined formData.description || undefined,
formData.can_sudo
); );
setFormData({ name: "", description: "" }); setFormData({ name: "", description: "", can_sudo: false });
setIsCreateDialogOpen(false); setIsCreateDialogOpen(false);
await fetchDepartments(orgId); await fetchDepartments(orgId);
} catch (err) { } catch (err) {
@@ -522,8 +523,9 @@ export default function DepartmentsPage() {
await api.organizations.updateDepartment(orgId, editingDept.id, { await api.organizations.updateDepartment(orgId, editingDept.id, {
name: formData.name, name: formData.name,
description: formData.description || undefined, description: formData.description || undefined,
can_sudo: formData.can_sudo,
}); });
setFormData({ name: "", description: "" }); setFormData({ name: "", description: "", can_sudo: false });
setEditingDept(null); setEditingDept(null);
setIsEditDialogOpen(false); setIsEditDialogOpen(false);
await fetchDepartments(orgId); await fetchDepartments(orgId);
@@ -546,7 +548,7 @@ export default function DepartmentsPage() {
const openEditDialog = (dept: Department) => { const openEditDialog = (dept: Department) => {
setEditingDept(dept); setEditingDept(dept);
setFormData({ name: dept.name, description: dept.description || "" }); setFormData({ name: dept.name, description: dept.description || "", can_sudo: dept.can_sudo || false });
setIsEditDialogOpen(true); setIsEditDialogOpen(true);
}; };
@@ -572,7 +574,7 @@ export default function DepartmentsPage() {
Manage departments and organize team members Manage departments and organize team members
</p> </p>
</div> </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" /> <Plus className="w-4 h-4 mr-2" />
Create Department Create Department
</Button> </Button>
@@ -615,10 +617,15 @@ export default function DepartmentsPage() {
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
</div> </div>
<div className="flex-1 min-w-0"> <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"> <p className="font-medium text-foreground">
{dept.name} {dept.name}
</p> </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> </div>
{dept.description && ( {dept.description && (
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
@@ -751,6 +758,18 @@ export default function DepartmentsPage() {
rows={3} rows={3}
/> />
</div> </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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}> <Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
@@ -792,6 +811,18 @@ export default function DepartmentsPage() {
rows={3} rows={3}
/> />
</div> </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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}> <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) { function formatDate(d: string | null | undefined) {
if (!d) return "—"; if (!d) return "—";
return new Date(d).toLocaleDateString(undefined, { const raw = typeof d === "string" && !(d.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(d)) ? d + "Z" : d;
year: "numeric", return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(new Date(raw));
month: "short",
day: "numeric",
});
} }
function capitalize(s: string) { function capitalize(s: string) {
+168
View File
@@ -17,6 +17,9 @@ import {
XCircle, XCircle,
Ban, Ban,
Zap, Zap,
Download,
RefreshCw,
AlertCircle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -53,10 +56,12 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 { useToast } from "@/hooks/use-toast";
import { import {
api, api,
ApiError, ApiError,
AvailableZtNetwork,
PortalNetwork, PortalNetwork,
DeviceNetworkMembership, DeviceNetworkMembership,
UserNetworkApproval, UserNetworkApproval,
@@ -149,6 +154,13 @@ export default function NetworksPage() {
const [deleteNetwork, setDeleteNetwork] = useState<PortalNetwork | null>(null); const [deleteNetwork, setDeleteNetwork] = useState<PortalNetwork | null>(null);
const [isDeleting, setIsDeleting] = useState(false); 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 () => { const fetchNetworks = useCallback(async () => {
if (!orgId) { setIsLoading(false); return; } if (!orgId) { setIsLoading(false); return; }
setIsLoading(true); setIsLoading(true);
@@ -193,6 +205,37 @@ export default function NetworksPage() {
setNetworkRequests([]); 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 () => { const handleCreate = async () => {
if (!orgId) return; if (!orgId) return;
setCreateError(null); setCreateError(null);
@@ -297,6 +340,9 @@ export default function NetworksPage() {
className="pl-10" className="pl-10"
/> />
</div> </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"> <Button onClick={() => setShowCreate(true)} className="gap-2">
<Plus className="w-4 h-4" /> Add Network <Plus className="w-4 h-4" /> Add Network
</Button> </Button>
@@ -387,6 +433,128 @@ export default function NetworksPage() {
</CardContent> </CardContent>
</Card> </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 */} {/* Create Network Dialog */}
<Dialog open={showCreate} onOpenChange={(open) => { if (!open) setShowCreate(false); }}> <Dialog open={showCreate} onOpenChange={(open) => { if (!open) setShowCreate(false); }}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
+210 -27
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react";
import { import {
Plus, Key, MoreHorizontal, Copy, Trash2, Loader2, Plus, Key, MoreHorizontal, Copy, Trash2, Loader2,
AlertCircle, CheckCircle, Network, Terminal, Check, AlertCircle, CheckCircle, Network, Terminal, Check,
ChevronDown, Globe, RefreshCw, Info, Globe, RefreshCw, Info, Pencil,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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") const ISSUER_URL = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1")
.replace(/\/api\/v1\/?$/, ""); .replace(/\/api\/v1\/?$/, "");
function buildProxyConfig(clientId: string, clientSecret: string, proxyHost: string) { /** Generate a cryptographically random 32-byte base64url cookie secret. */
return `provider = "oidc" function generateCookieSecret(): string {
oidc_issuer_url = "${ISSUER_URL}" const bytes = new Uint8Array(32);
client_id = "${clientId}" crypto.getRandomValues(bytes);
client_secret = "${clientSecret}" // Standard base64, then make it URL-safe (oauth2-proxy accepts both)
redirect_url = "http://${proxyHost}/oauth2/callback" return btoa(String.fromCharCode(...bytes));
scope = "openid profile email" }
cookie_secret = "$(openssl rand -base64 32 | head -c 32)"
cookie_secure = false function buildProxyConfig(
upstream = "http://127.0.0.1:8080/" clientId: string,
set_authorization_header = true clientSecret: string,
set_x_auth_request_header = true`; 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() { function useCopyButton() {
@@ -70,6 +97,10 @@ interface NewSecretState {
clientId: string; clientId: string;
secret: string; secret: string;
proxyHost?: string; proxyHost?: string;
proxyUpstream?: string;
proxySetAuthHeader?: boolean;
proxySetXAuthHeader?: boolean;
proxyCookieSecret?: string;
isProxy: boolean; isProxy: boolean;
} }
@@ -92,6 +123,15 @@ export default function OIDCClientsPage() {
// Proxy form // Proxy form
const proxyNameRef = useRef<HTMLInputElement>(null); const proxyNameRef = useRef<HTMLInputElement>(null);
const proxyHostRef = 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(() => { useEffect(() => {
if (!orgId) { setIsLoading(false); return; } if (!orgId) { setIsLoading(false); return; }
@@ -117,7 +157,11 @@ export default function OIDCClientsPage() {
name = proxyNameRef.current?.value.trim() ?? ""; name = proxyNameRef.current?.value.trim() ?? "";
proxyHost = proxyHostRef.current?.value.trim() ?? ""; proxyHost = proxyHostRef.current?.value.trim() ?? "";
if (!name || !proxyHost) return; 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); setIsCreating(true);
@@ -129,6 +173,10 @@ export default function OIDCClientsPage() {
clientId: created.client_id, clientId: created.client_id,
secret: created.client_secret, secret: created.client_secret,
proxyHost, proxyHost,
proxyUpstream: proxyUpstreamRef.current?.value.trim() || "http://127.0.0.1:8080/",
proxySetAuthHeader,
proxySetXAuthHeader,
proxyCookieSecret: dialogMode === "proxy" ? generateCookieSecret() : undefined,
isProxy: dialogMode === "proxy", isProxy: dialogMode === "proxy",
}); });
setDialogMode(null); 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 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; : null;
return ( 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 className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="page-title">OIDC Clients</h1> <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> </div>
<Button onClick={() => setDialogMode("generic")}> <Button onClick={() => setDialogMode("generic")}>
<Plus className="w-4 h-4 mr-2" /> <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" /> <Network className="w-10 h-10 text-muted-foreground/40" />
<div> <div>
<p className="font-medium text-muted-foreground">No OIDC clients yet</p> <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>
<div className="flex gap-2 flex-wrap justify-center"> <div className="flex gap-2 flex-wrap justify-center">
<Button variant="outline" onClick={() => setDialogMode("generic")}> <Button variant="outline" onClick={() => setDialogMode("generic")}>
@@ -292,6 +375,10 @@ export default function OIDCClientsPage() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEditDialog(client)}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"
@@ -320,7 +407,7 @@ export default function OIDCClientsPage() {
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Add OIDC Client</DialogTitle> <DialogTitle>Add OIDC Client</DialogTitle>
<DialogDescription>Register an application to authenticate via Gatehouse</DialogDescription> <DialogDescription>Register an application to authenticate via Secuird</DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs <Tabs
@@ -361,16 +448,56 @@ export default function OIDCClientsPage() {
<Input id="proxyName" placeholder="My Protected App" ref={proxyNameRef} /> <Input id="proxyName" placeholder="My Protected App" ref={proxyNameRef} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="proxyHost">Proxy host</Label> <Label htmlFor="proxyHost">Proxy public URL</Label>
<Input id="proxyHost" placeholder="app.example.com" ref={proxyHostRef} /> <Input id="proxyHost" placeholder="https://app.example.com" ref={proxyHostRef} />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
The hostname where oauth2-proxy runs. Redirect URI will be set to{" "} Full URL where oauth2-proxy is exposed.{" "}
<code className="bg-muted px-1 rounded">http://{"<host>"}/oauth2/callback</code> automatically. <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> </p>
</div> </div>
<div className="rounded-md bg-muted/50 border px-3 py-2 text-xs text-muted-foreground"> <div className="space-y-2">
After creating, you'll get a ready-to-paste config snippet for oauth2-proxy. <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>
<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> </TabsContent>
</Tabs> </Tabs>
@@ -389,6 +516,56 @@ export default function OIDCClientsPage() {
</DialogContent> </DialogContent>
</Dialog> </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 ─────────────────────────────────────────── */} {/* ── Reference ─────────────────────────────────────────── */}
<div className="mt-8"> <div className="mt-8">
<div className="flex items-center gap-2 mb-3 text-sm font-medium text-muted-foreground"> <div className="flex items-center gap-2 mb-3 text-sm font-medium text-muted-foreground">
@@ -503,7 +680,9 @@ export default function OIDCClientsPage() {
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs font-medium">1 Create a client (use the dialog above)</p> <p className="text-xs font-medium">1 Create a client (use the dialog above)</p>
<p className="text-xs text-muted-foreground"> <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> </p>
</div> </div>
@@ -514,9 +693,10 @@ export default function OIDCClientsPage() {
oidc_issuer_url = "${ISSUER_URL}" oidc_issuer_url = "${ISSUER_URL}"
client_id = "<your-client-id>" client_id = "<your-client-id>"
client_secret = "<your-client-secret>" client_secret = "<your-client-secret>"
redirect_url = "http://<proxy-host>/oauth2/callback" redirect_url = "https://<proxy-host>/oauth2/callback"
scope = "openid profile email" scope = "openid profile email"
cookie_secret = "$(openssl rand -base64 32 | head -c 32)" cookie_secret = "$(openssl rand -base64 32 | head -c 32)"
cookie_secure = true
upstream = "http://127.0.0.1:8080/" upstream = "http://127.0.0.1:8080/"
set_authorization_header = true set_authorization_header = true
set_x_auth_request_header = true`}</pre> set_x_auth_request_header = true`}</pre>
@@ -558,8 +738,11 @@ set_x_auth_request_header = true`}</pre>
OAUTH2_PROXY_CLIENT_ID: \${OIDC_CLIENT_ID} OAUTH2_PROXY_CLIENT_ID: \${OIDC_CLIENT_ID}
OAUTH2_PROXY_CLIENT_SECRET: \${OIDC_CLIENT_SECRET} OAUTH2_PROXY_CLIENT_SECRET: \${OIDC_CLIENT_SECRET}
OAUTH2_PROXY_COOKIE_SECRET: \${COOKIE_SECRET} OAUTH2_PROXY_COOKIE_SECRET: \${COOKIE_SECRET}
OAUTH2_PROXY_COOKIE_SECURE: "true"
OAUTH2_PROXY_UPSTREAM: http://app:8080/ 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> </div>
{/* Kubernetes snippet */} {/* Kubernetes snippet */}
+317 -118
View File
@@ -1,204 +1,403 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { Search, Filter, Download, User, Settings, Key, UserPlus, AlertTriangle, Loader2 } from "lucide-react"; import {
import { useParams } from "react-router-dom"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; 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 { api, AuditLogEntry } from "@/lib/api";
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
import { formatDateTime } from "@/lib/date";
const getEventIcon = (action: string) => { // ─── category / display helpers ──────────────────────────────────────────────
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" />;
};
const getEventTitle = (action: string) => { type Category = "auth" | "ssh" | "admin" | "member" | "policy" | "security" | "oauth" | "other";
const parts = action.split(".");
return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
};
const getActionCategory = (action: string): string => { const getCategory = (action: string): Category => {
if (action.includes("member") || action.includes("MEMBER")) return "members"; const a = action.toLowerCase();
if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) return "policies"; if (a.startsWith("session") || a === "user.login" || a === "user.logout") return "auth";
if (action.includes("client") || action.includes("OIDC")) return "clients"; 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"; 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() { export default function OrgAuditPage() {
const params = useParams<{ orgId?: string }>(); const { orgId } = useCurrentOrganizationId();
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
const orgId = params.orgId || fallbackOrgId;
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all"); 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 [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fetchAuditLogs = useCallback(async (currentOrgId: string) => { // debounce search
try { 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); setIsLoading(true);
setError(null); setError(null);
const response = await api.organizations.getAuditLogs(currentOrgId); try {
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) { } 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."); setError("Failed to load audit logs. Please try again.");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, [orgId, page, actionFilter, successFilter, debouncedSearch]);
useEffect(() => { useEffect(() => { fetchLogs(); }, [fetchLogs]);
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;
});
return ( return (
<div className="page-container"> <div className="page-container">
{/* Header */}
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="page-title">Audit Log</h1> <h1 className="page-title">Org Audit Log</h1>
<p className="page-description"> <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> </p>
</div> </div>
<Button variant="outline"> <Button variant="outline" size="sm" onClick={fetchLogs} disabled={isLoading}>
<Download className="w-4 h-4 mr-2" /> <RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
Export Refresh
</Button> </Button>
</div> </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"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
placeholder="Search events..." placeholder="Search descriptions…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="pl-10" className="pl-10"
/> />
</div> </div>
<Select value={typeFilter} onValueChange={setTypeFilter}> <Select value={actionFilter} onValueChange={setActionFilter}>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[210px]">
<Filter className="w-4 h-4 mr-2" /> <Filter className="w-4 h-4 mr-2" />
<SelectValue placeholder="Filter by type" /> <SelectValue placeholder="Filter by action" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All events</SelectItem> {ACTION_FILTER_OPTIONS.map((o) => (
<SelectItem value="members">Member changes</SelectItem> <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
<SelectItem value="policies">Policy changes</SelectItem> ))}
<SelectItem value="clients">OIDC clients</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> </SelectContent>
</Select> </Select>
</div> </div>
{/* Table */}
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? ( {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" /> <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> </div>
) : error ? ( ) : error ? (
<div className="p-8 text-center text-destructive"> <div className="py-12 text-center text-destructive">
{error} <AlertTriangle className="w-8 h-8 mx-auto mb-2" />
<p>{error}</p>
</div> </div>
) : filteredLogs.length === 0 ? ( ) : auditLogs.length === 0 ? (
<div className="p-8 text-center text-muted-foreground"> <div className="py-12 text-center text-muted-foreground">
No audit events found No audit events match the current filters.
</div> </div>
) : ( ) : (
<div className="divide-y"> <div className="divide-y">
{filteredLogs.map((log) => ( {auditLogs.map((log) => {
<div key={log.id} className="p-4 flex items-start gap-4"> 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 <div
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${ className={`mt-0.5 w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
!log.success log.success ? meta.color : "bg-destructive/10 text-destructive"
? "bg-destructive/10 text-destructive"
: "bg-accent/10 text-accent"
}`} }`}
> >
{getEventIcon(log.action)} {log.success ? getCategoryIcon(cat) : <XCircle className="w-4 h-4" />}
</div> </div>
{/* Body */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-foreground"> <span className="font-medium text-sm text-foreground">
{getEventTitle(log.action)} {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 && (
<p className="mt-0.5 text-sm text-muted-foreground">
{log.description}
{isCert && <CertDetail metadata={log.metadata} />}
</p> </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 && ( {log.resource_type && (
<Badge variant="secondary" className="text-xs"> <Badge variant="outline" className="text-xs px-1.5 py-0 font-mono">
{log.resource_type} {log.resource_type}
</Badge> </Badge>
)} )}
{!log.success && (
<Badge variant="destructive" className="text-xs">
Failed
</Badge>
)}
</div>
<div className="mt-1 text-sm text-muted-foreground">
<span>by {log.user?.full_name || log.user?.email || "System"}</span>
{log.description && (
<>
<span className="mx-2"></span>
<span>{log.description}</span>
</>
)}
</div> </div>
</div> </div>
<p className="text-sm text-muted-foreground whitespace-nowrap">
{formatDate(log.created_at)} {/* 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> </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>
))} </div>
);
})}
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </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> </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; const isSystem = !!ca.is_system;
// ── User CA: server trusts this public key so it accepts user certs ────── // ── User CA: server trusts this public key so it accepts user certs ──────
const userCaServerSnippet = `# On each SSH server — trust Gatehouse-issued user certificates: const userCaServerSnippet = `# On each SSH server — trust Secuird-issued user certificates:
echo '${ca.public_key.trim()}' >> /etc/ssh/trusted_user_ca_keys echo '${ca.public_key.trim()}' >> /etc/ssh/trusted_user_ca
# /etc/ssh/sshd_config (add once, then reload sshd): # /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 AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
# Create /etc/ssh/auth_principals/<unix-user> containing one principal per line.`; # 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) # Server side (separate step)
# 1. Collect the server's HOST public key: # 1. Collect the server's HOST public key:
# cat /etc/ssh/ssh_host_ed25519_key.pub # 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: # 3. Install the cert on the server:
# /etc/ssh/sshd_config: # /etc/ssh/sshd_config:
# HostKey /etc/ssh/ssh_host_ed25519_key # HostKey /etc/ssh/ssh_host_ed25519_key
@@ -144,7 +144,7 @@ AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Stats row — hidden for system CAs */} {/* Stats row — hidden for system CAs */}
{!isSystem && ( {!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"> <div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.active_certs}</p> <p className="text-lg font-semibold">{ca.active_certs}</p>
<p className="text-xs text-muted-foreground">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-lg font-semibold">{ca.default_cert_validity_hours}h</p>
<p className="text-xs text-muted-foreground">Default validity</p> <p className="text-xs text-muted-foreground">Default validity</p>
</div> </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> </div>
)} )}
@@ -200,8 +197,8 @@ AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5" /> <Terminal className="w-3.5 h-3.5" />
{isUser {isUser
? "Server setup — trust Gatehouse user certificates" ? "Server setup — trust Secuird user certificates"
: "Client setup — trust Gatehouse host certificates"} : "Client setup — trust Secuird host certificates"}
</span> </span>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="pb-3"> <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"> <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{" "} <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 <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>. <code className="font-mono">HostCertificate</code>.
</div> </div>
)} )}
+1 -1
View File
@@ -127,7 +127,7 @@ export function CASection({
<p> <p>
Certificates are being signed by a CA key loaded from the server Certificates are being signed by a CA key loaded from the server
configuration, not managed through this UI. Generate a managed key below to 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> </p>
</div> </div>
<Button <Button
+1 -1
View File
@@ -176,7 +176,7 @@ ssh-keygen -L -f /etc/ssh/ssh_host_ed25519_key-cert.pub`
</p> </p>
<p> <p>
<strong>Step 2 (here):</strong> For each server, collect its host public key, <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{" "} as <code className="font-mono">HostCertificate</code> in{" "}
<code className="font-mono">sshd_config</code>. <code className="font-mono">sshd_config</code>.
</p> </p>
+238 -102
View File
@@ -1,118 +1,224 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, Loader2, RefreshCw, Users } from "lucide-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 { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { api, AuditLogEntry } from "@/lib/api";
import { useAuth } from "@/contexts/AuthContext"; import { formatDateTime } from "@/lib/date";
// Map audit log action strings to display info // ─── event display mapping ────────────────────────────────────────────────────
const getEventDisplay = (action: string) => {
interface EventDisplay {
icon: React.ReactNode;
title: string;
}
const getEventDisplay = (action: string): EventDisplay => {
const a = action.toLowerCase(); 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 }; // Sessions
} if (a === "session.create") return { icon: <LogIn className="w-4 h-4" />, title: "Signed in" };
if (a.includes("login") || a.includes("authenticate")) { if (a === "session.revoke") return { icon: <LogOut className="w-4 h-4" />, title: "Signed out" };
return { icon: <LogIn className="w-4 h-4" />, title: "Signed in", failed: false }; 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" };
if (a.includes("logout") || a.includes("sign_out")) {
return { icon: <LogOut className="w-4 h-4" />, title: "Signed out", failed: false }; // OAuth / external auth
} if (a === "external_auth.link.completed") return { icon: <Link2 className="w-4 h-4" />, title: "OAuth account linked" };
if (a.includes("passkey") || a.includes("webauthn")) { if (a === "external_auth.link.initiated") return { icon: <Link2 className="w-4 h-4" />, title: "OAuth link started" };
return { icon: <Fingerprint className="w-4 h-4" />, title: "Passkey event", failed: false }; 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.includes("mfa") || a.includes("totp") || a.includes("2fa")) { if (a === "external_auth.login") return { icon: <LogIn className="w-4 h-4" />, title: "Signed in via OAuth" };
return { icon: <Smartphone className="w-4 h-4" />, title: "MFA event", failed: false }; if (a === "external_auth.login.failed") return { icon: <LogIn className="w-4 h-4" />, title: "OAuth login failed" };
}
if (a.includes("ssh")) { // SSH keys
return { icon: <Key className="w-4 h-4" />, title: "SSH key event", failed: false }; 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" };
return { icon: <Key className="w-4 h-4" />, title: action.replace(/_/g, " "), failed: !action.includes("success") && a.includes("fail") }; 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() { export default function ActivityPage() {
const { isOrgAdmin } = useAuth(); const [actionFilter, setActionFilter] = useState("all");
const [filter, setFilter] = useState("all"); const [search, setSearch] = useState("");
const [view, setView] = useState<"mine" | "org">("mine"); 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 [events, setEvents] = useState<AuditLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(""); 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); setIsLoading(true);
setError(""); setError("");
const req = try {
view === "org" && isOrgAdmin const params: Record<string, string> = {
? api.admin.getAuditLogs({ per_page: "100" }).then((d) => d.audit_logs ?? []) page: String(page),
: api.users.auditLogs({ per_page: "50" }).then((d) => d.audit_logs ?? []); per_page: String(PER_PAGE),
req
.then((logs) => setEvents(logs))
.catch(() => setError("Failed to load activity. Please try again."))
.finally(() => setIsLoading(false));
}; };
if (actionFilter !== "all") params.action = actionFilter;
if (debouncedSearch) params.q = debouncedSearch;
useEffect(() => { loadEvents(); }, [view]); // eslint-disable-line react-hooks/exhaustive-deps 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]);
const formatDate = (dateString: string) => { useEffect(() => { loadEvents(); }, [loadEvents]);
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) => { const formatDate = (dateString: string) =>
if (filter === "all") return true; formatDateTime(dateString, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
const a = e.action.toLowerCase();
if (filter === "logins")
return a.includes("session_create") || a.includes("session_revoke") || a.includes("external_auth") || a.includes("login") || a.includes("logout");
if (filter === "security")
return a.includes("mfa") || a.includes("passkey") || a.includes("ssh") || a.includes("totp") || a.includes("password") || a.includes("webauthn");
return true;
});
return ( return (
<div className="page-container"> <div className="page-container">
{/* Header */}
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="page-title">Activity</h1> <h1 className="page-title">My Activity</h1>
<p className="page-description"> <p className="page-description">Your recent account activity and security events</p>
{view === "org" ? "Organization-wide audit log" : "Your recent account activity and security events"}
</p>
</div> </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}> <Button variant="outline" size="icon" onClick={loadEvents} disabled={isLoading}>
<RefreshCw className={`w-4 h-4 ${isLoading ? "animate-spin" : ""}`} /> <RefreshCw className={`w-4 h-4 ${isLoading ? "animate-spin" : ""}`} />
</Button> </Button>
</div> </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> </div>
{/* Log list */}
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
@@ -124,19 +230,20 @@ export default function ActivityPage() {
<AlertTriangle className="w-8 h-8 mx-auto mb-2 text-destructive" /> <AlertTriangle className="w-8 h-8 mx-auto mb-2 text-destructive" />
<p>{error}</p> <p>{error}</p>
</div> </div>
) : filteredEvents.length === 0 ? ( ) : events.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<p>No activity events found.</p> <p>No activity events found.</p>
</div> </div>
) : ( ) : (
<div className="divide-y"> <div className="divide-y">
{filteredEvents.map((event) => { {events.map((event) => {
const display = getEventDisplay(event.action); const display = getEventDisplay(event.action);
const isCert = event.action.startsWith("ssh.cert");
return ( return (
<div key={event.id} className="p-4 flex items-start gap-4"> <div key={event.id} className="p-4 flex items-start gap-4">
<div <div
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${ 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-destructive/10 text-destructive"
: "bg-accent/10 text-accent" : "bg-accent/10 text-accent"
}`} }`}
@@ -145,33 +252,37 @@ export default function ActivityPage() {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-foreground capitalize"> <p className="font-medium text-foreground">{display.title}</p>
{display.title} {!event.success && (
</p> <Badge variant="destructive" className="text-xs">Failed</Badge>
{(!event.success || display.failed) && (
<Badge variant="destructive" className="text-xs">
Failed
</Badge>
)} )}
</div> </div>
<div className="mt-1 text-sm text-muted-foreground space-y-0.5"> {event.description && (
{view === "org" && event.user_id && ( <p className="mt-0.5 text-sm text-muted-foreground">{event.description}</p>
<p className="font-medium text-xs text-foreground/70">User: {event.user_id}</p>
)} )}
{event.description && <p>{event.description}</p>} {/* Cert-specific: principal + serial */}
<div className="flex items-center gap-2 flex-wrap"> {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 && ( {event.ip_address && (
<span className="font-mono text-xs">{event.ip_address}</span> <span className="font-mono">{event.ip_address}</span>
)} )}
{event.user_agent && ( {event.user_agent && (
<span className="truncate max-w-[200px]">{event.user_agent}</span> <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>
)} )}
</div> </div>
</div> </div>
</div> <div className="flex flex-col items-end gap-1 flex-shrink-0">
<p className="text-sm text-muted-foreground whitespace-nowrap"> <p className="text-xs text-muted-foreground whitespace-nowrap">
{formatDate(event.created_at)} {formatDate(event.created_at)}
</p> </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> </div>
); );
})} })}
@@ -179,6 +290,31 @@ export default function ActivityPage() {
)} )}
</CardContent> </CardContent>
</Card> </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> </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"> <Alert className="mb-6">
<AlertCircle className="w-4 h-4" /> <AlertCircle className="w-4 h-4" />
<AlertDescription> <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. They cannot be used to create new accounts.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
+20 -20
View File
@@ -48,6 +48,7 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api"; import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api";
import { formatDate as _formatDate } from "@/lib/date";
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
// Helpers // Helpers
@@ -55,11 +56,7 @@ import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg
function formatDate(dateStr: string | null): string { function formatDate(dateStr: string | null): string {
if (!dateStr) return "—"; if (!dateStr) return "—";
return new Date(dateStr).toLocaleDateString(undefined, { return _formatDate(dateStr);
year: "numeric",
month: "short",
day: "numeric",
});
} }
function CopyButton({ text }: { text: string }) { function CopyButton({ text }: { text: string }) {
@@ -658,7 +655,7 @@ export default function SSHKeysPage() {
CA Public Key CA Public Key
</CardTitle> </CardTitle>
<CardDescription> <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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -692,9 +689,9 @@ export default function SSHKeysPage() {
</p> </p>
<pre className="text-xs font-mono whitespace-pre-wrap break-all"> <pre className="text-xs font-mono whitespace-pre-wrap break-all">
{`# On each SSH server: {`# 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: # In /etc/ssh/sshd_config:
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys`} TrustedUserCAKeys /etc/ssh/trusted_user_ca`}
</pre> </pre>
</div> </div>
</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="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{verifyError}</div>
)} )}
<div className="space-y-2"> <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"> <div className="relative">
<Textarea <Textarea
readOnly readOnly
@@ -813,18 +813,18 @@ TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys`}
</div> </div>
</div> </div>
<div className="rounded-lg bg-muted p-3 space-y-1"> <div className="space-y-2">
<p className="text-xs font-semibold flex items-center gap-1"> <Label className="flex items-center gap-1">
<Terminal className="w-3 h-3" /> Step 2 Sign with ssh-keygen <Terminal className="w-3 h-3" /> Step 2 Sign with ssh-keygen
</p> </Label>
<pre className="text-xs font-mono whitespace-pre-wrap break-all"> <div className="relative">
{`echo '<challenge_text>' > /tmp/challenge.txt <Textarea
ssh-keygen -Y sign \\ readOnly
-f ~/.ssh/id_ed25519 \\ 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`}
-n gatehouse \\ className="font-mono text-xs pr-10"
/tmp/challenge.txt rows={6}
cat /tmp/challenge.txt.sig | base64 -w0`} />
</pre> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
+2 -2
View File
@@ -246,13 +246,13 @@ export default function SecurityPage() {
const formatLastUsed = (date: string | null) => { const formatLastUsed = (date: string | null) => {
if (!date) return "Never"; 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 now = new Date();
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)); const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Today"; if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday"; if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays} days ago`; if (diffDays < 7) return `${diffDays} days ago`;
return d.toLocaleDateString(); return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(d);
}; };
return ( 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"}
+11 -5
View File
@@ -1,14 +1,19 @@
import { defineConfig } from "vite"; import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import path from "path"; import path from "path";
import { componentTagger } from "lovable-tagger"; import { componentTagger } from "lovable-tagger";
// https://vitejs.dev/config/ export default defineConfig(({ mode }) => {
export default defineConfig(({ mode }) => ({ const env = loadEnv(mode, process.cwd(), "");
return {
server: { server: {
host: "::", host: "::",
port: 8080, port: 8080,
allowedHosts: process.env.VITE_ALLOWED_HOSTS?.split(",") || ["ui.webauthn.local","gatehouse-ui.hawkvelt.tech"], allowedHosts: env.VITE_ALLOWED_HOSTS?.split(",") || [
"ui.webauthn.local",
"gatehouse-ui.hawkvelt.tech",
],
}, },
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean), plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
resolve: { resolve: {
@@ -16,4 +21,5 @@ export default defineConfig(({ mode }) => ({
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
})); };
});