Skip to content

Commit 4965150

Browse files
feat: integrate web auth and session guard
1 parent 91bf3dc commit 4965150

13 files changed

Lines changed: 522 additions & 100 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NextResponse } from 'next/server';
2+
import { cookies } from 'next/headers';
3+
import { getBackendBaseUrl } from '../../../../src/lib/env';
4+
import { accessCookieOptions, authCookies, refreshCookieOptions } from '../../../../src/lib/authCookies';
5+
6+
type AuthTokens = {
7+
accessToken: string;
8+
refreshToken: string;
9+
user?: unknown;
10+
};
11+
12+
export async function POST(req: Request) {
13+
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
14+
if (!body) {
15+
return NextResponse.json({ message: 'Invalid JSON' }, { status: 400 });
16+
}
17+
18+
const response = await fetch(`${getBackendBaseUrl()}/auth/login`, {
19+
method: 'POST',
20+
headers: { 'content-type': 'application/json' },
21+
body: JSON.stringify(body),
22+
cache: 'no-store',
23+
});
24+
25+
const payloadText = await response.text();
26+
if (!response.ok) {
27+
// Pass through backend status/message without leaking tokens.
28+
return new NextResponse(payloadText, { status: response.status });
29+
}
30+
31+
const payload = JSON.parse(payloadText) as AuthTokens;
32+
33+
const cookieStore = await cookies();
34+
cookieStore.set(authCookies.accessToken, payload.accessToken, accessCookieOptions);
35+
cookieStore.set(authCookies.refreshToken, payload.refreshToken, refreshCookieOptions);
36+
37+
return NextResponse.json({ user: payload.user ?? null }, { status: 200 });
38+
}
39+
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { NextResponse } from 'next/server';
2+
import { cookies } from 'next/headers';
3+
import { authCookies } from '../../../../src/lib/authCookies';
4+
5+
export async function POST() {
6+
const cookieStore = await cookies();
7+
cookieStore.set(authCookies.accessToken, '', { httpOnly: true, path: '/', maxAge: 0 });
8+
cookieStore.set(authCookies.refreshToken, '', { httpOnly: true, path: '/', maxAge: 0 });
9+
return NextResponse.json({ ok: true }, { status: 200 });
10+
}
11+
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { NextResponse } from 'next/server';
2+
import { cookies } from 'next/headers';
3+
import { getBackendBaseUrl } from '../../../../src/lib/env';
4+
import { accessCookieOptions, authCookies, refreshCookieOptions } from '../../../../src/lib/authCookies';
5+
6+
type AuthTokens = {
7+
accessToken: string;
8+
refreshToken: string;
9+
user?: unknown;
10+
};
11+
12+
export async function POST(req: Request) {
13+
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
14+
if (!body) {
15+
return NextResponse.json({ message: 'Invalid JSON' }, { status: 400 });
16+
}
17+
18+
const response = await fetch(`${getBackendBaseUrl()}/auth/register`, {
19+
method: 'POST',
20+
headers: { 'content-type': 'application/json' },
21+
body: JSON.stringify(body),
22+
cache: 'no-store',
23+
});
24+
25+
const payloadText = await response.text();
26+
if (!response.ok) {
27+
return new NextResponse(payloadText, { status: response.status });
28+
}
29+
30+
const payload = JSON.parse(payloadText) as AuthTokens;
31+
32+
const cookieStore = await cookies();
33+
cookieStore.set(authCookies.accessToken, payload.accessToken, accessCookieOptions);
34+
cookieStore.set(authCookies.refreshToken, payload.refreshToken, refreshCookieOptions);
35+
36+
return NextResponse.json({ user: payload.user ?? null }, { status: 200 });
37+
}
38+
Lines changed: 66 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,78 @@
1-
'use client';
2-
3-
import { useParams } from 'next/navigation';
4-
import { useEffect, useState } from 'react';
5-
import { apiFetch } from '../../../../lib/api';
1+
import { DashboardLayout } from '../../../../src/components/layout';
2+
import { backendFetch } from '../../../../src/lib/backend';
63

74
type Balance = { id: string; userId: string; counterpartyUserId: string; amountCents: string };
8-
type Expense = { id: string; description: string; totalAmountCents: string; createdAt: string };
95

10-
export default function DashboardGroupPage() {
11-
const params = useParams<{ id: string }>();
6+
type Expense = {
7+
id: string;
8+
description: string;
9+
totalAmountCents: string;
10+
createdAt: string;
11+
payerId: string;
12+
};
13+
14+
type ExpensesResponse = { items: Expense[]; nextCursor: number | null };
15+
16+
type PageProps = { params: { id: string } };
17+
18+
export default async function DashboardGroupPage({ params }: PageProps) {
1219
const groupId = params.id;
13-
const [balances, setBalances] = useState<Balance[]>([]);
14-
const [expenses, setExpenses] = useState<Expense[]>([]);
15-
16-
useEffect(() => {
17-
if (!groupId) {
18-
return;
19-
}
20-
void apiFetch<Balance[]>(`/groups/${groupId}/balances`).then(setBalances).catch(() => setBalances([]));
21-
void apiFetch<{ items: Expense[] }>(`/groups/${groupId}/expenses?cursor=0&limit=20`)
22-
.then((data) => setExpenses(data.items))
23-
.catch(() => setExpenses([]));
24-
}, [groupId]);
20+
21+
let balances: Balance[] = [];
22+
let expenses: Expense[] = [];
23+
24+
try {
25+
balances = await backendFetch<Balance[]>(`/groups/${groupId}/balances`);
26+
} catch {
27+
balances = [];
28+
}
29+
30+
try {
31+
const data = await backendFetch<ExpensesResponse>(`/groups/${groupId}/expenses?cursor=0&limit=20`);
32+
expenses = data.items;
33+
} catch {
34+
expenses = [];
35+
}
2536

2637
return (
27-
<main className="mx-auto max-w-5xl px-6 py-10">
28-
<h1 className="text-3xl font-bold">Group Dashboard</h1>
29-
<p className="mt-2 text-slate-600">{groupId}</p>
30-
31-
<section className="mt-6">
32-
<h2 className="text-xl font-semibold">Balances</h2>
33-
<div className="mt-3 space-y-2">
34-
{balances.map((balance) => (
35-
<div key={balance.id} className="rounded border bg-white p-3">
36-
{balance.userId} vs {balance.counterpartyUserId}: $
37-
{(Number(balance.amountCents) / 100).toFixed(2)}
38-
</div>
39-
))}
38+
<DashboardLayout>
39+
<div className="rounded-2xl border border-border bg-card p-5 shadow-glass backdrop-blur-glass">
40+
<p className="text-sm text-text-secondary">Group</p>
41+
<p className="mt-1 font-semibold text-text-primary">{groupId}</p>
42+
</div>
43+
44+
<section className="grid gap-4 md:grid-cols-2">
45+
<div className="rounded-2xl border border-border bg-card p-5 shadow-glass backdrop-blur-glass">
46+
<h2 className="text-base font-semibold text-text-primary">Balances</h2>
47+
<div className="mt-3 space-y-2">
48+
{balances.map((balance) => (
49+
<div key={balance.id} className="rounded-xl border border-border/60 bg-surface/20 p-3 text-sm">
50+
<span className="text-text-secondary">{balance.userId}</span> <span className="text-text-secondary">vs</span>{' '}
51+
<span className="text-text-secondary">{balance.counterpartyUserId}</span>
52+
<span className="ml-2 font-medium text-text-primary">
53+
${(Number(balance.amountCents) / 100).toFixed(2)}
54+
</span>
55+
</div>
56+
))}
57+
{balances.length === 0 ? <p className="text-sm text-text-secondary">No balances found.</p> : null}
58+
</div>
4059
</div>
41-
</section>
4260

43-
<section className="mt-8">
44-
<h2 className="text-xl font-semibold">Recent Expenses</h2>
45-
<div className="mt-3 space-y-2">
46-
{expenses.map((expense) => (
47-
<div key={expense.id} className="rounded border bg-white p-3">
48-
<p className="font-medium">{expense.description}</p>
49-
<p className="text-sm text-slate-600">${(Number(expense.totalAmountCents) / 100).toFixed(2)}</p>
50-
</div>
51-
))}
61+
<div className="rounded-2xl border border-border bg-card p-5 shadow-glass backdrop-blur-glass">
62+
<h2 className="text-base font-semibold text-text-primary">Recent expenses</h2>
63+
<div className="mt-3 space-y-2">
64+
{expenses.map((expense) => (
65+
<div key={expense.id} className="rounded-xl border border-border/60 bg-surface/20 p-3">
66+
<p className="text-sm font-medium text-text-primary">{expense.description}</p>
67+
<p className="mt-1 text-sm text-text-secondary">
68+
${(Number(expense.totalAmountCents) / 100).toFixed(2)}
69+
</p>
70+
</div>
71+
))}
72+
{expenses.length === 0 ? <p className="text-sm text-text-secondary">No expenses yet.</p> : null}
73+
</div>
5274
</div>
5375
</section>
54-
</main>
76+
</DashboardLayout>
5577
);
5678
}
Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,36 @@
1-
'use client';
2-
3-
import Link from 'next/link';
4-
import { useEffect, useState } from 'react';
5-
import { apiFetch } from '../../../lib/api';
1+
import Link from 'next/link';
2+
import { DashboardLayout } from '../../../src/components/layout';
3+
import { backendFetch } from '../../../src/lib/backend';
64

75
type Group = { id: string; name: string; currency: string };
86

9-
export default function DashboardGroupsPage() {
10-
const [groups, setGroups] = useState<Group[]>([]);
11-
12-
useEffect(() => {
13-
void apiFetch<Group[]>('/groups').then(setGroups).catch(() => setGroups([]));
14-
}, []);
7+
export default async function DashboardGroupsPage() {
8+
let groups: Group[] = [];
9+
try {
10+
groups = await backendFetch<Group[]>('/groups');
11+
} catch {
12+
groups = [];
13+
}
1514

1615
return (
17-
<main className="mx-auto max-w-5xl px-6 py-10">
18-
<h1 className="text-3xl font-bold">Groups</h1>
19-
<div className="mt-6 space-y-3">
16+
<DashboardLayout>
17+
<section className="space-y-3">
2018
{groups.map((group) => (
2119
<Link
2220
key={group.id}
2321
href={`/dashboard/group/${group.id}`}
24-
className="block rounded-lg border bg-white p-4 shadow-sm"
22+
className="block rounded-2xl border border-border bg-card p-5 shadow-glass backdrop-blur-glass"
2523
>
26-
<p className="font-semibold">{group.name}</p>
27-
<p className="text-sm text-slate-600">{group.currency}</p>
24+
<p className="font-semibold text-text-primary">{group.name}</p>
25+
<p className="mt-1 text-sm text-text-secondary">{group.currency}</p>
2826
</Link>
2927
))}
30-
</div>
31-
</main>
28+
{groups.length === 0 ? (
29+
<div className="rounded-2xl border border-border bg-card p-6 text-sm text-text-secondary shadow-glass backdrop-blur-glass">
30+
No groups yet.
31+
</div>
32+
) : null}
33+
</section>
34+
</DashboardLayout>
3235
);
33-
}
36+
}

apps/web/app/dashboard/page.tsx

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,33 @@
1-
'use client';
2-
3-
import Link from 'next/link';
4-
import { useEffect, useState } from 'react';
5-
import { apiFetch } from '../../lib/api';
1+
import Link from 'next/link';
2+
import { DashboardLayout } from '../../src/components/layout';
3+
import { backendFetch } from '../../src/lib/backend';
64

75
type Group = { id: string; name: string; currency: string };
86

9-
export default function DashboardPage() {
10-
const [groups, setGroups] = useState<Group[]>([]);
11-
12-
useEffect(() => {
13-
void apiFetch<Group[]>('/groups').then(setGroups).catch(() => setGroups([]));
14-
}, []);
7+
export default async function DashboardPage() {
8+
let groups: Group[] = [];
9+
try {
10+
groups = await backendFetch<Group[]>('/groups');
11+
} catch {
12+
groups = [];
13+
}
1514

1615
return (
17-
<main className="mx-auto max-w-5xl px-6 py-10">
18-
<h1 className="text-3xl font-bold">Dashboard</h1>
19-
<p className="mt-2 text-slate-600">Authenticated overview of your FairShare account.</p>
20-
<div className="mt-6 grid gap-4 md:grid-cols-3">
21-
<Link href="/dashboard/groups" className="rounded-lg border bg-white p-4 shadow-sm">
22-
<h2 className="font-semibold">Groups</h2>
23-
<p className="text-sm text-slate-600">{groups.length} total groups</p>
16+
<DashboardLayout>
17+
<section className="grid gap-4 md:grid-cols-3">
18+
<Link href="/dashboard/groups" className="rounded-2xl border border-border bg-card p-5 shadow-glass backdrop-blur-glass">
19+
<h2 className="font-semibold text-text-primary">Groups</h2>
20+
<p className="mt-1 text-sm text-text-secondary">{groups.length} total groups</p>
2421
</Link>
25-
</div>
26-
</main>
22+
<div className="rounded-2xl border border-border bg-card p-5 shadow-glass backdrop-blur-glass">
23+
<h2 className="font-semibold text-text-primary">Quick actions</h2>
24+
<p className="mt-1 text-sm text-text-secondary">Create expenses and settle balances.</p>
25+
</div>
26+
<div className="rounded-2xl border border-border bg-card p-5 shadow-glass backdrop-blur-glass">
27+
<h2 className="font-semibold text-text-primary">Recent activity</h2>
28+
<p className="mt-1 text-sm text-text-secondary">Coming next.</p>
29+
</div>
30+
</section>
31+
</DashboardLayout>
2732
);
28-
}
33+
}

0 commit comments

Comments
 (0)