mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
feat: reset oauth ids (#20798)
This commit is contained in:
parent
9ecaa3fa9d
commit
538d5c81ea
15 changed files with 188 additions and 6 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/auth_admin_api.dart
generated
Normal file
BIN
mobile/openapi/lib/api/auth_admin_api.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/permission.dart
generated
BIN
mobile/openapi/lib/model/permission.dart
generated
Binary file not shown.
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue