Compare commits

...

7 Commits

Author SHA1 Message Date
renovate[bot]
94d9f0afbd fix(deps): update typescript-projects 2026-02-05 17:16:04 +00:00
Jason Rasmussen
237ea3aedd chore: update oauth documentation (#25907)
* chore: prefer lowercase for non i18n labels

* chore: update documentation
2026-02-05 09:00:00 -05:00
Michel Heusschen
810e9254f3 fix: preserve hidden people state across pagination (#25886)
* fix: preserve hidden people state across pagination

* track overrides instead

* use event instead of bind:people

* update test
2026-02-05 08:51:30 -05:00
Michel Heusschen
57e0835b46 fix: ensure theme stays in sync with @immich/ui (#25922) 2026-02-05 08:36:20 -05:00
Michel Heusschen
e97030a7ae fix: make switch labels properly clickable (#25898) 2026-02-05 12:09:27 +01:00
Michel Heusschen
fdf06a91cc fix: improve asset editor exit handling (#25917) 2026-02-05 12:01:54 +01:00
Michel Heusschen
732303661b fix: allow null tagIds in search dto (#25920) 2026-02-05 11:52:35 +01:00
20 changed files with 1372 additions and 2370 deletions

View File

@@ -56,11 +56,13 @@ Once you have a new OAuth client application configured, Immich can be configure
| Setting | Type | Default | Description |
| ---------------------------------------------------- | ------- | -------------------- | ----------------------------------------------------------------------------------- |
| Enabled | boolean | false | Enable/disable OAuth |
| Issuer URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
| Client ID | string | (required) | Required. Client ID (from previous step) |
| Client Secret | string | (required) | Required. Client Secret (previous step) |
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| `issuer_url` | URL | (required) | Required. Self-discovery URL for client (from previous step) |
| `client_id` | string | (required) | Required. Client ID (from previous step) |
| `client_secret` | string | (required) | Required. Client Secret (previous step) |
| `scope` | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| `id_token_signed_response_alg` | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| `userinfo_signed_response_alg` | string | none | The algorithm used to sign the userinfo response (examples: RS256, HS256) |
| Request timeout | string | 30,000 (30 seconds) | Number of milliseconds to wait for http requests to complete before giving up |
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** |

View File

@@ -16,7 +16,7 @@ config_roots = [
[tools]
node = "24.13.0"
flutter = "3.35.7"
pnpm = "10.28.0"
pnpm = "10.28.2"
terragrunt = "0.98.0"
opentofu = "1.11.4"
java = "21.0.2"

View File

@@ -3,7 +3,7 @@
"version": "2.5.3",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"engines": {
"pnpm": ">=10.0.0"
}

3419
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,14 +45,14 @@
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.210.0",
"@opentelemetry/instrumentation-http": "^0.210.0",
"@opentelemetry/instrumentation-ioredis": "^0.58.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.56.0",
"@opentelemetry/instrumentation-pg": "^0.62.0",
"@opentelemetry/exporter-prometheus": "^0.211.0",
"@opentelemetry/instrumentation-http": "^0.211.0",
"@opentelemetry/instrumentation-ioredis": "^0.59.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.57.0",
"@opentelemetry/instrumentation-pg": "^0.63.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.210.0",
"@opentelemetry/sdk-node": "^0.211.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2",
@@ -69,7 +69,7 @@
"compression": "^1.8.0",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cron": "4.3.5",
"cron": "4.4.0",
"exiftool-vendored": "^34.3.0",
"express": "^5.1.0",
"fast-glob": "^3.3.2",
@@ -81,7 +81,7 @@
"jose": "^5.10.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.2",
"kysely": "0.28.11",
"kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",

View File

@@ -97,7 +97,7 @@ class BaseSearchDto {
@ValidateUUID({ each: true, optional: true, description: 'Filter by person IDs' })
personIds?: string[];
@ValidateUUID({ each: true, optional: true, description: 'Filter by tag IDs' })
@ValidateUUID({ each: true, optional: true, nullable: true, description: 'Filter by tag IDs' })
tagIds?: string[] | null;
@ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' })

View File

@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.59.0",
"@immich/ui": "^0.61.3",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
@@ -37,7 +37,7 @@
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
"@photo-sphere-viewer/video-plugin": "^5.14.0",
"@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.41.0",
"@zoom-image/core": "^0.42.0",
"@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0",
"fabric": "^6.5.4",
@@ -98,7 +98,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.48.0",
"svelte": "5.49.1",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",

View File

@@ -0,0 +1,8 @@
import { vi } from 'vitest';
export const getResizeObserverMock = () =>
vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));

View File

@@ -105,7 +105,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER_URL"
label="issuer_url"
bind:value={configToEdit.oauth.issuerUrl}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
@@ -114,7 +114,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT_ID"
label="client_id"
bind:value={configToEdit.oauth.clientId}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
@@ -123,7 +123,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT_SECRET"
label="client_secret"
description={$t('admin.oauth_client_secret_description')}
bind:value={configToEdit.oauth.clientSecret}
disabled={disabled || !configToEdit.oauth.enabled}
@@ -132,7 +132,7 @@
{#if configToEdit.oauth.clientSecret}
<SettingSelect
label="TOKEN_ENDPOINT_AUTH_METHOD"
label="token_endpoint_auth_method"
bind:value={configToEdit.oauth.tokenEndpointAuthMethod}
disabled={disabled || !configToEdit.oauth.enabled || !configToEdit.oauth.clientSecret}
isEdited={!(configToEdit.oauth.tokenEndpointAuthMethod === config.oauth.tokenEndpointAuthMethod)}
@@ -146,7 +146,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
label="scope"
bind:value={configToEdit.oauth.scope}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
@@ -155,7 +155,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ID_TOKEN_SIGNED_RESPONSE_ALG"
label="id_token_signed_response_alg"
bind:value={configToEdit.oauth.signingAlgorithm}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
@@ -164,7 +164,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="USERINFO_SIGNED_RESPONSE_ALG"
label="userinfo_signed_response_alg"
bind:value={configToEdit.oauth.profileSigningAlgorithm}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}

View File

@@ -1,3 +1,4 @@
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
@@ -20,10 +21,7 @@ describe('AssetViewerNavBar component', () => {
Element.prototype.animate = vi.fn().mockImplementation(() => ({
cancel: () => {},
}));
vi.stubGlobal(
'ResizeObserver',
vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })),
);
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
return {
featureFlagsManager: {

View File

@@ -176,6 +176,7 @@
}
activityManager.reset();
assetViewerManager.closeEditor();
});
const handleGetAllAlbums = async () => {

View File

@@ -0,0 +1,39 @@
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
import CropArea from '$lib/components/asset-viewer/editor/transform-tool/crop-area.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { assetFactory } from '@test-data/factories/asset-factory';
import { render } from '@testing-library/svelte';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
vi.mock('$lib/utils');
describe('CropArea', () => {
beforeAll(() => {
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mocked(getAssetMediaUrl).mockReturnValue('/mock-image.jpg');
});
afterEach(() => {
transformManager.reset();
});
it('clears cursor styles on reset', () => {
const asset = assetFactory.build();
const { getByRole } = render(CropArea, { asset });
const cropArea = getByRole('button', { name: 'Crop area' });
transformManager.region = { x: 100, y: 100, width: 200, height: 200 };
transformManager.cropImageSize = { width: 1000, height: 1000 };
transformManager.cropImageScale = 1;
transformManager.updateCursor(100, 150);
expect(document.body.style.cursor).toBe('ew-resize');
expect(cropArea.style.cursor).toBe('ew-resize');
transformManager.reset();
expect(document.body.style.cursor).toBe('');
expect(cropArea.style.cursor).toBe('');
});
});

View File

@@ -0,0 +1,88 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { personFactory } from '@test-data/factories/person-factory';
import { render } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import ManagePeopleVisibilityWrapper from './manage-people-visibility.test-wrapper.svelte';
describe('ManagePeopleVisibility component', () => {
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
});
it('keeps toggled hidden state when loading more people', async () => {
const onClose = vi.fn();
const onUpdate = vi.fn();
const loadNextPage = vi.fn();
const [personA, personB, personC] = [
personFactory.build({ id: 'a', isHidden: false }),
personFactory.build({ id: 'b', isHidden: false }),
personFactory.build({ id: 'c', isHidden: true }),
];
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
props: {
people: [personA, personB],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
},
});
const user = userEvent.setup();
let personButtons = container.querySelectorAll('button[aria-pressed]');
expect(personButtons).toHaveLength(2);
await user.click(personButtons[0]);
expect(personButtons[0].getAttribute('aria-pressed')).toBe('true');
await rerender({
people: [personA, personB, personC],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
});
personButtons = container.querySelectorAll('button[aria-pressed]');
expect(personButtons).toHaveLength(3);
expect(personButtons[0].getAttribute('aria-pressed')).toBe('true');
expect(personButtons[2].getAttribute('aria-pressed')).toBe('true');
});
it('shows newly loaded hidden people as hidden', async () => {
const onClose = vi.fn();
const onUpdate = vi.fn();
const loadNextPage = vi.fn();
const [personA, personB, personC] = [
personFactory.build({ id: 'a', isHidden: false }),
personFactory.build({ id: 'b', isHidden: false }),
personFactory.build({ id: 'c', isHidden: true }),
];
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
props: {
people: [personA, personB],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
},
});
await rerender({
people: [personA, personB, personC],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
});
const personButtons = container.querySelectorAll('button[aria-pressed]');
expect(personButtons).toHaveLength(3);
expect(personButtons[2].getAttribute('aria-pressed')).toBe('true');
});
});

View File

@@ -10,27 +10,22 @@
import { Button, IconButton, toastManager } from '@immich/ui';
import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteMap } from 'svelte/reactivity';
interface Props {
people: PersonResponseDto[];
totalPeopleCount: number;
titleId?: string | undefined;
onClose: () => void;
onUpdate: (people: PersonResponseDto[]) => void;
loadNextPage: () => void;
}
let { people = $bindable(), totalPeopleCount, titleId = undefined, onClose, loadNextPage }: Props = $props();
let { people, totalPeopleCount, titleId = undefined, onClose, onUpdate, loadNextPage }: Props = $props();
let toggleVisibility = $state(ToggleVisibility.SHOW_ALL);
let showLoadingSpinner = $state(false);
const getPersonIsHidden = (people: PersonResponseDto[]) => {
const personIsHidden: Record<string, boolean> = {};
for (const person of people) {
personIsHidden[person.id] = person.isHidden;
}
return personIsHidden;
};
const overrides = new SvelteMap<string, boolean>();
const getNextVisibility = (toggleVisibility: ToggleVisibility) => {
if (toggleVisibility === ToggleVisibility.SHOW_ALL) {
@@ -46,23 +41,23 @@
toggleVisibility = getNextVisibility(toggleVisibility);
for (const person of people) {
let isHidden = overrides.get(person.id) ?? person.isHidden;
if (toggleVisibility === ToggleVisibility.HIDE_ALL) {
personIsHidden[person.id] = true;
isHidden = true;
} else if (toggleVisibility === ToggleVisibility.SHOW_ALL) {
personIsHidden[person.id] = false;
isHidden = false;
} else if (toggleVisibility === ToggleVisibility.HIDE_UNNANEMD && !person.name) {
personIsHidden[person.id] = true;
isHidden = true;
}
setHiddenOverride(person, isHidden);
}
};
const handleResetVisibility = () => (personIsHidden = getPersonIsHidden(people));
const handleSaveVisibility = async () => {
showLoadingSpinner = true;
const changed = people
.filter((person) => person.isHidden !== personIsHidden[person.id])
.map((person) => ({ id: person.id, isHidden: personIsHidden[person.id] }));
const changed = Array.from(overrides, ([id, isHidden]) => ({ id, isHidden }));
try {
if (changed.length > 0) {
@@ -76,9 +71,14 @@
}
for (const person of people) {
person.isHidden = personIsHidden[person.id];
const isHidden = overrides.get(person.id);
if (isHidden !== undefined) {
person.isHidden = isHidden;
}
}
overrides.clear();
onUpdate(people);
onClose();
} catch (error) {
handleError(error, $t('errors.unable_to_change_visibility', { values: { count: changed.length } }));
@@ -87,7 +87,13 @@
}
};
let personIsHidden = $state(getPersonIsHidden(people));
const setHiddenOverride = (person: PersonResponseDto, isHidden: boolean) => {
if (isHidden === person.isHidden) {
overrides.delete(person.id);
return;
}
overrides.set(person.id, isHidden);
};
let toggleButtonOptions: Record<ToggleVisibility, { icon: string; label: string }> = $derived({
[ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') },
@@ -124,7 +130,7 @@
variant="ghost"
aria-label={$t('reset_people_visibility')}
icon={mdiRestart}
onclick={handleResetVisibility}
onclick={() => overrides.clear()}
/>
<IconButton
shape="round"
@@ -142,11 +148,11 @@
<div class="flex flex-wrap gap-1 p-2 pb-8 md:px-8 mt-16">
<PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}>
{#snippet children({ person })}
{@const hidden = personIsHidden[person.id]}
{@const hidden = overrides.get(person.id) ?? person.isHidden}
<button
type="button"
class="group relative w-full h-full"
onclick={() => (personIsHidden[person.id] = !hidden)}
onclick={() => setHiddenOverride(person, !hidden)}
aria-pressed={hidden}
aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')}
>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { PersonResponseDto } from '@immich/sdk';
import { TooltipProvider } from '@immich/ui';
import ManagePeopleVisibility from './manage-people-visibility.svelte';
interface Props {
people: PersonResponseDto[];
totalPeopleCount: number;
titleId?: string | undefined;
onClose: () => void;
onUpdate: (people: PersonResponseDto[]) => void;
loadNextPage: () => void;
}
let props: Props = $props();
</script>
<TooltipProvider>
<ManagePeopleVisibility {...props} />
</TooltipProvider>

View File

@@ -0,0 +1,23 @@
import { render } from '@testing-library/svelte';
import SettingSwitch from './setting-switch.svelte';
describe('SettingSwitch component', () => {
it('links switch and subtitle ids on the switch', () => {
const { getByText } = render(SettingSwitch, {
props: {
title: 'Enable feature',
subtitle: 'Controls the feature state.',
},
});
const label = getByText('Enable feature') as HTMLLabelElement;
const subtitle = getByText('Controls the feature state.');
const subtitleId = subtitle.getAttribute('id');
const switchElement = document.querySelector(`#${label.htmlFor}`);
expect(subtitleId).not.toBeNull();
expect(label.htmlFor).not.toBe('');
expect(switchElement).not.toBeNull();
expect(switchElement?.getAttribute('aria-describedby')).toBe(subtitleId);
});
});

View File

@@ -28,14 +28,14 @@
let id: string = generateId();
let sliderId = $derived(`${id}-slider`);
let switchId = $derived(`input-${id}`);
let subtitleId = $derived(subtitle ? `${id}-subtitle` : undefined);
</script>
<div class="flex place-items-center justify-between">
<div class="me-2">
<div class="flex h-6.5 place-items-center gap-1">
<label class="font-medium text-primary text-sm" for={sliderId}>
<label class="font-medium text-primary text-sm" for={switchId}>
{title}
</label>
{#if isEdited}
@@ -54,5 +54,5 @@
{@render children?.()}
</div>
<Switch id={sliderId} bind:checked {disabled} onCheckedChange={onToggle} aria-describedby={subtitleId} />
<Switch {id} bind:checked {disabled} onCheckedChange={onToggle} aria-describedby={subtitleId} />
</div>

View File

@@ -223,6 +223,10 @@ class TransformManager implements EditToolManager {
this.dragOffset = { x: 0, y: 0 };
this.resizeSide = '';
this.imgElement = null;
if (this.cropAreaEl) {
this.cropAreaEl.style.cursor = '';
}
document.body.style.cursor = '';
this.cropAreaEl = null;
this.isDragging = false;
this.overlayEl = null;

View File

@@ -2,7 +2,7 @@ import { browser } from '$app/environment';
import { Theme } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import { theme as uiTheme, type Theme as UiTheme } from '@immich/ui';
import { onThemeChange as onUiThemeChange, theme as uiTheme, type Theme as UiTheme } from '@immich/ui';
export interface ThemeSetting {
value: Theme;
@@ -55,15 +55,14 @@ class ThemeManager {
}
#onAppInit() {
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener(
'change',
() => {
if (this.theme.system) {
this.#update('system');
}
},
{ passive: true },
);
const syncSystemTheme = () => {
this.#update(this.theme.system ? 'system' : this.theme.value);
};
syncSystemTheme();
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncSystemTheme, {
passive: true,
});
}
#update(value: Theme | 'system') {
@@ -75,6 +74,7 @@ class ThemeManager {
this.#theme.current = theme;
uiTheme.value = theme.value as unknown as UiTheme;
onUiThemeChange();
eventManager.emit('ThemeChange', theme);
}

View File

@@ -214,6 +214,7 @@
};
let people = $derived(data.people.people);
let visiblePeople = $derived(people.filter((people) => !people.isHidden));
let countVisiblePeople = $derived(searchName ? searchedPeopleLocal.length : data.people.total - data.people.hidden);
let showPeople = $derived(searchName ? searchedPeopleLocal : visiblePeople);
@@ -388,10 +389,11 @@
use:focusTrap
>
<ManagePeopleVisibility
bind:people
{people}
totalPeopleCount={data.people.total}
titleId="manage-visibility-title"
onClose={() => (selectHidden = false)}
onUpdate={(updatedPeople) => (people = updatedPeople.slice())}
{loadNextPage}
/>
</dialog>