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:
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user