diff --git a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts index a373da08e..9bd0bb316 100644 --- a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts @@ -213,6 +213,18 @@ export const authStore = { return authService.getSecurityEvents(); }, + async listSessions() { + const authService = getAuthService(); + if (!authService) return []; + return authService.listSessions(); + }, + + async revokeSession(sessionId: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available' }; + return authService.revokeSession(sessionId); + }, + /** * Sign in with email and password */ diff --git a/apps/manacore/apps/web/src/routes/(app)/settings/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/settings/+page.svelte index fa69256a1..1f4a62c3c 100644 --- a/apps/manacore/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/settings/+page.svelte @@ -1,7 +1,12 @@ + +
+
+
+
+ + + +
+
+

{t.title} ({sessions.length})

+

{t.subtitle}

+
+
+ +
+ + {#if error} +
{error}
+ {/if} + + {#if loading && sessions.length === 0} +
+
+
+ {:else if sessions.length === 0} +

{t.noSessions}

+ {:else} +
+ {#each sessions as session (session.id)} + {@const current = isCurrent(session)} + {@const deviceType = getDeviceType(session.userAgent)} +
+
+ {#if deviceType === 'mobile'} + + + + {:else if deviceType === 'tablet'} + + + + {:else} + + + + {/if} +
+
+
+ {getSessionLabel(session)} + {#if current} + {t.current} + {/if} +
+ {#if session.ipAddress} +
{session.ipAddress}
+ {/if} + {#if session.lastActivityAt} +
+ {t.lastActivity}: {formatRelativeTime(session.lastActivityAt)} +
+ {/if} +
+ {#if !current} + + {/if} +
+ {/each} +
+ + {#if otherSessionCount > 0} + + {/if} + {/if} +
+ + diff --git a/packages/shared-auth-ui/src/index.ts b/packages/shared-auth-ui/src/index.ts index dfa364a45..3c6e845a3 100644 --- a/packages/shared-auth-ui/src/index.ts +++ b/packages/shared-auth-ui/src/index.ts @@ -14,6 +14,7 @@ export { default as SecurityOnboarding } from './components/SecurityOnboarding.s export { default as ChangePassword } from './components/ChangePassword.svelte'; export { default as PasswordStrength } from './components/PasswordStrength.svelte'; export { default as AuditLog } from './components/AuditLog.svelte'; +export { default as SessionManager } from './components/SessionManager.svelte'; // Utilities export { @@ -34,3 +35,4 @@ export type { } from './types'; export type { PasskeyManagerTranslations } from './components/PasskeyManager.svelte'; export type { TwoFactorSetupTranslations } from './components/TwoFactorSetup.svelte'; +export type { SessionManagerTranslations } from './components/SessionManager.svelte'; diff --git a/packages/shared-auth-ui/src/pages/LoginPage.svelte b/packages/shared-auth-ui/src/pages/LoginPage.svelte index a5cc9caa5..ee0ebb7c4 100644 --- a/packages/shared-auth-ui/src/pages/LoginPage.svelte +++ b/packages/shared-auth-ui/src/pages/LoginPage.svelte @@ -148,6 +148,7 @@ let useBackupCode = $state(false); let trustDevice = $state(false); let rateLimitCountdown = $state(0); + let isLockedOut = $state(false); let magicLinkSent = $state(false); let sendingMagicLink = $state(false); @@ -157,9 +158,17 @@ rateLimitCountdown--; }, 1000); return () => clearTimeout(timer); + } else if (isLockedOut) { + isLockedOut = false; } }); + function formatCountdown(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m > 0 ? `${m}:${s.toString().padStart(2, '0')}` : `${s}s`; + } + // Theme state - can be toggled manually, defaults to system preference let userThemePreference = $state<'light' | 'dark' | null>(null); let systemIsDark = $state( @@ -267,13 +276,14 @@ } else { setError(result.error || t.signInFailed, 'general'); - // Detect rate limiting + // Detect rate limiting vs account lockout if (result.error?.includes('Too Many') || result.error?.includes('rate limit')) { rateLimitCountdown = 60; // 1 minute cooldown } else if ( result.error?.includes('temporarily locked') || result.error === 'ACCOUNT_LOCKED' ) { + isLockedOut = true; rateLimitCountdown = (result as any).retryAfter || 300; // 5 min default } } @@ -596,7 +606,32 @@ {/if} - {#if error} + {#if isLockedOut} + + {:else if error} @@ -1013,6 +1050,50 @@ margin-top: 0.25rem; } + .lockout-banner { + display: flex; + gap: 0.75rem; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 0.75rem; + background: rgba(245, 158, 11, 0.15); + border: 1px solid rgba(245, 158, 11, 0.3); + color: #f59e0b; + } + + .lockout-icon { + flex-shrink: 0; + margin-top: 0.125rem; + } + + .lockout-content { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .lockout-title { + font-weight: 600; + font-size: 0.9rem; + } + + .lockout-text { + font-size: 0.8rem; + opacity: 0.9; + } + + .lockout-reset-link { + background: none; + border: none; + cursor: pointer; + font-weight: 500; + font-size: 0.8rem; + padding: 0; + text-align: left; + text-decoration: underline; + margin-top: 0.25rem; + } + .resend-link { background: none; border: none; diff --git a/packages/shared-auth/src/core/authService.ts b/packages/shared-auth/src/core/authService.ts index 903870320..14da45f77 100644 --- a/packages/shared-auth/src/core/authService.ts +++ b/packages/shared-auth/src/core/authService.ts @@ -824,6 +824,52 @@ export function createAuthService(config: AuthServiceConfig) { } }, + /** + * List active sessions + */ + async listSessions(): Promise { + try { + const appToken = await service.getAppToken(); + if (!appToken) return []; + + const res = await fetch(`${baseUrl}/api/v1/auth/sessions`, { + headers: { Authorization: `Bearer ${appToken}` }, + }); + + if (!res.ok) return []; + return await res.json(); + } catch { + return []; + } + }, + + /** + * Revoke a session + */ + async revokeSession(sessionId: string): Promise { + try { + const appToken = await service.getAppToken(); + if (!appToken) return { success: false, error: 'Not authenticated' }; + + const res = await fetch(`${baseUrl}/api/v1/auth/sessions/${sessionId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${appToken}` }, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { success: false, error: err.message || 'Failed to revoke session' }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to revoke session', + }; + } + }, + /** * Get the current app token */ diff --git a/services/mana-core-auth/src/auth/auth.controller.ts b/services/mana-core-auth/src/auth/auth.controller.ts index 7df02cb00..b13e662e6 100644 --- a/services/mana-core-auth/src/auth/auth.controller.ts +++ b/services/mana-core-auth/src/auth/auth.controller.ts @@ -837,6 +837,47 @@ export class AuthController { return this.betterAuthService.getSecurityEvents(user.userId); } + // ========================================================================= + // Session Management + // ========================================================================= + + /** + * List active sessions for the current user + */ + @Get('sessions') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'List active sessions' }) + @ApiBearerAuth('JWT-auth') + @ApiResponse({ status: 200, description: 'Returns list of active sessions' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + async listSessions(@CurrentUser() user: CurrentUserData) { + return this.betterAuthService.listSessions(user.userId); + } + + /** + * Revoke a specific session + */ + @Delete('sessions/:id') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Revoke a session' }) + @ApiBearerAuth('JWT-auth') + @ApiResponse({ status: 204, description: 'Session revoked successfully' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 404, description: 'Session not found' }) + async revokeSession( + @CurrentUser() user: CurrentUserData, + @Param('id') sessionId: string, + @Req() req: Request + ) { + await this.betterAuthService.revokeSession(user.userId, sessionId); + this.securityEvents.logEventWithRequest(req, { + userId: user.userId, + eventType: SecurityEventType.LOGOUT, + metadata: { revokedSessionId: sessionId }, + }); + } + // ========================================================================= // Passkey (WebAuthn) Endpoints // ========================================================================= diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts index 2049015ff..a843ec1b9 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.ts @@ -2189,4 +2189,55 @@ export class BetterAuthService { return events; } + + /** + * List active sessions for a user + */ + async listSessions(userId: string) { + const db = getDb(this.databaseUrl); + const { sessions } = await import('../../db/schema'); + const { eq, and, isNull, gt } = await import('drizzle-orm'); + + const activeSessions = await db + .select({ + id: sessions.id, + ipAddress: sessions.ipAddress, + userAgent: sessions.userAgent, + deviceId: sessions.deviceId, + deviceName: sessions.deviceName, + lastActivityAt: sessions.lastActivityAt, + createdAt: sessions.createdAt, + expiresAt: sessions.expiresAt, + }) + .from(sessions) + .where( + and( + eq(sessions.userId, userId), + isNull(sessions.revokedAt), + gt(sessions.expiresAt, new Date()) + ) + ) + .orderBy(sessions.lastActivityAt); + + return activeSessions; + } + + /** + * Revoke a specific session + */ + async revokeSession(userId: string, sessionId: string) { + const db = getDb(this.databaseUrl); + const { sessions } = await import('../../db/schema'); + const { eq, and } = await import('drizzle-orm'); + + const result = await db + .update(sessions) + .set({ revokedAt: new Date() }) + .where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId))) + .returning({ id: sessions.id }); + + if (result.length === 0) { + throw new NotFoundException('Session not found'); + } + } }