diff --git a/__tests__/api/security-sessions.test.ts b/__tests__/api/security-sessions.test.ts new file mode 100644 index 0000000..1661a08 --- /dev/null +++ b/__tests__/api/security-sessions.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +const { mockSql } = vi.hoisted(() => ({ + mockSql: vi.fn(), +})) + +vi.mock('@/lib/db', () => ({ + sql: Object.assign(mockSql, { raw: vi.fn() }), +})) + +vi.mock('@/lib/auth/middleware', async () => { + const actual = await vi.importActual< + typeof import('@/lib/auth/middleware') + >('@/lib/auth/middleware') + const injectAuth = (handler: (...args: unknown[]) => unknown) => + async (request: unknown, ...rest: unknown[]) => + handler(request, { walletAddress: 'GWALLET', tokenJti: 'jti' }, ...rest) + return { ...actual, withAuth: injectAuth, withAuthCtx: injectAuth } +}) + +vi.mock('@/lib/security/rateLimit', () => ({ + enforceRateLimit: vi.fn(async () => null), + buildRateLimitKey: vi.fn(() => 'test-key'), +})) + +import { GET } from '@/app/api/auth/sessions/route' +import { POST } from '@/app/api/auth/sessions/[id]/revoke/route' + +function row() { + return { + id: 1, + jti: 'secret-jti', + user_agent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0', + ip_address: '203.0.113.10', + created_at: '2026-01-01T00:00:00.000Z', + last_used_at: null, + expires_at: '2030-01-01T00:00:00.000Z', + revoked_at: null, + replaced_by_jti: null, + } +} + +function revokeRequest(id: string) { + return { + request: new NextRequest( + `http://localhost/api/auth/sessions/${id}/revoke`, + { method: 'POST' } + ), + context: { params: Promise.resolve({ id }) }, + } +} + +describe('GET /api/auth/sessions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the wallet sessions without sensitive fields', async () => { + mockSql.mockResolvedValueOnce([row()]) + + const response = await GET( + new NextRequest('http://localhost/api/auth/sessions') + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.sessions).toHaveLength(1) + expect(body.data.sessions[0].id).toBe(1) + expect(body.data.sessions[0].status).toBe('active') + + const serialized = JSON.stringify(body) + expect(serialized).not.toContain('secret-jti') + expect(serialized).not.toContain('token_hash') + }) + + it('returns 500 when the query fails', async () => { + mockSql.mockRejectedValueOnce(new Error('db down')) + + const response = await GET( + new NextRequest('http://localhost/api/auth/sessions') + ) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body.code).toBe('SESSIONS_LOAD_FAILED') + }) +}) + +describe('POST /api/auth/sessions/[id]/revoke', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects a non-numeric session id', async () => { + const { request, context } = revokeRequest('not-a-number') + const response = await POST(request, context) + expect(response.status).toBe(400) + const body = await response.json() + expect(body.code).toBe('INVALID_SESSION_ID') + }) + + it('returns 404 when the session is not owned by the wallet', async () => { + mockSql.mockResolvedValueOnce([]) + const { request, context } = revokeRequest('9') + const response = await POST(request, context) + expect(response.status).toBe(404) + const body = await response.json() + expect(body.code).toBe('SESSION_NOT_FOUND') + }) + + it('revokes an owned active session', async () => { + mockSql.mockResolvedValueOnce([{ id: 9 }]) + mockSql.mockResolvedValueOnce([{ id: 9 }]) + const { request, context } = revokeRequest('9') + const response = await POST(request, context) + const body = await response.json() + expect(response.status).toBe(200) + expect(body.data).toEqual({ id: 9, status: 'revoked' }) + }) + + it('returns 409 when the session is no longer revocable', async () => { + mockSql.mockResolvedValueOnce([{ id: 9 }]) + mockSql.mockResolvedValueOnce([]) + const { request, context } = revokeRequest('9') + const response = await POST(request, context) + expect(response.status).toBe(409) + const body = await response.json() + expect(body.code).toBe('SESSION_NOT_REVOCABLE') + }) + + it('returns 500 when revocation fails unexpectedly', async () => { + mockSql.mockRejectedValueOnce(new Error('db down')) + const { request, context } = revokeRequest('9') + const response = await POST(request, context) + expect(response.status).toBe(500) + const body = await response.json() + expect(body.code).toBe('SESSION_REVOKE_FAILED') + }) +}) diff --git a/__tests__/auth/sessions.test.ts b/__tests__/auth/sessions.test.ts new file mode 100644 index 0000000..de20a71 --- /dev/null +++ b/__tests__/auth/sessions.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockSql } = vi.hoisted(() => ({ + mockSql: vi.fn(), +})) + +vi.mock('@/lib/db', () => ({ + sql: Object.assign(mockSql, { raw: vi.fn() }), +})) + +vi.mock('@/lib/auth/crypto', () => ({ + sha256Hex: (value: string) => `hash:${value}`, +})) + +import { + groupSessionRows, + listSecuritySessions, + revokeWalletSession, + type RevokeSessionResult, +} from '@/lib/auth/sessions' +import { + buildSecurityAlerts, + parseUserAgent, + type SecuritySession, +} from '@/lib/auth/session-types' + +interface RowOverrides { + id?: number | string + jti?: string + user_agent?: string | null + ip_address?: string | null + created_at?: string + last_used_at?: string | null + expires_at?: string + revoked_at?: string | null + replaced_by_jti?: string | null +} + +function row(overrides: RowOverrides = {}) { + return { + id: 1, + jti: 'jti-1', + user_agent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0', + ip_address: '203.0.113.10', + created_at: '2026-01-01T00:00:00.000Z', + last_used_at: null, + expires_at: '2030-01-01T00:00:00.000Z', + revoked_at: null, + replaced_by_jti: null, + ...overrides, + } +} + +function session(overrides: Partial = {}): SecuritySession { + return { + id: 1, + status: 'active', + current: false, + device: { + browser: 'Chrome', + os: 'Windows', + type: 'desktop', + label: 'Chrome on Windows', + }, + ipAddress: '203.0.113.10', + loginAt: '2026-01-01T00:00:00.000Z', + lastActiveAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2030-01-01T00:00:00.000Z', + revokedAt: null, + ...overrides, + } +} + +describe('parseUserAgent', () => { + it('detects desktop chrome on windows', () => { + const device = parseUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36' + ) + expect(device.browser).toBe('Chrome') + expect(device.os).toBe('Windows') + expect(device.type).toBe('desktop') + expect(device.label).toBe('Chrome on Windows') + }) + + it('detects mobile safari on iOS', () => { + const device = parseUserAgent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Version/17.0 Mobile/15E148 Safari/604.1' + ) + expect(device.os).toBe('iOS') + expect(device.type).toBe('mobile') + expect(device.browser).toBe('Safari') + }) + + it('handles a missing user agent safely', () => { + expect(parseUserAgent(null)).toEqual({ + browser: 'Unknown browser', + os: 'Unknown OS', + type: 'unknown', + label: 'Unknown device', + }) + }) +}) + +describe('groupSessionRows', () => { + const now = Date.parse('2026-01-10T00:00:00.000Z') + + it('collapses a rotation chain into a single session', () => { + const rows = [ + row({ + id: 3, + jti: 'c', + created_at: '2026-01-05T00:00:00.000Z', + last_used_at: '2026-01-06T00:00:00.000Z', + }), + row({ + id: 2, + jti: 'b', + created_at: '2026-01-03T00:00:00.000Z', + revoked_at: '2026-01-05T00:00:00.000Z', + replaced_by_jti: 'c', + }), + row({ + id: 1, + jti: 'a', + created_at: '2026-01-01T00:00:00.000Z', + revoked_at: '2026-01-03T00:00:00.000Z', + replaced_by_jti: 'b', + }), + ] + + const sessions = groupSessionRows(rows, { now }) + + expect(sessions).toHaveLength(1) + expect(sessions[0].id).toBe(1) + expect(sessions[0].status).toBe('active') + expect(sessions[0].loginAt).toBe('2026-01-01T00:00:00.000Z') + expect(sessions[0].lastActiveAt).toBe('2026-01-06T00:00:00.000Z') + }) + + it('marks revoked and expired tips', () => { + const rows = [ + row({ id: 1, jti: 'a', revoked_at: '2026-01-02T00:00:00.000Z' }), + row({ + id: 2, + jti: 'b', + created_at: '2025-01-01T00:00:00.000Z', + expires_at: '2025-06-01T00:00:00.000Z', + }), + ] + + const sessions = groupSessionRows(rows, { now }) + const byId = new Map(sessions.map((s) => [s.id, s])) + + expect(byId.get(1)?.status).toBe('revoked') + expect(byId.get(2)?.status).toBe('expired') + }) + + it('flags the current session from its jti', () => { + const rows = [ + row({ id: 1, jti: 'a' }), + row({ id: 2, jti: 'b', created_at: '2026-01-02T00:00:00.000Z' }), + ] + + const sessions = groupSessionRows(rows, { now, currentJti: 'a' }) + expect(sessions.find((s) => s.id === 1)?.current).toBe(true) + expect(sessions.find((s) => s.id === 2)?.current).toBe(false) + }) + + it('never exposes token material', () => { + const rows = [row({ id: 1, jti: 'secret-jti' })] + const sessions = groupSessionRows(rows, { now }) + expect(JSON.stringify(sessions)).not.toContain('secret-jti') + expect(JSON.stringify(sessions)).not.toContain('token_hash') + }) +}) + +describe('buildSecurityAlerts', () => { + const now = Date.parse('2026-01-10T00:00:00.000Z') + + it('returns no alerts for a single current session', () => { + const alerts = buildSecurityAlerts( + [session({ current: true, loginAt: '2026-01-01T00:00:00.000Z' })], + { now } + ) + expect(alerts).toHaveLength(0) + }) + + it('warns about a recent sign-in from another device', () => { + const alerts = buildSecurityAlerts( + [ + session({ + id: 1, + current: true, + ipAddress: '203.0.113.10', + loginAt: '2026-01-01T00:00:00.000Z', + }), + session({ + id: 2, + ipAddress: '198.51.100.4', + loginAt: '2026-01-09T12:00:00.000Z', + }), + ], + { now } + ) + + expect(alerts.map((a) => a.id)).toContain('recent-sign-in') + expect(alerts.map((a) => a.id)).toContain('unfamiliar-network') + expect( + alerts.find((a) => a.id === 'unfamiliar-network')?.severity + ).toBe('critical') + }) + + it('reports a recent revocation', () => { + const alerts = buildSecurityAlerts( + [session({ status: 'revoked', revokedAt: '2026-01-08T00:00:00.000Z' })], + { now } + ) + expect(alerts.map((a) => a.id)).toContain('recent-revocation') + }) +}) + +describe('listSecuritySessions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns grouped sessions and alerts', async () => { + const rows = [row({ id: 1, jti: 'a' })] + mockSql.mockResolvedValueOnce(rows) + + const result = await listSecuritySessions('GWALLET', null) + + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0].id).toBe(1) + expect(Array.isArray(result.alerts)).toBe(true) + }) +}) + +describe('revokeWalletSession', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns not_found when the session does not belong to the wallet', async () => { + mockSql.mockResolvedValueOnce([]) + const result: RevokeSessionResult = await revokeWalletSession('GWALLET', 7) + expect(result).toBe('not_found') + }) + + it('revokes an owned active session', async () => { + mockSql.mockResolvedValueOnce([{ id: 7 }]) + mockSql.mockResolvedValueOnce([{ id: 7 }]) + + const result = await revokeWalletSession('GWALLET', 7) + expect(result).toBe('revoked') + }) + + it('returns not_revocable when nothing was updated', async () => { + mockSql.mockResolvedValueOnce([{ id: 7 }]) + mockSql.mockResolvedValueOnce([]) + + const result = await revokeWalletSession('GWALLET', 7) + expect(result).toBe('not_revocable') + }) +}) diff --git a/app/api/auth/sessions/[id]/revoke/route.ts b/app/api/auth/sessions/[id]/revoke/route.ts new file mode 100644 index 0000000..bdb195e --- /dev/null +++ b/app/api/auth/sessions/[id]/revoke/route.ts @@ -0,0 +1,75 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuthCtx } from '@/lib/auth/middleware' +import { revokeWalletSession } from '@/lib/auth/sessions' +import { enforceRateLimit, buildRateLimitKey } from '@/lib/security/rateLimit' + +/** + * POST /api/auth/sessions/[id]/revoke + * + * Revokes a single session belonging to the authenticated wallet. The client + * must only reflect the revocation in the UI after a 2xx response. + * + * Error codes: + * - 400 INVALID_SESSION_ID – non-numeric / non-positive id + * - 404 SESSION_NOT_FOUND – id does not belong to this wallet + * - 409 SESSION_NOT_REVOCABLE – already revoked/expired or a rotation record + * - 429 RATE_LIMITED + * - 500 SESSION_REVOKE_FAILED + */ +export const POST = withAuthCtx<{ params: Promise<{ id: string }> }>( + async (request: NextRequest, auth, context) => { + try { + const { id } = await context.params + const sessionId = Number(id) + if (!Number.isInteger(sessionId) || sessionId <= 0) { + return NextResponse.json( + { error: 'Session id must be a positive integer.', code: 'INVALID_SESSION_ID' }, + { status: 400 } + ) + } + + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey( + request, + 'auth:sessions:revoke', + auth.walletAddress + ), + limit: 20, + windowMs: 60_000, + }) + if (limited) return limited + + const result = await revokeWalletSession(auth.walletAddress, sessionId) + + if (result === 'not_found') { + return NextResponse.json( + { error: 'Session not found.', code: 'SESSION_NOT_FOUND' }, + { status: 404 } + ) + } + + if (result === 'not_revocable') { + return NextResponse.json( + { + error: 'This session is no longer active and cannot be revoked.', + code: 'SESSION_NOT_REVOCABLE', + }, + { status: 409 } + ) + } + + return NextResponse.json( + { data: { id: sessionId, status: 'revoked' } }, + { status: 200, headers: { 'Cache-Control': 'private, no-store' } } + ) + } catch (err) { + console.error('[POST /api/auth/sessions/[id]/revoke]', err) + return NextResponse.json( + { error: 'Failed to revoke session', code: 'SESSION_REVOKE_FAILED' }, + { status: 500 } + ) + } + } +) diff --git a/app/api/auth/sessions/route.ts b/app/api/auth/sessions/route.ts new file mode 100644 index 0000000..525200e --- /dev/null +++ b/app/api/auth/sessions/route.ts @@ -0,0 +1,47 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { readRefreshToken } from '@/lib/auth/session' +import { listSecuritySessions } from '@/lib/auth/sessions' +import { enforceRateLimit, buildRateLimitKey } from '@/lib/security/rateLimit' + +/** + * GET /api/auth/sessions + * + * Returns the authenticated wallet's sessions (active, revoked and expired) + * plus derived security alerts. + * + * Security notes: + * - Scoped to `auth.walletAddress`; the client can never query another wallet. + * - Response contains only non-sensitive fields (never `jti`, `token_hash`). + * - The current session is flagged via the httpOnly refresh cookie, not via + * an attacker-controllable query parameter. + */ +export const GET = withAuth(async (request: NextRequest, auth) => { + try { + const limited = await enforceRateLimit(request, { + key: buildRateLimitKey(request, 'auth:sessions:list', auth.walletAddress), + limit: 60, + windowMs: 60_000, + }) + if (limited) return limited + + const refreshToken = readRefreshToken(request) + const payload = await listSecuritySessions(auth.walletAddress, refreshToken) + + return NextResponse.json( + { data: payload, meta: { generatedAt: new Date().toISOString() } }, + { + status: 200, + headers: { 'Cache-Control': 'private, no-store' }, + } + ) + } catch (err) { + console.error('[GET /api/auth/sessions]', err) + return NextResponse.json( + { error: 'Failed to load sessions', code: 'SESSIONS_LOAD_FAILED' }, + { status: 500 } + ) + } +}) diff --git a/app/dashboard/security/page.tsx b/app/dashboard/security/page.tsx new file mode 100644 index 0000000..e49099b --- /dev/null +++ b/app/dashboard/security/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import { SecurityDashboard } from './security-client' + +export const metadata: Metadata = { + title: 'Security | TaskChain', + description: + 'Review account activity, monitor active sessions and manage who can access your TaskChain account.', +} + +export default function SecurityPage() { + return +} diff --git a/app/dashboard/security/security-client.test.tsx b/app/dashboard/security/security-client.test.tsx new file mode 100644 index 0000000..7edfb87 --- /dev/null +++ b/app/dashboard/security/security-client.test.tsx @@ -0,0 +1,199 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { toastSuccess, toastError, pushMock } = vi.hoisted(() => ({ + toastSuccess: vi.fn(), + toastError: vi.fn(), + pushMock: vi.fn(), +})) + +vi.mock('sonner', () => ({ + toast: { success: toastSuccess, error: toastError }, +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock }), +})) + +vi.mock('@/components/wallet-provider', () => ({ + useStellarWallet: () => ({ + address: null, + network: 'UNKNOWN', + isWrongNetwork: false, + isConnecting: false, + connect: vi.fn(), + }), + truncateStellarAddress: (address: string) => address, + networkLabel: (network: string) => network, + REQUIRED_NETWORK: 'TESTNET', +})) + +import { SecurityDashboard } from './security-client' + +const remoteSession = { + id: 1, + status: 'active' as const, + current: false, + device: { + browser: 'Chrome', + os: 'Windows', + type: 'desktop' as const, + label: 'Chrome on Windows', + }, + ipAddress: '203.0.113.10', + loginAt: '2026-01-01T00:00:00.000Z', + lastActiveAt: '2026-01-02T00:00:00.000Z', + expiresAt: '2030-01-01T00:00:00.000Z', + revokedAt: null, +} + +const currentSession = { + ...remoteSession, + id: 2, + current: true, + device: { ...remoteSession.device, label: 'Firefox on macOS' }, + ipAddress: '203.0.113.10', +} + +function mockSessionsFetch( + sessions: unknown[], + alerts: unknown[] = [], + revokeResponse: { ok: boolean; status?: number; body?: unknown } = { + ok: true, + body: { data: { id: 1, status: 'revoked' } }, + } +) { + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (init?.method === 'POST' && url.includes('/revoke')) { + return Promise.resolve({ + ok: revokeResponse.ok, + status: revokeResponse.status ?? (revokeResponse.ok ? 200 : 409), + json: async () => + revokeResponse.body ?? { error: 'Failed to revoke session' }, + }) as Promise + } + + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ data: { sessions, alerts } }), + }) as Promise + }) + + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +describe('SecurityDashboard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('renders sessions and distinguishes the current device', async () => { + mockSessionsFetch([remoteSession, currentSession]) + + render() + + expect(await screen.findByText('Chrome on Windows')).toBeInTheDocument() + expect(screen.getByText('Firefox on macOS')).toBeInTheDocument() + expect(screen.getAllByText('Active').length).toBeGreaterThanOrEqual(2) + expect(screen.getByText('This device')).toBeInTheDocument() + }) + + it('shows the empty state when there are no active sessions', async () => { + mockSessionsFetch([]) + render() + expect(await screen.findByText('No active sessions.')).toBeInTheDocument() + }) + + it('shows an error state and retries on failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: false, + status: 500, + json: async () => ({ error: 'Failed to load sessions' }), + }) as Promise + ) + ) + + render() + + expect( + await screen.findByText('Failed to load sessions') + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /try again/i }) + ).toBeInTheDocument() + }) + + it('confirms before revoking and only then updates the UI', async () => { + const fetchMock = mockSessionsFetch([remoteSession, currentSession]) + + render() + + const revokeButton = await screen.findByRole('button', { name: 'Revoke' }) + fireEvent.click(revokeButton) + + // Confirmation dialog is shown and no request has been made yet. + expect( + await screen.findByText('Revoke this session?') + ).toBeInTheDocument() + expect( + fetchMock.mock.calls.some(([, init]) => init?.method === 'POST') + ).toBe(false) + + fireEvent.click(screen.getByRole('button', { name: /revoke session/i })) + + await waitFor(() => { + expect(toastSuccess).toHaveBeenCalledWith('Session revoked') + }) + + expect( + fetchMock.mock.calls.some( + ([url, init]) => + init?.method === 'POST' && String(url).includes('/sessions/1/revoke') + ) + ).toBe(true) + + // The revoked session moves out of the active list. + expect(screen.queryByText('Chrome on Windows')).not.toBeInTheDocument() + expect( + screen.getByRole('tab', { name: /revoked \(1\)/i }) + ).toBeInTheDocument() + + // Radix tabs activate on mouseDown, not click. + fireEvent.mouseDown(screen.getByRole('tab', { name: /revoked \(1\)/i })) + expect(await screen.findByText('Chrome on Windows')).toBeInTheDocument() + expect(screen.getByText('Revoked')).toBeInTheDocument() + }) + + it('keeps the session active and surfaces an error when revocation fails', async () => { + mockSessionsFetch([remoteSession, currentSession], [], { + ok: false, + status: 409, + body: { error: 'This session is no longer active' }, + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Revoke' })) + fireEvent.click(screen.getByRole('button', { name: /revoke session/i })) + + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + 'This session is no longer active' + ) + }) + + // The dialog closes and the session is still listed as active. + expect(screen.queryByText('Revoked')).not.toBeInTheDocument() + }) +}) diff --git a/app/dashboard/security/security-client.tsx b/app/dashboard/security/security-client.tsx new file mode 100644 index 0000000..1716511 --- /dev/null +++ b/app/dashboard/security/security-client.tsx @@ -0,0 +1,650 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { + AlertTriangle, + Check, + Copy, + Info, + Laptop, + Loader2, + MonitorSmartphone, + RefreshCw, + ShieldAlert, + ShieldCheck, + Smartphone, + Tablet, + Wallet, +} from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { + REQUIRED_NETWORK, + networkLabel, + truncateStellarAddress, + useStellarWallet, +} from '@/components/wallet-provider' +import { cn } from '@/lib/utils' +import { + buildSecurityAlerts, + type SecurityAlert, + type SecuritySession, + type SessionDeviceType, + type SessionStatus, +} from '@/lib/auth/session-types' + +function getAuthHeaders(): Record { + const token = + typeof window !== 'undefined' + ? localStorage.getItem('tc_dev_access_token') + : null + return token ? { Authorization: `Bearer ${token}` } : {} +} + +function formatDateTime(iso: string): string { + const date = new Date(iso) + return Number.isNaN(date.getTime()) ? 'Unknown time' : date.toLocaleString() +} + +function formatRelative(iso: string): string { + const parsed = Date.parse(iso) + if (Number.isNaN(parsed)) return 'Unknown' + + const diffMs = Date.now() - parsed + const diffSec = Math.floor(diffMs / 1000) + if (diffSec < 60) return 'just now' + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHour = Math.floor(diffMin / 60) + if (diffHour < 24) return `${diffHour}h ago` + const diffDay = Math.floor(diffHour / 24) + if (diffDay < 7) return `${diffDay}d ago` + return formatDateTime(iso) +} + +const deviceIcons: Record = { + mobile: Smartphone, + tablet: Tablet, + desktop: Laptop, + unknown: MonitorSmartphone, +} + +const alertStyles: Record< + SecurityAlert['severity'], + { container: string; icon: string; Icon: typeof ShieldAlert } +> = { + critical: { + container: 'border-destructive/40 bg-destructive/10', + icon: 'text-destructive', + Icon: ShieldAlert, + }, + warning: { + container: 'border-amber-500/40 bg-amber-500/10', + icon: 'text-amber-600 dark:text-amber-400', + Icon: AlertTriangle, + }, + info: { + container: 'border-border/40 bg-card/50', + icon: 'text-primary', + Icon: Info, + }, +} + +function StatusBadge({ status }: { status: SessionStatus }) { + if (status === 'active') { + return Active + } + if (status === 'revoked') { + return Revoked + } + return Expired +} + +function SessionSkeleton() { + return ( +