mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
aeee881fa6
commit
f35d889cfb
@@ -62,6 +62,7 @@ classDiagram
|
||||
FileView FolderListing FileTree
|
||||
HistoryView HistoryRow DiffView VersionBanner
|
||||
Insights ShareDialog
|
||||
ShareBanner SharesTable AdminTable
|
||||
OrgAdmin HubSettings ProjectSettings
|
||||
Palette shell AccountBar ...
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
+1
-1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user