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:
Snow Lee
2026-07-13 10:51:01 -07:00
co-authored by Claude Fable 5
parent 44b95143b0
commit f60be366e7
10 changed files with 711 additions and 28 deletions
+13 -6
View File
@@ -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)
+123
View File
@@ -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);
});
+30
View File
@@ -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";
+16 -6
View File
@@ -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 ?? "")
+56 -4
View File
@@ -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 &amp; 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
+1 -1
View File
@@ -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'>&#128059;</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>