mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 20:07:41 +09:00 
			
		
		
		
	feat: reset oauth ids (#20798)
This commit is contained in:
		| @@ -355,6 +355,9 @@ | |||||||
|     "trash_number_of_days_description": "Number of days to keep the assets in trash before permanently removing them", |     "trash_number_of_days_description": "Number of days to keep the assets in trash before permanently removing them", | ||||||
|     "trash_settings": "Trash Settings", |     "trash_settings": "Trash Settings", | ||||||
|     "trash_settings_description": "Manage 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_cleanup_job": "User cleanup", | ||||||
|     "user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", |     "user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", | ||||||
|     "user_delete_delay_settings": "Delete delay", |     "user_delete_delay_settings": "Delete delay", | ||||||
| @@ -921,6 +924,7 @@ | |||||||
|     "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", |     "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.", |     "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", |     "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_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_assets_to_shared_link": "Unable to add assets to shared link", | ||||||
|     "unable_to_add_comment": "Unable to add comment", |     "unable_to_add_comment": "Unable to add comment", | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -108,6 +108,7 @@ Class | Method | HTTP request | Description | |||||||
| *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets |  | *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets |  | ||||||
| *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |  | *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |  | ||||||
| *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |  | *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |  | ||||||
|  | *AuthAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all |  | ||||||
| *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |  | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |  | ||||||
| *AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code |  | *AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code |  | ||||||
| *AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status |  | *AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status |  | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							| @@ -34,6 +34,7 @@ part 'api/api_keys_api.dart'; | |||||||
| part 'api/activities_api.dart'; | part 'api/activities_api.dart'; | ||||||
| part 'api/albums_api.dart'; | part 'api/albums_api.dart'; | ||||||
| part 'api/assets_api.dart'; | part 'api/assets_api.dart'; | ||||||
|  | part 'api/auth_admin_api.dart'; | ||||||
| part 'api/authentication_api.dart'; | part 'api/authentication_api.dart'; | ||||||
| part 'api/deprecated_api.dart'; | part 'api/deprecated_api.dart'; | ||||||
| part 'api/download_api.dart'; | part 'api/download_api.dart'; | ||||||
|   | |||||||
							
								
								
									
										54
									
								
								mobile/openapi/lib/api/auth_admin_api.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								mobile/openapi/lib/api/auth_admin_api.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | |||||||
|  | // | ||||||
|  | // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||||
|  | // | ||||||
|  | // @dart=2.18 | ||||||
|  | 
 | ||||||
|  | // ignore_for_file: unused_element, unused_import | ||||||
|  | // ignore_for_file: always_put_required_named_parameters_first | ||||||
|  | // ignore_for_file: constant_identifier_names | ||||||
|  | // ignore_for_file: lines_longer_than_80_chars | ||||||
|  | 
 | ||||||
|  | part of openapi.api; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class AuthAdminApi { | ||||||
|  |   AuthAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; | ||||||
|  | 
 | ||||||
|  |   final ApiClient apiClient; | ||||||
|  | 
 | ||||||
|  |   /// This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission. | ||||||
|  |   /// | ||||||
|  |   /// Note: This method returns the HTTP [Response]. | ||||||
|  |   Future<Response> unlinkAllOAuthAccountsAdminWithHttpInfo() async { | ||||||
|  |     // ignore: prefer_const_declarations | ||||||
|  |     final apiPath = r'/admin/auth/unlink-all'; | ||||||
|  | 
 | ||||||
|  |     // ignore: prefer_final_locals | ||||||
|  |     Object? postBody; | ||||||
|  | 
 | ||||||
|  |     final queryParams = <QueryParam>[]; | ||||||
|  |     final headerParams = <String, String>{}; | ||||||
|  |     final formParams = <String, String>{}; | ||||||
|  | 
 | ||||||
|  |     const contentTypes = <String>[]; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     return apiClient.invokeAPI( | ||||||
|  |       apiPath, | ||||||
|  |       'POST', | ||||||
|  |       queryParams, | ||||||
|  |       postBody, | ||||||
|  |       headerParams, | ||||||
|  |       formParams, | ||||||
|  |       contentTypes.isEmpty ? null : contentTypes.first, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission. | ||||||
|  |   Future<void> unlinkAllOAuthAccountsAdmin() async { | ||||||
|  |     final response = await unlinkAllOAuthAccountsAdminWithHttpInfo(); | ||||||
|  |     if (response.statusCode >= HttpStatus.badRequest) { | ||||||
|  |       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										3
									
								
								mobile/openapi/lib/model/permission.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/lib/model/permission.dart
									
									
									
										generated
									
									
									
								
							| @@ -150,6 +150,7 @@ class Permission { | |||||||
|   static const adminUserPeriodRead = Permission._(r'adminUser.read'); |   static const adminUserPeriodRead = Permission._(r'adminUser.read'); | ||||||
|   static const adminUserPeriodUpdate = Permission._(r'adminUser.update'); |   static const adminUserPeriodUpdate = Permission._(r'adminUser.update'); | ||||||
|   static const adminUserPeriodDelete = Permission._(r'adminUser.delete'); |   static const adminUserPeriodDelete = Permission._(r'adminUser.delete'); | ||||||
|  |   static const adminAuthPeriodUnlinkAll = Permission._(r'adminAuth.unlinkAll'); | ||||||
| 
 | 
 | ||||||
|   /// List of all possible values in this [enum][Permission]. |   /// List of all possible values in this [enum][Permission]. | ||||||
|   static const values = <Permission>[ |   static const values = <Permission>[ | ||||||
| @@ -280,6 +281,7 @@ class Permission { | |||||||
|     adminUserPeriodRead, |     adminUserPeriodRead, | ||||||
|     adminUserPeriodUpdate, |     adminUserPeriodUpdate, | ||||||
|     adminUserPeriodDelete, |     adminUserPeriodDelete, | ||||||
|  |     adminAuthPeriodUnlinkAll, | ||||||
|   ]; |   ]; | ||||||
| 
 | 
 | ||||||
|   static Permission? fromJson(dynamic value) => PermissionTypeTransformer().decode(value); |   static Permission? fromJson(dynamic value) => PermissionTypeTransformer().decode(value); | ||||||
| @@ -445,6 +447,7 @@ class PermissionTypeTransformer { | |||||||
|         case r'adminUser.read': return Permission.adminUserPeriodRead; |         case r'adminUser.read': return Permission.adminUserPeriodRead; | ||||||
|         case r'adminUser.update': return Permission.adminUserPeriodUpdate; |         case r'adminUser.update': return Permission.adminUserPeriodUpdate; | ||||||
|         case r'adminUser.delete': return Permission.adminUserPeriodDelete; |         case r'adminUser.delete': return Permission.adminUserPeriodDelete; | ||||||
|  |         case r'adminAuth.unlinkAll': return Permission.adminAuthPeriodUnlinkAll; | ||||||
|         default: |         default: | ||||||
|           if (!allowNull) { |           if (!allowNull) { | ||||||
|             throw ArgumentError('Unknown enum value to decode: $data'); |             throw ArgumentError('Unknown enum value to decode: $data'); | ||||||
|   | |||||||
| @@ -214,6 +214,34 @@ | |||||||
|         "description": "This endpoint requires the `activity.delete` permission." |         "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": { |     "/admin/notifications": { | ||||||
|       "post": { |       "post": { | ||||||
|         "operationId": "createNotification", |         "operationId": "createNotification", | ||||||
| @@ -12687,7 +12715,8 @@ | |||||||
|           "adminUser.create", |           "adminUser.create", | ||||||
|           "adminUser.read", |           "adminUser.read", | ||||||
|           "adminUser.update", |           "adminUser.update", | ||||||
|           "adminUser.delete" |           "adminUser.delete", | ||||||
|  |           "adminAuth.unlinkAll" | ||||||
|         ], |         ], | ||||||
|         "type": "string" |         "type": "string" | ||||||
|       }, |       }, | ||||||
|   | |||||||
| @@ -1646,6 +1646,15 @@ export function deleteActivity({ id }: { | |||||||
|         method: "DELETE" |         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 }: { | export function createNotification({ notificationCreateDto }: { | ||||||
|     notificationCreateDto: NotificationCreateDto; |     notificationCreateDto: NotificationCreateDto; | ||||||
| }, opts?: Oazapfts.RequestOpts) { | }, opts?: Oazapfts.RequestOpts) { | ||||||
| @@ -4669,7 +4678,8 @@ export enum Permission { | |||||||
|     AdminUserCreate = "adminUser.create", |     AdminUserCreate = "adminUser.create", | ||||||
|     AdminUserRead = "adminUser.read", |     AdminUserRead = "adminUser.read", | ||||||
|     AdminUserUpdate = "adminUser.update", |     AdminUserUpdate = "adminUser.update", | ||||||
|     AdminUserDelete = "adminUser.delete" |     AdminUserDelete = "adminUser.delete", | ||||||
|  |     AdminAuthUnlinkAll = "adminAuth.unlinkAll" | ||||||
| } | } | ||||||
| export enum AssetMediaStatus { | export enum AssetMediaStatus { | ||||||
|     Created = "created", |     Created = "created", | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								server/src/controllers/auth-admin.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								server/src/controllers/auth-admin.controller.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<void> { | ||||||
|  |     return this.service.unlinkAll(auth); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -4,6 +4,7 @@ import { APIKeyController } from 'src/controllers/api-key.controller'; | |||||||
| import { AppController } from 'src/controllers/app.controller'; | import { AppController } from 'src/controllers/app.controller'; | ||||||
| import { AssetMediaController } from 'src/controllers/asset-media.controller'; | import { AssetMediaController } from 'src/controllers/asset-media.controller'; | ||||||
| import { AssetController } from 'src/controllers/asset.controller'; | import { AssetController } from 'src/controllers/asset.controller'; | ||||||
|  | import { AuthAdminController } from 'src/controllers/auth-admin.controller'; | ||||||
| import { AuthController } from 'src/controllers/auth.controller'; | import { AuthController } from 'src/controllers/auth.controller'; | ||||||
| import { DownloadController } from 'src/controllers/download.controller'; | import { DownloadController } from 'src/controllers/download.controller'; | ||||||
| import { DuplicateController } from 'src/controllers/duplicate.controller'; | import { DuplicateController } from 'src/controllers/duplicate.controller'; | ||||||
| @@ -40,6 +41,7 @@ export const controllers = [ | |||||||
|   AssetController, |   AssetController, | ||||||
|   AssetMediaController, |   AssetMediaController, | ||||||
|   AuthController, |   AuthController, | ||||||
|  |   AuthAdminController, | ||||||
|   DownloadController, |   DownloadController, | ||||||
|   DuplicateController, |   DuplicateController, | ||||||
|   FaceController, |   FaceController, | ||||||
|   | |||||||
| @@ -235,6 +235,8 @@ export enum Permission { | |||||||
|   AdminUserRead = 'adminUser.read', |   AdminUserRead = 'adminUser.read', | ||||||
|   AdminUserUpdate = 'adminUser.update', |   AdminUserUpdate = 'adminUser.update', | ||||||
|   AdminUserDelete = 'adminUser.delete', |   AdminUserDelete = 'adminUser.delete', | ||||||
|  |  | ||||||
|  |   AdminAuthUnlinkAll = 'adminAuth.unlinkAll', | ||||||
| } | } | ||||||
|  |  | ||||||
| export enum SharedLinkType { | export enum SharedLinkType { | ||||||
|   | |||||||
| @@ -194,6 +194,10 @@ export class UserRepository { | |||||||
|       .executeTakeFirstOrThrow(); |       .executeTakeFirstOrThrow(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   async updateAll(dto: Updateable<UserTable>) { | ||||||
|  |     await this.db.updateTable('user').set(dto).execute(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   restore(id: string) { |   restore(id: string) { | ||||||
|     return this.db |     return this.db | ||||||
|       .updateTable('user') |       .updateTable('user') | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								server/src/services/auth-admin.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								server/src/services/auth-admin.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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: '' }); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -5,6 +5,7 @@ import { ApiService } from 'src/services/api.service'; | |||||||
| import { AssetMediaService } from 'src/services/asset-media.service'; | import { AssetMediaService } from 'src/services/asset-media.service'; | ||||||
| import { AssetService } from 'src/services/asset.service'; | import { AssetService } from 'src/services/asset.service'; | ||||||
| import { AuditService } from 'src/services/audit.service'; | import { AuditService } from 'src/services/audit.service'; | ||||||
|  | import { AuthAdminService } from 'src/services/auth-admin.service'; | ||||||
| import { AuthService } from 'src/services/auth.service'; | import { AuthService } from 'src/services/auth.service'; | ||||||
| import { BackupService } from 'src/services/backup.service'; | import { BackupService } from 'src/services/backup.service'; | ||||||
| import { CliService } from 'src/services/cli.service'; | import { CliService } from 'src/services/cli.service'; | ||||||
| @@ -49,6 +50,7 @@ export const services = [ | |||||||
|   AssetService, |   AssetService, | ||||||
|   AuditService, |   AuditService, | ||||||
|   AuthService, |   AuthService, | ||||||
|  |   AuthAdminService, | ||||||
|   BackupService, |   BackupService, | ||||||
|   CliService, |   CliService, | ||||||
|   DatabaseService, |   DatabaseService, | ||||||
|   | |||||||
							
								
								
									
										66
									
								
								server/test/medium/specs/services/auth-admin.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								server/test/medium/specs/services/auth-admin.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<DB>; | ||||||
|  |  | ||||||
|  | const setup = (db?: Kysely<DB>) => { | ||||||
|  |   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: '' }), | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -1,5 +1,9 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|   import FormatMessage from '$lib/components/i18n/format-message.svelte'; |   import FormatMessage from '$lib/components/i18n/format-message.svelte'; | ||||||
|  |   import { | ||||||
|  |     notificationController, | ||||||
|  |     NotificationType, | ||||||
|  |   } from '$lib/components/shared-components/notification/notification'; | ||||||
|   import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; |   import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; | ||||||
|   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; |   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; | ||||||
|   import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; |   import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; | ||||||
| @@ -7,8 +11,10 @@ | |||||||
|   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; |   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; | ||||||
|   import { SettingInputFieldType } from '$lib/constants'; |   import { SettingInputFieldType } from '$lib/constants'; | ||||||
|   import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte'; |   import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte'; | ||||||
|   import { OAuthTokenEndpointAuthMethod, type SystemConfigDto } from '@immich/sdk'; |   import { handleError } from '$lib/utils/handle-error'; | ||||||
|   import { modalManager } from '@immich/ui'; |   import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin, type SystemConfigDto } from '@immich/sdk'; | ||||||
|  |   import { Button, modalManager, Text } from '@immich/ui'; | ||||||
|  |   import { mdiRestart } from '@mdi/js'; | ||||||
|   import { isEqual } from 'lodash-es'; |   import { isEqual } from 'lodash-es'; | ||||||
|   import { t } from 'svelte-i18n'; |   import { t } from 'svelte-i18n'; | ||||||
|   import { fade } from 'svelte/transition'; |   import { fade } from 'svelte/transition'; | ||||||
| @@ -44,6 +50,26 @@ | |||||||
|  |  | ||||||
|     onSave({ passwordLogin: config.passwordLogin, oauth: config.oauth }); |     onSave({ passwordLogin: config.passwordLogin, oauth: config.oauth }); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   const handleUnlinkAllOAuthAccounts = async () => { | ||||||
|  |     const confirmed = await modalManager.showDialog({ | ||||||
|  |       icon: mdiRestart, | ||||||
|  |       title: $t('admin.unlink_all_oauth_accounts'), | ||||||
|  |       prompt: $t('admin.unlink_all_oauth_accounts_prompt'), | ||||||
|  |       confirmColor: 'danger', | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     if (!confirmed) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await unlinkAllOAuthAccountsAdmin({}); | ||||||
|  |       notificationController.show({ message: $t('success'), type: NotificationType.Info }); | ||||||
|  |     } catch (error) { | ||||||
|  |       handleError(error, $t('errors.something_went_wrong')); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <div> | <div> | ||||||
| @@ -56,7 +82,7 @@ | |||||||
|           subtitle={$t('admin.oauth_settings_description')} |           subtitle={$t('admin.oauth_settings_description')} | ||||||
|         > |         > | ||||||
|           <div class="ms-4 mt-4 flex flex-col gap-4"> |           <div class="ms-4 mt-4 flex flex-col gap-4"> | ||||||
|             <p class="text-sm dark:text-immich-dark-fg"> |             <Text size="small"> | ||||||
|               <FormatMessage key="admin.oauth_settings_more_details"> |               <FormatMessage key="admin.oauth_settings_more_details"> | ||||||
|                 {#snippet children({ message })} |                 {#snippet children({ message })} | ||||||
|                   <a |                   <a | ||||||
| @@ -69,7 +95,7 @@ | |||||||
|                   </a> |                   </a> | ||||||
|                 {/snippet} |                 {/snippet} | ||||||
|               </FormatMessage> |               </FormatMessage> | ||||||
|             </p> |             </Text> | ||||||
|  |  | ||||||
|             <SettingSwitch |             <SettingSwitch | ||||||
|               {disabled} |               {disabled} | ||||||
| @@ -79,6 +105,14 @@ | |||||||
|  |  | ||||||
|             {#if config.oauth.enabled} |             {#if config.oauth.enabled} | ||||||
|               <hr /> |               <hr /> | ||||||
|  |  | ||||||
|  |               <div class="flex items-center gap-2 justify-between"> | ||||||
|  |                 <Text size="small">{$t('admin.unlink_all_oauth_accounts_description')}</Text> | ||||||
|  |                 <Button size="small" onclick={handleUnlinkAllOAuthAccounts} | ||||||
|  |                   >{$t('admin.unlink_all_oauth_accounts')}</Button | ||||||
|  |                 > | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|               <SettingInputField |               <SettingInputField | ||||||
|                 inputType={SettingInputFieldType.TEXT} |                 inputType={SettingInputFieldType.TEXT} | ||||||
|                 label="ISSUER_URL" |                 label="ISSUER_URL" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user