diff --git a/src/App.tsx b/src/App.tsx index 2b3a544..1979e11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,7 @@ import UserSecurityPage from "@/pages/user/SecurityPage"; import LinkedAccountsPage from "@/pages/user/LinkedAccountsPage"; import ActivityPage from "@/pages/user/ActivityPage"; import SSHKeysPage from "@/pages/user/SSHKeysPage"; +import CLIGuidePage from "@/pages/user/CLIGuidePage"; // Organization pages import OrgOverviewPage from "@/pages/org/OrgOverviewPage"; @@ -175,6 +176,7 @@ function AppRoutes() { } /> } /> } /> + } /> {/* Organization routes — org members: overview + own memberships only */} } /> diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index 2c3307f..017ff0d 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -17,6 +17,7 @@ import { Network, Monitor, ShieldAlert, + BookOpen, } from "lucide-react"; import { SecuirdLogo } from "@/components/branding/SecuirdLogo"; import { NavLink } from "@/components/NavLink"; @@ -42,6 +43,7 @@ const userNavItems = [ { title: "SSH Keys", url: "/ssh-keys", icon: Terminal }, { title: "Linked Accounts", url: "/linked-accounts", icon: Link2 }, { title: "Activity", url: "/activity", icon: Activity }, + { title: "CLI Guide", url: "/cli-guide", icon: BookOpen }, ]; // Visible to ALL org members @@ -212,7 +214,7 @@ export function AppSidebar() { {!collapsed && (
- v1.0.0 • Self-hosted + {import.meta.env.VITE_APP_VERSION ?? 'Secuird'}
)}
diff --git a/src/pages/org/ApiKeysPage.tsx b/src/pages/org/ApiKeysPage.tsx index f4d07cc..497fd49 100644 --- a/src/pages/org/ApiKeysPage.tsx +++ b/src/pages/org/ApiKeysPage.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useRef } from "react"; import { - Plus, Copy, Trash2, Loader2, AlertCircle, CheckCircle, Eye, EyeOff, MoreHorizontal, Edit2, Check + Plus, Copy, Trash2, Loader2, AlertCircle, CheckCircle, MoreHorizontal, Edit2, Check } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { DropdownMenu, @@ -201,187 +201,119 @@ export default function ApiKeysPage() { return (
-
-

API Keys

-

- Manage API keys for external integrations and programmatic access to your organization. -

-
- - {/* New key notification */} - {newSecret && ( - - - - - New API Key Created - - - Store this key securely. You won't be able to see it again. - - - -
- -

{newSecret.name}

-
-
- - - {newSecret.key} - -
- -
-
- )} - - {/* Create button */} -
-
- {/* Active Keys */} - {activeKeys.length > 0 && ( -
-

Active Keys

-
- {activeKeys.map((key) => ( - - -
-
-
-

{key.name}

- {key.last_used_at && ( - - Last used: {formatDate(key.last_used_at)} - - )} -
- {key.description && ( -

- {key.description} -

- )} -

- Created {formatDate(key.created_at)} -

-
- - - - - - handleEditKey(key)} - className="cursor-pointer" - > - - Edit - - - handleDeleteKey(key.id)} - className="text-destructive cursor-pointer" - disabled={isDeletingKey} - > - - Delete - - - -
-
-
- ))} + {/* New key reveal banner */} + {newSecret && ( +
+
+ + API key created — copy it now, you won't see it again.
-
- )} - - {/* Revoked Keys */} - {revokedKeys.length > 0 && ( -
-

Revoked Keys

-
- {revokedKeys.map((key) => ( - - -
-
-

- {key.name} -

-

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

-
-
-
-
- ))} -
-
- )} - - {/* Empty state */} - {apiKeys.length === 0 && ( - - - -

No API Keys

-

- Create your first API key to enable external integrations. -

- -
-
+
+ +
)} + {/* Key list */} + + + {isLoading ? ( +
+ + Loading... +
+ ) : apiKeys.length === 0 ? ( +
+ +

No API keys yet

+

Create one to enable external integrations.

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

{key.description}

+ )} +

Created {formatDate(key.created_at)}

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

{key.name}

+

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

+
+
+ ))} + + )} +
+ )} +
+
+ {/* Create Dialog */} diff --git a/src/pages/org/ca/CADetailCard.tsx b/src/pages/org/ca/CADetailCard.tsx index 0ad0f08..818a21f 100644 --- a/src/pages/org/ca/CADetailCard.tsx +++ b/src/pages/org/ca/CADetailCard.tsx @@ -144,7 +144,7 @@ AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u {/* Stats row — hidden for system CAs */} {!isSystem && ( -
+

{ca.active_certs}

Active certs

@@ -157,10 +157,7 @@ AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u

{ca.default_cert_validity_hours}h

Default validity

-
-

{ca.next_serial_number ?? "—"}

-

Next serial

-
+
)} diff --git a/src/pages/user/CLIGuidePage.tsx b/src/pages/user/CLIGuidePage.tsx new file mode 100644 index 0000000..b7f3f75 --- /dev/null +++ b/src/pages/user/CLIGuidePage.tsx @@ -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 ( +
+ +
+        {code}
+      
+
+ ); +} + +// ── Numbered step ────────────────────────────────────────────────────────────── +function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { + return ( +
+
+ {n} +
+
+

{title}

+ {children} +
+
+ ); +} + +// ── Collapsible FAQ item ─────────────────────────────────────────────────────── +function FaqItem({ q, children }: { q: string; children: React.ReactNode }) { + const [open, setOpen] = useState(false); + return ( + + + {open + ? + : } + {q} + + + {children} + + + ); +} + +// ── Main page ────────────────────────────────────────────────────────────────── +export default function CLIGuidePage() { + return ( +
+ {/* Header */} +
+
+ +

Secuird CLI

+
+

+ Sign your SSH key from the command line. Browser login happens once — token is cached. +

+
+ +
+ + {/* Setup steps */} +
+

Setup

+ + + + + + +

+ Creates an isolated virtualenv so nothing pollutes your system Python. +

+

Install dependencies

+ +

Create the secuird command

+ ~/.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`} /> +
+ + + ~/.secuird/.env`} /> + + + + +

Your browser will open for login. Token is cached after first login.

+
+ + + +

Certificate saved to /tmp/ssh-cert. Re-run when it expires.

+
+ + + + +
+ +
+ + {/* Commands reference */} +
+

Commands

+
+ {[ + ["--request-cert", "-r", "Request / renew a signed SSH certificate"], + ["--add-key -k ", "-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]) => ( +
+ {flag} + {short + ? {short} + : } + {desc} +
+ ))} +
+
+ +
+ + {/* FAQ */} +
+

FAQ

+
+ +

No — the token is cached at ~/.secuird/token_cache.json and reused until it expires.

+
+ +

The CLI listens on port 8250 locally. Make sure nothing else is using that port and complete the login before closing the tab.

+
+ +

Run secuird --add-key -k ~/.ssh/id_ed25519.pub then check with secuird --list-keys.

+
+ + > ~/.bashrc && source ~/.bashrc`} /> + + +

You can use a cron job to automatically renew your certificate before it expires. Run secuird --request-cert interactively at least once first so a cached token exists.

+
+
+
+ + {/* Footer */} +

+ + View source on GitHub + + {" · "} + + Manage SSH keys in the UI + +

+ +
+
+ ); +} diff --git a/src/pages/user/SSHKeysPage.tsx b/src/pages/user/SSHKeysPage.tsx index e8ff568..b924449 100644 --- a/src/pages/user/SSHKeysPage.tsx +++ b/src/pages/user/SSHKeysPage.tsx @@ -824,9 +824,6 @@ TrustedUserCAKeys /etc/ssh/trusted_user_ca`} className="font-mono text-xs pr-10" rows={6} /> -
- /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`} /> -
diff --git a/vite.config.ts b/vite.config.ts index 284c334..9db64c3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,19 +1,25 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react-swc"; import path from "path"; import { componentTagger } from "lovable-tagger"; -// https://vitejs.dev/config/ -export default defineConfig(({ mode }) => ({ - server: { - host: "::", - port: 8080, - allowedHosts: process.env.VITE_ALLOWED_HOSTS?.split(",") || ["ui.webauthn.local","secuird-ui.hawkvelt.tech"], - }, - plugins: [react(), mode === "development" && componentTagger()].filter(Boolean), - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + + return { + server: { + host: "::", + port: 8080, + allowedHosts: env.VITE_ALLOWED_HOSTS?.split(",") || [ + "ui.webauthn.local", + "gatehouse-ui.hawkvelt.tech", + ], }, - }, -})); + plugins: [react(), mode === "development" && componentTagger()].filter(Boolean), + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + }; +});