Compare commits

..

4 Commits

Author SHA1 Message Date
Mees Frensel
5404ebe034 fix(server): scoped permissions for more endpoints 2026-01-22 16:38:22 +01:00
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
17 changed files with 133 additions and 240 deletions

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');
@@ -166,6 +168,7 @@ class Permission {
static const queueJobPeriodRead = Permission._(r'queueJob.read');
static const queueJobPeriodUpdate = Permission._(r'queueJob.update');
static const queueJobPeriodDelete = Permission._(r'queueJob.delete');
static const viewPeriodFolder = Permission._(r'view.folder');
static const workflowPeriodCreate = Permission._(r'workflow.create');
static const workflowPeriodRead = Permission._(r'workflow.read');
static const workflowPeriodUpdate = Permission._(r'workflow.update');
@@ -238,6 +241,8 @@ class Permission {
timelinePeriodRead,
timelinePeriodDownload,
maintenance,
mapPeriodRead,
mapPeriodSearch,
memoryPeriodCreate,
memoryPeriodRead,
memoryPeriodUpdate,
@@ -322,6 +327,7 @@ class Permission {
queueJobPeriodRead,
queueJobPeriodUpdate,
queueJobPeriodDelete,
viewPeriodFolder,
workflowPeriodCreate,
workflowPeriodRead,
workflowPeriodUpdate,
@@ -429,6 +435,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;
@@ -513,6 +521,7 @@ class PermissionTypeTransformer {
case r'queueJob.read': return Permission.queueJobPeriodRead;
case r'queueJob.update': return Permission.queueJobPeriodUpdate;
case r'queueJob.delete': return Permission.queueJobPeriodDelete;
case r'view.folder': return Permission.viewPeriodFolder;
case r'workflow.create': return Permission.workflowPeriodCreate;
case r'workflow.read': return Permission.workflowPeriodRead;
case r'workflow.update': return Permission.workflowPeriodUpdate;

View File

@@ -3173,6 +3173,7 @@
"state": "Stable"
}
],
"x-immich-permission": "asset.upload",
"x-immich-state": "Stable"
}
},
@@ -3225,6 +3226,7 @@
"state": "Stable"
}
],
"x-immich-permission": "job.create",
"x-immich-state": "Stable"
}
},
@@ -6305,6 +6307,7 @@
"state": "Stable"
}
],
"x-immich-permission": "map.read",
"x-immich-state": "Stable"
}
},
@@ -6376,6 +6379,7 @@
"state": "Stable"
}
],
"x-immich-permission": "map.search",
"x-immich-state": "Stable"
}
},
@@ -14616,6 +14620,7 @@
"state": "Stable"
}
],
"x-immich-permission": "view.folder",
"x-immich-state": "Stable"
}
},
@@ -14668,6 +14673,7 @@
"state": "Stable"
}
],
"x-immich-permission": "view.folder",
"x-immich-state": "Stable"
}
},
@@ -18966,6 +18972,8 @@
"timeline.read",
"timeline.download",
"maintenance",
"map.read",
"map.search",
"memory.create",
"memory.read",
"memory.update",
@@ -19050,6 +19058,7 @@
"queueJob.read",
"queueJob.update",
"queueJob.delete",
"view.folder",
"workflow.create",
"workflow.read",
"workflow.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",
@@ -5618,6 +5620,7 @@ export enum Permission {
QueueJobRead = "queueJob.read",
QueueJobUpdate = "queueJob.update",
QueueJobDelete = "queueJob.delete",
ViewFolder = "view.folder",
WorkflowCreate = "workflow.create",
WorkflowRead = "workflow.read",
WorkflowUpdate = "workflow.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

@@ -15,7 +15,7 @@ import {
} from 'src/enum';
import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types';
export type SystemConfig = {
export interface SystemConfig {
backup: {
database: {
enabled: boolean;
@@ -187,7 +187,7 @@ export type SystemConfig = {
user: {
deleteDelay: number;
};
};
}
export type MachineLearningConfig = SystemConfig['machineLearning'];

View File

@@ -202,7 +202,7 @@ export class AssetMediaController {
}
@Post('exist')
@Authenticated()
@Authenticated({ permission: Permission.AssetUpload })
@Endpoint({
summary: 'Check existing assets',
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',

View File

@@ -66,7 +66,7 @@ export class AssetController {
}
@Post('jobs')
@Authenticated()
@Authenticated({ permission: Permission.JobCreate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Run an asset job',

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

@@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiTag } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { ViewService } from 'src/services/view.service';
@@ -13,7 +13,7 @@ export class ViewController {
constructor(private service: ViewService) {}
@Get('folder/unique-paths')
@Authenticated()
@Authenticated({ permission: Permission.ViewFolder })
@Endpoint({
summary: 'Retrieve unique paths',
description: 'Retrieve a list of unique folder paths from asset original paths.',
@@ -24,7 +24,7 @@ export class ViewController {
}
@Get('folder')
@Authenticated()
@Authenticated({ permission: Permission.ViewFolder })
@Endpoint({
summary: 'Retrieve assets by original path',
description: 'Retrieve assets that are children of a specific folder.',

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',
@@ -267,6 +270,8 @@ export enum Permission {
QueueJobUpdate = 'queueJob.update',
QueueJobDelete = 'queueJob.delete',
ViewFolder = 'view.folder',
WorkflowCreate = 'workflow.create',
WorkflowRead = 'workflow.read',
WorkflowUpdate = 'workflow.update',

View File

@@ -387,7 +387,7 @@ describe(MetadataService.name, () => {
it('should extract tags from TagsList', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
mockReadTags({ TagsList: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -398,7 +398,7 @@ describe(MetadataService.name, () => {
it('should extract hierarchy from TagsList', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
mockReadTags({ TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
@@ -419,7 +419,7 @@ describe(MetadataService.name, () => {
it('should extract tags from Keywords as a string', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
mockReadTags({ Keywords: 'Parent' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -430,7 +430,7 @@ describe(MetadataService.name, () => {
it('should extract tags from Keywords as a list', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
mockReadTags({ Keywords: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -441,10 +441,7 @@ describe(MetadataService.name, () => {
it('should extract tags from Keywords as a list with a number', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
});
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
mockReadTags({ Keywords: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -456,7 +453,7 @@ describe(MetadataService.name, () => {
it('should extract hierarchal tags from Keywords', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
mockReadTags({ Keywords: 'Parent/Child' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -476,10 +473,7 @@ describe(MetadataService.name, () => {
it('should ignore Keywords when TagsList is present', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
exifInfo: factory.exif({ tags: ['Parent/Child', 'Child'] }),
});
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Child'] } } as any);
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -499,10 +493,7 @@ describe(MetadataService.name, () => {
it('should extract hierarchy from HierarchicalSubject', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
exifInfo: factory.exif({ tags: ['Parent/Child', 'TagA'] }),
});
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'TagA'] } } as any);
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
@@ -524,10 +515,7 @@ describe(MetadataService.name, () => {
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
});
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -539,7 +527,7 @@ describe(MetadataService.name, () => {
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Mom|Dad'] }) });
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Mom|Dad'] } } as any);
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
@@ -554,10 +542,7 @@ describe(MetadataService.name, () => {
it('should ignore HierarchicalSubject when TagsList is present', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
exifInfo: factory.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }),
});
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Parent2/Child2'] } } as any);
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);

View File

@@ -4,7 +4,6 @@ import { JobStatus } from 'src/enum';
import { TagService } from 'src/services/tag.service';
import { authStub } from 'test/fixtures/auth.stub';
import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(TagService.name, () => {
@@ -192,10 +191,7 @@ describe(TagService.name, () => {
it('should upsert records', async () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
tags: [factory.tag({ value: 'tag-1' }), factory.tag({ value: 'tag-2' })],
});
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] } as any);
mocks.tag.upsertAssetIds.mockResolvedValue([
{ tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' },
@@ -246,10 +242,7 @@ describe(TagService.name, () => {
mocks.tag.get.mockResolvedValue(tagStub.tag);
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
mocks.tag.addAssetIds.mockResolvedValue();
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
tags: [factory.tag({ value: 'tag-1' })],
});
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }] } as any);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
await expect(

View File

@@ -23,39 +23,34 @@ import {
VideoCodec,
} from 'src/enum';
export type DeepPartial<T> =
T extends Record<string, unknown>
? { [K in keyof T]?: DeepPartial<T[K]> }
: T extends Array<infer R>
? DeepPartial<R>[]
: T;
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
export type FullsizeImageOptions = {
export interface FullsizeImageOptions {
format: ImageFormat;
quality: number;
enabled: boolean;
};
}
export type ImageOptions = {
export interface ImageOptions {
format: ImageFormat;
quality: number;
size: number;
};
}
export type RawImageInfo = {
export interface RawImageInfo {
width: number;
height: number;
channels: 1 | 2 | 3 | 4;
};
}
type DecodeImageOptions = {
interface DecodeImageOptions {
colorspace: string;
processInvalidImages: boolean;
raw?: RawImageInfo;
edits?: AssetEditActionItem[];
};
}
export interface DecodeToBufferOptions extends DecodeImageOptions {
size?: number;
@@ -509,7 +504,7 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
[SystemMetadataKey.MemoriesState]: MemoriesState;
}
export type UserPreferences = {
export interface UserPreferences {
albums: {
defaultAssetOrder: AssetOrder;
};
@@ -552,7 +547,7 @@ export type UserPreferences = {
cast: {
gCastEnabled: boolean;
};
};
}
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
key: T;

View File

@@ -1,7 +1,6 @@
import {
Activity,
ApiKey,
AssetFace,
AssetFile,
AuthApiKey,
AuthSharedLink,
@@ -10,16 +9,12 @@ import {
Library,
Memory,
Partner,
Person,
Session,
Stack,
Tag,
User,
UserAdmin,
} from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto';
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
import {
AssetFileType,
@@ -28,11 +23,10 @@ import {
AssetVisibility,
MemoryType,
Permission,
SourceType,
UserMetadataKey,
UserStatus,
} from 'src/enum';
import { DeepPartial, OnThisDayData, UserMetadataItem } from 'src/types';
import { OnThisDayData, UserMetadataItem } from 'src/types';
import { v4, v7 } from 'uuid';
export const newUuid = () => v4();
@@ -166,18 +160,11 @@ const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
...dto,
});
const stackFactory = ({ owner, assets, ...stack }: DeepPartial<Stack> = {}): Stack => {
const ownerId = newUuid();
return {
id: newUuid(),
primaryAssetId: assets?.[0].id ?? newUuid(),
ownerId,
owner: userFactory(owner ?? { id: ownerId }),
assets: assets?.map((asset) => assetFactory(asset)) ?? [],
...stack,
};
};
const stackFactory = () => ({
id: newUuid(),
ownerId: newUuid(),
primaryAssetId: newUuid(),
});
const userFactory = (user: Partial<User> = {}) => ({
id: newUuid(),
@@ -236,43 +223,39 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
};
};
const assetFactory = (
asset: Omit<DeepPartial<MapAsset>, 'exifInfo' | 'owner' | 'stack' | 'tags' | 'faces' | 'files' | 'edits'> = {},
) => {
return {
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `/data/12/34/IMG_123.jpg`,
ownerId: newUuid(),
stackId: null,
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
isEdited: false,
...asset,
};
};
const assetFactory = (asset: Partial<MapAsset> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `/data/12/34/IMG_123.jpg`,
ownerId: newUuid(),
stackId: null,
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
isEdited: false,
...asset,
});
const activityFactory = (activity: Partial<Activity> = {}) => {
const userId = activity.userId || newUuid();
@@ -408,102 +391,6 @@ const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
...file,
});
const exifFactory = (exif: Partial<Exif> = {}) => ({
assetId: newUuid(),
autoStackId: null,
bitsPerSample: null,
city: 'Austin',
colorspace: null,
country: 'United States of America',
dateTimeOriginal: newDate(),
description: '',
exifImageHeight: 420,
exifImageWidth: 42,
exposureTime: null,
fileSizeInByte: 69,
fNumber: 1.7,
focalLength: 4.38,
fps: null,
iso: 947,
latitude: 30.267_334_570_570_195,
longitude: -97.789_833_534_282_07,
lensModel: null,
livePhotoCID: null,
make: 'Google',
model: 'Pixel 7',
modifyDate: newDate(),
orientation: '1',
profileDescription: null,
projectionType: null,
rating: 4,
state: 'Texas',
tags: ['parent/child'],
timeZone: 'UTC-6',
...exif,
});
const tagFactory = (tag: Partial<Tag>): Tag => ({
id: newUuid(),
color: null,
createdAt: newDate(),
parentId: null,
updatedAt: newDate(),
value: `tag-${newUuid()}`,
...tag,
});
const faceFactory = ({ person, ...face }: DeepPartial<AssetFace> = {}): AssetFace => ({
assetId: newUuid(),
boundingBoxX1: 1,
boundingBoxX2: 2,
boundingBoxY1: 1,
boundingBoxY2: 2,
deletedAt: null,
id: newUuid(),
imageHeight: 420,
imageWidth: 42,
isVisible: true,
personId: null,
sourceType: SourceType.MachineLearning,
updatedAt: newDate(),
updateId: newUuidV7(),
person: person === null ? null : personFactory(person),
...face,
});
const assetEditFactory = (edit?: Partial<AssetEditActionItem>): AssetEditActionItem => {
switch (edit?.action) {
case AssetEditAction.Crop: {
return { action: AssetEditAction.Crop, parameters: { height: 42, width: 42, x: 0, y: 10 }, ...edit };
}
case AssetEditAction.Mirror: {
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal }, ...edit };
}
case AssetEditAction.Rotate: {
return { action: AssetEditAction.Rotate, parameters: { angle: 90 }, ...edit };
}
default: {
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } };
}
}
};
const personFactory = (person?: Partial<Person>): Person => ({
birthDate: newDate(),
color: null,
createdAt: newDate(),
faceAssetId: null,
id: newUuid(),
isFavorite: false,
isHidden: false,
name: 'person',
ownerId: newUuid(),
thumbnailPath: '/path/to/person/thumbnail.jpg',
updatedAt: newDate(),
updateId: newUuidV7(),
...person,
});
export const factory = {
activity: activityFactory,
apiKey: apiKeyFactory,
@@ -525,11 +412,6 @@ export const factory = {
jobAssets: {
sidecarWrite: assetSidecarWriteFactory,
},
exif: exifFactory,
face: faceFactory,
person: personFactory,
assetEdit: assetEditFactory,
tag: tagFactory,
uuid: newUuid,
date: newDate,
responses: {

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}