feat: allow accounts with a quota of 0 GiB (#17413)

* Allow 0GiB quotas in user create/edit form, remove unused translations

* Make requireQuota check for null or 0

* Add unlimited quota change to the docs

* Fix user dto formatting

* Fix formating edit-user-form

* Regenerate open-api files

* Revert unnecessary i18n file changes

* Re-add newline en.json

* Resolve linting issues

* Fix formatting edit-user-form

* Re-add manifest
This commit is contained in:
Ruben Hensen 2025-04-07 16:22:56 +02:00 committed by Min Idzelis
parent be62773e31
commit 77039d87d8
11 changed files with 24 additions and 19 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -31,7 +31,7 @@ Admin can send a welcome email if the Email option is set, you can learn here ho
Admin can specify the storage quota for the user as the instance's admin; once the limit is reached, the user won't be able to upload to the instance anymore. Admin can specify the storage quota for the user as the instance's admin; once the limit is reached, the user won't be able to upload to the instance anymore.
In order to select a storage quota, click on the pencil icon and enter the storage quota in GiB. You can choose an unlimited quota using the value 0 (default). In order to select a storage quota, click on the pencil icon and enter the storage quota in GiB. You can choose an unlimited quota by leaving it empty (default).
:::tip :::tip
The system administrator can see the usage quota percentage of all users in Server Stats page. The system administrator can see the usage quota percentage of all users in Server Stats page.

View File

@ -164,7 +164,6 @@
"no_pattern_added": "No pattern added", "no_pattern_added": "No pattern added",
"note_apply_storage_label_previous_assets": "Note: To apply the Storage Label to previously uploaded assets, run the", "note_apply_storage_label_previous_assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
"note_cannot_be_changed_later": "NOTE: This cannot be changed later!", "note_cannot_be_changed_later": "NOTE: This cannot be changed later!",
"note_unlimited_quota": "Note: Enter 0 for unlimited quota",
"notification_email_from_address": "From address", "notification_email_from_address": "From address",
"notification_email_from_address_description": "Sender email address, for example: \"Immich Photo Server <noreply@example.com>\"", "notification_email_from_address_description": "Sender email address, for example: \"Immich Photo Server <noreply@example.com>\"",
"notification_email_host_description": "Host of the email server (e.g. smtp.immich.app)", "notification_email_host_description": "Host of the email server (e.g. smtp.immich.app)",
@ -929,7 +928,6 @@
"no_shared_albums_message": "Create an album to share photos and videos with people in your network", "no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"not_in_any_album": "Not in any album", "not_in_any_album": "Not in any album",
"note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the", "note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
"note_unlimited_quota": "Note: Enter 0 for unlimited quota",
"notes": "Notes", "notes": "Notes",
"notification_toggle_setting_description": "Enable email notifications", "notification_toggle_setting_description": "Enable email notifications",
"notifications": "Notifications", "notifications": "Notifications",
@ -1384,4 +1382,4 @@
"yes": "Yes", "yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links", "you_dont_have_any_shared_links": "You don't have any shared links",
"zoom_image": "Zoom Image" "zoom_image": "Zoom Image"
} }

View File

@ -36,7 +36,7 @@ class UserAdminCreateDto {
String password; String password;
/// Minimum value: 1 /// Minimum value: 0
int? quotaSizeInBytes; int? quotaSizeInBytes;
/// ///

View File

@ -45,7 +45,7 @@ class UserAdminUpdateDto {
/// ///
String? password; String? password;
/// Minimum value: 1 /// Minimum value: 0
int? quotaSizeInBytes; int? quotaSizeInBytes;
/// ///

View File

@ -13624,7 +13624,7 @@
}, },
"quotaSizeInBytes": { "quotaSizeInBytes": {
"format": "int64", "format": "int64",
"minimum": 1, "minimum": 0,
"nullable": true, "nullable": true,
"type": "integer" "type": "integer"
}, },
@ -13763,7 +13763,7 @@
}, },
"quotaSizeInBytes": { "quotaSizeInBytes": {
"format": "int64", "format": "int64",
"minimum": 1, "minimum": 0,
"nullable": true, "nullable": true,
"type": "integer" "type": "integer"
}, },

View File

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import { User, UserAdmin } from 'src/database'; import { User, UserAdmin } from 'src/database';
import { UserMetadataEntity, UserMetadataItem } from 'src/entities/user-metadata.entity'; import { UserMetadataEntity, UserMetadataItem } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
@ -77,7 +77,7 @@ export class UserAdminCreateDto {
@Optional({ nullable: true }) @Optional({ nullable: true })
@IsNumber() @IsNumber()
@IsPositive() @Min(0)
@ApiProperty({ type: 'integer', format: 'int64' }) @ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null; quotaSizeInBytes?: number | null;
@ -115,7 +115,7 @@ export class UserAdminUpdateDto {
@Optional({ nullable: true }) @Optional({ nullable: true })
@IsNumber() @IsNumber()
@IsPositive() @Min(0)
@ApiProperty({ type: 'integer', format: 'int64' }) @ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null; quotaSizeInBytes?: number | null;
} }

View File

@ -435,7 +435,7 @@ export class AssetMediaService extends BaseService {
} }
private requireQuota(auth: AuthDto, size: number) { private requireQuota(auth: AuthDto, size: number) {
if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) { if (auth.user.quotaSizeInBytes !== null && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
throw new BadRequestException('Quota has been exceeded!'); throw new BadRequestException('Quota has been exceeded!');
} }
} }

View File

@ -30,7 +30,7 @@
let quotaSize: string | undefined = $state(); let quotaSize: string | undefined = $state();
let isCreatingUser = $state(false); let isCreatingUser = $state(false);
let quotaSizeInBytes = $derived(quotaSize ? convertToBytes(Number(quotaSize), ByteUnit.GiB) : null); let quotaSizeInBytes = $derived(quotaSize === null ? null : convertToBytes(Number(quotaSize), ByteUnit.GiB));
let quotaSizeWarning = $derived( let quotaSizeWarning = $derived(
quotaSizeInBytes && userInteraction.serverInfo && quotaSizeInBytes > userInteraction.serverInfo.diskSizeRaw, quotaSizeInBytes && userInteraction.serverInfo && quotaSizeInBytes > userInteraction.serverInfo.diskSizeRaw,
); );
@ -113,7 +113,7 @@
</Field> </Field>
<Field label={$t('admin.quota_size_gib')}> <Field label={$t('admin.quota_size_gib')}>
<Input bind:value={quotaSize} type="number" min="0" /> <Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" />
{#if quotaSizeWarning} {#if quotaSizeWarning}
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText> <HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
{/if} {/if}

View File

@ -28,7 +28,7 @@
onEditSuccess, onEditSuccess,
}: Props = $props(); }: Props = $props();
let quotaSize = $state(user.quotaSizeInBytes ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : null); let quotaSize = $state(user.quotaSizeInBytes === null ? null : convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB));
const previousQutoa = user.quotaSizeInBytes; const previousQutoa = user.quotaSizeInBytes;
@ -48,7 +48,7 @@
email, email,
name, name,
storageLabel: storageLabel || '', storageLabel: storageLabel || '',
quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), ByteUnit.GiB) : null, quotaSizeInBytes: quotaSize === null ? null : convertToBytes(Number(quotaSize), ByteUnit.GiB),
}, },
}); });
@ -126,8 +126,15 @@
<p class="text-red-400 text-sm">{$t('errors.quota_higher_than_disk_size')}</p> <p class="text-red-400 text-sm">{$t('errors.quota_higher_than_disk_size')}</p>
{/if}</label {/if}</label
> >
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} /> <input
<p>{$t('admin.note_unlimited_quota')}</p> class="immich-form-input"
id="quotaSize"
name="quotaSize"
placeholder={$t('unlimited')}
type="number"
min="0"
bind:value={quotaSize}
/>
</div> </div>
<div class="my-4 flex flex-col gap-2"> <div class="my-4 flex flex-col gap-2">

View File

@ -209,7 +209,7 @@
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{immichUser.name}</td> <td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{immichUser.name}</td>
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm"> <td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
<div class="container mx-auto flex flex-wrap justify-center"> <div class="container mx-auto flex flex-wrap justify-center">
{#if immichUser.quotaSizeInBytes && immichUser.quotaSizeInBytes > 0} {#if immichUser.quotaSizeInBytes !== null && immichUser.quotaSizeInBytes >= 0}
{getByteUnitString(immichUser.quotaSizeInBytes, $locale)} {getByteUnitString(immichUser.quotaSizeInBytes, $locale)}
{:else} {:else}
<Icon path={mdiInfinity} size="16" /> <Icon path={mdiInfinity} size="16" />