[WebApp] AccountSwitcher

This commit is contained in:
2026-07-22 13:56:09 +02:00
parent 3759d3f6c6
commit 1074749cd0
7 changed files with 405 additions and 60 deletions
+2
View File
@@ -45,6 +45,7 @@ import {
PlayCircleOutline, PlayCircleOutline,
StopOutline, StopOutline,
ArrowLeftOutline, ArrowLeftOutline,
DownOutline,
} from '@ant-design/icons-angular/icons'; } from '@ant-design/icons-angular/icons';
import { routes } from './app.routes'; import { routes } from './app.routes';
@@ -91,6 +92,7 @@ const icons: IconDefinition[] = [
PlayCircleOutline, PlayCircleOutline,
StopOutline, StopOutline,
ArrowLeftOutline, ArrowLeftOutline,
DownOutline,
]; ];
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
+117 -21
View File
@@ -1,54 +1,150 @@
import { Injectable, signal, computed } from '@angular/core'; import { Injectable, signal, computed } from '@angular/core';
const USER_ID_KEY = 'scn_user_id'; export interface Account {
const ADMIN_KEY_KEY = 'scn_admin_key'; 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({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class AuthService { export class AuthService {
private userId = signal<string | null>(null); private _accounts = signal<Account[]>([]);
private adminKey = signal<string | null>(null); private _activeUserId = signal<string | null>(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() { constructor() {
this.loadFromStorage(); this.loadFromStorage();
} }
private loadFromStorage(): void { private loadFromStorage(): void {
const userId = localStorage.getItem(USER_ID_KEY); const raw = localStorage.getItem(ACCOUNTS_KEY);
const adminKey = localStorage.getItem(ADMIN_KEY_KEY); if (raw) {
if (userId && adminKey) { try {
this.userId.set(userId); const parsed = JSON.parse(raw);
this.adminKey.set(adminKey); 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 { login(userId: string, adminKey: string): void {
localStorage.setItem(USER_ID_KEY, userId); if (this._accounts().some(a => a.userId === userId)) {
localStorage.setItem(ADMIN_KEY_KEY, adminKey); this._accounts.update(list =>
this.userId.set(userId); list.map(a => (a.userId === userId ? { ...a, adminKey } : a))
this.adminKey.set(adminKey); );
} else {
this._accounts.update(list => [...list, { userId, adminKey, username: null }]);
}
this._activeUserId.set(userId);
this.persist();
} }
/** Log out the currently active account. */
logout(): void { logout(): void {
localStorage.removeItem(USER_ID_KEY); const active = this._activeUserId();
localStorage.removeItem(ADMIN_KEY_KEY); if (active) {
this.userId.set(null); this.removeAccount(active);
this.adminKey.set(null); }
}
/** 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 { getUserId(): string | null {
return this.userId(); return this._activeUserId();
} }
getAdminKey(): string | null { getAdminKey(): string | null {
return this.adminKey(); return this.activeAccount()?.adminKey ?? null;
} }
getAuthHeader(): string | null { getAuthHeader(): string | null {
const key = this.adminKey(); const key = this.getAdminKey();
return key ? `SCN ${key}` : null; return key ? `SCN ${key}` : null;
} }
} }
@@ -137,7 +137,12 @@ export class AccountInfoComponent implements OnInit {
next: () => { next: () => {
this.notification.success('Account deleted'); this.notification.success('Account deleted');
this.authService.logout(); 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: () => { error: () => {
this.deleting.set(false); this.deleting.set(false);
@@ -1,6 +1,6 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router, ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NzInputModule } from 'ng-zorro-antd/input'; import { NzInputModule } from 'ng-zorro-antd/input';
import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzButtonModule } from 'ng-zorro-antd/button';
@@ -27,7 +27,6 @@ import { isAdminKey } from '../../../core/models';
export class LoginComponent { export class LoginComponent {
private authService = inject(AuthService); private authService = inject(AuthService);
private apiService = inject(ApiService); private apiService = inject(ApiService);
private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
userId = ''; userId = '';
@@ -56,9 +55,11 @@ export class LoginComponent {
return; 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'; const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/messages';
this.router.navigateByUrl(returnUrl); window.location.assign(returnUrl);
}, },
error: (err) => { error: (err) => {
this.authService.logout(); this.authService.logout();
@@ -56,27 +56,25 @@
</span> </span>
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="expert-mode-toggle"> <div
<nz-switch class="account-trigger"
[ngModel]="expertMode()" nz-dropdown
(ngModelChange)="settingsService.setExpertMode($event)" nzTrigger="click"
nzSize="small" [nzClickHide]="false"
></nz-switch> nzPlacement="bottomRight"
<span class="expert-mode-label">Expert</span> [nzDropdownMenu]="accountMenu"
>
<div class="user-info">
@if (username()) {
<span class="username">{{ username() }}</span>
}
<span class="user-id mono">{{ userId }}</span>
@if (currentKey()) {
<span class="key-id mono">{{ currentKey()!.keytoken_id }}</span>
}
</div>
<span nz-icon nzType="down" class="account-caret"></span>
</div> </div>
<div class="user-info">
@if (username()) {
<span class="username">{{ username() }}</span>
}
<span class="user-id mono">{{ userId }}</span>
@if (currentKey()) {
<span class="key-id mono">{{ currentKey()!.keytoken_id }}</span>
}
</div>
<button nz-button nzType="text" nzDanger (click)="logout()">
<span nz-icon nzType="logout"></span>
Logout
</button>
</div> </div>
</nz-header> </nz-header>
<nz-content class="content-area"> <nz-content class="content-area">
@@ -84,3 +82,65 @@
</nz-content> </nz-content>
</nz-layout> </nz-layout>
</nz-layout> </nz-layout>
<nz-dropdown-menu #accountMenu="nzDropdownMenu">
<div class="account-dropdown">
<!-- Settings -->
<div class="dropdown-section">
<div class="settings-row">
<span class="settings-label">
<span nz-icon nzType="setting"></span>
Expert mode
</span>
<nz-switch
[ngModel]="expertMode()"
(ngModelChange)="settingsService.setExpertMode($event)"
nzSize="small"
></nz-switch>
</div>
</div>
<div class="dropdown-divider"></div>
<!-- Accounts -->
<div class="dropdown-section accounts-section">
@for (acc of accounts(); track acc.userId) {
<div
class="account-item"
[class.active]="acc.userId === userId"
(click)="switchAccount(acc.userId)"
>
<span nz-icon nzType="user" class="account-avatar"></span>
<div class="account-meta">
<span class="account-name">{{ acc.username || acc.userId }}</span>
<span class="account-sub mono">{{ acc.userId }}</span>
</div>
@if (acc.userId === userId) {
<span nz-icon nzType="check" class="account-check"></span>
}
<button
nz-button
nzType="text"
nzSize="small"
nzDanger
class="account-logout"
title="Logout"
(click)="logoutAccount(acc.userId, $event)"
>
<span nz-icon nzType="logout"></span>
</button>
</div>
}
</div>
<div class="dropdown-divider"></div>
<!-- Add account -->
<div class="dropdown-section">
<button nz-button nzType="text" class="add-account-btn" (click)="addAccount()">
<span nz-icon nzType="user-add"></span>
Login with another account
</button>
</div>
</div>
</nz-dropdown-menu>
@@ -51,17 +51,6 @@
align-items: center; align-items: center;
} }
.expert-mode-toggle {
display: flex;
align-items: center;
gap: 8px;
.expert-mode-label {
font-size: 13px;
color: #666;
}
}
.header-trigger { .header-trigger {
font-size: 18px; font-size: 18px;
cursor: pointer; cursor: pointer;
@@ -79,11 +68,43 @@
gap: 16px; 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 { .user-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
line-height: 1.3; line-height: 1.3;
min-width: 0;
.username,
.user-id,
.key-id {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.username { .username {
color: #333; 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 { .content-area {
margin-left: 240px; margin-left: 240px;
transition: margin-left 0.2s; transition: margin-left 0.2s;
@@ -122,3 +257,25 @@ nz-layout:has(.ant-layout-sider-collapsed) .content-area {
margin-left: 80px; 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;
}
}
}
@@ -39,6 +39,7 @@ export class MainLayoutComponent implements OnInit {
isCollapsed = signal(false); isCollapsed = signal(false);
userId = this.authService.getUserId(); userId = this.authService.getUserId();
accounts = this.authService.accounts;
currentKey = signal<KeyToken | null>(null); currentKey = signal<KeyToken | null>(null);
username = signal<string | null>(null); username = signal<string | null>(null);
expertMode = this.settingsService.expertMode; expertMode = this.settingsService.expertMode;
@@ -62,7 +63,11 @@ export class MainLayoutComponent implements OnInit {
if (!userId) return; if (!userId) return;
this.apiService.getUser(userId).subscribe({ 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); this.isCollapsed.update(v => !v);
} }
logout(): void { switchAccount(userId: string): void {
this.authService.logout(); 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']); this.router.navigate(['/login']);
} }
} }