fix(hub): manage a file's public links from the file, not the org panel (BEA-16) (#66)

Minting a public link was one click on the file page; revoking it meant
knowing to go avatar menu → Organization → scroll to "PUBLIC SHARE LINKS".
The action was instant and local, the undo remote and unhinted.

GET /api/p/{project}/shares already existed at PermRead with no frontend
consumer, so this is UI-only:

- A "Publicly shared" banner on the file page whenever the open file has
  live links: the count, each URL, and copy / open / revoke — the same
  words the Share dialog uses. Revoking updates it in place.
- A "Public links" card in project Settings listing that project's live
  links (path, who, when, expiry) with Revoke.
- One SharesTable behind both, plus the org-wide audit, which stays as the
  cross-project view and now links each row back to its file.

The banner shows for anyone with read (a member should know the folder
they rely on is exposed); Revoke only where the Share button already is.

The spec's double-mint bug does not exist: ShareDB.Create already reuses a
live share for (project, path) when neither side has a TTL, and the web UI
never sends expires_in. The real defect was the dialog claiming "Public
link created" on a second click — it now just says "Public link".

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-28 07:11:32 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent aeee881fa6
commit f35d889cfb
16 changed files with 615 additions and 302 deletions
+1
View File
@@ -62,6 +62,7 @@ classDiagram
FileView FolderListing FileTree
HistoryView HistoryRow DiffView VersionBanner
Insights ShareDialog
ShareBanner SharesTable AdminTable
OrgAdmin HubSettings ProjectSettings
Palette shell AccountBar ...
}
+100 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { login, wikiId, expectToast } from "./helpers";
import { login, wikiId, expectToast, READER } from "./helpers";
// Phase 2: tree, folder listings (heat dots + change feed), file views
// (markdown/wikilinks/images), breadcrumbs, upload, share, palette.
@@ -190,6 +190,105 @@ test("tree chevron folds and unfolds a folder", async ({ page }) => {
await expect(page.locator('#tree .row[data-path="notes/readme.md"]')).toBeVisible();
});
// BEA-16: the undo for "I made this public" lives on the file, not three
// clicks away in the org panel.
test("public link: the file page says it is shared, and revokes without a reload", async ({
page,
}) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/guide.md`);
await expect(page.locator(".share-banner")).toHaveCount(0);
await page.click("#share-btn");
const url = (await page.locator(".modal-url").textContent())!;
await page.click(".modal button:has-text('Done')");
// The indicator is on the file itself, and it is still there after a reload
// (the dialog used to be the only place the link — and its Revoke — existed).
await page.reload();
const banner = page.locator(".share-banner");
await expect(banner).toBeVisible();
await expect(banner).toContainText("Publicly shared");
await expect(banner).toContainText("1 active link");
await expect(banner).toContainText("no expiry");
expect((await page.request.get(url)).status()).toBe(200);
// Revoking from the file page kills the link and updates in place.
await banner.locator(".ai-del").click();
await page.click(".modal .danger-btn");
await expectToast(page, "Share revoked");
await expect(banner).toHaveCount(0);
expect((await page.request.get(url)).status()).toBe(404);
});
test("project settings lists this project's public links and revokes them", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.request.post(`/api/p/${pid}/shares`, { data: { path: "notes/readme.md" } });
await page.goto(`/${pid}/settings`);
const row = page.locator(".admin-item", { hasText: "notes/readme.md" });
await expect(row).toBeVisible();
await expect(row.locator(".ai-tag")).toContainText("by e2e@example.com");
await expect(row.locator(".ai-tag")).toContainText("no expiry");
await row.locator(".ai-del").click();
await page.click(".modal .danger-btn");
await expectToast(page, "Share revoked");
await expect(page.locator(".admin-item", { hasText: "notes/readme.md" })).toHaveCount(0);
await expect(page.locator(".admin-empty", { hasText: "No public links." })).toBeVisible();
});
test("a read-only member sees the public-link banner but cannot revoke", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
const made = await (
await page.request.post(`/api/p/${pid}/shares`, { data: { path: "index.md" } })
).json();
// A real second identity in this page: drop the admin session first, or
// the helper's first-time form login never sees /auth/login.
await page.context().clearCookies();
await login(page, READER);
await page.goto(`/${pid}/index.md`);
const banner = page.locator(".share-banner");
await expect(banner).toBeVisible();
await expect(banner).toContainText("Publicly shared");
await expect(banner.locator("button:has-text('Copy link')")).toBeVisible();
await expect(banner.locator(".ai-del")).toHaveCount(0);
await expect(page.locator("#share-btn")).toHaveCount(0);
await page.context().clearCookies();
await login(page); // clean up as someone who may
await page.request.delete(`/api/shares/${made.token}`);
});
test("public links: banner and settings table fit a 390px viewport", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
const made = await (
await page.request.post(`/api/p/${pid}/shares`, { data: { path: "guide.md" } })
).json();
await page.setViewportSize({ width: 390, height: 780 });
const sideways = () =>
page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1);
await page.goto(`/${pid}/guide.md`);
await expect(page.locator(".share-banner")).toBeVisible();
expect(await sideways()).toBe(false);
await page.goto(`/${pid}/settings`);
await expect(page.locator(".admin-item", { hasText: "guide.md" })).toBeVisible();
expect(await sideways()).toBe(false);
// The table takes its own horizontal scroll rather than widening the page.
const box = page.locator(".project-settings .admin-card-table").last();
expect(await box.evaluate((el) => getComputedStyle(el).overflowX)).toBe("auto");
await page.request.delete(`/api/shares/${made.token}`);
});
// BEA-7: a history row is an address for the version it describes.
test("history row opens THAT version, banner says so, View current returns", async ({ page }) => {
+7 -2
View File
@@ -182,14 +182,19 @@ export interface OrgInviteInfo {
uses: number;
}
// GET /api/orgs/{org}/shares (handleOrgShares, admin.go)
export interface OrgShareInfo {
// One live public link. Both listings return the same shape (shareJSON,
// shares.go): GET /api/p/{project}/shares for one project, and
// GET /api/orgs/{org}/shares for the org-wide audit, which alone adds
// project_name.
export interface ShareInfo {
token: string;
url: string;
path: string;
project: string;
project_name?: string;
creator?: string;
created?: string;
expires?: string;
}
// GET/POST /api/admin/policy (handleAdminPolicy, admin.go)
+30 -2
View File
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { atLeast } from "../api/types";
import type { Project, ServerConfig } from "../api/types";
import { useHeat, useTree } from "../hooks/useBrowse";
import { useShares } from "../hooks/useHub";
import { urlForPath, urlForView, type Route } from "../router";
import { currentNavType, navigate, useLocationPath } from "../nav";
import { HTML_EXT, copyText } from "../util";
@@ -21,6 +22,7 @@ import { Breadcrumbs } from "../components/Breadcrumbs";
import { FolderListing } from "../components/FolderListing";
import { FileView } from "../components/FileView";
import { ShareDialog } from "../components/ShareDialog";
import { ShareBanner } from "../components/ShareBanner";
import { Palette, type PaletteItem } from "../components/Palette";
import { ConnectGuide } from "../components/ConnectGuide";
import { Insights, useInsightsDevices } from "../components/Insights";
@@ -157,6 +159,15 @@ export default function Browser(props: {
// Minting a public link is a write. A read-only member sees no Share
// button rather than a button that 403s.
const canShare = !panel && hub && !!project && isFile && atLeast(project.perm, "write");
// The project's live public links, filtered to the open file. One query
// for the whole project (Settings reads the same cache entry), so opening
// a file costs no extra request.
const { data: shares } = useShares(project?.id, hub && !!project);
const refreshShares = useCallback(
() => void qc.invalidateQueries({ queryKey: ["shares", project?.id] }),
[qc, project?.id],
);
const fileShares = isFile ? (shares || []).filter((s) => s.path === path) : [];
const canHistory = !panel && hub && !!project;
// Browser upload is deliberately absent (for now): content enters through
// local sync only; the web app is a read/share/history surface.
@@ -181,10 +192,11 @@ export default function Browser(props: {
const s = await r.json();
const copied = await copyText(s.url);
setShare({ url: s.url, copied });
refreshShares(); // the banner appears (or stays) without a reload
} catch (err) {
toast("Share failed: " + (err as Error).message, true);
}
}, [apiBase, path]);
}, [apiBase, path, refreshShares]);
const historyNow = useCallback(() => {
if (!path) return openHistory("");
@@ -455,10 +467,26 @@ export default function Browser(props: {
onContentScroll={onScroll}
>
<Page width={pageWidth} className={pageClass}>
{!panel && isFile && (
<ShareBanner
shares={fileShares}
canRevoke={!!project && atLeast(project.perm, "write")}
onChanged={refreshShares}
/>
)}
{view}
</Page>
</AppShell>
{share && <ShareDialog url={share.url} copied={share.copied} onClose={() => setShare(null)} />}
{share && (
<ShareDialog
url={share.url}
copied={share.copied}
onClose={() => {
setShare(null);
refreshShares(); // the dialog can revoke; the banner must agree
}}
/>
)}
<Palette open={paletteOpen} onClose={() => setPaletteOpen(false)} candidates={paletteCandidates} />
</>
);
@@ -0,0 +1,58 @@
import { flexRender, type Table as RTable } from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
/* The render shell every react-table admin list shares: the .admin-item /
.ai-* vocabulary the CSS already styles, plus a header cell that is
sortable from the keyboard. It lives here because three tables (members,
org shares, project shares) render identically two of them used to
carry copies of this markup and drifted apart. */
// SortableHead is a real button inside the th, so sorting is reachable by
// keyboard with the direction announced rather than carried by a glyph
// alone.
export function SortableHead({ header }: { header: any }) {
const sorted = header.column.getIsSorted();
if (!header.column.getCanSort()) {
// A column with no header text and nothing to sort (the actions column)
// would be a dead tab stop with an empty accessible name.
return <TableHead>{flexRender(header.column.columnDef.header, header.getContext())}</TableHead>;
}
return (
<TableHead
data-sort={sorted || undefined}
aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "none"}
>
<button type="button" className="th-sort" onClick={header.column.getToggleSortingHandler()}>
{flexRender(header.column.columnDef.header, header.getContext())}
{sorted === "asc" ? " ↑" : sorted === "desc" ? " ↓" : ""}
</button>
</TableHead>
);
}
export function AdminTable<T>({ table, className }: { table: RTable<T>; className?: string }) {
return (
<div className={"admin-list admin-card-table" + (className ? " " + className : "")}>
<Table className="admin-table">
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<SortableHead key={h.id} header={h} />
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((r) => (
<TableRow key={r.id} className="admin-item">
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -2,7 +2,6 @@ import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
@@ -12,12 +11,13 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { api, getJSON, postJSON } from "../api/http";
import type { Org, OrgInviteInfo, OrgShareInfo, Project } from "../api/types";
import type { Org, OrgInviteInfo, ShareInfo, Project } from "../api/types";
import { modalConfirm } from "../modal";
import { toast } from "../toast";
import { copyText } from "../util";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { AdminTable } from "./AdminTable";
import { SharesTable } from "./SharesTable";
/* The org admin panel: members (owners can change roles / remove), rename,
projects (rename / delete), invite links (create / revoke), and an
@@ -29,30 +29,6 @@ const renameSchema = z.object({
});
type RenameForm = z.infer<typeof renameSchema>;
// SortableHead is both tables' header cell: a real button inside the th so
// sorting is reachable by keyboard, with the direction announced rather than
// carried by a glyph alone. Two tables ten centimetres apart had opposite
// accessibility contracts before this existed.
function SortableHead({ header }: { header: any }) {
const sorted = header.column.getIsSorted();
if (!header.column.getCanSort()) {
// The actions column has no header text and nothing to sort; a button
// here is a dead tab stop with an empty accessible name.
return <TableHead>{flexRender(header.column.columnDef.header, header.getContext())}</TableHead>;
}
return (
<TableHead
data-sort={sorted || undefined}
aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "none"}
>
<button type="button" className="th-sort" onClick={header.column.getToggleSortingHandler()}>
{flexRender(header.column.columnDef.header, header.getContext())}
{sorted === "asc" ? " ↑" : sorted === "desc" ? " ↓" : ""}
</button>
</TableHead>
);
}
type Member = Org["members"][number];
export function OrgAdmin({
@@ -84,7 +60,7 @@ export function OrgAdmin({
});
const { data: shares } = useQuery({
queryKey: ["orgShares", org.id],
queryFn: () => getJSON<{ shares: OrgShareInfo[] }>(`/api/orgs/${org.id}/shares`),
queryFn: () => getJSON<{ shares: ShareInfo[] }>(`/api/orgs/${org.id}/shares`),
enabled: owner,
select: (d) => d.shares || [],
});
@@ -227,7 +203,11 @@ export function OrgAdmin({
</div>
<h3>Public share links</h3>
<SharesTable shares={shares || []} onChanged={refreshShares} />
<p className="admin-sub">
Every live link across this organization's projects. A project's own links are on its
Settings page, and on the file itself.
</p>
<SharesTable shares={shares || []} onChanged={refreshShares} showProject />
</>
)}
</div>
@@ -327,143 +307,5 @@ function MembersTable({
getSortedRowModel: getSortedRowModel(),
});
return (
<div className="admin-list admin-card-table">
<Table className="admin-table">
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<SortableHead key={h.id} header={h} />
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((r) => (
<TableRow key={r.id} className="admin-item">
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
/* ---- shares audit (react-table: sortable path/project) ---- */
function SharesTable({
shares,
onChanged,
}: {
shares: OrgShareInfo[];
onChanged: () => void;
}) {
const [sorting, setSorting] = useState<SortingState>([]);
const col = useMemo(() => createColumnHelper<OrgShareInfo>(), []);
const columns = useMemo(
() => [
col.accessor("path", {
header: "Path",
cell: (c) => (
<a
className="ai-main mono"
href={c.row.original.url}
target="_blank"
rel="noopener noreferrer"
title={c.getValue()}
>
{c.getValue()}
</a>
),
}),
col.accessor((s) => s.project_name || "", {
id: "project",
header: "Project",
cell: (c) => (
<span className="ai-tag">
{(c.getValue() || "") +
(c.row.original.creator ? " · by " + c.row.original.creator : "") +
(c.row.original.created ? " · " + new Date(c.row.original.created).toLocaleDateString() : "")}
</span>
),
}),
col.display({
id: "actions",
header: "",
cell: (c) => (
<button
className="ai-del"
aria-label={`Revoke the share of ${c.row.original.path}`}
onClick={async () => {
const sh = c.row.original;
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.");
onChanged();
} catch (e) {
toast((e as Error).message, true);
}
}}
>
Revoke
</button>
),
}),
],
[col, onChanged],
);
const table = useReactTable({
data: shares,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
if (shares.length === 0)
return (
<div className="admin-list">
<div className="admin-empty">No public shares.</div>
</div>
);
return (
<div className="admin-list admin-card-table">
<Table className="admin-table">
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<SortableHead key={h.id} header={h} />
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((r) => (
<TableRow key={r.id} className="admin-item">
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
return <AdminTable table={table} />;
}
@@ -6,8 +6,9 @@ import { useQueryClient } from "@tanstack/react-query";
import { api } from "../api/http";
import { modalConfirm, modalPrompt } from "../modal";
import { toast } from "../toast";
import { useHubRefresh, usePermissions } from "../hooks/useHub";
import { useHubRefresh, usePermissions, useShares } from "../hooks/useHub";
import { PROJECT_ICONS, ProjectIcon } from "./shell";
import { SharesTable } from "./SharesTable";
import { projColor } from "./ProjectNav";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -247,6 +248,8 @@ export function ProjectSettings({
<People project={project} org={org} />
<PublicLinks project={project} />
{/* Admin-only, and only as UX: handleProjectDelete enforces it too. */}
{mayEdit && (
<Card className="ps-danger">
@@ -288,6 +291,34 @@ export function ProjectSettings({
);
}
// Public links: this project's live share URLs, where its own settings are.
// The org panel keeps the cross-project audit; nobody should have to go
// there to find one project's links.
function PublicLinks({ project }: { project: Project }) {
const qc = useQueryClient();
const { data: shares, error } = useShares(project.id);
if (error) return null; // sharing is off on this server, or single-volume mode
return (
<Card>
<CardHeader>
<CardTitle>Public links</CardTitle>
<CardDescription>
Files in this project that anyone with the URL can read no account needed.
</CardDescription>
</CardHeader>
<Separator />
<CardContent>
<SharesTable
shares={shares || []}
canRevoke={atLeast(project.perm, "write")}
onChanged={() => qc.invalidateQueries({ queryKey: ["shares", project.id] })}
empty="No public links."
/>
</CardContent>
</Card>
);
}
const LEVELS: Array<{ value: PermLevel; label: string }> = [
{ value: "admin", label: "Admin" },
{ value: "write", label: "Write" },
@@ -0,0 +1,72 @@
import { Button } from "@/components/ui/button";
import type { ShareInfo } from "../api/types";
import { copyText } from "../util";
import { toast } from "../toast";
import { Icon } from "./shell";
import { revokeShare, shareDetail } from "./SharesTable";
/* A file that is publicly reachable says so while you are reading it. The
Share dialog used to be the only place the link and its Revoke button
existed, so once it closed the undo lived three clicks away in the org
panel while the action was one click away here.
Anyone with read sees the banner (a member should know the folder they
rely on is exposed); Revoke is offered where the Share button already is,
and the server checks again anyway. */
export function ShareBanner({
shares,
canRevoke,
onChanged,
}: {
shares: ShareInfo[];
canRevoke: boolean;
onChanged: () => void;
}) {
if (shares.length === 0) return null;
return (
<div className="share-banner" role="status">
<div className="sb-head">
<Icon name="share" />
<b>Publicly shared</b>
<span className="sb-count">
{shares.length} active link{shares.length > 1 ? "s" : ""}
</span>
</div>
{/* Same words as the Share dialog: it is what the user already read. */}
<p className="sb-note">
<b>Anyone with this link can view this file</b> no account needed. It always shows the
latest version until you revoke it.
</p>
{shares.map((s) => (
<div className="sb-link" key={s.token}>
<span className="sb-url mono" title={s.url}>
{s.url}
</span>
<span className="sb-meta">{shareDetail(s, false)}</span>
<span className="sb-actions">
<Button
variant="subtle"
onClick={() =>
copyText(s.url).then((ok) => toast(ok ? "Copied." : "Select and copy the link."))
}
>
Copy link
</Button>
<Button variant="subtle" onClick={() => window.open(s.url, "_blank")}>
Open
</Button>
{canRevoke && (
<button
className="ai-del"
aria-label={`Revoke the share of ${s.path}`}
onClick={() => revokeShare(s, onChanged)}
>
Revoke
</button>
)}
</span>
</div>
))}
</div>
);
}
@@ -5,7 +5,9 @@ import { toast } from "../toast";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
/* A clear, explicitly-public share confirmation: warns that anyone with the
link can view, and offers copy / open / revoke. */
link can view, and offers copy / open / revoke. The title says "Public
link", not "created": ShareDB.Create hands back the file's existing live
link when there is one, so a second Share click is not a second link. */
export function ShareDialog({
url,
copied,
@@ -20,7 +22,7 @@ export function ShareDialog({
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="modal" showCloseButton={false}>
<DialogTitle asChild>
<h3>Public link created</h3>
<h3>Public link</h3>
</DialogTitle>
<p>
<b>Anyone with this link can view this file</b> no account needed. It always shows the
@@ -0,0 +1,125 @@
import { useMemo, useState } from "react";
import {
createColumnHelper,
getCoreRowModel,
getSortedRowModel,
useReactTable,
type SortingState,
} from "@tanstack/react-table";
import { api } from "../api/http";
import type { ShareInfo } from "../api/types";
import { modalConfirm } from "../modal";
import { toast } from "../toast";
import { linkProps } from "../nav";
import { urlForPath } from "../router";
import { AdminTable } from "./AdminTable";
/* The one live-public-links table in the product. The org panel passes
org-wide rows (showProject) as the cross-project audit; a project's own
Settings passes just its rows. Revoking is DELETE /api/shares/{token}
either way the server does its own PermWrite check, so canRevoke only
decides whether to offer the control. */
export function shareDetail(s: ShareInfo, showProject: boolean): string {
const bits: string[] = [];
if (showProject && s.project_name) bits.push(s.project_name);
if (s.creator) bits.push("by " + s.creator);
if (s.created) bits.push(new Date(s.created).toLocaleDateString());
bits.push(s.expires ? "expires " + new Date(s.expires).toLocaleDateString() : "no expiry");
return bits.join(" · ");
}
export function SharesTable({
shares,
onChanged,
showProject = false,
canRevoke = true,
empty = "No public shares.",
}: {
shares: ShareInfo[];
onChanged: () => void;
showProject?: boolean;
canRevoke?: boolean;
empty?: string;
}) {
const [sorting, setSorting] = useState<SortingState>([]);
const col = useMemo(() => createColumnHelper<ShareInfo>(), []);
const columns = useMemo(
() => [
col.accessor("path", {
header: "Path",
// Links to the file in the hub, not to /s/<token>: from an audit row
// the question is always "what is this?", and the public URL is one
// click away on the file itself.
cell: (c) => (
<a
className="ai-main mono"
title={c.getValue()}
{...linkProps(urlForPath(c.getValue(), c.row.original.project))}
>
{c.getValue()}
</a>
),
}),
col.accessor((s) => shareDetail(s, showProject), {
id: "detail",
header: showProject ? "Project" : "Shared",
cell: (c) => <span className="ai-tag">{c.getValue()}</span>,
}),
col.display({
id: "actions",
header: "",
cell: (c) =>
canRevoke ? (
<button
className="ai-del"
aria-label={`Revoke the share of ${c.row.original.path}`}
onClick={() => revokeShare(c.row.original, onChanged)}
>
Revoke
</button>
) : null,
}),
],
[col, onChanged, showProject, canRevoke],
);
const table = useReactTable({
data: shares,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
if (shares.length === 0)
return (
<div className="admin-list">
<div className="admin-empty">{empty}</div>
</div>
);
return <AdminTable table={table} className="shares-table" />;
}
// Shared by the table and the file-page banner so "revoke" means the same
// thing (confirm, delete, tell the caller to refetch) wherever it is offered.
export async function revokeShare(sh: ShareInfo, onChanged: () => void) {
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.");
onChanged();
} catch (e) {
toast((e as Error).message, true);
}
}
+14 -1
View File
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { OrgList, PendingList, ProjectList, ProjectPerms } from "../api/types";
import type { OrgList, PendingList, ProjectList, ProjectPerms, ShareInfo } from "../api/types";
// Hub-wide server state: the project list (polled — new projects appear
// without a reload, matching the classic app's 30s refresh) and the orgs
@@ -35,6 +35,19 @@ export function usePermissions(projectId: string | undefined) {
});
}
// One project's live public links. Any member with read may fetch them —
// knowing the folder you rely on is exposed is not an admin privilege. One
// cache entry feeds both the file-page banner and the Settings list, so a
// revoke in either place updates the other.
export function useShares(projectId: string | undefined, enabled = true) {
return useQuery({
queryKey: ["shares", projectId],
queryFn: () => getJSON<{ shares: ShareInfo[] }>(`/api/p/${projectId}/shares`),
enabled: !!projectId && enabled,
select: (d) => d.shares || [],
});
}
// Pending signups; only fetched for hub admins (the admin bar shows the
// count).
export function usePending(enabled: boolean) {
+37
View File
@@ -216,6 +216,43 @@ button, input, a.btn { font-family: inherit; }
.admin-table td { padding: 0; border-bottom: 1px solid var(--border); overflow: hidden; text-overflow: ellipsis; }
.admin-table tr.admin-item { display: table-row; }
.admin-table tr.admin-item td { padding: 8px 10px; }
/* Narrow screens: the table keeps its own horizontal scroll so the page
never scrolls sideways under it. */
.admin-card-table { overflow-x: auto; }
/* The shares table's actions column holds one button, not a select plus one
like the members table. At 186px it stole the room the row's own facts
(who, when, whether it expires) needed, and they truncated to "no exp…". */
.shares-table .admin-table th:last-child, .shares-table .admin-table td:last-child { width: 110px; }
/* ---- file-page "this file is public" banner ---- */
.share-banner {
margin: 0 0 18px; padding: 12px 14px;
border: 1px solid var(--border); border-left: 3px solid var(--accent);
border-radius: var(--r-ctl); background: var(--surface);
}
.share-banner .sb-head {
display: flex; align-items: center; gap: 8px;
font-size: 13px; color: var(--text);
}
.share-banner .sb-head .ico, .share-banner .sb-head svg {
width: 15px; height: 15px; flex: none; color: var(--accent);
}
.share-banner .sb-count { color: var(--text-faint); font-size: 12px; }
.share-banner .sb-note {
margin: 6px 0 10px; font-size: 12.5px; line-height: 1.55;
color: var(--text-faint); max-width: 64ch;
}
.share-banner .sb-link {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding-top: 8px; border-top: 1px solid var(--border);
}
.share-banner .sb-link + .sb-link { margin-top: 8px; }
.share-banner .sb-url {
flex: 1 1 260px; min-width: 0; font-size: 12px; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.share-banner .sb-meta { font-size: 11.5px; color: var(--text-faint); }
.share-banner .sb-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
/* ---- project menu ---- */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<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 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-D5c5wxeV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DgC5ZhxZ.css">
<script type="module" crossorigin src="/assets/index-BSJZ_rnL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D8NgjNS2.css">
</head>
<body>
<div id="root"></div>