Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): logout of all tabs #12407

Merged
merged 1 commit into from
Sep 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion server/src/interfaces/event.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }];
};
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
*/
Expand Down
12 changes: 8 additions & 4 deletions server/src/repositories/event.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
OnGatewayConnection,
Expand Down Expand Up @@ -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,
) {
Expand All @@ -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);
Expand Down Expand Up @@ -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]) {
Expand Down
7 changes: 6 additions & 1 deletion server/src/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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>;
Expand Down Expand Up @@ -87,14 +90,15 @@ describe('AuthService', () => {
} as any);

cryptoMock = newCryptoRepositoryMock();
eventMock = newEventRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
sessionMock = newSessionRepositoryMock();
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', () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions server/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion server/src/services/notification.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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>;
Expand All @@ -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', () => {
Expand Down
9 changes: 8 additions & 1 deletion server/src/services/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
<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';
import SearchBar from '../search-bar/search-bar.svelte';
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;

Expand All @@ -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>

Expand Down Expand Up @@ -153,7 +145,7 @@
{/if}

{#if shouldShowAccountInfoPanel}
<AccountInfoPanel on:logout={logOut} />
<AccountInfoPanel on:logout={onLogout} />
{/if}
</div>
</section>
Expand Down
4 changes: 4 additions & 0 deletions web/src/lib/stores/websocket.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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({
Expand All @@ -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 = () => {
Expand Down
17 changes: 16 additions & 1 deletion web/src/lib/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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