h4ckath0n 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/bin/cli.js +189 -0
  2. package/lib/scaffold.js +144 -0
  3. package/package.json +38 -0
  4. package/templates/fullstack/README.md +56 -0
  5. package/templates/fullstack/backend/.python-version +1 -0
  6. package/templates/fullstack/backend/app/__init__.py +1 -0
  7. package/templates/fullstack/backend/app/cli.py +98 -0
  8. package/templates/fullstack/backend/app/main.py +7 -0
  9. package/templates/fullstack/backend/app/middleware.py +55 -0
  10. package/templates/fullstack/backend/pyproject.toml +19 -0
  11. package/templates/fullstack/web/eslint.config.js +22 -0
  12. package/templates/fullstack/web/index.html +13 -0
  13. package/templates/fullstack/web/package-lock.json +5133 -0
  14. package/templates/fullstack/web/package.json +42 -0
  15. package/templates/fullstack/web/public/vite.svg +4 -0
  16. package/templates/fullstack/web/src/App.tsx +45 -0
  17. package/templates/fullstack/web/src/auth/AuthContext.tsx +238 -0
  18. package/templates/fullstack/web/src/auth/__tests__/token.test.ts +22 -0
  19. package/templates/fullstack/web/src/auth/__tests__/webauthn.test.ts +44 -0
  20. package/templates/fullstack/web/src/auth/api.ts +63 -0
  21. package/templates/fullstack/web/src/auth/deviceKey.ts +71 -0
  22. package/templates/fullstack/web/src/auth/index.ts +26 -0
  23. package/templates/fullstack/web/src/auth/token.ts +59 -0
  24. package/templates/fullstack/web/src/auth/webauthn.ts +133 -0
  25. package/templates/fullstack/web/src/auth/ws.ts +40 -0
  26. package/templates/fullstack/web/src/components/Alert.tsx +35 -0
  27. package/templates/fullstack/web/src/components/Button.tsx +37 -0
  28. package/templates/fullstack/web/src/components/Card.tsx +22 -0
  29. package/templates/fullstack/web/src/components/Input.tsx +27 -0
  30. package/templates/fullstack/web/src/components/Layout.tsx +88 -0
  31. package/templates/fullstack/web/src/components/ProtectedRoute.tsx +34 -0
  32. package/templates/fullstack/web/src/components/index.ts +6 -0
  33. package/templates/fullstack/web/src/index.css +48 -0
  34. package/templates/fullstack/web/src/main.tsx +28 -0
  35. package/templates/fullstack/web/src/pages/Admin.tsx +43 -0
  36. package/templates/fullstack/web/src/pages/Dashboard.tsx +75 -0
  37. package/templates/fullstack/web/src/pages/Landing.tsx +73 -0
  38. package/templates/fullstack/web/src/pages/Login.tsx +66 -0
  39. package/templates/fullstack/web/src/pages/Register.tsx +80 -0
  40. package/templates/fullstack/web/src/pages/Settings.tsx +172 -0
  41. package/templates/fullstack/web/src/pages/index.ts +6 -0
  42. package/templates/fullstack/web/src/test/setup.ts +1 -0
  43. package/templates/fullstack/web/src/vite-env.d.ts +1 -0
  44. package/templates/fullstack/web/tsconfig.app.json +21 -0
  45. package/templates/fullstack/web/tsconfig.json +7 -0
  46. package/templates/fullstack/web/tsconfig.node.json +18 -0
  47. package/templates/fullstack/web/vite.config.ts +16 -0
  48. package/templates/fullstack/web/vitest.config.ts +9 -0
@@ -0,0 +1,133 @@
1
+ export function base64urlEncode(buffer: ArrayBuffer): string {
2
+ const bytes = new Uint8Array(buffer);
3
+ let str = "";
4
+ for (const byte of bytes) {
5
+ str += String.fromCharCode(byte);
6
+ }
7
+ return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8
+ }
9
+
10
+ export function base64urlDecode(str: string): ArrayBuffer {
11
+ const padded = str.replace(/-/g, "+").replace(/_/g, "/");
12
+ const padding = (4 - (padded.length % 4)) % 4;
13
+ const base64 = padded + "=".repeat(padding);
14
+ const binary = atob(base64);
15
+ const bytes = new Uint8Array(binary.length);
16
+ for (let i = 0; i < binary.length; i++) {
17
+ bytes[i] = binary.charCodeAt(i);
18
+ }
19
+ return bytes.buffer;
20
+ }
21
+
22
+ interface ServerPublicKeyOptions {
23
+ challenge: string;
24
+ rp: { name: string; id: string };
25
+ user?: { id: string; name: string; displayName: string };
26
+ pubKeyCredParams?: Array<{ type: string; alg: number }>;
27
+ timeout?: number;
28
+ attestation?: string;
29
+ authenticatorSelection?: {
30
+ authenticatorAttachment?: string;
31
+ residentKey?: string;
32
+ requireResidentKey?: boolean;
33
+ userVerification?: string;
34
+ };
35
+ excludeCredentials?: Array<{
36
+ id: string;
37
+ type: string;
38
+ transports?: string[];
39
+ }>;
40
+ allowCredentials?: Array<{
41
+ id: string;
42
+ type: string;
43
+ transports?: string[];
44
+ }>;
45
+ }
46
+
47
+ export function toCreateOptions(
48
+ serverOptions: ServerPublicKeyOptions,
49
+ ): CredentialCreationOptions {
50
+ const publicKey: PublicKeyCredentialCreationOptions = {
51
+ challenge: base64urlDecode(serverOptions.challenge),
52
+ rp: serverOptions.rp,
53
+ user: serverOptions.user
54
+ ? {
55
+ id: base64urlDecode(serverOptions.user.id),
56
+ name: serverOptions.user.name,
57
+ displayName: serverOptions.user.displayName,
58
+ }
59
+ : (() => {
60
+ throw new Error("WebAuthn registration requires user data");
61
+ })(),
62
+ pubKeyCredParams: (serverOptions.pubKeyCredParams ?? []).map((p) => ({
63
+ type: p.type as PublicKeyCredentialType,
64
+ alg: p.alg,
65
+ })),
66
+ timeout: serverOptions.timeout,
67
+ attestation:
68
+ serverOptions.attestation as AttestationConveyancePreference,
69
+ authenticatorSelection:
70
+ serverOptions.authenticatorSelection as AuthenticatorSelectionCriteria,
71
+ excludeCredentials: (serverOptions.excludeCredentials ?? []).map((c) => ({
72
+ id: base64urlDecode(c.id),
73
+ type: c.type as PublicKeyCredentialType,
74
+ transports: c.transports as AuthenticatorTransport[],
75
+ })),
76
+ };
77
+ return { publicKey };
78
+ }
79
+
80
+ export function toGetOptions(
81
+ serverOptions: ServerPublicKeyOptions,
82
+ ): CredentialRequestOptions {
83
+ const publicKey: PublicKeyCredentialRequestOptions = {
84
+ challenge: base64urlDecode(serverOptions.challenge),
85
+ rpId: serverOptions.rp.id,
86
+ timeout: serverOptions.timeout,
87
+ userVerification:
88
+ (serverOptions.authenticatorSelection
89
+ ?.userVerification as UserVerificationRequirement) ?? "preferred",
90
+ allowCredentials: (serverOptions.allowCredentials ?? []).map((c) => ({
91
+ id: base64urlDecode(c.id),
92
+ type: c.type as PublicKeyCredentialType,
93
+ transports: c.transports as AuthenticatorTransport[],
94
+ })),
95
+ };
96
+ return { publicKey };
97
+ }
98
+
99
+ export function serializeCreateResponse(
100
+ credential: PublicKeyCredential,
101
+ ): Record<string, unknown> {
102
+ const response =
103
+ credential.response as AuthenticatorAttestationResponse;
104
+ return {
105
+ id: credential.id,
106
+ rawId: base64urlEncode(credential.rawId),
107
+ type: credential.type,
108
+ response: {
109
+ clientDataJSON: base64urlEncode(response.clientDataJSON),
110
+ attestationObject: base64urlEncode(response.attestationObject),
111
+ },
112
+ };
113
+ }
114
+
115
+ export function serializeGetResponse(
116
+ credential: PublicKeyCredential,
117
+ ): Record<string, unknown> {
118
+ const response =
119
+ credential.response as AuthenticatorAssertionResponse;
120
+ return {
121
+ id: credential.id,
122
+ rawId: base64urlEncode(credential.rawId),
123
+ type: credential.type,
124
+ response: {
125
+ clientDataJSON: base64urlEncode(response.clientDataJSON),
126
+ authenticatorData: base64urlEncode(response.authenticatorData),
127
+ signature: base64urlEncode(response.signature),
128
+ userHandle: response.userHandle
129
+ ? base64urlEncode(response.userHandle)
130
+ : null,
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,40 @@
1
+ import { getOrMintToken } from "./token";
2
+
3
+ /**
4
+ * Create an authenticated WebSocket connection.
5
+ * Sends auth token as first message frame instead of query string.
6
+ * Never put tokens in WebSocket URL query params.
7
+ */
8
+ export async function createAuthWebSocket(
9
+ url: string,
10
+ onMessage?: (data: unknown) => void,
11
+ ): Promise<WebSocket> {
12
+ const token = await getOrMintToken("ws");
13
+ const ws = new WebSocket(url);
14
+
15
+ ws.addEventListener("open", () => {
16
+ ws.send(JSON.stringify({ type: "auth", token }));
17
+ });
18
+
19
+ if (onMessage) {
20
+ ws.addEventListener("message", (event) => {
21
+ try {
22
+ const data = JSON.parse(event.data as string);
23
+ onMessage(data);
24
+ } catch {
25
+ onMessage(event.data);
26
+ }
27
+ });
28
+ }
29
+
30
+ return ws;
31
+ }
32
+
33
+ /**
34
+ * Send a re-auth message on an existing WebSocket when token is renewed.
35
+ */
36
+ export async function sendReauth(ws: WebSocket): Promise<void> {
37
+ if (ws.readyState !== WebSocket.OPEN) return;
38
+ const token = await getOrMintToken("ws");
39
+ ws.send(JSON.stringify({ type: "auth", token }));
40
+ }
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from "react";
2
+ import { AlertCircle, CheckCircle, Info, XCircle } from "lucide-react";
3
+
4
+ interface AlertProps {
5
+ variant?: "info" | "success" | "warning" | "error";
6
+ children: ReactNode;
7
+ className?: string;
8
+ }
9
+
10
+ const icons = {
11
+ info: Info,
12
+ success: CheckCircle,
13
+ warning: AlertCircle,
14
+ error: XCircle,
15
+ };
16
+
17
+ const styles = {
18
+ info: "bg-primary/10 text-primary border-primary/20",
19
+ success: "bg-success/10 text-success border-success/20",
20
+ warning: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20",
21
+ error: "bg-danger/10 text-danger border-danger/20",
22
+ };
23
+
24
+ export function Alert({ variant = "info", children, className = "" }: AlertProps) {
25
+ const Icon = icons[variant];
26
+ return (
27
+ <div
28
+ className={`flex items-start gap-3 p-4 rounded-2xl border ${styles[variant]} ${className}`}
29
+ role="alert"
30
+ >
31
+ <Icon className="w-5 h-5 shrink-0 mt-0.5" />
32
+ <div className="text-sm">{children}</div>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,37 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
2
+
3
+ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
4
+ variant?: "primary" | "secondary" | "danger" | "ghost";
5
+ size?: "sm" | "md" | "lg";
6
+ children: ReactNode;
7
+ }
8
+
9
+ const variants = {
10
+ primary: "bg-primary text-white hover:bg-primary-hover",
11
+ secondary: "bg-surface-alt text-text border border-border hover:bg-border",
12
+ danger: "bg-danger text-white hover:bg-danger-hover",
13
+ ghost: "text-text hover:bg-surface-alt",
14
+ };
15
+
16
+ const sizes = {
17
+ sm: "px-3 py-1.5 text-sm",
18
+ md: "px-4 py-2 text-sm",
19
+ lg: "px-6 py-2.5 text-base",
20
+ };
21
+
22
+ export function Button({
23
+ variant = "primary",
24
+ size = "md",
25
+ className = "",
26
+ children,
27
+ ...props
28
+ }: ButtonProps) {
29
+ return (
30
+ <button
31
+ className={`inline-flex items-center justify-center gap-2 font-medium rounded-2xl transition-colors disabled:opacity-50 disabled:pointer-events-none ${variants[variant]} ${sizes[size]} ${className}`}
32
+ {...props}
33
+ >
34
+ {children}
35
+ </button>
36
+ );
37
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ interface CardProps {
4
+ children: ReactNode;
5
+ className?: string;
6
+ }
7
+
8
+ export function Card({ children, className = "" }: CardProps) {
9
+ return (
10
+ <div className={`bg-surface border border-border rounded-2xl shadow-sm ${className}`}>
11
+ {children}
12
+ </div>
13
+ );
14
+ }
15
+
16
+ export function CardHeader({ children, className = "" }: CardProps) {
17
+ return <div className={`px-6 py-4 border-b border-border ${className}`}>{children}</div>;
18
+ }
19
+
20
+ export function CardContent({ children, className = "" }: CardProps) {
21
+ return <div className={`px-6 py-4 ${className}`}>{children}</div>;
22
+ }
@@ -0,0 +1,27 @@
1
+ import type { InputHTMLAttributes } from "react";
2
+
3
+ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
4
+ label?: string;
5
+ error?: string;
6
+ }
7
+
8
+ export function Input({ label, error, className = "", id, ...props }: InputProps) {
9
+ const inputId = id || label?.toLowerCase().replace(/\s+/g, "-");
10
+ return (
11
+ <div className="space-y-1.5">
12
+ {label && (
13
+ <label htmlFor={inputId} className="block text-sm font-medium text-text">
14
+ {label}
15
+ </label>
16
+ )}
17
+ <input
18
+ id={inputId}
19
+ className={`w-full px-3 py-2 bg-surface border border-border rounded-xl text-text placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors ${
20
+ error ? "border-danger" : ""
21
+ } ${className}`}
22
+ {...props}
23
+ />
24
+ {error && <p className="text-sm text-danger">{error}</p>}
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,88 @@
1
+ import { Outlet, Link } from "react-router";
2
+ import { useAuth } from "../auth";
3
+ import { Sun, Moon, Shield, LogOut, LayoutDashboard, Settings } from "lucide-react";
4
+ import { useState, useEffect } from "react";
5
+
6
+ export function Layout() {
7
+ const { isAuthenticated, logout } = useAuth();
8
+ const [dark, setDark] = useState(() => {
9
+ if (typeof window === "undefined") return false;
10
+ return document.documentElement.getAttribute("data-theme") === "dark" ||
11
+ (!document.documentElement.getAttribute("data-theme") &&
12
+ window.matchMedia("(prefers-color-scheme: dark)").matches);
13
+ });
14
+
15
+ useEffect(() => {
16
+ document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
17
+ }, [dark]);
18
+
19
+ return (
20
+ <div className="min-h-screen bg-surface">
21
+ <nav className="border-b border-border bg-surface/80 backdrop-blur-sm sticky top-0 z-50">
22
+ <div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
23
+ <div className="flex items-center justify-between h-16">
24
+ <Link to="/" className="flex items-center gap-2 font-bold text-lg text-text">
25
+ <Shield className="w-5 h-5 text-primary" />
26
+ <span>{"{{PROJECT_NAME}}"}</span>
27
+ </Link>
28
+
29
+ <div className="flex items-center gap-3">
30
+ <button
31
+ onClick={() => setDark(!dark)}
32
+ className="p-2 rounded-xl hover:bg-surface-alt transition-colors"
33
+ aria-label="Toggle dark mode"
34
+ >
35
+ {dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
36
+ </button>
37
+
38
+ {isAuthenticated ? (
39
+ <>
40
+ <Link
41
+ to="/dashboard"
42
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-xl hover:bg-surface-alt transition-colors"
43
+ >
44
+ <LayoutDashboard className="w-4 h-4" />
45
+ Dashboard
46
+ </Link>
47
+ <Link
48
+ to="/settings"
49
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-xl hover:bg-surface-alt transition-colors"
50
+ >
51
+ <Settings className="w-4 h-4" />
52
+ Settings
53
+ </Link>
54
+ <button
55
+ onClick={() => void logout()}
56
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-xl hover:bg-surface-alt transition-colors text-danger"
57
+ >
58
+ <LogOut className="w-4 h-4" />
59
+ Logout
60
+ </button>
61
+ </>
62
+ ) : (
63
+ <>
64
+ <Link
65
+ to="/login"
66
+ className="px-3 py-1.5 text-sm rounded-xl hover:bg-surface-alt transition-colors"
67
+ >
68
+ Login
69
+ </Link>
70
+ <Link
71
+ to="/register"
72
+ className="px-4 py-1.5 text-sm bg-primary text-white rounded-xl hover:bg-primary-hover transition-colors"
73
+ >
74
+ Register
75
+ </Link>
76
+ </>
77
+ )}
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </nav>
82
+
83
+ <main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
84
+ <Outlet />
85
+ </main>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,34 @@
1
+ import { Navigate } from "react-router";
2
+ import { useAuth } from "../auth";
3
+
4
+ interface Props {
5
+ children: React.ReactNode;
6
+ requiredRole?: string;
7
+ }
8
+
9
+ export function ProtectedRoute({ children, requiredRole }: Props) {
10
+ const { isAuthenticated, isLoading, role } = useAuth();
11
+
12
+ if (isLoading) {
13
+ return (
14
+ <div className="flex items-center justify-center min-h-[50vh]">
15
+ <div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
16
+ </div>
17
+ );
18
+ }
19
+
20
+ if (!isAuthenticated) {
21
+ return <Navigate to="/login" replace />;
22
+ }
23
+
24
+ if (requiredRole && role !== requiredRole) {
25
+ return (
26
+ <div className="text-center py-16">
27
+ <h2 className="text-2xl font-bold text-text mb-2">Access Denied</h2>
28
+ <p className="text-text-muted">You do not have permission to view this page.</p>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ return <>{children}</>;
34
+ }
@@ -0,0 +1,6 @@
1
+ export { Card, CardHeader, CardContent } from "./Card";
2
+ export { Button } from "./Button";
3
+ export { Input } from "./Input";
4
+ export { Alert } from "./Alert";
5
+ export { Layout } from "./Layout";
6
+ export { ProtectedRoute } from "./ProtectedRoute";
@@ -0,0 +1,48 @@
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --color-primary: #6366f1;
5
+ --color-primary-hover: #4f46e5;
6
+ --color-surface: #ffffff;
7
+ --color-surface-alt: #f9fafb;
8
+ --color-border: #e5e7eb;
9
+ --color-text: #111827;
10
+ --color-text-muted: #6b7280;
11
+ --color-danger: #ef4444;
12
+ --color-danger-hover: #dc2626;
13
+ --color-success: #22c55e;
14
+ }
15
+
16
+ @layer base {
17
+ body {
18
+ @apply bg-surface text-text antialiased;
19
+ }
20
+ }
21
+
22
+ @media (prefers-color-scheme: dark) {
23
+ @theme {
24
+ --color-primary: #818cf8;
25
+ --color-primary-hover: #6366f1;
26
+ --color-surface: #0f172a;
27
+ --color-surface-alt: #1e293b;
28
+ --color-border: #334155;
29
+ --color-text: #f1f5f9;
30
+ --color-text-muted: #94a3b8;
31
+ --color-danger: #f87171;
32
+ --color-danger-hover: #ef4444;
33
+ --color-success: #4ade80;
34
+ }
35
+ }
36
+
37
+ [data-theme="dark"] {
38
+ --color-primary: #818cf8;
39
+ --color-primary-hover: #6366f1;
40
+ --color-surface: #0f172a;
41
+ --color-surface-alt: #1e293b;
42
+ --color-border: #334155;
43
+ --color-text: #f1f5f9;
44
+ --color-text-muted: #94a3b8;
45
+ --color-danger: #f87171;
46
+ --color-danger-hover: #ef4444;
47
+ --color-success: #4ade80;
48
+ }
@@ -0,0 +1,28 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { BrowserRouter } from "react-router";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { AuthProvider } from "./auth/AuthContext";
6
+ import { App } from "./App";
7
+ import "./index.css";
8
+
9
+ const queryClient = new QueryClient({
10
+ defaultOptions: {
11
+ queries: {
12
+ retry: 1,
13
+ refetchOnWindowFocus: false,
14
+ },
15
+ },
16
+ });
17
+
18
+ createRoot(document.getElementById("root")!).render(
19
+ <StrictMode>
20
+ <QueryClientProvider client={queryClient}>
21
+ <BrowserRouter>
22
+ <AuthProvider>
23
+ <App />
24
+ </AuthProvider>
25
+ </BrowserRouter>
26
+ </QueryClientProvider>
27
+ </StrictMode>
28
+ );
@@ -0,0 +1,43 @@
1
+ import { useAuth } from "../auth";
2
+ import { Card, CardContent, CardHeader } from "../components/Card";
3
+ import { Shield, Users } from "lucide-react";
4
+ import { Alert } from "../components/Alert";
5
+
6
+ export function Admin() {
7
+ const { userId, role } = useAuth();
8
+
9
+ return (
10
+ <div className="space-y-6">
11
+ <div>
12
+ <h1 className="text-2xl font-bold text-text">Admin Panel</h1>
13
+ <p className="text-text-muted">Server-side RBAC enforces all admin operations</p>
14
+ </div>
15
+
16
+ <Alert variant="info">
17
+ This page is role-gated in the frontend. However, all admin operations are enforced
18
+ server-side. The server derives roles from the database, never from JWT claims.
19
+ </Alert>
20
+
21
+ <Card>
22
+ <CardHeader>
23
+ <div className="flex items-center gap-3">
24
+ <Shield className="w-5 h-5 text-primary" />
25
+ <h2 className="text-lg font-semibold text-text">Admin Info</h2>
26
+ </div>
27
+ </CardHeader>
28
+ <CardContent className="space-y-3">
29
+ <div className="flex items-center gap-2 text-sm">
30
+ <Users className="w-4 h-4 text-text-muted" />
31
+ <span className="text-text-muted">User ID:</span>
32
+ <span className="font-mono text-text">{userId}</span>
33
+ </div>
34
+ <div className="flex items-center gap-2 text-sm">
35
+ <Shield className="w-4 h-4 text-text-muted" />
36
+ <span className="text-text-muted">Role:</span>
37
+ <span className="font-medium text-text capitalize">{role}</span>
38
+ </div>
39
+ </CardContent>
40
+ </Card>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,75 @@
1
+ import { useAuth } from "../auth";
2
+ import { Card, CardContent, CardHeader } from "../components/Card";
3
+ import { Shield, User, Key } from "lucide-react";
4
+
5
+ export function Dashboard() {
6
+ const { userId, deviceId, role, displayName } = useAuth();
7
+
8
+ return (
9
+ <div className="space-y-6">
10
+ <div>
11
+ <h1 className="text-2xl font-bold text-text">Dashboard</h1>
12
+ <p className="text-text-muted">
13
+ Welcome{displayName ? `, ${displayName}` : ""}!
14
+ </p>
15
+ </div>
16
+
17
+ <div className="grid sm:grid-cols-3 gap-4">
18
+ <Card>
19
+ <CardContent className="flex items-center gap-3 py-5">
20
+ <div className="p-2 bg-primary/10 rounded-xl">
21
+ <User className="w-5 h-5 text-primary" />
22
+ </div>
23
+ <div>
24
+ <p className="text-xs text-text-muted">User ID</p>
25
+ <p className="text-sm font-mono text-text truncate max-w-[160px]">{userId}</p>
26
+ </div>
27
+ </CardContent>
28
+ </Card>
29
+
30
+ <Card>
31
+ <CardContent className="flex items-center gap-3 py-5">
32
+ <div className="p-2 bg-primary/10 rounded-xl">
33
+ <Key className="w-5 h-5 text-primary" />
34
+ </div>
35
+ <div>
36
+ <p className="text-xs text-text-muted">Device ID</p>
37
+ <p className="text-sm font-mono text-text truncate max-w-[160px]">{deviceId}</p>
38
+ </div>
39
+ </CardContent>
40
+ </Card>
41
+
42
+ <Card>
43
+ <CardContent className="flex items-center gap-3 py-5">
44
+ <div className="p-2 bg-primary/10 rounded-xl">
45
+ <Shield className="w-5 h-5 text-primary" />
46
+ </div>
47
+ <div>
48
+ <p className="text-xs text-text-muted">Role</p>
49
+ <p className="text-sm font-medium text-text capitalize">{role || "user"}</p>
50
+ </div>
51
+ </CardContent>
52
+ </Card>
53
+ </div>
54
+
55
+ <Card>
56
+ <CardHeader>
57
+ <h2 className="text-lg font-semibold text-text">Getting Started</h2>
58
+ </CardHeader>
59
+ <CardContent className="space-y-3 text-sm text-text-muted">
60
+ <p>
61
+ Your device has a unique P-256 keypair stored in IndexedDB. The private key is
62
+ non-extractable and never leaves this browser.
63
+ </p>
64
+ <p>
65
+ API requests are authenticated with short-lived JWTs (15 min) signed by your device
66
+ key. The server verifies each request using your registered public key.
67
+ </p>
68
+ <p>
69
+ Visit <strong>Settings</strong> to manage your passkeys, or start building your app!
70
+ </p>
71
+ </CardContent>
72
+ </Card>
73
+ </div>
74
+ );
75
+ }