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:
@@ -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 {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user