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.
- package/bin/cli.js +189 -0
- package/lib/scaffold.js +144 -0
- package/package.json +38 -0
- package/templates/fullstack/README.md +56 -0
- package/templates/fullstack/backend/.python-version +1 -0
- package/templates/fullstack/backend/app/__init__.py +1 -0
- package/templates/fullstack/backend/app/cli.py +98 -0
- package/templates/fullstack/backend/app/main.py +7 -0
- package/templates/fullstack/backend/app/middleware.py +55 -0
- package/templates/fullstack/backend/pyproject.toml +19 -0
- package/templates/fullstack/web/eslint.config.js +22 -0
- package/templates/fullstack/web/index.html +13 -0
- package/templates/fullstack/web/package-lock.json +5133 -0
- package/templates/fullstack/web/package.json +42 -0
- package/templates/fullstack/web/public/vite.svg +4 -0
- package/templates/fullstack/web/src/App.tsx +45 -0
- package/templates/fullstack/web/src/auth/AuthContext.tsx +238 -0
- package/templates/fullstack/web/src/auth/__tests__/token.test.ts +22 -0
- package/templates/fullstack/web/src/auth/__tests__/webauthn.test.ts +44 -0
- package/templates/fullstack/web/src/auth/api.ts +63 -0
- package/templates/fullstack/web/src/auth/deviceKey.ts +71 -0
- package/templates/fullstack/web/src/auth/index.ts +26 -0
- package/templates/fullstack/web/src/auth/token.ts +59 -0
- package/templates/fullstack/web/src/auth/webauthn.ts +133 -0
- package/templates/fullstack/web/src/auth/ws.ts +40 -0
- package/templates/fullstack/web/src/components/Alert.tsx +35 -0
- package/templates/fullstack/web/src/components/Button.tsx +37 -0
- package/templates/fullstack/web/src/components/Card.tsx +22 -0
- package/templates/fullstack/web/src/components/Input.tsx +27 -0
- package/templates/fullstack/web/src/components/Layout.tsx +88 -0
- package/templates/fullstack/web/src/components/ProtectedRoute.tsx +34 -0
- package/templates/fullstack/web/src/components/index.ts +6 -0
- package/templates/fullstack/web/src/index.css +48 -0
- package/templates/fullstack/web/src/main.tsx +28 -0
- package/templates/fullstack/web/src/pages/Admin.tsx +43 -0
- package/templates/fullstack/web/src/pages/Dashboard.tsx +75 -0
- package/templates/fullstack/web/src/pages/Landing.tsx +73 -0
- package/templates/fullstack/web/src/pages/Login.tsx +66 -0
- package/templates/fullstack/web/src/pages/Register.tsx +80 -0
- package/templates/fullstack/web/src/pages/Settings.tsx +172 -0
- package/templates/fullstack/web/src/pages/index.ts +6 -0
- package/templates/fullstack/web/src/test/setup.ts +1 -0
- package/templates/fullstack/web/src/vite-env.d.ts +1 -0
- package/templates/fullstack/web/tsconfig.app.json +21 -0
- package/templates/fullstack/web/tsconfig.json +7 -0
- package/templates/fullstack/web/tsconfig.node.json +18 -0
- package/templates/fullstack/web/vite.config.ts +16 -0
- 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,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
|
+
}
|