diff --git a/i18n/en.json b/i18n/en.json index bc52dbd85..2554bb578 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -355,6 +355,9 @@ "trash_number_of_days_description": "Number of days to keep the assets in trash before permanently removing them", "trash_settings": "Trash Settings", "trash_settings_description": "Manage trash settings", + "unlink_all_oauth_accounts": "Unlink all OAuth accounts", + "unlink_all_oauth_accounts_description": "Remember to unlink all OAuth accounts before migrating to a new provider.", + "unlink_all_oauth_accounts_prompt": "Are you sure you want to unlink all OAuth accounts? This will reset the OAuth ID for each user and cannot be undone.", "user_cleanup_job": "User cleanup", "user_delete_delay": "{user}'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Delete delay", @@ -921,6 +924,7 @@ "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", "profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.", "quota_higher_than_disk_size": "You set a quota higher than the disk size", + "something_went_wrong": "Something went wrong", "unable_to_add_album_users": "Unable to add users to album", "unable_to_add_assets_to_shared_link": "Unable to add assets to shared link", "unable_to_add_comment": "Unable to add comment", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c4349ff65..2397d55c7 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8c1fa1a80..8ecb9cd5f 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/auth_admin_api.dart b/mobile/openapi/lib/api/auth_admin_api.dart new file mode 100644 index 000000000..d22b449aa Binary files /dev/null and b/mobile/openapi/lib/api/auth_admin_api.dart differ diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index b0903e8f1..95b9a55fb 100644 Binary files a/mobile/openapi/lib/model/permission.dart and b/mobile/openapi/lib/model/permission.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e8b5df9dc..ad22aa09c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -214,6 +214,34 @@ "description": "This endpoint requires the `activity.delete` permission." } }, + "/admin/auth/unlink-all": { + "post": { + "operationId": "unlinkAllOAuthAccountsAdmin", + "parameters": [], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Auth (admin)" + ], + "x-immich-admin-only": true, + "x-immich-permission": "adminAuth.unlinkAll", + "description": "This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission." + } + }, "/admin/notifications": { "post": { "operationId": "createNotification", @@ -12687,7 +12715,8 @@ "adminUser.create", "adminUser.read", "adminUser.update", - "adminUser.delete" + "adminUser.delete", + "adminAuth.unlinkAll" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5011e065e..ee5e2a769 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1646,6 +1646,15 @@ export function deleteActivity({ id }: { method: "DELETE" })); } +/** + * This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission. + */ +export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/admin/auth/unlink-all", { + ...opts, + method: "POST" + })); +} export function createNotification({ notificationCreateDto }: { notificationCreateDto: NotificationCreateDto; }, opts?: Oazapfts.RequestOpts) { @@ -4669,7 +4678,8 @@ export enum Permission { AdminUserCreate = "adminUser.create", AdminUserRead = "adminUser.read", AdminUserUpdate = "adminUser.update", - AdminUserDelete = "adminUser.delete" + AdminUserDelete = "adminUser.delete", + AdminAuthUnlinkAll = "adminAuth.unlinkAll" } export enum AssetMediaStatus { Created = "created", diff --git a/server/src/controllers/auth-admin.controller.ts b/server/src/controllers/auth-admin.controller.ts new file mode 100644 index 000000000..dba352783 --- /dev/null +++ b/server/src/controllers/auth-admin.controller.ts @@ -0,0 +1,18 @@ +import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { AuthAdminService } from 'src/services/auth-admin.service'; + +@ApiTags('Auth (admin)') +@Controller('admin/auth') +export class AuthAdminController { + constructor(private service: AuthAdminService) {} + @Post('unlink-all') + @Authenticated({ permission: Permission.AdminAuthUnlinkAll, admin: true }) + @HttpCode(HttpStatus.NO_CONTENT) + unlinkAllOAuthAccountsAdmin(@Auth() auth: AuthDto): Promise { + return this.service.unlinkAll(auth); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 9c39e580b..137abf103 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -4,6 +4,7 @@ import { APIKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; +import { AuthAdminController } from 'src/controllers/auth-admin.controller'; import { AuthController } from 'src/controllers/auth.controller'; import { DownloadController } from 'src/controllers/download.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller'; @@ -40,6 +41,7 @@ export const controllers = [ AssetController, AssetMediaController, AuthController, + AuthAdminController, DownloadController, DuplicateController, FaceController, diff --git a/server/src/enum.ts b/server/src/enum.ts index 8c7ee85a3..02ef22288 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -235,6 +235,8 @@ export enum Permission { AdminUserRead = 'adminUser.read', AdminUserUpdate = 'adminUser.update', AdminUserDelete = 'adminUser.delete', + + AdminAuthUnlinkAll = 'adminAuth.unlinkAll', } export enum SharedLinkType { diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 9d5f19b26..a63a4cc55 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -194,6 +194,10 @@ export class UserRepository { .executeTakeFirstOrThrow(); } + async updateAll(dto: Updateable) { + await this.db.updateTable('user').set(dto).execute(); + } + restore(id: string) { return this.db .updateTable('user') diff --git a/server/src/services/auth-admin.service.ts b/server/src/services/auth-admin.service.ts new file mode 100644 index 000000000..3648a1995 --- /dev/null +++ b/server/src/services/auth-admin.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { BaseService } from 'src/services/base.service'; + +@Injectable() +export class AuthAdminService extends BaseService { + async unlinkAll(_auth: AuthDto) { + // TODO replace '' with null + await this.userRepository.updateAll({ oauthId: '' }); + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 88b68d2c1..cad38ca1f 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -5,6 +5,7 @@ import { ApiService } from 'src/services/api.service'; import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; +import { AuthAdminService } from 'src/services/auth-admin.service'; import { AuthService } from 'src/services/auth.service'; import { BackupService } from 'src/services/backup.service'; import { CliService } from 'src/services/cli.service'; @@ -49,6 +50,7 @@ export const services = [ AssetService, AuditService, AuthService, + AuthAdminService, BackupService, CliService, DatabaseService, diff --git a/server/test/medium/specs/services/auth-admin.service.spec.ts b/server/test/medium/specs/services/auth-admin.service.spec.ts new file mode 100644 index 000000000..fa2a69f66 --- /dev/null +++ b/server/test/medium/specs/services/auth-admin.service.spec.ts @@ -0,0 +1,66 @@ +import { Kysely } from 'kysely'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { AuthAdminService } from 'src/services/auth-admin.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(AuthAdminService, { + database: db || defaultDatabase, + real: [UserRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AuthAdminService.name, () => { + describe('unlinkAll', () => { + it('should reset user.oauthId', async () => { + const { sut, ctx } = setup(); + const userRepo = ctx.get(UserRepository); + const { user } = await ctx.newUser({ oauthId: 'test-oauth-id' }); + const auth = factory.auth(); + + await expect(sut.unlinkAll(auth)).resolves.toBeUndefined(); + await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual( + expect.objectContaining({ oauthId: '' }), + ); + }); + + it('should reset a deleted user', async () => { + const { sut, ctx } = setup(); + const userRepo = ctx.get(UserRepository); + const { user } = await ctx.newUser({ oauthId: 'test-oauth-id', deletedAt: new Date() }); + const auth = factory.auth(); + + await expect(sut.unlinkAll(auth)).resolves.toBeUndefined(); + await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual( + expect.objectContaining({ oauthId: '' }), + ); + }); + + it('should reset multiple users', async () => { + const { sut, ctx } = setup(); + const userRepo = ctx.get(UserRepository); + const { user: user1 } = await ctx.newUser({ oauthId: '1' }); + const { user: user2 } = await ctx.newUser({ oauthId: '2', deletedAt: new Date() }); + const auth = factory.auth(); + + await expect(sut.unlinkAll(auth)).resolves.toBeUndefined(); + await expect(userRepo.get(user1.id, { withDeleted: true })).resolves.toEqual( + expect.objectContaining({ oauthId: '' }), + ); + await expect(userRepo.get(user2.id, { withDeleted: true })).resolves.toEqual( + expect.objectContaining({ oauthId: '' }), + ); + }); + }); +}); diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index ce6dc2617..ef371910c 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -1,5 +1,9 @@ @@ -56,7 +82,7 @@ subtitle={$t('admin.oauth_settings_description')} > - + {#snippet children({ message })} {/snippet} - + + + + {$t('admin.unlink_all_oauth_accounts_description')} + {$t('admin.unlink_all_oauth_accounts')} + +
+ {#snippet children({ message })} {/snippet} -