diff --git a/packages/frontend/src/app.tsx b/packages/frontend/src/app.tsx deleted file mode 100644 index 6a03a4f..0000000 --- a/packages/frontend/src/app.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { BrowserRouter, Route, Routes } from "react-router-dom"; -import { Auth } from "@/components/auth-provider"; -import NotFound from "@/pages/NotFound"; -import { ThemeProvider } from "@/components/theme-provider"; -import Index from "@/pages/Index"; -import Landing from "@/pages/Landing"; -import Login from "@/pages/Login"; -import Test from "@/pages/Test"; - -function App() { - return ( - - - - {/* public routes */} - } /> - } /> - - {/* authed routes */} - - - - } - /> - - - - } - /> - - } /> - - - - ); -} - -export default App; diff --git a/packages/frontend/src/components/account-dialog.tsx b/packages/frontend/src/components/account-dialog.tsx index 36d73ff..2458615 100644 --- a/packages/frontend/src/components/account-dialog.tsx +++ b/packages/frontend/src/components/account-dialog.tsx @@ -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(null); const [error, setError] = useState(""); const [submitAttempted, setSubmitAttempted] = useState(false); - const [userId, setUserId] = useState(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) => { diff --git a/packages/frontend/src/components/auth-provider.tsx b/packages/frontend/src/components/auth-provider.tsx deleted file mode 100644 index 5d350b4..0000000 --- a/packages/frontend/src/components/auth-provider.tsx +++ /dev/null @@ -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(); - 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 ; - } - - return ; -} diff --git a/packages/frontend/src/components/create-issue.tsx b/packages/frontend/src/components/create-issue.tsx index 43edeac..82a4c97 100644 --- a/packages/frontend/src/components/create-issue.tsx +++ b/packages/frontend/src/components/create-issue.tsx @@ -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; }) { - 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; } diff --git a/packages/frontend/src/components/create-organisation.tsx b/packages/frontend/src/components/create-organisation.tsx index 9106c92..eb2b52a 100644 --- a/packages/frontend/src/components/create-organisation.tsx +++ b/packages/frontend/src/components/create-organisation.tsx @@ -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; }) { - 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(); diff --git a/packages/frontend/src/components/create-project.tsx b/packages/frontend/src/components/create-project.tsx index bf2a465..21c2a4b 100644 --- a/packages/frontend/src/components/create-project.tsx +++ b/packages/frontend/src/components/create-project.tsx @@ -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; }) { - 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; diff --git a/packages/frontend/src/components/login-form.tsx b/packages/frontend/src/components/login-form.tsx index aa4106b..a45ee96 100644 --- a/packages/frontend/src/components/login-form.tsx +++ b/packages/frontend/src/components/login-form.tsx @@ -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 }); } diff --git a/packages/frontend/src/components/organisations-dialog.tsx b/packages/frontend/src/components/organisations-dialog.tsx index 134118c..d8439c5 100644 --- a/packages/frontend/src/components/organisations-dialog.tsx +++ b/packages/frontend/src/components/organisations-dialog.tsx @@ -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; }) { - const user = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord; + const { user } = useAuthenticatedSession(); const [open, setOpen] = useState(false); const [members, setMembers] = useState([]); diff --git a/packages/frontend/src/components/session-provider.tsx b/packages/frontend/src/components/session-provider.tsx new file mode 100644 index 0000000..ae78c05 --- /dev/null +++ b/packages/frontend/src/components/session-provider.tsx @@ -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(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(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 {children}; +} + +export function RequireAuth({ children }: { children: React.ReactNode }) { + const { user, isLoading } = useSession(); + const location = useLocation(); + + if (isLoading) { + return ; + } + + if (!user) { + const next = encodeURIComponent(location.pathname + location.search); + return ; + } + + return <>{children}; +} diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index b3e1bf9..6b9ba0f 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -1,10 +1,47 @@ import "./App.css"; import React from "react"; import ReactDOM from "react-dom/client"; -import App from "@/app"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { RequireAuth, SessionProvider } from "@/components/session-provider"; +import { ThemeProvider } from "@/components/theme-provider"; +import App from "@/pages/App"; +import Landing from "@/pages/Landing"; +import Login from "@/pages/Login"; +import NotFound from "@/pages/NotFound"; +import Test from "@/pages/Test"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + + + {/* public routes */} + } /> + } /> + + {/* authed routes */} + + + + } + /> + + + + } + /> + + } /> + + + + , ); diff --git a/packages/frontend/src/pages/Index.tsx b/packages/frontend/src/pages/App.tsx similarity index 96% rename from packages/frontend/src/pages/Index.tsx rename to packages/frontend/src/pages/App.tsx index c78bd9b..601266c 100644 --- a/packages/frontend/src/pages/Index.tsx +++ b/packages/frontend/src/pages/App.tsx @@ -16,6 +16,7 @@ import { OrganisationSelect } from "@/components/organisation-select"; import OrganisationsDialog from "@/components/organisations-dialog"; import { ProjectSelect } from "@/components/project-select"; import { ServerConfigurationDialog } from "@/components/server-configuration-dialog"; +import { useAuthenticatedSession } from "@/components/session-provider"; import SmallUserDisplay from "@/components/small-user-display"; import { Button } from "@/components/ui/button"; import { @@ -30,10 +31,8 @@ import { issue, organisation, project } from "@/lib/server"; const BREATHING_ROOM = 1; -function Index() { - const userData = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord; - - const [user, setUser] = useState(userData); +export default function App() { + const { user } = useAuthenticatedSession(); const organisationsRef = useRef(false); const [organisations, setOrganisations] = useState([]); @@ -47,11 +46,6 @@ function Index() { const [members, setMembers] = useState([]); - const refetchUser = async () => { - const userData = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord; - setUser(userData); - }; - const refetchOrganisations = async (options?: { selectOrganisationId?: number }) => { try { await organisation.byUser({ @@ -260,11 +254,7 @@ function Index() { - { - refetchUser(); - }} - /> + ); } - -export default Index; diff --git a/packages/frontend/src/pages/Landing.tsx b/packages/frontend/src/pages/Landing.tsx index 1ce76b8..ea4fab7 100644 --- a/packages/frontend/src/pages/Landing.tsx +++ b/packages/frontend/src/pages/Landing.tsx @@ -1,45 +1,16 @@ -import type { UserRecord } from "@issue/shared"; -import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; +import { useSession } from "@/components/session-provider"; import { Button } from "@/components/ui/button"; -import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils"; - -type AuthState = "unknown" | "authenticated" | "unauthenticated"; export default function Landing() { - const [authState, setAuthState] = useState("unknown"); - const verifiedRef = useRef(false); - - useEffect(() => { - if (verifiedRef.current) return; - verifiedRef.current = true; - - fetch(`${getServerURL()}/auth/me`, { - credentials: "include", - }) - .then(async (res) => { - if (res.ok) { - const data = (await res.json()) as { user: UserRecord; csrfToken: string }; - localStorage.setItem("user", JSON.stringify(data.user)); - setCsrfToken(data.csrfToken); - setAuthState("authenticated"); - } else { - clearAuth(); - setAuthState("unauthenticated"); - } - }) - .catch(() => { - clearAuth(); - setAuthState("unauthenticated"); - }); - }, []); + const { user, isLoading } = useSession(); return (
Issue
- {authState === "authenticated" ? ( + {!isLoading && user ? ( diff --git a/packages/frontend/src/pages/Login.tsx b/packages/frontend/src/pages/Login.tsx index d6bac3f..ad75c13 100644 --- a/packages/frontend/src/pages/Login.tsx +++ b/packages/frontend/src/pages/Login.tsx @@ -1,44 +1,29 @@ -import type { UserRecord } from "@issue/shared"; -import { useEffect, useRef, useState } from "react"; +import { useEffect } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import Loading from "@/components/loading"; import LogInForm from "@/components/login-form"; -import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils"; +import { useSession } from "@/components/session-provider"; export default function Login() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const [checking, setChecking] = useState(true); - const checkedRef = useRef(false); + const { user, isLoading } = useSession(); useEffect(() => { - if (checkedRef.current) return; - checkedRef.current = true; + if (!isLoading && user) { + const next = searchParams.get("next") || "/app"; + navigate(next, { replace: true }); + } + }, [user, isLoading, navigate, searchParams]); - fetch(`${getServerURL()}/auth/me`, { - credentials: "include", - }) - .then(async (res) => { - if (res.ok) { - const data = (await res.json()) as { user: UserRecord; csrfToken: string }; - setCsrfToken(data.csrfToken); - localStorage.setItem("user", JSON.stringify(data.user)); - const next = searchParams.get("next") || "/app"; - navigate(next, { replace: true }); - } else { - clearAuth(); - setChecking(false); - } - }) - .catch(() => { - setChecking(false); - }); - }, [navigate, searchParams]); - - if (checking) { + if (isLoading) { return ; } + if (user) { + return ; + } + return (