mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 22:51:59 +00:00
feat(web): logout of all tabs (#12407)
This commit is contained in:
parent
0dabb890cf
commit
2554cc96b0
9 changed files with 73 additions and 25 deletions
|
@ -21,6 +21,9 @@ type EmitEventMap = {
|
|||
'asset.tag': [{ assetId: string }];
|
||||
'asset.untag': [{ assetId: string }];
|
||||
|
||||
// session events
|
||||
'session.delete': [{ sessionId: string }];
|
||||
|
||||
// user events
|
||||
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
|
||||
};
|
||||
|
@ -43,6 +46,7 @@ export enum ClientEvent {
|
|||
SERVER_VERSION = 'on_server_version',
|
||||
CONFIG_UPDATE = 'on_config_update',
|
||||
NEW_RELEASE = 'on_new_release',
|
||||
SESSION_DELETE = 'on_session_delete',
|
||||
}
|
||||
|
||||
export interface ClientEventMap {
|
||||
|
@ -58,6 +62,7 @@ export interface ClientEventMap {
|
|||
[ClientEvent.SERVER_VERSION]: ServerVersionResponseDto;
|
||||
[ClientEvent.CONFIG_UPDATE]: Record<string, never>;
|
||||
[ClientEvent.NEW_RELEASE]: ReleaseNotification;
|
||||
[ClientEvent.SESSION_DELETE]: string;
|
||||
}
|
||||
|
||||
export enum ServerEvent {
|
||||
|
@ -77,7 +82,7 @@ export interface IEventRepository {
|
|||
/**
|
||||
* Send to connected clients for a specific user
|
||||
*/
|
||||
clientSend<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]): void;
|
||||
clientSend<E extends keyof ClientEventMap>(event: E, room: string, data: ClientEventMap[E]): void;
|
||||
/**
|
||||
* Send to all connected clients
|
||||
*/
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
|
@ -37,7 +38,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
|||
private server?: Server;
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private moduleRef: ModuleRef,
|
||||
private eventEmitter: EventEmitter2,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
|
@ -62,12 +63,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
|||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
this.logger.log(`Websocket Connect: ${client.id}`);
|
||||
const auth = await this.authService.authenticate({
|
||||
const auth = await this.moduleRef.get(AuthService).authenticate({
|
||||
headers: client.request.headers,
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' },
|
||||
});
|
||||
await client.join(auth.user.id);
|
||||
if (auth.session) {
|
||||
await client.join(auth.session.id);
|
||||
}
|
||||
this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id });
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
|
||||
|
@ -96,8 +100,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
|||
}
|
||||
}
|
||||
|
||||
clientSend<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]) {
|
||||
this.server?.to(userId).emit(event, data);
|
||||
clientSend<E extends keyof ClientEventMap>(event: E, room: string, data: ClientEventMap[E]) {
|
||||
this.server?.to(room).emit(event, data);
|
||||
}
|
||||
|
||||
clientBroadcast<E extends keyof ClientEventMap>(event: E, data: ClientEventMap[E]) {
|
||||
|
|
|
@ -6,6 +6,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
|
|||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
|
@ -20,6 +21,7 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
|||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
|
||||
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
|
||||
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
|
||||
|
@ -56,6 +58,7 @@ const oauthUserWithDefaultQuota = {
|
|||
describe('AuthService', () => {
|
||||
let sut: AuthService;
|
||||
let cryptoMock: Mocked<ICryptoRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
|
@ -87,6 +90,7 @@ describe('AuthService', () => {
|
|||
} as any);
|
||||
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
systemMock = newSystemMetadataRepositoryMock();
|
||||
|
@ -94,7 +98,7 @@ describe('AuthService', () => {
|
|||
shareMock = newSharedLinkRepositoryMock();
|
||||
keyMock = newKeyRepositoryMock();
|
||||
|
||||
sut = new AuthService(cryptoMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock);
|
||||
sut = new AuthService(cryptoMock, eventMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
|
@ -208,6 +212,7 @@ describe('AuthService', () => {
|
|||
});
|
||||
|
||||
expect(sessionMock.delete).toHaveBeenCalledWith('token123');
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' });
|
||||
});
|
||||
|
||||
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
|
||||
|
|
|
@ -34,6 +34,7 @@ import { UserEntity } from 'src/entities/user.entity';
|
|||
import { Permission } from 'src/enum';
|
||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
|
@ -75,6 +76,7 @@ export class AuthService {
|
|||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
|
@ -114,6 +116,7 @@ export class AuthService {
|
|||
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||
if (auth.session) {
|
||||
await this.sessionRepository.delete(auth.session.id);
|
||||
await this.eventRepository.emit('session.delete', { sessionId: auth.session.id });
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -6,6 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
|||
import { AssetFileType, UserMetadataKey } from 'src/enum';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
|
||||
|
@ -17,6 +18,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
|
|||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock';
|
||||
|
@ -64,6 +66,7 @@ const configs = {
|
|||
describe(NotificationService.name, () => {
|
||||
let albumMock: Mocked<IAlbumRepository>;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let notificationMock: Mocked<INotificationRepository>;
|
||||
|
@ -74,13 +77,23 @@ describe(NotificationService.name, () => {
|
|||
beforeEach(() => {
|
||||
albumMock = newAlbumRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
notificationMock = newNotificationRepositoryMock();
|
||||
systemMock = newSystemMetadataRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
|
||||
sut = new NotificationService(systemMock, notificationMock, userMock, jobMock, loggerMock, assetMock, albumMock);
|
||||
sut = new NotificationService(
|
||||
eventMock,
|
||||
systemMock,
|
||||
notificationMock,
|
||||
userMock,
|
||||
jobMock,
|
||||
loggerMock,
|
||||
assetMock,
|
||||
albumMock,
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
|
|
|
@ -6,7 +6,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
|||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IEmailJob,
|
||||
IJobRepository,
|
||||
|
@ -30,6 +30,7 @@ export class NotificationService {
|
|||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
|
@ -74,6 +75,12 @@ export class NotificationService {
|
|||
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } });
|
||||
}
|
||||
|
||||
@OnEmit({ event: 'session.delete' })
|
||||
onSessionDelete({ sessionId }: ArgOf<'session.delete'>) {
|
||||
// after the response is sent
|
||||
setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500);
|
||||
}
|
||||
|
||||
async sendTestEmail(id: string, dto: SystemConfigSmtpDto) {
|
||||
const user = await this.userRepository.get(id, { withDeleted: false });
|
||||
if (!user) {
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
||||
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { resetSavedUser, user } from '$lib/stores/user.store';
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { handleLogout } from '$lib/utils/auth';
|
||||
import { logout } from '@immich/sdk';
|
||||
import { mdiCog, mdiMagnify, mdiTrayArrowUp } from '@mdi/js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { AppRoute } from '../../../constants';
|
||||
import ImmichLogo from '../immich-logo.svelte';
|
||||
|
@ -17,9 +19,6 @@
|
|||
import ThemeButton from '../theme-button.svelte';
|
||||
import UserAvatar from '../user-avatar.svelte';
|
||||
import AccountInfoPanel from './account-info-panel.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { foldersStore } from '$lib/stores/folders.store';
|
||||
|
||||
export let showUploadButton = true;
|
||||
|
||||
|
@ -30,16 +29,9 @@
|
|||
uploadClicked: void;
|
||||
}>();
|
||||
|
||||
const logOut = async () => {
|
||||
const onLogout = async () => {
|
||||
const { redirectUri } = await logout();
|
||||
|
||||
if (redirectUri.startsWith('/')) {
|
||||
await goto(redirectUri);
|
||||
} else {
|
||||
window.location.href = redirectUri;
|
||||
}
|
||||
resetSavedUser();
|
||||
foldersStore.clearCache();
|
||||
await handleLogout(redirectUri);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -153,7 +145,7 @@
|
|||
{/if}
|
||||
|
||||
{#if shouldShowAccountInfoPanel}
|
||||
<AccountInfoPanel on:logout={logOut} />
|
||||
<AccountInfoPanel on:logout={onLogout} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { AppRoute } from '$lib/constants';
|
||||
import { handleLogout } from '$lib/utils/auth';
|
||||
import { createEventEmitter } from '$lib/utils/eventemitter';
|
||||
import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
@ -24,6 +26,7 @@ export interface Events {
|
|||
on_server_version: (serverVersion: ServerVersionResponseDto) => void;
|
||||
on_config_update: () => void;
|
||||
on_new_release: (newRelase: ReleaseEvent) => void;
|
||||
on_session_delete: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
const websocket: Socket<Events> = io({
|
||||
|
@ -47,6 +50,7 @@ websocket
|
|||
.on('disconnect', () => websocketStore.connected.set(false))
|
||||
.on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
|
||||
.on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion))
|
||||
.on('on_session_delete', () => handleLogout(AppRoute.AUTH_LOGIN))
|
||||
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
|
||||
|
||||
export const openWebsocketConnection = () => {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { foldersStore } from '$lib/stores/folders.store';
|
||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||
import { serverInfo } from '$lib/stores/server-info.store';
|
||||
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
|
||||
import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store';
|
||||
import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { DateTime } from 'luxon';
|
||||
|
@ -87,3 +89,16 @@ export const getAccountAge = (): number => {
|
|||
|
||||
return Number(accountAge);
|
||||
};
|
||||
|
||||
export const handleLogout = async (redirectUri: string) => {
|
||||
try {
|
||||
if (redirectUri.startsWith('/')) {
|
||||
await goto(redirectUri);
|
||||
} else {
|
||||
window.location.href = redirectUri;
|
||||
}
|
||||
} finally {
|
||||
resetSavedUser();
|
||||
foldersStore.clearCache();
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue