[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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user