Compare commits

...

4 Commits

Author SHA1 Message Date
shenlong-tanwen
5ccc05feeb fix: foreground cloud sync 2026-01-22 20:37:44 +05:30
Alex
7cbfc12e0d chore: use context menu for user table (#25428)
* chore: use context menu for user table

* chore: reorder columns

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-01-22 07:44:08 -05:00
Mees Frensel
c320146538 fix: add scoped API permissions to map endpoints (#25423) 2026-01-22 07:43:29 -05:00
solluh
3304c8efd8 docs: update README_de_DE.md (#25443) 2026-01-22 12:55:44 +01:00
16 changed files with 70 additions and 95 deletions

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart' as m;
import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart';
@@ -23,13 +22,8 @@ class BackgroundSyncManager {
final SyncCallback? onHashingComplete;
final SyncErrorCallback? onHashingError;
final SyncCallback? onCloudIdSyncStart;
final SyncCallback? onCloudIdSyncComplete;
final SyncErrorCallback? onCloudIdSyncError;
Cancelable<bool?>? _syncTask;
Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _cloudIdSyncTask;
Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _linkedAlbumSyncTask;
Cancelable<void>? _hashTask;
@@ -44,9 +38,6 @@ class BackgroundSyncManager {
this.onHashingStart,
this.onHashingComplete,
this.onHashingError,
this.onCloudIdSyncStart,
this.onCloudIdSyncComplete,
this.onCloudIdSyncError,
});
Future<void> cancel() async {
@@ -64,12 +55,6 @@ class BackgroundSyncManager {
_syncWebsocketTask?.cancel();
_syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null;
if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future);
}
@@ -216,25 +201,6 @@ class BackgroundSyncManager {
_linkedAlbumSyncTask = null;
});
}
Future<void> syncCloudIds() {
if (_cloudIdSyncTask != null) {
return _cloudIdSyncTask!.future;
}
onCloudIdSyncStart?.call();
_cloudIdSyncTask = runInIsolateGentle(computation: m.syncCloudIds);
return _cloudIdSyncTask!
.whenComplete(() {
onCloudIdSyncComplete?.call();
_cloudIdSyncTask = null;
})
.catchError((error) {
onCloudIdSyncError?.call(error.toString());
_cloudIdSyncTask = null;
});
}
}
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(

View File

@@ -10,14 +10,13 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart' hide AssetVisibility;
Future<void> syncCloudIds(ProviderContainer ref) async {
Future<void> syncCloudIds(Ref ref) async {
if (!CurrentPlatform.isIOS) {
return;
}
@@ -35,14 +34,6 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
}
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
try {
await ref.read(syncStreamServiceProvider).sync();
} catch (e, s) {
logger.fine('Failed to complete remote sync before cloudId migration.', e, s);
return;
}
// Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {

View File

@@ -75,7 +75,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
_resumeBackup(backupProvider);
}),
_resumeBackup(backupProvider),
backgroundManager.syncCloudIds(),
]);
} else {
await backgroundManager.hashAssets();

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
@@ -156,11 +157,11 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
]);
if (syncSuccess) {
await Future.wait([
_safeRun(syncCloudIds(_ref), "syncCloudIds"),
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
_resumeBackup();
}),
_resumeBackup(),
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
]);
} else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets");

View File

@@ -1,19 +1,23 @@
import 'dart:async';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/hash.dart';
@@ -172,6 +176,15 @@ class AuthNotifier extends StateNotifier<AuthState> {
isAdmin: user.isAdmin,
);
// TODO: Temporarily run cloud Id sync here until we have a better place to do it
unawaited(
_ref.read(backgroundSyncProvider).syncRemote().then((success) {
if (success) {
return syncCloudIds(_ref);
}
}),
);
return true;
}

View File

@@ -28,9 +28,6 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
onHashingStart: syncStatusNotifier.startHashJob,
onHashingComplete: syncStatusNotifier.completeHashJob,
onHashingError: syncStatusNotifier.errorHashJob,
onCloudIdSyncStart: syncStatusNotifier.startCloudIdSync,
onCloudIdSyncComplete: syncStatusNotifier.completeCloudIdSync,
onCloudIdSyncError: syncStatusNotifier.errorCloudIdSync,
);
ref.onDispose(manager.cancel);
return manager;

View File

@@ -21,7 +21,6 @@ class SyncStatusState {
final SyncStatus remoteSyncStatus;
final SyncStatus localSyncStatus;
final SyncStatus hashJobStatus;
final SyncStatus cloudIdSyncStatus;
final String? errorMessage;
@@ -29,7 +28,6 @@ class SyncStatusState {
this.remoteSyncStatus = SyncStatus.idle,
this.localSyncStatus = SyncStatus.idle,
this.hashJobStatus = SyncStatus.idle,
this.cloudIdSyncStatus = SyncStatus.idle,
this.errorMessage,
});
@@ -37,14 +35,12 @@ class SyncStatusState {
SyncStatus? remoteSyncStatus,
SyncStatus? localSyncStatus,
SyncStatus? hashJobStatus,
SyncStatus? cloudIdSyncStatus,
String? errorMessage,
}) {
return SyncStatusState(
remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus,
localSyncStatus: localSyncStatus ?? this.localSyncStatus,
hashJobStatus: hashJobStatus ?? this.hashJobStatus,
cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@@ -52,7 +48,6 @@ class SyncStatusState {
bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing;
bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing;
bool get isHashing => hashJobStatus == SyncStatus.syncing;
bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing;
@override
bool operator ==(Object other) {
@@ -61,12 +56,11 @@ class SyncStatusState {
other.remoteSyncStatus == remoteSyncStatus &&
other.localSyncStatus == localSyncStatus &&
other.hashJobStatus == hashJobStatus &&
other.cloudIdSyncStatus == cloudIdSyncStatus &&
other.errorMessage == errorMessage;
}
@override
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage);
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, errorMessage);
}
class SyncStatusNotifier extends Notifier<SyncStatusState> {
@@ -77,7 +71,6 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
remoteSyncStatus: SyncStatus.idle,
localSyncStatus: SyncStatus.idle,
hashJobStatus: SyncStatus.idle,
cloudIdSyncStatus: SyncStatus.idle,
);
}
@@ -116,18 +109,6 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
void startHashJob() => setHashJobStatus(SyncStatus.syncing);
void completeHashJob() => setHashJobStatus(SyncStatus.success);
void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error);
///
/// Cloud ID Sync Job
///
void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) {
state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
}
void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing);
void completeCloudIdSync() => setCloudIdSyncStatus(SyncStatus.success);
void errorCloudIdSync(String error) => setCloudIdSyncStatus(SyncStatus.error, error);
}
final syncStatusProvider = NotifierProvider<SyncStatusNotifier, SyncStatusState>(SyncStatusNotifier.new);

View File

@@ -82,6 +82,8 @@ class Permission {
static const timelinePeriodRead = Permission._(r'timeline.read');
static const timelinePeriodDownload = Permission._(r'timeline.download');
static const maintenance = Permission._(r'maintenance');
static const mapPeriodRead = Permission._(r'map.read');
static const mapPeriodSearch = Permission._(r'map.search');
static const memoryPeriodCreate = Permission._(r'memory.create');
static const memoryPeriodRead = Permission._(r'memory.read');
static const memoryPeriodUpdate = Permission._(r'memory.update');
@@ -238,6 +240,8 @@ class Permission {
timelinePeriodRead,
timelinePeriodDownload,
maintenance,
mapPeriodRead,
mapPeriodSearch,
memoryPeriodCreate,
memoryPeriodRead,
memoryPeriodUpdate,
@@ -429,6 +433,8 @@ class PermissionTypeTransformer {
case r'timeline.read': return Permission.timelinePeriodRead;
case r'timeline.download': return Permission.timelinePeriodDownload;
case r'maintenance': return Permission.maintenance;
case r'map.read': return Permission.mapPeriodRead;
case r'map.search': return Permission.mapPeriodSearch;
case r'memory.create': return Permission.memoryPeriodCreate;
case r'memory.read': return Permission.memoryPeriodRead;
case r'memory.update': return Permission.memoryPeriodUpdate;

View File

@@ -6305,6 +6305,7 @@
"state": "Stable"
}
],
"x-immich-permission": "map.read",
"x-immich-state": "Stable"
}
},
@@ -6376,6 +6377,7 @@
"state": "Stable"
}
],
"x-immich-permission": "map.search",
"x-immich-state": "Stable"
}
},
@@ -18966,6 +18968,8 @@
"timeline.read",
"timeline.download",
"maintenance",
"map.read",
"map.search",
"memory.create",
"memory.read",
"memory.update",

View File

@@ -5534,6 +5534,8 @@ export enum Permission {
TimelineRead = "timeline.read",
TimelineDownload = "timeline.download",
Maintenance = "maintenance",
MapRead = "map.read",
MapSearch = "map.search",
MemoryCreate = "memory.create",
MemoryRead = "memory.read",
MemoryUpdate = "memory.update",

View File

@@ -38,11 +38,6 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## Warnung
- ⚠️ Das Projekt befindet sich in **sehr aktiver** Entwicklung.
- ⚠️ Gehe von möglichen Fehlern und von Änderungen mit Breaking-Changes aus.
- ⚠️ **Nutze die App auf keinen Fall als einziges Speichermedium für deine Fotos und Videos.**
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
> [!NOTE]
@@ -62,7 +57,7 @@
## Demo
Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben.
Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Smartphone-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben.
### Login Daten
@@ -93,7 +88,7 @@ Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App
| LivePhoto/MotionPhoto Sicherung und Wiedergabe | Ja | Ja |
| Unterstützung für 360-Grad-Bilder | Nein | Ja |
| Benutzerdefinierte Speicherstruktur | Ja | Ja |
| Öffentliches Teilen | Nein | Ja |
| Öffentliches Teilen | Ja | Ja |
| Archiv und Favoriten | Ja | Ja |
| Globale Karte | Ja | Ja |
| Partnerfreigabe (Teilen) | Ja | Ja |
@@ -103,7 +98,7 @@ Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App
| Schreibgeschützte Gallerie | Ja | Ja |
| Gestapelte Bilder | Ja | Ja |
| Tags | Nein | Ja |
| Ordner-Ansicht | Nein | Ja |
| Ordner-Ansicht | Ja | Ja |
## Übersetzungen

View File

@@ -8,7 +8,7 @@ import {
MapReverseGeocodeDto,
MapReverseGeocodeResponseDto,
} from 'src/dtos/map.dto';
import { ApiTag } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MapService } from 'src/services/map.service';
@@ -18,7 +18,7 @@ export class MapController {
constructor(private service: MapService) {}
@Get('markers')
@Authenticated()
@Authenticated({ permission: Permission.MapRead })
@Endpoint({
summary: 'Retrieve map markers',
description: 'Retrieve a list of latitude and longitude coordinates for every asset with location data.',
@@ -28,8 +28,8 @@ export class MapController {
return this.service.getMapMarkers(auth, options);
}
@Authenticated()
@Get('reverse-geocode')
@Authenticated({ permission: Permission.MapSearch })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Reverse geocode coordinates',

View File

@@ -160,6 +160,9 @@ export enum Permission {
Maintenance = 'maintenance',
MapRead = 'map.read',
MapSearch = 'map.search',
MemoryCreate = 'memory.create',
MemoryRead = 'memory.read',
MemoryUpdate = 'memory.update',

View File

@@ -23,6 +23,7 @@ import {
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiDeleteRestore,
mdiInformationOutline,
mdiLockReset,
mdiLockSmart,
mdiPencilOutline,
@@ -46,6 +47,12 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
};
export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => {
const Detail: ActionItem = {
icon: mdiInformationOutline,
title: $t('details'),
onAction: () => goto(Route.viewUser(user)),
};
const Update: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
@@ -92,7 +99,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
onAction: () => handleResetPinCodeUserAdmin(user),
};
return { Update, Delete, Restore, ResetPassword, ResetPinCode };
return { Detail, Update, Delete, Restore, ResetPassword, ResetPinCode };
};
export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {

View File

@@ -1,15 +1,18 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
import { Route } from '$lib/route';
import { getUserAdminActions, getUserAdminsActions } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import {
Button,
CommandPaletteDefaultProvider,
Container,
ContextMenuButton,
Icon,
Link,
MenuItemType,
Table,
TableBody,
TableCell,
@@ -46,11 +49,16 @@
const { Create } = $derived(getUserAdminsActions($t));
const getActionsForUser = (user: UserAdminResponseDto) => {
const { Detail, Update, Delete, ResetPassword, ResetPinCode } = getUserAdminActions($t, user);
return [Detail, Update, ResetPassword, ResetPinCode, MenuItemType.Divider, Delete];
};
const classes = {
column1: 'w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12',
column2: 'hidden sm:block w-3/12',
column3: 'hidden xl:block w-3/12 2xl:w-2/12',
column4: 'w-4/12 lg:w-3/12 xl:w-2/12',
column1: 'w-8/12 md:w-5/12 lg:w-4/12',
column2: 'hidden md:block md:w-5/12 lg:w-4/12',
column3: 'hidden lg:block lg:w-2/12',
column4: 'w-4/12 md:w-2/12 flex justify-end',
};
</script>
@@ -68,16 +76,18 @@
<Container center size="large">
<Table class="mt-4" striped spacing="small" size="small">
<TableHeader>
<TableHeading class={classes.column1}>{$t('email')}</TableHeading>
<TableHeading class={classes.column2}>{$t('name')}</TableHeading>
<TableHeading class={classes.column1}>{$t('name')}</TableHeading>
<TableHeading class={classes.column2}>{$t('email')}</TableHeading>
<TableHeading class={classes.column3}>{$t('has_quota')}</TableHeading>
</TableHeader>
<TableBody>
{#each users as user (user.id)}
<TableRow color={user.deletedAt ? 'danger' : undefined}>
<TableCell class={classes.column1}>{user.email}</TableCell>
<TableCell class={classes.column2}>{user.name}</TableCell>
<TableCell class={classes.column1}>
<Link href={Route.viewUser(user)}>{user.name}</Link>
</TableCell>
<TableCell class={classes.column2}>{user.email}</TableCell>
<TableCell class={classes.column3}>
<div class="container mx-auto flex flex-wrap justify-center">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
@@ -88,7 +98,7 @@
</div>
</TableCell>
<TableCell class={classes.column4}>
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForUser(user)} />
</TableCell>
</TableRow>
{/each}

View File

@@ -198,8 +198,8 @@
})}
>
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
<div class="mt-4 h-1.75 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-1.75 rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
</div>
</div>
{/if}