Skip to content
Closed
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
17 changes: 16 additions & 1 deletion apps/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiOkResponse,
Expand All @@ -7,6 +7,7 @@ import {
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { Request } from 'express';
import { AuthService } from './auth.service';
import {
getAuthChallengeRateLimit,
Expand All @@ -16,6 +17,7 @@ import {
import { ChallengeQueryDto, ChallengeResponseDto } from './dto/challenge-query.dto';
import { VerifyDto, VerifyResponseDto } from './dto/verify.dto';
import { RefreshTokenDto, RevokeTokenDto } from './dto/refresh-token.dto';
import { JwtAuthGuard } from './jwt-auth.guard';

@ApiTags('v1: auth')
@Controller('auth')
Expand Down Expand Up @@ -66,4 +68,17 @@ export class AuthController {
revoke(@Body() body: RevokeTokenDto) {
return this.authService.revokeToken(body.token);
}

@ApiOperation({ summary: 'Get current user info from JWT token' })
@ApiOkResponse({ description: 'Address and token expiration time.' })
@ApiUnauthorizedResponse({ description: 'Invalid or expired token.' })
@Get('me')
@UseGuards(JwtAuthGuard)
me(@Req() req: Request) {
const user = req.user as { address: string; exp: number };
return {
address: user.address,
expiresAt: new Date(user.exp * 1000).toISOString(),
};
}
}
4 changes: 2 additions & 2 deletions apps/backend/src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}

validate(payload: { sub: string }) {
return { address: payload.sub };
validate(payload: { sub: string; iat: number; exp: number }) {
return { address: payload.sub, exp: payload.exp };
}
}
5 changes: 5 additions & 0 deletions apps/frontend/app/bounties/[id]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import BountyDetailSkeleton from "@/app/components/BountyDetailSkeleton";

export default function BountyDetailLoading() {
return <BountyDetailSkeleton />;
}
52 changes: 52 additions & 0 deletions apps/frontend/app/components/BountyDetailSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export default function BountyDetailSkeleton() {
return (
<main className="min-h-[calc(100vh-73px)] overflow-x-hidden bg-slate-50 px-3 py-6 text-slate-950 transition-colors dark:bg-slate-950 dark:text-slate-100 sm:px-4 sm:py-10">
<div className="mx-auto grid w-full max-w-6xl gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
{/* Main content card */}
<section className="min-w-0 rounded-2xl border border-slate-200 bg-white p-4 shadow-2xl shadow-slate-200/70 dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-slate-950/40 sm:p-6">
{/* Status badge + meta row */}
<div className="mb-6 flex min-w-0 flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="h-6 w-20 animate-pulse rounded-full bg-slate-200 dark:bg-slate-800" />
<div className="h-4 w-32 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
<div className="h-4 w-28 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
</div>

{/* Title */}
<div className="h-10 w-3/4 animate-pulse rounded bg-slate-200 dark:bg-slate-800 sm:h-12" />

{/* Description lines */}
<div className="mt-5 space-y-3">
<div className="h-4 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
<div className="h-4 w-11/12 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
<div className="h-4 w-4/5 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
<div className="h-4 w-3/4 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
</div>

{/* Detail cards grid */}
<div className="mt-8 grid gap-4 sm:grid-cols-3">
{[0, 1, 2].map((i) => (
<div
key={i}
className="animate-pulse rounded-xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950/60"
>
<div className="h-3 w-12 rounded bg-slate-300 dark:bg-slate-700" />
<div className="mt-2 h-4 w-28 rounded bg-slate-300 dark:bg-slate-700" />
</div>
))}
</div>
</section>

{/* Sidebar / submit card */}
<aside className="min-w-0 rounded-2xl border border-slate-200 bg-white p-4 shadow-2xl shadow-slate-200/70 dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-slate-950/40 sm:p-6">
<div className="h-6 w-28 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
<div className="mt-2 h-4 w-56 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
<div className="mt-5 space-y-4">
<div className="h-20 animate-pulse rounded-lg bg-slate-200 dark:bg-slate-800" />
<div className="h-32 animate-pulse rounded-lg bg-slate-200 dark:bg-slate-800" />
<div className="h-11 w-full animate-pulse rounded-lg bg-slate-300 dark:bg-slate-700" />
</div>
</aside>
</div>
</main>
);
}
57 changes: 57 additions & 0 deletions apps/frontend/app/components/BountyListSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export default function BountyListSkeleton() {
return (
<main className="min-h-[calc(100vh-73px)] bg-slate-50 px-4 py-10 text-slate-950 dark:bg-slate-950 dark:text-slate-100 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
{/* Hero banner */}
<div className="mb-10 flex flex-col justify-between gap-6 rounded-3xl border border-slate-200 bg-gradient-to-br from-white via-slate-50 to-slate-100 p-6 shadow-2xl shadow-slate-200/70 transition-colors dark:border-slate-800 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900 dark:shadow-black/20 sm:p-8 lg:flex-row lg:items-end">
<div className="max-w-3xl">
<div className="h-4 w-28 animate-pulse rounded bg-slate-300 dark:bg-slate-700" />
<div className="mt-4 h-10 w-96 animate-pulse rounded bg-slate-300 dark:bg-slate-700 sm:h-12" />
<div className="mt-4 space-y-2">
<div className="h-4 w-full max-w-lg animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
<div className="h-4 w-3/4 max-w-md animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
</div>
</div>
<div className="h-12 w-36 animate-pulse rounded-xl bg-slate-300 dark:bg-slate-700" />
</div>

{/* Search / filter bar */}
<div className="mb-8 animate-pulse rounded-3xl border border-slate-200 bg-white p-4 shadow-xl shadow-slate-200/60 dark:border-slate-800 dark:bg-slate-900/80 dark:shadow-black/10 sm:p-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-[1.6fr_0.8fr_0.8fr_auto]">
<div className="h-12 rounded-2xl bg-slate-200 dark:bg-slate-800" />
<div className="h-12 rounded-2xl bg-slate-200 dark:bg-slate-800" />
<div className="h-12 rounded-2xl bg-slate-200 dark:bg-slate-800" />
<div className="h-12 w-28 rounded-2xl bg-slate-300 dark:bg-slate-700" />
</div>
<div className="mt-4 h-4 w-64 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
</div>

{/* Bounty grid */}
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="animate-pulse rounded-2xl border border-slate-200 bg-white p-5 shadow-lg shadow-slate-200/50 dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-black/10"
>
{/* Card header */}
<div className="flex items-center gap-2">
<div className="h-5 w-16 rounded-full bg-slate-200 dark:bg-slate-800" />
<div className="h-4 w-20 rounded bg-slate-200 dark:bg-slate-800" />
</div>
{/* Card title */}
<div className="mt-3 space-y-2">
<div className="h-5 w-full rounded bg-slate-200 dark:bg-slate-800" />
<div className="h-5 w-3/4 rounded bg-slate-200 dark:bg-slate-800" />
</div>
{/* Card footer */}
<div className="mt-4 flex items-center justify-between">
<div className="h-4 w-24 rounded bg-slate-200 dark:bg-slate-800" />
<div className="h-4 w-16 rounded bg-slate-200 dark:bg-slate-800" />
</div>
</div>
))}
</div>
</div>
</main>
);
}
28 changes: 28 additions & 0 deletions apps/frontend/app/components/DashboardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export default function DashboardSkeleton() {
return (
<main className="mx-auto max-w-5xl bg-slate-50 px-4 py-8 text-slate-950 dark:bg-slate-950 dark:text-slate-100 sm:px-6">
{/* Title */}
<div className="mb-6 h-8 w-36 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />

{/* Tabs */}
<div className="mb-6 flex border-b border-slate-300 dark:border-slate-700">
<div className="h-10 w-32 animate-pulse rounded-t bg-slate-200 dark:bg-slate-800" />
<div className="ml-2 h-10 w-28 animate-pulse rounded-t bg-slate-200 dark:bg-slate-800" />
</div>

{/* Table rows */}
<div className="space-y-3">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="flex animate-pulse items-center gap-4 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900/50"
>
<div className="h-4 flex-1 rounded bg-slate-200 dark:bg-slate-800" />
<div className="h-4 w-24 rounded bg-slate-200 dark:bg-slate-800" />
<div className="h-6 w-20 rounded-full bg-slate-200 dark:bg-slate-800" />
</div>
))}
</div>
</main>
);
}
5 changes: 5 additions & 0 deletions apps/frontend/app/dashboard/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import DashboardSkeleton from "@/app/components/DashboardSkeleton";

export default function DashboardLoading() {
return <DashboardSkeleton />;
}
17 changes: 3 additions & 14 deletions apps/frontend/app/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
const skeletonCards = Array.from({ length: 6 }, (_, index) => index);
import BountyListSkeleton from "@/app/components/BountyListSkeleton";

export default function Loading() {
return (
<main className="min-h-[calc(100vh-73px)] bg-slate-50 px-4 py-10 text-slate-950 dark:bg-slate-950 dark:text-slate-100 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<div className="mb-10 h-64 animate-pulse rounded-3xl border border-slate-200 bg-slate-200 dark:border-slate-800 dark:bg-slate-900" />
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{skeletonCards.map((card) => (
<div key={card} className="h-44 animate-pulse rounded-2xl border border-slate-200 bg-slate-200 dark:border-slate-800 dark:bg-slate-900" />
))}
</div>
</div>
</main>
);
}
return <BountyListSkeleton />;
}
42 changes: 40 additions & 2 deletions apps/frontend/lib/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,51 @@ describe("frontend auth token storage", () => {
(signMessage as jest.Mock).mockReset();
});

it("reuses a saved JWT only when the subject matches the active public key", async () => {
it("reuses a saved JWT when the token is fresh and matches the active public key", async () => {
const token = createJwt("GACTIVE");
window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ expiresAt: new Date(Date.now() + 120_000).toISOString() }),
} as Response);

await expect(getAccessToken("GACTIVE")).resolves.toBe(token);

expect(fetchMock).not.toHaveBeenCalled();
// Should call /me endpoint to check freshness but not re-auth
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/auth/me"),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: `Bearer ${token}` }),
}),
);
});

it("clears a stale JWT and re-authenticates when the token is near expiry", async () => {
const staleToken = createJwt("GACTIVE");
window.localStorage.setItem(TOKEN_STORAGE_KEY, staleToken);
// Mock /me returning a near-expiry token
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ expiresAt: new Date(Date.now() + 30_000).toISOString() }),
} as Response);
const freshToken = createJwt("GACTIVE");
(signMessage as jest.Mock).mockResolvedValue({ signedMessage: "signed-nonce" });
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => ({ nonce: "nonce" }),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ accessToken: freshToken }),
} as Response);

await expect(getAccessToken("GACTIVE")).resolves.toBe(freshToken);

expect(window.localStorage.getItem(TOKEN_STORAGE_KEY)).toBe(freshToken);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(signMessage).toHaveBeenCalledWith("nonce", { address: "GACTIVE" });
});

it("clears a stale JWT and authenticates again for a different public key", async () => {
Expand Down
20 changes: 18 additions & 2 deletions apps/frontend/lib/api.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,27 @@ function AuthProbe() {
);
}

describe("useAuth — saved-token path (no fetch)", () => {
describe("useAuth — saved-token path (fetches /me to check freshness)", () => {
let fetchMock: jest.MockedFunction<typeof fetch>;

beforeEach(() => {
window.localStorage.clear();
window.__lastToken = undefined;
window.__lastError = undefined;
jest.clearAllMocks();

fetchMock = jest.fn() as jest.MockedFunction<typeof fetch>;
global.fetch = fetchMock;
});

it("returns a saved token from localStorage without signing or fetching", async () => {
it("returns a saved token from localStorage when /me confirms freshness (>60s TTL)", async () => {
const savedToken = createJwt("GABC");
window.localStorage.setItem(TOKEN_STORAGE_KEY, savedToken);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ expiresAt: new Date(Date.now() + 120_000).toISOString() }),
} as Response);

const { getByText } = render(<AuthProbe />);

await act(async () => {
Expand All @@ -81,6 +91,12 @@ describe("useAuth — saved-token path (no fetch)", () => {
expect(mockedFreighter.signMessage).not.toHaveBeenCalled();
expect(window.__lastToken).toBe(savedToken);
expect(window.__lastError).toBeNull();
// Should have called /me once
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/auth/me"),
expect.anything(),
);
});

it("clearToken removes the stored token", () => {
Expand Down
24 changes: 21 additions & 3 deletions apps/frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type AuthTokenResponse = {

type JwtPayload = {
sub?: unknown;
exp?: number;
};

export async function getAccessToken(publicKey: string): Promise<string> {
Expand All @@ -20,10 +21,27 @@ export async function getAccessToken(publicKey: string): Promise<string> {

if (savedToken) {
if (isTokenForPublicKey(savedToken, publicKey)) {
return savedToken;
// Token matches the public key — check freshness via /me endpoint
try {
const meResponse = await fetch(`${API_URL}/api/v1/auth/me`, {
headers: { Authorization: `Bearer ${savedToken}` },
});
if (meResponse.ok) {
const { expiresAt } = (await meResponse.json()) as { expiresAt: string };
const ttlMs = new Date(expiresAt).getTime() - Date.now();
if (ttlMs > 60_000) {
// More than 60s remaining — token is still fresh
return savedToken;
}
}
} catch {
// Network error — fall through to re-auth below
}
// Token is stale or /me call failed — clear and re-authenticate
clearAuthToken();
} else {
clearAuthToken();
}

clearAuthToken();
}

const challengeResponse = await fetch(
Expand Down
Loading