feat(drive): add quota update handler

per today: admin only can update quota
    shared drive can have quota updated (personal drive's quota belong to user's quota)
This commit is contained in:
Edouard Vanbelle
2026-07-19 14:21:44 +02:00
parent 0133450d40
commit 05dfda9aa3
26 changed files with 1547 additions and 177 deletions
@@ -0,0 +1,159 @@
<script lang="ts">
// Shared quota edit modal for the two admin surfaces that mutate a
// storage cap:
//
// * User envelope — `PUT /api/admin/users/{id}/quota` (0 on the
// wire = unlimited, per the backend's `check_storage_quota`
// `quota <= 0` short-circuit).
// * Shared drive — `PATCH /api/drives/{id}/quota` (`null` on
// the wire = unlimited; the service also normalises 0/negative
// to unlimited defensively).
//
// The wire encodings differ; the UX should not. This component
// reuses the pre-refactor user-modal layout (single input + unit
// dropdown + "0 for unlimited" hint) so the two surfaces share
// their i18n keys (`admin.quota_for`, `admin.quota_label`,
// `admin.quota_unlimited_hint`, `common.cancel`, `common.save`).
// The save callback receives an explicit `unlimited` boolean and
// a positive `bytes` count — the caller encodes for its own
// endpoint (0 for users, null for drives) so the magic value
// never spreads into the UI or shared component.
import { t } from '$lib/i18n/index.svelte';
import Modal from '$lib/components/Modal.svelte';
interface Props {
open: boolean;
/** Modal title (e.g. "Edit quota"). */
title: string;
/**
* Human-readable label of the subject whose quota is being
* edited — a username, or a drive name. Rendered as
* "Quota for **{subjectName}**".
*/
subjectName: string;
/**
* Current cap in bytes. `null` or `0` both render as `0`
* in the input (which the "0 = unlimited" hint labels as
* unlimited) — matches both endpoint conventions.
*/
initialBytes: number | null;
/** Disables inputs + swaps the primary button to "Saving…". */
busy?: boolean;
error?: string | null;
onclose: () => void;
/**
* Called on Save. `unlimited: true` (input was `0` or
* negative) → the caller should send whatever "unlimited"
* means to its endpoint (0 for users, `null` for drives).
* `unlimited: false` → `bytes` is the caller's positive
* integer to persist verbatim.
*/
onsave: (result: { unlimited: boolean; bytes: number }) => void;
/** data-testid prefix so both call-sites get stable selectors. */
testIdPrefix?: string;
}
const QUOTA_UNITS = [
{ value: 1024 ** 2, label: 'MB' },
{ value: 1024 ** 3, label: 'GB' },
{ value: 1024 ** 4, label: 'TB' }
] as const;
let {
open,
title,
subjectName,
initialBytes,
busy = false,
error = null,
onclose,
onsave,
testIdPrefix = 'quota'
}: Props = $props();
// Local draft state — the parent owns `initialBytes` and can pass
// a new value on every open; `$effect` resets the draft whenever
// `open` transitions to true so a re-open shows fresh state
// instead of the last edit.
let value = $state(0);
let unit = $state<number>(1024 ** 3);
let wasOpen = $state(false);
$effect(() => {
if (open && !wasOpen) {
const bytes = initialBytes ?? 0;
// Pick the largest unit that yields a value >= 1 so the
// number stays readable; fall back to GB for the
// unlimited case so the form is filled with a sane default.
if (bytes >= 1024 ** 4) {
unit = 1024 ** 4;
} else if (bytes >= 1024 ** 3 || bytes === 0) {
unit = 1024 ** 3;
} else {
unit = 1024 ** 2;
}
value = bytes > 0 ? Math.round((bytes / unit) * 10) / 10 : 0;
}
wasOpen = open;
});
function submit(e: Event) {
e.preventDefault();
if (value <= 0) {
onsave({ unlimited: true, bytes: 0 });
} else {
onsave({ unlimited: false, bytes: Math.round(value * unit) });
}
}
</script>
<Modal {open} {title} {onclose}>
<form id="quota-editor-form" class="form" data-testid={`${testIdPrefix}-form`} onsubmit={submit}>
<p class="muted">
{t('admin.quota_for', 'Quota for')} <strong>{subjectName}</strong>
</p>
<label>
<span>{t('admin.quota_label', 'Quota')}</span>
<div class="quota-input">
<input
type="number"
data-testid={`${testIdPrefix}-value-input`}
min="0"
step="0.1"
bind:value
disabled={busy}
/>
<select bind:value={unit} data-testid={`${testIdPrefix}-unit-select`} disabled={busy}>
{#each QUOTA_UNITS as u (u.label)}
<option value={u.value}>{u.label}</option>
{/each}
</select>
</div>
<span class="muted">{t('admin.quota_unlimited_hint', 'Set to 0 for unlimited')}</span>
</label>
{#if error}
<p class="status--error">{error}</p>
{/if}
</form>
{#snippet footer()}
<button
class="btn"
type="button"
data-testid={`${testIdPrefix}-cancel-btn`}
onclick={onclose}
disabled={busy}
>
{t('common.cancel', 'Cancel')}
</button>
<button
class="btn btn--primary"
type="submit"
form="quota-editor-form"
data-testid={`${testIdPrefix}-save-btn`}
disabled={busy}
>
{busy ? t('common.saving', 'Saving…') : t('common.save', 'Save')}
</button>
{/snippet}
</Modal>