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 { onMount } from 'svelte';
|
||||||
|
|
||||||
import { listDriveMembers } from '$lib/api/endpoints/drives';
|
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 type { Drive, DriveMember, DriveRole } from '$lib/api/types';
|
||||||
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
||||||
import UserVignette from '$lib/components/UserVignette.svelte';
|
import UserVignette from '$lib/components/UserVignette.svelte';
|
||||||
@@ -26,6 +28,55 @@
|
|||||||
// honest UX. Shared drives + Owner role → full controls.
|
// honest UX. Shared drives + Owner role → full controls.
|
||||||
const canManageMembers = $derived(drive?.kind === 'shared' && drive?.caller_role === 'owner');
|
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 {
|
function roleLabel(role: DriveRole): string {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'owner':
|
case 'owner':
|
||||||
@@ -153,10 +204,59 @@
|
|||||||
<a class="link" href={resolve('/files')}>{t('drive.back_to_files', 'Back to Files')}</a>
|
<a class="link" href={resolve('/files')}>{t('drive.back_to_files', 'Back to Files')}</a>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<h1>
|
<div class="drive-title">
|
||||||
<Icon name={driveIcon(drive)} />
|
<Icon name={driveIcon(drive)} />
|
||||||
{drive.name}
|
{#if renaming}
|
||||||
</h1>
|
<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">
|
<div class="card">
|
||||||
<h2><Icon name="info-circle" /> {t('drive.info', 'Drive info')}</h2>
|
<h2><Icon name="info-circle" /> {t('drive.info', 'Drive info')}</h2>
|
||||||
@@ -402,6 +502,54 @@
|
|||||||
text-decoration: underline;
|
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
|
/* Members card header: title on the left, "Manage members" button on
|
||||||
the right when the caller can mutate membership. */
|
the right when the caller can mutate membership. */
|
||||||
.members__header {
|
.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
|
self.authz
|
||||||
.require(
|
.require(
|
||||||
Subject::User(caller_id),
|
Subject::User(caller_id),
|
||||||
Permission::Update,
|
required_perm,
|
||||||
Self::folder_resource(id)?,
|
Self::folder_resource(id)?,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -704,30 +704,19 @@ HTTP 404
|
|||||||
|
|
||||||
|
|
||||||
# 26d — Editor renames the drive (root folder) → 404.
|
# 26d — Editor renames the drive (root folder) → 404.
|
||||||
# Editor bundle has Update on content, but the rename
|
# Folder rename normally requires `Permission::Update` (which
|
||||||
# endpoint uses authz.require(...) and Editor's bundle on
|
# Editor has on every folder in the drive via the engine's drive
|
||||||
# the drive root resolves through the same drive precheck.
|
# precheck). The folder service promotes the requirement to
|
||||||
# Editor has Update; folder rename uses Update; so this
|
# `Permission::Manage` when the target folder has `parent_id IS
|
||||||
# should actually SUCCEED. Asserting 200 to reflect the
|
# NULL` — i.e. it's a drive root — so the drive-rename surface is
|
||||||
# real engine semantics — the "Editor can rename the drive"
|
# Owner-only per drive.md §6, without changing the public folder
|
||||||
# fact is a real product question worth surfacing here.
|
# endpoint shape. Anti-enum: refusal returns 404 (not 403).
|
||||||
# If you want rename to be Owner-only, the fix is in the
|
|
||||||
# folder service (require Manage, not Update).
|
|
||||||
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
|
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
|
||||||
Authorization: Bearer {{bob_token}}
|
Authorization: Bearer {{bob_token}}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
{ "name": "team-drive-editor-renamed" }
|
{ "name": "team-drive-editor-renamed" }
|
||||||
|
|
||||||
HTTP 200
|
HTTP 404
|
||||||
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user