-
-
-
NEXTCLOUD SCAN FOLDER
-
- {band.nc_folder_path ?? `bands/${band.slug}/`}
-
-
- {amAdmin && !editingFolder && (
-
- )}
-
- {editingFolder && (
-
-
setFolderInput(e.target.value)}
- placeholder={`bands/${band.slug}/`}
- style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, fontFamily: "monospace", boxSizing: "border-box" }}
- />
-
-
-
-
-
- )}
+
+
+
+
-
-
Members
- {amAdmin && (
- <>
-
-
- {/* Search for users to invite (new feature) */}
- {/* Temporarily hide user search until backend supports it */}
- >
- )}
-
-
- {inviteLink && (
-
-
Invite link (copied to clipboard, valid 72h):
-
{inviteLink}
-
-
- )}
-
-
- {members?.map((m) => (
-
-
- {m.display_name}
- {m.email}
-
-
-
- {m.role}
-
- {amAdmin && m.role !== "admin" && (
-
- )}
-
-
- ))}
-
-
- {/* Admin: Invite Management Section (new feature) */}
- {amAdmin &&
}
+ {/* ── Scan feedback ─────────────────────────────────────── */}
+ {scanning && scanProgress && (
+
+ {scanProgress}
+ )}
+ {scanMsg && (
+
+ {scanMsg}
+
+ )}
- {/* Recordings header */}
-
-
Recordings
+ {/* ── New song form ─────────────────────────────────────── */}
+ {showCreate && (
+
+ {error &&
{error}
}
+
+
setTitle(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()}
+ style={{
+ width: "100%",
+ padding: "8px 12px",
+ background: "rgba(255,255,255,0.05)",
+ border: "1px solid rgba(255,255,255,0.08)",
+ borderRadius: 7,
+ color: "#eeeef2",
+ marginBottom: 12,
+ fontSize: 14,
+ fontFamily: "inherit",
+ boxSizing: "border-box",
+ outline: "none",
+ }}
+ autoFocus
+ />
-
-
-
-
- {scanning && scanProgress && (
-
- {scanProgress}
-
- )}
- {scanMsg && (
-
- {scanMsg}
-
- )}
-
- {showCreate && (
-
- {error &&
{error}
}
-
-
setTitle(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()}
- style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }}
- autoFocus
- />
-
-
-
-
-
- )}
-
- {/* Tabs */}
-
- {(["dates", "search"] as const).map((t) => (
-
- ))}
+
+
+ )}
- {/* By Date tab */}
- {tab === "dates" && (
-
- {sessions?.map((s) => (
-
+ {(["dates", "search"] as const).map((t) => (
+
+ ))}
+
+
+ {/* ── By Date tab ───────────────────────────────────────── */}
+ {tab === "dates" && (
+
+ {sessions?.map((s) => (
+
{
+ (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.045)";
+ (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.09)";
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
+ (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.05)";
+ }}
+ >
+
+
+ {weekday(s.date)}
+
+ {formatDate(s.date)}
+ {s.label && (
+
+ {s.label}
+
+ )}
+
+
-
- {weekday(s.date)}
- {formatDate(s.date)}
- {s.label && (
- {s.label}
- )}
-
-
- {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
-
-
- ))}
- {sessions?.length === 0 && !unattributedSongs?.length && (
-
- No sessions yet. Scan Nextcloud to import from {band.nc_folder_path ?? `bands/${band.slug}/`}.
-
- )}
+ {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
+
+
+ ))}
- {/* Songs not linked to any dated session */}
- {!!unattributedSongs?.length && (
-
-
- UNATTRIBUTED RECORDINGS
-
-
- {unattributedSongs.map((song) => (
-
-
-
{song.title}
-
- {song.tags.map((t) => (
- {t}
- ))}
-
-
-
- {song.status}
- {song.version_count} version{song.version_count !== 1 ? "s" : ""}
-
-
- ))}
-
-
- )}
-
- )}
+ {sessions?.length === 0 && !unattributedSongs?.length && (
+
+ No sessions yet. Scan Nextcloud or create a song to get started.
+
+ )}
- {/* Search tab */}
- {tab === "search" && (
-
- {/* Filters */}
-
-
-
-
- setSearchQ(e.target.value)}
- onKeyDown={(e) => { if (e.key === "Enter") { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); } }}
- placeholder="Search by name…"
- style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
- />
-
-
-
- setSearchKey(e.target.value)}
- placeholder="e.g. Am, C, F#"
- style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
- />
-
-
-
- setSearchBpmMin(e.target.value)}
- type="number"
- min={0}
- placeholder="e.g. 80"
- style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
- />
-
-
-
- setSearchBpmMax(e.target.value)}
- type="number"
- min={0}
- placeholder="e.g. 140"
- style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }}
- />
-
-
-
- {/* Tag filter */}
-
-
-
- {searchTags.map((t) => (
-
- {t}
-
-
- ))}
-
-
- setSearchTagInput(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && addTag()}
- placeholder="Add tag…"
- style={{ flex: 1, padding: "6px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 12 }}
- />
-
-
-
-
-
+ )}
);
}
diff --git a/web/src/pages/BandSettingsPage.test.md b/web/src/pages/BandSettingsPage.test.md
new file mode 100644
index 0000000..7315ce7
--- /dev/null
+++ b/web/src/pages/BandSettingsPage.test.md
@@ -0,0 +1,151 @@
+# BandSettingsPage — Test Cases
+
+Feature branch: `feature/main-view-refactor`
+
+---
+
+## 1. BandPage cleanliness
+
+**TC-01** — BandPage renders no member list
+Navigate to `/bands/:bandId`. Assert that no member name, email, or role badge is rendered.
+
+**TC-02** — BandPage renders no invite button
+Navigate to `/bands/:bandId`. Assert that "+ Invite" is absent.
+
+**TC-03** — BandPage renders no NC folder widget
+Navigate to `/bands/:bandId`. Assert that "NEXTCLOUD SCAN FOLDER" / "SCAN PATH" label is absent.
+
+**TC-04** — BandPage still shows sessions
+Navigate to `/bands/:bandId`. Assert that dated session rows are rendered (or empty-state message if no sessions).
+
+**TC-05** — BandPage still shows Scan Nextcloud button
+Navigate to `/bands/:bandId`. Assert "⟳ Scan Nextcloud" button is present.
+
+**TC-06** — BandPage still shows + New Song button
+Navigate to `/bands/:bandId`. Assert "+ New Song" button is present.
+
+**TC-07** — BandPage search tab remains functional
+Click "Search" tab, enter a query, click Search. Assert results render or empty state shown.
+
+---
+
+## 2. Navigation — sidebar
+
+**TC-08** — Band settings nav items appear when band is active
+Log in, select any band. Assert sidebar contains "Members", "Storage", "Band Settings" nav items under a "Band Settings" section label.
+
+**TC-09** — Band settings nav items absent when no band active
+Navigate to `/` (no band selected). Assert sidebar does NOT show "Members", "Storage", "Band Settings" items.
+
+**TC-10** — Members nav item highlights correctly
+Navigate to `/bands/:bandId/settings/members`. Assert "Members" nav item has amber active style; "Storage" and "Band Settings" do not.
+
+**TC-11** — Storage nav item highlights correctly
+Navigate to `/bands/:bandId/settings/storage`. Assert "Storage" nav item is active.
+
+**TC-12** — Band Settings nav item highlights correctly
+Navigate to `/bands/:bandId/settings/band`. Assert "Band Settings" nav item is active.
+
+**TC-13** — Switching bands from band switcher while on settings stays on the same panel type
+On `/bands/A/settings/storage`, switch to band B. Assert navigation goes to `/bands/B` (library) — band switcher navigates to library, which is correct. Band settings panel is band-specific.
+
+---
+
+## 3. Routing
+
+**TC-14** — Base settings URL redirects to members panel
+Navigate directly to `/bands/:bandId/settings`. Assert browser URL redirects to `/bands/:bandId/settings/members` without a visible flash.
+
+**TC-15** — Direct URL navigation to storage panel works
+Navigate directly to `/bands/:bandId/settings/storage`. Assert Storage panel content is rendered.
+
+**TC-16** — Direct URL navigation to band panel works
+Navigate directly to `/bands/:bandId/settings/band`. Assert Band Settings panel content is rendered.
+
+**TC-17** — Unknown panel falls back to members
+Navigate to `/bands/:bandId/settings/unknown-panel`. Assert Members panel is rendered (fallback in `activePanel` logic).
+
+---
+
+## 4. Members panel — access control
+
+**TC-18** — Admin sees + Invite button
+Log in as admin, navigate to `/bands/:bandId/settings/members`. Assert "+ Invite" button is present.
+
+**TC-19** — Non-admin does not see + Invite button
+Log in as member (non-admin), navigate to `/bands/:bandId/settings/members`. Assert "+ Invite" button is absent.
+
+**TC-20** — Admin sees Remove button on non-admin members
+Log in as admin. Assert "Remove" button appears next to member-role users.
+
+**TC-21** — Non-admin does not see Remove button
+Log in as member. Assert no "Remove" button appears for any member.
+
+**TC-22** — Admin does not see Remove button for other admins
+Log in as admin. Assert "Remove" button is absent next to rows where role is "admin".
+
+**TC-23** — Pending Invites section only visible to admins
+Log in as member. Assert "Pending Invites" heading is absent.
+
+---
+
+## 5. Members panel — functionality
+
+**TC-24** — Generate invite creates a link and copies to clipboard
+As admin, click "+ Invite". Assert an invite URL (`/invite/
`) appears in the UI and `navigator.clipboard.writeText` was called with it.
+
+**TC-25** — Dismiss hides the invite link banner
+After generating an invite, click "Dismiss". Assert the invite link banner disappears.
+
+**TC-26** — Remove member removes from list
+As admin, click "Remove" on a member-role row. Mock the DELETE endpoint to 200. Assert the members query is invalidated and the member disappears.
+
+**TC-27** — Revoke invite removes from pending list
+As admin, click "Revoke" on a pending invite. Mock the DELETE endpoint. Assert the invites query is invalidated.
+
+**TC-28** — Copy invite link writes to clipboard
+In the pending invites list, click "Copy" on an invite row. Assert `navigator.clipboard.writeText` was called with the correct URL.
+
+---
+
+## 6. Storage panel — access control and functionality
+
+**TC-29** — Admin sees Edit button on NC folder path
+Log in as admin, navigate to storage panel. Assert "Edit" button is visible next to the scan path.
+
+**TC-30** — Non-admin does not see Edit button
+Log in as member, navigate to storage panel. Assert "Edit" button is absent.
+
+**TC-31** — Editing NC folder path and saving updates the band
+As admin, click Edit, change the path, click Save. Mock PATCH `/bands/:bandId` to 200. Assert band query is invalidated and edit form closes.
+
+**TC-32** — Cancel edit closes form without saving
+As admin, click Edit, change the path, click Cancel. Assert the form disappears and PATCH was not called.
+
+**TC-33** — Default path shown when nc_folder_path is null
+When `band.nc_folder_path` is null, assert the displayed path is `bands//`.
+
+---
+
+## 7. Band settings panel — access control and functionality
+
+**TC-34** — Admin sees Save changes button
+Log in as admin, navigate to band panel. Assert "Save changes" button is present.
+
+**TC-35** — Non-admin does not see Save button, sees info text
+Log in as member, navigate to band panel. Assert "Save changes" absent and "Only admins can edit band settings." is shown.
+
+**TC-36** — Name field is disabled for non-admins
+Log in as member. Assert the band name input has the `disabled` attribute.
+
+**TC-37** — Saving band name and tags calls PATCH
+As admin, change band name to "New Name", click Save. Assert PATCH `/bands/:bandId` called with `{ name: "New Name", genre_tags: [...] }`.
+
+**TC-38** — Adding a genre tag updates the tag list
+Type "punk" in the tag input, press Enter. Assert "punk" pill appears in the tag list.
+
+**TC-39** — Removing a genre tag removes its pill
+Click the × on a genre tag pill. Assert the pill disappears from the list.
+
+**TC-40** — Delete band button disabled for non-admins
+Log in as member. Assert the "Delete band" button has the `disabled` attribute.
diff --git a/web/src/pages/BandSettingsPage.test.tsx b/web/src/pages/BandSettingsPage.test.tsx
new file mode 100644
index 0000000..bc6aeba
--- /dev/null
+++ b/web/src/pages/BandSettingsPage.test.tsx
@@ -0,0 +1,320 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { screen, fireEvent, waitFor } from "@testing-library/react";
+import { renderWithProviders } from "../test/helpers";
+import { BandSettingsPage } from "./BandSettingsPage";
+
+// ── Shared fixtures ───────────────────────────────────────────────────────────
+
+const ME = { id: "m-me", email: "s@example.com", display_name: "Steffen", avatar_url: null, created_at: "" };
+
+const BAND = {
+ id: "band-1",
+ name: "Loud Hands",
+ slug: "loud-hands",
+ genre_tags: ["post-rock", "math-rock"],
+ nc_folder_path: "bands/loud-hands/",
+};
+
+const MEMBERS_ADMIN = [
+ { id: "m-me", display_name: "Steffen", email: "s@example.com", role: "admin", joined_at: "" },
+ { id: "m-2", display_name: "Alex", email: "a@example.com", role: "member", joined_at: "" },
+];
+
+const MEMBERS_NON_ADMIN = [
+ { id: "m-me", display_name: "Steffen", email: "s@example.com", role: "member", joined_at: "" },
+ { id: "m-2", display_name: "Alex", email: "a@example.com", role: "admin", joined_at: "" },
+];
+
+const INVITES_RESPONSE = {
+ invites: [
+ {
+ id: "inv-1",
+ token: "abcdef1234567890abcd",
+ role: "member",
+ expires_at: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
+ is_used: false,
+ band_id: "band-1",
+ created_at: new Date().toISOString(),
+ used_at: null,
+ },
+ ],
+ total: 1,
+ pending: 1,
+};
+
+// ── Mocks ─────────────────────────────────────────────────────────────────────
+
+const {
+ mockGetBand,
+ mockApiGet,
+ mockApiPost,
+ mockApiPatch,
+ mockApiDelete,
+ mockListInvites,
+ mockRevokeInvite,
+} = vi.hoisted(() => ({
+ mockGetBand: vi.fn(),
+ mockApiGet: vi.fn(),
+ mockApiPost: vi.fn(),
+ mockApiPatch: vi.fn(),
+ mockApiDelete: vi.fn(),
+ mockListInvites: vi.fn(),
+ mockRevokeInvite: vi.fn(),
+}));
+
+vi.mock("../api/bands", () => ({ getBand: mockGetBand }));
+vi.mock("../api/invites", () => ({
+ listInvites: mockListInvites,
+ revokeInvite: mockRevokeInvite,
+}));
+vi.mock("../api/client", () => ({
+ api: {
+ get: mockApiGet,
+ post: mockApiPost,
+ patch: mockApiPatch,
+ delete: mockApiDelete,
+ },
+ isLoggedIn: vi.fn().mockReturnValue(true),
+}));
+
+// ── Default mock implementations ─────────────────────────────────────────────
+
+afterEach(() => vi.clearAllMocks());
+
+function setupApiGet(members: typeof MEMBERS_ADMIN) {
+ mockApiGet.mockImplementation((url: string) => {
+ if (url === "/auth/me") return Promise.resolve(ME);
+ if (url.includes("/members")) return Promise.resolve(members);
+ return Promise.resolve([]);
+ });
+}
+
+beforeEach(() => {
+ mockGetBand.mockResolvedValue(BAND);
+ setupApiGet(MEMBERS_ADMIN);
+ mockApiPost.mockResolvedValue({ id: "inv-new", token: "newtoken123", role: "member", expires_at: "" });
+ mockApiPatch.mockResolvedValue(BAND);
+ mockApiDelete.mockResolvedValue({});
+ mockListInvites.mockResolvedValue(INVITES_RESPONSE);
+ mockRevokeInvite.mockResolvedValue({});
+});
+
+// ── Render helpers ────────────────────────────────────────────────────────────
+
+function renderPanel(panel: "members" | "storage" | "band", members = MEMBERS_ADMIN) {
+ setupApiGet(members);
+ return renderWithProviders(, {
+ path: "/bands/:bandId/settings/:panel",
+ route: `/bands/band-1/settings/${panel}`,
+ });
+}
+
+// ── Routing ───────────────────────────────────────────────────────────────────
+
+describe("BandSettingsPage — routing (TC-15 to TC-17)", () => {
+ it("TC-15: renders Storage panel for /settings/storage", async () => {
+ renderPanel("storage");
+ const heading = await screen.findByRole("heading", { name: /storage/i });
+ expect(heading).toBeTruthy();
+ });
+
+ it("TC-16: renders Band Settings panel for /settings/band", async () => {
+ renderPanel("band");
+ const heading = await screen.findByRole("heading", { name: /band settings/i });
+ expect(heading).toBeTruthy();
+ });
+
+ it("TC-17: unknown panel falls back to Members", async () => {
+ mockApiGet.mockResolvedValue(MEMBERS_ADMIN);
+ renderWithProviders(, {
+ path: "/bands/:bandId/settings/:panel",
+ route: "/bands/band-1/settings/unknown-panel",
+ });
+ const heading = await screen.findByRole("heading", { name: /members/i });
+ expect(heading).toBeTruthy();
+ });
+});
+
+// ── Members panel — access control ───────────────────────────────────────────
+
+describe("BandSettingsPage — Members panel access control (TC-18 to TC-23)", () => {
+ it("TC-18: admin sees + Invite button", async () => {
+ renderPanel("members", MEMBERS_ADMIN);
+ const btn = await screen.findByText(/\+ invite/i);
+ expect(btn).toBeTruthy();
+ });
+
+ it("TC-19: non-admin does not see + Invite button", async () => {
+ renderPanel("members", MEMBERS_NON_ADMIN);
+ await screen.findByText("Alex"); // wait for members to load
+ expect(screen.queryByText(/\+ invite/i)).toBeNull();
+ });
+
+ it("TC-20: admin sees Remove button on non-admin members", async () => {
+ renderPanel("members", MEMBERS_ADMIN);
+ const removeBtn = await screen.findByText("Remove");
+ expect(removeBtn).toBeTruthy();
+ });
+
+ it("TC-21: non-admin does not see any Remove button", async () => {
+ renderPanel("members", MEMBERS_NON_ADMIN);
+ await screen.findByText("Alex");
+ expect(screen.queryByText("Remove")).toBeNull();
+ });
+
+ it("TC-22: admin does not see Remove on admin-role members", async () => {
+ renderPanel("members", MEMBERS_ADMIN);
+ await screen.findByText("Steffen");
+ // Only one Remove button — for Alex (member), not Steffen (admin)
+ const removeBtns = screen.queryAllByText("Remove");
+ expect(removeBtns).toHaveLength(1);
+ });
+
+ it("TC-23: Pending Invites section hidden from non-admins", async () => {
+ renderPanel("members", MEMBERS_NON_ADMIN);
+ await screen.findByText("Alex");
+ expect(screen.queryByText(/pending invites/i)).toBeNull();
+ });
+});
+
+// ── Members panel — functionality ─────────────────────────────────────────────
+
+describe("BandSettingsPage — Members panel functionality (TC-24 to TC-28)", () => {
+ it("TC-24: generate invite shows link in UI", async () => {
+ const token = "tok123abc456def789gh";
+ mockApiPost.mockResolvedValue({ id: "inv-new", token, role: "member", expires_at: "" });
+ renderPanel("members", MEMBERS_ADMIN);
+ const inviteBtn = await screen.findByText(/\+ invite/i);
+ fireEvent.click(inviteBtn);
+ const linkEl = await screen.findByText(new RegExp(token));
+ expect(linkEl).toBeTruthy();
+ });
+
+ it("TC-26: remove member calls DELETE endpoint", async () => {
+ renderPanel("members", MEMBERS_ADMIN);
+ const removeBtn = await screen.findByText("Remove");
+ fireEvent.click(removeBtn);
+ await waitFor(() => {
+ expect(mockApiDelete).toHaveBeenCalledWith("/bands/band-1/members/m-2");
+ });
+ });
+
+ it("TC-27: revoke invite calls revokeInvite and refetches", async () => {
+ renderPanel("members", MEMBERS_ADMIN);
+ const revokeBtn = await screen.findByText("Revoke");
+ fireEvent.click(revokeBtn);
+ await waitFor(() => {
+ expect(mockRevokeInvite).toHaveBeenCalledWith("inv-1");
+ });
+ });
+});
+
+// ── Storage panel — access control ───────────────────────────────────────────
+
+describe("BandSettingsPage — Storage panel access control (TC-29 to TC-33)", () => {
+ it("TC-29: admin sees Edit button", async () => {
+ renderPanel("storage", MEMBERS_ADMIN);
+ const edit = await screen.findByText("Edit");
+ expect(edit).toBeTruthy();
+ });
+
+ it("TC-30: non-admin does not see Edit button", async () => {
+ renderPanel("storage", MEMBERS_NON_ADMIN);
+ await screen.findByText(/scan path/i);
+ expect(screen.queryByText("Edit")).toBeNull();
+ });
+
+ it("TC-31: saving NC folder path calls PATCH and closes form", async () => {
+ renderPanel("storage", MEMBERS_ADMIN);
+ fireEvent.click(await screen.findByText("Edit"));
+ const input = screen.getByPlaceholderText(/bands\/loud-hands\//i);
+ fireEvent.change(input, { target: { value: "bands/custom-path/" } });
+ fireEvent.click(screen.getByText("Save"));
+ await waitFor(() => {
+ expect(mockApiPatch).toHaveBeenCalledWith(
+ "/bands/band-1",
+ { nc_folder_path: "bands/custom-path/" }
+ );
+ });
+ });
+
+ it("TC-32: cancel edit closes form without calling PATCH", async () => {
+ renderPanel("storage", MEMBERS_ADMIN);
+ fireEvent.click(await screen.findByText("Edit"));
+ fireEvent.click(screen.getByText("Cancel"));
+ await waitFor(() => {
+ expect(mockApiPatch).not.toHaveBeenCalled();
+ });
+ expect(screen.queryByText("Save")).toBeNull();
+ });
+
+ it("TC-33: shows default path when nc_folder_path is null", async () => {
+ mockGetBand.mockResolvedValueOnce({ ...BAND, nc_folder_path: null });
+ renderPanel("storage", MEMBERS_ADMIN);
+ const path = await screen.findByText("bands/loud-hands/");
+ expect(path).toBeTruthy();
+ });
+});
+
+// ── Band settings panel — access control ──────────────────────────────────────
+
+describe("BandSettingsPage — Band Settings panel access control (TC-34 to TC-40)", () => {
+ it("TC-34: admin sees Save changes button", async () => {
+ renderPanel("band", MEMBERS_ADMIN);
+ const btn = await screen.findByText(/save changes/i);
+ expect(btn).toBeTruthy();
+ });
+
+ it("TC-35: non-admin sees info text instead of Save button", async () => {
+ renderPanel("band", MEMBERS_NON_ADMIN);
+ // Wait for the band panel heading so we know the page has fully loaded
+ await screen.findByRole("heading", { name: /band settings/i });
+ // Once queries settle, the BandPanel-level info text should appear and Save should be absent
+ await waitFor(() => {
+ expect(screen.getByText(/only admins can edit band settings/i)).toBeTruthy();
+ });
+ expect(screen.queryByText(/save changes/i)).toBeNull();
+ });
+
+ it("TC-36: name field is disabled for non-admins", async () => {
+ renderPanel("band", MEMBERS_NON_ADMIN);
+ const input = await screen.findByDisplayValue("Loud Hands");
+ expect((input as HTMLInputElement).disabled).toBe(true);
+ });
+
+ it("TC-37: saving calls PATCH with name and genre_tags", async () => {
+ renderPanel("band", MEMBERS_ADMIN);
+ await screen.findByText(/save changes/i);
+ fireEvent.click(screen.getByText(/save changes/i));
+ await waitFor(() => {
+ expect(mockApiPatch).toHaveBeenCalledWith("/bands/band-1", {
+ name: "Loud Hands",
+ genre_tags: ["post-rock", "math-rock"],
+ });
+ });
+ });
+
+ it("TC-38: adding a genre tag shows the new pill", async () => {
+ renderPanel("band", MEMBERS_ADMIN);
+ const tagInput = await screen.findByPlaceholderText(/add genre tag/i);
+ fireEvent.change(tagInput, { target: { value: "punk" } });
+ fireEvent.keyDown(tagInput, { key: "Enter" });
+ expect(screen.getByText("punk")).toBeTruthy();
+ });
+
+ it("TC-39: removing a genre tag removes its pill", async () => {
+ renderPanel("band", MEMBERS_ADMIN);
+ // Find the × button next to "post-rock"
+ await screen.findByText("post-rock");
+ // There are two tags; find the × buttons
+ const removeButtons = screen.getAllByText("×");
+ fireEvent.click(removeButtons[0]);
+ expect(screen.queryByText("post-rock")).toBeNull();
+ });
+
+ it("TC-40: Delete band button is disabled for non-admins", async () => {
+ renderPanel("band", MEMBERS_NON_ADMIN);
+ const deleteBtn = await screen.findByText(/delete band/i);
+ expect((deleteBtn as HTMLButtonElement).disabled).toBe(true);
+ });
+});
diff --git a/web/src/pages/BandSettingsPage.tsx b/web/src/pages/BandSettingsPage.tsx
new file mode 100644
index 0000000..57905c7
--- /dev/null
+++ b/web/src/pages/BandSettingsPage.tsx
@@ -0,0 +1,867 @@
+import { useState } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { getBand } from "../api/bands";
+import { api } from "../api/client";
+import { listInvites, revokeInvite } from "../api/invites";
+import type { MemberRead } from "../api/auth";
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+interface BandMember {
+ id: string;
+ display_name: string;
+ email: string;
+ role: string;
+ joined_at: string;
+}
+
+interface BandInvite {
+ id: string;
+ token: string;
+ role: string;
+ expires_at: string | null;
+ is_used: boolean;
+}
+
+type Panel = "members" | "storage" | "band";
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function formatExpiry(expiresAt: string | null | undefined): string {
+ if (!expiresAt) return "No expiry";
+ const date = new Date(expiresAt);
+ const diffHours = Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60));
+ if (diffHours <= 0) return "Expired";
+ if (diffHours < 24) return `Expires in ${diffHours}h`;
+ return `Expires in ${Math.floor(diffHours / 24)}d`;
+}
+
+function isActive(invite: BandInvite): boolean {
+ return !invite.is_used && !!invite.expires_at && new Date(invite.expires_at) > new Date();
+}
+
+// ── Panel nav item ────────────────────────────────────────────────────────────
+
+function PanelNavItem({
+ label,
+ active,
+ onClick,
+}: {
+ label: string;
+ active: boolean;
+ onClick: () => void;
+}) {
+ const [hovered, setHovered] = useState(false);
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ style={{
+ width: "100%",
+ textAlign: "left",
+ padding: "7px 10px",
+ borderRadius: 7,
+ border: "none",
+ cursor: "pointer",
+ fontSize: 12,
+ fontFamily: "inherit",
+ marginBottom: 1,
+ background: active
+ ? "rgba(232,162,42,0.1)"
+ : hovered
+ ? "rgba(255,255,255,0.04)"
+ : "transparent",
+ color: active
+ ? "#e8a22a"
+ : hovered
+ ? "rgba(255,255,255,0.65)"
+ : "rgba(255,255,255,0.35)",
+ transition: "background 0.12s, color 0.12s",
+ }}
+ >
+ {label}
+
+ );
+}
+
+// ── Section title ─────────────────────────────────────────────────────────────
+
+function SectionTitle({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Divider() {
+ return ;
+}
+
+// ── Members panel ─────────────────────────────────────────────────────────────
+
+function MembersPanel({
+ bandId,
+ amAdmin,
+ members,
+ membersLoading,
+}: {
+ bandId: string;
+ amAdmin: boolean;
+ members: BandMember[] | undefined;
+ membersLoading: boolean;
+}) {
+ const qc = useQueryClient();
+ const [inviteLink, setInviteLink] = useState(null);
+
+ const { data: invitesData, isLoading: invitesLoading } = useQuery({
+ queryKey: ["invites", bandId],
+ queryFn: () => listInvites(bandId),
+ enabled: amAdmin,
+ retry: false,
+ });
+
+ const inviteMutation = useMutation({
+ mutationFn: () => api.post(`/bands/${bandId}/invites`, {}),
+ onSuccess: (invite) => {
+ const url = `${window.location.origin}/invite/${invite.token}`;
+ setInviteLink(url);
+ navigator.clipboard.writeText(url).catch(() => {});
+ qc.invalidateQueries({ queryKey: ["invites", bandId] });
+ },
+ });
+
+ const removeMutation = useMutation({
+ mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }),
+ });
+
+ const revokeMutation = useMutation({
+ mutationFn: (inviteId: string) => revokeInvite(inviteId),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["invites", bandId] }),
+ });
+
+ const activeInvites = invitesData?.invites.filter(isActive) ?? [];
+
+ return (
+
+ {/* Member list */}
+
+ Members
+ {amAdmin && (
+ inviteMutation.mutate()}
+ disabled={inviteMutation.isPending}
+ style={{
+ background: "rgba(232,162,42,0.14)",
+ border: "1px solid rgba(232,162,42,0.28)",
+ borderRadius: 6,
+ color: "#e8a22a",
+ cursor: "pointer",
+ padding: "4px 12px",
+ fontSize: 12,
+ fontFamily: "inherit",
+ }}
+ >
+ {inviteMutation.isPending ? "Generating…" : "+ Invite"}
+
+ )}
+
+
+ {inviteLink && (
+
+
+ Invite link (copied to clipboard · valid 72h):
+
+
{inviteLink}
+
setInviteLink(null)}
+ style={{
+ display: "block",
+ marginTop: 6,
+ background: "none",
+ border: "none",
+ color: "rgba(255,255,255,0.28)",
+ cursor: "pointer",
+ fontSize: 11,
+ padding: 0,
+ fontFamily: "inherit",
+ }}
+ >
+ Dismiss
+
+
+ )}
+
+ {membersLoading ? (
+
Loading…
+ ) : (
+
+ {members?.map((m) => (
+
+
+ {m.display_name.slice(0, 2).toUpperCase()}
+
+
+
{m.display_name}
+
{m.email}
+
+
+ {m.role}
+
+ {amAdmin && m.role !== "admin" && (
+
removeMutation.mutate(m.id)}
+ disabled={removeMutation.isPending}
+ style={{
+ background: "rgba(220,80,80,0.08)",
+ border: "1px solid rgba(220,80,80,0.2)",
+ borderRadius: 5,
+ color: "#e07070",
+ cursor: "pointer",
+ fontSize: 11,
+ padding: "3px 8px",
+ fontFamily: "inherit",
+ }}
+ >
+ Remove
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Role info cards */}
+
+
+
Admin
+
+ Upload, delete, manage members and storage
+
+
+
+
Member
+
+ Listen, comment, annotate — no upload or management
+
+
+
+
+ {/* Pending invites — admin only */}
+ {amAdmin && (
+ <>
+
+
Pending Invites
+
+ {invitesLoading ? (
+
Loading invites…
+ ) : activeInvites.length === 0 ? (
+
No pending invites.
+ ) : (
+
+ {activeInvites.map((invite) => (
+
+
+
+ {invite.token.slice(0, 8)}…{invite.token.slice(-4)}
+
+
+ {formatExpiry(invite.expires_at)} · {invite.role}
+
+
+
+ navigator.clipboard
+ .writeText(`${window.location.origin}/invite/${invite.token}`)
+ .catch(() => {})
+ }
+ style={{
+ background: "none",
+ border: "1px solid rgba(255,255,255,0.09)",
+ borderRadius: 5,
+ color: "rgba(255,255,255,0.42)",
+ cursor: "pointer",
+ fontSize: 11,
+ padding: "3px 8px",
+ fontFamily: "inherit",
+ }}
+ >
+ Copy
+
+
revokeMutation.mutate(invite.id)}
+ disabled={revokeMutation.isPending}
+ style={{
+ background: "rgba(220,80,80,0.08)",
+ border: "1px solid rgba(220,80,80,0.2)",
+ borderRadius: 5,
+ color: "#e07070",
+ cursor: "pointer",
+ fontSize: 11,
+ padding: "3px 8px",
+ fontFamily: "inherit",
+ }}
+ >
+ Revoke
+
+
+ ))}
+
+ )}
+
+ No account needed to accept an invite.
+
+ >
+ )}
+
+ );
+}
+
+// ── Storage panel ─────────────────────────────────────────────────────────────
+
+function StoragePanel({
+ bandId,
+ band,
+ amAdmin,
+}: {
+ bandId: string;
+ band: { slug: string; nc_folder_path: string | null };
+ amAdmin: boolean;
+}) {
+ const qc = useQueryClient();
+ const [editing, setEditing] = useState(false);
+ const [folderInput, setFolderInput] = useState("");
+
+ const updateMutation = useMutation({
+ mutationFn: (nc_folder_path: string) => api.patch(`/bands/${bandId}`, { nc_folder_path }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["band", bandId] });
+ setEditing(false);
+ },
+ });
+
+ const defaultPath = `bands/${band.slug}/`;
+ const currentPath = band.nc_folder_path ?? defaultPath;
+
+ return (
+
+
Nextcloud Scan Folder
+
+ RehearsalHub reads recordings directly from your Nextcloud — files are never copied to our
+ servers.
+
+
+
+
+
+
+ Scan path
+
+
+ {currentPath}
+
+
+ {amAdmin && !editing && (
+
{ setFolderInput(band.nc_folder_path ?? ""); setEditing(true); }}
+ style={{
+ background: "none",
+ border: "1px solid rgba(255,255,255,0.09)",
+ borderRadius: 6,
+ color: "rgba(255,255,255,0.42)",
+ cursor: "pointer",
+ padding: "4px 10px",
+ fontSize: 11,
+ fontFamily: "inherit",
+ }}
+ >
+ Edit
+
+ )}
+
+
+ {editing && (
+
+
setFolderInput(e.target.value)}
+ placeholder={defaultPath}
+ style={{
+ width: "100%",
+ padding: "8px 12px",
+ background: "rgba(255,255,255,0.05)",
+ border: "1px solid rgba(255,255,255,0.08)",
+ borderRadius: 7,
+ color: "#eeeef2",
+ fontSize: 13,
+ fontFamily: "monospace",
+ boxSizing: "border-box",
+ outline: "none",
+ }}
+ />
+
+ updateMutation.mutate(folderInput)}
+ disabled={updateMutation.isPending}
+ style={{
+ background: "rgba(232,162,42,0.14)",
+ border: "1px solid rgba(232,162,42,0.28)",
+ borderRadius: 6,
+ color: "#e8a22a",
+ cursor: "pointer",
+ padding: "6px 14px",
+ fontSize: 12,
+ fontWeight: 600,
+ fontFamily: "inherit",
+ }}
+ >
+ {updateMutation.isPending ? "Saving…" : "Save"}
+
+ setEditing(false)}
+ style={{
+ background: "none",
+ border: "1px solid rgba(255,255,255,0.09)",
+ borderRadius: 6,
+ color: "rgba(255,255,255,0.42)",
+ cursor: "pointer",
+ padding: "6px 14px",
+ fontSize: 12,
+ fontFamily: "inherit",
+ }}
+ >
+ Cancel
+
+
+
+ )}
+
+
+ );
+}
+
+// ── Band settings panel ───────────────────────────────────────────────────────
+
+function BandPanel({
+ bandId,
+ band,
+ amAdmin,
+}: {
+ bandId: string;
+ band: { name: string; slug: string; genre_tags: string[] };
+ amAdmin: boolean;
+}) {
+ const qc = useQueryClient();
+ const [nameInput, setNameInput] = useState(band.name);
+ const [tagInput, setTagInput] = useState("");
+ const [tags, setTags] = useState(band.genre_tags);
+ const [saved, setSaved] = useState(false);
+
+ const updateMutation = useMutation({
+ mutationFn: (payload: { name?: string; genre_tags?: string[] }) =>
+ api.patch(`/bands/${bandId}`, payload),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["band", bandId] });
+ qc.invalidateQueries({ queryKey: ["bands"] });
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2000);
+ },
+ });
+
+ function addTag() {
+ const t = tagInput.trim();
+ if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
+ setTagInput("");
+ }
+
+ function removeTag(t: string) {
+ setTags((prev) => prev.filter((x) => x !== t));
+ }
+
+ return (
+
+
Identity
+
+
+
+ setNameInput(e.target.value)}
+ disabled={!amAdmin}
+ style={{
+ width: "100%",
+ padding: "8px 11px",
+ background: "rgba(255,255,255,0.05)",
+ border: "1px solid rgba(255,255,255,0.08)",
+ borderRadius: 7,
+ color: "#eeeef2",
+ fontSize: 13,
+ fontFamily: "inherit",
+ boxSizing: "border-box",
+ outline: "none",
+ opacity: amAdmin ? 1 : 0.5,
+ }}
+ />
+
+
+
+
+
+ {tags.map((t) => (
+
+ {t}
+ {amAdmin && (
+ removeTag(t)}
+ style={{
+ background: "none",
+ border: "none",
+ color: "#a878e8",
+ cursor: "pointer",
+ fontSize: 13,
+ padding: 0,
+ lineHeight: 1,
+ fontFamily: "inherit",
+ }}
+ >
+ ×
+
+ )}
+
+ ))}
+
+ {amAdmin && (
+
+ setTagInput(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && addTag()}
+ placeholder="Add genre tag…"
+ style={{
+ flex: 1,
+ padding: "6px 10px",
+ background: "rgba(255,255,255,0.05)",
+ border: "1px solid rgba(255,255,255,0.08)",
+ borderRadius: 7,
+ color: "#eeeef2",
+ fontSize: 12,
+ fontFamily: "inherit",
+ outline: "none",
+ }}
+ />
+
+ +
+
+
+ )}
+
+
+ {amAdmin && (
+
updateMutation.mutate({ name: nameInput.trim() || band.name, genre_tags: tags })}
+ disabled={updateMutation.isPending}
+ style={{
+ background: "rgba(232,162,42,0.14)",
+ border: "1px solid rgba(232,162,42,0.28)",
+ borderRadius: 6,
+ color: saved ? "#4dba85" : "#e8a22a",
+ cursor: "pointer",
+ padding: "7px 18px",
+ fontSize: 13,
+ fontWeight: 600,
+ fontFamily: "inherit",
+ transition: "color 0.2s",
+ }}
+ >
+ {updateMutation.isPending ? "Saving…" : saved ? "Saved ✓" : "Save changes"}
+
+ )}
+
+ {!amAdmin && (
+
Only admins can edit band settings.
+ )}
+
+
+
+ {/* Danger zone */}
+
+
Delete this band
+
+ Removes all members and deletes comments. Storage files are NOT deleted.
+
+
+ Delete band
+
+
+
+ );
+}
+
+// ── BandSettingsPage ──────────────────────────────────────────────────────────
+
+export function BandSettingsPage() {
+ const { bandId, panel } = useParams<{ bandId: string; panel: string }>();
+ const navigate = useNavigate();
+
+ const activePanel: Panel =
+ panel === "storage" ? "storage" : panel === "band" ? "band" : "members";
+
+ const { data: band, isLoading: bandLoading } = useQuery({
+ queryKey: ["band", bandId],
+ queryFn: () => getBand(bandId!),
+ enabled: !!bandId,
+ });
+
+ const { data: members, isLoading: membersLoading } = useQuery({
+ queryKey: ["members", bandId],
+ queryFn: () => api.get(`/bands/${bandId}/members`),
+ enabled: !!bandId,
+ });
+
+ const { data: me } = useQuery({
+ queryKey: ["me"],
+ queryFn: () => api.get("/auth/me"),
+ });
+
+ const amAdmin =
+ !!me && (members?.some((m) => m.id === me.id && m.role === "admin") ?? false);
+
+ const go = (p: Panel) => navigate(`/bands/${bandId}/settings/${p}`);
+
+ if (bandLoading) {
+ return Loading…
;
+ }
+ if (!band) {
+ return Band not found
;
+ }
+
+ return (
+
+ {/* ── Left panel nav ─────────────────────────────── */}
+
+
+ Band — {band.name}
+
+
go("members")} />
+ go("storage")} />
+ go("band")} />
+
+
+ {/* ── Panel content ──────────────────────────────── */}
+
+
+ {activePanel === "members" && (
+ <>
+
+ Members
+
+
+ Manage who has access to {band.name}'s recordings.
+
+
+ >
+ )}
+
+ {activePanel === "storage" && (
+ <>
+
+ Storage
+
+
+ Configure where {band.name} stores recordings.
+
+
+ >
+ )}
+
+ {activePanel === "band" && (
+ <>
+
+ Band Settings
+
+
+ Only admins can edit these settings.
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/web/src/test/helpers.tsx b/web/src/test/helpers.tsx
new file mode 100644
index 0000000..cc7e6a1
--- /dev/null
+++ b/web/src/test/helpers.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter, Routes, Route } from "react-router-dom";
+import { render } from "@testing-library/react";
+
+export function createTestQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, staleTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+}
+
+interface RenderOptions {
+ path?: string;
+ route?: string;
+}
+
+export function renderWithProviders(
+ ui: React.ReactElement,
+ { path = "/", route = "/" }: RenderOptions = {}
+) {
+ const queryClient = createTestQueryClient();
+ return render(
+
+
+
+
+
+
+
+ );
+}