diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 5b6aa1f0..c3e0f407 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -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, @@ -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, @@ -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') @@ -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(), + }; + } } diff --git a/apps/backend/src/auth/jwt.strategy.ts b/apps/backend/src/auth/jwt.strategy.ts index 0650315a..9e45a6f4 100644 --- a/apps/backend/src/auth/jwt.strategy.ts +++ b/apps/backend/src/auth/jwt.strategy.ts @@ -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 }; } } diff --git a/apps/frontend/app/bounties/[id]/loading.tsx b/apps/frontend/app/bounties/[id]/loading.tsx new file mode 100644 index 00000000..af2846b5 --- /dev/null +++ b/apps/frontend/app/bounties/[id]/loading.tsx @@ -0,0 +1,5 @@ +import BountyDetailSkeleton from "@/app/components/BountyDetailSkeleton"; + +export default function BountyDetailLoading() { + return ; +} \ No newline at end of file diff --git a/apps/frontend/app/components/BountyDetailSkeleton.tsx b/apps/frontend/app/components/BountyDetailSkeleton.tsx new file mode 100644 index 00000000..54d124a3 --- /dev/null +++ b/apps/frontend/app/components/BountyDetailSkeleton.tsx @@ -0,0 +1,52 @@ +export default function BountyDetailSkeleton() { + return ( +
+
+ {/* Main content card */} +
+ {/* Status badge + meta row */} +
+
+
+
+
+ + {/* Title */} +
+ + {/* Description lines */} +
+
+
+
+
+
+ + {/* Detail cards grid */} +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+ ))} +
+
+ + {/* Sidebar / submit card */} + +
+
+ ); +} \ No newline at end of file diff --git a/apps/frontend/app/components/BountyListSkeleton.tsx b/apps/frontend/app/components/BountyListSkeleton.tsx new file mode 100644 index 00000000..9bdc6ef9 --- /dev/null +++ b/apps/frontend/app/components/BountyListSkeleton.tsx @@ -0,0 +1,57 @@ +export default function BountyListSkeleton() { + return ( +
+
+ {/* Hero banner */} +
+
+
+
+
+
+
+
+
+
+
+ + {/* Search / filter bar */} +
+
+
+
+
+
+
+
+
+ + {/* Bounty grid */} +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ {/* Card header */} +
+
+
+
+ {/* Card title */} +
+
+
+
+ {/* Card footer */} +
+
+
+
+
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/frontend/app/components/DashboardSkeleton.tsx b/apps/frontend/app/components/DashboardSkeleton.tsx new file mode 100644 index 00000000..9e952d08 --- /dev/null +++ b/apps/frontend/app/components/DashboardSkeleton.tsx @@ -0,0 +1,28 @@ +export default function DashboardSkeleton() { + return ( +
+ {/* Title */} +
+ + {/* Tabs */} +
+
+
+
+ + {/* Table rows */} +
+ {[0, 1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/apps/frontend/app/dashboard/loading.tsx b/apps/frontend/app/dashboard/loading.tsx new file mode 100644 index 00000000..bc1f7e20 --- /dev/null +++ b/apps/frontend/app/dashboard/loading.tsx @@ -0,0 +1,5 @@ +import DashboardSkeleton from "@/app/components/DashboardSkeleton"; + +export default function DashboardLoading() { + return ; +} \ No newline at end of file diff --git a/apps/frontend/app/loading.tsx b/apps/frontend/app/loading.tsx index c41a752e..3762ed87 100644 --- a/apps/frontend/app/loading.tsx +++ b/apps/frontend/app/loading.tsx @@ -1,16 +1,5 @@ -const skeletonCards = Array.from({ length: 6 }, (_, index) => index); +import BountyListSkeleton from "@/app/components/BountyListSkeleton"; export default function Loading() { - return ( -
-
-
-
- {skeletonCards.map((card) => ( -
- ))} -
-
-
- ); -} + return ; +} \ No newline at end of file diff --git a/apps/frontend/lib/api.spec.ts b/apps/frontend/lib/api.spec.ts index f6a8247b..85f2d198 100644 --- a/apps/frontend/lib/api.spec.ts +++ b/apps/frontend/lib/api.spec.ts @@ -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 () => { diff --git a/apps/frontend/lib/api.spec.tsx b/apps/frontend/lib/api.spec.tsx index 7c71b637..ea8ce2fe 100644 --- a/apps/frontend/lib/api.spec.tsx +++ b/apps/frontend/lib/api.spec.tsx @@ -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; + beforeEach(() => { window.localStorage.clear(); window.__lastToken = undefined; window.__lastError = undefined; jest.clearAllMocks(); + + fetchMock = jest.fn() as jest.MockedFunction; + 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(); await act(async () => { @@ -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", () => { diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 40934453..ee7ce0bc 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -12,6 +12,7 @@ type AuthTokenResponse = { type JwtPayload = { sub?: unknown; + exp?: number; }; export async function getAccessToken(publicKey: string): Promise { @@ -20,10 +21,27 @@ export async function getAccessToken(publicKey: string): Promise { 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(