add auth system: users, login, JWT, protected routes

Domain: User entity, AuthPort/PasswordHashPort/SecretStore ports.
Adapters: auth (argon2 hashing, JWT tokens), secret-store (env-based),
config-sqlite user repository, http-api auth routes + extractors.
Application: auth_service. SPA: login page, auth client, protected router.
This commit is contained in:
2026-06-19 01:39:42 +02:00
parent 4139330234
commit adda731dc6
41 changed files with 1331 additions and 153 deletions

59
spa/src/api/auth.ts Normal file
View File

@@ -0,0 +1,59 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
const BASE = "/api"
export function useAuthStatus() {
return useQuery({
queryKey: ["auth-status"],
queryFn: async () => {
const res = await fetch(`${BASE}/auth/status`)
return res.json() as Promise<{ needs_setup: boolean }>
},
})
}
export function useLogin() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (creds: { username: string; password: string }) => {
const res = await fetch(`${BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(creds),
})
if (!res.ok) {
const text = await res.text().catch(() => res.statusText)
throw new Error(text)
}
return res.json() as Promise<{ token: string }>
},
onSuccess: (data) => {
localStorage.setItem("kframe_token", data.token)
qc.invalidateQueries()
},
})
}
export function useRegister() {
return useMutation({
mutationFn: async (creds: { username: string; password: string }) => {
const res = await fetch(`${BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(creds),
})
if (!res.ok) {
const text = await res.text().catch(() => res.statusText)
throw new Error(text)
}
},
})
}
export function getToken(): string | null {
return localStorage.getItem("kframe_token")
}
export function clearToken() {
localStorage.removeItem("kframe_token")
}

View File

@@ -1,13 +1,25 @@
import { getToken, clearToken } from "./auth"
const BASE = "/api"
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...init?.headers,
},
})
const token = getToken()
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(init?.headers as Record<string, string>),
}
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
const res = await fetch(`${BASE}${path}`, { ...init, headers })
if (res.status === 401) {
clearToken()
window.location.href = "/login"
throw new Error("Unauthorized")
}
if (!res.ok) {
const text = await res.text().catch(() => res.statusText)
throw new Error(`${res.status}: ${text}`)

View File

@@ -1,8 +1,9 @@
import { Link, useRouterState } from "@tanstack/react-router"
import { Link, useNavigate, useRouterState } from "@tanstack/react-router"
import {
SidebarProvider,
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
@@ -12,6 +13,8 @@ import {
} from "@/components/ui/sidebar"
import { Separator } from "@/components/ui/separator"
import { Toaster } from "@/components/ui/sonner"
import { Button } from "@/components/ui/button"
import { clearToken } from "@/api/auth"
import {
LayoutDashboard,
Database,
@@ -19,6 +22,7 @@ import {
Layers,
Save,
BookOpen,
LogOut,
} from "lucide-react"
const NAV = [
@@ -32,6 +36,12 @@ const NAV = [
export function AppShell({ children }: { children: React.ReactNode }) {
const { location } = useRouterState()
const navigate = useNavigate()
function logout() {
clearToken()
navigate({ to: "/login" })
}
return (
<SidebarProvider>
@@ -59,6 +69,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
})}
</SidebarMenu>
</SidebarContent>
<SidebarFooter className="p-2">
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={logout}>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Button>
</SidebarFooter>
</Sidebar>
<SidebarInset>
<header className="flex h-12 items-center gap-2 border-b px-4">

79
spa/src/pages/login.tsx Normal file
View File

@@ -0,0 +1,79 @@
import { useState } from "react"
import { useNavigate } from "@tanstack/react-router"
import { useLogin, useRegister, useAuthStatus } from "@/api/auth"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "sonner"
export function LoginPage() {
const navigate = useNavigate()
const { data: status } = useAuthStatus()
const login = useLogin()
const register = useRegister()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const isSetup = status?.needs_setup ?? false
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
try {
if (isSetup) {
await register.mutateAsync({ username, password })
toast.success("Account created")
await login.mutateAsync({ username, password })
} else {
await login.mutateAsync({ username, password })
}
navigate({ to: "/" })
} catch (err) {
toast.error(String(err))
}
}
return (
<div className="flex min-h-svh items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-center text-xl">
{isSetup ? "K-Frame Setup" : "K-Frame Login"}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid gap-4">
{isSetup && (
<p className="text-muted-foreground text-center text-sm">
Create your admin account to get started.
</p>
)}
<div className="grid gap-2">
<Label>Username</Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
/>
</div>
<div className="grid gap-2">
<Label>Password</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button
type="submit"
disabled={!username || !password || login.isPending || register.isPending}
>
{isSetup ? "Create Account" : "Sign In"}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -3,6 +3,7 @@ import {
createRoute,
createRouter,
Outlet,
redirect,
} from "@tanstack/react-router"
import { AppShell } from "@/components/app-shell"
import { DashboardPage } from "@/pages/dashboard"
@@ -11,8 +12,35 @@ import { WidgetsPage } from "@/pages/widgets"
import { LayoutBuilderPage } from "@/pages/layout-builder"
import { PresetsPage } from "@/pages/presets"
import { GuidePage } from "@/pages/guide"
import { LoginPage } from "@/pages/login"
import { getToken } from "@/api/auth"
import { Toaster } from "@/components/ui/sonner"
function requireAuth() {
if (!getToken()) {
throw redirect({ to: "/login" })
}
}
const rootRoute = createRootRoute({
component: () => <Outlet />,
})
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/login",
component: () => (
<>
<LoginPage />
<Toaster />
</>
),
})
const authenticatedRoute = createRoute({
getParentRoute: () => rootRoute,
id: "authenticated",
beforeLoad: requireAuth,
component: () => (
<AppShell>
<Outlet />
@@ -21,48 +49,51 @@ const rootRoute = createRootRoute({
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => authenticatedRoute,
path: "/",
component: DashboardPage,
})
const dataSourcesRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => authenticatedRoute,
path: "/data-sources",
component: DataSourcesPage,
})
const widgetsRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => authenticatedRoute,
path: "/widgets",
component: WidgetsPage,
})
const layoutRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => authenticatedRoute,
path: "/layout",
component: LayoutBuilderPage,
})
const presetsRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => authenticatedRoute,
path: "/presets",
component: PresetsPage,
})
const guideRoute = createRoute({
getParentRoute: () => rootRoute,
getParentRoute: () => authenticatedRoute,
path: "/guide",
component: GuidePage,
})
const routeTree = rootRoute.addChildren([
indexRoute,
dataSourcesRoute,
widgetsRoute,
layoutRoute,
presetsRoute,
guideRoute,
loginRoute,
authenticatedRoute.addChildren([
indexRoute,
dataSourcesRoute,
widgetsRoute,
layoutRoute,
presetsRoute,
guideRoute,
]),
])
export const router = createRouter({ routeTree })