migrated auth components from localStorage (token) to cookie-based auth

This commit is contained in:
Oliver Bryan
2026-01-09 05:35:48 +00:00
parent e074500a77
commit 76cbcb7b03
5 changed files with 41 additions and 55 deletions

View File

@@ -2,7 +2,7 @@ import type { UserRecord } from "@issue/shared";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Navigate, useLocation } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
import Loading from "@/components/loading"; import Loading from "@/components/loading";
import { getServerURL } from "@/lib/utils"; import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
export function Auth({ children }: { children: React.ReactNode }) { export function Auth({ children }: { children: React.ReactNode }) {
const [loggedIn, setLoggedIn] = useState<boolean>(); const [loggedIn, setLoggedIn] = useState<boolean>();
@@ -12,29 +12,22 @@ export function Auth({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
if (fetched.current) return; if (fetched.current) return;
fetched.current = true; fetched.current = true;
const token = localStorage.getItem("token");
if (!token) {
return setLoggedIn(false);
}
fetch(`${getServerURL()}/auth/me`, { fetch(`${getServerURL()}/auth/me`, {
headers: { Authorization: `Bearer ${token}` }, credentials: "include",
}) })
.then(async (res) => { .then(async (res) => {
if (!res.ok) { if (!res.ok) {
throw new Error(`auth check failed: ${res.status}`); throw new Error(`auth check failed: ${res.status}`);
} }
return (await res.json()) as UserRecord; const data = (await res.json()) as { user: UserRecord; csrfToken: string };
})
.then((data) => {
setLoggedIn(true); setLoggedIn(true);
localStorage.setItem("user", JSON.stringify(data)); setCsrfToken(data.csrfToken);
localStorage.setItem("user", JSON.stringify(data.user));
}) })
.catch(() => { .catch(() => {
setLoggedIn(false); setLoggedIn(false);
localStorage.removeItem("token"); clearAuth();
localStorage.removeItem("user");
localStorage.removeItem("selectedOrganisationId");
localStorage.removeItem("selectedProjectId");
}); });
}, []); }, []);

View File

@@ -2,45 +2,34 @@ import type { UserRecord } from "@issue/shared";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getServerURL } from "@/lib/utils"; import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
type AuthState = "unknown" | "authenticated" | "unauthenticated"; type AuthState = "unknown" | "authenticated" | "unauthenticated";
export default function Landing() { export default function Landing() {
const [authState, setAuthState] = useState<AuthState>(() => { const [authState, setAuthState] = useState<AuthState>("unknown");
// if token exists, assume authenticated until verified
return localStorage.getItem("token") ? "unknown" : "unauthenticated";
});
const verifiedRef = useRef(false); const verifiedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (verifiedRef.current) return; if (verifiedRef.current) return;
verifiedRef.current = true; verifiedRef.current = true;
const token = localStorage.getItem("token");
if (!token) {
setAuthState("unauthenticated");
return;
}
// verify token in background
fetch(`${getServerURL()}/auth/me`, { fetch(`${getServerURL()}/auth/me`, {
headers: { Authorization: `Bearer ${token}` }, credentials: "include",
}) })
.then(async (res) => { .then(async (res) => {
if (res.ok) { if (res.ok) {
const user = (await res.json()) as UserRecord; const data = (await res.json()) as { user: UserRecord; csrfToken: string };
localStorage.setItem("user", JSON.stringify(user)); localStorage.setItem("user", JSON.stringify(data.user));
setCsrfToken(data.csrfToken);
setAuthState("authenticated"); setAuthState("authenticated");
} else { } else {
localStorage.removeItem("token"); clearAuth();
localStorage.removeItem("user");
setAuthState("unauthenticated"); setAuthState("unauthenticated");
} }
}) })
.catch(() => { .catch(() => {
localStorage.removeItem("token"); clearAuth();
localStorage.removeItem("user");
setAuthState("unauthenticated"); setAuthState("unauthenticated");
}); });
}, []); }, []);

View File

@@ -1,7 +1,7 @@
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { clearAuth, cn, getCsrfToken, getServerURL } from "@/lib/utils";
export default function LogOutButton({ export default function LogOutButton({
noStyle = false, noStyle = false,
@@ -12,12 +12,20 @@ export default function LogOutButton({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const logOut = () => { const logOut = async () => {
localStorage.removeItem("token"); const csrfToken = getCsrfToken();
localStorage.removeItem("user"); const headers: HeadersInit = {};
localStorage.removeItem("selectedOrganisationId"); if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
localStorage.removeItem("selectedProjectId");
try {
await fetch(`${getServerURL()}/auth/logout`, {
method: "POST",
headers,
credentials: "include",
});
} catch {}
clearAuth();
navigate(0); navigate(0);
}; };

View File

@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { UploadAvatar } from "@/components/upload-avatar"; import { UploadAvatar } from "@/components/upload-avatar";
import { capitalise, cn, getServerURL } from "@/lib/utils"; import { capitalise, cn, getServerURL, setCsrfToken } from "@/lib/utils";
const DEMO_USERS = [ const DEMO_USERS = [
{ name: "User 1", username: "u1", password: "a" }, { name: "User 1", username: "u1", password: "a" },
@@ -43,14 +43,14 @@ export default function LogInForm() {
fetch(`${getServerURL()}/auth/login`, { fetch(`${getServerURL()}/auth/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
credentials: "include",
}) })
.then(async (res) => { .then(async (res) => {
if (res.status === 200) { if (res.status === 200) {
setError(""); setError("");
const data = await res.json(); const data = await res.json();
localStorage.setItem("token", data.token); setCsrfToken(data.csrfToken);
localStorage.setItem("user", JSON.stringify(data.user)); localStorage.setItem("user", JSON.stringify(data.user));
const next = searchParams.get("next") || "/app"; const next = searchParams.get("next") || "/app";
navigate(next, { replace: true }); navigate(next, { replace: true });
@@ -82,12 +82,13 @@ export default function LogInForm() {
password, password,
avatarURL, avatarURL,
}), }),
credentials: "include",
}) })
.then(async (res) => { .then(async (res) => {
if (res.status === 200) { if (res.status === 200) {
setError(""); setError("");
const data = await res.json(); const data = await res.json();
localStorage.setItem("token", data.token); setCsrfToken(data.csrfToken);
localStorage.setItem("user", JSON.stringify(data.user)); localStorage.setItem("user", JSON.stringify(data.user));
const next = searchParams.get("next") || "/app"; const next = searchParams.get("next") || "/app";
navigate(next, { replace: true }); navigate(next, { replace: true });

View File

@@ -1,8 +1,9 @@
import type { UserRecord } from "@issue/shared";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import Loading from "@/components/loading"; import Loading from "@/components/loading";
import LogInForm from "@/components/login-form"; import LogInForm from "@/components/login-form";
import { getServerURL } from "@/lib/utils"; import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -14,24 +15,18 @@ export default function LoginPage() {
if (checkedRef.current) return; if (checkedRef.current) return;
checkedRef.current = true; checkedRef.current = true;
const token = localStorage.getItem("token");
if (!token) {
setChecking(false);
return;
}
fetch(`${getServerURL()}/auth/me`, { fetch(`${getServerURL()}/auth/me`, {
headers: { Authorization: `Bearer ${token}` }, credentials: "include",
}) })
.then(async (res) => { .then(async (res) => {
if (res.ok) { if (res.ok) {
// logged in, redirect to next if defined const data = (await res.json()) as { user: UserRecord; csrfToken: string };
// fallback to /app setCsrfToken(data.csrfToken);
localStorage.setItem("user", JSON.stringify(data.user));
const next = searchParams.get("next") || "/app"; const next = searchParams.get("next") || "/app";
navigate(next, { replace: true }); navigate(next, { replace: true });
} else { } else {
localStorage.removeItem("token"); clearAuth();
localStorage.removeItem("user");
setChecking(false); setChecking(false);
} }
}) })