Refactor storage to provider-agnostic band-scoped model

Replaces per-member Nextcloud credentials with a BandStorage model that
supports multiple providers. Credentials are Fernet-encrypted at rest;
worker receives audio via an internal streaming endpoint instead of
direct storage access.

- Add BandStorage DB model with partial unique index (one active per band)
- Add migrations 0007 (create band_storage) and 0008 (drop old nc columns)
- Add StorageFactory that builds the correct StorageClient from BandStorage
- Add storage router: connect/nextcloud, OAuth2 authorize/callback, list, disconnect
- Add Fernet encryption helpers in security/encryption.py
- Rewrite watcher for per-band polling via internal API config endpoint
- Update worker to stream audio from API instead of accessing storage directly
- Update frontend: new storage API in bands.ts, rewritten StorageSection,
  simplified band creation modal (no storage step)
- Add STORAGE_ENCRYPTION_KEY to all docker-compose files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-10 23:22:36 +02:00
parent ba22853bc7
commit b2d6b4d113
44 changed files with 1725 additions and 675 deletions

View File

@@ -5,7 +5,6 @@ export interface Band {
name: string;
slug: string;
genre_tags: string[];
nc_folder_path: string | null;
created_at: string;
updated_at: string;
memberships?: BandMembership[];
@@ -18,6 +17,25 @@ export interface BandMembership {
joined_at: string;
}
export interface BandStorage {
id: string;
band_id: string;
provider: string;
label: string | null;
is_active: boolean;
root_path: string | null;
created_at: string;
updated_at: string;
}
export interface NextcloudConnectData {
url: string;
username: string;
app_password: string;
label?: string;
root_path?: string;
}
export const listBands = () => api.get<Band[]>("/bands");
export const getBand = (bandId: string) => api.get<Band>(`/bands/${bandId}`);
@@ -25,5 +43,13 @@ export const createBand = (data: {
name: string;
slug: string;
genre_tags?: string[];
nc_base_path?: string;
}) => api.post<Band>("/bands", data);
export const listStorage = (bandId: string) =>
api.get<BandStorage[]>(`/bands/${bandId}/storage`);
export const connectNextcloud = (bandId: string, data: NextcloudConnectData) =>
api.post<BandStorage>(`/bands/${bandId}/storage/connect/nextcloud`, data);
export const disconnectStorage = (bandId: string) =>
api.delete(`/bands/${bandId}/storage`);

View File

@@ -4,7 +4,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { listBands, createBand } from "../api/bands";
import { getInitials } from "../utils";
import { useBandStore } from "../stores/bandStore";
import { api } from "../api/client";
// ── Shared primitives ──────────────────────────────────────────────────────────
@@ -30,27 +29,6 @@ const labelStyle: React.CSSProperties = {
marginBottom: 5,
};
// ── Step indicator ─────────────────────────────────────────────────────────────
function StepDots({ current, total }: { current: number; total: number }) {
return (
<div style={{ display: "flex", gap: 5, alignItems: "center" }}>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
style={{
width: i === current ? 16 : 6,
height: 6,
borderRadius: 3,
background: i === current ? "#14b8a6" : i < current ? "rgba(20,184,166,0.4)" : "rgba(255,255,255,0.12)",
transition: "all 0.2s",
}}
/>
))}
</div>
);
}
// ── Error banner ───────────────────────────────────────────────────────────────
function ErrorBanner({ msg }: { msg: string }) {
@@ -61,117 +39,20 @@ function ErrorBanner({ msg }: { msg: string }) {
);
}
// ── Step 1: Storage setup ──────────────────────────────────────────────────────
// ── Band creation form ─────────────────────────────────────────────────────────
interface Me { nc_configured: boolean; nc_url: string | null; nc_username: string | null; }
function StorageStep({ me, onNext }: { me: Me; onNext: () => void }) {
const qc = useQueryClient();
const [ncUrl, setNcUrl] = useState(me.nc_url ?? "");
const [ncUsername, setNcUsername] = useState(me.nc_username ?? "");
const [ncPassword, setNcPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const urlRef = useRef<HTMLInputElement>(null);
useEffect(() => { urlRef.current?.focus(); }, []);
const saveMutation = useMutation({
mutationFn: () =>
api.patch("/auth/me/settings", {
nc_url: ncUrl.trim() || null,
nc_username: ncUsername.trim() || null,
nc_password: ncPassword || null,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["me"] });
onNext();
},
onError: (err) => setError(err instanceof Error ? err.message : "Failed to save"),
});
const canSave = ncUrl.trim() && ncUsername.trim() && ncPassword;
return (
<>
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>NEXTCLOUD URL</label>
<input
ref={urlRef}
value={ncUrl}
onChange={(e) => setNcUrl(e.target.value)}
style={inputStyle}
placeholder="https://cloud.example.com"
type="url"
/>
</div>
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>USERNAME</label>
<input
value={ncUsername}
onChange={(e) => setNcUsername(e.target.value)}
style={inputStyle}
placeholder="your-nc-username"
autoComplete="username"
/>
</div>
<div style={{ marginBottom: 4 }}>
<label style={labelStyle}>APP PASSWORD</label>
<input
value={ncPassword}
onChange={(e) => setNcPassword(e.target.value)}
style={inputStyle}
type="password"
placeholder="Generate one in Nextcloud → Settings → Security"
autoComplete="current-password"
/>
</div>
<p style={{ margin: "0 0 20px", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
Use an app password, not your account password.
</p>
{error && <ErrorBanner msg={error} />}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
onClick={onNext}
style={{ padding: "8px 16px", background: "transparent", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 7, color: "rgba(232,233,240,0.5)", cursor: "pointer", fontSize: 13, fontFamily: "inherit" }}
>
Skip for now
</button>
<button
onClick={() => saveMutation.mutate()}
disabled={!canSave || saveMutation.isPending}
style={{ padding: "8px 18px", background: canSave ? "#14b8a6" : "rgba(20,184,166,0.3)", border: "none", borderRadius: 7, color: canSave ? "#fff" : "rgba(255,255,255,0.4)", cursor: canSave ? "pointer" : "default", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}
>
{saveMutation.isPending ? "Saving…" : "Save & Continue"}
</button>
</div>
</>
);
}
// ── Step 2: Band details ───────────────────────────────────────────────────────
function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: () => void }) {
function BandStep({ onClose }: { onClose: () => void }) {
const navigate = useNavigate();
const qc = useQueryClient();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [ncFolder, setNcFolder] = useState("");
const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => { nameRef.current?.focus(); }, []);
const mutation = useMutation({
mutationFn: () =>
createBand({
name,
slug,
...(ncFolder.trim() ? { nc_base_path: ncFolder.trim() } : {}),
}),
mutationFn: () => createBand({ name, slug }),
onSuccess: (band) => {
qc.invalidateQueries({ queryKey: ["bands"] });
onClose();
@@ -187,12 +68,6 @@ function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: (
return (
<>
{!ncConfigured && (
<div style={{ marginBottom: 18, padding: "9px 12px", background: "rgba(251,191,36,0.07)", border: "1px solid rgba(251,191,36,0.2)", borderRadius: 7, fontSize: 12, color: "rgba(251,191,36,0.8)", lineHeight: 1.5 }}>
Storage not configured recordings won't be scanned. You can set it up later in Settings Storage.
</div>
)}
{error && <ErrorBanner msg={error} />}
<div style={{ marginBottom: 14 }}>
@@ -207,7 +82,7 @@ function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: (
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 24 }}>
<label style={labelStyle}>SLUG</label>
<input
value={slug}
@@ -217,24 +92,9 @@ function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: (
/>
</div>
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", paddingTop: 18, marginBottom: 22 }}>
<label style={labelStyle}>
NEXTCLOUD FOLDER{" "}
<span style={{ color: "rgba(232,233,240,0.25)", fontWeight: 400, letterSpacing: 0 }}>(optional)</span>
</label>
<input
value={ncFolder}
onChange={(e) => setNcFolder(e.target.value)}
style={{ ...inputStyle, fontFamily: "monospace" }}
placeholder={slug ? `bands/${slug}/` : "bands/my-band/"}
disabled={!ncConfigured}
/>
<p style={{ margin: "7px 0 0", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
{ncConfigured
? <>Leave blank to auto-create <code style={{ color: "rgba(232,233,240,0.45)", fontFamily: "monospace" }}>bands/{slug || "slug"}/</code>.</>
: "Connect storage first to set a folder."}
</p>
</div>
<p style={{ margin: "0 0 20px", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
Connect storage after creating the band via Settings Storage.
</p>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
@@ -255,17 +115,9 @@ function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: (
);
}
// ── Create Band Modal (orchestrates steps) ─────────────────────────────────────
// ── Create Band Modal ──────────────────────────────────────────────────────────
function CreateBandModal({ onClose }: { onClose: () => void }) {
const { data: me, isLoading } = useQuery<Me>({
queryKey: ["me"],
queryFn: () => api.get("/auth/me"),
});
// Start on step 0 (storage) if NC not configured, otherwise jump straight to step 1 (band)
const [step, setStep] = useState<0 | 1 | null>(me ? (me.nc_configured ? 1 : 0) : null);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
@@ -273,9 +125,6 @@ function CreateBandModal({ onClose }: { onClose: () => void }) {
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
const totalSteps = me?.nc_configured === false ? 2 : 1;
const currentDot = step === 0 ? 0 : totalSteps - 1;
return (
<div
onClick={onClose}
@@ -285,28 +134,13 @@ function CreateBandModal({ onClose }: { onClose: () => void }) {
onClick={(e) => e.stopPropagation()}
style={{ background: "#112018", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 14, padding: 28, width: 420, boxShadow: "0 24px 64px rgba(0,0,0,0.6)" }}
>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 18 }}>
<div>
<h3 style={{ margin: "0 0 3px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}>
{step === 0 ? "Connect storage" : "New band"}
</h3>
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.4)" }}>
{step === 0 ? "Needed to scan and index your recordings." : "Create a workspace for your recordings."}
</p>
</div>
{totalSteps > 1 && step !== null && (
<StepDots current={currentDot} total={totalSteps} />
)}
<div style={{ marginBottom: 18 }}>
<h3 style={{ margin: "0 0 3px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}>New band</h3>
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.4)" }}>
Create a workspace for your recordings.
</p>
</div>
{isLoading || step === null ? (
<p style={{ color: "rgba(232,233,240,0.3)", fontSize: 13 }}>Loading</p>
) : step === 0 ? (
<StorageStep me={me!} onNext={() => setStep(1)} />
) : (
<BandStep ncConfigured={me?.nc_configured ?? false} onClose={onClose} />
)}
<BandStep onClose={onClose} />
</div>
</div>
);

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
import { listBands } from "../api/bands";
import { listBands, listStorage, connectNextcloud, disconnectStorage } from "../api/bands";
import { listInvites, revokeInvite } from "../api/invites";
import { useBandStore } from "../stores/bandStore";
import { getInitials } from "../utils";
@@ -14,9 +14,6 @@ interface MemberRead {
display_name: string;
email: string;
avatar_url: string | null;
nc_username: string | null;
nc_url: string | null;
nc_configured: boolean;
}
interface BandMember {
@@ -40,7 +37,6 @@ interface Band {
name: string;
slug: string;
genre_tags: string[];
nc_folder_path: string | null;
}
type Section = "profile" | "members" | "storage" | "band";
@@ -267,42 +263,48 @@ function ProfileSection({ me }: { me: MemberRead }) {
);
}
// ── Storage section (NC credentials + scan folder) ────────────────────────────
// ── Storage section ────────────────────────────────────────────────────────────
function StorageSection({ bandId, band, amAdmin, me }: { bandId: string; band: Band; amAdmin: boolean; me: MemberRead }) {
function StorageSection({ bandId, band, amAdmin }: { bandId: string; band: Band; amAdmin: boolean }) {
const qc = useQueryClient();
// NC credentials state
const [ncUrl, setNcUrl] = useState(me.nc_url ?? "");
const [ncUsername, setNcUsername] = useState(me.nc_username ?? "");
const [showConnect, setShowConnect] = useState(false);
const [ncUrl, setNcUrl] = useState("");
const [ncUsername, setNcUsername] = useState("");
const [ncPassword, setNcPassword] = useState("");
const [ncSaved, setNcSaved] = useState(false);
const [ncError, setNcError] = useState<string | null>(null);
const [ncRootPath, setNcRootPath] = useState("");
const [connectError, setConnectError] = useState<string | null>(null);
// Scan folder state
const [editingPath, setEditingPath] = useState(false);
const [folderInput, setFolderInput] = useState("");
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null);
const ncMutation = useMutation({
mutationFn: () => api.patch<MemberRead>("/auth/me/settings", {
nc_url: ncUrl || undefined,
nc_username: ncUsername || undefined,
nc_password: ncPassword || undefined,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["me"] });
setNcSaved(true); setNcError(null); setNcPassword("");
setTimeout(() => setNcSaved(false), 2500);
},
onError: (err) => setNcError(err instanceof Error ? err.message : "Save failed"),
const { data: storageConfigs, isLoading: storageLoading } = useQuery({
queryKey: ["storage", bandId],
queryFn: () => listStorage(bandId),
});
const pathMutation = useMutation({
mutationFn: (nc_folder_path: string) => api.patch(`/bands/${bandId}`, { nc_folder_path }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["band", bandId] }); setEditingPath(false); },
const activeStorage = storageConfigs?.find((s) => s.is_active) ?? null;
const connectMutation = useMutation({
mutationFn: () => connectNextcloud(bandId, {
url: ncUrl.trim(),
username: ncUsername.trim(),
app_password: ncPassword,
root_path: ncRootPath.trim() || undefined,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["storage", bandId] });
setShowConnect(false);
setNcUrl(""); setNcUsername(""); setNcPassword(""); setNcRootPath("");
setConnectError(null);
},
onError: (err) => setConnectError(err instanceof Error ? err.message : "Connection failed"),
});
const disconnectMutation = useMutation({
mutationFn: () => disconnectStorage(bandId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["storage", bandId] }),
});
async function startScan() {
@@ -340,33 +342,57 @@ function StorageSection({ bandId, band, amAdmin, me }: { bandId: string; band: B
finally { setScanning(false); setScanProgress(null); }
}
const defaultPath = `bands/${band.slug}/`;
const currentPath = band.nc_folder_path ?? defaultPath;
const canConnect = ncUrl.trim() && ncUsername.trim() && ncPassword;
return (
<div>
<SectionHeading title="Storage" subtitle="Configure Nextcloud credentials and your band's recording folder." />
<SectionHeading title="Storage" subtitle="Connect a storage provider to scan and index your band's recordings." />
{/* NC Connection */}
<div style={{ marginBottom: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 16 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: me.nc_configured ? "#34d399" : "rgba(232,233,240,0.25)", flexShrink: 0, boxShadow: me.nc_configured ? "0 0 6px rgba(52,211,153,0.5)" : "none" }} />
<span style={{ fontSize: 12, color: me.nc_configured ? "#34d399" : "rgba(232,233,240,0.4)" }}>
{me.nc_configured ? "Nextcloud connected" : "Nextcloud not configured"}
</span>
</div>
<div style={{ padding: "14px 16px", background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, marginBottom: 16 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8, marginBottom: 12 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#e8e9f0", marginBottom: 2 }}>Nextcloud Connection</div>
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.35)" }}>
Your personal credentials will move to per-band config in a future update.
</div>
{/* Status card */}
<div style={{ padding: "14px 16px", background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, marginBottom: 16 }}>
{storageLoading ? (
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.3)" }}>Loading</p>
) : activeStorage ? (
<>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "#34d399", flexShrink: 0, boxShadow: "0 0 6px rgba(52,211,153,0.5)" }} />
<span style={{ fontSize: 13, fontWeight: 600, color: "#34d399" }}>
{activeStorage.label ?? activeStorage.provider}
</span>
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.3)", textTransform: "capitalize" }}>({activeStorage.provider})</span>
</div>
{activeStorage.root_path && (
<div style={{ marginBottom: 10 }}>
<Label>Scan path</Label>
<code style={{ fontSize: 12, color: "#34d399", fontFamily: "monospace" }}>{activeStorage.root_path}</code>
</div>
)}
{amAdmin && (
<button
onClick={() => disconnectMutation.mutate()}
disabled={disconnectMutation.isPending}
style={{ padding: "5px 12px", background: "rgba(244,63,94,0.08)", border: "1px solid rgba(244,63,94,0.2)", borderRadius: 6, color: "#f87171", cursor: "pointer", fontSize: 11, fontFamily: "inherit" }}
>
{disconnectMutation.isPending ? "Disconnecting…" : "Disconnect"}
</button>
)}
</>
) : (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "rgba(232,233,240,0.2)", flexShrink: 0 }} />
<span style={{ fontSize: 12, color: "rgba(232,233,240,0.35)" }}>No storage connected</span>
</div>
)}
</div>
<div style={{ display: "grid", gap: 12 }}>
{/* Connect form — admin only, shown when no active storage or toggled */}
{amAdmin && (!activeStorage || showConnect) && (
<>
{activeStorage && <Divider />}
<div style={{ fontSize: 13, fontWeight: 600, color: "#e8e9f0", marginBottom: 12 }}>
{activeStorage ? "Replace connection" : "Connect Nextcloud"}
</div>
<div style={{ display: "grid", gap: 12, marginBottom: 14 }}>
<div>
<Label>Nextcloud URL</Label>
<Input value={ncUrl} onChange={(e) => setNcUrl(e.target.value)} placeholder="https://cloud.example.com" />
@@ -376,69 +402,58 @@ function StorageSection({ bandId, band, amAdmin, me }: { bandId: string; band: B
<Input value={ncUsername} onChange={(e) => setNcUsername(e.target.value)} />
</div>
<div>
<Label>Password / App Password</Label>
<Input type="password" value={ncPassword} onChange={(e) => setNcPassword(e.target.value)} placeholder={me.nc_configured ? "•••••••• (leave blank to keep)" : ""} />
<Label>App Password</Label>
<Input type="password" value={ncPassword} onChange={(e) => setNcPassword(e.target.value)} placeholder="Generate in Nextcloud → Settings → Security" />
</div>
<div>
<Label>Root path <span style={{ color: "rgba(232,233,240,0.25)", fontWeight: 400 }}>(optional)</span></Label>
<Input value={ncRootPath} onChange={(e) => setNcRootPath(e.target.value)} placeholder={`bands/${band.slug}/`} style={{ fontFamily: "monospace" }} />
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.28)", marginTop: 4 }}>
Use an app password from Nextcloud Settings Security.
Leave blank to auto-create <code style={{ fontFamily: "monospace" }}>bands/{band.slug}/</code>
</div>
</div>
</div>
{ncError && <p style={{ color: "#f87171", fontSize: 12, margin: "12px 0 0" }}>{ncError}</p>}
<div style={{ marginTop: 14 }}>
<SaveBtn pending={ncMutation.isPending} saved={ncSaved} onClick={() => ncMutation.mutate()} />
</div>
</div>
</div>
{/* Scan folder — admin only */}
{amAdmin && (
<>
<Divider />
<div style={{ fontSize: 13, fontWeight: 600, color: "#e8e9f0", marginBottom: 4 }}>Scan Folder</div>
<div style={{ fontSize: 12, color: "rgba(232,233,240,0.35)", marginBottom: 16, lineHeight: 1.55 }}>
RehearsalHub reads recordings from your Nextcloud files are never copied to our servers.
</div>
<div style={{ background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, padding: "12px 16px", marginBottom: 14 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div>
<Label>Scan path</Label>
<code style={{ fontSize: 13, color: "#34d399", fontFamily: "monospace" }}>{currentPath}</code>
</div>
{!editingPath && (
<button
onClick={() => { setFolderInput(band.nc_folder_path ?? ""); setEditingPath(true); }}
style={{ padding: "4px 10px", background: "transparent", border: `1px solid ${border}`, borderRadius: 6, color: "rgba(232,233,240,0.42)", cursor: "pointer", fontSize: 11, fontFamily: "inherit" }}
>
Edit
</button>
)}
</div>
{editingPath && (
<div style={{ marginTop: 12 }}>
<Input value={folderInput} onChange={(e) => setFolderInput(e.target.value)} placeholder={defaultPath} style={{ fontFamily: "monospace" }} />
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button onClick={() => pathMutation.mutate(folderInput)} disabled={pathMutation.isPending}
style={{ padding: "6px 14px", background: "rgba(20,184,166,0.12)", border: "1px solid rgba(20,184,166,0.3)", borderRadius: 6, color: "#2dd4bf", cursor: "pointer", fontSize: 12, fontWeight: 600, fontFamily: "inherit" }}>
{pathMutation.isPending ? "Saving…" : "Save"}
</button>
<button onClick={() => setEditingPath(false)}
style={{ padding: "6px 14px", background: "transparent", border: `1px solid ${border}`, borderRadius: 6, color: "rgba(232,233,240,0.42)", cursor: "pointer", fontSize: 12, fontFamily: "inherit" }}>
Cancel
</button>
</div>
</div>
{connectError && <p style={{ color: "#f87171", fontSize: 12, marginBottom: 12 }}>{connectError}</p>}
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => connectMutation.mutate()}
disabled={!canConnect || connectMutation.isPending}
style={{ padding: "8px 18px", background: canConnect ? "linear-gradient(135deg, #0d9488, #06b6d4)" : "rgba(20,184,166,0.2)", border: "none", borderRadius: 8, color: canConnect ? "white" : "rgba(255,255,255,0.35)", cursor: canConnect ? "pointer" : "default", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}
>
{connectMutation.isPending ? "Connecting…" : "Connect"}
</button>
{activeStorage && (
<button onClick={() => setShowConnect(false)}
style={{ padding: "8px 14px", background: "transparent", border: `1px solid ${border}`, borderRadius: 8, color: "rgba(232,233,240,0.42)", cursor: "pointer", fontSize: 13, fontFamily: "inherit" }}>
Cancel
</button>
)}
</div>
</>
)}
{amAdmin && activeStorage && !showConnect && (
<button
onClick={() => setShowConnect(true)}
style={{ padding: "7px 14px", background: "transparent", border: `1px solid ${border}`, borderRadius: 8, color: "rgba(232,233,240,0.42)", cursor: "pointer", fontSize: 12, fontFamily: "inherit", marginBottom: 16 }}
>
Replace connection
</button>
)}
{/* Scan — admin only, only if active storage */}
{amAdmin && activeStorage && (
<>
<Divider />
<div style={{ fontSize: 13, fontWeight: 600, color: "#e8e9f0", marginBottom: 4 }}>Scan Recordings</div>
<div style={{ fontSize: 12, color: "rgba(232,233,240,0.35)", marginBottom: 12, lineHeight: 1.55 }}>
RehearsalHub reads recordings from storage files are never copied to our servers.
</div>
<button
onClick={startScan} disabled={scanning}
style={{ padding: "7px 16px", background: scanning ? "transparent" : "rgba(52,211,153,0.08)", border: `1px solid ${scanning ? border : "rgba(52,211,153,0.25)"}`, borderRadius: 8, color: scanning ? "rgba(232,233,240,0.28)" : "#34d399", cursor: scanning ? "default" : "pointer", fontSize: 12, fontFamily: "inherit", transition: "all 0.12s" }}>
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
{scanning ? "Scanning…" : "⟳ Scan Storage"}
</button>
{scanning && scanProgress && (
<div style={{ marginTop: 10, background: "rgba(255,255,255,0.03)", border: `1px solid ${border}`, borderRadius: 8, color: "rgba(232,233,240,0.42)", fontSize: 12, padding: "8px 14px", fontFamily: "monospace" }}>
{scanProgress}
@@ -750,7 +765,7 @@ export function SettingsPage() {
<div style={{ padding: "0 16px 24px" }}>
{section === "profile" && <ProfileSection me={me} />}
{section === "members" && activeBandId && band && <MembersSection bandId={activeBandId} band={band} amAdmin={amAdmin} members={members} membersLoading={membersLoading} />}
{section === "storage" && activeBandId && band && <StorageSection bandId={activeBandId} band={band} amAdmin={amAdmin} me={me} />}
{section === "storage" && activeBandId && band && <StorageSection bandId={activeBandId} band={band} amAdmin={amAdmin} />}
{section === "band" && activeBandId && band && amAdmin && <BandSection bandId={activeBandId} band={band} />}
</div>
</div>
@@ -832,7 +847,7 @@ export function SettingsPage() {
<MembersSection bandId={activeBandId} band={band} amAdmin={amAdmin} members={members} membersLoading={membersLoading} />
)}
{section === "storage" && activeBandId && band && (
<StorageSection bandId={activeBandId} band={band} amAdmin={amAdmin} me={me} />
<StorageSection bandId={activeBandId} band={band} amAdmin={amAdmin} />
)}
{section === "band" && activeBandId && band && amAdmin && (
<BandSection bandId={activeBandId} band={band} />