diff --git a/agents-docs/features/authentication.md b/agents-docs/features/authentication.md index 838a539..b4b24e4 100644 --- a/agents-docs/features/authentication.md +++ b/agents-docs/features/authentication.md @@ -12,6 +12,12 @@ Session-token authentication for the signaling server and product client. | Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only | | Product client local DB | OS user account | SQLite and attachments are plaintext at rest | +## Client logout + +- Desktop: title-bar menu **Logout** (`UserLogoutService`). +- Mobile / all platforms: settings modal footer **Logout** (`data-testid="settings-logout-button"`) — required because the title bar is hidden on mobile breakpoints. +- Logout disconnects realtime sessions, clears the persisted current-user id, resets NgRx room/user/message state, and navigates to `/login`. + ## Login / register response ```json diff --git a/agents-docs/features/mobile-capacitor.md b/agents-docs/features/mobile-capacitor.md index 9058d15..3348c94 100644 --- a/agents-docs/features/mobile-capacitor.md +++ b/agents-docs/features/mobile-capacitor.md @@ -256,6 +256,7 @@ Network security configs: - `MobileCallSessionService` — CallKit + foreground service + in-call notifications. - `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring. - `MobileAppUpdateService` — periodic Play Store / App Store checks (30 min) and settings UI actions; mirrors Electron `DesktopAppUpdateService` polling but uses native store APIs instead of release manifests. +- Settings → **Data** on Capacitor shells shows the private app-data root and **Erase user data** (`LocalUserDataService` clears SQLite, Capacitor attachment files, auth tokens, and `metoyou_*` localStorage keys, then logs out). ## Phase 3 completion notes diff --git a/e2e/helpers/settings-modal.ts b/e2e/helpers/settings-modal.ts new file mode 100644 index 0000000..521dc91 --- /dev/null +++ b/e2e/helpers/settings-modal.ts @@ -0,0 +1,76 @@ +import { expect, type Page } from '@playwright/test'; + +const MOBILE_VIEWPORT = { width: 390, height: 844 }; + +export async function openSettingsModal(page: Page, settingsPage = 'general'): Promise { + await page.evaluate((targetPage) => { + interface SettingsModalServiceHandle { + open: (page: string) => void; + } + interface SettingsModalComponentHandle { + mobilePage?: { set: (page: 'menu' | 'detail') => void }; + animating?: { set: (value: boolean) => void }; + navigate?: (page: string) => void; + } + interface AppComponentHandle { + settingsModal?: SettingsModalServiceHandle; + } + interface AngularDebugApi { + getComponent: (element: Element) => AppComponentHandle & SettingsModalComponentHandle; + applyChanges?: (component: unknown) => void; + } + + const debugApi = (window as Window & { ng?: AngularDebugApi }).ng; + const appRoot = document.querySelector('app-root'); + const settingsHost = document.querySelector('app-settings-modal'); + const appComponent = appRoot && debugApi?.getComponent(appRoot); + const settingsComponent = settingsHost && debugApi?.getComponent(settingsHost); + + if (!appComponent?.settingsModal?.open) { + throw new Error('Angular debug API could not open settings modal'); + } + + appComponent.settingsModal.open(targetPage); + settingsComponent?.mobilePage?.set('menu'); + settingsComponent?.animating?.set(true); + debugApi?.applyChanges?.(appComponent); + debugApi?.applyChanges?.(settingsComponent); + }, settingsPage); + + await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('settings-logout-button')).toBeVisible({ timeout: 10_000 }); +} + +export async function openSettingsDetailPage(page: Page, settingsPage: string): Promise { + await openSettingsModal(page, settingsPage); + + await page.evaluate((targetPage) => { + interface SettingsModalComponentHandle { + navigate?: (page: string) => void; + animating?: { set: (value: boolean) => void }; + } + interface AngularDebugApi { + getComponent: (element: Element) => SettingsModalComponentHandle; + applyChanges?: (component: SettingsModalComponentHandle) => void; + } + + const host = document.querySelector('app-settings-modal'); + const debugApi = (window as Window & { ng?: AngularDebugApi }).ng; + const component = host && debugApi?.getComponent(host); + + if (!component?.navigate) { + throw new Error('Angular debug API could not navigate settings modal'); + } + + component.navigate(targetPage); + component.animating?.set(true); + debugApi?.applyChanges?.(component); + }, settingsPage); +} + +export async function openSettingsDataPage(page: Page): Promise { + await openSettingsDetailPage(page, 'data'); + await expect(page.locator('app-data-settings')).toBeVisible({ timeout: 10_000 }); +} + +export { MOBILE_VIEWPORT }; diff --git a/e2e/tests/mobile/mobile-settings-logout.spec.ts b/e2e/tests/mobile/mobile-settings-logout.spec.ts new file mode 100644 index 0000000..3a39c0f --- /dev/null +++ b/e2e/tests/mobile/mobile-settings-logout.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '../../fixtures/multi-client'; +import { expectDashboardReady } from '../../helpers/dashboard'; +import { MOBILE_VIEWPORT, openSettingsModal } from '../../helpers/settings-modal'; +import { RegisterPage } from '../../pages/register.page'; + +test.describe('Mobile settings logout', () => { + test('exposes logout in the settings menu on mobile viewports', async ({ createClient }) => { + const { page } = await createClient(); + const suffix = `mobile_logout_${Date.now()}`; + + await page.setViewportSize(MOBILE_VIEWPORT); + + const register = new RegisterPage(page); + + await register.goto(); + await register.register(`user_${suffix}`, 'Mobile Logout User', 'TestPass123!'); + await expectDashboardReady(page); + + await openSettingsModal(page); + await page.getByTestId('settings-logout-button').click(); + + await expect(page).toHaveURL(/\/login/, { timeout: 15_000 }); + await expect(page.locator('#login-username')).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/toju-app/public/i18n/catalog/settings.json b/toju-app/public/i18n/catalog/settings.json index 3f36167..a42e812 100644 --- a/toju-app/public/i18n/catalog/settings.json +++ b/toju-app/public/i18n/catalog/settings.json @@ -262,12 +262,14 @@ "localData": { "title": "Local data", "description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.", + "descriptionMobile": "Review and erase the private app storage that holds local messages, rooms, attachments, and saved settings on this device.", "restartApp": "Restart app" }, - "desktopOnly": "Data management is only available in the packaged Electron desktop app.", + "desktopOnly": "Data management is only available in the desktop app or native mobile app.", "currentFolder": { "title": "Current data folder", - "resolving": "Resolving data folder..." + "resolving": "Resolving data folder...", + "descriptionMobile": "Files are stored in the app's private data directory on this device." }, "openFolder": "Open folder", "opening": "Opening...", @@ -287,6 +289,7 @@ "erase": { "title": "Erase user data", "description": "Remove local app data from this device and recreate an empty database.", + "descriptionMobile": "Remove local messages, rooms, attachments, and saved app data from this device.", "button": "Erase user data", "erasing": "Erasing...", "confirm": "Erase all local Toju data on this device? This cannot be undone." @@ -301,6 +304,7 @@ "importedWithBackup": "Imported data. Previous data was backed up to {{path}}.", "imported": "Imported data.", "erased": "Local data erased. Restart the app to finish resetting the session.", + "erasedMobile": "Local data erased. You have been signed out.", "operationFailed": "Data operation failed." } }, diff --git a/toju-app/public/i18n/en.json b/toju-app/public/i18n/en.json index 6853e03..b5235bd 100644 --- a/toju-app/public/i18n/en.json +++ b/toju-app/public/i18n/en.json @@ -1317,12 +1317,14 @@ "localData": { "title": "Local data", "description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.", + "descriptionMobile": "Review and erase the private app storage that holds local messages, rooms, attachments, and saved settings on this device.", "restartApp": "Restart app" }, - "desktopOnly": "Data management is only available in the packaged Electron desktop app.", + "desktopOnly": "Data management is only available in the desktop app or native mobile app.", "currentFolder": { "title": "Current data folder", - "resolving": "Resolving data folder..." + "resolving": "Resolving data folder...", + "descriptionMobile": "Files are stored in the app's private data directory on this device." }, "openFolder": "Open folder", "opening": "Opening...", @@ -1342,6 +1344,7 @@ "erase": { "title": "Erase user data", "description": "Remove local app data from this device and recreate an empty database.", + "descriptionMobile": "Remove local messages, rooms, attachments, and saved app data from this device.", "button": "Erase user data", "erasing": "Erasing...", "confirm": "Erase all local Toju data on this device? This cannot be undone." @@ -1356,6 +1359,7 @@ "importedWithBackup": "Imported data. Previous data was backed up to {{path}}.", "imported": "Imported data.", "erased": "Local data erased. Restart the app to finish resetting the session.", + "erasedMobile": "Local data erased. You have been signed out.", "operationFailed": "Data operation failed." } }, diff --git a/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.spec.ts b/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.spec.ts index 083de2a..e9cc6d9 100644 --- a/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.spec.ts +++ b/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.spec.ts @@ -43,4 +43,13 @@ describe('AuthTokenStoreService', () => { expect(service.getToken('http://localhost:3001')).toBeNull(); }); + + it('clears every stored token', () => { + service.setToken('http://localhost:3001', 'token-abc', Date.now() + 60_000); + service.setToken('http://localhost:3002', 'token-def', Date.now() + 60_000); + + service.clearAllTokens(); + + expect(service.hasAnyValidToken()).toBe(false); + }); }); diff --git a/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.ts b/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.ts index c8ead5d..a41cc73 100644 --- a/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.ts @@ -49,6 +49,12 @@ export class AuthTokenStoreService { this.writeStore(nextStore); } + clearAllTokens(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch {} + } + hasAnyValidToken(): boolean { const now = Date.now(); diff --git a/toju-app/src/app/domains/authentication/application/services/user-logout.service.spec.ts b/toju-app/src/app/domains/authentication/application/services/user-logout.service.spec.ts new file mode 100644 index 0000000..d8d6376 --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/user-logout.service.spec.ts @@ -0,0 +1,66 @@ +import '@angular/compiler'; +import { Injector, runInInjectionContext } from '@angular/core'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; + +import { RealtimeSessionFacade } from '../../../../core/realtime'; +import { MessagesActions } from '../../../../store/messages/messages.actions'; +import { RoomsActions } from '../../../../store/rooms/rooms.actions'; +import { UsersActions } from '../../../../store/users/users.actions'; +import { UserLogoutService } from './user-logout.service'; + +describe('UserLogoutService', () => { + let webrtc: { disconnect: ReturnType }; + let store: { dispatch: ReturnType }; + let router: { navigate: ReturnType }; + let service: UserLogoutService; + + beforeEach(() => { + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + key: vi.fn(() => null), + length: 0 + }); + + webrtc = { disconnect: vi.fn() }; + store = { dispatch: vi.fn() }; + router = { navigate: vi.fn(() => Promise.resolve(true)) }; + + const injector = Injector.create({ + providers: [ + UserLogoutService, + { provide: RealtimeSessionFacade, useValue: webrtc }, + { provide: Store, useValue: store }, + { provide: Router, useValue: router } + ] + }); + + service = runInInjectionContext(injector, () => injector.get(UserLogoutService)); + }); + + it('disconnects, clears persisted user scope, resets store slices, and navigates to login', () => { + service.logout(); + + expect(webrtc.disconnect).toHaveBeenCalledOnce(); + expect(store.dispatch).toHaveBeenCalledWith(MessagesActions.clearMessages()); + expect(store.dispatch).toHaveBeenCalledWith(RoomsActions.resetRoomsState()); + expect(store.dispatch).toHaveBeenCalledWith(UsersActions.resetUsersState()); + expect(router.navigate).toHaveBeenCalledWith(['/login']); + }); + + it('can reset client state without navigating away', () => { + service.logout({ navigate: false }); + + expect(router.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/toju-app/src/app/domains/authentication/application/services/user-logout.service.ts b/toju-app/src/app/domains/authentication/application/services/user-logout.service.ts new file mode 100644 index 0000000..9731a2e --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/user-logout.service.ts @@ -0,0 +1,28 @@ +import { Injectable, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; + +import { RealtimeSessionFacade } from '../../../../core/realtime'; +import { clearStoredCurrentUserId } from '../../../../core/storage/current-user-storage'; +import { MessagesActions } from '../../../../store/messages/messages.actions'; +import { RoomsActions } from '../../../../store/rooms/rooms.actions'; +import { UsersActions } from '../../../../store/users/users.actions'; + +@Injectable({ providedIn: 'root' }) +export class UserLogoutService { + private readonly webrtc = inject(RealtimeSessionFacade); + private readonly store = inject(Store); + private readonly router = inject(Router); + + logout(options?: { navigate?: boolean }): void { + this.webrtc.disconnect(); + clearStoredCurrentUserId(); + this.store.dispatch(MessagesActions.clearMessages()); + this.store.dispatch(RoomsActions.resetRoomsState()); + this.store.dispatch(UsersActions.resetUsersState()); + + if (options?.navigate !== false) { + void this.router.navigate(['/login']); + } + } +} diff --git a/toju-app/src/app/domains/authentication/index.ts b/toju-app/src/app/domains/authentication/index.ts index cc262d1..9214a75 100644 --- a/toju-app/src/app/domains/authentication/index.ts +++ b/toju-app/src/app/domains/authentication/index.ts @@ -1,5 +1,6 @@ export * from './application/services/authentication.service'; export * from './application/services/auth-token-store.service'; +export * from './application/services/user-logout.service'; export * from './application/services/signal-server-auth.service'; export * from './application/services/signal-server-authorize.service'; export * from './application/services/signal-server-credential-store.service'; diff --git a/toju-app/src/app/features/settings/domain/logic/data-settings-capability.rules.spec.ts b/toju-app/src/app/features/settings/domain/logic/data-settings-capability.rules.spec.ts new file mode 100644 index 0000000..384489f --- /dev/null +++ b/toju-app/src/app/features/settings/domain/logic/data-settings-capability.rules.spec.ts @@ -0,0 +1,29 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { + supportsDesktopDataFolderActions, + supportsLocalDataManagement, + supportsMobileLocalDataErase +} from './data-settings-capability.rules'; + +describe('data settings capability rules', () => { + it('enables local data management on Electron and Capacitor only', () => { + expect(supportsLocalDataManagement({ isElectron: true, isCapacitor: false })).toBe(true); + expect(supportsLocalDataManagement({ isElectron: false, isCapacitor: true })).toBe(true); + expect(supportsLocalDataManagement({ isElectron: false, isCapacitor: false })).toBe(false); + }); + + it('limits folder, export, and import actions to Electron', () => { + expect(supportsDesktopDataFolderActions({ isElectron: true, isCapacitor: false })).toBe(true); + expect(supportsDesktopDataFolderActions({ isElectron: false, isCapacitor: true })).toBe(false); + }); + + it('allows erase on Capacitor shells', () => { + expect(supportsMobileLocalDataErase({ isElectron: false, isCapacitor: true })).toBe(true); + expect(supportsMobileLocalDataErase({ isElectron: true, isCapacitor: false })).toBe(false); + }); +}); diff --git a/toju-app/src/app/features/settings/domain/logic/data-settings-capability.rules.ts b/toju-app/src/app/features/settings/domain/logic/data-settings-capability.rules.ts new file mode 100644 index 0000000..71cdf93 --- /dev/null +++ b/toju-app/src/app/features/settings/domain/logic/data-settings-capability.rules.ts @@ -0,0 +1,16 @@ +export interface DataSettingsPlatform { + isElectron: boolean; + isCapacitor: boolean; +} + +export function supportsLocalDataManagement(platform: DataSettingsPlatform): boolean { + return platform.isElectron || platform.isCapacitor; +} + +export function supportsDesktopDataFolderActions(platform: DataSettingsPlatform): boolean { + return platform.isElectron; +} + +export function supportsMobileLocalDataErase(platform: DataSettingsPlatform): boolean { + return platform.isCapacitor; +} diff --git a/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.html b/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.html index 52b0ded..1d63dd1 100644 --- a/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.html +++ b/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.html @@ -10,7 +10,11 @@

{{ 'settings.data.localData.title' | translate }}

- {{ 'settings.data.localData.description' | translate }} + @if (supportsMobileLocalDataErase) { + {{ 'settings.data.localData.descriptionMobile' | translate }} + } @else { + {{ 'settings.data.localData.description' | translate }} + }

@@ -25,17 +29,17 @@ name="lucideRefreshCw" class="h-4 w-4" /> - Restart app + {{ 'settings.data.localData.restartApp' | translate }} } - @if (!isElectron) { + @if (!supportsLocalDataManagement) {

{{ 'settings.data.desktopOnly' | translate }}

- } @else { + } @else if (supportsDesktopDataFolderActions) {
{{ 'settings.data.currentFolder.title' | translate }}
@@ -108,6 +112,7 @@
+ } @else if (supportsMobileLocalDataErase) { +
+
+
{{ 'settings.data.currentFolder.title' | translate }}
+

+ {{ dataPath() || ('settings.data.currentFolder.resolving' | translate) }} +

+

{{ 'settings.data.currentFolder.descriptionMobile' | translate }}

+
+
- @if (statusMessage()) { -
-

{{ statusMessage() }}

-
- } +
+
+
{{ 'settings.data.erase.title' | translate }}
+

{{ 'settings.data.erase.descriptionMobile' | translate }}

+
- @if (errorMessage()) { -
-

{{ errorMessage() }}

-
- } + +
+ } + + @if (statusMessage()) { +
+

{{ statusMessage() }}

+
+ } + + @if (errorMessage()) { +
+

{{ errorMessage() }}

+
} diff --git a/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.ts b/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.ts index 910dc50..a34e34c 100644 --- a/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/data-settings/data-settings.component.ts @@ -15,8 +15,16 @@ import { lucideUpload } from '@ng-icons/lucide'; +import { PlatformService } from '../../../../core/platform'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; +import { CapacitorAttachmentFileStore } from '../../../../domains/attachment/infrastructure/services/capacitor-attachment-file-store'; +import { LocalUserDataService } from '../../../../infrastructure/persistence/local-user-data.service'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; +import { + supportsDesktopDataFolderActions, + supportsLocalDataManagement, + supportsMobileLocalDataErase +} from '../../domain/logic/data-settings-capability.rules'; type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart'; @@ -42,9 +50,25 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart'; }) export class DataSettingsComponent { private readonly electron = inject(ElectronBridgeService); + private readonly platform = inject(PlatformService); + private readonly localUserData = inject(LocalUserDataService); + private readonly capacitorAttachmentStore = inject(CapacitorAttachmentFileStore); private readonly appI18n = inject(AppI18nService); readonly isElectron = this.electron.isAvailable; + readonly isCapacitor = this.platform.isCapacitor; + readonly supportsLocalDataManagement = supportsLocalDataManagement({ + isElectron: this.isElectron, + isCapacitor: this.isCapacitor + }); + readonly supportsDesktopDataFolderActions = supportsDesktopDataFolderActions({ + isElectron: this.isElectron, + isCapacitor: this.isCapacitor + }); + readonly supportsMobileLocalDataErase = supportsMobileLocalDataErase({ + isElectron: this.isElectron, + isCapacitor: this.isCapacitor + }); readonly dataPath = signal(null); readonly busyAction = signal(null); readonly statusMessage = signal(null); @@ -106,6 +130,14 @@ export class DataSettingsComponent { } await this.runAction('erase', async () => { + if (this.supportsMobileLocalDataErase) { + const result = await this.localUserData.eraseLocalUserData(); + + this.restartRequired.set(result.restartRequired); + this.statusMessage.set(this.appI18n.instant('settings.data.messages.erasedMobile')); + return; + } + const result = await this.electron.requireApi().eraseUserData(); this.restartRequired.set(result.restartRequired); @@ -121,14 +153,18 @@ export class DataSettingsComponent { } private async loadDataPath(): Promise { - if (!this.isElectron) { + if (this.supportsDesktopDataFolderActions) { + try { + this.dataPath.set(await this.electron.requireApi().getAppDataPath()); + } catch { + this.dataPath.set(null); + } + return; } - try { - this.dataPath.set(await this.electron.requireApi().getAppDataPath()); - } catch { - this.dataPath.set(null); + if (this.supportsMobileLocalDataErase) { + this.dataPath.set(await this.capacitorAttachmentStore.getAppDataPath()); } } diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html index d3e0fe1..ebafba3 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html @@ -129,7 +129,21 @@ } -
+
+ @if (currentUser()) { + + }