From 9a3401ddb600e33f47c3b9332633a09a4925d3cc Mon Sep 17 00:00:00 2001 From: Corianas Date: Sun, 12 Jul 2026 21:58:15 +0800 Subject: [PATCH 1/2] Handle expired sessions instead of silently breaking A 401 from any request now tears the session down cleanly: the shim clears the token, broadcasts SIGNED_OUT so ProtectedRoute redirects to login, toasts why, and stashes the current path so login returns you there. Both the QueryBuilder (every .from() call) and a new apiFetch wrapper funnel through one handler, deduped against concurrent 401s. Consolidates the hand-rolled fetch+Bearer call sites (invoice PDF, bulk PDF, bulk send, api-keys, change-password, bill-import) onto apiFetch, dropping their manual localStorage token reads and dead getSession calls. Co-Authored-By: Claude Fable 5 --- src/components/MyProfileDialog.tsx | 8 +- .../purchases/ImportBillCSVDialog.tsx | 78 ++++++++----------- src/integrations/api/client.ts | 45 +++++++++++ src/integrations/supabase/client.ts | 2 +- src/pages/ApiKeys.tsx | 8 +- src/pages/InvoiceDetail.tsx | 5 +- src/pages/Invoices.tsx | 8 +- src/pages/Login.tsx | 4 +- 8 files changed, 89 insertions(+), 69 deletions(-) diff --git a/src/components/MyProfileDialog.tsx b/src/components/MyProfileDialog.tsx index 22da71a..131dfab 100644 --- a/src/components/MyProfileDialog.tsx +++ b/src/components/MyProfileDialog.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { supabase } from "@/integrations/supabase/client"; +import { supabase, apiFetch } from "@/integrations/supabase/client"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -15,8 +15,6 @@ interface MyProfileDialogProps { onOpenChange: (open: boolean) => void; } -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; - export default function MyProfileDialog({ open, onOpenChange }: MyProfileDialogProps) { const { toast } = useToast(); const { user } = useAuth(); @@ -133,12 +131,10 @@ export default function MyProfileDialog({ open, onOpenChange }: MyProfileDialogP setChangingPassword(true); try { - const token = localStorage.getItem('auth_token'); - const response = await fetch(`${API_URL}/auth/change-password`, { + const response = await apiFetch('/auth/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ currentPassword: passwordData.currentPassword, diff --git a/src/components/purchases/ImportBillCSVDialog.tsx b/src/components/purchases/ImportBillCSVDialog.tsx index 6d5c11a..a6b27e9 100644 --- a/src/components/purchases/ImportBillCSVDialog.tsx +++ b/src/components/purchases/ImportBillCSVDialog.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import { supabase } from '@/integrations/supabase/client'; +import { supabase, apiFetch } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -262,25 +262,19 @@ export default function ImportBillCSVDialog({ }); // Call the bill-import API - const { data: { session } } = await supabase.auth.getSession(); - - const response = await fetch( - `${import.meta.env.VITE_API_URL}/bill-import`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${session?.access_token}`, - }, - body: JSON.stringify({ - vendor_id: vendorId, - vendor_name: vendorName, - csv_data: hasHeaderRow ? rawData : [headers, ...rawData], - column_mapping: apiColumnMapping, - file_name: fileName, - }), - } - ); + const response = await apiFetch('/bill-import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + vendor_id: vendorId, + vendor_name: vendorName, + csv_data: hasHeaderRow ? rawData : [headers, ...rawData], + column_mapping: apiColumnMapping, + file_name: fileName, + }), + }); if (!response.ok) { const error = await response.json(); @@ -371,19 +365,13 @@ export default function ImportBillCSVDialog({ })); // Update the session with current selections - const { data: { session } } = await supabase.auth.getSession(); - - const updateResponse = await fetch( - `${import.meta.env.VITE_API_URL}/bill-import/${sessionId}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${session?.access_token}`, - }, - body: JSON.stringify({ matched_rows: updatedRows }), - } - ); + const updateResponse = await apiFetch(`/bill-import/${sessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ matched_rows: updatedRows }), + }); if (!updateResponse.ok) { const error = await updateResponse.json(); @@ -391,20 +379,16 @@ export default function ImportBillCSVDialog({ } // Confirm the import - const confirmResponse = await fetch( - `${import.meta.env.VITE_API_URL}/bill-import/${sessionId}/confirm`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${session?.access_token}`, - }, - body: JSON.stringify({ - save_mappings: true, - date: todayLocal() - }), - } - ); + const confirmResponse = await apiFetch(`/bill-import/${sessionId}/confirm`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + save_mappings: true, + date: todayLocal() + }), + }); if (!confirmResponse.ok) { const error = await confirmResponse.json(); diff --git a/src/integrations/api/client.ts b/src/integrations/api/client.ts index 7aedbb7..4d681ad 100644 --- a/src/integrations/api/client.ts +++ b/src/integrations/api/client.ts @@ -3,14 +3,18 @@ * This allows minimal changes to existing code while using a local SQLite backend */ +import { toast } from 'sonner'; + const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; // Token management let authToken: string | null = localStorage.getItem('auth_token'); let authStateListeners: ((event: string, session: any) => void)[] = []; +let sessionExpiredHandled = false; function setAuthToken(token: string | null) { authToken = token; + if (token) sessionExpiredHandled = false; if (token) { localStorage.setItem('auth_token', token); } else { @@ -28,6 +32,46 @@ function getAuthHeaders(): Record { return headers; } +// Handle a 401 from any request: tear down the session, notify listeners so +// ProtectedRoute redirects to /login, and let the user know why. Deduped so a +// burst of concurrent 401s (e.g. several in-flight requests) only fires once. +function handleUnauthorized() { + if (!authToken) return; + if (sessionExpiredHandled) return; + sessionExpiredHandled = true; + + try { + const here = window.location.pathname + window.location.search; + if (!here.startsWith('/login') && !here.startsWith('/signup')) { + sessionStorage.setItem('post_login_redirect', here); + } + } catch { + // sessionStorage unavailable (e.g. privacy mode) - not worth failing over + } + + setAuthToken(null); + authStateListeners.forEach((l) => l('SIGNED_OUT', null)); + toast.error('Your session has expired. Please sign in again.'); + + setTimeout(() => { + sessionExpiredHandled = false; + }, 2000); +} + +// Fetch wrapper for call sites that need direct access to the Response +// (blobs, custom status handling) instead of the QueryBuilder. Attaches the +// bearer token automatically and reacts to 401s the same way QueryBuilder does. +export async function apiFetch(path: string, options: RequestInit = {}): Promise { + const url = path.startsWith('http') ? path : `${API_URL}${path.startsWith('/') ? path : `/${path}`}`; + const headers = new Headers(options.headers || {}); + if (authToken && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${authToken}`); + } + const response = await fetch(url, { ...options, headers }); + if (response.status === 401) handleUnauthorized(); + return response; +} + // Response type matching Supabase interface ApiResponse { data: T | null; @@ -257,6 +301,7 @@ class QueryBuilder { if (!response.ok) { const errorData = await response.json().catch(() => ({ error: response.statusText })); + if (response.status === 401) handleUnauthorized(); return { data: null, error: { message: errorData.error || response.statusText } }; } diff --git a/src/integrations/supabase/client.ts b/src/integrations/supabase/client.ts index 13b4335..7a731f1 100644 --- a/src/integrations/supabase/client.ts +++ b/src/integrations/supabase/client.ts @@ -8,4 +8,4 @@ * import { supabase } from "@/integrations/supabase/client"; */ -export { supabase } from '@/integrations/api/client'; +export { supabase, apiFetch } from '@/integrations/api/client'; diff --git a/src/pages/ApiKeys.tsx b/src/pages/ApiKeys.tsx index b202c54..cea21e3 100644 --- a/src/pages/ApiKeys.tsx +++ b/src/pages/ApiKeys.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { supabase } from '@/integrations/supabase/client'; +import { supabase, apiFetch } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -61,8 +61,6 @@ const AVAILABLE_SCOPES = [ { value: 'banking', label: 'Banking' }, ]; -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; - export default function ApiKeys() { const { user } = useAuth(); const { isOwner, canWrite } = usePermissions(); @@ -189,13 +187,11 @@ export default function ApiKeys() { } const keyUserId = isOwner && newKeyUserId ? newKeyUserId : user?.id; - const token = localStorage.getItem('auth_token'); - const response = await fetch(`${API_URL}/auth/api-keys`, { + const response = await apiFetch('/auth/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ name: newKeyName, diff --git a/src/pages/InvoiceDetail.tsx b/src/pages/InvoiceDetail.tsx index de15f7f..a1195ce 100644 --- a/src/pages/InvoiceDetail.tsx +++ b/src/pages/InvoiceDetail.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; -import { supabase } from '@/integrations/supabase/client'; +import { supabase, apiFetch } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -553,11 +553,10 @@ export default function InvoiceDetail() { toast({ title: 'Generating PDF...', description: 'Please wait' }); try { - const response = await fetch(`${import.meta.env.VITE_API_URL}/functions/generate-invoice-pdf`, { + const response = await apiFetch('/functions/generate-invoice-pdf', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` }, body: JSON.stringify({ invoiceId: id }) }); diff --git a/src/pages/Invoices.tsx b/src/pages/Invoices.tsx index 29556c4..514f780 100644 --- a/src/pages/Invoices.tsx +++ b/src/pages/Invoices.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { supabase } from '@/integrations/supabase/client'; +import { supabase, apiFetch } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; @@ -156,11 +156,10 @@ export default function Invoices() { toast({ title: 'Generating PDF...', description: `Preparing ${selectedInvoices.length} invoice(s)` }); try { - const response = await fetch(`${import.meta.env.VITE_API_URL}/functions/generate-invoices-pdf`, { + const response = await apiFetch('/functions/generate-invoices-pdf', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` }, body: JSON.stringify({ invoiceIds: selectedInvoices.map(inv => inv.id) }) }); @@ -204,11 +203,10 @@ export default function Invoices() { let sent = 0; let failures: { invoiceNumber: string; reason: string }[] = []; try { - const response = await fetch(`${import.meta.env.VITE_API_URL}/mail/send-invoices`, { + const response = await apiFetch('/mail/send-invoices', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` }, body: JSON.stringify({ invoiceIds: selectedNonDrafts.map(inv => inv.id) }) }); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index b7d361c..c5f1a69 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -30,7 +30,9 @@ export default function Login() { variant: 'destructive', }); } else { - navigate('/'); + const redirect = sessionStorage.getItem('post_login_redirect'); + sessionStorage.removeItem('post_login_redirect'); + navigate(redirect && redirect.startsWith('/') ? redirect : '/'); } setLoading(false); }; From 848a6ed493e6e3da1f82b391263d136ff057c321 Mon Sep 17 00:00:00 2001 From: Corianas Date: Sun, 12 Jul 2026 22:05:43 +0800 Subject: [PATCH 2/2] Make team invites work without SMTP The invite form now actually creates the user via /auth/users (with the selected role) and surfaces the tokenised /signup?token= link so the invitee can set their password - previously it only fired a token-less email that no one could act on, and did nothing at all when SMTP was unconfigured. The link is shown with a copy button whenever email is unavailable (or alongside a successful send), and /mail/send-invite now emails that same tokenised link instead of a bare /signup URL. Adds an auth test for the role_id invite create -> accept round trip. Co-Authored-By: Claude Fable 5 --- server/__tests__/auth.test.ts | 32 ++++++++++++ server/routes/mail.ts | 8 +-- src/pages/Team.tsx | 95 ++++++++++++++++++++++++++++------- 3 files changed, 113 insertions(+), 22 deletions(-) diff --git a/server/__tests__/auth.test.ts b/server/__tests__/auth.test.ts index da53d05..804cdeb 100644 --- a/server/__tests__/auth.test.ts +++ b/server/__tests__/auth.test.ts @@ -143,4 +143,36 @@ describe('auth', () => { expect(res.status).toBe(400); }); + + // The Team.tsx invite flow creates the user with a role_id (no password) in + // one call, then the invitee hits accept-invite with the token from the + // response. This proves that round trip end-to-end, including that the + // invite is single-use (cleared) once redeemed. + it('creates an invited user with a role_id and completes the invite -> accept round trip', async () => { + const token = await login(app); + + const createRes = await request(app) + .post('/api/auth/users') + .set('Authorization', `Bearer ${token}`) + .send({ email: 'staffer@example.com', full_name: 'Staffer', role_id: 'role-staff' }); + + expect(createRes.status).toBe(200); + expect(createRes.body.invite_token).toBeTruthy(); + + const inviteToken = createRes.body.invite_token; + + const acceptRes = await request(app) + .post('/api/auth/accept-invite') + .send({ token: inviteToken, password: 'chosen-password-1' }); + + expect(acceptRes.status).toBe(200); + expect(acceptRes.body.token).toBeTruthy(); + + // The invite is cleared on redemption: reusing the same token now fails. + const reuseRes = await request(app) + .post('/api/auth/accept-invite') + .send({ token: inviteToken, password: 'another-password-2' }); + + expect(reuseRes.status).toBe(400); + }); }); diff --git a/server/routes/mail.ts b/server/routes/mail.ts index 38f8377..4ef1fbe 100644 --- a/server/routes/mail.ts +++ b/server/routes/mail.ts @@ -264,7 +264,7 @@ router.post('/send-invoices', authMiddleware, async (req: AuthRequest, res: Resp router.post('/send-invite', authMiddleware, async (req: AuthRequest, res: Response) => { const requireWrite = requirePermission('team', 'write'); await requireWrite(req, res, async () => { - const { email } = req.body; + const { email, inviteUrl } = req.body; if (!email) { res.status(400).json({ error: 'Email is required' }); @@ -277,9 +277,11 @@ router.post('/send-invite', authMiddleware, async (req: AuthRequest, res: Respon // Get inviter name const inviterName = req.user?.full_name || req.user?.email || 'Team Admin'; - // Build signup URL (use origin from request or env) + // Build signup URL: prefer the caller-supplied invite link (carries the + // invite token so the recipient can actually accept it), falling back to + // a token-less signup page if none was given. const origin = process.env.APP_URL || 'http://localhost:8080'; - const signupUrl = `${origin}/signup`; + const signupUrl = (typeof inviteUrl === 'string' && inviteUrl) ? inviteUrl : `${origin}/signup`; const result = await sendInviteEmail(email, inviterName, companyName, signupUrl); diff --git a/src/pages/Team.tsx b/src/pages/Team.tsx index f626d72..be58891 100644 --- a/src/pages/Team.tsx +++ b/src/pages/Team.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { supabase } from '@/integrations/supabase/client'; +import { supabase, apiFetch } from '@/integrations/supabase/client'; import { useAuth } from '@/contexts/AuthContext'; import { usePermissions } from '@/contexts/PermissionContext'; import { Button } from '@/components/ui/button'; @@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Badge } from '@/components/ui/badge'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { useToast } from '@/hooks/use-toast'; -import { Users, UserPlus, Shield, Mail, Search, Building, Briefcase } from 'lucide-react'; +import { Users, UserPlus, Shield, Mail, Search, Building, Briefcase, Copy, Check } from 'lucide-react'; import { format } from 'date-fns'; import { Link } from 'react-router-dom'; import { EmptyState } from '@/components/EmptyState'; @@ -50,6 +50,8 @@ export default function Team() { const [inviteRoleId, setInviteRoleId] = useState(''); const [inviting, setInviting] = useState(false); const [searchTerm, setSearchTerm] = useState(''); + const [pendingInvite, setPendingInvite] = useState<{ email: string; url: string; emailed: boolean } | null>(null); + const [copied, setCopied] = useState(false); const canManageTeam = canWrite('team'); @@ -126,37 +128,64 @@ export default function Team() { } setInviting(true); - + try { - const response = await fetch(`${import.meta.env.VITE_API_URL}/mail/send-invite`, { + const createResponse = await apiFetch('/auth/users', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` - }, - body: JSON.stringify({ email: inviteEmail }) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: inviteEmail, role_id: inviteRoleId || undefined }), }); - const data = await response.json(); + const data = await createResponse.json(); - if (!response.ok) { + if (!createResponse.ok) { toast({ - title: 'Email not configured', - description: `SMTP not set up. Ask ${inviteEmail} to sign up at your app URL, then assign their role here.`, + title: 'Error', + description: data.error || 'Failed to create user', + variant: 'destructive', }); + return; + } + + const inviteUrl = `${window.location.origin}/signup?token=${data.invite_token}`; + + const emailResponse = await apiFetch('/mail/send-invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: inviteEmail, inviteUrl }), + }); + + const emailed = emailResponse.ok; + + setPendingInvite({ email: inviteEmail, url: inviteUrl, emailed }); + + if (emailed) { + toast({ title: 'Success', description: `Invitation emailed to ${inviteEmail}` }); } else { - toast({ title: 'Success', description: `Invitation sent to ${inviteEmail}` }); - setInviteEmail(''); + toast({ + title: 'Email not sent', + description: "Email isn't set up — copy the invite link below to send it yourself.", + }); } + + fetchData(); + setInviteEmail(''); } catch (error: any) { toast({ title: 'Error', description: error.message || 'Failed to send invite', - variant: 'destructive' + variant: 'destructive', }); + } finally { + setInviting(false); } - - setInviting(false); + } + + async function handleCopyInviteLink() { + if (!pendingInvite) return; + await navigator.clipboard.writeText(pendingInvite.url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); } async function handleUpdateRole(userId: string, newRoleId: string) { @@ -216,7 +245,8 @@ export default function Team() { Invite Team Member - New members will need to sign up with the email you provide. You can then set their role. + Invited members get a link to set their password — it's emailed automatically if + SMTP is configured, otherwise you can copy it and share it with them yourself. @@ -254,6 +284,33 @@ export default function Team() { {inviting ? 'Inviting...' : 'Invite'} + + {pendingInvite && ( +
+

Invite link for {pendingInvite.email}

+

+ {pendingInvite.emailed + ? 'This link was also emailed to them.' + : "Email isn't configured — share this link with them manually."} +

+
+ + +
+
+ )}
)}