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:
59
spa/src/api/auth.ts
Normal file
59
spa/src/api/auth.ts
Normal 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")
|
||||
}
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
79
spa/src/pages/login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user