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
32 changes: 32 additions & 0 deletions server/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
8 changes: 5 additions & 3 deletions server/routes/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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);

Expand Down
8 changes: 2 additions & 6 deletions src/components/MyProfileDialog.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,8 +15,6 @@
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();
Expand Down Expand Up @@ -44,7 +42,7 @@
if (open && user?.id) {
fetchProfile();
}
}, [open, user?.id]);

Check warning on line 45 in src/components/MyProfileDialog.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

React Hook useEffect has a missing dependency: 'fetchProfile'. Either include it or remove the dependency array

async function fetchProfile() {
setLoading(true);
Expand Down Expand Up @@ -133,12 +131,10 @@

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,
Expand Down
78 changes: 31 additions & 47 deletions src/components/purchases/ImportBillCSVDialog.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -371,40 +365,30 @@ 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();
throw new Error(error.error || 'Failed to update selections');
}

// 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();
Expand Down
45 changes: 45 additions & 0 deletions src/integrations/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,6 +32,46 @@ function getAuthHeaders(): Record<string, string> {
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<Response> {
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<T> {
data: T | null;
Expand Down Expand Up @@ -257,6 +301,7 @@ class QueryBuilder<T = any> {

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 } };
}

Expand Down
2 changes: 1 addition & 1 deletion src/integrations/supabase/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
* import { supabase } from "@/integrations/supabase/client";
*/

export { supabase } from '@/integrations/api/client';
export { supabase, apiFetch } from '@/integrations/api/client';
8 changes: 2 additions & 6 deletions src/pages/ApiKeys.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 2 additions & 3 deletions src/pages/InvoiceDetail.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 })
});
Expand Down
8 changes: 3 additions & 5 deletions src/pages/Invoices.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) })
});
Expand Down Expand Up @@ -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) })
});
Expand Down
4 changes: 3 additions & 1 deletion src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
Loading
Loading