diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e2cb4b0417..7f4e48a167 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AuthenticationApi.md b/mobile/openapi/doc/AuthenticationApi.md index ae9755147a..67de700967 100644 Binary files a/mobile/openapi/doc/AuthenticationApi.md and b/mobile/openapi/doc/AuthenticationApi.md differ diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index f8ba2cdb16..5035d51558 100644 Binary files a/mobile/openapi/lib/api/authentication_api.dart and b/mobile/openapi/lib/api/authentication_api.dart differ diff --git a/mobile/openapi/test/authentication_api_test.dart b/mobile/openapi/test/authentication_api_test.dart index 9c5ae81b67..9fed80d71c 100644 Binary files a/mobile/openapi/test/authentication_api_test.dart and b/mobile/openapi/test/authentication_api_test.dart differ diff --git a/server/apps/immich/src/controllers/auth.controller.ts b/server/apps/immich/src/controllers/auth.controller.ts index 6ea39fd9d5..6642911d30 100644 --- a/server/apps/immich/src/controllers/auth.controller.ts +++ b/server/apps/immich/src/controllers/auth.controller.ts @@ -52,6 +52,12 @@ export class AuthController { return this.service.getDevices(authUser); } + @Authenticated() + @Delete('devices') + logoutAuthDevices(@GetAuthUser() authUser: AuthUserDto): Promise { + return this.service.logoutDevices(authUser); + } + @Authenticated() @Delete('devices/:id') logoutAuthDevice(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index d73299c0fd..12d5f07812 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -393,6 +393,29 @@ "api_key": [] } ] + }, + "delete": { + "operationId": "logoutAuthDevices", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Authentication" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ] } }, "/auth/devices/{id}": { diff --git a/server/libs/domain/src/auth/auth.service.spec.ts b/server/libs/domain/src/auth/auth.service.spec.ts index 19b12acca9..e371618aab 100644 --- a/server/libs/domain/src/auth/auth.service.spec.ts +++ b/server/libs/domain/src/auth/auth.service.spec.ts @@ -357,6 +357,18 @@ describe('AuthService', () => { }); }); + describe('logoutDevices', () => { + it('should logout all devices', async () => { + userTokenMock.getAll.mockResolvedValue([userTokenEntityStub.inactiveToken, userTokenEntityStub.userToken]); + + await sut.logoutDevices(authStub.user1); + + expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id); + expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'not_active'); + expect(userTokenMock.delete).not.toHaveBeenCalledWith(authStub.user1.id, 'token-id'); + }); + }); + describe('logoutDevice', () => { it('should logout the device', async () => { await sut.logoutDevice(authStub.user1, 'token-1'); diff --git a/server/libs/domain/src/auth/auth.service.ts b/server/libs/domain/src/auth/auth.service.ts index bf4ff7a830..af7cd449b6 100644 --- a/server/libs/domain/src/auth/auth.service.ts +++ b/server/libs/domain/src/auth/auth.service.ts @@ -163,6 +163,16 @@ export class AuthService { await this.userTokenCore.delete(authUser.id, deviceId); } + async logoutDevices(authUser: AuthUserDto): Promise { + const devices = await this.userTokenCore.getAll(authUser.id); + for (const device of devices) { + if (device.id === authUser.accessTokenId) { + continue; + } + await this.userTokenCore.delete(authUser.id, device.id); + } + } + private getBearerToken(headers: IncomingHttpHeaders): string | null { const [type, token] = (headers.authorization || '').split(' '); if (type.toLowerCase() === 'bearer') { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 08ffcb25fc..f6ca2bfe3d 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -6299,6 +6299,44 @@ export const AuthenticationApiAxiosParamCreator = function (configuration?: Conf + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + logoutAuthDevices: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/auth/devices`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -6414,6 +6452,15 @@ export const AuthenticationApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.logoutAuthDevice(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async logoutAuthDevices(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.logoutAuthDevices(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {*} [options] Override http request option. @@ -6485,6 +6532,14 @@ export const AuthenticationApiFactory = function (configuration?: Configuration, logoutAuthDevice(id: string, options?: any): AxiosPromise { return localVarFp.logoutAuthDevice(id, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + logoutAuthDevices(options?: any): AxiosPromise { + return localVarFp.logoutAuthDevices(options).then((request) => request(axios, basePath)); + }, /** * * @param {*} [options] Override http request option. @@ -6567,6 +6622,16 @@ export class AuthenticationApi extends BaseAPI { return AuthenticationApiFp(this.configuration).logoutAuthDevice(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuthenticationApi + */ + public logoutAuthDevices(options?: AxiosRequestConfig) { + return AuthenticationApiFp(this.configuration).logoutAuthDevices(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {*} [options] Override http request option. diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index b9a259cdfd..0d814581dd 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -62,7 +62,7 @@ diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte index 1752da82e6..5e6544d432 100644 --- a/web/src/lib/components/user-settings-page/device-list.svelte +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -2,6 +2,7 @@ import { api, AuthDeviceResponseDto } from '@api'; import { onMount } from 'svelte'; import { handleError } from '../../utils/handle-error'; + import Button from '../elements/buttons/button.svelte'; import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte'; import { notificationController, @@ -11,6 +12,7 @@ let devices: AuthDeviceResponseDto[] = []; let deleteDevice: AuthDeviceResponseDto | null = null; + let deleteAll = false; const refresh = () => api.authenticationApi.getAuthDevices().then(({ data }) => (devices = data)); @@ -30,22 +32,45 @@ await api.authenticationApi.logoutAuthDevice(deleteDevice.id); notificationController.show({ message: `Logged out device`, type: NotificationType.Info }); } catch (error) { - handleError(error, 'Unable to logout device'); + handleError(error, 'Unable to log out device'); } finally { await refresh(); deleteDevice = null; } }; + + const handleDeleteAll = async () => { + try { + await api.authenticationApi.logoutAuthDevices(); + notificationController.show({ + message: `Logged out all devices`, + type: NotificationType.Info + }); + } catch (error) { + handleError(error, 'Unable to log out all devices'); + } finally { + await refresh(); + deleteAll = false; + } + }; {#if deleteDevice} handleDelete()} on:cancel={() => (deleteDevice = null)} /> {/if} +{#if deleteAll} + handleDeleteAll()} + on:cancel={() => (deleteAll = false)} + /> +{/if} +
{#if currentDevice}
@@ -56,7 +81,7 @@
{/if} {#if otherDevices.length > 0} -
+

OTHER DEVICES

@@ -67,5 +92,11 @@ {/if} {/each}
+

+ LOG OUT ALL DEVICES +

+
+ +
{/if}