diff --git a/frontend/src/routes/config/drive/[uuid]/+page.svelte b/frontend/src/routes/config/drive/[uuid]/+page.svelte index 93eb50b7..1bac5dcd 100644 --- a/frontend/src/routes/config/drive/[uuid]/+page.svelte +++ b/frontend/src/routes/config/drive/[uuid]/+page.svelte @@ -4,6 +4,8 @@ import { onMount } from 'svelte'; import { listDriveMembers } from '$lib/api/endpoints/drives'; + import { renameFolder } from '$lib/api/endpoints/folders'; + import { errorToast } from '$lib/utils/errors'; import type { Drive, DriveMember, DriveRole } from '$lib/api/types'; import ShareDialog from '$lib/components/ShareDialog.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; @@ -26,6 +28,55 @@ // honest UX. Shared drives + Owner role → full controls. const canManageMembers = $derived(drive?.kind === 'shared' && drive?.caller_role === 'owner'); + // Rename is allowed for any Owner (both shared and personal), since + // the backend requires `Permission::Manage` on the drive's root + // folder which only the Owner bundle carries. Personal-drive Owners + // are the user themselves (seeded by the lifecycle hook). + const canRename = $derived(drive?.caller_role === 'owner'); + + // Inline rename state. `renameDraft` shadows `drive.name` while the + // input is open; we don't write back to the store until the server + // accepts the change. `renameBusy` disables the save/cancel buttons + // during the round-trip. + let renaming = $state(false); + let renameDraft = $state(''); + let renameBusy = $state(false); + + function startRename() { + if (!drive) return; + renameDraft = drive.name; + renaming = true; + } + + function cancelRename() { + renaming = false; + renameDraft = ''; + } + + async function saveRename() { + if (!drive) return; + const next = renameDraft.trim(); + if (next.length === 0 || next === drive.name) { + cancelRename(); + return; + } + renameBusy = true; + try { + // Drive name = root folder name (drive.md §3); rename via the + // folder endpoint. Backend promotes the perm to Manage for + // parent_id IS NULL, so a non-Owner caller would 404 here + // (but the UI also hid this button for non-Owners). + await renameFolder(drive.root_folder_id, next); + drivesStore.invalidate(); + await drivesStore.load(); + renaming = false; + } catch (e) { + errorToast(e); + } finally { + renameBusy = false; + } + } + function roleLabel(role: DriveRole): string { switch (role) { case 'owner': @@ -153,10 +204,59 @@ {t('drive.back_to_files', 'Back to Files')} {:else} -

+
- {drive.name} -

+ {#if renaming} + { + if (e.key === 'Enter') void saveRename(); + else if (e.key === 'Escape') cancelRename(); + }} + /> + + + {:else} +

{drive.name}

+ {#if canRename} + + {/if} + {/if} +

{t('drive.info', 'Drive info')}

@@ -402,6 +502,54 @@ text-decoration: underline; } + /* Drive title row: icon + name (or inline rename input) + edit/save + affordances. Mirrors the visual weight of the previous static +

so the page layout doesn't shift when entering rename mode. */ + .drive-title { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + } + + .drive-title__name { + margin: 0; + } + + .drive-title__input { + flex: 1; + min-width: 0; + max-width: 28rem; + padding: 0.4rem 0.6rem; + font-size: 1.5rem; + font-weight: var(--weight-semibold, 600); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-input); + color: var(--color-text); + } + + /* Compact icon button used in the title row + nowhere else here. + The shared `.icon-btn` style isn't promoted to a global yet, so + we duplicate the minimum that this page needs. */ + .icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-surface); + color: var(--color-text); + cursor: pointer; + } + + .icon-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + /* Members card header: title on the left, "Manage members" button on the right when the caller can mutate membership. */ .members__header { diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 8ec34bf7..9b5cb9fc 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -446,10 +446,32 @@ impl FolderUseCase for FolderService { ))); } + // Drive roots double as the drive's display name (per drive.md §3, + // `drives.name` is sourced from `storage.folders.name` of the row + // pointed at by `root_folder_id`). Per drive.md §6 the rename is + // Owner-only — but with `Permission::Update` that's leaky because + // every Editor of the drive has Update on every folder in the + // drive, including the root. So we promote the requirement to + // `Manage` for root folders. A root is identified by + // `parent_id IS NULL`; that's the same property the drive seeder + // and the drive-of-resource resolver rely on, so no schema-level + // assumption shifts here. + let folder = self.folder_storage.get_folder(id).await.map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to look up folder before rename: {id}: {e}"), + ) + })?; + let required_perm = if folder.parent_id().is_none() { + Permission::Manage + } else { + Permission::Update + }; + self.authz .require( Subject::User(caller_id), - Permission::Update, + required_perm, Self::folder_resource(id)?, ) .await?; diff --git a/tests/api/drives_membership.hurl b/tests/api/drives_membership.hurl index 80070d37..58d4f872 100644 --- a/tests/api/drives_membership.hurl +++ b/tests/api/drives_membership.hurl @@ -704,30 +704,19 @@ HTTP 404 # 26d — Editor renames the drive (root folder) → 404. -# Editor bundle has Update on content, but the rename -# endpoint uses authz.require(...) and Editor's bundle on -# the drive root resolves through the same drive precheck. -# Editor has Update; folder rename uses Update; so this -# should actually SUCCEED. Asserting 200 to reflect the -# real engine semantics — the "Editor can rename the drive" -# fact is a real product question worth surfacing here. -# If you want rename to be Owner-only, the fix is in the -# folder service (require Manage, not Update). +# Folder rename normally requires `Permission::Update` (which +# Editor has on every folder in the drive via the engine's drive +# precheck). The folder service promotes the requirement to +# `Permission::Manage` when the target folder has `parent_id IS +# NULL` — i.e. it's a drive root — so the drive-rename surface is +# Owner-only per drive.md §6, without changing the public folder +# endpoint shape. Anti-enum: refusal returns 404 (not 403). PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename Authorization: Bearer {{bob_token}} Content-Type: application/json { "name": "team-drive-editor-renamed" } -HTTP 200 - - -# Restore the previous name so downstream assertions don't drift. -PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename -Authorization: Bearer {{alice_token}} -Content-Type: application/json -{ "name": "team-drive-renamed" } - -HTTP 200 +HTTP 404 # ─────────────────────────────────────────────────────────────