SessionProvider: centralised state management

this replaces auth-provider, centralising user data

can be extended to keep additional data

allows for user data to propogate components throughout the app

provides useSession and useAuthenticatedSession()
This commit is contained in:
Oliver Bryan
2026-01-09 06:14:09 +00:00
parent 3d963579a3
commit ac0de68d47
13 changed files with 172 additions and 199 deletions

View File

@@ -1,6 +1,6 @@
import type { UserRecord } from "@issue/shared";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field";
@@ -8,7 +8,9 @@ import { Label } from "@/components/ui/label";
import { UploadAvatar } from "@/components/upload-avatar";
import { user } from "@/lib/server";
function AccountDialog({ onUpdate, trigger }: { onUpdate?: () => void; trigger?: ReactNode }) {
function AccountDialog({ trigger }: { trigger?: ReactNode }) {
const { user: currentUser, setUser } = useAuthenticatedSession();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [username, setUsername] = useState("");
@@ -16,24 +18,18 @@ function AccountDialog({ onUpdate, trigger }: { onUpdate?: () => void; trigger?:
const [avatarURL, setAvatarUrl] = useState<string | null>(null);
const [error, setError] = useState("");
const [submitAttempted, setSubmitAttempted] = useState(false);
const [userId, setUserId] = useState<number | null>(null);
useEffect(() => {
if (!open) return;
const userStr = localStorage.getItem("user");
if (userStr) {
const user = JSON.parse(userStr) as UserRecord;
setName(user.name);
setUsername(user.username);
setUserId(user.id);
setAvatarUrl(user.avatarURL || null);
}
setName(currentUser.name);
setUsername(currentUser.username);
setAvatarUrl(currentUser.avatarURL || null);
setPassword("");
setError("");
setSubmitAttempted(false);
}, [open]);
}, [open, currentUser]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -43,21 +39,15 @@ function AccountDialog({ onUpdate, trigger }: { onUpdate?: () => void; trigger?:
return;
}
if (!userId) {
setError("User not found");
return;
}
await user.update({
id: userId,
id: currentUser.id,
name: name.trim(),
password: password.trim(),
avatarURL,
onSuccess: (data) => {
setError("");
localStorage.setItem("user", JSON.stringify(data));
setUser(data);
setPassword("");
onUpdate?.();
setOpen(false);
},
onError: (errorMessage) => {

View File

@@ -1,44 +0,0 @@
import type { UserRecord } from "@issue/shared";
import { useEffect, useRef, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import Loading from "@/components/loading";
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
export function Auth({ children }: { children: React.ReactNode }) {
const [loggedIn, setLoggedIn] = useState<boolean>();
const fetched = useRef(false);
const location = useLocation();
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetch(`${getServerURL()}/auth/me`, {
credentials: "include",
})
.then(async (res) => {
if (!res.ok) {
throw new Error(`auth check failed: ${res.status}`);
}
const data = (await res.json()) as { user: UserRecord; csrfToken: string };
setLoggedIn(true);
setCsrfToken(data.csrfToken);
localStorage.setItem("user", JSON.stringify(data.user));
})
.catch(() => {
setLoggedIn(false);
clearAuth();
});
}, []);
if (loggedIn) {
return <>{children}</>;
}
if (loggedIn === false) {
const next = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?next=${next}`} replace />;
}
return <Loading message={"Checking authentication"} />;
}

View File

@@ -1,5 +1,6 @@
import type { UserRecord } from "@issue/shared";
import { type FormEvent, useState } from "react";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -26,7 +27,7 @@ export function CreateIssue({
trigger?: React.ReactNode;
completeAction?: (issueId: number) => void | Promise<void>;
}) {
const userId = JSON.parse(localStorage.getItem("user") || "{}").id as number | undefined;
const { user } = useAuthenticatedSession();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
@@ -61,7 +62,7 @@ export function CreateIssue({
return;
}
if (!userId) {
if (!user.id) {
setError("you must be logged in to create an issue");
return;
}

View File

@@ -1,4 +1,5 @@
import { type FormEvent, useState } from "react";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -28,7 +29,7 @@ export function CreateOrganisation({
trigger?: React.ReactNode;
completeAction?: (organisationId: number) => void | Promise<void>;
}) {
const userId = JSON.parse(localStorage.getItem("user") || "{}").id as number | undefined;
const { user } = useAuthenticatedSession();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
@@ -64,7 +65,7 @@ export function CreateOrganisation({
if (name.trim() === "" || name.trim().length > 16) return;
if (slug.trim() === "" || slug.trim().length > 16) return;
if (!userId) {
if (!user.id) {
setError("you must be logged in to create an organisation");
return;
}
@@ -75,7 +76,7 @@ export function CreateOrganisation({
name,
slug,
description,
userId,
userId: user.id,
onSuccess: async (data) => {
setOpen(false);
reset();

View File

@@ -1,5 +1,6 @@
import type { ProjectRecord } from "@issue/shared";
import { type FormEvent, useState } from "react";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -29,7 +30,7 @@ export function CreateProject({
trigger?: React.ReactNode;
completeAction?: (projectId: number) => void | Promise<void>;
}) {
const userId = JSON.parse(localStorage.getItem("user") || "{}").id as number | undefined;
const { user } = useAuthenticatedSession();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
@@ -64,7 +65,7 @@ export function CreateProject({
return;
}
if (!userId) {
if (!user.id) {
setError("you must be logged in to create a project");
return;
}
@@ -79,7 +80,7 @@ export function CreateProject({
await project.create({
key,
name,
creatorId: userId,
creatorId: user.id,
organisationId,
onSuccess: async (data) => {
const project = data as ProjectRecord;

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import Avatar from "@/components/avatar";
import { ServerConfigurationDialog } from "@/components/server-configuration-dialog";
import { useSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field";
@@ -20,6 +21,7 @@ const DEMO_USERS = [
export default function LogInForm() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { setUser } = useSession();
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
const [showWarning, setShowWarning] = useState(() => {
@@ -51,7 +53,7 @@ export default function LogInForm() {
setError("");
const data = await res.json();
setCsrfToken(data.csrfToken);
localStorage.setItem("user", JSON.stringify(data.user));
setUser(data.user);
const next = searchParams.get("next") || "/app";
navigate(next, { replace: true });
}
@@ -89,7 +91,7 @@ export default function LogInForm() {
setError("");
const data = await res.json();
setCsrfToken(data.csrfToken);
localStorage.setItem("user", JSON.stringify(data.user));
setUser(data.user);
const next = searchParams.get("next") || "/app";
navigate(next, { replace: true });
}

View File

@@ -1,9 +1,10 @@
import type { OrganisationMemberResponse, OrganisationResponse, UserRecord } from "@issue/shared";
import type { OrganisationMemberResponse, OrganisationResponse } from "@issue/shared";
import { Plus, X } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { AddMemberDialog } from "@/components/add-member-dialog";
import { OrganisationSelect } from "@/components/organisation-select";
import { useAuthenticatedSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -23,7 +24,7 @@ function OrganisationsDialog({
setSelectedOrganisation: (organisation: OrganisationResponse | null) => void;
refetchOrganisations: (options?: { selectOrganisationId?: number }) => Promise<void>;
}) {
const user = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord;
const { user } = useAuthenticatedSession();
const [open, setOpen] = useState(false);
const [members, setMembers] = useState<OrganisationMemberResponse[]>([]);

View File

@@ -0,0 +1,84 @@
import type { UserRecord } from "@issue/shared";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import Loading from "@/components/loading";
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
interface SessionContextValue {
user: UserRecord | null;
setUser: (user: UserRecord) => void;
isLoading: boolean;
}
const SessionContext = createContext<SessionContextValue | null>(null);
// for use outside RequireAuth
export function useSession(): SessionContextValue {
const context = useContext(SessionContext);
if (!context) {
throw new Error("useSession must be used within a SessionProvider");
}
return context;
}
// for use inside RequireAuth
export function useAuthenticatedSession(): { user: UserRecord; setUser: (user: UserRecord) => void } {
const { user, setUser } = useSession();
if (!user) {
throw new Error("useAuthenticatedSession must be used within RequireAuth");
}
return { user, setUser };
}
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [user, setUserState] = useState<UserRecord | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetched = useRef(false);
const setUser = useCallback((user: UserRecord) => {
setUserState(user);
localStorage.setItem("user", JSON.stringify(user));
}, []);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetch(`${getServerURL()}/auth/me`, {
credentials: "include",
})
.then(async (res) => {
if (!res.ok) {
throw new Error(`auth check failed: ${res.status}`);
}
const data = (await res.json()) as { user: UserRecord; csrfToken: string };
setUser(data.user);
setCsrfToken(data.csrfToken);
})
.catch(() => {
setUserState(null);
clearAuth();
})
.finally(() => {
setIsLoading(false);
});
}, [setUser]);
return <SessionContext.Provider value={{ user, setUser, isLoading }}>{children}</SessionContext.Provider>;
}
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useSession();
const location = useLocation();
if (isLoading) {
return <Loading message={"Checking authentication"} />;
}
if (!user) {
const next = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?next=${next}`} replace />;
}
return <>{children}</>;
}