Merge upstream/main into feat/external-file-mounts
Resolve conflicts between the external-file-mounts feature and upstream's D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move gates, resource-access hook, folder-cascade lifecycle hook). Key resolutions: - FolderService::new now takes (repo, authz, file_lifecycle, mount_router); all callers + DI updated. - FileRetrievalService / FileManagementService keep both the mount_router and the new resource_access_hook / drive_repo / storage_usage wiring. - list_files_batch_with_perms: adapt the mount branch from offset- to keyset (after_name) pagination, mirroring paginate_mount_entries. - download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>` signature, retain the mount-download branch. - Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by (the mount owner) — the fields the frontend now uses for owner display. - admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab. - Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
@@ -157,6 +157,79 @@ export async function removeDriveMemberAdmin(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /api/admin/drives/{id}` — admin-only drive delete (D3b).
|
||||
*
|
||||
* Bypasses the per-drive `Manage` check (the admin guard at the route
|
||||
* edge is the access control). The default-personal-drive guard and
|
||||
* the "drive must be empty" check still fire server-side — admins
|
||||
* can't accidentally wipe a populated drive or a user's home folder.
|
||||
* 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',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
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 */
|
||||
}
|
||||
// 405 / 409 carry actionable messages from the backend; bubble them.
|
||||
throw new Error(detail || `delete drive failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Users ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdminUsersPage {
|
||||
@@ -170,6 +243,48 @@ export function listUsers(limit: number, offset: number): Promise<AdminUsersPage
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-scoped single-user lookup — `GET /api/admin/users/{id}`.
|
||||
* Returns the full `User` DTO including `storage_quota_bytes` +
|
||||
* `storage_used_bytes` which the non-admin `/api/users/{id}`
|
||||
* response omits for privacy.
|
||||
*
|
||||
* Result promises are cached per id at module scope so multiple
|
||||
* callers for the same user (e.g. the admin drives table with N
|
||||
* personal drives owned by the same person) share one fetch. A
|
||||
* `null` result is cached too so a missing user isn't re-fetched
|
||||
* on every render.
|
||||
*
|
||||
* The cache is process-lifetime; a page navigation away and back
|
||||
* still sees the cached value. Callers that need to refresh (e.g.
|
||||
* after `setUserQuota`) should call `invalidateAdminUserCache`.
|
||||
*/
|
||||
const adminUserCache = new Map<string, Promise<User | null>>();
|
||||
|
||||
export function getUserAdmin(id: string): Promise<User | null> {
|
||||
const hit = adminUserCache.get(id);
|
||||
if (hit) return hit;
|
||||
const pending = (async (): Promise<User | null> => {
|
||||
try {
|
||||
return await apiJson<User>(`/api/admin/users/${encodeURIComponent(id)}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
adminUserCache.set(id, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Drop cached admin lookups so mutations (quota change, role change,
|
||||
* delete) don't return stale data. Called with no arg = clear all,
|
||||
* or with a specific user id to drop just that entry. */
|
||||
export function invalidateAdminUserCache(userId?: string): void {
|
||||
if (userId) adminUserCache.delete(userId);
|
||||
else adminUserCache.clear();
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -203,6 +318,20 @@ export function deleteUser(userId: string): Promise<void> {
|
||||
return mutate(`/api/admin/users/${userId}`, 'DELETE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a currently-external (grant-only) user to an internal
|
||||
* account. The deployment must have magic-link login enabled — the
|
||||
* admin doesn't set the target's password, so the promoted user
|
||||
* needs some way to log in. Backend refuses with:
|
||||
* * 400 — magic-link disabled deployment-wide
|
||||
* * 403 — target is OIDC-linked
|
||||
* * 404 — user not found
|
||||
* * 409 — user is already internal
|
||||
*/
|
||||
export function promoteUserToInternal(userId: string): Promise<void> {
|
||||
return mutate(`/api/admin/users/${userId}/promote-to-internal`, 'POST');
|
||||
}
|
||||
|
||||
// ── Dashboard ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdminDashboard {
|
||||
|
||||
Reference in New Issue
Block a user