mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(webapp): [phase 4] admin surfaces — org admin panel + hub settings
- org admin: rename, member roles/removal (self marked), project rename/delete, invite links (create/copy/revoke, uses + expiry), org-wide public-share audit with revoke; members get a read-only view - hub settings: verification/approval policy toggles (verification disabled without SMTP), read-only domains/self-signup/admins, pending signup queue with approve/deny (count feeds the admin bar) - panels replace the content pane without becoming routes (classic-app parity): Browser takes a panel prop, HubApp owns the state and any navigation closes it - e2e: 8 admin specs, all mutations self-reverting (42 total green) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
co-authored by
Claude Fable 5
parent
44b95143b0
commit
f60be366e7
@@ -186,11 +186,18 @@ refresh; invalidate after uploads/renames/admin actions — mirror today's
|
||||
`e2e/home.spec.ts`; suite is 34 specs, ~13s, stable across runs.
|
||||
|
||||
### Phase 4 — admin surfaces
|
||||
- [ ] Org admin: rename, members (role change/remove), invite create/list/
|
||||
revoke with expiry, org shares list + revoke.
|
||||
- [ ] Hub settings + pending-approval queue (approve/deny), policy toggles.
|
||||
- [ ] All actions confirm via modal where the current UI does; toasts on
|
||||
success/error.
|
||||
- [x] Org admin: rename, members (role change/remove, self marked),
|
||||
projects (rename/delete), invite create/list/revoke with expiry +
|
||||
uses, org-wide share audit with revoke; member view is read-only.
|
||||
- [x] Hub settings + pending-approval queue (approve/deny), policy
|
||||
toggles (verification disabled without SMTP), read-only
|
||||
domains/self-signup/admins rows.
|
||||
- [x] Destructive actions confirm via modal; toasts on success/error.
|
||||
Design note: panels are NOT routes (parity with the classic app) —
|
||||
Browser takes a `panel` prop that replaces the content pane and
|
||||
hides file actions; HubApp owns the state and clears it on any
|
||||
pathname change. Known accepted deviation: deleting the currently
|
||||
open project closes the panel (the URL fallback redirect fires).
|
||||
|
||||
### Phase 5 — parity gate & swap
|
||||
- [ ] Port the 17 checks from the pre-existing smoke suite (see
|
||||
@@ -253,7 +260,7 @@ file path; reload on `/insights`.
|
||||
- [x] Phase 1
|
||||
- [x] Phase 2
|
||||
- [x] Phase 3
|
||||
- [ ] Phase 4
|
||||
- [x] Phase 4
|
||||
- [ ] Phase 5
|
||||
|
||||
Blockers / deviations: (record here; stop rather than deviate silently)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { login, wikiId, ADMIN, MEMBER } from "./helpers";
|
||||
|
||||
// Phase 4: org admin (rename, members, projects, invites, share audit) and
|
||||
// hub settings (policy toggles, pending queue). Panels are not routes —
|
||||
// navigation closes them. Mutating specs revert their changes: the suite
|
||||
// shares one hub per run.
|
||||
|
||||
test("org admin: members with roles, self marked, rename round-trip", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#invite-btn"); // owner's Manage button
|
||||
await expect(page.locator("#org-title")).toHaveText("default");
|
||||
await expect(page.locator("#crumb")).toHaveText("default");
|
||||
await expect(page.locator(".admin-item", { hasText: ADMIN })).toContainText("(you)");
|
||||
const memberRow = page.locator(".admin-item", { hasText: MEMBER });
|
||||
await expect(memberRow.locator("select")).toHaveValue("member");
|
||||
|
||||
// Rename and revert
|
||||
await page.fill("#org-rename", "renamed-org");
|
||||
await page.click("#org-rename-btn");
|
||||
await expect(page.locator("#toast")).toContainText("Renamed");
|
||||
await expect(page.locator("#orgbar #org-name")).toHaveText("renamed-org");
|
||||
await page.fill("#org-rename", "default");
|
||||
await page.click("#org-rename-btn");
|
||||
await expect(page.locator("#orgbar #org-name")).toHaveText("default");
|
||||
});
|
||||
|
||||
test("org admin: member role change round-trip", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#invite-btn");
|
||||
const sel = page.locator(".admin-item", { hasText: MEMBER }).locator("select");
|
||||
await sel.selectOption("owner");
|
||||
await expect(page.locator("#toast")).toContainText("Role updated");
|
||||
await expect(sel).toHaveValue("owner");
|
||||
await sel.selectOption("member");
|
||||
await expect(sel).toHaveValue("member");
|
||||
});
|
||||
|
||||
test("org admin: invite create shows in list, revoke removes it", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#invite-btn");
|
||||
await page.click(".admin-h .pbtn"); // New invite
|
||||
await expect(page.locator("#toast")).toContainText("Invite");
|
||||
const row = page.locator(".admin-item", { hasText: "/join/" }).first();
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator(".ai-tag")).toContainText("unused");
|
||||
await row.locator(".ai-del").click();
|
||||
await page.click(".modal .danger-btn"); // confirm revoke
|
||||
await expect(page.locator("#toast")).toContainText("Revoked");
|
||||
await expect(page.locator(".admin-item", { hasText: "/join/" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("org admin: public share audit lists and revokes", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.request.post(`/api/p/${pid}/shares`, { data: { path: "index.md" } });
|
||||
await page.click("#invite-btn");
|
||||
const row = page.locator(".admin-item", { hasText: "index.md" });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator(".ai-tag")).toContainText("wiki");
|
||||
await row.locator(".ai-del").click();
|
||||
await page.click(".modal .danger-btn");
|
||||
await expect(page.locator("#toast")).toContainText("Share revoked");
|
||||
await expect(page.locator(".admin-item", { hasText: "index.md" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("org admin: project rename and delete", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.request.post("/api/projects", { data: { name: "doomed" } });
|
||||
await page.reload(); // pick up the new project
|
||||
await page.click("#invite-btn");
|
||||
const row = page.locator(".admin-item", { hasText: "doomed" });
|
||||
await row.locator(".ai-btn", { hasText: "Rename" }).click();
|
||||
await page.fill(".modal-input", "doomed-2");
|
||||
await page.click(".modal .pbtn");
|
||||
await expect(page.locator("#toast")).toContainText("Renamed");
|
||||
const row2 = page.locator(".admin-item", { hasText: "doomed-2" });
|
||||
await expect(row2).toBeVisible();
|
||||
await row2.locator(".ai-del").click();
|
||||
await page.click(".modal .danger-btn");
|
||||
await expect(page.locator("#toast")).toContainText("Deleted");
|
||||
await expect(page.locator(".admin-item", { hasText: "doomed-2" })).toHaveCount(0);
|
||||
await expect(page.locator("#projects .row .label", { hasText: "doomed-2" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("member sees the org panel read-only", async ({ page }) => {
|
||||
await login(page, MEMBER);
|
||||
await page.click("#orgbar #org-name");
|
||||
await expect(page.locator("#org-title")).toContainText("member");
|
||||
await expect(page.locator("#org-rename")).toHaveCount(0);
|
||||
await expect(page.locator(".admin-item select")).toHaveCount(0);
|
||||
await expect(page.locator(".admin-item .ai-tag").first()).toBeVisible(); // role tags
|
||||
});
|
||||
|
||||
test("hub settings: policy view, save round-trip, pending queue empty", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#adminbar");
|
||||
await expect(page.locator("#crumb")).toHaveText("Signup & access");
|
||||
await expect(page.locator(".admin h1")).toHaveText("Signup & access");
|
||||
// Server has no SMTP: verification toggle disabled
|
||||
const ver = page.locator(".admin-item.toggle").first().locator("input");
|
||||
await expect(ver).toBeDisabled();
|
||||
await expect(page.locator(".admin-item", { hasText: "Self-signup" })).toContainText("invite-only");
|
||||
await expect(page.locator(".admin-item", { hasText: "Hub admins" })).toContainText(ADMIN);
|
||||
// Toggle approval on, save, revert
|
||||
const app = page.locator(".admin-item.toggle").nth(1).locator("input");
|
||||
await app.check();
|
||||
await page.click(".admin > .pbtn");
|
||||
await expect(page.locator("#toast")).toContainText("policy saved");
|
||||
await app.uncheck();
|
||||
await page.click(".admin > .pbtn");
|
||||
await expect(page.locator("#toast")).toContainText("policy saved");
|
||||
await expect(page.locator(".admin-empty", { hasText: "No one is waiting" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("navigating away closes an open admin panel", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#adminbar");
|
||||
await expect(page.locator(".admin h1")).toBeVisible();
|
||||
await page.click('#tree .row[data-path="index.md"]');
|
||||
await expect(page.locator("#content h1")).toHaveText("Wiki");
|
||||
await expect(page.locator(".admin")).toHaveCount(0);
|
||||
});
|
||||
@@ -124,6 +124,36 @@ export interface ShareCreated {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// GET /api/orgs/{org}/invites (handleInviteList, orgs.go)
|
||||
export interface OrgInviteInfo {
|
||||
token: string;
|
||||
url: string;
|
||||
creator?: string;
|
||||
created?: string;
|
||||
expires: string;
|
||||
uses: number;
|
||||
}
|
||||
|
||||
// GET /api/orgs/{org}/shares (handleOrgShares, admin.go)
|
||||
export interface OrgShareInfo {
|
||||
token: string;
|
||||
url: string;
|
||||
path: string;
|
||||
project_name?: string;
|
||||
creator?: string;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
// GET/POST /api/admin/policy (handleAdminPolicy, admin.go)
|
||||
export interface AdminPolicy {
|
||||
require_verification: boolean;
|
||||
require_approval: boolean;
|
||||
allow_signup: boolean;
|
||||
allowed_domains?: string[]; // read-only (server config)
|
||||
admins?: string[]; // read-only (server config)
|
||||
mailer: boolean;
|
||||
}
|
||||
|
||||
// POST .../upload/init (handleUploadInit, upload.go)
|
||||
export interface UploadPlan {
|
||||
mode: "direct" | "server";
|
||||
|
||||
@@ -37,6 +37,10 @@ export default function Browser(props: {
|
||||
projects?: Project[];
|
||||
canInsights?: boolean;
|
||||
sidebar: { vault: ReactNode; projectsNav?: ReactNode; orgBar?: ReactNode };
|
||||
// Admin panels (org admin, hub settings) replace the content pane without
|
||||
// touching the URL — matching the classic app, where they were never
|
||||
// routes. Any navigation closes them (the caller owns that state).
|
||||
panel?: { crumb: string; body: ReactNode } | null;
|
||||
}) {
|
||||
const { config, apiBase, route, hub, project } = props;
|
||||
const routeKey = useLocationPath(); // scroll memo key, one slot per URL
|
||||
@@ -138,11 +142,12 @@ export default function Browser(props: {
|
||||
const uploadInput = useRef<HTMLInputElement>(null);
|
||||
const downloadRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const canShare = hub && !!project && isFile;
|
||||
const canHistory = hub && !!project;
|
||||
const panel = props.panel ?? null;
|
||||
const canShare = !panel && hub && !!project && isFile;
|
||||
const canHistory = !panel && hub && !!project;
|
||||
const canUpload = !!config.upload?.enabled && (!hub || !!project);
|
||||
const canDownload = isFile;
|
||||
const canMore = isFile || (hub && !!project && isDir);
|
||||
const canDownload = !panel && isFile;
|
||||
const canMore = !panel && (isFile || (hub && !!project && isDir));
|
||||
const downloadURL = apiBase + "download?path=" + encodeURIComponent(path);
|
||||
|
||||
const shareNow = useCallback(async () => {
|
||||
@@ -243,7 +248,10 @@ export default function Browser(props: {
|
||||
const isFolderFn = useCallback((p: string) => dirIndex.has(p), [dirIndex]);
|
||||
let contentClass = "markdown";
|
||||
let view: ReactNode;
|
||||
if (route.view === "insights") {
|
||||
if (panel) {
|
||||
contentClass = "view";
|
||||
view = panel.body;
|
||||
} else if (route.view === "insights") {
|
||||
contentClass = "view";
|
||||
view = props.canInsights ? (
|
||||
<Insights
|
||||
@@ -323,7 +331,9 @@ export default function Browser(props: {
|
||||
view = <div className="empty">Select a file to read it.</div>;
|
||||
}
|
||||
|
||||
const crumb = path ? (
|
||||
const crumb = panel ? (
|
||||
panel.crumb
|
||||
) : path ? (
|
||||
<Breadcrumbs path={path} onOpenFolder={openPath} />
|
||||
) : route.view === "insights" ? (
|
||||
"Insights — " + (project?.name ?? "")
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { InviteAccepted, Project, ProjectCreated, ServerConfig } from "../a
|
||||
import { useOrgs, usePending, useProjects, useHubRefresh } from "../hooks/useHub";
|
||||
import { parseRoute } from "../router";
|
||||
import { navigate, Redirect, useLocationPath } from "../nav";
|
||||
import { AppShell, Topbar, VaultHeader } from "../components/shell";
|
||||
import { AppShell, Topbar, VaultHeader, closeSidebarOnMobile } from "../components/shell";
|
||||
import { OrgAdmin } from "../components/OrgAdmin";
|
||||
import { HubSettings } from "../components/HubSettings";
|
||||
import { ProjectNav } from "../components/ProjectNav";
|
||||
import { OrgBar } from "../components/OrgBar";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
@@ -17,6 +19,10 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
// Org just joined via an invite this page-load: prefer its projects over
|
||||
// whatever happens to be first in the list.
|
||||
const [joinedOrgId, setJoinedOrgId] = useState<string | null>(null);
|
||||
// Admin panels replace the content pane without touching the URL (they
|
||||
// were never routes in the classic app); any navigation closes them.
|
||||
const [panel, setPanel] = useState<null | { kind: "hub" } | { kind: "org"; orgId: string }>(null);
|
||||
useEffect(() => setPanel(null), [pathname]);
|
||||
|
||||
const joinToken = useMemo(() => {
|
||||
const m = pathname.match(/^\/join\/([0-9a-f]+)\/?$/);
|
||||
@@ -75,8 +81,27 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
name={projects ? (current ? current.name : brand) : "…"}
|
||||
onHome={current ? () => navigate("/" + current.id) : undefined}
|
||||
showSignout={config.auth.enabled}
|
||||
admin={isAdmin ? { pending: pending?.length || 0, onClick: () => {} } : undefined}
|
||||
gear={gearTarget ? { onClick: () => {} } : undefined}
|
||||
admin={
|
||||
isAdmin
|
||||
? {
|
||||
pending: pending?.length || 0,
|
||||
onClick: () => {
|
||||
setPanel({ kind: "hub" });
|
||||
closeSidebarOnMobile();
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
gear={
|
||||
gearTarget
|
||||
? {
|
||||
onClick: () => {
|
||||
setPanel({ kind: "org", orgId: gearTarget.id });
|
||||
closeSidebarOnMobile();
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -117,6 +142,24 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
);
|
||||
}
|
||||
|
||||
const panelOrg = panel?.kind === "org" ? orgs.find((o) => o.id === panel.orgId) : null;
|
||||
const activePanel =
|
||||
panel?.kind === "hub"
|
||||
? { crumb: "Signup & access", body: <HubSettings /> }
|
||||
: panelOrg
|
||||
? {
|
||||
crumb: panelOrg.name,
|
||||
body: (
|
||||
<OrgAdmin
|
||||
org={panelOrg}
|
||||
projects={projects}
|
||||
myEmail={config.me?.email || ""}
|
||||
onProjectsChanged={refresh}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
// Landing ("/") and unknown project ids both resolve to a real project
|
||||
// URL; replace so back/forward never bounces through the redirect.
|
||||
if (route.project !== current.id) {
|
||||
@@ -136,8 +179,17 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
sidebar={{
|
||||
vault,
|
||||
projectsNav: <ProjectNav projects={projects} currentId={current.id} />,
|
||||
orgBar: <OrgBar org={org} onManage={() => {}} />,
|
||||
orgBar: (
|
||||
<OrgBar
|
||||
org={org}
|
||||
onManage={(o) => {
|
||||
setPanel({ kind: "org", orgId: o.id });
|
||||
closeSidebarOnMobile();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
panel={activePanel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getJSON, postJSON } from "../api/http";
|
||||
import type { AdminPolicy } from "../api/types";
|
||||
import { usePending } from "../hooks/useHub";
|
||||
import { toast } from "../toast";
|
||||
|
||||
/* Hub-admin settings: signup/access policy. Verification & approval are
|
||||
live toggles; the domain allowlist and admin list are shown read-only
|
||||
(they're server-config owned, deliberately not browser-editable).
|
||||
Pending approvals live here too, so this is the single admin home. */
|
||||
export function HubSettings() {
|
||||
const qc = useQueryClient();
|
||||
const { data: pol, error } = useQuery({
|
||||
queryKey: ["admin", "policy"],
|
||||
queryFn: () => getJSON<AdminPolicy>("/api/admin/policy"),
|
||||
});
|
||||
const { data: pending } = usePending(true);
|
||||
const [ver, setVer] = useState(false);
|
||||
const [app, setApp] = useState(false);
|
||||
useEffect(() => {
|
||||
if (pol) {
|
||||
setVer(pol.require_verification && pol.mailer);
|
||||
setApp(pol.require_approval);
|
||||
}
|
||||
}, [pol]);
|
||||
useEffect(() => {
|
||||
if (error) toast((error as Error).message, true);
|
||||
}, [error]);
|
||||
if (!pol) return null;
|
||||
|
||||
const act = async (id: string, verb: "approve" | "deny", email: string) => {
|
||||
try {
|
||||
await postJSON(`/api/admin/pending/${id}/${verb}`);
|
||||
toast((verb === "approve" ? "Approved " : "Denied ") + email);
|
||||
qc.invalidateQueries({ queryKey: ["admin", "pending"] });
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin">
|
||||
<h1>Signup & access</h1>
|
||||
<p className="admin-sub">
|
||||
Who can create an account on this hub, and how new accounts are vetted.
|
||||
</p>
|
||||
|
||||
<h3>New-account vetting</h3>
|
||||
<div className="admin-list">
|
||||
<PolicyToggle
|
||||
label="Require email verification"
|
||||
desc={
|
||||
pol.mailer
|
||||
? "New accounts must click an emailed link before they can sign in — proves they control the address."
|
||||
: "Configure SMTP on the server (auth.smtp) to enable email verification."
|
||||
}
|
||||
checked={ver}
|
||||
disabled={!pol.mailer}
|
||||
onChange={setVer}
|
||||
/>
|
||||
<PolicyToggle
|
||||
label="Require admin approval"
|
||||
desc="New accounts wait for a hub admin to approve them before they gain access."
|
||||
checked={app}
|
||||
onChange={setApp}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="pbtn"
|
||||
style={{ marginTop: 14 }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await postJSON("/api/admin/policy", { require_verification: ver, require_approval: app });
|
||||
toast("Signup policy saved.");
|
||||
qc.invalidateQueries({ queryKey: ["admin", "policy"] });
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save policy
|
||||
</button>
|
||||
|
||||
<h3>Who can sign up</h3>
|
||||
<div className="admin-list">
|
||||
<div className="admin-item">
|
||||
<span className="ai-main">Allowed email domains</span>
|
||||
<span className="ai-tag">
|
||||
{pol.allowed_domains && pol.allowed_domains.length
|
||||
? pol.allowed_domains.map((d) => "@" + d).join(", ")
|
||||
: "any"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-item">
|
||||
<span className="ai-main">Self-signup</span>
|
||||
<span className="ai-tag">{pol.allow_signup ? "open" : "invite-only"}</span>
|
||||
</div>
|
||||
<div className="admin-item">
|
||||
<span className="ai-main">Hub admins</span>
|
||||
<span className="ai-tag">
|
||||
{pol.admins && pol.admins.length ? pol.admins.join(", ") : "none"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="admin-sub">
|
||||
Domains and admins are set in the server config file (they can't be widened from the
|
||||
browser).
|
||||
</p>
|
||||
|
||||
<h3>Pending signups</h3>
|
||||
<div className="admin-list">
|
||||
{(!pending || pending.length === 0) && (
|
||||
<div className="admin-empty">No one is waiting for approval.</div>
|
||||
)}
|
||||
{(pending || []).map((u) => (
|
||||
<div className="admin-item" key={u.id}>
|
||||
<span className="ai-main">{(u.name ? u.name + " · " : "") + u.email}</span>
|
||||
<button className="pbtn" onClick={() => act(u.id, "approve", u.email)}>
|
||||
Approve
|
||||
</button>
|
||||
<button className="ai-del" onClick={() => act(u.id, "deny", u.email)}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyToggle({
|
||||
label,
|
||||
desc,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="admin-item toggle" style={disabled ? { opacity: 0.55 } : undefined}>
|
||||
<span className="ai-main">
|
||||
<div className="tg-label">{label}</div>
|
||||
<div className="tg-desc">{desc}</div>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, getJSON, postJSON } from "../api/http";
|
||||
import type { Org, OrgInviteInfo, OrgShareInfo, Project } from "../api/types";
|
||||
import { modalConfirm, modalPrompt } from "../modal";
|
||||
import { toast } from "../toast";
|
||||
import { copyText } from "../util";
|
||||
|
||||
/* The org admin panel: members (owners can change roles / remove), rename,
|
||||
projects (rename / delete), invite links (create / revoke), and an
|
||||
org-wide audit of public shares. */
|
||||
export function OrgAdmin({
|
||||
org,
|
||||
projects,
|
||||
myEmail,
|
||||
onProjectsChanged,
|
||||
}: {
|
||||
org: Org;
|
||||
projects: Project[];
|
||||
myEmail: string;
|
||||
onProjectsChanged: () => Promise<void>;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const owner = org.role === "owner";
|
||||
const [renameVal, setRenameVal] = useState(org.name);
|
||||
|
||||
const refreshOrgs = () => qc.invalidateQueries({ queryKey: ["orgs"] });
|
||||
const refreshInvites = () => qc.invalidateQueries({ queryKey: ["invites", org.id] });
|
||||
const refreshShares = () => qc.invalidateQueries({ queryKey: ["orgShares", org.id] });
|
||||
|
||||
const { data: invites } = useQuery({
|
||||
queryKey: ["invites", org.id],
|
||||
queryFn: () => getJSON<{ invites: OrgInviteInfo[] }>(`/api/orgs/${org.id}/invites`),
|
||||
enabled: owner,
|
||||
select: (d) => d.invites || [],
|
||||
});
|
||||
const { data: shares } = useQuery({
|
||||
queryKey: ["orgShares", org.id],
|
||||
queryFn: () => getJSON<{ shares: OrgShareInfo[] }>(`/api/orgs/${org.id}/shares`),
|
||||
enabled: owner,
|
||||
select: (d) => d.shares || [],
|
||||
});
|
||||
|
||||
const orgProjects = projects.filter((p) => p.org === org.id);
|
||||
|
||||
return (
|
||||
<div className="admin">
|
||||
<h1 id="org-title">{org.name + (owner ? "" : " · member")}</h1>
|
||||
|
||||
{owner && (
|
||||
<div className="admin-row">
|
||||
<input
|
||||
id="org-rename"
|
||||
type="text"
|
||||
value={renameVal}
|
||||
onChange={(e) => setRenameVal(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="pbtn"
|
||||
id="org-rename-btn"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api("PATCH", "/api/orgs/" + org.id, { name: renameVal.trim() });
|
||||
toast("Renamed.");
|
||||
refreshOrgs();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Rename org
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3>Members</h3>
|
||||
<div className="admin-list">
|
||||
{org.members.map((m) => {
|
||||
const isSelf = !!myEmail && m.email.toLowerCase() === myEmail.toLowerCase();
|
||||
return (
|
||||
<div className="admin-item" key={m.email}>
|
||||
<span className="ai-main">{m.email + (isSelf ? " (you)" : "")}</span>
|
||||
{owner && !isSelf ? (
|
||||
<>
|
||||
<select
|
||||
value={m.role}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
await api(
|
||||
"PATCH",
|
||||
`/api/orgs/${org.id}/members/${encodeURIComponent(m.email)}`,
|
||||
{ role: e.target.value },
|
||||
);
|
||||
toast("Role updated.");
|
||||
} catch (err) {
|
||||
toast((err as Error).message, true);
|
||||
}
|
||||
refreshOrgs();
|
||||
}}
|
||||
>
|
||||
<option value="owner">owner</option>
|
||||
<option value="member">member</option>
|
||||
</select>
|
||||
<button
|
||||
className="ai-del"
|
||||
onClick={async () => {
|
||||
if (
|
||||
!(await modalConfirm(
|
||||
"Remove member",
|
||||
`Remove ${m.email} from ${org.name}?`,
|
||||
"Remove",
|
||||
true,
|
||||
))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api("DELETE", `/api/orgs/${org.id}/members/${encodeURIComponent(m.email)}`);
|
||||
toast("Removed.");
|
||||
refreshOrgs();
|
||||
} catch (err) {
|
||||
toast((err as Error).message, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="ai-tag">{m.role}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{owner && (
|
||||
<>
|
||||
<h3>Projects</h3>
|
||||
<div className="admin-list">
|
||||
{orgProjects.length === 0 && <div className="admin-empty">No projects yet.</div>}
|
||||
{orgProjects.map((p) => (
|
||||
<div className="admin-item" key={p.id}>
|
||||
<span className="ai-main">{p.name}</span>
|
||||
<button
|
||||
className="ai-btn"
|
||||
onClick={async () => {
|
||||
const name = await modalPrompt("Rename project", "New name", p.name, "Rename");
|
||||
if (!name || name === p.name) return;
|
||||
try {
|
||||
await api("PATCH", "/api/projects/" + p.id, { name });
|
||||
toast("Renamed.");
|
||||
await onProjectsChanged();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
className="ai-del"
|
||||
onClick={async () => {
|
||||
if (
|
||||
!(await modalConfirm(
|
||||
"Delete project",
|
||||
`Delete “${p.name}”? Its files stay in storage, but it's removed from the hub.`,
|
||||
"Delete",
|
||||
true,
|
||||
))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api("DELETE", "/api/projects/" + p.id);
|
||||
toast(`Deleted “${p.name}”.`);
|
||||
await onProjectsChanged();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="admin-h">
|
||||
<h3>Invite links</h3>
|
||||
<button
|
||||
className="pbtn"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const out = await postJSON<{ url: string }>(`/api/orgs/${org.id}/invites`);
|
||||
const ok = await copyText(out.url);
|
||||
toast(ok ? "Invite link copied to clipboard." : "Invite created — copy it from the list below.");
|
||||
refreshInvites();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
New invite
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-list">
|
||||
{invites && invites.length === 0 && (
|
||||
<div className="admin-empty">No active invite links.</div>
|
||||
)}
|
||||
{(invites || []).map((inv) => (
|
||||
<div className="admin-item" key={inv.token}>
|
||||
<span
|
||||
className="ai-main mono"
|
||||
style={{ cursor: "pointer" }}
|
||||
title="Copy"
|
||||
onClick={() =>
|
||||
copyText(inv.url).then((ok) => toast(ok ? "Copied." : "Select and copy the link."))
|
||||
}
|
||||
>
|
||||
{inv.url}
|
||||
</span>
|
||||
<span className="ai-tag">
|
||||
{(inv.creator ? "by " + inv.creator + " · " : "") +
|
||||
(inv.uses ? inv.uses + " joined · " : "unused · ") +
|
||||
"expires " +
|
||||
new Date(inv.expires).toLocaleDateString()}
|
||||
</span>
|
||||
<button
|
||||
className="ai-del"
|
||||
onClick={async () => {
|
||||
if (
|
||||
!(await modalConfirm(
|
||||
"Revoke invite",
|
||||
"Revoke this invite link? Anyone still holding it won't be able to join.",
|
||||
"Revoke",
|
||||
true,
|
||||
))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api("DELETE", `/api/orgs/${org.id}/invites/${inv.token}`);
|
||||
toast("Revoked.");
|
||||
refreshInvites();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3>Public share links</h3>
|
||||
<div className="admin-list">
|
||||
{shares && shares.length === 0 && <div className="admin-empty">No public shares.</div>}
|
||||
{(shares || []).map((sh) => (
|
||||
<div className="admin-item" key={sh.token}>
|
||||
<span
|
||||
className="ai-main mono"
|
||||
style={{ cursor: "pointer" }}
|
||||
title={sh.url}
|
||||
onClick={() => window.open(sh.url, "_blank")}
|
||||
>
|
||||
{sh.path}
|
||||
</span>
|
||||
<span className="ai-tag">
|
||||
{(sh.project_name || "") +
|
||||
(sh.creator ? " · by " + sh.creator : "") +
|
||||
(sh.created ? " · " + new Date(sh.created).toLocaleDateString() : "")}
|
||||
</span>
|
||||
<button
|
||||
className="ai-del"
|
||||
onClick={async () => {
|
||||
if (
|
||||
!(await modalConfirm(
|
||||
"Revoke share link",
|
||||
`Revoke the public link to “${sh.path}”? Anyone with the URL will lose access.`,
|
||||
"Revoke",
|
||||
true,
|
||||
))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api("DELETE", "/api/shares/" + sh.token);
|
||||
toast("Share revoked.");
|
||||
refreshShares();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐻</text></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-D-n_MmWw.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-B1CZxrv9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-oVhdizP9.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user