From 1074749cd029eb812923a6553c341b3495870bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Wed, 22 Jul 2026 13:56:09 +0200 Subject: [PATCH] [WebApp] AccountSwitcher --- webapp/src/app/app.config.ts | 2 + webapp/src/app/core/services/auth.service.ts | 138 ++++++++++++-- .../account-info/account-info.component.ts | 7 +- .../features/auth/login/login.component.ts | 9 +- .../main-layout/main-layout.component.html | 100 ++++++++-- .../main-layout/main-layout.component.scss | 179 ++++++++++++++++-- .../main-layout/main-layout.component.ts | 30 ++- 7 files changed, 405 insertions(+), 60 deletions(-) diff --git a/webapp/src/app/app.config.ts b/webapp/src/app/app.config.ts index 2e90a87..8e4406c 100644 --- a/webapp/src/app/app.config.ts +++ b/webapp/src/app/app.config.ts @@ -45,6 +45,7 @@ import { PlayCircleOutline, StopOutline, ArrowLeftOutline, + DownOutline, } from '@ant-design/icons-angular/icons'; import { routes } from './app.routes'; @@ -91,6 +92,7 @@ const icons: IconDefinition[] = [ PlayCircleOutline, StopOutline, ArrowLeftOutline, + DownOutline, ]; export const appConfig: ApplicationConfig = { diff --git a/webapp/src/app/core/services/auth.service.ts b/webapp/src/app/core/services/auth.service.ts index ea80279..212f99e 100644 --- a/webapp/src/app/core/services/auth.service.ts +++ b/webapp/src/app/core/services/auth.service.ts @@ -1,54 +1,150 @@ import { Injectable, signal, computed } from '@angular/core'; -const USER_ID_KEY = 'scn_user_id'; -const ADMIN_KEY_KEY = 'scn_admin_key'; +export interface Account { + userId: string; + adminKey: string; + username: string | null; +} + +const ACCOUNTS_KEY = 'scn_accounts'; +const ACTIVE_KEY = 'scn_active_user_id'; + +// Legacy single-account storage keys (migrated on first load). +const LEGACY_USER_ID_KEY = 'scn_user_id'; +const LEGACY_ADMIN_KEY_KEY = 'scn_admin_key'; @Injectable({ providedIn: 'root' }) export class AuthService { - private userId = signal(null); - private adminKey = signal(null); + private _accounts = signal([]); + private _activeUserId = signal(null); - isAuthenticated = computed(() => !!this.userId() && !!this.adminKey()); + /** All logged-in accounts. */ + accounts = this._accounts.asReadonly(); + activeUserId = this._activeUserId.asReadonly(); + + activeAccount = computed(() => { + const id = this._activeUserId(); + return this._accounts().find(a => a.userId === id) ?? null; + }); + + isAuthenticated = computed(() => !!this.activeAccount()); constructor() { this.loadFromStorage(); } private loadFromStorage(): void { - const userId = localStorage.getItem(USER_ID_KEY); - const adminKey = localStorage.getItem(ADMIN_KEY_KEY); - if (userId && adminKey) { - this.userId.set(userId); - this.adminKey.set(adminKey); + const raw = localStorage.getItem(ACCOUNTS_KEY); + if (raw) { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + this._accounts.set( + parsed + .filter(a => a && a.userId && a.adminKey) + .map(a => ({ userId: a.userId, adminKey: a.adminKey, username: a.username ?? null })) + ); + } + } catch { + // Ignore malformed storage; treat as logged out. + } + } + + let active = localStorage.getItem(ACTIVE_KEY); + + // Migrate legacy single-account storage into the multi-account format. + if (this._accounts().length === 0) { + const legacyId = localStorage.getItem(LEGACY_USER_ID_KEY); + const legacyKey = localStorage.getItem(LEGACY_ADMIN_KEY_KEY); + if (legacyId && legacyKey) { + this._accounts.set([{ userId: legacyId, adminKey: legacyKey, username: null }]); + active = legacyId; + localStorage.removeItem(LEGACY_USER_ID_KEY); + localStorage.removeItem(LEGACY_ADMIN_KEY_KEY); + this.persist(active); + } + } + + // Ensure the active pointer references an existing account. + if (!active || !this._accounts().some(a => a.userId === active)) { + active = this._accounts()[0]?.userId ?? null; + } + this._activeUserId.set(active); + } + + private persist(activeOverride?: string | null): void { + localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(this._accounts())); + const active = activeOverride !== undefined ? activeOverride : this._activeUserId(); + if (active) { + localStorage.setItem(ACTIVE_KEY, active); + } else { + localStorage.removeItem(ACTIVE_KEY); } } + /** Add (or update) an account and make it the active one. */ login(userId: string, adminKey: string): void { - localStorage.setItem(USER_ID_KEY, userId); - localStorage.setItem(ADMIN_KEY_KEY, adminKey); - this.userId.set(userId); - this.adminKey.set(adminKey); + if (this._accounts().some(a => a.userId === userId)) { + this._accounts.update(list => + list.map(a => (a.userId === userId ? { ...a, adminKey } : a)) + ); + } else { + this._accounts.update(list => [...list, { userId, adminKey, username: null }]); + } + this._activeUserId.set(userId); + this.persist(); } + /** Log out the currently active account. */ logout(): void { - localStorage.removeItem(USER_ID_KEY); - localStorage.removeItem(ADMIN_KEY_KEY); - this.userId.set(null); - this.adminKey.set(null); + const active = this._activeUserId(); + if (active) { + this.removeAccount(active); + } + } + + /** Log out a specific account (active or background). */ + logoutAccount(userId: string): void { + this.removeAccount(userId); + } + + private removeAccount(userId: string): void { + this._accounts.update(list => list.filter(a => a.userId !== userId)); + if (this._activeUserId() === userId) { + this._activeUserId.set(this._accounts()[0]?.userId ?? null); + } + this.persist(); + } + + /** Switch which logged-in account is active. */ + switchAccount(userId: string): void { + if (!this._accounts().some(a => a.userId === userId)) return; + this._activeUserId.set(userId); + this.persist(); + } + + /** Cache the resolved username on the active account. */ + setActiveUsername(username: string | null): void { + const active = this._activeUserId(); + if (!active) return; + this._accounts.update(list => + list.map(a => (a.userId === active ? { ...a, username } : a)) + ); + this.persist(); } getUserId(): string | null { - return this.userId(); + return this._activeUserId(); } getAdminKey(): string | null { - return this.adminKey(); + return this.activeAccount()?.adminKey ?? null; } getAuthHeader(): string | null { - const key = this.adminKey(); + const key = this.getAdminKey(); return key ? `SCN ${key}` : null; } } diff --git a/webapp/src/app/features/account/account-info/account-info.component.ts b/webapp/src/app/features/account/account-info/account-info.component.ts index c91776a..dad7814 100644 --- a/webapp/src/app/features/account/account-info/account-info.component.ts +++ b/webapp/src/app/features/account/account-info/account-info.component.ts @@ -137,7 +137,12 @@ export class AccountInfoComponent implements OnInit { next: () => { this.notification.success('Account deleted'); this.authService.logout(); - this.router.navigate(['/login']); + if (this.authService.isAuthenticated()) { + // Another account is still logged in — reload into it cleanly. + window.location.assign('/'); + } else { + this.router.navigate(['/login']); + } }, error: () => { this.deleting.set(false); diff --git a/webapp/src/app/features/auth/login/login.component.ts b/webapp/src/app/features/auth/login/login.component.ts index 2073027..1908fa4 100644 --- a/webapp/src/app/features/auth/login/login.component.ts +++ b/webapp/src/app/features/auth/login/login.component.ts @@ -1,6 +1,6 @@ import { Component, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { Router, ActivatedRoute } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzButtonModule } from 'ng-zorro-antd/button'; @@ -27,7 +27,6 @@ import { isAdminKey } from '../../../core/models'; export class LoginComponent { private authService = inject(AuthService); private apiService = inject(ApiService); - private router = inject(Router); private route = inject(ActivatedRoute); userId = ''; @@ -56,9 +55,11 @@ export class LoginComponent { return; } - // Login successful + // Login successful. Use a full navigation so all per-account state + // (singleton caches, component signals) is initialised for the new + // active account — important when adding a second account. const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/messages'; - this.router.navigateByUrl(returnUrl); + window.location.assign(returnUrl); }, error: (err) => { this.authService.logout(); diff --git a/webapp/src/app/layout/main-layout/main-layout.component.html b/webapp/src/app/layout/main-layout/main-layout.component.html index a58ad32..9f6dc21 100644 --- a/webapp/src/app/layout/main-layout/main-layout.component.html +++ b/webapp/src/app/layout/main-layout/main-layout.component.html @@ -56,27 +56,25 @@
-
- - Expert + - -
@@ -84,3 +82,65 @@ + + + + diff --git a/webapp/src/app/layout/main-layout/main-layout.component.scss b/webapp/src/app/layout/main-layout/main-layout.component.scss index 05e7f2e..a397970 100644 --- a/webapp/src/app/layout/main-layout/main-layout.component.scss +++ b/webapp/src/app/layout/main-layout/main-layout.component.scss @@ -51,17 +51,6 @@ align-items: center; } -.expert-mode-toggle { - display: flex; - align-items: center; - gap: 8px; - - .expert-mode-label { - font-size: 13px; - color: #666; - } -} - .header-trigger { font-size: 18px; cursor: pointer; @@ -79,11 +68,43 @@ gap: 16px; } +// --- Account switcher trigger (in header) --- +.account-trigger { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + padding: 6px 10px; + border-radius: 8px; + max-width: 260px; + transition: background 0.2s; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + .account-caret { + font-size: 11px; + color: #999; + flex-shrink: 0; + } +} + .user-info { display: flex; flex-direction: column; align-items: flex-end; line-height: 1.3; + min-width: 0; + + .username, + .user-id, + .key-id { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .username { color: #333; @@ -102,6 +123,120 @@ } } +// --- Account switcher dropdown panel --- +.account-dropdown { + background: #fff; + border-radius: 8px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.14); + padding: 6px; + min-width: 260px; + max-width: calc(100vw - 24px); +} + +.dropdown-section { + display: flex; + flex-direction: column; +} + +.dropdown-divider { + height: 1px; + background: #f0f0f0; + margin: 6px 4px; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 10px; + + .settings-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #333; + } +} + +.accounts-section { + max-height: 40vh; + overflow-y: auto; +} + +.account-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + &.active { + background: #e6f4ff; + + &:hover { + background: #d6ecff; + } + } + + .account-avatar { + font-size: 18px; + color: #888; + flex-shrink: 0; + } + + .account-meta { + display: flex; + flex-direction: column; + line-height: 1.3; + min-width: 0; + flex: 1; + + .account-name { + font-size: 14px; + font-weight: 600; + color: #333; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .account-sub { + font-size: 12px; + color: #999; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .account-check { + color: #1890ff; + flex-shrink: 0; + } + + .account-logout { + flex-shrink: 0; + } +} + +.add-account-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + padding: 8px 10px; + height: auto; +} + .content-area { margin-left: 240px; transition: margin-left 0.2s; @@ -122,3 +257,25 @@ nz-layout:has(.ant-layout-sider-collapsed) .content-area { margin-left: 80px; } } + +// --- Mobile --- +@media (max-width: 600px) { + .app-header { + padding: 0 12px; + } + + .header-right { + gap: 8px; + } + + .account-trigger { + max-width: 62vw; + gap: 6px; + padding: 6px; + + // Keep the trigger to one identifying line on small screens. + .user-info .key-id { + display: none; + } + } +} diff --git a/webapp/src/app/layout/main-layout/main-layout.component.ts b/webapp/src/app/layout/main-layout/main-layout.component.ts index 2c0c411..25f3d50 100644 --- a/webapp/src/app/layout/main-layout/main-layout.component.ts +++ b/webapp/src/app/layout/main-layout/main-layout.component.ts @@ -39,6 +39,7 @@ export class MainLayoutComponent implements OnInit { isCollapsed = signal(false); userId = this.authService.getUserId(); + accounts = this.authService.accounts; currentKey = signal(null); username = signal(null); expertMode = this.settingsService.expertMode; @@ -62,7 +63,11 @@ export class MainLayoutComponent implements OnInit { if (!userId) return; this.apiService.getUser(userId).subscribe({ - next: (user) => this.username.set(user.username) + next: (user) => { + this.username.set(user.username); + // Cache the resolved name so the account switcher can label it. + this.authService.setActiveUsername(user.username); + } }); } @@ -70,8 +75,27 @@ export class MainLayoutComponent implements OnInit { this.isCollapsed.update(v => !v); } - logout(): void { - this.authService.logout(); + switchAccount(userId: string): void { + if (userId === this.userId) return; + this.authService.switchAccount(userId); + // Full reload so all per-account state (caches, component signals) resets cleanly. + window.location.assign('/'); + } + + logoutAccount(userId: string, event: Event): void { + event.stopPropagation(); + const wasActive = userId === this.authService.getUserId(); + this.authService.logoutAccount(userId); + + if (!this.authService.isAuthenticated()) { + this.router.navigate(['/login']); + } else if (wasActive) { + window.location.assign('/'); + } + // Otherwise a background account was removed; the accounts() signal updates the list. + } + + addAccount(): void { this.router.navigate(['/login']); } }