Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions e2e/tests/01-login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ test("wrong password shows an error", async ({ page }) => {
const person = await createPerson(["mail-users"])
await page.goto("/login")
await passwordStep(page, { ...person, password: "wrong-password-123" })
await expect(page.getByRole("alert")).toContainText("Неверное имя пользователя или пароль")
await expect(page.locator(".msg.error")).toContainText("Неверное имя пользователя или пароль")
})

test("password, TOTP enrolment, then every sign-in needs the second factor", async ({ page }) => {
Expand All @@ -38,7 +38,7 @@ test("password, TOTP enrolment, then every sign-in needs the second factor", asy
await expect(page.getByRole("heading", { name: new RegExp(`Привет, ${person.username}|Ещё шаг`) })).toBeVisible()

await totpStep(page, "000000")
await expect(page.getByRole("alert")).toContainText(/Код не подошёл|invalid/)
await expect(page.locator(".msg.error")).toContainText(/Код не подошёл|invalid/)

await totpStep(page, await freshTotp(secret))
await expect(page.getByRole("heading", { name: "Готово" })).toBeVisible()
Expand Down
Binary file added ui/src/app/favicon.ico
Binary file not shown.
5 changes: 1 addition & 4 deletions ui/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,6 @@ a:hover { color: var(--accent-hi); }
.langs a { color: var(--g3); }
.langs a[aria-current="true"] { color: var(--g1); }

.status { display: flex; gap: 8px; align-items: center; }
.status .dot { width: 5px; height: 5px; background: var(--accent); }
.status .on { color: var(--g2); }

.main { position: relative; z-index: 1; flex: 1; display: flex; min-height: 0; }

Expand Down Expand Up @@ -145,7 +142,7 @@ a:hover { color: var(--accent-hi); }
.bar { padding: 0 16px; }
.bar.top { height: 56px; }
.brand .name { font-size: 20px; }
.brand .sep, .brand .section, .status .off { display: none; }
.brand .sep, .brand .section { display: none; }
.left { padding: 24px 16px; }
.title { font-size: 44px; }
}
Expand Down
2 changes: 1 addition & 1 deletion ui/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
<Panel
path="~/identity/auth"
rd={rd}
step={refresh ? t("login.refresh.label") : t("login.label")}
step={refresh ? t("login.refresh.label") : undefined}
title={refresh ? t("login.refresh.title") : t("login.title")}
desc={refresh ? t("login.refresh.desc") : t("login.desc")}
>
Expand Down
18 changes: 7 additions & 11 deletions ui/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,6 @@ export async function Shell({
)}
</div>
<footer className="bar bottom">
<div className="status">
<span className="dot" aria-hidden="true" />
<span className="on">{t("footer.online")}</span>
<span className="off">{t("footer.encrypted")}</span>
</div>
<span>© {new Date().getFullYear()} BULBASHENKO.COM</span>
</footer>
<div className="scanlines" aria-hidden="true" />
Expand All @@ -68,7 +63,7 @@ export async function Panel({
children,
}: {
path: string
step: string
step?: string
title: string
desc?: string
rd?: string
Expand All @@ -83,17 +78,18 @@ export async function Panel({
</div>
<div className="content">
<div className="heading">
<div className="step">
<span className="dot" aria-hidden="true" />
<span>{step}</span>
</div>
{step && (
<div className="step">
<span className="dot" aria-hidden="true" />
<span>{step}</span>
</div>
)}
<h1 className="title">{title}</h1>
{desc && <p className="desc">{desc}</p>}
</div>
{children}
</div>
<div className="foot-row">
<span>{t("footer.secure")}</span>
<span className="rule" aria-hidden="true" />
<span>{t("footer.stack")}</span>
</div>
Expand Down
101 changes: 91 additions & 10 deletions ui/src/components/visual/Visuals.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { useEffect, useState } from "react"
import { useEffect, useState, useSyncExternalStore } from "react"

export type EyeMode = "idle" | "closed" | "scan" | "ok" | "error"

Expand All @@ -11,15 +11,92 @@ export function setEyeMode(mode: EyeMode) {
window.dispatchEvent(new CustomEvent<EyeMode>(EVENT, { detail: mode }))
}

const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches
const REDUCED = "(prefers-reduced-motion: reduce)"
// Must match the breakpoint that hides .right in globals.css: below it the eye's code is not even downloaded.
const WIDE = "(min-width: 1100px)"
// Set when WebGL failed or frames were too slow; kept for the tab session so later pages don't try again.
const DOWNGRADED = "visual:off"

/** The 3D eye drone rendered as ASCII (three.js, bundled). Skipped for people who ask for reduced motion. */
const listeners = new Set<() => void>()
let downgraded: boolean | undefined

function isDowngraded() {
if (downgraded === undefined) {
try {
downgraded = sessionStorage.getItem(DOWNGRADED) === "1"
} catch {
downgraded = false
}
}
return downgraded
}

function downgrade() {
if (isDowngraded()) return
downgraded = true
try {
sessionStorage.setItem(DOWNGRADED, "1")
} catch {}
listeners.forEach((l) => l())
}

function subscribe(onChange: () => void) {
const queries = [REDUCED, WIDE].map((q) => window.matchMedia(q))
queries.forEach((m) => m.addEventListener("change", onChange))
// The custom elements report a missing or lost WebGL2 context with this event.
if (listeners.size === 0) window.addEventListener("visual:fail", downgrade)
listeners.add(onChange)
return () => {
queries.forEach((m) => m.removeEventListener("change", onChange))
listeners.delete(onChange)
if (listeners.size === 0) window.removeEventListener("visual:fail", downgrade)
}
}

/** Animations run unless the person asks for reduced motion or Save-Data, WebGL2 is missing, or frames are too slow. */
function isLive() {
const saveData = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData
return !(isDowngraded() || saveData || window.matchMedia(REDUCED).matches)
}

const useLive = () => useSyncExternalStore(subscribe, isLive, () => false)
const useWide = () => useSyncExternalStore(subscribe, () => window.matchMedia(WIDE).matches, () => false)

/**
* Turns the animations off when the page can't hold its frame rate: the mean interval between animation frames
* stays above 35 ms (under ~28 fps) over a 3 s window. The first second after load and hidden tabs don't count.
*/
function useFrameWatchdog(active: boolean) {
useEffect(() => {
if (!active) return
let raf = 0, prev = 0, sum = 0, count = 0
const start = performance.now()
const tick = (now: number) => {
raf = requestAnimationFrame(tick)
const dt = now - prev
prev = now
// Gaps over 250 ms are a backgrounded tab or a one-off hitch, not a slow device.
if (now - start < 1000 || document.hidden || dt > 250) return
sum += dt
count++
if (sum < 3000) return
if (sum / count > 35) downgrade()
sum = count = 0
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [active])
}

/** The 3D eye drone rendered as ASCII (three.js, bundled). Wide screens only, like the panel that holds it. */
export function Eye({ initial = "idle" }: { initial?: EyeMode }) {
const live = useLive()
const wide = useWide()
const [ready, setReady] = useState(false)
const [mode, setMode] = useState<EyeMode>(initial)

useEffect(() => {
if (reducedMotion()) return
if (!live || !wide) return
let alive = true
import("./eye-ascii.js").then(() => alive && setReady(true))
const onMode = (event: Event) => setMode((event as CustomEvent<EyeMode>).detail)
Expand All @@ -32,22 +109,26 @@ export function Eye({ initial = "idle" }: { initial?: EyeMode }) {
window.removeEventListener(EVENT, onMode)
if (timer) clearTimeout(timer)
}
}, [initial])
}, [initial, live, wide])

if (!ready) return null
if (!live || !wide || !ready) return null
return <eye-ascii mode={mode} color="mono" framing="full" ramp="block" glitch="on" />
}

/** Dim drifting ASCII field behind everything. */
/** Dim drifting ASCII field behind everything. Also runs the frame-rate watchdog for the page. */
export function AsciiBackground() {
const live = useLive()
const [ready, setReady] = useState(false)
useFrameWatchdog(live)

useEffect(() => {
if (reducedMotion()) return
if (!live) return
let alive = true
import("./ascii-bg.js").then(() => alive && setReady(true))
return () => {
alive = false
}
}, [])
return ready ? <ascii-bg dim="0.4" /> : null
}, [live])

return live && ready ? <ascii-bg dim="0.4" /> : null
}
Loading
Loading