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.
267 lines
9.0 KiB
TypeScript
267 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
}
|