[feat](web): serve the SPA and web actions on the single listener

Add the browser edge (contract rev 8.2/9): a Preact/Vite SPA under web/,
an embedded placeholder bundle, the /web/* auth and action handlers, and
the explicit browser-action allowlist. The SPA and the API share the one
plaintext :8080 listener; there is no second port and no peer edge, so
main.go also drops the mTLS listener and the identity mounts in the debug
compose fall away. /_manifest announces the web block only while the SPA is
mounted (honesty rule), always with the single service port.

The DSNs move to the shared database's scram-sha-256 credentials, matching
rev 9's removal of the PKI layer.

Committed web/bun.lock because web/package.json and vite.config.ts are new
in this commit. src/web/dist/index.html is a hand-written placeholder, not
a generated bundle: it keeps bare `go build`/`go vet` working from a clean
checkout, while /web/dist (the real build output) stays gitignored.
This commit is contained in:
2026-10-19 09:28:59 +01:00
parent c58e193038
commit a791232f18
46 changed files with 5392 additions and 61 deletions
+266
View File
@@ -0,0 +1,266 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { apiFetch, ApiError, jsonBody, toApiError } from '../lib/api';
import { setAuthed } from '../lib/auth';
import { currentSearch, didClientNavigate, getQueryParam, navigate, sanitizeNext } from '../router';
import { Spinner } from '../components/Spinner';
import type { User } from '../types';
type Probe = 'checking' | 'connected' | 'unreachable';
interface FieldError {
email?: string;
password?: string;
username?: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function Login() {
const [register, setRegister] = useState(() => getQueryParam(currentSearch.value, 'mode') === 'register');
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldError>({});
const [serverError, setServerError] = useState<string | null>(null);
const [probe, setProbe] = useState<Probe>('checking');
const emailRef = useRef<HTMLInputElement>(null);
const next = sanitizeNext(getQueryParam(currentSearch.value, 'next'));
useEffect(() => {
probeServer();
// autofocus Email only on client-side navigation, never cold page load
if (didClientNavigate) emailRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const probeServer = async () => {
setProbe('checking');
try {
await apiFetch('/web/auth/me', { refreshOn401: false });
setProbe('connected');
} catch {
setProbe('unreachable');
}
};
const toggleMode = () => {
const newMode = !register;
setRegister(newMode);
setFieldErrors({});
setServerError(null);
window.history.replaceState(
{},
'',
newMode ? '/login?mode=register' : '/login'
);
};
const validate = (): boolean => {
const errs: FieldError = {};
if (!EMAIL_RE.test(email.trim())) errs.email = 'Enter a valid email address';
if (!password) errs.password = 'Password is required';
if (register && !username.trim()) errs.username = 'Username is required';
setFieldErrors(errs);
return Object.keys(errs).length === 0;
};
const submit = async (e: Event) => {
e.preventDefault();
setServerError(null);
if (submitting) return;
if (!validate()) return;
setSubmitting(true);
const body = register
? { username: username.trim(), password, email: email.trim() }
: { email: email.trim(), password };
try {
const res = await apiFetch(register ? '/web/auth/register' : '/web/auth/login', {
refreshOn401: false,
...jsonBody(body),
});
if (res.ok) {
const data = (await res.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
navigate(next);
return;
}
throw new ApiError(0, 'Something went wrong');
}
const err = await toApiError(res);
throw err;
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Something went wrong';
setServerError(msg);
} finally {
setSubmitting(false);
}
};
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<a href="#login-main" className="skip-link">
Skip to content
</a>
<main id="login-main" className="w-full max-w-[400px] card p-6 sm:p-8" tabIndex={-1}>
<div className="flex flex-col items-center text-center">
<div className="logo-tile mb-4" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<path
d="M4 20h4l10.5-10.5a2.83 2.83 0 10-4-4L4 16v4z"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
<path d="M13.5 6.5l4 4" stroke="currentColor" strokeWidth="2" />
</svg>
</div>
<h1 className="text-[24px] font-bold leading-[1.25]" style={{ margin: 0 }}>
KeepNote
</h1>
<p className="mt-1 mb-6" style={{ color: 'var(--dn-muted)', fontSize: 15 }}>
Your notes on Dcntrlized
</p>
</div>
<form onSubmit={submit} noValidate>
<div className="mb-4">
<label className="field-label" htmlFor="email">
Email
</label>
<input
ref={emailRef}
id="email"
className="field"
type="email"
value={email}
placeholder="[email protected]"
autoComplete="email"
inputMode="email"
autoCapitalize="none"
spellcheck={false}
aria-invalid={!!fieldErrors.email}
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
/>
{fieldErrors.email && (
<p id="email-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.email}
</p>
)}
</div>
{register && (
<div className="mb-4">
<label className="field-label" htmlFor="username">
Username
</label>
<input
id="username"
className="field"
type="text"
value={username}
placeholder="johndoe"
autoComplete="username"
autoCapitalize="none"
spellcheck={false}
aria-invalid={!!fieldErrors.username}
aria-describedby={fieldErrors.username ? 'username-error' : undefined}
onInput={(e) => setUsername((e.target as HTMLInputElement).value)}
/>
{fieldErrors.username && (
<p id="username-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.username}
</p>
)}
</div>
)}
<div className="mb-5">
<label className="field-label" htmlFor="password">
Password
</label>
<input
id="password"
className="field"
type="password"
value={password}
placeholder="••••••••"
autoComplete={register ? 'new-password' : 'current-password'}
aria-invalid={!!fieldErrors.password}
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
/>
{fieldErrors.password && (
<p id="password-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.password}
</p>
)}
</div>
{serverError && (
<div className="mb-4 banner banner-error" role="alert">
{serverError}
</div>
)}
<button type="submit" className="btn btn-primary w-full" disabled={submitting}>
{submitting && <Spinner />}
{register ? 'Create account' : 'Sign in'}
</button>
</form>
<button
type="button"
className="mt-3 w-full"
aria-pressed={register}
style={{ minHeight: 44, color: 'var(--dn-accent)', fontWeight: 600, background: 'transparent', border: 'none', cursor: 'pointer' }}
onClick={toggleMode}
>
{register ? 'Already have an account? Sign in' : 'New here? Create an account'}
</button>
</main>
<footer
className="w-full max-w-[400px] mt-6 flex items-center gap-2"
role="status"
style={{ color: 'var(--dn-muted)', fontSize: 12, minHeight: 44 }}
>
<span
aria-hidden="true"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
probe === 'connected' ? 'var(--dn-ok)' : probe === 'unreachable' ? 'var(--dn-bad)' : 'var(--dn-muted)',
flex: 'none',
}}
/>
{probe === 'checking' && <span>Checking connection…</span>}
{probe === 'connected' && (
<span className="mono truncate" title={origin}>
Connected to {origin}
</span>
)}
{probe === 'unreachable' && (
<>
<span>Server unreachable</span>
<button
type="button"
className="banner-action"
style={{ marginLeft: 'auto', minHeight: 44 }}
onClick={probeServer}
>
Retry
</button>
</>
)}
</footer>
</div>
);
}