Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 20 additions & 14 deletions packages/web/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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();
Expand All @@ -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) => {
Expand Down
80 changes: 80 additions & 0 deletions packages/web/src/lib/__tests__/authStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 5 additions & 6 deletions packages/web/src/lib/apollo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -28,7 +29,7 @@ const httpLink = createHttpLink({
});

const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('authToken');
const token = getToken();
return {
headers: {
...headers,
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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}` : '',
};
Expand Down
109 changes: 109 additions & 0 deletions packages/web/src/lib/authStorage.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
31 changes: 21 additions & 10 deletions packages/web/src/pages/Signin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Record<string, string>>({});
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<boolean | null>(null);
const [magicLinkEmailValid, setMagicLinkEmailValid] = useState<boolean | null>(null);
const [loginAttempts, setLoginAttempts] = useState(0);
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -804,10 +810,10 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea
{errors.password && <p id="password-error" className="mt-1 text-xs text-red-400" role="alert">{errors.password}</p>}
</div>

{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between">
<label className="flex items-center cursor-pointer group">
<div className="relative">
{/* Keep me logged in & Forgot Password */}
<div className="flex items-start justify-between gap-3">
<label className="flex items-start cursor-pointer group">
<div className="relative mt-0.5">
<input
type="checkbox"
checked={rememberMe}
Expand All @@ -820,11 +826,16 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea
)}
</div>
</div>
<span className="ml-3 text-sm text-gray-300 group-hover:text-gray-100 transition-colors">
Remember me
<span className="ml-3 leading-tight">
<span className="block text-sm text-gray-200 group-hover:text-gray-100 transition-colors">
Keep me logged in
</span>
<span className="block text-xs text-gray-400">
Stay signed in on this device — uncheck on shared computers
</span>
</span>
</label>
<Link to="/forgot-password" className="text-sm text-teal-400 hover:text-teal-300 transition-colors">
<Link to="/forgot-password" className="text-sm text-teal-400 hover:text-teal-300 transition-colors whitespace-nowrap">
Forgot password?
</Link>
</div>
Expand Down
Loading
Loading