diff --git a/web/public/login-space.webp b/web/public/login-space.webp deleted file mode 100644 index 54f3bc1..0000000 Binary files a/web/public/login-space.webp and /dev/null differ diff --git a/web/src/features/auth/DropletGlass.tsx b/web/src/features/auth/DropletGlass.tsx new file mode 100644 index 0000000..f48b34b --- /dev/null +++ b/web/src/features/auth/DropletGlass.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState, type RefObject } from "react"; + +/** + * Displacement map for a rounded rect: neutral (128,128) in the middle, and + * within `bezel` px of the edge the sample point is pushed inward along the + * edge normal — the background bends at the rim like light through a droplet. + */ +function buildMap(w: number, h: number, radius: number, bezel: number): string { + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) return ""; + const img = ctx.createImageData(w, h); + const hx = w / 2; + const hy = h / 2; + const r = Math.min(radius, hx, hy); + + // Signed distance to the rounded-rect edge (negative inside). + const sdf = (x: number, y: number) => { + const qx = Math.abs(x - hx) - (hx - r); + const qy = Math.abs(y - hy) - (hy - r); + const ox = Math.max(qx, 0); + const oy = Math.max(qy, 0); + return Math.hypot(ox, oy) + Math.min(Math.max(qx, qy), 0) - r; + }; + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = (y * w + x) * 4; + const px = x + 0.5; + const py = y + 0.5; + const depth = -sdf(px, py); + let dx = 0; + let dy = 0; + if (depth < bezel) { + // Outward normal from the SDF gradient. + const gx = sdf(px + 1, py) - sdf(px - 1, py); + const gy = sdf(px, py + 1) - sdf(px, py - 1); + const gl = Math.hypot(gx, gy) || 1; + const t = 1 - Math.max(depth, 0) / bezel; + const f = t * t; // strongest right at the rim, fading inward + dx = -(gx / gl) * f; + dy = -(gy / gl) * f; + } + img.data[i] = Math.round(128 + dx * 127); + img.data[i + 1] = Math.round(128 + dy * 127); + img.data[i + 2] = 128; + img.data[i + 3] = 255; + } + } + ctx.putImageData(img, 0, 0); + return canvas.toDataURL(); +} + +/** + * SVG filter that refracts whatever sits behind `target`; reference it with + * `backdrop-filter: url(#id)` (Chromium only — other engines keep the plain + * blur declared before it). + */ +export function DropletGlassFilter({ + id, + target, + radius = 30, + bezel = 26, + strength = 46, +}: { + id: string; + target: RefObject; + radius?: number; + bezel?: number; + strength?: number; +}) { + const [map, setMap] = useState<{ url: string; w: number; h: number } | null>(null); + + useEffect(() => { + const el = target.current; + if (!el) return; + let timer = 0; + const rebuild = () => { + const w = Math.round(el.offsetWidth); + const h = Math.round(el.offsetHeight); + if (!w || !h) return; + setMap((prev) => (prev && prev.w === w && prev.h === h ? prev : { url: buildMap(w, h, radius, bezel), w, h })); + }; + const ro = new ResizeObserver(() => { + window.clearTimeout(timer); + timer = window.setTimeout(rebuild, 60); + }); + ro.observe(el); + rebuild(); + return () => { + ro.disconnect(); + window.clearTimeout(timer); + }; + }, [target, radius, bezel]); + + return ( + + ); +} diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx index 85c9003..51e3ec1 100644 --- a/web/src/features/auth/LoginPage.tsx +++ b/web/src/features/auth/LoginPage.tsx @@ -1,19 +1,46 @@ import { Alert, Button, Card, Form, Input, Label, TextField } from "@heroui/react"; -import { useState, type FormEvent } from "react"; +import { useRef, useState, type FormEvent, type KeyboardEvent } from "react"; import { Navigate, useSearchParams } from "react-router-dom"; import { useAuth } from "./AuthContext"; import { useI18n } from "../../i18n"; import { LoginShell } from "./LoginShell"; +import { DropletGlassFilter } from "./DropletGlass"; +import { ApiRequestError } from "../../services/api/http"; + +const LAST_USER_KEY = "netx.login.lastUser"; + +function readLastUser(): string { + try { + return localStorage.getItem(LAST_USER_KEY) || ""; + } catch { + return ""; + } +} + +/** Map the login endpoint's error codes to readable text. */ +function loginErrorText(err: unknown, t: (key: string, vars?: Record) => string): string { + if (err instanceof ApiRequestError) { + if (err.detail === "invalid_credentials") return t("auth.badCredentials"); + const d = err.detail as { error?: string; retry_after_sec?: number } | null; + if (d && typeof d === "object" && d.error === "login_locked") { + return t("auth.loginLocked", { minutes: Math.max(1, Math.ceil((d.retry_after_sec || 60) / 60)) }); + } + } + return String(err instanceof Error ? err.message : err) || t("auth.loginFailed"); +} export function LoginPage() { const { t } = useI18n(); const { ready, user, login } = useAuth(); const [params] = useSearchParams(); - const [username, setUsername] = useState("admin"); + const [lastUser] = useState(readLastUser); + const [username, setUsername] = useState(lastUser || "admin"); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); + const [capsLock, setCapsLock] = useState(false); + const cardRef = useRef(null); if (ready && user) { const next = params.get("next") || "/"; @@ -24,23 +51,32 @@ export function LoginPage() { e.preventDefault(); setError(""); setBusy(true); + const name = username.trim(); try { - await login(username.trim(), password); + await login(name, password); + try { + localStorage.setItem(LAST_USER_KEY, name); + } catch { + /* storage blocked — nothing to remember */ + } } catch (err) { - setError(String(err instanceof Error ? err.message : err) || t("auth.loginFailed")); + setError(loginErrorText(err, t)); } finally { setBusy(false); } }; + const syncCaps = (e: KeyboardEvent) => setCapsLock(e.getModifierState("CapsLock")); + return ( - + + - {t("auth.loginTitle")} -
+ + {t("auth.loginTitle")}
void onSubmit(e)}> @@ -48,28 +84,36 @@ export function LoginPage() { fullWidth name="username" value={username} - onChange={setUsername} + onChange={(v) => { + setUsername(v); + setError(""); + }} isDisabled={busy || !ready} - autoFocus + autoFocus={!lastUser} autoComplete="username" > -
+
{ + setPassword(v); + setError(""); + }} isDisabled={busy || !ready} + autoFocus={!!lastUser} autoComplete="current-password" > + {capsLock ? {t("auth.capsLock")} : null}