diff --git a/docker/etc/nginx/conf.d/default.conf b/docker/etc/nginx/conf.d/default.conf
index fef1823a..ef69abc1 100644
--- a/docker/etc/nginx/conf.d/default.conf
+++ b/docker/etc/nginx/conf.d/default.conf
@@ -39,7 +39,24 @@ server {
proxy_read_timeout 5s;
}
+ location /.well-known {
+ proxy_pass http://127.0.0.1:4000;
+ proxy_http_version 1.1;
+
+ proxy_send_timeout 5s;
+ proxy_read_timeout 5s;
+ }
+
+ location /oauth {
+ proxy_pass http://127.0.0.1:4000;
+ proxy_http_version 1.1;
+
+ proxy_send_timeout 5s;
+ proxy_read_timeout 5s;
+ }
+
location / {
root /qr/http;
+ try_files $uri $uri.html $uri/ =404;
}
}
diff --git a/web/package-lock.json b/web/package-lock.json
index ca8eb218..236e573d 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -9,9 +9,13 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
+ "clsx": "^2.1.1",
+ "js-md5": "^0.8.3",
"next": "15.1.7",
+ "oidc-client-ts": "^3.5.0",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -1721,6 +1725,14 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -3655,6 +3667,11 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/js-md5": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
+ "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3725,6 +3742,14 @@
"node": ">=4.0"
}
},
+ "node_modules/jwt-decode": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
+ "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4167,6 +4192,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/oidc-client-ts": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz",
+ "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==",
+ "dependencies": {
+ "jwt-decode": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5343,6 +5379,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/tailwind-merge": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
+ "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
diff --git a/web/package.json b/web/package.json
index b7966ec4..dc7e8f23 100644
--- a/web/package.json
+++ b/web/package.json
@@ -11,9 +11,13 @@
"lint": "next lint"
},
"dependencies": {
+ "clsx": "^2.1.1",
+ "js-md5": "^0.8.3",
"next": "15.1.7",
+ "oidc-client-ts": "^3.5.0",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
diff --git a/web/src/app/(auth)/layout.tsx b/web/src/app/(auth)/layout.tsx
new file mode 100644
index 00000000..e7d51802
--- /dev/null
+++ b/web/src/app/(auth)/layout.tsx
@@ -0,0 +1,23 @@
+import { VT323, Silkscreen, Press_Start_2P, Geist, Geist_Mono } from "next/font/google"
+import '@/styles/globals.css'
+
+const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] })
+const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] })
+const pressStart2P = Press_Start_2P({ weight: "400", variable: "--font-press-start", subsets: ["latin"] })
+const vt323 = VT323({ weight: "400", variable: "--font-vt323", subsets: ["latin"] })
+const silkscreen = Silkscreen({ weight: ["400", "700"], variable: "--font-silkscreen", subsets: ["latin"] })
+
+export default function AuthLayout({ children }: { children: React.ReactNode }) {
+ const fontVars = `${geistSans.variable} ${geistMono.variable} ${pressStart2P.variable} ${vt323.variable} ${silkscreen.variable}`
+ return (
+
+ )
+}
diff --git a/web/src/app/(auth)/login/page.tsx b/web/src/app/(auth)/login/page.tsx
new file mode 100644
index 00000000..a6501d3b
--- /dev/null
+++ b/web/src/app/(auth)/login/page.tsx
@@ -0,0 +1,100 @@
+"use client"
+
+import { useState, Suspense } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import MetalPanelScrewed from "@/components/ui/metal-panel-screwed";
+import QrLogo from "@/components/ui/qr-logo";
+import HudInput from "@/components/ui/hud-input";
+import HudButton from "@/components/ui/hud-button";
+import StatusLine, { type StatusVariant } from "@/components/ui/status-line";
+import { getUserManager } from "@/lib/auth";
+import { hashCredentials } from "@/lib/utils";
+import Splash from "@/components/ui/splash";
+
+
+type LoginStatus = {
+ message: string
+ variant: StatusVariant
+}
+
+export default function LoginPage() {
+ return (
+
+
+
+ );
+}
+
+function LoginContent() {
+ const [username, setUsername] = useState("")
+ const [password, setPassword] = useState("")
+ const [status, setStatus] = useState({ message: "", variant: "idle" })
+
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ async function handleLogin() {
+ if (!username || !password) {
+ setStatus({ message: "fill in all fields", variant: "error" })
+ return
+ }
+
+ setStatus({ message: "authenticating...", variant: "busy" })
+
+ try {
+ await getUserManager().signinResourceOwnerCredentials({
+ username,
+ password: hashCredentials(username, password),
+ })
+ setStatus({ message: "OK", variant: "ok" })
+
+ await new Promise(r => setTimeout(r, 600))
+
+ const raw = searchParams.get("redirect") ?? "/"
+ const redirect = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/"
+
+ router.push(redirect)
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : "connection error"
+ setStatus({ message, variant: "error" })
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ setUsername(e.target.value)}
+ />
+ setPassword(e.target.value)}
+ />
+
+
+
+ LOGIN
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/src/components/ui/decorations/screw.tsx b/web/src/components/ui/decorations/screw.tsx
new file mode 100644
index 00000000..adba019f
--- /dev/null
+++ b/web/src/components/ui/decorations/screw.tsx
@@ -0,0 +1,29 @@
+type ScrewSlot = "tl" | "tr" | "bl" | "br"
+
+interface ScrewProps {
+ slot: ScrewSlot
+}
+
+const positions: Record = {
+ tl: "top-[10px] left-[10px]",
+ tr: "top-[10px] right-[10px]",
+ bl: "bottom-[10px] left-[10px]",
+ br: "bottom-[10px] right-[10px]",
+}
+
+export default function Screw({ slot }: ScrewProps) {
+ return (
+
+ {/* horizontal bar of the Phillips cross */}
+
+ {/* vertical bar */}
+
+
+ )
+}
diff --git a/web/src/components/ui/hud-button.tsx b/web/src/components/ui/hud-button.tsx
new file mode 100644
index 00000000..496889ff
--- /dev/null
+++ b/web/src/components/ui/hud-button.tsx
@@ -0,0 +1,52 @@
+import { cn } from "@/lib/utils"
+
+interface HudButtonProps {
+ children: React.ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ className?: string
+}
+
+export default function HudButton({
+ children,
+ onClick,
+ disabled = false,
+ className,
+}: HudButtonProps) {
+ return (
+
+ )
+}
diff --git a/web/src/components/ui/hud-input.tsx b/web/src/components/ui/hud-input.tsx
new file mode 100644
index 00000000..29b013bb
--- /dev/null
+++ b/web/src/components/ui/hud-input.tsx
@@ -0,0 +1,53 @@
+import { cn } from "@/lib/utils"
+
+interface HudInputProps {
+ label: string
+ id: string
+ type?: "text" | "password"
+ placeholder?: string
+ autoComplete?: string
+ className?: string
+ value: string
+ onChange: (e: React.ChangeEvent) => void
+}
+
+export default function HudInput({
+ label,
+ id,
+ type = "text",
+ placeholder,
+ autoComplete,
+ className,
+ value,
+ onChange,
+}: HudInputProps) {
+ return (
+
+
+
+
+ )
+}
diff --git a/web/src/components/ui/metal-panel-screwed-h.tsx b/web/src/components/ui/metal-panel-screwed-h.tsx
new file mode 100644
index 00000000..0657c742
--- /dev/null
+++ b/web/src/components/ui/metal-panel-screwed-h.tsx
@@ -0,0 +1,18 @@
+import MetalPanel from "@/components/ui/metal-panel"
+import Screw from "@/components/ui/decorations/screw"
+import { cn } from "@/lib/utils"
+
+interface MetalPanelScrewedHProps {
+ children: React.ReactNode
+ className?: string
+}
+
+export default function MetalPanelScrewedH({ children, className }: MetalPanelScrewedHProps) {
+ return (
+
+
+
+ {children}
+
+ )
+}
diff --git a/web/src/components/ui/metal-panel-screwed.tsx b/web/src/components/ui/metal-panel-screwed.tsx
new file mode 100644
index 00000000..aa584f3d
--- /dev/null
+++ b/web/src/components/ui/metal-panel-screwed.tsx
@@ -0,0 +1,20 @@
+import MetalPanel from "@/components/ui/metal-panel"
+import Screw from "@/components/ui/decorations/screw"
+import { cn } from "@/lib/utils"
+
+interface MetalPanelScrewedProps {
+ children: React.ReactNode
+ className?: string
+}
+
+export default function MetalPanelScrewed({ children, className }: MetalPanelScrewedProps) {
+ return (
+
+
+
+
+
+ {children}
+
+ )
+}
diff --git a/web/src/components/ui/metal-panel.tsx b/web/src/components/ui/metal-panel.tsx
new file mode 100644
index 00000000..275f046f
--- /dev/null
+++ b/web/src/components/ui/metal-panel.tsx
@@ -0,0 +1,21 @@
+import { cn } from "@/lib/utils"
+
+interface MetalPanelProps {
+ children: React.ReactNode
+ className?: string
+}
+
+export default function MetalPanel({ children, className }: MetalPanelProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/web/src/components/ui/qr-logo.tsx b/web/src/components/ui/qr-logo.tsx
new file mode 100644
index 00000000..a88621d4
--- /dev/null
+++ b/web/src/components/ui/qr-logo.tsx
@@ -0,0 +1,15 @@
+export default function QrLogo() {
+ return (
+
+
+
+ QUADRADIUS+
+
+
+ )
+}
diff --git a/web/src/components/ui/splash.tsx b/web/src/components/ui/splash.tsx
new file mode 100644
index 00000000..44626505
--- /dev/null
+++ b/web/src/components/ui/splash.tsx
@@ -0,0 +1,39 @@
+"use client"
+
+import { useState, useEffect } from "react"
+
+const DEFAULT_SPLASH = "Now with 100% more squares"
+
+export default function Splash() {
+ const [splash, setSplash] = useState("")
+
+ useEffect(() => {
+ async function loadSplash() {
+ try {
+ const res = await fetch("/splashes.txt")
+ if (!res.ok) return
+ const text = await res.text()
+ const lines = text.split("\n").map((s: string) => s.trim()).filter(Boolean)
+ if (lines.length > 0)
+ setSplash(lines[Math.floor(Math.random() * lines.length)])
+ } catch {
+ setSplash(DEFAULT_SPLASH)
+ }
+ }
+ loadSplash()
+ }, [])
+
+ return (
+
+ {splash}
+
+ )
+}
diff --git a/web/src/components/ui/status-line.tsx b/web/src/components/ui/status-line.tsx
new file mode 100644
index 00000000..a18f2fc1
--- /dev/null
+++ b/web/src/components/ui/status-line.tsx
@@ -0,0 +1,34 @@
+import { cn } from "@/lib/utils"
+
+
+interface StatusLineProps {
+ message?: string
+ variant?: StatusVariant
+ className?: string
+}
+
+const variantClasses: Record = {
+ idle: "text-[var(--foreground)]",
+ busy: "text-status-busy",
+ ok: "text-status-ok",
+ error: "text-status-error",
+}
+export type StatusVariant = "idle" | "busy" | "ok" | "error"
+
+export default function StatusLine({
+ message = "",
+ variant = "idle",
+ className,
+}: StatusLineProps) {
+ return (
+
+ {message}
+
+ )
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
new file mode 100644
index 00000000..75e71ada
--- /dev/null
+++ b/web/src/lib/api.ts
@@ -0,0 +1,31 @@
+import { getUserManager } from "./auth"
+
+export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise {
+ const manager = getUserManager()
+ let user = await manager.getUser()
+
+ const doFetch = (accessToken?: string) =>
+ fetch(input, {
+ ...init,
+ headers: {
+ "Content-Type": "application/json",
+ ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
+ ...init?.headers,
+ },
+ })
+
+ let res = await doFetch(user?.access_token)
+
+ if (res.status === 401) {
+ try {
+ user = await manager.signinSilent()
+ res = await doFetch(user?.access_token)
+ } catch {
+ await manager.removeUser()
+ window.location.href = "/login"
+ return res
+ }
+ }
+
+ return res
+}
diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts
new file mode 100644
index 00000000..f9b352f2
--- /dev/null
+++ b/web/src/lib/auth.ts
@@ -0,0 +1,27 @@
+import { UserManager, WebStorageStateStore } from "oidc-client-ts"
+
+let _userManager: UserManager | null = null
+
+export function getUserManager(): UserManager {
+ if (!_userManager) {
+ _userManager = new UserManager({
+ authority: window.location.origin,
+ client_id: "frontend",
+ redirect_uri: window.location.origin,
+ userStore: new WebStorageStateStore({ store: window.localStorage }),
+ loadUserInfo: false,
+ automaticSilentRenew: true,
+ accessTokenExpiringNotificationTimeInSeconds: 60,
+ })
+
+ _userManager.events.addSilentRenewError((err) => {
+ console.error("token refresh failed:", err)
+ })
+
+ _userManager.events.addAccessTokenExpired(() => {
+ _userManager?.removeUser()
+ window.location.href = "/login"
+ })
+ }
+ return _userManager
+}
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
new file mode 100644
index 00000000..e4f4da26
--- /dev/null
+++ b/web/src/lib/utils.ts
@@ -0,0 +1,11 @@
+import { ClassValue, clsx } from "clsx"
+import { md5 } from "js-md5"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+export function hashCredentials(username: string, password: string): string {
+ return md5(`++${username.toUpperCase()}++${password}`)
+}
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index e2bca192..8cf0651d 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -3,29 +3,51 @@
@tailwind utilities;
:root {
- --background: #34312c;
- --foreground: #746f67;
- --text-primary: #cccccc;
- --primary: #ab9c45;
- --secondary: #6c757d;
- --accent: #311a16;
- --neutral: #919993;
- --base-100: #b7b396;
+ /* App */
+ --background: #34312c;
+ --foreground: #746f67;
+ --text-primary: #cccccc;
+ --primary: #ab9c45;
+ --neutral: #919993;
+
+ /* Dark base scale */
--base-200: #311a16;
--base-300: #21120e;
--base-400: #120a07;
--base-500: #0a0a0a;
- --base-600: #050505;
- --base-700: #020202;
- --base-800: #010101;
--base-900: #24241f;
- /* New colors sampled from the image */
- --red-dark: #480000;
+ /* Reds */
+ --red-dark: #480000;
--red-medium: #a52a2a;
- --red-light: #cd5c5c;
- --button-bg: #8b1a1a;
- --button-text: #ffffff;
+ --red-light: #cd5c5c;
+
+ /* Metal panel */
+ --panel-bg: #dedad2;
+ --panel-edge: #b8b2a0;
+ --panel-groove: #a09a88;
+ --panel-shine: #f4f0e6;
+ --screw-bg: #c8c2b0;
+ --screw-shine: #f0ece0;
+
+ /* CRT inputs */
+ --input-bg: #3d0a0a;
+ --input-border: #6b1010;
+ --input-focus: #991515;
+ --input-text: #ff2222;
+ --input-placeholder: #661010;
+
+ /* Login button */
+ --btn-login-bg: #5c0f0f;
+ --btn-login-hover: #7a1414;
+ --btn-login-active: #3d0808;
+ --btn-login-border: #8b1a1a;
+ --btn-login-text: #ffaaaa;
+
+ /* Status line */
+ --status-error: #ff4444;
+ --status-ok: #44ff88;
+ --status-busy: #cc1a1a;
}
body {
@@ -102,3 +124,13 @@ body {
display: inline-block;
transform-origin: center;
}
+
+/* Login splash animation */
+@keyframes splash-pulse {
+ from { transform: rotate(16deg) scale(1); }
+ to { transform: rotate(18deg) scale(1.08); }
+}
+
+.splash-pulse {
+ animation: splash-pulse 0.9s ease-in-out infinite alternate;
+}
diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts
index 109807be..abf2d523 100644
--- a/web/tailwind.config.ts
+++ b/web/tailwind.config.ts
@@ -11,6 +11,39 @@ export default {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
+ "text-primary": "var(--text-primary)",
+ "red-dark": "var(--red-dark)",
+ "red-medium": "var(--red-medium)",
+ "red-light": "var(--red-light)",
+ "panel-bg": "var(--panel-bg)",
+ "panel-edge": "var(--panel-edge)",
+ "panel-groove": "var(--panel-groove)",
+ "input-bg": "var(--input-bg)",
+ "input-border": "var(--input-border)",
+ "input-focus": "var(--input-focus)",
+ "input-text": "var(--input-text)",
+ "btn-login-bg": "var(--btn-login-bg)",
+ "btn-login-hover":"var(--btn-login-hover)",
+ "btn-login-text": "var(--btn-login-text)",
+ "status-error": "var(--status-error)",
+ "status-ok": "var(--status-ok)",
+ "status-busy": "var(--status-busy)",
+ "base-200": "var(--base-200)",
+ "base-300": "var(--base-300)",
+ "base-400": "var(--base-400)",
+ "base-500": "var(--base-500)",
+ "base-900": "var(--base-900)",
+ },
+ fontFamily: {
+ vt323: ["var(--font-vt323)"],
+ silkscreen: ["var(--font-silkscreen)"],
+ mono: ["var(--font-geist-mono)"],
+ },
+ boxShadow: {
+ "panel": "0 0 0 1px var(--panel-shine) inset, 0 4px 8px rgba(0,0,0,0.8), 0 12px 40px rgba(0,0,0,0.7)",
+ "input": "inset 0 2px 6px rgba(0,0,0,0.5)",
+ "input-focus": "inset 0 2px 6px rgba(0,0,0,0.5), 0 0 0 2px rgba(153,21,21,0.25)",
+ "btn-press": "inset 0 2px 4px rgba(0,0,0,0.6), 0 0 18px rgba(255,34,34,0.5)",
},
},
},