Skip to content
Merged
2 changes: 2 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,8 @@
"change_password_description": "This is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
"change_password_form_confirm_password": "Confirm Password",
"change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
"change_password_form_log_out": "Log out all other devices",
"change_password_form_log_out_description": "It is recommended to log out of all other devices",
"change_password_form_new_password": "New Password",
"change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password",
Expand Down
19 changes: 18 additions & 1 deletion mobile/openapi/lib/model/change_password_dto.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -11469,6 +11469,10 @@
},
"ChangePasswordDto": {
"properties": {
"logOutOhterSessions": {
"example": true,
"type": "boolean"
},
"newPassword": {
"example": "password",
"minLength": 8,
Expand Down
1 change: 1 addition & 0 deletions open-api/typescript-sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ export type SignUpDto = {
password: string;
};
export type ChangePasswordDto = {
logOutOhterSessions?: boolean;
newPassword: string;
password: string;
};
Expand Down
2 changes: 1 addition & 1 deletion server/src/controllers/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ describe(AuthController.name, () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer())
.post('/auth/change-password')
.send({ password: 'password', newPassword: 'Password1234' });
.send({ password: 'password', newPassword: 'Password1234', logOutOhterSessions: false });
expect(ctx.authenticate).toHaveBeenCalled();
});
});
Expand Down
7 changes: 6 additions & 1 deletion server/src/dtos/auth.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { IsBoolean, IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import { ImmichCookie, UserMetadataKey } from 'src/enum';
import { UserMetadataItem } from 'src/types';
Expand Down Expand Up @@ -83,6 +83,11 @@ export class ChangePasswordDto {
@MinLength(8)
@ApiProperty({ example: 'password' })
newPassword!: string;

@IsBoolean()
@Optional()
@ApiProperty({ example: true })
Comment thread
MontejoJorge marked this conversation as resolved.
Outdated
Comment thread
MontejoJorge marked this conversation as resolved.
Outdated
logOutOhterSessions?: boolean;
Comment thread
MontejoJorge marked this conversation as resolved.
Outdated
}

export class PinCodeSetupDto {
Expand Down
1 change: 1 addition & 0 deletions server/src/repositories/event.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ type EventMap = {
/** user is permanently deleted */
UserDelete: [UserEvent];
UserRestore: [UserEvent];
UserLogoutOtherSessions: [{ userId: string; currentSessionId?: string }];
Comment thread
MontejoJorge marked this conversation as resolved.
Outdated

// websocket events
WebsocketConnect: [{ userId: string }];
Expand Down
18 changes: 18 additions & 0 deletions server/src/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,24 @@ describe(AuthService.name, () => {

await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
});

it('should change the password and emmit UserLogoutOtherSessions', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
const dto = { password: 'old-password', newPassword: 'new-password', logOutOhterSessions: true };

mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
mocks.user.update.mockResolvedValue(user);

await sut.changePassword(auth, dto);

expect(mocks.user.getForChangePassword).toHaveBeenCalledWith(user.id);
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
expect(mocks.event.emit).toHaveBeenCalledWith('UserLogoutOtherSessions', {
userId: user.id,
currentSessionId: auth.session?.id,
});
});
});

describe('logout', () => {
Expand Down
7 changes: 7 additions & 0 deletions server/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ export class AuthService extends BaseService {

const updatedUser = await this.userRepository.update(user.id, { password: hashedPassword });

if (dto.logOutOhterSessions) {
await this.eventRepository.emit('UserLogoutOtherSessions', {
userId: auth.user.id,
currentSessionId: auth.session?.id,
});
}
Comment thread
MontejoJorge marked this conversation as resolved.
Outdated

return mapUserAdmin(updatedUser);
}

Expand Down
16 changes: 13 additions & 3 deletions server/src/services/session.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { OnJob } from 'src/decorators';
import { OnEvent, OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
SessionCreateDto,
Expand All @@ -10,6 +10,7 @@ import {
mapSession,
} from 'src/dtos/session.dto';
import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';

@Injectable()
Expand Down Expand Up @@ -74,10 +75,19 @@ export class SessionService extends BaseService {
await this.sessionRepository.update(id, { pinExpiresAt: null });
}

@OnEvent({ name: 'UserLogoutOtherSessions' })
async handleUserLogoutOtherSessions({ userId, currentSessionId }: ArgOf<'UserLogoutOtherSessions'>): Promise<void> {
await this.deleteAllSessionsForUser(userId, currentSessionId);
}

async deleteAll(auth: AuthDto): Promise<void> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
await this.deleteAllSessionsForUser(auth.user.id, auth.session?.id);
}

private async deleteAllSessionsForUser(userId: string, excludeSessionId?: string): Promise<void> {
Comment thread
MontejoJorge marked this conversation as resolved.
Outdated
const sessions = await this.sessionRepository.getByUserId(userId);
for (const session of sessions) {
if (session.id === auth.session?.id) {
if (session.id === excludeSessionId) {
continue;
}
await this.sessionRepository.delete(session.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { changePassword } from '@immich/sdk';
import { Button } from '@immich/ui';
Expand All @@ -14,10 +15,11 @@
let password = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let logOutOhterSessions = $state(false);
Comment thread
MontejoJorge marked this conversation as resolved.
Outdated

const handleChangePassword = async () => {
try {
await changePassword({ changePasswordDto: { password, newPassword } });
await changePassword({ changePasswordDto: { password, newPassword, logOutOhterSessions } });

notificationController.show({
message: $t('updated_password'),
Expand Down Expand Up @@ -69,6 +71,12 @@
passwordAutocomplete="new-password"
/>

<SettingSwitch
title={$t('log_out_all_devices')}
subtitle={$t('change_password_form_log_out_description')}
bind:checked={logOutOhterSessions}
/>

<div class="flex justify-end">
<Button
shape="round"
Expand Down
Loading