diff --git a/docs/docs/administration/img/reset-admin-password.webp b/docs/docs/administration/img/reset-admin-password.webp deleted file mode 100644 index 5062d24a6b..0000000000 Binary files a/docs/docs/administration/img/reset-admin-password.webp and /dev/null differ diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index 104c16c0a2..49bcf17700 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -35,6 +35,7 @@ Found Admin: - Email=admin@example.com - Name=Immich Admin ? Please choose a new password (optional) immich-is-cool +? Invalidate existing sessions? Yes The admin password has been updated. ``` diff --git a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts index 24699cda30..ad0e43ff14 100644 --- a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts +++ b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts @@ -65,6 +65,10 @@ describe(`immich-admin`, () => { child.stdout.on('data', (chunk) => { data += chunk; if (data.includes('Please choose a new password (optional)')) { + child.stdin.write('\n'); + } + + if (data.includes('Invalidate existing sessions?')) { child.stdin.end('\n'); } }); diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts index 2a2dd1857d..0d03e4cd95 100644 --- a/server/src/commands/index.ts +++ b/server/src/commands/index.ts @@ -8,13 +8,13 @@ import { } from 'src/commands/media-location.command'; import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; -import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; +import { PromptPasswordResetQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; import { SchemaCheck } from 'src/commands/schema-check'; import { VersionCommand } from 'src/commands/version.command'; export const commandsAndQuestions = [ ResetAdminPasswordCommand, - PromptPasswordQuestions, + PromptPasswordResetQuestions, PromptEmailQuestion, EnablePasswordLoginCommand, DisablePasswordLoginCommand, diff --git a/server/src/commands/reset-admin-password.command.ts b/server/src/commands/reset-admin-password.command.ts index e5dee49837..703f693326 100644 --- a/server/src/commands/reset-admin-password.command.ts +++ b/server/src/commands/reset-admin-password.command.ts @@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { CliService } from 'src/services/cli.service'; const prompt = (inquirer: InquirerService) => { - return function ask(admin: UserAdminResponseDto) { + return (admin: UserAdminResponseDto) => { const { id, oauthId, email, name } = admin; console.log(`Found Admin: - ID=${id} @@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => { - Email=${email} - Name=${name}`); - return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password); + return inquirer.ask<{ newPassword: string; invalidateSessions: boolean }>('prompt-password-reset', {}); }; }; @@ -43,13 +43,23 @@ export class ResetAdminPasswordCommand extends CommandRunner { } } -@QuestionSet({ name: 'prompt-password' }) -export class PromptPasswordQuestions { +@QuestionSet({ name: 'prompt-password-reset' }) +export class PromptPasswordResetQuestions { @Question({ message: 'Please choose a new password (optional)', - name: 'password', + name: 'newPassword', }) parsePassword(value: string) { return value; } + + @Question({ + type: 'confirm', + message: 'Invalidate existing sessions?', + default: true, + name: 'invalidateSessions', + }) + parseInvalidate(value: boolean): boolean { + return value; + } } diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 347d9eef00..29c62d258f 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -37,7 +37,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); - const ask = vitest.fn().mockImplementation(() => {}); + const ask = vitest.fn().mockResolvedValue({ newPassword: undefined, invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -47,6 +47,7 @@ describe(CliService.name, () => { expect(ask).toHaveBeenCalled(); expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); + expect(mocks.session.invalidateAll).not.toHaveBeenCalled(); }); it('should use the supplied password', async () => { @@ -55,7 +56,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin); - const ask = vitest.fn().mockResolvedValue('new-password'); + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -66,6 +67,20 @@ describe(CliService.name, () => { expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); }); + + it('should invalidate existing sessions when requested', async () => { + const admin = UserFactory.create({ isAdmin: true }); + + mocks.user.getAdmin.mockResolvedValue(admin); + mocks.user.update.mockResolvedValue(admin); + mocks.session.invalidateAll.mockResolvedValue(void 0); + + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: true }); + + await sut.resetAdminPassword(ask); + + expect(mocks.session.invalidateAll).toHaveBeenCalledWith({ userId: admin.id }); + }); }); describe('disablePasswordLogin', () => { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 23d70b6860..f37353d7a1 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -58,18 +58,24 @@ export class CliService extends BaseService { return users.map((user) => mapUserAdmin(user)); } - async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise) { + async resetAdminPassword( + ask: (admin: UserAdminResponseDto) => Promise<{ newPassword: string | undefined; invalidateSessions: boolean }>, + ) { const admin = await this.userRepository.getAdmin(); if (!admin) { throw new Error('Admin account does not exist'); } - const providedPassword = await ask(mapUserAdmin(admin)); + const { newPassword: providedPassword, invalidateSessions } = await ask(mapUserAdmin(admin)); const password = providedPassword || this.cryptoRepository.randomBytesAsText(24); const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS); await this.userRepository.update(admin.id, { password: hashedPassword }); + if (invalidateSessions) { + await this.sessionRepository.invalidateAll({ userId: admin.id }); + } + return { admin, password, provided: !!providedPassword }; }