feat(auth): add Discord OAuth sign-in and user timezones

Add persistent user and session storage, protected profile requests, and React sign-in controls. Capture and preserve each user's timezone, and load development and production configuration before migrations and server startup.
This commit is contained in:
syntaxbullet
2026-09-04 08:29:48 +02:00
parent 29bf75760b
commit d3cbf2f7dc
22 changed files with 997 additions and 24 deletions

View File

@@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import { useLocation } from "react-router";
import type { PublicUser } from "../shared/user";
const authErrors: Record<string, string> = {
not_configured: "Discord sign-in is not configured yet.",
invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.",
denied: "Discord sign-in was cancelled. You can try again when ready.",
invalid_code: "Discord did not return a sign-in code. Please try again.",
discord_unavailable: "Could not complete Discord sign-in. Please try again.",
};
export function AuthControls() {
const [user, setUser] = useState<PublicUser | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { search } = useLocation();
const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""];
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch("/api/me", { signal: controller.signal });
if (response.status === 401) return;
if (!response.ok) throw new Error("Could not load your account. Please reload to try again.");
setUser(await response.json());
} catch (error) {
if (!controller.signal.aborted) setError(error instanceof Error ? error.message : "Could not load your account.");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}
void loadUser();
return () => controller.abort();
}, []);
function signIn() {
let timezone = "UTC";
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
}
async function signOut() {
setBusy(true);
setError("");
try {
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Could not sign out. Please try again.");
setUser(null);
} catch (error) {
setError(error instanceof Error ? error.message : "Could not sign out.");
} finally { setBusy(false); }
}
return (
<section aria-label="Account">
{loading ? <p role="status">Loading account</p> : user ? (
<p>
Signed in as <strong>{user.displayName}</strong> · {user.timezone}{" "}
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
<a href="/api/me">View my profile</a>
</p>
) : <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
{(error || signInError) && <p role="alert">{error || signInError}</p>}
</section>
);
}