feat(drive): user can rename drive

- only owners can rename root folders name (aka the drive name)
    - add UI to rename drive's name
This commit is contained in:
Edouard Vanbelle
2026-06-24 02:00:29 +02:00
parent 6034dd47d9
commit 289cf19270
3 changed files with 182 additions and 23 deletions
@@ -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 @@
<a class="link" href={resolve('/files')}>{t('drive.back_to_files', 'Back to Files')}</a>
</div>
{:else}
<h1>
<div class="drive-title">
<Icon name={driveIcon(drive)} />
{drive.name}
</h1>
{#if renaming}
<input
class="drive-title__input"
type="text"
data-testid="drive-rename-input"
bind:value={renameDraft}
maxlength="200"
disabled={renameBusy}
onkeydown={(e) => {
if (e.key === 'Enter') void saveRename();
else if (e.key === 'Escape') cancelRename();
}}
/>
<button
type="button"
class="icon-btn"
data-testid="drive-rename-save-btn"
title={t('common.save', 'Save')}
aria-label={t('common.save', 'Save')}
onclick={() => void saveRename()}
disabled={renameBusy}
>
<Icon name="check" />
</button>
<button
type="button"
class="icon-btn"
data-testid="drive-rename-cancel-btn"
title={t('common.cancel', 'Cancel')}
aria-label={t('common.cancel', 'Cancel')}
onclick={cancelRename}
disabled={renameBusy}
>
<Icon name="times" />
</button>
{:else}
<h1 class="drive-title__name">{drive.name}</h1>
{#if canRename}
<button
type="button"
class="icon-btn"
data-testid="drive-rename-edit-btn"
title={t('drive.rename', 'Rename drive')}
aria-label={t('drive.rename', 'Rename drive')}
onclick={startRename}
>
<Icon name="pencil-alt" />
</button>
{/if}
{/if}
</div>
<div class="card">
<h2><Icon name="info-circle" /> {t('drive.info', 'Drive info')}</h2>
@@ -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
<h1> 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 {
+23 -1
View File
@@ -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?;
+8 -19
View File
@@ -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
# ─────────────────────────────────────────────────────────────