diff --git a/packages/web/src/contexts/AuthContext.tsx b/packages/web/src/contexts/AuthContext.tsx index deed328c..8d568a29 100644 --- a/packages/web/src/contexts/AuthContext.tsx +++ b/packages/web/src/contexts/AuthContext.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { useLazyQuery, gql } from '@apollo/client'; import { User, Team, AuthContextType } from '../types/auth'; +import { getToken, getUserRaw, setSession, setUser, clearSession, isKept } from '../lib/authStorage'; const ME_QUERY = gql` query Me { @@ -42,10 +43,11 @@ export function AuthProvider({ children }: AuthProviderProps) { if (meData.me) { setCurrentUser(meData.me); setCurrentTeam(meData.me.team); + // Refresh the cached user so it stays in sync with the server. + setUser(meData.me); } else { // ME query returned null, clear stale data - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); + clearSession(); setCurrentUser(null); setCurrentTeam(null); } @@ -56,25 +58,26 @@ export function AuthProvider({ children }: AuthProviderProps) { useEffect(() => { if (meError) { // Token is invalid, clear it - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); + clearSession(); setCurrentUser(null); setCurrentTeam(null); setIsInitializing(false); } }, [meError]); - // Load saved user from localStorage and validate token on mount + // Load saved session (localStorage if "keep me logged in", else sessionStorage) + // and validate the token on mount. useEffect(() => { - const token = localStorage.getItem('authToken'); - const savedUser = localStorage.getItem('currentUser'); + const token = getToken(); + const savedUser = getUserRaw(); if (token) { if (savedUser) { try { JSON.parse(savedUser); } catch (error) { - localStorage.removeItem('currentUser'); + // Corrupt cache — drop it; the ME query will repopulate. + clearSession(); } } getMe(); @@ -83,21 +86,24 @@ export function AuthProvider({ children }: AuthProviderProps) { } }, [getMe]); - const login = (user: User, token?: string) => { + // keepLoggedIn defaults to the user's last choice (true unless they opted out) + // so callers that don't pass it (e.g. guest login) honour the preference. + const login = (user: User, token?: string, keepLoggedIn: boolean = isKept()) => { setCurrentUser(user); setCurrentTeam(user.team || null); - + if (token) { - localStorage.setItem('authToken', token); + setSession(token, user, keepLoggedIn); + } else { + // No new token (e.g. a user refresh) — just update the cached user. + setUser(user); } - localStorage.setItem('currentUser', JSON.stringify(user)); }; const logout = () => { setCurrentUser(null); setCurrentTeam(null); - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); + clearSession(); }; const switchUser = (_userId: string) => { diff --git a/packages/web/src/lib/__tests__/authStorage.test.ts b/packages/web/src/lib/__tests__/authStorage.test.ts new file mode 100644 index 00000000..c12d2ca2 --- /dev/null +++ b/packages/web/src/lib/__tests__/authStorage.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getToken, + getUserRaw, + isKept, + setSession, + setToken, + setUser, + clearSession, +} from '../authStorage'; + +const TOKEN = 'authToken'; +const USER = 'currentUser'; + +describe('authStorage', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + it('keep=true persists the session in localStorage (survives restart)', () => { + setSession('tok-1', { id: 'u1' }, true); + expect(localStorage.getItem(TOKEN)).toBe('tok-1'); + expect(sessionStorage.getItem(TOKEN)).toBeNull(); + expect(getToken()).toBe('tok-1'); + expect(JSON.parse(getUserRaw()!)).toEqual({ id: 'u1' }); + expect(isKept()).toBe(true); + }); + + it('keep=false stores the session only in sessionStorage', () => { + setSession('tok-2', { id: 'u2' }, false); + expect(sessionStorage.getItem(TOKEN)).toBe('tok-2'); + expect(localStorage.getItem(TOKEN)).toBeNull(); + expect(getToken()).toBe('tok-2'); + expect(isKept()).toBe(false); + }); + + it('switching keep clears the stale copy in the other store', () => { + setSession('tok-keep', { id: 'a' }, true); + setSession('tok-session', { id: 'a' }, false); + expect(localStorage.getItem(TOKEN)).toBeNull(); // old persistent copy gone + expect(sessionStorage.getItem(TOKEN)).toBe('tok-session'); + expect(getToken()).toBe('tok-session'); + }); + + it('defaults to "kept" when no preference was ever recorded', () => { + expect(isKept()).toBe(true); + }); + + it('getToken prefers localStorage over sessionStorage', () => { + sessionStorage.setItem(TOKEN, 'session-tok'); + localStorage.setItem(TOKEN, 'local-tok'); + expect(getToken()).toBe('local-tok'); + }); + + it('setToken stores just the token (user populated later) with the keep flag', () => { + setToken('magic-tok', true); + expect(localStorage.getItem(TOKEN)).toBe('magic-tok'); + expect(getUserRaw()).toBeNull(); + expect(isKept()).toBe(true); + }); + + it('setUser updates the cached user in whichever store holds the token', () => { + setSession('tok', { id: 'old' }, false); + setUser({ id: 'new' }); + expect(JSON.parse(sessionStorage.getItem(USER)!)).toEqual({ id: 'new' }); + expect(localStorage.getItem(USER)).toBeNull(); + }); + + it('clearSession removes token + user from BOTH stores but keeps the preference', () => { + setSession('tok', { id: 'a' }, false); + clearSession(); + expect(getToken()).toBeNull(); + expect(getUserRaw()).toBeNull(); + expect(localStorage.getItem(TOKEN)).toBeNull(); + expect(sessionStorage.getItem(TOKEN)).toBeNull(); + // The keep preference is intentionally retained for the next login. + expect(isKept()).toBe(false); + }); +}); diff --git a/packages/web/src/lib/apollo.ts b/packages/web/src/lib/apollo.ts index 17093cda..7ade50ac 100644 --- a/packages/web/src/lib/apollo.ts +++ b/packages/web/src/lib/apollo.ts @@ -4,6 +4,7 @@ import { onError } from '@apollo/client/link/error'; import { getMainDefinition } from '@apollo/client/utilities'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { createClient } from 'graphql-ws'; +import { getToken, clearSession } from './authStorage'; // Dynamically construct GraphQL URLs based on current host const getGraphQLUrl = () => { @@ -28,7 +29,7 @@ const httpLink = createHttpLink({ }); const authLink = setContext((_, { headers }) => { - const token = localStorage.getItem('authToken'); + const token = getToken(); return { headers: { ...headers, @@ -44,8 +45,7 @@ const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) if (error.extensions?.code === 'UNAUTHENTICATED' || error.message.includes('Invalid token') || error.message.includes('Unauthorized')) { - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); + clearSession(); // Don't reload immediately - let auth context handle it return; } @@ -55,8 +55,7 @@ const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) if (networkError) { // Handle 401/403 network errors if ('statusCode' in networkError && (networkError.statusCode === 401 || networkError.statusCode === 403)) { - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); + clearSession(); // Don't reload immediately - let auth context handle it return; } @@ -67,7 +66,7 @@ const wsLink = new GraphQLWsLink( createClient({ url: getWebSocketUrl(), connectionParams: () => { - const token = localStorage.getItem('authToken'); + const token = getToken(); return { authorization: token ? `Bearer ${token}` : '', }; diff --git a/packages/web/src/lib/authStorage.ts b/packages/web/src/lib/authStorage.ts new file mode 100644 index 00000000..07b04aed --- /dev/null +++ b/packages/web/src/lib/authStorage.ts @@ -0,0 +1,109 @@ +/** + * Auth session storage with an explicit "keep me logged in" choice. + * + * - keep = true → persist the session in localStorage (survives browser restart; + * the default, so users don't have to log in over and over). + * - keep = false → store in sessionStorage (cleared when the tab/window closes). + * + * Reads prefer localStorage then fall back to sessionStorage, so a single + * accessor works regardless of which mode the session was created in. The token + * and user are always kept in the SAME store; the "keep" flag itself lives in + * localStorage so the Sign-in form can default the checkbox to the last choice. + */ + +const TOKEN_KEY = 'authToken'; +const USER_KEY = 'currentUser'; +const KEEP_KEY = 'graphdone:keepLoggedIn'; + +function safeGet(store: Storage, key: string): string | null { + try { + return store.getItem(key); + } catch { + return null; + } +} + +function safeRemove(store: Storage, key: string): void { + try { + store.removeItem(key); + } catch { + /* ignore (storage disabled / private mode) */ + } +} + +/** The auth token from whichever store holds the session (localStorage first). */ +export function getToken(): string | null { + return safeGet(localStorage, TOKEN_KEY) ?? safeGet(sessionStorage, TOKEN_KEY); +} + +/** The cached user JSON string from whichever store holds the session. */ +export function getUserRaw(): string | null { + return safeGet(localStorage, USER_KEY) ?? safeGet(sessionStorage, USER_KEY); +} + +/** Whether the user chose to stay logged in. Defaults to true (keep me in). */ +export function isKept(): boolean { + return safeGet(localStorage, KEEP_KEY) !== 'false'; +} + +/** + * Persist a session. `keep` decides the store; the other store is cleared so a + * stale copy never shadows the current one. + */ +export function setSession(token: string, user: unknown, keep: boolean): void { + const primary = keep ? localStorage : sessionStorage; + const secondary = keep ? sessionStorage : localStorage; + try { + primary.setItem(TOKEN_KEY, token); + primary.setItem(USER_KEY, typeof user === 'string' ? user : JSON.stringify(user)); + } catch { + /* ignore */ + } + safeRemove(secondary, TOKEN_KEY); + safeRemove(secondary, USER_KEY); + try { + localStorage.setItem(KEEP_KEY, keep ? 'true' : 'false'); + } catch { + /* ignore */ + } +} + +/** + * Persist just a token (the user is fetched separately, e.g. after a magic-link + * redirect). Sets the keep store + flag the same way as setSession. + */ +export function setToken(token: string, keep: boolean): void { + const primary = keep ? localStorage : sessionStorage; + const secondary = keep ? sessionStorage : localStorage; + try { + primary.setItem(TOKEN_KEY, token); + } catch { + /* ignore */ + } + safeRemove(secondary, TOKEN_KEY); + safeRemove(secondary, USER_KEY); + try { + localStorage.setItem(KEEP_KEY, keep ? 'true' : 'false'); + } catch { + /* ignore */ + } +} + +/** Update just the cached user (e.g. after a profile refresh), in-place. */ +export function setUser(user: unknown): void { + const raw = typeof user === 'string' ? user : JSON.stringify(user); + const store = safeGet(localStorage, TOKEN_KEY) != null ? localStorage : sessionStorage; + try { + store.setItem(USER_KEY, raw); + } catch { + /* ignore */ + } +} + +/** Clear the session from BOTH stores (leaves the keep-preference intact). */ +export function clearSession(): void { + for (const store of [localStorage, sessionStorage]) { + safeRemove(store, TOKEN_KEY); + safeRemove(store, USER_KEY); + } +} diff --git a/packages/web/src/pages/Signin.tsx b/packages/web/src/pages/Signin.tsx index 823bf719..2f48ccff 100644 --- a/packages/web/src/pages/Signin.tsx +++ b/packages/web/src/pages/Signin.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, gql } from '@apollo/client'; import { Eye, EyeOff, ArrowRight, Mail, Lock, Users, Github, Zap, Check, CheckCircle, XCircle, AlertTriangle, Shield } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; +import { setToken } from '../lib/authStorage'; import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { GuestModeDialog } from '../components/GuestModeDialog'; import { PasswordRequirements } from '../components/PasswordRequirements'; @@ -97,7 +98,9 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea const [magicLinkSent, setMagicLinkSent] = useState(false); const [magicLinkLoading, setMagicLinkLoading] = useState(false); const [errors, setErrors] = useState>({}); - const [rememberMe, setRememberMe] = useState(false); + // Default ON — keep users signed in on this device so they don't have to log + // in over and over (clearly labelled below; uncheck for a session-only login). + const [rememberMe, setRememberMe] = useState(true); const [emailValid, setEmailValid] = useState(null); const [magicLinkEmailValid, setMagicLinkEmailValid] = useState(null); const [loginAttempts, setLoginAttempts] = useState(0); @@ -179,7 +182,10 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea setErrors({ submit: 'Authentication failed. Please try again.' }); } } else if (token) { - localStorage.setItem('authToken', token); + // Magic-link sign-in: keep them signed in on this device by default + // (they clicked their own link). The user is loaded by the ME query + // after reload. + setToken(token, true); window.history.replaceState({}, '', '/login'); window.location.reload(); } @@ -239,7 +245,7 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea setLoginAttempts(0); localStorage.removeItem('loginAttempts'); localStorage.removeItem('lockoutTime'); - setAuthUser(data.login.user, data.login.token); + setAuthUser(data.login.user, data.login.token, rememberMe); navigate('/'); }, onError: (error) => { @@ -804,10 +810,10 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea {errors.password && } - {/* Remember Me & Forgot Password */} -
-