diff --git a/apps/api/prisma/migrations/20260722225616_add_sessions/migration.sql b/apps/api/prisma/migrations/20260722225616_add_sessions/migration.sql new file mode 100644 index 00000000..2e45d2d5 --- /dev/null +++ b/apps/api/prisma/migrations/20260722225616_add_sessions/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "userAgent" TEXT, + "ipAddress" TEXT, + "lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + "revokedAt" DATETIME, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4005a669..373f4456 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -20,6 +20,21 @@ model User { applications JobApplication[] apiTokens ApiToken[] emailVerificationTokens EmailVerificationToken[] + sessions Session[] +} + +model Session { + id String @id + userId String + userAgent String? + ipAddress String? + lastUsedAt DateTime @default(now()) + createdAt DateTime @default(now()) + expiresAt DateTime + revokedAt DateTime? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) } model EmailVerificationToken { diff --git a/apps/api/src/__tests__/application/sessions/CreateSessionUseCase.test.ts b/apps/api/src/__tests__/application/sessions/CreateSessionUseCase.test.ts new file mode 100644 index 00000000..38078945 --- /dev/null +++ b/apps/api/src/__tests__/application/sessions/CreateSessionUseCase.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CreateSessionUseCase } from '@/use-cases/sessions/CreateSessionUseCase.js'; +import { makeSessionRepository, makeSession } from '@/__tests__/helpers/mocks.js'; +import { SESSION } from '@/constants.js'; + +describe('CreateSessionUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates a session with a generated id and device info', async () => { + const sessionRepository = makeSessionRepository({ + create: vi.fn().mockResolvedValue(makeSession({ id: 'generated-id' })), + }); + const generateId = vi.fn().mockReturnValue('generated-id'); + + await new CreateSessionUseCase({ sessionRepository, generateId }).execute({ + userId: 'user-1', + userAgent: 'Mozilla/5.0', + ipAddress: '10.0.0.1', + }); + + expect(sessionRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'generated-id', + userId: 'user-1', + userAgent: 'Mozilla/5.0', + ipAddress: '10.0.0.1', + }), + ); + }); + + it('sets expiresAt roughly SESSION.TTL_MS in the future', async () => { + const sessionRepository = makeSessionRepository({ + create: vi.fn().mockResolvedValue(makeSession()), + }); + const generateId = vi.fn().mockReturnValue('generated-id'); + + const before = Date.now(); + await new CreateSessionUseCase({ sessionRepository, generateId }).execute({ + userId: 'user-1', + userAgent: null, + ipAddress: null, + }); + const after = Date.now(); + + const createCall = vi.mocked(sessionRepository.create).mock.calls[0][0]; + const expiresAtMs = createCall.expiresAt.getTime(); + expect(expiresAtMs).toBeGreaterThanOrEqual(before + SESSION.TTL_MS - 1000); + expect(expiresAtMs).toBeLessThanOrEqual(after + SESSION.TTL_MS + 1000); + }); + + it('returns the created session', async () => { + const session = makeSession({ id: 'generated-id' }); + const sessionRepository = makeSessionRepository({ create: vi.fn().mockResolvedValue(session) }); + + const result = await new CreateSessionUseCase({ + sessionRepository, + generateId: vi.fn().mockReturnValue('generated-id'), + }).execute({ userId: 'user-1', userAgent: null, ipAddress: null }); + + expect(result).toEqual(session); + }); +}); diff --git a/apps/api/src/__tests__/application/sessions/ListSessionsUseCase.test.ts b/apps/api/src/__tests__/application/sessions/ListSessionsUseCase.test.ts new file mode 100644 index 00000000..aee2933f --- /dev/null +++ b/apps/api/src/__tests__/application/sessions/ListSessionsUseCase.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ListSessionsUseCase } from '@/use-cases/sessions/ListSessionsUseCase.js'; +import { makeSessionRepository, makeSession } from '@/__tests__/helpers/mocks.js'; + +describe('ListSessionsUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns active sessions for the given user', async () => { + const sessions = [makeSession({ id: 's1' }), makeSession({ id: 's2' })]; + const sessionRepository = makeSessionRepository({ + findActiveByUserId: vi.fn().mockResolvedValue(sessions), + }); + + const result = await new ListSessionsUseCase({ sessionRepository }).execute('user-1'); + + expect(sessionRepository.findActiveByUserId).toHaveBeenCalledWith('user-1'); + expect(result).toEqual(sessions); + }); +}); diff --git a/apps/api/src/__tests__/application/sessions/RevokeOtherSessionsUseCase.test.ts b/apps/api/src/__tests__/application/sessions/RevokeOtherSessionsUseCase.test.ts new file mode 100644 index 00000000..6cf10d28 --- /dev/null +++ b/apps/api/src/__tests__/application/sessions/RevokeOtherSessionsUseCase.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RevokeOtherSessionsUseCase } from '@/use-cases/sessions/RevokeOtherSessionsUseCase.js'; +import { makeSessionRepository } from '@/__tests__/helpers/mocks.js'; + +describe('RevokeOtherSessionsUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('revokes all sessions for the user except the current one', async () => { + const sessionRepository = makeSessionRepository(); + + await new RevokeOtherSessionsUseCase({ sessionRepository }).execute('user-1', 'session-1'); + + expect(sessionRepository.revokeAllForUserExcept).toHaveBeenCalledWith('user-1', 'session-1'); + }); +}); diff --git a/apps/api/src/__tests__/application/sessions/RevokeSessionUseCase.test.ts b/apps/api/src/__tests__/application/sessions/RevokeSessionUseCase.test.ts new file mode 100644 index 00000000..0354387a --- /dev/null +++ b/apps/api/src/__tests__/application/sessions/RevokeSessionUseCase.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RevokeSessionUseCase } from '@/use-cases/sessions/RevokeSessionUseCase.js'; +import { makeSessionRepository, makeSession } from '@/__tests__/helpers/mocks.js'; + +describe('RevokeSessionUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws NOT_FOUND when the session does not belong to the user', async () => { + const sessionRepository = makeSessionRepository({ + findByIdAndUserId: vi.fn().mockResolvedValue(null), + }); + + const err = await new RevokeSessionUseCase({ sessionRepository }) + .execute('session-1', 'user-1') + .catch((e) => e); + + expect((err as { code: string }).code).toBe('NOT_FOUND'); + expect(sessionRepository.revoke).not.toHaveBeenCalled(); + }); + + it('revokes the session when owned by the user', async () => { + const session = makeSession({ id: 'session-1', userId: 'user-1' }); + const sessionRepository = makeSessionRepository({ + findByIdAndUserId: vi.fn().mockResolvedValue(session), + }); + + await new RevokeSessionUseCase({ sessionRepository }).execute('session-1', 'user-1'); + + expect(sessionRepository.findByIdAndUserId).toHaveBeenCalledWith('session-1', 'user-1'); + expect(sessionRepository.revoke).toHaveBeenCalledWith('session-1'); + }); +}); diff --git a/apps/api/src/__tests__/application/sessions/TouchSessionUseCase.test.ts b/apps/api/src/__tests__/application/sessions/TouchSessionUseCase.test.ts new file mode 100644 index 00000000..a09cb79d --- /dev/null +++ b/apps/api/src/__tests__/application/sessions/TouchSessionUseCase.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TouchSessionUseCase } from '@/use-cases/sessions/TouchSessionUseCase.js'; +import { makeSessionRepository, makeSession } from '@/__tests__/helpers/mocks.js'; + +describe('TouchSessionUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws UNAUTHORIZED when the session does not exist', async () => { + const sessionRepository = makeSessionRepository({ findById: vi.fn().mockResolvedValue(null) }); + + const err = await new TouchSessionUseCase({ sessionRepository }) + .execute('missing') + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(sessionRepository.touch).not.toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when the session has been revoked', async () => { + const session = makeSession({ revokedAt: new Date() }); + const sessionRepository = makeSessionRepository({ + findById: vi.fn().mockResolvedValue(session), + }); + + const err = await new TouchSessionUseCase({ sessionRepository }) + .execute('session-1') + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(sessionRepository.touch).not.toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when the session has expired', async () => { + const session = makeSession({ expiresAt: new Date(Date.now() - 1000) }); + const sessionRepository = makeSessionRepository({ + findById: vi.fn().mockResolvedValue(session), + }); + + const err = await new TouchSessionUseCase({ sessionRepository }) + .execute('session-1') + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + }); + + it('updates lastUsedAt/expiresAt for a valid session', async () => { + const session = makeSession({ id: 'session-1', expiresAt: new Date(Date.now() + 60_000) }); + const sessionRepository = makeSessionRepository({ + findById: vi.fn().mockResolvedValue(session), + }); + + await new TouchSessionUseCase({ sessionRepository }).execute('session-1'); + + expect(sessionRepository.touch).toHaveBeenCalledWith('session-1', expect.any(Date)); + }); +}); diff --git a/apps/api/src/__tests__/helpers/createTestDb.ts b/apps/api/src/__tests__/helpers/createTestDb.ts index f3844a22..4880a867 100644 --- a/apps/api/src/__tests__/helpers/createTestDb.ts +++ b/apps/api/src/__tests__/helpers/createTestDb.ts @@ -116,6 +116,18 @@ const SCHEMA_STATEMENTS = [ FOREIGN KEY ("applicationId") REFERENCES "JobApplication"("id") ON DELETE CASCADE )`, `CREATE INDEX "Contact_applicationId_idx" ON "Contact"("applicationId")`, + `CREATE TABLE "Session" ( + "id" TEXT PRIMARY KEY, + "userId" TEXT NOT NULL, + "userAgent" TEXT, + "ipAddress" TEXT, + "lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + "revokedAt" DATETIME, + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE + )`, + `CREATE INDEX "Session_userId_idx" ON "Session"("userId")`, `CREATE TABLE "EmailVerificationToken" ( "id" TEXT PRIMARY KEY, "userId" TEXT NOT NULL, diff --git a/apps/api/src/__tests__/helpers/mocks.ts b/apps/api/src/__tests__/helpers/mocks.ts index 40101bf2..4ec54ed9 100644 --- a/apps/api/src/__tests__/helpers/mocks.ts +++ b/apps/api/src/__tests__/helpers/mocks.ts @@ -10,6 +10,8 @@ import type { IInterviewRoundRepository } from '@/use-cases/ports/IInterviewRoun import type { IActivityLogRepository } from '@/use-cases/ports/IActivityLogRepository.js'; import type { IContactRepository } from '@/use-cases/ports/IContactRepository.js'; import type { IStorageProvider } from '@/use-cases/ports/IStorageProvider.js'; +import type { ISessionRepository } from '@/use-cases/ports/ISessionRepository.js'; +import type { Session } from '@/domain/session/Session.js'; import type { IEmailVerificationTokenRepository } from '@/use-cases/ports/IEmailVerificationTokenRepository.js'; import type { EmailVerificationToken } from '@/domain/emailVerificationToken/EmailVerificationToken.js'; import type { User } from '@/domain/user/User.js'; @@ -110,6 +112,31 @@ export const makeApiToken = (overrides?: Partial): ApiToken => ({ ...overrides, }); +export const makeSessionRepository = ( + overrides?: Partial, +): ISessionRepository => ({ + create: vi.fn(), + findById: vi.fn().mockResolvedValue(null), + findByIdAndUserId: vi.fn().mockResolvedValue(null), + findActiveByUserId: vi.fn().mockResolvedValue([]), + touch: vi.fn().mockResolvedValue(undefined), + revoke: vi.fn().mockResolvedValue(undefined), + revokeAllForUserExcept: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + +export const makeSession = (overrides?: Partial): Session => ({ + id: 'session-1', + userId: 'user-1', + userAgent: 'Mozilla/5.0 (test)', + ipAddress: '127.0.0.1', + lastUsedAt: new Date('2024-01-01T00:00:00.000Z'), + createdAt: new Date('2024-01-01T00:00:00.000Z'), + expiresAt: new Date('2024-01-08T00:00:00.000Z'), + revokedAt: null, + ...overrides, +}); + export const makeEmailVerificationTokenRepository = ( overrides?: Partial, ): IEmailVerificationTokenRepository => ({ diff --git a/apps/api/src/__tests__/infrastructure/auth/FastifyJwtTokenService.test.ts b/apps/api/src/__tests__/infrastructure/auth/FastifyJwtTokenService.test.ts index 5e3a13a2..54a73630 100644 --- a/apps/api/src/__tests__/infrastructure/auth/FastifyJwtTokenService.test.ts +++ b/apps/api/src/__tests__/infrastructure/auth/FastifyJwtTokenService.test.ts @@ -19,21 +19,21 @@ describe('FastifyJwtTokenService', () => { describe('sign', () => { it('signs an access token with the user payload and ACCESS expiry, no explicit key', () => { - service.sign('user-1', 'user@example.com'); + service.sign('user-1', 'user@example.com', 'session-1'); expect(fastifyJwt.jwt.sign).toHaveBeenNthCalledWith( 1, - { sub: 'user-1', email: 'user@example.com' }, + { sub: 'user-1', email: 'user@example.com', sid: 'session-1' }, { expiresIn: JWT_EXPIRY.ACCESS }, ); }); it('signs a refresh token with the refresh secret and REFRESH expiry', () => { - service.sign('user-1', 'user@example.com'); + service.sign('user-1', 'user@example.com', 'session-1'); expect(fastifyJwt.jwt.sign).toHaveBeenNthCalledWith( 2, - { sub: 'user-1', email: 'user@example.com' }, + { sub: 'user-1', email: 'user@example.com', sid: 'session-1' }, { key: REFRESH_SECRET, expiresIn: JWT_EXPIRY.REFRESH }, ); }); @@ -43,7 +43,7 @@ describe('FastifyJwtTokenService', () => { .mockReturnValueOnce('access-token-value') .mockReturnValueOnce('refresh-token-value'); - const result = service.sign('user-1', 'user@example.com'); + const result = service.sign('user-1', 'user@example.com', 'session-1'); expect(result).toEqual({ accessToken: 'access-token-value', @@ -57,6 +57,7 @@ describe('FastifyJwtTokenService', () => { vi.mocked(fastifyJwt.jwt.verify).mockReturnValue({ sub: 'user-1', email: 'user@example.com', + sid: 'session-1', }); const result = service.verifyRefresh('a-refresh-token'); @@ -64,7 +65,7 @@ describe('FastifyJwtTokenService', () => { expect(fastifyJwt.jwt.verify).toHaveBeenCalledWith('a-refresh-token', { key: REFRESH_SECRET, }); - expect(result).toEqual({ sub: 'user-1', email: 'user@example.com' }); + expect(result).toEqual({ sub: 'user-1', email: 'user@example.com', sid: 'session-1' }); }); it('throws an UNAUTHORIZED-coded error when verification fails', () => { diff --git a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaSessionRepository.test.ts b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaSessionRepository.test.ts new file mode 100644 index 00000000..48cce4a3 --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaSessionRepository.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaSessionRepository } from '@/infrastructure/db/repositories/PrismaSessionRepository.js'; +import { createTestDb, type TestDb } from '@/__tests__/helpers/createTestDb.js'; + +describe('PrismaSessionRepository', () => { + let db: TestDb; + let repo: PrismaSessionRepository; + + beforeAll(async () => { + db = await createTestDb(); + repo = new PrismaSessionRepository({ prisma: db.prisma }); + }); + + afterAll(() => db.cleanup()); + + beforeEach(async () => { + await db.prisma.session.deleteMany(); + await db.prisma.user.deleteMany(); + await db.prisma.user.create({ + data: { id: 'user-1', email: 'a@b.com', passwordHash: 'hashed' }, + }); + }); + + const futureExpiry = () => new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + describe('create', () => { + it('persists a session and returns the entity', async () => { + const expiresAt = futureExpiry(); + const session = await repo.create({ + id: 'session-1', + userId: 'user-1', + userAgent: 'Mozilla/5.0', + ipAddress: '10.0.0.1', + expiresAt, + }); + + expect(session.id).toBe('session-1'); + expect(session.userId).toBe('user-1'); + expect(session.userAgent).toBe('Mozilla/5.0'); + expect(session.ipAddress).toBe('10.0.0.1'); + expect(session.expiresAt).toEqual(expiresAt); + expect(session.revokedAt).toBeNull(); + expect(session.createdAt).toBeInstanceOf(Date); + expect(session.lastUsedAt).toBeInstanceOf(Date); + }); + }); + + describe('findById / findByIdAndUserId', () => { + it('returns the session when it exists', async () => { + await repo.create({ + id: 'session-1', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + + expect((await repo.findById('session-1'))?.id).toBe('session-1'); + expect((await repo.findByIdAndUserId('session-1', 'user-1'))?.id).toBe('session-1'); + }); + + it('returns null when the session belongs to a different user', async () => { + await repo.create({ + id: 'session-1', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + + expect(await repo.findByIdAndUserId('session-1', 'someone-else')).toBeNull(); + }); + + it('returns null when the session does not exist', async () => { + expect(await repo.findById('missing')).toBeNull(); + }); + }); + + describe('findActiveByUserId', () => { + it('excludes revoked sessions', async () => { + await repo.create({ + id: 'active', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + await repo.create({ + id: 'revoked', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + await repo.revoke('revoked'); + + const active = await repo.findActiveByUserId('user-1'); + + expect(active.map((s) => s.id)).toEqual(['active']); + }); + + it('excludes expired sessions', async () => { + await repo.create({ + id: 'expired', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: new Date(Date.now() - 1000), + }); + + const active = await repo.findActiveByUserId('user-1'); + + expect(active).toHaveLength(0); + }); + + it('does not return sessions belonging to other users', async () => { + await db.prisma.user.create({ + data: { id: 'user-2', email: 'c@d.com', passwordHash: 'hashed' }, + }); + await repo.create({ + id: 'other-user-session', + userId: 'user-2', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + + expect(await repo.findActiveByUserId('user-1')).toHaveLength(0); + }); + }); + + describe('touch', () => { + it('updates lastUsedAt and expiresAt', async () => { + await repo.create({ + id: 'session-1', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + const newExpiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); + + await repo.touch('session-1', newExpiresAt); + + const updated = await repo.findById('session-1'); + expect(updated?.expiresAt).toEqual(newExpiresAt); + }); + }); + + describe('revoke', () => { + it('sets revokedAt', async () => { + await repo.create({ + id: 'session-1', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + + await repo.revoke('session-1'); + + const updated = await repo.findById('session-1'); + expect(updated?.revokedAt).toBeInstanceOf(Date); + }); + }); + + describe('revokeAllForUserExcept', () => { + it('revokes all sessions for the user except the given one', async () => { + await repo.create({ + id: 'keep', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + await repo.create({ + id: 'revoke-me', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + + await repo.revokeAllForUserExcept('user-1', 'keep'); + + const active = await repo.findActiveByUserId('user-1'); + expect(active.map((s) => s.id)).toEqual(['keep']); + }); + + it('does not affect sessions belonging to other users', async () => { + await db.prisma.user.create({ + data: { id: 'user-2', email: 'c@d.com', passwordHash: 'hashed' }, + }); + await repo.create({ + id: 'mine', + userId: 'user-1', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + await repo.create({ + id: 'theirs', + userId: 'user-2', + userAgent: null, + ipAddress: null, + expiresAt: futureExpiry(), + }); + + await repo.revokeAllForUserExcept('user-1', 'mine'); + + expect(await repo.findActiveByUserId('user-2')).toHaveLength(1); + }); + }); +}); diff --git a/apps/api/src/__tests__/interface-adapters/mappers/SessionMapper.test.ts b/apps/api/src/__tests__/interface-adapters/mappers/SessionMapper.test.ts new file mode 100644 index 00000000..513c1db4 --- /dev/null +++ b/apps/api/src/__tests__/interface-adapters/mappers/SessionMapper.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { SessionMapper } from '@/interface-adapters/mappers/SessionMapper.js'; +import { makeSession } from '@/__tests__/helpers/mocks.js'; + +describe('SessionMapper', () => { + const mapper = new SessionMapper(); + + it('converts lastUsedAt and createdAt to ISO strings', () => { + const session = makeSession({ + createdAt: new Date('2024-01-01T00:00:00.000Z'), + lastUsedAt: new Date('2024-02-01T00:00:00.000Z'), + }); + + const dto = mapper.toDTO(session, undefined); + + expect(dto.createdAt).toBe('2024-01-01T00:00:00.000Z'); + expect(dto.lastUsedAt).toBe('2024-02-01T00:00:00.000Z'); + }); + + it('marks the session current when its id matches the given current session id', () => { + const session = makeSession({ id: 'session-abc' }); + + expect(mapper.toDTO(session, 'session-abc').current).toBe(true); + expect(mapper.toDTO(session, 'session-xyz').current).toBe(false); + expect(mapper.toDTO(session, undefined).current).toBe(false); + }); + + it('passes userAgent and ipAddress through unchanged, including null', () => { + const session = makeSession({ userAgent: null, ipAddress: null }); + + const dto = mapper.toDTO(session, undefined); + + expect(dto.userAgent).toBeNull(); + expect(dto.ipAddress).toBeNull(); + }); + + it('does not include userId on the DTO', () => { + const session = makeSession(); + + const dto = mapper.toDTO(session, undefined); + + expect(dto).not.toHaveProperty('userId'); + }); +}); diff --git a/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts b/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts index 10e70b32..68198bcf 100644 --- a/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts +++ b/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AuthResolver } from '@/interface-adapters/resolvers/AuthResolver.js'; -import { makeUser } from '@/__tests__/helpers/mocks.js'; +import { makeUser, makeSession } from '@/__tests__/helpers/mocks.js'; import type { IRegisterUseCase } from '@/use-cases/auth/IRegisterUseCase.js'; import type { ILoginUseCase } from '@/use-cases/auth/ILoginUseCase.js'; import type { IVerifyEmailUseCase } from '@/use-cases/auth/IVerifyEmailUseCase.js'; import type { ITokenService } from '@/use-cases/ports/ITokenService.js'; +import type { CreateSessionUseCase } from '@/use-cases/sessions/CreateSessionUseCase.js'; +import type { TouchSessionUseCase } from '@/use-cases/sessions/TouchSessionUseCase.js'; + +const stub = (methods: Partial): T => methods as T; const makeRegisterUseCase = (overrides?: Partial): IRegisterUseCase => ({ execute: vi.fn(), @@ -27,10 +31,18 @@ const makeTokenService = (overrides?: Partial): ITokenService => ...overrides, }); +const device = { userAgent: 'test-agent', ipAddress: '127.0.0.1' }; + const baseDeps = () => ({ registerUseCase: makeRegisterUseCase(), loginUseCase: makeLoginUseCase(), tokenService: makeTokenService(), + createSessionUseCase: stub({ + execute: vi.fn().mockResolvedValue(makeSession()), + }), + touchSessionUseCase: stub({ + execute: vi.fn().mockResolvedValue(makeSession()), + }), verifyEmailUseCase: makeVerifyEmailUseCase(), }); @@ -40,62 +52,84 @@ describe('AuthResolver', () => { }); describe('register', () => { - it('calls registerUseCase and returns a token pair', async () => { + it('registers, creates a session, and returns a token pair', async () => { const registerUseCase = makeRegisterUseCase({ execute: vi.fn().mockResolvedValue({ userId: 'user-1', email: 'test@example.com' }), }); const tokenService = makeTokenService(); + const createSessionUseCase = stub({ + execute: vi.fn().mockResolvedValue(makeSession({ id: 'session-1' })), + }); - const resolver = new AuthResolver({ ...baseDeps(), registerUseCase, tokenService }); + const resolver = new AuthResolver({ + ...baseDeps(), + registerUseCase, + tokenService, + createSessionUseCase, + }); - const result = await resolver.register('test@example.com', 'password123'); + const result = await resolver.register('test@example.com', 'password123', device); expect(registerUseCase.execute).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123', }); - expect(tokenService.sign).toHaveBeenCalledWith('user-1', 'test@example.com'); + expect(createSessionUseCase.execute).toHaveBeenCalledWith({ userId: 'user-1', ...device }); + expect(tokenService.sign).toHaveBeenCalledWith('user-1', 'test@example.com', 'session-1'); expect(result).toEqual({ accessToken: 'access-token', refreshToken: 'refresh-token' }); }); }); describe('login', () => { - it('calls loginUseCase and returns a token pair', async () => { + it('logs in, creates a session, and returns a token pair', async () => { const user = makeUser(); - const loginUseCase = makeLoginUseCase({ - execute: vi.fn().mockResolvedValue(user), - }); + const loginUseCase = makeLoginUseCase({ execute: vi.fn().mockResolvedValue(user) }); const tokenService = makeTokenService(); + const createSessionUseCase = stub({ + execute: vi.fn().mockResolvedValue(makeSession({ id: 'session-1' })), + }); - const resolver = new AuthResolver({ ...baseDeps(), loginUseCase, tokenService }); + const resolver = new AuthResolver({ + ...baseDeps(), + loginUseCase, + tokenService, + createSessionUseCase, + }); - const result = await resolver.login('test@example.com', 'password123'); + const result = await resolver.login('test@example.com', 'password123', device); expect(loginUseCase.execute).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123', }); - expect(tokenService.sign).toHaveBeenCalledWith(user.id, user.email); + expect(createSessionUseCase.execute).toHaveBeenCalledWith({ userId: user.id, ...device }); + expect(tokenService.sign).toHaveBeenCalledWith(user.id, user.email, 'session-1'); expect(result).toEqual({ accessToken: 'access-token', refreshToken: 'refresh-token' }); }); }); describe('refreshToken', () => { - it('verifies the refresh token and returns a new token pair', () => { + it('verifies the refresh token, touches the session, and returns a new token pair', async () => { const tokenService = makeTokenService({ - verifyRefresh: vi.fn().mockReturnValue({ sub: 'user-1', email: 'test@example.com' }), + verifyRefresh: vi + .fn() + .mockReturnValue({ sub: 'user-1', email: 'test@example.com', sid: 'session-1' }), + }); + const touchSessionUseCase = stub({ + execute: vi.fn().mockResolvedValue(makeSession({ id: 'session-1' })), }); - const resolver = new AuthResolver({ ...baseDeps(), tokenService }); + const resolver = new AuthResolver({ ...baseDeps(), tokenService, touchSessionUseCase }); - const result = resolver.refreshToken('valid-refresh-token'); + const result = await resolver.refreshToken('valid-refresh-token'); expect(tokenService.verifyRefresh).toHaveBeenCalledWith('valid-refresh-token'); - expect(tokenService.sign).toHaveBeenCalledWith('user-1', 'test@example.com'); + expect(touchSessionUseCase.execute).toHaveBeenCalledWith('session-1'); + expect(tokenService.sign).toHaveBeenCalledWith('user-1', 'test@example.com', 'session-1'); expect(result).toEqual({ accessToken: 'access-token', refreshToken: 'refresh-token' }); }); - it('throws UNAUTHORIZED when the refresh token is invalid', () => { + it('throws UNAUTHORIZED when the refresh token is invalid', async () => { const tokenService = makeTokenService({ verifyRefresh: vi.fn().mockImplementation(() => { throw Object.assign(new Error('Invalid refresh token'), { code: 'UNAUTHORIZED' }); @@ -104,17 +138,33 @@ describe('AuthResolver', () => { const resolver = new AuthResolver({ ...baseDeps(), tokenService }); - const err = (() => { - try { - resolver.refreshToken('expired-token'); - } catch (e) { - return e; - } - })(); + const err = await resolver.refreshToken('expired-token').catch((e) => e); expect(err).toBeInstanceOf(Error); expect((err as { code: string }).code).toBe('UNAUTHORIZED'); }); + + it('throws UNAUTHORIZED when the session has been revoked', async () => { + const tokenService = makeTokenService({ + verifyRefresh: vi + .fn() + .mockReturnValue({ sub: 'user-1', email: 'test@example.com', sid: 'session-1' }), + }); + const touchSessionUseCase = stub({ + execute: vi + .fn() + .mockRejectedValue( + Object.assign(new Error('Session revoked or expired'), { code: 'UNAUTHORIZED' }), + ), + }); + + const resolver = new AuthResolver({ ...baseDeps(), tokenService, touchSessionUseCase }); + + const err = await resolver.refreshToken('valid-refresh-token').catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(tokenService.sign).not.toHaveBeenCalled(); + }); }); describe('verifyEmail', () => { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9827b66f..d5cd763c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -59,7 +59,7 @@ export async function buildApp() { return { statusCode: 200, response: { ...result, errors } }; }, context: async (request, reply): Promise => { - let user: { sub: string; email: string } | null = null; + let user: { sub: string; email: string; sid?: string } | null = null; const cookieToken = request.cookies[COOKIES.ACCESS_TOKEN]; const authHeader = request.headers.authorization; @@ -84,7 +84,7 @@ export async function buildApp() { } else { // JWT path try { - user = fastify.jwt.verify<{ sub: string; email: string }>(rawToken); + user = fastify.jwt.verify<{ sub: string; email: string; sid: string }>(rawToken); } catch { // Expired/invalid token — resolvers enforce auth } diff --git a/apps/api/src/constants.ts b/apps/api/src/constants.ts index 58630606..3badf26d 100644 --- a/apps/api/src/constants.ts +++ b/apps/api/src/constants.ts @@ -76,6 +76,12 @@ export const JWT_EXPIRY = { REFRESH: '7d', } as const; +/** Session (device/refresh-token tracking) settings. */ +export const SESSION = { + /** How long a session stays active without a refresh, in milliseconds — mirrors the refresh JWT's lifetime and slides forward on each refresh. */ + TTL_MS: COOKIE_MAX_AGE_S.REFRESH_TOKEN * 1000, +} as const; + /** API-token (`jfat_...`) settings. */ export const API_TOKEN = { PREFIX: 'jfat_', diff --git a/apps/api/src/domain/session/Session.ts b/apps/api/src/domain/session/Session.ts new file mode 100644 index 00000000..5c003ece --- /dev/null +++ b/apps/api/src/domain/session/Session.ts @@ -0,0 +1,10 @@ +export interface Session { + id: string; + userId: string; + userAgent: string | null; + ipAddress: string | null; + lastUsedAt: Date; + createdAt: Date; + expiresAt: Date; + revokedAt: Date | null; +} diff --git a/apps/api/src/http/container.ts b/apps/api/src/http/container.ts index a9616e02..1f285672 100644 --- a/apps/api/src/http/container.ts +++ b/apps/api/src/http/container.ts @@ -16,6 +16,7 @@ import { PrismaInterviewRoundRepository } from '@/infrastructure/db/repositories import { CachedInterviewRoundRepository } from '@/infrastructure/db/repositories/CachedInterviewRoundRepository.js'; import { PrismaActivityLogRepository } from '@/infrastructure/db/repositories/PrismaActivityLogRepository.js'; import { PrismaContactRepository } from '@/infrastructure/db/repositories/PrismaContactRepository.js'; +import { PrismaSessionRepository } from '@/infrastructure/db/repositories/PrismaSessionRepository.js'; import { PrismaEmailVerificationTokenRepository } from '@/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.js'; import { LocalStorageProvider } from '@/infrastructure/storage/LocalStorageProvider.js'; @@ -27,6 +28,7 @@ import { DocumentMapper } from '@/interface-adapters/mappers/DocumentMapper.js'; import { InterviewRoundMapper } from '@/interface-adapters/mappers/InterviewRoundMapper.js'; import { ActivityLogMapper } from '@/interface-adapters/mappers/ActivityLogMapper.js'; import { ContactMapper } from '@/interface-adapters/mappers/ContactMapper.js'; +import { SessionMapper } from '@/interface-adapters/mappers/SessionMapper.js'; import { AuthResolver } from '@/interface-adapters/resolvers/AuthResolver.js'; import { ApplicationResolver } from '@/interface-adapters/resolvers/ApplicationResolver.js'; @@ -86,6 +88,11 @@ import { ParseJobDescriptionUseCase } from '@/use-cases/jobDescription/ParseJobD import { GenerateCoverLetterUseCase } from '@/use-cases/coverLetter/GenerateCoverLetterUseCase.js'; import { ComputeHealthScoreUseCase } from '@/use-cases/application/ComputeHealthScoreUseCase.js'; import { SendWeeklyDigestUseCase } from '@/use-cases/digest/SendWeeklyDigestUseCase.js'; +import { CreateSessionUseCase } from '@/use-cases/sessions/CreateSessionUseCase.js'; +import { TouchSessionUseCase } from '@/use-cases/sessions/TouchSessionUseCase.js'; +import { ListSessionsUseCase } from '@/use-cases/sessions/ListSessionsUseCase.js'; +import { RevokeSessionUseCase } from '@/use-cases/sessions/RevokeSessionUseCase.js'; +import { RevokeOtherSessionsUseCase } from '@/use-cases/sessions/RevokeOtherSessionsUseCase.js'; import type { FastifyInstance } from 'fastify'; import { ENV, LLM_PROVIDER, STORAGE_PROVIDER } from '@/constants.js'; @@ -117,6 +124,7 @@ declare module '@fastify/awilix' { activityLogRepository: PrismaActivityLogRepository; apiTokenRepository: PrismaApiTokenRepository; contactRepository: PrismaContactRepository; + sessionRepository: PrismaSessionRepository; emailVerificationTokenRepository: PrismaEmailVerificationTokenRepository; applicationMapper: ApplicationMapper; @@ -126,6 +134,7 @@ declare module '@fastify/awilix' { interviewRoundMapper: InterviewRoundMapper; activityLogMapper: ActivityLogMapper; contactMapper: ContactMapper; + sessionMapper: SessionMapper; authResolver: AuthResolver; applicationResolver: ApplicationResolver; @@ -181,6 +190,11 @@ declare module '@fastify/awilix' { generateCoverLetterUseCase: GenerateCoverLetterUseCase; computeHealthScoreUseCase: ComputeHealthScoreUseCase; sendWeeklyDigestUseCase: SendWeeklyDigestUseCase; + createSessionUseCase: CreateSessionUseCase; + touchSessionUseCase: TouchSessionUseCase; + listSessionsUseCase: ListSessionsUseCase; + revokeSessionUseCase: RevokeSessionUseCase; + revokeOtherSessionsUseCase: RevokeOtherSessionsUseCase; } } @@ -233,6 +247,7 @@ export function buildContainer(fastify: FastifyInstance): void { activityLogRepository: asClass(PrismaActivityLogRepository, { lifetime: Lifetime.SINGLETON }), apiTokenRepository: asClass(PrismaApiTokenRepository, { lifetime: Lifetime.SINGLETON }), contactRepository: asClass(PrismaContactRepository, { lifetime: Lifetime.SINGLETON }), + sessionRepository: asClass(PrismaSessionRepository, { lifetime: Lifetime.SINGLETON }), emailVerificationTokenRepository: asClass(PrismaEmailVerificationTokenRepository, { lifetime: Lifetime.SINGLETON, }), @@ -245,6 +260,7 @@ export function buildContainer(fastify: FastifyInstance): void { interviewRoundMapper: asClass(InterviewRoundMapper, { lifetime: Lifetime.SINGLETON }), activityLogMapper: asClass(ActivityLogMapper, { lifetime: Lifetime.SINGLETON }), contactMapper: asClass(ContactMapper, { lifetime: Lifetime.SINGLETON }), + sessionMapper: asClass(SessionMapper, { lifetime: Lifetime.SINGLETON }), // Resolvers authResolver: asClass(AuthResolver, { lifetime: Lifetime.SINGLETON }), @@ -321,5 +337,12 @@ export function buildContainer(fastify: FastifyInstance): void { }), computeHealthScoreUseCase: asClass(ComputeHealthScoreUseCase, { lifetime: Lifetime.TRANSIENT }), sendWeeklyDigestUseCase: asClass(SendWeeklyDigestUseCase, { lifetime: Lifetime.TRANSIENT }), + createSessionUseCase: asClass(CreateSessionUseCase, { lifetime: Lifetime.TRANSIENT }), + touchSessionUseCase: asClass(TouchSessionUseCase, { lifetime: Lifetime.TRANSIENT }), + listSessionsUseCase: asClass(ListSessionsUseCase, { lifetime: Lifetime.TRANSIENT }), + revokeSessionUseCase: asClass(RevokeSessionUseCase, { lifetime: Lifetime.TRANSIENT }), + revokeOtherSessionsUseCase: asClass(RevokeOtherSessionsUseCase, { + lifetime: Lifetime.TRANSIENT, + }), }); } diff --git a/apps/api/src/http/context.ts b/apps/api/src/http/context.ts index e5c67c69..77b21690 100644 --- a/apps/api/src/http/context.ts +++ b/apps/api/src/http/context.ts @@ -5,6 +5,8 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; export interface JwtUser { sub: string; email: string; + /** Session id — present for JWT-authenticated requests, absent for API-token auth (no session). */ + sid?: string; } export interface GraphQLContext { diff --git a/apps/api/src/http/schema/index.ts b/apps/api/src/http/schema/index.ts index ba6bdd4b..ce7f1a38 100644 --- a/apps/api/src/http/schema/index.ts +++ b/apps/api/src/http/schema/index.ts @@ -17,6 +17,7 @@ import './types/inputs/DocumentInputs.js'; import './types/ContactType.js'; import './types/ParsedJobDescriptionType.js'; import './types/HealthScoreType.js'; +import './types/SessionType.js'; // Queries import './queries/applicationQueries.js'; @@ -28,6 +29,7 @@ import './queries/activityLogQueries.js'; import './queries/apiTokenQueries.js'; import './queries/contactQueries.js'; import './queries/healthScoreQueries.js'; +import './queries/sessionQueries.js'; // Mutations import './mutations/authMutations.js'; @@ -40,5 +42,6 @@ import './mutations/apiTokenMutations.js'; import './mutations/contactMutations.js'; import './mutations/jobDescriptionMutations.js'; import './mutations/coverLetterMutations.js'; +import './mutations/sessionMutations.js'; export const schema = builder.toSchema(); diff --git a/apps/api/src/http/schema/mutations/authMutations.ts b/apps/api/src/http/schema/mutations/authMutations.ts index a3f78eb2..84f1ecc8 100644 --- a/apps/api/src/http/schema/mutations/authMutations.ts +++ b/apps/api/src/http/schema/mutations/authMutations.ts @@ -1,9 +1,18 @@ import { GraphQLError } from 'graphql'; +import type { FastifyRequest } from 'fastify'; import { builder } from '@/http/schema/builder.js'; import { setAuthCookies, clearAuthCookies } from '@/http/schema/types/AuthPayloadType.js'; +import type { DeviceInfo } from '@/interface-adapters/resolvers/AuthResolver.js'; import { fromCodedError } from '@/http/errors/AppError.js'; import { ERROR_CODES } from '@/constants.js'; +function deviceInfoFrom(request: FastifyRequest): DeviceInfo { + return { + userAgent: request.headers['user-agent'] ?? null, + ipAddress: request.ip ?? null, + }; +} + builder.mutationField('register', (t) => t.boolean({ args: { @@ -12,7 +21,11 @@ builder.mutationField('register', (t) => }, resolve: async (_root, args, ctx) => { const { authResolver } = ctx.diScope.cradle; - const tokens = await authResolver.register(args.email, args.password); + const tokens = await authResolver.register( + args.email, + args.password, + deviceInfoFrom(ctx.request), + ); setAuthCookies(ctx.reply, tokens.accessToken, tokens.refreshToken); return true; }, @@ -27,7 +40,11 @@ builder.mutationField('login', (t) => }, resolve: async (_root, args, ctx) => { const { authResolver } = ctx.diScope.cradle; - const tokens = await authResolver.login(args.email, args.password); + const tokens = await authResolver.login( + args.email, + args.password, + deviceInfoFrom(ctx.request), + ); setAuthCookies(ctx.reply, tokens.accessToken, tokens.refreshToken); return true; }, @@ -36,14 +53,14 @@ builder.mutationField('login', (t) => builder.mutationField('refreshToken', (t) => t.boolean({ - resolve: (_root, _args, ctx) => { + resolve: async (_root, _args, ctx) => { const refreshTokenCookie = ctx.request.cookies.jf_refresh_token; if (!refreshTokenCookie) throw new GraphQLError('No refresh token', { extensions: { code: ERROR_CODES.UNAUTHORIZED }, }); const { authResolver } = ctx.diScope.cradle; - const tokens = authResolver.refreshToken(refreshTokenCookie); + const tokens = await authResolver.refreshToken(refreshTokenCookie); setAuthCookies(ctx.reply, tokens.accessToken, tokens.refreshToken); return true; }, @@ -52,7 +69,12 @@ builder.mutationField('refreshToken', (t) => builder.mutationField('logout', (t) => t.boolean({ - resolve: (_root, _args, ctx) => { + resolve: async (_root, _args, ctx) => { + if (ctx.user?.sid) { + // Best-effort — an already-expired/missing session shouldn't block logout. + const { revokeSessionUseCase } = ctx.diScope.cradle; + await revokeSessionUseCase.execute(ctx.user.sid, ctx.user.sub).catch(() => {}); + } clearAuthCookies(ctx.reply); return true; }, diff --git a/apps/api/src/http/schema/mutations/sessionMutations.ts b/apps/api/src/http/schema/mutations/sessionMutations.ts new file mode 100644 index 00000000..695e1a1f --- /dev/null +++ b/apps/api/src/http/schema/mutations/sessionMutations.ts @@ -0,0 +1,40 @@ +import { GraphQLError } from 'graphql'; +import { builder } from '@/http/schema/builder.js'; +import { fromCodedError } from '@/http/errors/AppError.js'; +import { ERROR_CODES } from '@/constants.js'; + +builder.mutationField('revokeSession', (t) => + t.boolean({ + args: { + id: t.arg.id({ required: true }), + }, + resolve: async (_root, args, ctx) => { + if (!ctx.user) + throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); + const { revokeSessionUseCase } = ctx.diScope.cradle; + try { + await revokeSessionUseCase.execute(String(args.id), ctx.user.sub); + return true; + } catch (err) { + throw fromCodedError(err); + } + }, + }), +); + +builder.mutationField('revokeOtherSessions', (t) => + t.boolean({ + resolve: async (_root, _args, ctx) => { + const user = ctx.user; + if (!user) + throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); + if (!user.sid) + throw new GraphQLError('No active session', { + extensions: { code: ERROR_CODES.UNAUTHORIZED }, + }); + const { revokeOtherSessionsUseCase } = ctx.diScope.cradle; + await revokeOtherSessionsUseCase.execute(user.sub, user.sid); + return true; + }, + }), +); diff --git a/apps/api/src/http/schema/queries/sessionQueries.ts b/apps/api/src/http/schema/queries/sessionQueries.ts new file mode 100644 index 00000000..cd4ef430 --- /dev/null +++ b/apps/api/src/http/schema/queries/sessionQueries.ts @@ -0,0 +1,18 @@ +import { GraphQLError } from 'graphql'; +import { builder } from '@/http/schema/builder.js'; +import { SessionRef } from '@/http/schema/types/SessionType.js'; +import { ERROR_CODES } from '@/constants.js'; + +builder.queryField('sessions', (t) => + t.field({ + type: [SessionRef], + resolve: async (_root, _args, ctx) => { + const user = ctx.user; + if (!user) + throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); + const { listSessionsUseCase, sessionMapper } = ctx.diScope.cradle; + const sessions = await listSessionsUseCase.execute(user.sub); + return sessions.map((session) => sessionMapper.toDTO(session, user.sid)); + }, + }), +); diff --git a/apps/api/src/http/schema/types/SessionType.ts b/apps/api/src/http/schema/types/SessionType.ts new file mode 100644 index 00000000..5aa129f0 --- /dev/null +++ b/apps/api/src/http/schema/types/SessionType.ts @@ -0,0 +1,14 @@ +import { builder } from '@/http/schema/builder.js'; +import type { SessionDTO } from '@/interface-adapters/mappers/SessionMapper.js'; + +export const SessionRef = builder.objectRef('Session'); +SessionRef.implement({ + fields: (t) => ({ + id: t.exposeID('id'), + userAgent: t.exposeString('userAgent', { nullable: true }), + ipAddress: t.exposeString('ipAddress', { nullable: true }), + lastUsedAt: t.exposeString('lastUsedAt'), + createdAt: t.exposeString('createdAt'), + current: t.exposeBoolean('current'), + }), +}); diff --git a/apps/api/src/infrastructure/auth/FastifyJwtTokenService.ts b/apps/api/src/infrastructure/auth/FastifyJwtTokenService.ts index 2f0e57d9..81787e62 100644 --- a/apps/api/src/infrastructure/auth/FastifyJwtTokenService.ts +++ b/apps/api/src/infrastructure/auth/FastifyJwtTokenService.ts @@ -9,21 +9,21 @@ interface Deps { export class FastifyJwtTokenService implements ITokenService { constructor(private readonly deps: Deps) {} - sign(userId: string, email: string): TokenPair { + sign(userId: string, email: string, sessionId: string): TokenPair { const accessToken = this.deps.fastify.jwt.sign( - { sub: userId, email }, + { sub: userId, email, sid: sessionId }, { expiresIn: JWT_EXPIRY.ACCESS }, ); const refreshToken = this.deps.fastify.jwt.sign( - { sub: userId, email }, + { sub: userId, email, sid: sessionId }, { key: process.env[ENV.JWT_REFRESH_SECRET]!, expiresIn: JWT_EXPIRY.REFRESH }, ); return { accessToken, refreshToken }; } - verifyRefresh(token: string): { sub: string; email: string } { + verifyRefresh(token: string): { sub: string; email: string; sid: string } { try { - return this.deps.fastify.jwt.verify<{ sub: string; email: string }>(token, { + return this.deps.fastify.jwt.verify<{ sub: string; email: string; sid: string }>(token, { key: process.env[ENV.JWT_REFRESH_SECRET]!, }); } catch { diff --git a/apps/api/src/infrastructure/db/repositories/PrismaSessionRepository.ts b/apps/api/src/infrastructure/db/repositories/PrismaSessionRepository.ts new file mode 100644 index 00000000..84cafa27 --- /dev/null +++ b/apps/api/src/infrastructure/db/repositories/PrismaSessionRepository.ts @@ -0,0 +1,81 @@ +import type { PrismaClient } from '@prisma/client'; +import type { Session } from '@/domain/session/Session.js'; +import { getClient } from '../transactionContext.js'; +import type { + ISessionRepository, + CreateSessionData, +} from '@/use-cases/ports/ISessionRepository.js'; + +type PrismaSession = { + id: string; + userId: string; + userAgent: string | null; + ipAddress: string | null; + lastUsedAt: Date; + createdAt: Date; + expiresAt: Date; + revokedAt: Date | null; +}; + +export class PrismaSessionRepository implements ISessionRepository { + private readonly prisma: PrismaClient; + + constructor({ prisma }: { prisma: PrismaClient }) { + this.prisma = prisma; + } + + private get db(): PrismaClient { + return getClient(this.prisma); + } + + async create(data: CreateSessionData): Promise { + const row = await this.db.session.create({ data }); + return this.toEntity(row); + } + + async findById(id: string): Promise { + const row = await this.db.session.findUnique({ where: { id } }); + return row ? this.toEntity(row) : null; + } + + async findByIdAndUserId(id: string, userId: string): Promise { + const row = await this.db.session.findFirst({ where: { id, userId } }); + return row ? this.toEntity(row) : null; + } + + async findActiveByUserId(userId: string): Promise { + const rows = await this.db.session.findMany({ + where: { userId, revokedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { lastUsedAt: 'desc' }, + }); + return rows.map((r) => this.toEntity(r)); + } + + async touch(id: string, expiresAt: Date): Promise { + await this.db.session.update({ where: { id }, data: { lastUsedAt: new Date(), expiresAt } }); + } + + async revoke(id: string): Promise { + await this.db.session.update({ where: { id }, data: { revokedAt: new Date() } }); + } + + async revokeAllForUserExcept(userId: string, exceptId: string): Promise { + await this.db.session.updateMany({ + where: { userId, id: { not: exceptId }, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + private toEntity(row: PrismaSession): Session { + return { + id: row.id, + userId: row.userId, + userAgent: row.userAgent, + ipAddress: row.ipAddress, + lastUsedAt: row.lastUsedAt, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + revokedAt: row.revokedAt, + }; + } +} diff --git a/apps/api/src/interface-adapters/mappers/SessionMapper.ts b/apps/api/src/interface-adapters/mappers/SessionMapper.ts new file mode 100644 index 00000000..99ba310a --- /dev/null +++ b/apps/api/src/interface-adapters/mappers/SessionMapper.ts @@ -0,0 +1,23 @@ +import type { Session } from '@/domain/session/Session.js'; + +export interface SessionDTO { + id: string; + userAgent: string | null; + ipAddress: string | null; + lastUsedAt: string; + createdAt: string; + current: boolean; +} + +export class SessionMapper { + toDTO(session: Session, currentSessionId: string | undefined): SessionDTO { + return { + id: session.id, + userAgent: session.userAgent, + ipAddress: session.ipAddress, + lastUsedAt: session.lastUsedAt.toISOString(), + createdAt: session.createdAt.toISOString(), + current: session.id === currentSessionId, + }; + } +} diff --git a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts index b6763ed0..b5157c5b 100644 --- a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts +++ b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts @@ -2,30 +2,42 @@ import type { IRegisterUseCase } from '@/use-cases/auth/IRegisterUseCase.js'; import type { ILoginUseCase } from '@/use-cases/auth/ILoginUseCase.js'; import type { IVerifyEmailUseCase } from '@/use-cases/auth/IVerifyEmailUseCase.js'; import type { ITokenService, TokenPair } from '@/use-cases/ports/ITokenService.js'; +import type { CreateSessionUseCase } from '@/use-cases/sessions/CreateSessionUseCase.js'; +import type { TouchSessionUseCase } from '@/use-cases/sessions/TouchSessionUseCase.js'; interface Deps { registerUseCase: IRegisterUseCase; loginUseCase: ILoginUseCase; tokenService: ITokenService; + createSessionUseCase: CreateSessionUseCase; + touchSessionUseCase: TouchSessionUseCase; verifyEmailUseCase: IVerifyEmailUseCase; } +export interface DeviceInfo { + userAgent: string | null; + ipAddress: string | null; +} + export class AuthResolver { constructor(private readonly deps: Deps) {} - async register(email: string, password: string): Promise { + async register(email: string, password: string, device: DeviceInfo): Promise { const { userId } = await this.deps.registerUseCase.execute({ email, password }); - return this.deps.tokenService.sign(userId, email); + const session = await this.deps.createSessionUseCase.execute({ userId, ...device }); + return this.deps.tokenService.sign(userId, email, session.id); } - async login(email: string, password: string): Promise { + async login(email: string, password: string, device: DeviceInfo): Promise { const user = await this.deps.loginUseCase.execute({ email, password }); - return this.deps.tokenService.sign(user.id, user.email); + const session = await this.deps.createSessionUseCase.execute({ userId: user.id, ...device }); + return this.deps.tokenService.sign(user.id, user.email, session.id); } - refreshToken(refreshToken: string): TokenPair { + async refreshToken(refreshToken: string): Promise { const payload = this.deps.tokenService.verifyRefresh(refreshToken); - return this.deps.tokenService.sign(payload.sub, payload.email); + await this.deps.touchSessionUseCase.execute(payload.sid); + return this.deps.tokenService.sign(payload.sub, payload.email, payload.sid); } async verifyEmail(token: string): Promise { diff --git a/apps/api/src/use-cases/ports/ISessionRepository.ts b/apps/api/src/use-cases/ports/ISessionRepository.ts new file mode 100644 index 00000000..bf3980c1 --- /dev/null +++ b/apps/api/src/use-cases/ports/ISessionRepository.ts @@ -0,0 +1,19 @@ +import type { Session } from '@/domain/session/Session.js'; + +export interface CreateSessionData { + id: string; + userId: string; + userAgent: string | null; + ipAddress: string | null; + expiresAt: Date; +} + +export interface ISessionRepository { + create(data: CreateSessionData): Promise; + findById(id: string): Promise; + findByIdAndUserId(id: string, userId: string): Promise; + findActiveByUserId(userId: string): Promise; + touch(id: string, expiresAt: Date): Promise; + revoke(id: string): Promise; + revokeAllForUserExcept(userId: string, exceptId: string): Promise; +} diff --git a/apps/api/src/use-cases/ports/ITokenService.ts b/apps/api/src/use-cases/ports/ITokenService.ts index 187ec997..0d0c4024 100644 --- a/apps/api/src/use-cases/ports/ITokenService.ts +++ b/apps/api/src/use-cases/ports/ITokenService.ts @@ -4,6 +4,6 @@ export interface TokenPair { } export interface ITokenService { - sign(userId: string, email: string): TokenPair; - verifyRefresh(token: string): { sub: string; email: string }; + sign(userId: string, email: string, sessionId: string): TokenPair; + verifyRefresh(token: string): { sub: string; email: string; sid: string }; } diff --git a/apps/api/src/use-cases/sessions/CreateSessionUseCase.ts b/apps/api/src/use-cases/sessions/CreateSessionUseCase.ts new file mode 100644 index 00000000..7a8e7bf4 --- /dev/null +++ b/apps/api/src/use-cases/sessions/CreateSessionUseCase.ts @@ -0,0 +1,28 @@ +import type { ISessionRepository } from '@/use-cases/ports/ISessionRepository.js'; +import type { Session } from '@/domain/session/Session.js'; +import { SESSION } from '@/constants.js'; + +interface Deps { + sessionRepository: ISessionRepository; + generateId: () => string; +} + +export interface CreateSessionInput { + userId: string; + userAgent: string | null; + ipAddress: string | null; +} + +export class CreateSessionUseCase { + constructor(private readonly deps: Deps) {} + + async execute(input: CreateSessionInput): Promise { + return this.deps.sessionRepository.create({ + id: this.deps.generateId(), + userId: input.userId, + userAgent: input.userAgent, + ipAddress: input.ipAddress, + expiresAt: new Date(Date.now() + SESSION.TTL_MS), + }); + } +} diff --git a/apps/api/src/use-cases/sessions/ListSessionsUseCase.ts b/apps/api/src/use-cases/sessions/ListSessionsUseCase.ts new file mode 100644 index 00000000..1479aa65 --- /dev/null +++ b/apps/api/src/use-cases/sessions/ListSessionsUseCase.ts @@ -0,0 +1,14 @@ +import type { ISessionRepository } from '@/use-cases/ports/ISessionRepository.js'; +import type { Session } from '@/domain/session/Session.js'; + +interface Deps { + sessionRepository: ISessionRepository; +} + +export class ListSessionsUseCase { + constructor(private readonly deps: Deps) {} + + async execute(userId: string): Promise { + return this.deps.sessionRepository.findActiveByUserId(userId); + } +} diff --git a/apps/api/src/use-cases/sessions/RevokeOtherSessionsUseCase.ts b/apps/api/src/use-cases/sessions/RevokeOtherSessionsUseCase.ts new file mode 100644 index 00000000..927f8244 --- /dev/null +++ b/apps/api/src/use-cases/sessions/RevokeOtherSessionsUseCase.ts @@ -0,0 +1,13 @@ +import type { ISessionRepository } from '@/use-cases/ports/ISessionRepository.js'; + +interface Deps { + sessionRepository: ISessionRepository; +} + +export class RevokeOtherSessionsUseCase { + constructor(private readonly deps: Deps) {} + + async execute(userId: string, currentSessionId: string): Promise { + await this.deps.sessionRepository.revokeAllForUserExcept(userId, currentSessionId); + } +} diff --git a/apps/api/src/use-cases/sessions/RevokeSessionUseCase.ts b/apps/api/src/use-cases/sessions/RevokeSessionUseCase.ts new file mode 100644 index 00000000..18d3f42c --- /dev/null +++ b/apps/api/src/use-cases/sessions/RevokeSessionUseCase.ts @@ -0,0 +1,18 @@ +import type { ISessionRepository } from '@/use-cases/ports/ISessionRepository.js'; +import { ERROR_CODES } from '@/constants.js'; + +interface Deps { + sessionRepository: ISessionRepository; +} + +export class RevokeSessionUseCase { + constructor(private readonly deps: Deps) {} + + async execute(sessionId: string, userId: string): Promise { + const session = await this.deps.sessionRepository.findByIdAndUserId(sessionId, userId); + if (!session) { + throw Object.assign(new Error('Session not found'), { code: ERROR_CODES.NOT_FOUND }); + } + await this.deps.sessionRepository.revoke(sessionId); + } +} diff --git a/apps/api/src/use-cases/sessions/TouchSessionUseCase.ts b/apps/api/src/use-cases/sessions/TouchSessionUseCase.ts new file mode 100644 index 00000000..67054251 --- /dev/null +++ b/apps/api/src/use-cases/sessions/TouchSessionUseCase.ts @@ -0,0 +1,25 @@ +import type { ISessionRepository } from '@/use-cases/ports/ISessionRepository.js'; +import type { Session } from '@/domain/session/Session.js'; +import { ERROR_CODES, SESSION } from '@/constants.js'; + +interface Deps { + sessionRepository: ISessionRepository; +} + +export class TouchSessionUseCase { + constructor(private readonly deps: Deps) {} + + async execute(sessionId: string): Promise { + const session = await this.deps.sessionRepository.findById(sessionId); + if (!session || session.revokedAt || session.expiresAt < new Date()) { + throw Object.assign(new Error('Session revoked or expired'), { + code: ERROR_CODES.UNAUTHORIZED, + }); + } + + const expiresAt = new Date(Date.now() + SESSION.TTL_MS); + await this.deps.sessionRepository.touch(sessionId, expiresAt); + + return { ...session, lastUsedAt: new Date(), expiresAt }; + } +} diff --git a/apps/web/src/__tests__/components/AccountPage.test.tsx b/apps/web/src/__tests__/components/AccountPage.test.tsx index 63e6357c..20fea912 100644 --- a/apps/web/src/__tests__/components/AccountPage.test.tsx +++ b/apps/web/src/__tests__/components/AccountPage.test.tsx @@ -44,14 +44,23 @@ function Wrapper({ children }: { children: React.ReactNode }) { return {children}; } -const meResponse = { +const defaultResponse = { me: { id: 'user-1', email: 'test@example.com', name: null, timezone: null, targetRole: null }, + sessions: [ + { + id: 'session-1', + userAgent: 'Mozilla/5.0 (Macintosh)', + ipAddress: '10.0.0.1', + lastUsedAt: '2024-01-01T00:00:00.000Z', + current: true, + }, + ], }; describe('AccountPage', () => { beforeEach(() => { vi.clearAllMocks(); - mockGqlRequest.mockResolvedValue(meResponse); + mockGqlRequest.mockResolvedValue(defaultResponse); }); afterEach(() => { @@ -64,16 +73,21 @@ describe('AccountPage', () => { expect(screen.getByText('Profile')).toBeInTheDocument(); expect(screen.getByText('Email address')).toBeInTheDocument(); expect(screen.getByText('Password')).toBeInTheDocument(); + expect(screen.getByText('Active sessions')).toBeInTheDocument(); expect(screen.getByText('Export your data')).toBeInTheDocument(); expect(screen.getByText('Danger zone')).toBeInTheDocument(); await waitFor(() => { expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('Me')); }); + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('Sessions')); + }); }); describe('profile form', () => { it('pre-fills fields from the me query', async () => { mockGqlRequest.mockResolvedValue({ + ...defaultResponse, me: { id: 'user-1', email: 'test@example.com', @@ -120,6 +134,7 @@ describe('AccountPage', () => { it('sends null for cleared fields', async () => { mockGqlRequest.mockResolvedValue({ + ...defaultResponse, me: { id: 'user-1', email: 'test@example.com', @@ -167,8 +182,9 @@ describe('AccountPage', () => { describe('email update form', () => { it('calls updateEmail mutation with current password and new email', async () => { - mockGqlRequest.mockResolvedValue({ updateEmail: true }); render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); + mockGqlRequest.mockResolvedValue({ updateEmail: true }); const updateEmailBtn = screen.getByRole('button', { name: /update email/i }); const form = updateEmailBtn.closest('form')!; @@ -188,10 +204,11 @@ describe('AccountPage', () => { }); it('shows error message on email update failure', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); mockGqlRequest.mockRejectedValue({ response: { errors: [{ message: 'Email already in use' }] }, }); - render(, { wrapper: Wrapper }); const updateEmailBtn = screen.getByRole('button', { name: /update email/i }); const form = updateEmailBtn.closest('form')!; @@ -231,8 +248,9 @@ describe('AccountPage', () => { }); it('calls updatePassword mutation with matching passwords', async () => { - mockGqlRequest.mockResolvedValue({ updatePassword: true }); render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); + mockGqlRequest.mockResolvedValue({ updatePassword: true }); const updatePasswordBtn = screen.getByRole('button', { name: /update password/i }); const form = updatePasswordBtn.closest('form')!; @@ -252,8 +270,9 @@ describe('AccountPage', () => { }); it('shows generic error message when password update fails', async () => { - mockGqlRequest.mockRejectedValue(new Error('network error')); render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); + mockGqlRequest.mockRejectedValue(new Error('network error')); const updatePasswordBtn = screen.getByRole('button', { name: /update password/i }); const form = updatePasswordBtn.closest('form')!; @@ -270,14 +289,98 @@ describe('AccountPage', () => { }); }); + describe('active sessions', () => { + it('shows the current session without a revoke button', async () => { + render(, { wrapper: Wrapper }); + + await waitFor(() => { + expect(screen.getByText('Mozilla/5.0 (Macintosh)')).toBeInTheDocument(); + }); + expect(screen.getByText('This device')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^revoke$/i })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /sign out other sessions/i }), + ).not.toBeInTheDocument(); + }); + + it('shows a revoke button for non-current sessions and calls revokeSession', async () => { + mockGqlRequest.mockResolvedValue({ + ...defaultResponse, + sessions: [ + { + id: 'session-1', + userAgent: 'Chrome', + ipAddress: '1.1.1.1', + lastUsedAt: '2024-01-01T00:00:00.000Z', + current: true, + }, + { + id: 'session-2', + userAgent: 'Safari', + ipAddress: '2.2.2.2', + lastUsedAt: '2024-01-02T00:00:00.000Z', + current: false, + }, + ], + }); + render(, { wrapper: Wrapper }); + await waitFor(() => expect(screen.getByText('Safari')).toBeInTheDocument()); + + mockGqlRequest.mockResolvedValueOnce({ revokeSession: true }); + fireEvent.click(screen.getByRole('button', { name: /^revoke$/i })); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('RevokeSession'), { + id: 'session-2', + }); + }); + }); + + it('shows "Sign out other sessions" when there is more than one session', async () => { + mockGqlRequest.mockResolvedValue({ + ...defaultResponse, + sessions: [ + { + id: 'session-1', + userAgent: 'Chrome', + ipAddress: '1.1.1.1', + lastUsedAt: '2024-01-01T00:00:00.000Z', + current: true, + }, + { + id: 'session-2', + userAgent: 'Safari', + ipAddress: '2.2.2.2', + lastUsedAt: '2024-01-02T00:00:00.000Z', + current: false, + }, + ], + }); + render(, { wrapper: Wrapper }); + await waitFor(() => + expect( + screen.getByRole('button', { name: /sign out other sessions/i }), + ).toBeInTheDocument(), + ); + + mockGqlRequest.mockResolvedValueOnce({ revokeOtherSessions: true }); + fireEvent.click(screen.getByRole('button', { name: /sign out other sessions/i })); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('RevokeOtherSessions')); + }); + }); + }); + describe('data export', () => { it('calls exportUserData and triggers download on success', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); mockGqlRequest.mockResolvedValue({ exportUserData: '{"applications":[]}' }); const mockRevokeObjectURL = vi.fn(); vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); vi.spyOn(URL, 'revokeObjectURL').mockImplementation(mockRevokeObjectURL); - render(, { wrapper: Wrapper }); fireEvent.click(screen.getByRole('button', { name: /download export/i })); await waitFor(() => { @@ -291,8 +394,9 @@ describe('AccountPage', () => { describe('account deletion', () => { it('calls deleteAccount mutation, clears auth, and navigates to /login', async () => { - mockGqlRequest.mockResolvedValue({ deleteAccount: true }); render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); + mockGqlRequest.mockResolvedValue({ deleteAccount: true }); const deleteBtn = screen.getByRole('button', { name: /delete my account/i }); const form = deleteBtn.closest('form')!; @@ -311,10 +415,11 @@ describe('AccountPage', () => { }); it('shows error message when deletion fails', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); mockGqlRequest.mockRejectedValue({ response: { errors: [{ message: 'Invalid password' }] }, }); - render(, { wrapper: Wrapper }); const deleteBtn = screen.getByRole('button', { name: /delete my account/i }); const form = deleteBtn.closest('form')!; diff --git a/apps/web/src/routes/_authenticated/account.tsx b/apps/web/src/routes/_authenticated/account.tsx index b6c7a73f..167b1c46 100644 --- a/apps/web/src/routes/_authenticated/account.tsx +++ b/apps/web/src/routes/_authenticated/account.tsx @@ -56,6 +56,30 @@ const EXPORT_USER_DATA = ` } `; +const SESSIONS_QUERY = ` + query Sessions { + sessions { + id + userAgent + ipAddress + lastUsedAt + current + } + } +`; + +const REVOKE_SESSION = ` + mutation RevokeSession($id: ID!) { + revokeSession(id: $id) + } +`; + +const REVOKE_OTHER_SESSIONS = ` + mutation RevokeOtherSessions { + revokeOtherSessions + } +`; + // ── Schemas ──────────────────────────────────────────────────────────────── const profileSchema = z.object({ @@ -89,6 +113,14 @@ type EmailForm = z.infer; type PasswordForm = z.infer; type DeleteForm = z.infer; +type Session = { + id: string; + userAgent: string | null; + ipAddress: string | null; + lastUsedAt: string; + current: boolean; +}; + type Me = { id: string; email: string; @@ -110,6 +142,23 @@ export function AccountPage() { const navigate = useNavigate(); const qc = useQueryClient(); + // Active sessions + const { data: sessionsData } = useQuery({ + queryKey: ['sessions'], + queryFn: () => gqlClient.request<{ sessions: Session[] }>(SESSIONS_QUERY), + }); + const sessions = sessionsData?.sessions ?? []; + + const onRevokeSession = async (id: string) => { + await gqlClient.request(REVOKE_SESSION, { id }); + await qc.invalidateQueries({ queryKey: ['sessions'] }); + }; + + const onRevokeOtherSessions = async () => { + await gqlClient.request(REVOKE_OTHER_SESSIONS); + await qc.invalidateQueries({ queryKey: ['sessions'] }); + }; + const timezoneOptions = useMemo(() => { try { return Intl.supportedValuesOf('timeZone'); @@ -415,6 +464,61 @@ export function AccountPage() {
+ {/* ── Active sessions ── */} +
+
+
+

+ Active sessions +

+

+ Devices currently signed in to your account. +

+
+ {sessions.length > 1 && ( + + )} +
+
    + {sessions.map((session) => ( +
  • +
    +

    + {session.userAgent ?? 'Unknown device'} + {session.current && ( + This device + )} +

    +

    + {session.ipAddress ?? 'Unknown IP'} · Last active{' '} + {new Date(session.lastUsedAt).toLocaleString()} +

    +
    + {!session.current && ( + + )} +
  • + ))} +
+
+ +
+ {/* ── Export ── */}