diff --git a/public/gatehouse-logo.svg b/public/secuird-logo.svg similarity index 100% rename from public/gatehouse-logo.svg rename to public/secuird-logo.svg diff --git a/src/App.tsx b/src/App.tsx index 3d68c6b..01e0de9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,7 @@ import UserSecurityPage from "@/pages/user/SecurityPage"; import LinkedAccountsPage from "@/pages/user/LinkedAccountsPage"; import ActivityPage from "@/pages/user/ActivityPage"; import SSHKeysPage from "@/pages/user/SSHKeysPage"; +import CLIGuidePage from "@/pages/user/CLIGuidePage"; // Organization pages import OrgOverviewPage from "@/pages/org/OrgOverviewPage"; @@ -47,10 +48,12 @@ import OIDCClientsPage from "@/pages/org/OIDCClientsPage"; import CAsPage from "@/pages/org/CAsPage"; import DepartmentsPage from "@/pages/org/DepartmentsPage"; import PrincipalsPage from "@/pages/org/PrincipalsPage"; +import ApiKeysPage from "@/pages/org/ApiKeysPage"; import MyMembershipsPage from "@/pages/org/MyMembershipsPage"; import NetworksPage from "@/pages/org/NetworksPage"; import DevicesPage from "@/pages/org/DevicesPage"; import AccessPage from "@/pages/org/AccessPage"; +import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage"; import SystemAuditPage from "@/pages/admin/SystemAuditPage"; import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage"; import OrgSetupPage from "@/pages/auth/OrgSetupPage"; @@ -170,10 +173,11 @@ function AppRoutes() { }> {/* User routes */} } /> - } /> + } /> } /> } /> } /> + } /> {/* Organization routes — org members: overview + own memberships only */} } /> @@ -184,6 +188,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -191,6 +196,7 @@ function AppRoutes() { } /> } /> } /> + } /> {/* Admin routes — org admin/owner only */} } /> diff --git a/src/components/branding/GatehouseLogo.tsx b/src/components/branding/SecuirdLogo.tsx similarity index 91% rename from src/components/branding/GatehouseLogo.tsx rename to src/components/branding/SecuirdLogo.tsx index a0028e4..aa80334 100644 --- a/src/components/branding/GatehouseLogo.tsx +++ b/src/components/branding/SecuirdLogo.tsx @@ -1,21 +1,21 @@ import { cn } from "@/lib/utils"; -interface GatehouseLogoProps { +interface SecuirdLogoProps { size?: "sm" | "md" | "lg"; variant?: "default" | "light"; className?: string; } /** - * Gatehouse Logo - Abstract gate/doorway mark + * Secuird Logo - Abstract gate/doorway mark * Represents controlled entry and policy enforcement * Two vertical pillars forming a gateway with negative space */ -export function GatehouseLogo({ +export function SecuirdLogo({ size = "md", variant = "default", className -}: GatehouseLogoProps) { +}: SecuirdLogoProps) { const sizeClasses = { sm: "w-8 h-8", md: "w-9 h-9", diff --git a/src/components/dev/ApiDevTools.tsx b/src/components/dev/ApiDevTools.tsx index b42c2da..7d27842 100644 --- a/src/components/dev/ApiDevTools.tsx +++ b/src/components/dev/ApiDevTools.tsx @@ -65,9 +65,9 @@ const isDev = import.meta.env.DEV; const originalFetch = window.fetch; // Avoid patching multiple times during HMR -const globalAny = window as unknown as { __gatehouseFetchPatched?: boolean }; -if (isDev && !globalAny.__gatehouseFetchPatched) { - globalAny.__gatehouseFetchPatched = true; +const globalAny = window as unknown as { __secuirdFetchPatched?: boolean }; +if (isDev && !globalAny.__secuirdFetchPatched) { + globalAny.__secuirdFetchPatched = true; try { window.fetch = async function (input, init) { @@ -165,9 +165,9 @@ if (isDev && !globalAny.__gatehouseFetchPatched) { }; } catch (patchError) { // Log any errors during fetch patching with full stack trace - console.error("[Gatehouse DevTools] Failed to patch fetch:", patchError); + console.error("[Secuird DevTools] Failed to patch fetch:", patchError); if (patchError instanceof Error) { - console.error("[Gatehouse DevTools] Stack trace:", patchError.stack); + console.error("[Secuird DevTools] Stack trace:", patchError.stack); } } } @@ -220,7 +220,7 @@ export default function ApiDevTools() { {/* Header */}
- Gatehouse API DevTools + Secuird API DevTools {logs.length} requests diff --git a/src/components/layouts/MarketingLayout.tsx b/src/components/layouts/MarketingLayout.tsx index a460d11..2b3bcce 100644 --- a/src/components/layouts/MarketingLayout.tsx +++ b/src/components/layouts/MarketingLayout.tsx @@ -1,5 +1,5 @@ import { Link, Outlet, useLocation } from "react-router-dom"; -import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; +import { SecuirdLogo as GatehouseLogo } from "@/components/branding/SecuirdLogo"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { diff --git a/src/components/layouts/MfaEnforcementLayout.tsx b/src/components/layouts/MfaEnforcementLayout.tsx index 29d4102..6b4d260 100644 --- a/src/components/layouts/MfaEnforcementLayout.tsx +++ b/src/components/layouts/MfaEnforcementLayout.tsx @@ -7,6 +7,7 @@ import { useAuth } from '@/contexts/AuthContext'; import { AddPasskeyWizard } from '@/components/security/AddPasskeyWizard'; import { TotpEnrollmentWizard } from '@/components/security/TotpEnrollmentWizard'; import { api } from '@/lib/api'; +import { formatDate } from '@/lib/date'; export default function MfaEnforcementLayout() { const navigate = useNavigate(); @@ -97,7 +98,7 @@ export default function MfaEnforcementLayout() {
- Gatehouse + Secuird
@@ -124,7 +125,7 @@ export default function MfaEnforcementLayout() { {mfaCompliance?.deadline_at && (

- Deadline: {new Date(mfaCompliance.deadline_at).toLocaleDateString()} + Deadline: {formatDate(mfaCompliance.deadline_at)}

)} diff --git a/src/components/layouts/PublicLayout.tsx b/src/components/layouts/PublicLayout.tsx index 5605e4d..507e530 100644 --- a/src/components/layouts/PublicLayout.tsx +++ b/src/components/layouts/PublicLayout.tsx @@ -1,5 +1,5 @@ import { Outlet, Link } from "react-router-dom"; -import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; +import { SecuirdLogo } from "@/components/branding/SecuirdLogo"; export default function PublicLayout() { return ( @@ -11,8 +11,8 @@ export default function PublicLayout() {
- - Gatehouse + + Secuird
@@ -28,7 +28,7 @@ export default function PublicLayout() {

- © {new Date().getFullYear()} Gatehouse. Identity & Access. + © {new Date().getFullYear()} Secuird. Identity & Access.

diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index f3ac838..ed9a78d 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -17,8 +17,9 @@ import { Network, Monitor, ShieldAlert, + BookOpen, } from "lucide-react"; -import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; +import { SecuirdLogo } from "@/components/branding/SecuirdLogo"; import { NavLink } from "@/components/NavLink"; import { useAuth } from "@/contexts/AuthContext"; import { @@ -38,10 +39,11 @@ import { cn } from "@/lib/utils"; const userNavItems = [ { title: "Profile", url: "/profile", icon: User }, - { title: "Security", url: "/security", icon: Shield }, + { title: "Security", url: "/account/security", icon: Shield }, { title: "SSH Keys", url: "/ssh-keys", icon: Terminal }, { title: "Linked Accounts", url: "/linked-accounts", icon: Link2 }, { title: "Activity", url: "/activity", icon: Activity }, + { title: "CLI Guide", url: "/cli-guide", icon: BookOpen }, ]; // Visible to ALL org members @@ -57,23 +59,26 @@ const orgAdminNavItems = [ { title: "Members", url: "/org/members", icon: Users }, { title: "Departments", url: "/org/departments", icon: Layers }, { title: "Principals", url: "/org/principals", icon: GitBranch }, + { title: "API Keys", url: "/org/api-keys", icon: Key }, { title: "Policies", url: "/org/policies", icon: Settings }, { title: "ZeroTier Networks", url: "/org/zerotier/networks", icon: Network }, { title: "ZeroTier Access", url: "/org/zerotier/access", icon: ShieldAlert }, + { title: "ZeroTier Config", url: "/org/zerotier/config", icon: Settings }, ]; const adminNavItems = [ { title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck }, { title: "OIDC Clients", url: "/org/clients", icon: Key }, { title: "Org Audit Log", url: "/org/audit", icon: FileText }, - { title: "System Logs", url: "/admin/audit", icon: ScrollText }, ]; +const systemLogNavItem = { title: "System Logs", url: "/admin/audit", icon: ScrollText }; + export function AppSidebar() { const { state } = useSidebar(); const collapsed = state === "collapsed"; const location = useLocation(); - const { isOrgAdmin, isOrgMember } = useAuth(); + const { isOrgAdmin, isOrgMember, canViewSystemLogs } = useAuth(); const isActive = (path: string) => location.pathname === path; const isOrgActive = orgAdminNavItems.some((item) => isActive(item.url)) || adminNavItems.some((item) => isActive(item.url)); @@ -90,10 +95,10 @@ export function AppSidebar() { {/* Logo */}
- + {!collapsed && ( - Gatehouse + Secuird )}
@@ -180,7 +185,7 @@ export function AppSidebar() { )} - {adminNavItems.map((item) => ( + {[...adminNavItems, ...(canViewSystemLogs ? [systemLogNavItem] : [])].map((item) => ( {!collapsed && (
- v1.0.0 • Self-hosted + {import.meta.env.VITE_APP_VERSION ?? 'Secuird'}
)} diff --git a/src/components/security/TotpEnrollmentWizard.tsx b/src/components/security/TotpEnrollmentWizard.tsx index 2120b83..3bed6cd 100644 --- a/src/components/security/TotpEnrollmentWizard.tsx +++ b/src/components/security/TotpEnrollmentWizard.tsx @@ -275,7 +275,7 @@ export function TotpEnrollmentWizard({

- 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.

diff --git a/src/config.ts b/src/config.ts index cfe8adb..0849a36 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -// Gatehouse Configuration +// Secuird Configuration // Environment-specific settings for the application export const config = { @@ -9,7 +9,7 @@ export const config = { // App metadata app: { - name: "Gatehouse", + name: "Secuird", description: "Identity & Access Platform", }, diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 4eb6068..883ce89 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -17,6 +17,8 @@ interface AuthContextType { isAuthenticated: boolean; isOrgAdmin: boolean; isOrgMember: boolean; + /** True when the current user is allowed to view the system-wide audit log. */ + canViewSystemLogs: boolean; mfaCompliance: MfaComplianceSummary | null; requiresMfaEnrollment: boolean; login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise; @@ -32,7 +34,7 @@ interface AuthContextType { const AuthContext = createContext(null); // LocalStorage key for MFA compliance persistence -const MFA_COMPLIANCE_KEY = 'gatehouse_mfa_compliance'; +const MFA_COMPLIANCE_KEY = 'secuird_mfa_compliance'; // Helper to persist MFA compliance to localStorage function persistMfaCompliance(compliance: MfaComplianceSummary | null): void { @@ -265,7 +267,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { await checkOrgAdmin(); if (!skipNavigate) { - navigate('/profile'); + const orgsData = await api.users.organizations(); + const hasOrg = orgsData.organizations && orgsData.organizations.length > 0; + + if (hasOrg) { + navigate('/profile'); + } else { + navigate('/org-setup'); + } } }, [navigate, checkOrgAdmin]); @@ -291,6 +300,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: !!user, isOrgAdmin, isOrgMember, + canViewSystemLogs: user?.can_view_system_logs ?? false, mfaCompliance, requiresMfaEnrollment, login, diff --git a/src/index.css b/src/index.css index 43a05a4..b9fbffd 100644 --- a/src/index.css +++ b/src/index.css @@ -4,79 +4,80 @@ @tailwind components; @tailwind utilities; -/* Gatehouse Design System - Enterprise Identity & Access Platform +/* Secuird Design System - Enterprise Identity & Access Platform Authoritative, infrastructure-grade aesthetic with slate/charcoal/muted blue palette Colors are HSL for theming flexibility */ @layer base { :root { - /* Core palette - Deep slate with teal accent */ - --background: 210 20% 98%; - --foreground: 222 47% 11%; + /* Core palette - Light blue-gray with teal accent */ + --background: 216 22% 94%; /* cool blue-gray — cards lift clearly off this */ + --foreground: 222 47% 9%; /* near-black navy */ - --card: 0 0% 100%; - --card-foreground: 222 47% 11%; + --card: 0 0% 100%; /* pure white — 6% lightness gap over bg */ + --card-foreground: 222 47% 9%; --popover: 0 0% 100%; - --popover-foreground: 222 47% 11%; + --popover-foreground: 222 47% 9%; - /* Primary - Deep navy for trust */ - --primary: 222 47% 20%; - --primary-foreground: 210 40% 98%; + /* Primary — teal, fully saturated, dark enough to read on white */ + --primary: 173 65% 36%; + --primary-foreground: 0 0% 100%; - /* Secondary - Soft slate */ - --secondary: 215 20% 95%; - --secondary-foreground: 222 47% 20%; + /* Secondary — cool blue-gray, clearly darker than bg */ + --secondary: 216 20% 91%; + --secondary-foreground: 222 47% 18%; - /* Muted - Subtle backgrounds */ - --muted: 215 20% 96%; - --muted-foreground: 215 16% 47%; + /* Muted — noticeably darker than secondary, used for section bg */ + --muted: 216 18% 88%; + --muted-foreground: 222 18% 42%; - /* Accent - Teal for actions and highlights */ - --accent: 173 58% 39%; + /* Accent — same teal as primary */ + --accent: 173 65% 36%; --accent-foreground: 0 0% 100%; - /* Semantic colors */ - --destructive: 0 72% 51%; + /* Semantic */ + --destructive: 0 72% 48%; --destructive-foreground: 0 0% 100%; - --success: 152 69% 31%; + --success: 152 60% 30%; --success-foreground: 0 0% 100%; - --warning: 38 92% 50%; + --warning: 38 90% 48%; --warning-foreground: 0 0% 100%; - --info: 199 89% 48%; + --info: 199 80% 44%; --info-foreground: 0 0% 100%; - /* UI elements */ - --border: 214 32% 91%; - --input: 214 32% 91%; - --ring: 173 58% 39%; + /* UI chrome */ + --border: 216 18% 84%; /* clearly visible on white card */ + --input: 216 18% 92%; + --ring: 173 65% 36%; --radius: 0.5rem; - /* Sidebar - Darker for visual hierarchy */ - --sidebar-background: 222 47% 11%; - --sidebar-foreground: 215 20% 85%; - --sidebar-primary: 173 58% 45%; + /* Sidebar */ + --sidebar-background: 222 30% 95%; + --sidebar-foreground: 222 47% 18%; + --sidebar-primary: 173 65% 36%; --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 222 40% 18%; - --sidebar-accent-foreground: 210 40% 98%; - --sidebar-border: 222 40% 20%; - --sidebar-ring: 173 58% 45%; - --sidebar-muted: 215 16% 55%; + --sidebar-accent: 216 20% 88%; + --sidebar-accent-foreground: 222 47% 9%; + --sidebar-border: 216 18% 84%; + --sidebar-ring: 173 65% 36%; + --sidebar-muted: 222 20% 48%; - /* Custom gradients and effects */ - --gradient-brand: linear-gradient(135deg, hsl(222 47% 20%), hsl(222 47% 11%)); - --gradient-accent: linear-gradient(135deg, hsl(173 58% 39%), hsl(173 58% 32%)); - --gradient-subtle: linear-gradient(135deg, hsl(210 20% 98%), hsl(215 20% 96%)); - - --shadow-sm: 0 1px 2px 0 hsl(222 47% 11% / 0.05); - --shadow-md: 0 4px 6px -1px hsl(222 47% 11% / 0.1), 0 2px 4px -2px hsl(222 47% 11% / 0.1); - --shadow-lg: 0 10px 15px -3px hsl(222 47% 11% / 0.1), 0 4px 6px -4px hsl(222 47% 11% / 0.1); - --shadow-card: 0 1px 3px 0 hsl(222 47% 11% / 0.06), 0 1px 2px -1px hsl(222 47% 11% / 0.06); + /* Gradients */ + --gradient-brand: linear-gradient(135deg, hsl(173 65% 36%), hsl(173 65% 28%)); + --gradient-accent: linear-gradient(135deg, hsl(173 65% 36%), hsl(173 65% 28%)); + --gradient-subtle: linear-gradient(135deg, hsl(216 28% 97%), hsl(216 18% 93%)); + + /* Shadows — stronger alpha so cards lift off the bg */ + --shadow-sm: 0 1px 2px 0 hsl(222 47% 9% / 0.10); + --shadow-md: 0 4px 6px -1px hsl(222 47% 9% / 0.14), 0 2px 4px -2px hsl(222 47% 9% / 0.10); + --shadow-lg: 0 10px 15px -3px hsl(222 47% 9% / 0.14), 0 4px 6px -4px hsl(222 47% 9% / 0.10); + --shadow-card: 0 2px 6px 0 hsl(222 47% 9% / 0.10), 0 1px 2px -1px hsl(222 47% 9% / 0.08); } .dark { diff --git a/src/lib/api.ts b/src/lib/api.ts index f7aaa48..50fc380 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -// API Client for Gatehouse Backend +// API Client for Secuird Backend // Uses Bearer token authentication import { config } from '@/config'; @@ -33,6 +33,10 @@ export interface User { has_password?: boolean; totp_enabled?: boolean; linked_providers?: string[]; + /** Session-derived group memberships (from OIDC claims or session device_info). */ + groups?: string[]; + /** Whether the current user is allowed to access the system-wide audit log. */ + can_view_system_logs?: boolean; } export interface Organization { @@ -244,6 +248,38 @@ export interface LinkAccountResponse { linked_account: LinkedAccount; } +export interface OrganizationApiKey { + id: string; + organization_id: string; + name: string; + description: string | null; + key_hash?: string; // Usually excluded from responses for security + last_used_at: string | null; + is_revoked: boolean; + revoked_at: string | null; + revoke_reason: string | null; + created_at: string; + updated_at: string; +} + +export interface CertificateAuditLog { + id: string; + action: string; + certificate_serial: string; + key_id: string; + principals: string[]; + user_id: string; + user_email: string | null; + issued_at: string; + valid_after: string; + valid_before: string; + ip_address: string | null; + user_agent: string | null; + message: string | null; + success: boolean; + created_at: string; +} + class ApiError extends Error { code: number; type: string; @@ -259,8 +295,8 @@ class ApiError extends Error { } // Token storage keys -const TOKEN_KEY = 'gatehouse_token'; -const TOKEN_EXPIRY_KEY = 'gatehouse_token_expiry'; +const TOKEN_KEY = 'secuird_token'; +const TOKEN_EXPIRY_KEY = 'secuird_token_expiry'; // Token management export const tokenManager = { @@ -938,7 +974,7 @@ export const api = { // Get organization audit logs getAuditLogs: (orgId: string, params?: Record, requestConfig?: RequestConfig) => - request<{ audit_logs: AuditLogEntry[]; count: number }>( + request<{ audit_logs: AuditLogEntry[]; count: number; page: number; per_page: number; pages: number }>( `/organizations/${orgId}/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`, {}, true, @@ -950,14 +986,14 @@ export const api = { request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/departments`, {}, true, requestConfig), // Create department - createDepartment: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) => + createDepartment: (orgId: string, name: string, description?: string, canSudo?: boolean, requestConfig?: RequestConfig) => request<{ department: Department }>(`/organizations/${orgId}/departments`, { method: 'POST', - body: JSON.stringify({ name, description }), + body: JSON.stringify({ name, description, can_sudo: canSudo }), }, true, requestConfig), // Update department - updateDepartment: (orgId: string, deptId: string, data: { name?: string; description?: string }, requestConfig?: RequestConfig) => + updateDepartment: (orgId: string, deptId: string, data: { name?: string; description?: string; can_sudo?: boolean }, requestConfig?: RequestConfig) => request<{ department: Department }>(`/organizations/${orgId}/departments/${deptId}`, { method: 'PATCH', body: JSON.stringify(data), @@ -1081,6 +1117,13 @@ export const api = { method: 'DELETE', }, true, requestConfig), + // Update OIDC client (name and/or redirect_uris) + updateClient: (orgId: string, clientId: string, data: { name?: string; redirect_uris?: string[] }, requestConfig?: RequestConfig) => + request<{ client: OIDCClient }>(`/organizations/${orgId}/clients/${clientId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }, true, requestConfig), + // Send MFA reminder to a member sendMfaReminder: (orgId: string, userId: string, requestConfig?: RequestConfig) => request<{ message: string }>(`/organizations/${orgId}/members/${userId}/send-mfa-reminder`, { @@ -1126,6 +1169,39 @@ export const api = { request<{ ca_id: string }>(`/organizations/${orgId}/cas/${caId}`, { method: 'DELETE', }, true, requestConfig), + + // Get API keys for organization + getApiKeys: (orgId: string, requestConfig?: RequestConfig) => + request<{ api_keys: OrganizationApiKey[]; count: number }>(`/organizations/${orgId}/api-keys`, {}, true, requestConfig), + + // Create new API key + createApiKey: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) => + request<{ api_key: OrganizationApiKey & { key?: string } }>(`/organizations/${orgId}/api-keys`, { + method: 'POST', + body: JSON.stringify({ name, description }), + }, true, requestConfig), + + // Update API key + updateApiKey: (orgId: string, keyId: string, data: { name?: string; description?: string }, requestConfig?: RequestConfig) => + request<{ api_key: OrganizationApiKey }>(`/organizations/${orgId}/api-keys/${keyId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }, true, requestConfig), + + // Delete API key + deleteApiKey: (orgId: string, keyId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/api-keys/${keyId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Get certificate audit logs for organization + getCertificateAuditLogs: (orgId: string, params?: Record, requestConfig?: RequestConfig) => + request<{ audit_logs: CertificateAuditLog[]; count: number; page: number; per_page: number; pages: number }>( + `/organizations/${orgId}/certificates/audit${params ? '?' + new URLSearchParams(params).toString() : ''}`, + {}, + true, + requestConfig + ), }, invites: { @@ -1301,6 +1377,14 @@ export const api = { { method: "DELETE" }, true, requestConfig, ), + /** List all ZeroTier networks from the org's controller/account, annotated + * with whether each is already managed as a portal network. */ + listAvailableZtNetworks: (orgId: string, requestConfig?: RequestConfig) => + request<{ networks: AvailableZtNetwork[]; count: number; zt_error?: string }>( + `/organizations/${orgId}/zerotier/available-networks`, + {}, true, requestConfig, + ), + getNetworkMembers: (orgId: string, networkId: string, requestConfig?: RequestConfig) => request<{ memberships: DeviceNetworkMembership[]; count: number }>( `/organizations/${orgId}/networks/${networkId}/members`, @@ -1373,6 +1457,12 @@ export const api = { `/organizations/${orgId}/approvals`, {}, true, requestConfig, ), + adminListAllApprovals: (orgId: string, networkId?: string, state?: string, requestConfig?: RequestConfig) => + request<{ approvals: UserNetworkApproval[]; count: number }>( + `/organizations/${orgId}/admin/approvals${networkId || state ? `?${new URLSearchParams(Object.fromEntries(Object.entries({ network_id: networkId, state }).filter(([, v]) => v != null) as [string, string][]))}` : ""}`, + {}, true, requestConfig, + ), + listPendingApprovals: (orgId: string, networkId?: string, requestConfig?: RequestConfig) => request<{ approvals: UserNetworkApproval[]; count: number }>( `/organizations/${orgId}/approvals/pending${networkId ? `?network_id=${networkId}` : ""}`, @@ -1489,25 +1579,25 @@ export const api = { true, requestConfig, ), - // ── ZeroTier Controller (admin) ────────────────────────────────────────── - getZtStatus: (requestConfig?: RequestConfig) => + // ── ZeroTier Controller (org-scoped admin) ───────────────────────────────── + getZtStatus: (orgId: string, requestConfig?: RequestConfig) => request<{ status: Record }>( - "/admin/zerotier/status", {}, true, requestConfig, + `/admin/zerotier/status?org_id=${orgId}`, {}, true, requestConfig, ), - listZtNetworks: (requestConfig?: RequestConfig) => + listZtNetworks: (orgId: string, requestConfig?: RequestConfig) => request<{ networks: ZeroTierNetwork[]; count: number }>( - "/admin/zerotier/networks", {}, true, requestConfig, + `/admin/zerotier/networks?org_id=${orgId}`, {}, true, requestConfig, ), - getZtNetwork: (networkId: string, requestConfig?: RequestConfig) => + getZtNetwork: (orgId: string, networkId: string, requestConfig?: RequestConfig) => request<{ network: ZeroTierNetwork }>( - `/admin/zerotier/networks/${networkId}`, {}, true, requestConfig, + `/admin/zerotier/networks/${networkId}?org_id=${orgId}`, {}, true, requestConfig, ), - listZtMembers: (networkId: string, requestConfig?: RequestConfig) => + listZtMembers: (orgId: string, networkId: string, requestConfig?: RequestConfig) => request<{ members: ZeroTierMember[]; count: number }>( - `/admin/zerotier/networks/${networkId}/members`, {}, true, requestConfig, + `/admin/zerotier/networks/${networkId}/members?org_id=${orgId}`, {}, true, requestConfig, ), triggerReconciliation: (requestConfig?: RequestConfig) => @@ -1515,6 +1605,26 @@ export const api = { "/admin/zerotier/reconcile", { method: "POST" }, true, requestConfig, ), + + // ── Per-org ZeroTier config ────────────────────────────────────────────── + getOrgZtConfig: (orgId: string, requestConfig?: RequestConfig) => + request<{ zerotier_config: ZeroTierOrgConfig }>( + `/organizations/${orgId}/zerotier-config`, + {}, true, requestConfig, + ), + + setOrgZtConfig: (orgId: string, data: ZeroTierOrgConfigInput, requestConfig?: RequestConfig) => + request<{ zerotier_config: ZeroTierOrgConfig; connectivity_test: { ok: boolean; error: string | null } }>( + `/organizations/${orgId}/zerotier-config`, + { method: "PUT", body: JSON.stringify(data) }, + true, requestConfig, + ), + + deleteOrgZtConfig: (orgId: string, requestConfig?: RequestConfig) => + request<{ message: string }>( + `/organizations/${orgId}/zerotier-config`, + { method: "DELETE" }, true, requestConfig, + ), }, }; @@ -1553,6 +1663,7 @@ export interface Department { organization_id: string; name: string; description: string | null; + can_sudo: boolean; created_at: string; updated_at: string; deleted_at: string | null; @@ -1824,6 +1935,21 @@ export interface PortalNetwork { active_membership_count?: number; } +/** A ZeroTier network returned from the controller, annotated with whether + * it is already managed as a portal network in Secuird. */ +export interface AvailableZtNetwork { + id: string; + name: string; + description: string | null; + owner_id: string | null; + online_member_count: number; + authorized_member_count: number; + total_member_count: number; + already_managed: boolean; + portal_network_id: string | null; + portal_network_name: string | null; +} + export interface Device { id: string; user_id: string; @@ -1968,4 +2094,21 @@ export interface ZeroTierNetwork { ip_assignment_pools: Record[]; routes: Record[]; }; +} + +/** 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"; } \ No newline at end of file diff --git a/src/lib/date.ts b/src/lib/date.ts new file mode 100644 index 0000000..30717bb --- /dev/null +++ b/src/lib/date.ts @@ -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); +} diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx index c378830..efa17e5 100644 --- a/src/pages/admin/AdminUsersPage.tsx +++ b/src/pages/admin/AdminUsersPage.tsx @@ -61,7 +61,8 @@ import { useAuth } from "@/contexts/AuthContext"; function formatDate(d: string | null) { if (!d) return "—"; - return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); + const raw = !(d.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(d)) ? d + "Z" : d; + return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(new Date(raw)); } function capitalize(s: string) { diff --git a/src/pages/admin/SystemAuditPage.tsx b/src/pages/admin/SystemAuditPage.tsx index dced7e4..378fe4e 100644 --- a/src/pages/admin/SystemAuditPage.tsx +++ b/src/pages/admin/SystemAuditPage.tsx @@ -6,23 +6,21 @@ import { ChevronLeft, ChevronRight, LogIn, - LogOut, Key, UserPlus, Shield, Settings, AlertTriangle, - Fingerprint, - Smartphone, Terminal, Loader2, CheckCircle2, XCircle, Globe, + Lock, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Select, @@ -31,25 +29,28 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { api, AuditLogEntry } from "@/lib/api"; +import { api, AuditLogEntry, ApiError } from "@/lib/api"; +import { formatDateTime } from "@/lib/date"; // ─── category helpers ──────────────────────────────────────────────────────── -type Category = "auth" | "ssh" | "org" | "user" | "security" | "token" | "other"; +type Category = "auth" | "ssh" | "org" | "user" | "security" | "token" | "admin" | "other"; const getCategory = (action: string): Category => { const a = action.toLowerCase(); - if (a.startsWith("session") || a.includes("login") || a.includes("logout") || a.includes("external_auth")) + if (a.startsWith("session") || a === "user.login" || a === "user.logout" || a.startsWith("external_auth.login")) return "auth"; if (a.startsWith("ssh")) return "ssh"; + if (a.startsWith("admin.")) + return "admin"; if (a.startsWith("org") || a.includes("member") || a.includes("department") || a.includes("invite")) return "org"; if (a.startsWith("user")) return "user"; if (a.includes("mfa") || a.includes("totp") || a.includes("webauthn") || a.includes("passkey") || a.includes("password")) return "security"; - if (a.includes("token") || a.includes("oidc") || a.includes("client")) + if (a.includes("token") || a.includes("oidc") || a.includes("client") || a.startsWith("external_auth")) return "token"; return "other"; }; @@ -61,6 +62,7 @@ const CATEGORY_META: Record = { user: { label: "User", color: "bg-amber-500/10 text-amber-600 dark:text-amber-400" }, security: { label: "Security", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400" }, token: { label: "Token", color: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400" }, + admin: { label: "Admin", color: "bg-red-500/10 text-red-600 dark:text-red-400" }, other: { label: "Other", color: "bg-muted text-muted-foreground" }, }; @@ -73,6 +75,7 @@ const getCategoryIcon = (category: Category) => { case "user": return ; case "security": return ; case "token": return ; + case "admin": return ; default: return ; } }; @@ -86,26 +89,42 @@ const getActionLabel = (action: string) => // ─── component ─────────────────────────────────────────────────────────────── const ACTION_FILTER_OPTIONS = [ - { value: "all", label: "All actions" }, - { value: "SESSION_CREATE", label: "Login" }, - { value: "SESSION_REVOKE", label: "Logout" }, - { value: "EXTERNAL_AUTH_LOGIN", label: "OAuth Login" }, - { value: "EXTERNAL_AUTH_LOGIN_FAILED", label: "OAuth Failed" }, - { value: "USER_REGISTER", label: "Register" }, - { value: "SSH_KEY_ADDED", label: "SSH Key Added" }, - { value: "SSH_KEY_VERIFIED", label: "SSH Key Verified" }, - { value: "SSH_CERT_ISSUED", label: "SSH Cert Issued" }, - { value: "SSH_CERT_REVOKED", label: "SSH Cert Revoked" }, - { value: "SSH_CERT_FAILED", label: "SSH Cert Failed" }, - { value: "ORG_CREATE", label: "Org Created" }, - { value: "ORG_MEMBER_ADD", label: "Member Added" }, - { value: "ORG_MEMBER_ROLE_CHANGE", label: "Role Changed" }, + { value: "all", label: "All actions" }, + { value: "session.create", label: "Login" }, + { value: "session.revoke", label: "Logout" }, + { value: "external_auth.login", label: "OAuth Login" }, + { value: "external_auth.login.failed", label: "OAuth Failed" }, + { value: "external_auth.link.completed", label: "OAuth Account Linked" }, + { value: "external_auth.unlink", label: "OAuth Account Unlinked" }, + { value: "user.register", label: "Register" }, + { value: "ssh.key.added", label: "SSH Key Added" }, + { value: "ssh.key.verified", label: "SSH Key Verified" }, + { value: "ssh.key.deleted", label: "SSH Key Deleted" }, + { value: "ssh.cert.issued", label: "SSH Cert Issued" }, + { value: "ssh.cert.revoked", label: "SSH Cert Revoked" }, + { value: "ssh.cert.failed", label: "SSH Cert Failed" }, + { value: "org.create", label: "Org Created" }, + { value: "org.member.add", label: "Member Added" }, + { value: "org.member.remove", label: "Member Removed" }, + { value: "org.member.role_change", label: "Role Changed" }, + { value: "org.security_policy.update", label: "Security Policy Updated" }, + { value: "admin.mfa.remove", label: "MFA Removed (Admin)" }, + { value: "admin.oauth.unlink", label: "OAuth Unlinked (Admin)" }, + { value: "admin.password.set", label: "Password Set (Admin)" }, + { value: "totp.enroll.completed", label: "TOTP Enrolled" }, + { value: "totp.disabled", label: "TOTP Disabled" }, + { value: "webauthn.register.completed", label: "Passkey Registered" }, + { value: "webauthn.credential.deleted", label: "Passkey Removed" }, + { value: "user.password_change", label: "Password Changed" }, + { value: "user.password_reset", label: "Password Reset" }, + { value: "user.suspend", label: "User Suspended" }, ]; export default function SystemAuditPage() { const [logs, setLogs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [accessDenied, setAccessDenied] = useState(false); const [isAdminView, setIsAdminView] = useState(false); // filters @@ -129,6 +148,7 @@ export default function SystemAuditPage() { const fetchLogs = useCallback(async () => { setIsLoading(true); setError(null); + setAccessDenied(false); try { const params: Record = { page: String(page), @@ -144,8 +164,12 @@ export default function SystemAuditPage() { setTotalPages(resp.pages ?? 1); setIsAdminView(resp.is_admin_view ?? false); } catch (err) { - console.error("Failed to fetch system audit logs:", err); - setError("Failed to load audit logs. Please try again."); + if (err instanceof ApiError && err.code === 403) { + setAccessDenied(true); + } else { + console.error("Failed to fetch system audit logs:", err); + setError("Failed to load audit logs. Please try again."); + } } finally { setIsLoading(false); } @@ -160,17 +184,7 @@ export default function SystemAuditPage() { setPage(1); }, [actionFilter, successFilter, debouncedSearch]); - const formatDate = (dateString: string) => { - const d = new Date(dateString); - return new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: false, - }).format(d); - }; + const formatDate = (dateString: string) => formatDateTime(dateString); const formatUserAgent = (ua: string | null) => { if (!ua) return null; @@ -244,6 +258,14 @@ export default function SystemAuditPage() { Loading…
+ ) : accessDenied ? ( +
+ +

Access Restricted

+

+ You don't have permission to view system-wide audit logs. Contact your administrator to request access. +

+
) : error ? (
diff --git a/src/pages/auth/ActivatePage.tsx b/src/pages/auth/ActivatePage.tsx index 50f21fe..00770c5 100644 --- a/src/pages/auth/ActivatePage.tsx +++ b/src/pages/auth/ActivatePage.tsx @@ -3,7 +3,7 @@ import { useSearchParams, useNavigate } from "react-router-dom"; import { CheckCircle, XCircle, Loader2, Mail } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; +import { SecuirdLogo } from "@/components/branding/SecuirdLogo"; import { api, ApiError } from "@/lib/api"; type Status = "loading" | "success" | "error" | "missing"; @@ -42,7 +42,7 @@ export default function ActivatePage() {
{/* Logo */}
- +
diff --git a/src/pages/auth/InviteAcceptPage.tsx b/src/pages/auth/InviteAcceptPage.tsx index 85d41a9..19b8db1 100644 --- a/src/pages/auth/InviteAcceptPage.tsx +++ b/src/pages/auth/InviteAcceptPage.tsx @@ -61,7 +61,7 @@ export default function InviteAcceptPage() { const result = await api.invites.accept(token, name || undefined, inviteData?.user_exists ? undefined : password); if (result.token) { // Store the token manually since we're not using the normal login flow - localStorage.setItem("gatehouse_token", result.token); + localStorage.setItem("secuird_token", result.token); } navigate("/profile"); } catch (err: unknown) { @@ -127,7 +127,7 @@ export default function InviteAcceptPage() {

Account found

-

You already have a Gatehouse account. Click below to join the organization.

+

You already have a Secuird account. Click below to join the organization.

) : ( diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index ecdfdb8..6d08970 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -27,8 +27,8 @@ import { OAuthProvider } from "@/lib/oauth"; type LoginStep = 'credentials' | 'totp' | 'webauthn' | 'passkey-email' | 'mfa-enrollment' | 'mfa'; -const GATEHOUSE_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'; -const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); +const SECUIRD_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'; +const SECUIRD_OIDC = SECUIRD_API.replace(/\/api\/v1\/?$/, ''); /** * Complete an OIDC authorization flow after the user has authenticated. @@ -36,7 +36,7 @@ const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); * the auth code and returns the redirect URL for the calling application. */ async function completeOidcFlow(oidcSessionId: string, token: string): Promise { - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), @@ -49,7 +49,7 @@ async function completeOidcFlow(oidcSessionId: string, token: string): Promise(null); // OIDC bridge: if oidc_session_id is in the URL, we're acting as the - // login UI for an OIDC authorization flow (e.g. SecuIRD → Gatehouse). + // login UI for an OIDC authorization flow (e.g. SecuIRD → Secuird). // After successful login, call /oidc/complete and redirect to the client app. const oidcSessionId = searchParams.get('oidc_session_id'); const oidcError = searchParams.get('error'); // CLI bridge: if cli_token or cli_redirect is present the login was triggered - // by the Gatehouse CLI tool. After successful auth the token is delivered + // by the Secuird CLI tool. After successful auth the token is delivered // directly to the CLI's local callback server. const cliToken = searchParams.get('cli_token'); const cliRedirectParam = searchParams.get('cli_redirect'); @@ -81,7 +81,7 @@ export default function LoginPage() { useEffect(() => { if (!cliToken || cliFetchedRef.current) return; cliFetchedRef.current = true; - fetch(`${GATEHOUSE_API}/cli/redirect-url?token=${encodeURIComponent(cliToken)}`) + fetch(`${SECUIRD_API}/cli/redirect-url?token=${encodeURIComponent(cliToken)}`) .then((r) => r.json()) .then((body) => { if (body?.data?.redirect_url) { @@ -165,7 +165,7 @@ export default function LoginPage() { // MFA enrollment required - will be handled by ProtectedLayout // Navigation happens in AuthContext (MFA path always navigates) } else if (oidcSessionId) { - // OIDC bridge: send token back to the Gatehouse backend to complete the flow + // OIDC bridge: send token back to the Secuird backend to complete the flow const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); } else if (cliRedirectUrl) { @@ -176,7 +176,7 @@ export default function LoginPage() { // Normal login: navigation already handled by AuthContext (skipNavigate=false) } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] Login failed:", error); + console.error("[Secuird] Login failed:", error); } const message = error instanceof ApiError @@ -230,7 +230,14 @@ export default function LoginPage() { finishCliFlow(response.token); } else { await refreshUser(); - navigate('/profile'); + const orgsData = await api.users.organizations(); + const hasOrg = orgsData.organizations && orgsData.organizations.length > 0; + + if (hasOrg) { + navigate('/profile'); + } else { + navigate('/org-setup'); + } } } else { // Fallback to regular TOTP verification @@ -246,7 +253,7 @@ export default function LoginPage() { } } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] MFA verification failed:", error); + console.error("[Secuird] MFA verification failed:", error); } const message = error instanceof ApiError @@ -294,7 +301,7 @@ export default function LoginPage() { // Normal login: navigation already handled by AuthContext (skipNavigate=false) } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] TOTP verification failed:", error); + console.error("[Secuird] TOTP verification failed:", error); } const message = error instanceof ApiError @@ -347,6 +354,8 @@ export default function LoginPage() { // Token is stored by completeLogin, refresh user and navigate await refreshUser(); + await checkOrgAdmin(); + if (oidcSessionId) { const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); @@ -354,7 +363,10 @@ export default function LoginPage() { const token = tokenManager.getToken(); if (token) finishCliFlow(token); } else { - navigate('/profile'); + // Verify org membership before navigating to prevent showing org-setup briefly + const orgsData = await api.users.organizations(); + const hasOrg = orgsData.organizations && orgsData.organizations.length > 0; + navigate(hasOrg ? '/profile' : '/org-setup'); } toast({ @@ -363,7 +375,7 @@ export default function LoginPage() { }); } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] Passkey login failed:", error); + console.error("[Secuird] Passkey login failed:", error); } let message = "Failed to sign in with passkey"; @@ -430,7 +442,11 @@ export default function LoginPage() { if (token) finishCliFlow(token); } else { await refreshUser(); - navigate('/profile'); + await checkOrgAdmin(); + // Verify org membership before navigating to prevent showing org-setup briefly + const orgsData = await api.users.organizations(); + const hasOrg = orgsData.organizations && orgsData.organizations.length > 0; + navigate(hasOrg ? '/profile' : '/org-setup'); toast({ title: "Welcome back", description: `Signed in as ${result.user.email}`, @@ -438,7 +454,7 @@ export default function LoginPage() { } } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] WebAuthn verification failed:", error); + console.error("[Secuird] WebAuthn verification failed:", error); } let message = "Failed to verify passkey"; @@ -518,7 +534,7 @@ export default function LoginPage() { } catch (error) { if (import.meta.env.DEV) { - console.error("[Gatehouse] OAuth login failed:", error); + console.error("[Secuird] OAuth login failed:", error); } let message = `Failed to initiate ${provider} sign in`; @@ -939,7 +955,7 @@ export default function LoginPage() {

{cliRedirectUrl - ? "Sign in to grant the Gatehouse CLI access to your account" + ? "Sign in to grant the Secuird CLI access to your account" : oidcSessionId ? "An application is requesting access to your account" : "Sign in to your account to continue"} diff --git a/src/pages/auth/OAuthCallbackPage.tsx b/src/pages/auth/OAuthCallbackPage.tsx index a2a7332..ef5c317 100644 --- a/src/pages/auth/OAuthCallbackPage.tsx +++ b/src/pages/auth/OAuthCallbackPage.tsx @@ -9,11 +9,11 @@ import { useToast } from "@/hooks/use-toast"; type CallbackState = 'loading' | 'success' | 'error'; -const GATEHOUSE_API = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1') as string; -const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); +const SECUIRD_API = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1') as string; +const SECUIRD_OIDC = SECUIRD_API.replace(/\/api\/v1\/?$/, ''); async function completeOidcFlow(oidcSessionId: string, token: string): Promise { - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), @@ -24,7 +24,7 @@ async function completeOidcFlow(oidcSessionId: string, token: string): Promise = { openid: { icon: Shield, label: "OpenID", description: "Verify your identity" }, @@ -41,7 +41,7 @@ export default function OIDCConsentPage() { (async () => { try { - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/begin`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/begin`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ oidc_session_id: oidcSessionId }), @@ -67,7 +67,7 @@ export default function OIDCConsentPage() { navigate(`/login?oidc_session_id=${context.oidc_session_id}`); return; } - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ oidc_session_id: context.oidc_session_id, token }), diff --git a/src/pages/auth/OIDCLoginPage.tsx b/src/pages/auth/OIDCLoginPage.tsx index 4f8434c..0049c33 100644 --- a/src/pages/auth/OIDCLoginPage.tsx +++ b/src/pages/auth/OIDCLoginPage.tsx @@ -1,7 +1,7 @@ /** * OIDCLoginPage — Standalone OIDC proxy login UI * - * Unified entry point for OIDC authorization flows via the Gatehouse OIDC bridge. + * Unified entry point for OIDC authorization flows via the Secuird OIDC bridge. * Handles: * 1. Unauthenticated users → shows an email/password login form * 2. Already-authenticated users → shows a consent/approval screen directly @@ -9,7 +9,7 @@ * Route: /oidc-login?oidc_session_id= * * Configure your oauth2-proxy / OIDC client's login_url to: - * https:///oidc-login + * https:///oidc-login */ import { useState, useEffect, useCallback } from "react"; import { useSearchParams, useNavigate } from "react-router-dom"; @@ -37,7 +37,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { ApiError, tokenManager } from "@/lib/api"; // ── Configuration ───────────────────────────────────────────────────────────── -const GATEHOUSE_OIDC = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1") +const SECUIRD_OIDC = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1") .replace(/\/api\/v1\/?$/, ""); // ── Scope display metadata ──────────────────────────────────────────────────── @@ -62,7 +62,7 @@ type PageStep = "loading" | "login" | "consent" | "error"; // ── API helpers ─────────────────────────────────────────────────────────────── async function fetchOIDCContext(oidcSessionId: string): Promise { - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/begin`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/begin`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ oidc_session_id: oidcSessionId }), @@ -75,7 +75,7 @@ async function fetchOIDCContext(oidcSessionId: string): Promise { } async function completeOIDCFlow(oidcSessionId: string, token: string): Promise { - const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + const res = await fetch(`${SECUIRD_OIDC}/oidc/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), diff --git a/src/pages/auth/OrgSetupPage.tsx b/src/pages/auth/OrgSetupPage.tsx index b303442..1bf3060 100644 --- a/src/pages/auth/OrgSetupPage.tsx +++ b/src/pages/auth/OrgSetupPage.tsx @@ -8,6 +8,7 @@ */ import { useState, useEffect } from "react"; import { useNavigate, useLocation } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; import { Building2, Plus, ArrowRight, Loader2, Mail, ChevronDown, ChevronUp } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -33,6 +34,7 @@ interface LocationState { export default function OrgSetupPage() { const navigate = useNavigate(); const location = useLocation(); + const queryClient = useQueryClient(); const { refreshUser, checkOrgAdmin, isOrgMember, isLoading } = useAuth(); // If the user already belongs to an org (e.g. they bookmarked /org-setup), @@ -100,6 +102,7 @@ export default function OrgSetupPage() { const done = async () => { await refreshUser(); await checkOrgAdmin(); + queryClient.invalidateQueries({ queryKey: ['organizations'] }); navigate("/profile", { replace: true }); }; diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx index cf84110..f9b542a 100644 --- a/src/pages/auth/RegisterPage.tsx +++ b/src/pages/auth/RegisterPage.tsx @@ -108,7 +108,7 @@ export default function RegisterPage() { Create your account

- Get started with Gatehouse in seconds + Get started with Secuird in seconds

diff --git a/src/pages/marketing/DemoPage.tsx b/src/pages/marketing/DemoPage.tsx index 0b97a56..51490a5 100644 --- a/src/pages/marketing/DemoPage.tsx +++ b/src/pages/marketing/DemoPage.tsx @@ -227,29 +227,32 @@ return ( {/* CTA */} -
+
-
-

- Ready to Try It Yourself? -

-

- Start your free trial today. No credit card required. Full access to all features. -

-
- - - - - - -
-
+ +
+ +

+ Ready to Try It Yourself? +

+

+ Start your free trial today. No credit card required. Full access to all features. +

+
+ + + + + + +
+
+
diff --git a/src/pages/marketing/FeaturesPage.tsx b/src/pages/marketing/FeaturesPage.tsx index 1360b35..748b84f 100644 --- a/src/pages/marketing/FeaturesPage.tsx +++ b/src/pages/marketing/FeaturesPage.tsx @@ -509,13 +509,13 @@ return (

- - diff --git a/src/pages/marketing/HomePage.tsx b/src/pages/marketing/HomePage.tsx index 0d42b85..bd05e68 100644 --- a/src/pages/marketing/HomePage.tsx +++ b/src/pages/marketing/HomePage.tsx @@ -80,14 +80,11 @@ export default function HomePage() { return ( <> {/* Hero Section */} -
- {/* Background gradient */} -
- +
{/* Badge */} -
+
Security-first identity platform
@@ -95,7 +92,7 @@ return ( {/* Headline */}

Enterprise Authentication, - Without the Enterprise Complexity + Without the Enterprise Complexity

{/* Subheadline */} @@ -423,13 +420,13 @@ return (

- - diff --git a/src/pages/marketing/PricingPage.tsx b/src/pages/marketing/PricingPage.tsx index e840b28..b021949 100644 --- a/src/pages/marketing/PricingPage.tsx +++ b/src/pages/marketing/PricingPage.tsx @@ -328,29 +328,32 @@ return (
{/* CTA */} -
+
-
-

- Start Your Free Trial Today -

-

- Try Secuird free for 14 days. No credit card required. Full access to all Business features. -

-
- - - - - - -
-
+ +
+ +

+ Start Your Free Trial Today +

+

+ Try Secuird free for 14 days. No credit card required. Full access to all Business features. +

+
+ + + + + + +
+
+
diff --git a/src/pages/marketing/SSHCertificatesPage.tsx b/src/pages/marketing/SSHCertificatesPage.tsx index 5ba917a..a876f73 100644 --- a/src/pages/marketing/SSHCertificatesPage.tsx +++ b/src/pages/marketing/SSHCertificatesPage.tsx @@ -435,13 +435,13 @@ $ systemctl restart sshd`}

- - diff --git a/src/pages/marketing/SecurityPage.tsx b/src/pages/marketing/SecurityPage.tsx index 4dd920d..7ad2b44 100644 --- a/src/pages/marketing/SecurityPage.tsx +++ b/src/pages/marketing/SecurityPage.tsx @@ -464,12 +464,12 @@ return (

- -
diff --git a/src/pages/org/AccessPage.tsx b/src/pages/org/AccessPage.tsx index 3b26731..269b2c0 100644 --- a/src/pages/org/AccessPage.tsx +++ b/src/pages/org/AccessPage.tsx @@ -163,7 +163,7 @@ export default function AccessPage() { try { const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([ api.zerotier.listPendingApprovals(orgId), - api.zerotier.listMyApprovals(orgId), + api.zerotier.adminListAllApprovals(orgId), api.zerotier.listSessions(orgId), api.zerotier.listNetworks(orgId), api.organizations.getMembers(orgId), diff --git a/src/pages/org/ApiKeysPage.tsx b/src/pages/org/ApiKeysPage.tsx new file mode 100644 index 0000000..497fd49 --- /dev/null +++ b/src/pages/org/ApiKeysPage.tsx @@ -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(null); + const [editingKey, setEditingKey] = useState(null); + const [showKey, setShowKey] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + const nameRef = useRef(null); + const descriptionRef = useRef(null); + const editNameRef = useRef(null); + const editDescriptionRef = useRef(null); + + // Fetch API keys + const { data: apiKeysData, isLoading } = useQuery({ + queryKey: ['api-keys', orgId], + queryFn: () => orgId ? api.organizations.getApiKeys(orgId) : null, + enabled: !!orgId, + }); + + // Create API key mutation + const { mutate: createKey, isPending: isCreatingKey } = useMutation({ + mutationFn: () => { + if (!orgId) throw new Error('Organization ID not set'); + const name = nameRef.current?.value; + const description = descriptionRef.current?.value; + if (!name) throw new Error('Name is required'); + return api.organizations.createApiKey(orgId, name, description); + }, + onSuccess: (data) => { + const apiKey = data.api_key; + setNewSecret({ + key: apiKey.key || '', + name: apiKey.name, + description: apiKey.description || undefined, + createdAt: apiKey.created_at, + }); + setIsCreateDialogOpen(false); + if (nameRef.current) nameRef.current.value = ''; + if (descriptionRef.current) descriptionRef.current.value = ''; + queryClient.invalidateQueries({ queryKey: ['api-keys', orgId] }); + toast({ + title: 'API Key Created', + description: 'Store the key value securely - you won\'t be able to see it again.', + }); + }, + onError: () => { + toast({ + title: 'Failed to create API key', + description: 'Please try again.', + variant: 'destructive', + }); + }, + }); + + // Update API key mutation + const { mutate: updateKey, isPending: isUpdatingKey } = useMutation({ + mutationFn: () => { + if (!orgId || !editingKey) throw new Error('Required data missing'); + return api.organizations.updateApiKey(orgId, editingKey.id, { + name: editNameRef.current?.value, + description: editDescriptionRef.current?.value, + }); + }, + onSuccess: () => { + setIsEditDialogOpen(false); + queryClient.invalidateQueries({ queryKey: ['api-keys', orgId] }); + toast({ + title: 'API Key Updated', + description: 'Changes saved successfully.', + }); + }, + onError: () => { + toast({ + title: 'Failed to update API key', + description: 'Please try again.', + variant: 'destructive', + }); + }, + }); + + // Delete API key mutation + const { mutate: deleteKey, isPending: isDeletingKey } = useMutation({ + mutationFn: (keyId: string) => { + if (!orgId) throw new Error('Organization ID not set'); + return api.organizations.deleteApiKey(orgId, keyId); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['api-keys', orgId] }); + toast({ + title: 'API Key Deleted', + description: 'The API key has been permanently removed.', + }); + }, + onError: () => { + toast({ + title: 'Failed to delete API key', + description: 'Please try again.', + variant: 'destructive', + }); + }, + }); + + const handleCreateKey = () => { + setIsCreating(true); + createKey(); + setIsCreating(false); + }; + + const handleEditKey = (key: OrganizationApiKey) => { + setEditingKey({ + id: key.id, + name: key.name, + description: key.description, + }); + setIsEditDialogOpen(true); + }; + + const handleUpdateKey = () => { + updateKey(); + }; + + const handleDeleteKey = (keyId: string) => { + if (confirm('Are you sure you want to delete this API key? This action cannot be undone.')) { + deleteKey(keyId); + } + }; + + const apiKeys = apiKeysData?.api_keys || []; + const activeKeys = apiKeys.filter(k => !k.is_revoked); + const revokedKeys = apiKeys.filter(k => k.is_revoked); + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+
+

API Keys

+

Manage API keys for programmatic access to your organization.

+
+ +
+ + {/* New key reveal banner */} + {newSecret && ( +
+
+ + API key created — copy it now, you won't see it again. +
+
+ + {newSecret.key} + + +
+ +
+ )} + + {/* Key list */} + + + {isLoading ? ( +
+ + Loading... +
+ ) : apiKeys.length === 0 ? ( +
+ +

No API keys yet

+

Create one to enable external integrations.

+ +
+ ) : ( +
+ {activeKeys.map((key) => ( +
+
+
+ {key.name} + {key.last_used_at && ( + + Last used {formatDate(key.last_used_at)} + + )} +
+ {key.description && ( +

{key.description}

+ )} +

Created {formatDate(key.created_at)}

+
+ + + + + + handleEditKey(key)} className="cursor-pointer"> + Edit + + + handleDeleteKey(key.id)} + className="text-destructive cursor-pointer" + disabled={isDeletingKey} + > + Delete + + + +
+ ))} + + {revokedKeys.length > 0 && ( + <> +
+ Revoked +
+ {revokedKeys.map((key) => ( +
+
+

{key.name}

+

+ Revoked {formatDate(key.revoked_at || '')} + {key.revoke_reason && ` — ${key.revoke_reason}`} +

+
+
+ ))} + + )} +
+ )} +
+
+ + {/* Create Dialog */} + + + + Create API Key + + Create a new API key for external integrations. The key will be displayed only once. + + +
+
+ + +
+
+ +