From 751f0ad47355a1460a384b5de1bb00ac18ee237f Mon Sep 17 00:00:00 2001 From: Fruktus Date: Fri, 3 Jul 2026 19:27:37 +0200 Subject: [PATCH] feat: add web app login page --- docker/etc/nginx/conf.d/default.conf | 17 +++ web/package-lock.json | 47 +++++++- web/package.json | 6 +- web/src/app/(auth)/layout.tsx | 23 ++++ web/src/app/(auth)/login/page.tsx | 100 ++++++++++++++++++ web/src/components/ui/decorations/screw.tsx | 29 +++++ web/src/components/ui/hud-button.tsx | 52 +++++++++ web/src/components/ui/hud-input.tsx | 53 ++++++++++ .../components/ui/metal-panel-screwed-h.tsx | 18 ++++ web/src/components/ui/metal-panel-screwed.tsx | 20 ++++ web/src/components/ui/metal-panel.tsx | 21 ++++ web/src/components/ui/qr-logo.tsx | 15 +++ web/src/components/ui/splash.tsx | 39 +++++++ web/src/components/ui/status-line.tsx | 34 ++++++ web/src/lib/api.ts | 31 ++++++ web/src/lib/auth.ts | 27 +++++ web/src/lib/utils.ts | 11 ++ web/src/styles/globals.css | 64 ++++++++--- web/tailwind.config.ts | 33 ++++++ 19 files changed, 622 insertions(+), 18 deletions(-) create mode 100644 web/src/app/(auth)/layout.tsx create mode 100644 web/src/app/(auth)/login/page.tsx create mode 100644 web/src/components/ui/decorations/screw.tsx create mode 100644 web/src/components/ui/hud-button.tsx create mode 100644 web/src/components/ui/hud-input.tsx create mode 100644 web/src/components/ui/metal-panel-screwed-h.tsx create mode 100644 web/src/components/ui/metal-panel-screwed.tsx create mode 100644 web/src/components/ui/metal-panel.tsx create mode 100644 web/src/components/ui/qr-logo.tsx create mode 100644 web/src/components/ui/splash.tsx create mode 100644 web/src/components/ui/status-line.tsx create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/lib/utils.ts 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 ( +
+
+ {children} +
+ ) +} 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)", }, }, },