Merge pull request #4 from jamesii-b/gatehouse/secuird-CA-merge-v2.01
Gatehouse/secuird ca merge v2.01
This commit is contained in:
|
Before Width: | Height: | Size: 661 B After Width: | Height: | Size: 661 B |
+7
-1
@@ -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",
|
||||||
@@ -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,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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://<your-proxy-host>/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
@@ -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} · {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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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} · {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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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
@@ -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"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user