fix: Bug - Add logout in mobile version of settings, allow clearing data on android
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 7m55s
Queue Release Build / build-windows (push) Successful in 28m37s
Queue Release Build / build-linux (push) Successful in 47m3s
Queue Release Build / build-android (push) Successful in 20m33s
Queue Release Build / finalize (push) Successful in 3m48s
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 7m55s
Queue Release Build / build-windows (push) Successful in 28m37s
Queue Release Build / build-linux (push) Successful in 47m3s
Queue Release Build / build-android (push) Successful in 20m33s
Queue Release Build / finalize (push) Successful in 3m48s
Expose settings logout on mobile where the title bar is hidden, and enable Capacitor data settings with storage visibility and local erase/sign-out. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 |
|
| 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 |
|
| 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
|
## Login / register response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ Network security configs:
|
|||||||
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
|
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
|
||||||
- `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring.
|
- `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.
|
- `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
|
## Phase 3 completion notes
|
||||||
|
|
||||||
|
|||||||
76
e2e/helpers/settings-modal.ts
Normal file
76
e2e/helpers/settings-modal.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await openSettingsDetailPage(page, 'data');
|
||||||
|
await expect(page.locator('app-data-settings')).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MOBILE_VIEWPORT };
|
||||||
25
e2e/tests/mobile/mobile-settings-logout.spec.ts
Normal file
25
e2e/tests/mobile/mobile-settings-logout.spec.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -262,12 +262,14 @@
|
|||||||
"localData": {
|
"localData": {
|
||||||
"title": "Local data",
|
"title": "Local data",
|
||||||
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
"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"
|
"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": {
|
"currentFolder": {
|
||||||
"title": "Current data folder",
|
"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",
|
"openFolder": "Open folder",
|
||||||
"opening": "Opening...",
|
"opening": "Opening...",
|
||||||
@@ -287,6 +289,7 @@
|
|||||||
"erase": {
|
"erase": {
|
||||||
"title": "Erase user data",
|
"title": "Erase user data",
|
||||||
"description": "Remove local app data from this device and recreate an empty database.",
|
"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",
|
"button": "Erase user data",
|
||||||
"erasing": "Erasing...",
|
"erasing": "Erasing...",
|
||||||
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
"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}}.",
|
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
||||||
"imported": "Imported data.",
|
"imported": "Imported data.",
|
||||||
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
"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."
|
"operationFailed": "Data operation failed."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1317,12 +1317,14 @@
|
|||||||
"localData": {
|
"localData": {
|
||||||
"title": "Local data",
|
"title": "Local data",
|
||||||
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
"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"
|
"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": {
|
"currentFolder": {
|
||||||
"title": "Current data folder",
|
"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",
|
"openFolder": "Open folder",
|
||||||
"opening": "Opening...",
|
"opening": "Opening...",
|
||||||
@@ -1342,6 +1344,7 @@
|
|||||||
"erase": {
|
"erase": {
|
||||||
"title": "Erase user data",
|
"title": "Erase user data",
|
||||||
"description": "Remove local app data from this device and recreate an empty database.",
|
"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",
|
"button": "Erase user data",
|
||||||
"erasing": "Erasing...",
|
"erasing": "Erasing...",
|
||||||
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
"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}}.",
|
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
||||||
"imported": "Imported data.",
|
"imported": "Imported data.",
|
||||||
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
"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."
|
"operationFailed": "Data operation failed."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,4 +43,13 @@ describe('AuthTokenStoreService', () => {
|
|||||||
|
|
||||||
expect(service.getToken('http://localhost:3001')).toBeNull();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export class AuthTokenStoreService {
|
|||||||
this.writeStore(nextStore);
|
this.writeStore(nextStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearAllTokens(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
hasAnyValidToken(): boolean {
|
hasAnyValidToken(): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
|||||||
@@ -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<typeof vi.fn> };
|
||||||
|
let store: { dispatch: ReturnType<typeof vi.fn> };
|
||||||
|
let router: { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './application/services/authentication.service';
|
export * from './application/services/authentication.service';
|
||||||
export * from './application/services/auth-token-store.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-auth.service';
|
||||||
export * from './application/services/signal-server-authorize.service';
|
export * from './application/services/signal-server-authorize.service';
|
||||||
export * from './application/services/signal-server-credential-store.service';
|
export * from './application/services/signal-server-credential-store.service';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@
|
|||||||
<h4 class="text-base font-semibold text-foreground">{{ 'settings.data.localData.title' | translate }}</h4>
|
<h4 class="text-base font-semibold text-foreground">{{ 'settings.data.localData.title' | translate }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-muted-foreground">
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
{{ 'settings.data.localData.description' | translate }}
|
@if (supportsMobileLocalDataErase) {
|
||||||
|
{{ 'settings.data.localData.descriptionMobile' | translate }}
|
||||||
|
} @else {
|
||||||
|
{{ 'settings.data.localData.description' | translate }}
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,17 +29,17 @@
|
|||||||
name="lucideRefreshCw"
|
name="lucideRefreshCw"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
Restart app
|
{{ 'settings.data.localData.restartApp' | translate }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (!isElectron) {
|
@if (!supportsLocalDataManagement) {
|
||||||
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
||||||
<p class="text-sm text-muted-foreground">{{ 'settings.data.desktopOnly' | translate }}</p>
|
<p class="text-sm text-muted-foreground">{{ 'settings.data.desktopOnly' | translate }}</p>
|
||||||
</section>
|
</section>
|
||||||
} @else {
|
} @else if (supportsDesktopDataFolderActions) {
|
||||||
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||||
<div>
|
<div>
|
||||||
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
|
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
|
||||||
@@ -108,6 +112,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="data-settings-erase-button"
|
||||||
(click)="eraseData()"
|
(click)="eraseData()"
|
||||||
[disabled]="busyAction() !== null"
|
[disabled]="busyAction() !== null"
|
||||||
class="inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-60"
|
class="inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
@@ -119,17 +124,48 @@
|
|||||||
{{ busyAction() === 'erase' ? ('settings.data.erase.erasing' | translate) : ('settings.data.erase.button' | translate) }}
|
{{ busyAction() === 'erase' ? ('settings.data.erase.erasing' | translate) : ('settings.data.erase.button' | translate) }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
} @else if (supportsMobileLocalDataErase) {
|
||||||
|
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||||
|
<div>
|
||||||
|
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
|
||||||
|
<p class="mt-2 break-all rounded-lg border border-border bg-secondary/20 px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{{ dataPath() || ('settings.data.currentFolder.resolving' | translate) }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">{{ 'settings.data.currentFolder.descriptionMobile' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@if (statusMessage()) {
|
<section class="space-y-4 rounded-lg border border-destructive/30 bg-destructive/10 p-5">
|
||||||
<section class="rounded-lg border border-primary/30 bg-primary/10 p-4">
|
<div>
|
||||||
<p class="break-words text-sm text-foreground">{{ statusMessage() }}</p>
|
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.erase.title' | translate }}</h5>
|
||||||
</section>
|
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.data.erase.descriptionMobile' | translate }}</p>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
@if (errorMessage()) {
|
<button
|
||||||
<section class="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
|
type="button"
|
||||||
<p class="break-words text-sm text-foreground">{{ errorMessage() }}</p>
|
data-testid="data-settings-erase-button"
|
||||||
</section>
|
(click)="eraseData()"
|
||||||
}
|
[disabled]="busyAction() !== null"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideTrash2"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{{ busyAction() === 'erase' ? ('settings.data.erase.erasing' | translate) : ('settings.data.erase.button' | translate) }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (statusMessage()) {
|
||||||
|
<section class="rounded-lg border border-primary/30 bg-primary/10 p-4">
|
||||||
|
<p class="break-words text-sm text-foreground">{{ statusMessage() }}</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<section class="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
|
||||||
|
<p class="break-words text-sm text-foreground">{{ errorMessage() }}</p>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,16 @@ import {
|
|||||||
lucideUpload
|
lucideUpload
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
|
import { PlatformService } from '../../../../core/platform';
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
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 { 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';
|
type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
||||||
|
|
||||||
@@ -42,9 +50,25 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
|||||||
})
|
})
|
||||||
export class DataSettingsComponent {
|
export class DataSettingsComponent {
|
||||||
private readonly electron = inject(ElectronBridgeService);
|
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);
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
readonly isElectron = this.electron.isAvailable;
|
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<string | null>(null);
|
readonly dataPath = signal<string | null>(null);
|
||||||
readonly busyAction = signal<DataAction | null>(null);
|
readonly busyAction = signal<DataAction | null>(null);
|
||||||
readonly statusMessage = signal<string | null>(null);
|
readonly statusMessage = signal<string | null>(null);
|
||||||
@@ -106,6 +130,14 @@ export class DataSettingsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.runAction('erase', async () => {
|
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();
|
const result = await this.electron.requireApi().eraseUserData();
|
||||||
|
|
||||||
this.restartRequired.set(result.restartRequired);
|
this.restartRequired.set(result.restartRequired);
|
||||||
@@ -121,14 +153,18 @@ export class DataSettingsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadDataPath(): Promise<void> {
|
private async loadDataPath(): Promise<void> {
|
||||||
if (!this.isElectron) {
|
if (this.supportsDesktopDataFolderActions) {
|
||||||
|
try {
|
||||||
|
this.dataPath.set(await this.electron.requireApi().getAppDataPath());
|
||||||
|
} catch {
|
||||||
|
this.dataPath.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (this.supportsMobileLocalDataErase) {
|
||||||
this.dataPath.set(await this.electron.requireApi().getAppDataPath());
|
this.dataPath.set(await this.capacitorAttachmentStore.getAppDataPath());
|
||||||
} catch {
|
|
||||||
this.dataPath.set(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,21 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-auto border-t border-border px-3 py-3">
|
<div class="mt-auto border-t border-border px-3 py-3 space-y-2">
|
||||||
|
@if (currentUser()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="settings-logout-button"
|
||||||
|
(click)="logout()"
|
||||||
|
class="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2.5 text-sm text-destructive transition-colors hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideLogOut"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{{ 'common.logout' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="openThirdPartyLicenses()"
|
(click)="openThirdPartyLicenses()"
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ import {
|
|||||||
lucideTerminal,
|
lucideTerminal,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideBan,
|
lucideBan,
|
||||||
lucideShield
|
lucideShield,
|
||||||
|
lucideLogOut
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||||
import { ViewportService } from '../../../core/platform';
|
import { ViewportService } from '../../../core/platform';
|
||||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
|
import { UserLogoutService } from '../../../domains/authentication/application/services/user-logout.service';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { Room, UserRole } from '../../../shared-kernel';
|
import { Room, UserRole } from '../../../shared-kernel';
|
||||||
@@ -97,7 +99,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
|
|||||||
lucideTerminal,
|
lucideTerminal,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideBan,
|
lucideBan,
|
||||||
lucideShield
|
lucideShield,
|
||||||
|
lucideLogOut
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
templateUrl: './settings-modal.component.html'
|
templateUrl: './settings-modal.component.html'
|
||||||
@@ -106,6 +109,7 @@ export class SettingsModalComponent {
|
|||||||
readonly modal = inject(SettingsModalService);
|
readonly modal = inject(SettingsModalService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private webrtc = inject(RealtimeSessionFacade);
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private userLogout = inject(UserLogoutService);
|
||||||
private theme = inject(ThemeService);
|
private theme = inject(ThemeService);
|
||||||
private themeLibrary = inject(ThemeLibraryService);
|
private themeLibrary = inject(ThemeLibraryService);
|
||||||
private viewport = inject(ViewportService);
|
private viewport = inject(ViewportService);
|
||||||
@@ -413,6 +417,11 @@ export class SettingsModalComponent {
|
|||||||
this.showThirdPartyLicenses.set(false);
|
this.showThirdPartyLicenses.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.closeForExternalNavigation();
|
||||||
|
this.userLogout.logout();
|
||||||
|
}
|
||||||
|
|
||||||
navigate(page: SettingsPage): void {
|
navigate(page: SettingsPage): void {
|
||||||
this.modal.navigate(page);
|
this.modal.navigate(page);
|
||||||
|
|
||||||
|
|||||||
@@ -31,15 +31,13 @@ import {
|
|||||||
selectIsSignalServerReconnecting,
|
selectIsSignalServerReconnecting,
|
||||||
selectSignalServerCompatibilityError
|
selectSignalServerCompatibilityError
|
||||||
} from '../../../store/rooms/rooms.selectors';
|
} from '../../../store/rooms/rooms.selectors';
|
||||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
|
||||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||||
import { PlatformService } from '../../../core/platform';
|
import { PlatformService } from '../../../core/platform';
|
||||||
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
|
import { UserLogoutService } from '../../../domains/authentication/application/services/user-logout.service';
|
||||||
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
|
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
|
||||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||||
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
|
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
|
||||||
@@ -94,6 +92,7 @@ export class TitleBarComponent {
|
|||||||
private pluginRegistry = inject(PluginRegistryService);
|
private pluginRegistry = inject(PluginRegistryService);
|
||||||
private pluginRequirements = inject(PluginRequirementStateService);
|
private pluginRequirements = inject(PluginRequirementStateService);
|
||||||
private pluginStore = inject(PluginStoreService);
|
private pluginStore = inject(PluginStoreService);
|
||||||
|
private userLogout = inject(UserLogoutService);
|
||||||
|
|
||||||
private getWindowControlsApi() {
|
private getWindowControlsApi() {
|
||||||
return this.electronBridge.getApi();
|
return this.electronBridge.getApi();
|
||||||
@@ -376,16 +375,7 @@ export class TitleBarComponent {
|
|||||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||||
logout() {
|
logout() {
|
||||||
this._showMenu.set(false);
|
this._showMenu.set(false);
|
||||||
// Disconnect from signaling server - this broadcasts "user_left" to all
|
this.userLogout.logout();
|
||||||
// servers the user was a member of, so other users see them go offline.
|
|
||||||
this.webrtc.disconnect();
|
|
||||||
|
|
||||||
clearStoredCurrentUserId();
|
|
||||||
this.store.dispatch(MessagesActions.clearMessages());
|
|
||||||
this.store.dispatch(RoomsActions.resetRoomsState());
|
|
||||||
this.store.dispatch(UsersActions.resetUsersState());
|
|
||||||
|
|
||||||
this.router.navigate(['/login']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { Injector, runInInjectionContext } from '@angular/core';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { PlatformService } from '../../core/platform';
|
||||||
|
import { AuthTokenStoreService } from '../../domains/authentication/application/services/auth-token-store.service';
|
||||||
|
import { UserLogoutService } from '../../domains/authentication/application/services/user-logout.service';
|
||||||
|
import { DatabaseService } from './database.service';
|
||||||
|
import { LocalUserDataService } from './local-user-data.service';
|
||||||
|
|
||||||
|
const filesystemMocks = vi.hoisted(() => ({
|
||||||
|
rmdir: vi.fn(() => Promise.resolve())
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../domains/attachment/infrastructure/services/capacitor-attachment-filesystem.adapter', () => ({
|
||||||
|
loadCapacitorAttachmentFilesystem: vi.fn(async () => ({
|
||||||
|
filesystem: {
|
||||||
|
rmdir: filesystemMocks.rmdir
|
||||||
|
},
|
||||||
|
directory: 'DATA',
|
||||||
|
convertFileSrc: (url: string) => url
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('LocalUserDataService', () => {
|
||||||
|
let database: { clearAllData: ReturnType<typeof vi.fn> };
|
||||||
|
let authTokenStore: { clearAllTokens: ReturnType<typeof vi.fn> };
|
||||||
|
let userLogout: { logout: ReturnType<typeof vi.fn> };
|
||||||
|
let service: LocalUserDataService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||||
|
removeItem: (key: string) => { storage.delete(key); },
|
||||||
|
clear: () => { storage.clear(); },
|
||||||
|
key: (index: number) => Array.from(storage.keys())[index] ?? null,
|
||||||
|
get length() {
|
||||||
|
return storage.size;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem('metoyou_currentUserId', 'user-1');
|
||||||
|
localStorage.setItem('metoyou_voice_settings', '{}');
|
||||||
|
localStorage.setItem('metoyou.authTokens', '{"http://localhost:3001":{"token":"abc","expiresAt":9999999999999}}');
|
||||||
|
|
||||||
|
database = { clearAllData: vi.fn(() => Promise.resolve()) };
|
||||||
|
authTokenStore = { clearAllTokens: vi.fn() };
|
||||||
|
userLogout = { logout: vi.fn() };
|
||||||
|
filesystemMocks.rmdir.mockClear();
|
||||||
|
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
LocalUserDataService,
|
||||||
|
{ provide: PlatformService, useValue: { isCapacitor: true, isElectron: false, isBrowser: false } },
|
||||||
|
{ provide: DatabaseService, useValue: database },
|
||||||
|
{ provide: AuthTokenStoreService, useValue: authTokenStore },
|
||||||
|
{ provide: UserLogoutService, useValue: userLogout }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
service = runInInjectionContext(injector, () => injector.get(LocalUserDataService));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears native storage, auth tokens, and logs the user out', async () => {
|
||||||
|
const result = await service.eraseLocalUserData();
|
||||||
|
|
||||||
|
expect(database.clearAllData).toHaveBeenCalledOnce();
|
||||||
|
expect(filesystemMocks.rmdir).toHaveBeenCalledWith({
|
||||||
|
path: 'metoyou',
|
||||||
|
directory: 'DATA',
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authTokenStore.clearAllTokens).toHaveBeenCalledOnce();
|
||||||
|
expect(localStorage.getItem('metoyou_currentUserId')).toBeNull();
|
||||||
|
expect(localStorage.getItem('metoyou_voice_settings')).toBeNull();
|
||||||
|
expect(userLogout.logout).toHaveBeenCalledOnce();
|
||||||
|
expect(result).toEqual({ restartRequired: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects erase on non-native shells', async () => {
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
LocalUserDataService,
|
||||||
|
{ provide: PlatformService, useValue: { isCapacitor: false, isElectron: false, isBrowser: true } },
|
||||||
|
{ provide: DatabaseService, useValue: database },
|
||||||
|
{ provide: AuthTokenStoreService, useValue: authTokenStore },
|
||||||
|
{ provide: UserLogoutService, useValue: userLogout }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const browserService = runInInjectionContext(injector, () => injector.get(LocalUserDataService));
|
||||||
|
|
||||||
|
await expect(browserService.eraseLocalUserData()).rejects.toThrow(/native mobile/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { PlatformService } from '../../core/platform';
|
||||||
|
import { clearStoredLocalAppData } from '../../core/storage/current-user-storage';
|
||||||
|
import { DatabaseService } from './database.service';
|
||||||
|
import { UserLogoutService } from '../../domains/authentication/application/services/user-logout.service';
|
||||||
|
import { AuthTokenStoreService } from '../../domains/authentication/application/services/auth-token-store.service';
|
||||||
|
import { loadCapacitorAttachmentFilesystem } from '../../domains/attachment/infrastructure/services/capacitor-attachment-filesystem.adapter';
|
||||||
|
|
||||||
|
const CAPACITOR_APP_DATA_ROOT = 'metoyou';
|
||||||
|
|
||||||
|
export interface EraseLocalUserDataResult {
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LocalUserDataService {
|
||||||
|
private readonly platform = inject(PlatformService);
|
||||||
|
private readonly database = inject(DatabaseService);
|
||||||
|
private readonly userLogout = inject(UserLogoutService);
|
||||||
|
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||||
|
|
||||||
|
async eraseLocalUserData(): Promise<EraseLocalUserDataResult> {
|
||||||
|
if (!this.platform.isCapacitor) {
|
||||||
|
throw new Error('Local user data erase is only supported on native mobile shells.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.database.clearAllData();
|
||||||
|
await this.deleteCapacitorAttachmentTree();
|
||||||
|
this.authTokenStore.clearAllTokens();
|
||||||
|
clearStoredLocalAppData();
|
||||||
|
this.userLogout.logout();
|
||||||
|
|
||||||
|
return { restartRequired: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteCapacitorAttachmentTree(): Promise<void> {
|
||||||
|
const filesystem = await loadCapacitorAttachmentFilesystem();
|
||||||
|
|
||||||
|
if (!filesystem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await filesystem.filesystem.rmdir({
|
||||||
|
path: CAPACITOR_APP_DATA_ROOT,
|
||||||
|
directory: filesystem.directory,
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Missing directory is fine during erase.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user