Compare commits

..

2 Commits

Author SHA1 Message Date
Yaros
e67126823d chore: use context.t 2026-07-21 19:54:16 +02:00
Yaros
24e52c6b8d fix: backup delay translation key parsing 2026-07-21 18:49:13 +02:00
10 changed files with 36 additions and 120 deletions

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
@@ -10,6 +9,7 @@ import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
@@ -255,11 +255,11 @@ class _BackupDelaySlider extends ConsumerWidget {
_ => 600,
};
static String formatBackupDelaySliderValue(int v) => switch (v) {
0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}),
1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}),
2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}),
_ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}),
static String formatBackupDelaySliderValue(BuildContext context, int v) => switch (v) {
0 => context.t.setting_notifications_notify_seconds(count: 5),
1 => context.t.setting_notifications_notify_seconds(count: 30),
2 => context.t.setting_notifications_notify_minutes(count: 2),
_ => context.t.setting_notifications_notify_minutes(count: 10),
};
@override
@@ -272,8 +272,8 @@ class _BackupDelaySlider extends ConsumerWidget {
Padding(
padding: const EdgeInsets.only(left: 24.0, top: 8.0),
child: Text(
'backup_controller_page_background_delay'.tr(
namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
context.t.backup_controller_page_background_delay(
duration: formatBackupDelaySliderValue(context, currentValue),
),
style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
@@ -291,7 +291,7 @@ class _BackupDelaySlider extends ConsumerWidget {
max: 3.0,
min: 0.0,
divisions: 3,
label: formatBackupDelaySliderValue(currentValue),
label: formatBackupDelaySliderValue(context, currentValue),
),
],
);

View File

@@ -4,8 +4,7 @@
select
"id",
"expiresAt",
"pinExpiresAt",
"oauthBearerToken"
"pinExpiresAt"
from
"session"
where

View File

@@ -83,7 +83,7 @@ export class OAuthRepository {
url: string,
expectedState: string,
codeVerifier: string,
): Promise<{ profile: OAuthProfile; sid?: string; idToken?: string }> {
): Promise<{ profile: OAuthProfile; sid?: string }> {
const client = await this.getClient(config);
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
@@ -111,7 +111,7 @@ export class OAuthRepository {
}
}
return { profile, sid, idToken: tokens.id_token };
return { profile, sid };
} catch (error: Error | any) {
if (error.message.includes('unexpected JWT alg received')) {
this.logger.warn(

View File

@@ -32,7 +32,7 @@ export class SessionRepository {
get(id: string) {
return this.db
.selectFrom('session')
.select(['id', 'expiresAt', 'pinExpiresAt', 'oauthBearerToken'])
.select(['id', 'expiresAt', 'pinExpiresAt'])
.where('id', '=', id)
.executeTakeFirst();
}

View File

@@ -1,9 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "session" ADD "oauthBearerToken" character varying;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "session" DROP COLUMN "oauthBearerToken";`.execute(db);
}

View File

@@ -55,7 +55,4 @@ export class SessionTable {
@Column({ nullable: true, index: true })
oauthSid!: string | null;
@Column({ nullable: true })
oauthBearerToken!: string | null;
}

View File

@@ -160,25 +160,7 @@ describe(AuthService.name, () => {
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint/',
});
});
it('should include the id token hint for OAuth sessions', async () => {
const auth = AuthFactory.from().session().build();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.session.get.mockResolvedValue({
id: auth.session!.id,
expiresAt: null,
oauthBearerToken: 'id-token',
pinExpiresAt: null,
});
mocks.session.delete.mockResolvedValue();
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint/?id_token_hint=id-token',
redirectUri: 'http://end-session-endpoint',
});
});
@@ -191,7 +173,7 @@ describe(AuthService.name, () => {
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://custom-logout-url/',
redirectUri: 'http://custom-logout-url',
});
});
@@ -204,7 +186,7 @@ describe(AuthService.name, () => {
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint/',
redirectUri: 'http://end-session-endpoint',
});
});
@@ -219,12 +201,6 @@ describe(AuthService.name, () => {
it('should delete the access token', async () => {
const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto;
mocks.session.get.mockResolvedValue({
id: auth.session!.id,
expiresAt: null,
oauthBearerToken: null,
pinExpiresAt: null,
});
mocks.session.delete.mockResolvedValue();
await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({
@@ -746,27 +722,6 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
});
it('should store the OAuth bearer token on the new session', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'oauth-sid', idToken: 'oauth-bearer-token' });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
expect(mocks.session.create).toHaveBeenCalledWith(
expect.objectContaining({ oauthSid: 'oauth-sid', oauthBearerToken: 'oauth-bearer-token' }),
);
});
it('should normalize the email from the OAuth profile before linking', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
@@ -1170,7 +1125,6 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: { sub: 'sub' },
sid: session.oauthSid ?? undefined,
idToken: session.oauthBearerToken ?? undefined,
});
mocks.user.update.mockResolvedValue(user);
mocks.session.update.mockResolvedValue(session);
@@ -1181,10 +1135,7 @@ describe(AuthService.name, () => {
{},
);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, {
oauthSid: session.oauthSid,
oauthBearerToken: session.oauthBearerToken,
});
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' });
});
@@ -1218,7 +1169,7 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
});
it('should unlink an account and remove the OAuth data from the session', async () => {
it('should unlink an account and remove the oauthSid from the session', async () => {
const user = UserFactory.create();
const session = SessionFactory.create();
const auth = AuthFactory.from(user).session(session).build();
@@ -1229,7 +1180,7 @@ describe(AuthService.name, () => {
await sut.unlink(auth);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null, oauthBearerToken: null });
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
});
});

View File

@@ -76,17 +76,14 @@ export class AuthService extends BaseService {
}
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
let oauthBearerToken: string | undefined;
if (auth.session) {
const session = await this.sessionRepository.get(auth.session.id);
oauthBearerToken = session?.oauthBearerToken ?? undefined;
await this.sessionRepository.delete(auth.session.id);
await this.eventRepository.emit('SessionDelete', { sessionId: auth.session.id });
}
return {
successful: true,
redirectUri: await this.getLogoutEndpoint(authType, oauthBearerToken),
redirectUri: await this.getLogoutEndpoint(authType),
};
}
@@ -309,11 +306,12 @@ export class AuthService extends BaseService {
}
const url = this.resolveRedirectUri(oauth, dto.url);
const {
profile,
sid: oauthSid,
idToken: oauthBearerToken,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier);
const { profile, sid: oauthSid } = await this.oauthRepository.getProfileAndOAuthSid(
oauth,
url,
expectedState,
codeVerifier,
);
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
@@ -380,7 +378,7 @@ export class AuthService extends BaseService {
await this.syncProfilePicture(user, profile.picture);
}
return this.createLoginResponse(user, loginDetails, oauthSid, oauthBearerToken);
return this.createLoginResponse(user, loginDetails, oauthSid);
}
private async syncProfilePicture(user: UserAdmin, url: string) {
@@ -421,7 +419,6 @@ export class AuthService extends BaseService {
const {
profile: { sub: oauthId },
sid,
idToken,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
const duplicate = await this.userRepository.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== auth.user.id) {
@@ -429,11 +426,8 @@ export class AuthService extends BaseService {
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
if (auth.session && (sid || idToken)) {
await this.sessionRepository.update(auth.session.id, {
oauthSid: sid,
oauthBearerToken: idToken,
});
if (auth.session && sid) {
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
}
const user = await this.userRepository.update(auth.user.id, { oauthId });
@@ -442,14 +436,14 @@ export class AuthService extends BaseService {
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: null, oauthBearerToken: null });
await this.sessionRepository.update(auth.session.id, { oauthSid: null });
}
const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
return mapUserAdmin(user);
}
private async getLogoutEndpoint(authType: AuthType, oauthBearerToken?: string | null): Promise<string> {
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
if (authType !== AuthType.OAuth) {
return LOGIN_URL;
}
@@ -459,20 +453,11 @@ export class AuthService extends BaseService {
return LOGIN_URL;
}
const endSessionEndpoint =
config.oauth.endSessionEndpoint || (await this.oauthRepository.getLogoutEndpoint(config.oauth));
if (!endSessionEndpoint) {
return LOGIN_URL;
if (config.oauth.endSessionEndpoint) {
return config.oauth.endSessionEndpoint;
}
const url = new URL(endSessionEndpoint);
if (oauthBearerToken) {
url.searchParams.set('id_token_hint', oauthBearerToken);
}
return url.href;
return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL;
}
private getBearerToken(headers: IncomingHttpHeaders): string | null {
@@ -614,12 +599,7 @@ export class AuthService extends BaseService {
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
}
private async createLoginResponse(
user: UserAdmin,
loginDetails: LoginDetails,
oauthSid?: string,
oauthBearerToken?: string,
) {
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails, oauthSid?: string) {
const token = this.cryptoRepository.randomBytesAsText(32);
const hashed = this.cryptoRepository.hashSha256(token);
@@ -630,7 +610,6 @@ export class AuthService extends BaseService {
appVersion: loginDetails.appVersion,
userId: user.id,
oauthSid: oauthSid ?? null,
oauthBearerToken: oauthBearerToken ?? null,
});
return mapLoginResponse(user, token);

View File

@@ -26,7 +26,6 @@ export class SessionFactory {
updatedAt: newDate(),
userId: newUuid(),
oauthSid: newUuid(),
oauthBearerToken: 'oauth-bearer-token',
...dto,
});
}

View File

@@ -22,7 +22,7 @@ const authFactory = ({
user,
}: {
apiKey?: Partial<AuthApiKey>;
session?: { id?: string; hasElevatedPermission?: boolean; oauthBearerToken?: string | null };
session?: { id?: string; hasElevatedPermission?: boolean };
user?: Omit<
Partial<UserAdmin>,
'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt'