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:
69
src/components/AuthControls.tsx
Normal file
69
src/components/AuthControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user