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
+44
View File
@@ -167,6 +167,50 @@ export async function removeDriveMemberAdmin(
* Throws on non-2xx so the caller can branch on `405` (default
* personal) vs `409` (non-empty) when surfacing the failure.
*/
/**
* `PATCH /api/drives/{id}/quota` — admin-only shared-drive quota
* mutation (D4). `quotaBytes = null` or ≤ 0 → unlimited (the backend
* normalises 0/negative to NULL).
*
* **Refuses personal drives** with HTTP 400 — the effective cap
* comes from the owner user's `storage_quota_bytes` envelope, edit
* via `setUserQuota` (`PUT /api/admin/users/{id}/quota`) instead.
* Callers should gate the UI on `drive.kind === 'shared'` so users
* never see the refusal.
*
* **Soft-quota semantic on shrink**: a new cap below current
* `used_bytes` is accepted — the write-time gate then blocks new
* writes until the drive shrinks back under. No existing content
* is retroactively touched. Matches xfs/ext4 quota behaviour.
*
* Returns the persisted value (the backend's normalisation of the
* input) so the caller can update local state without re-fetching.
* Throws on non-2xx with the backend's error message when present.
*/
export async function updateDriveQuota(
driveId: string,
quotaBytes: number | null
): Promise<number | null> {
const res = await apiFetch(`/api/drives/${encodeURIComponent(driveId)}/quota`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ quota_bytes: quotaBytes })
});
if (!res.ok) {
let detail = '';
try {
const parsed = (await res.json()) as { error?: string; message?: string };
detail = parsed.error ?? parsed.message ?? '';
} catch {
/* response body wasn't JSON */
}
throw new Error(detail || `update drive quota failed: ${res.status}`);
}
const body = (await res.json()) as { quota_bytes: number | null };
return body.quota_bytes;
}
export async function deleteDriveAdmin(driveId: string): Promise<void> {
const res = await apiFetch(`/api/admin/drives/${encodeURIComponent(driveId)}`, {
method: 'DELETE',
@@ -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>
+4
View File
@@ -250,6 +250,10 @@ export const OxiIcons: Record<string, IconEntry> = {
512,
"M371.7 43.1C360.1 32 343 28.9 328.3 35.2S304 56 304 72l0 136.3-172.3-165.1C120.1 32 103 28.9 88.3 35.2S64 56 64 72l0 368c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9L304 303.7 304 440c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9l192-184c7.9-7.5 12.3-18 12.3-28.9s-4.5-21.3-12.3-28.9l-192-184z"
],
"gauge-simple-high": [
512,
"M0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm320 96c0-15.9-5.8-30.4-15.3-41.6l76.6-147.4c6.1-11.8 1.5-26.3-10.2-32.4s-26.2-1.5-32.4 10.2L262.1 288.3c-2-.2-4-.3-6.1-.3c-35.3 0-64 28.7-64 64s28.7 64 64 64s64-28.7 64-64z"
],
"github": [
496,
"M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"