mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): react-table admin tables + RHF/zod forms; docs reflect the Stack A dependency set
Members and shares-audit render through @tanstack/react-table (sortable, spec-first); org rename, hub signup policy, and onboarding create/join are react-hook-form + zod with inline errors replacing toast-on-typo. 55/55. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
co-authored by
Claude Fable 5
parent
2e835d266d
commit
fad9ae4f6b
@@ -494,9 +494,9 @@ conflicts. Set `BDRIVE_HOME` to relocate all beardrive state (used heavily in te
|
||||
### Web frontend
|
||||
|
||||
The hub's web UI is a React + TypeScript app in `internal/webapp/frontend`
|
||||
(Vite; runtime dependencies are just react, react-dom,
|
||||
@tanstack/react-query, and lucide-react for icons — routing is a small
|
||||
in-repo history router). Its
|
||||
(Vite + Tailwind v4 + shadcn/ui components owned in-repo; TanStack
|
||||
query/table/virtual, react-hook-form + zod, cmdk, sonner, lucide-react —
|
||||
routing stays a small in-repo history router). Its
|
||||
**built output is committed** at `internal/webapp/static`, the `go:embed`
|
||||
target, so building or `go install`-ing the binary never needs Node.
|
||||
|
||||
|
||||
@@ -116,10 +116,10 @@ test("hub settings: policy view, save round-trip, pending queue empty", async ({
|
||||
// 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 page.click('.admin button:has-text("Save policy")');
|
||||
await expectToast(page, "policy saved");
|
||||
await app.uncheck();
|
||||
await page.click(".admin > .pbtn");
|
||||
await page.click('.admin button:has-text("Save policy")');
|
||||
await expectToast(page, "policy saved");
|
||||
await expect(page.locator(".admin-empty", { hasText: "No one is waiting" })).toBeVisible();
|
||||
});
|
||||
@@ -133,3 +133,14 @@ test("navigating away closes an open admin panel", async ({ page }) => {
|
||||
await expect(page.locator("#content h1")).toHaveText("Wiki");
|
||||
await expect(page.locator(".admin")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("members table sorts by email", async ({ page }) => {
|
||||
await login(page);
|
||||
await openOrgSettings(page);
|
||||
const emails = page.locator(".admin-table .admin-item .ai-main");
|
||||
await expect(emails.first()).toBeVisible();
|
||||
const before = await emails.allTextContents();
|
||||
await page.click('.admin-table th:has-text("Member")');
|
||||
const after = await emails.allTextContents();
|
||||
expect([...before].reverse()).toEqual(after);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "../toast";
|
||||
|
||||
// Onboarding: a signed-in account with no projects shouldn't hit a blank
|
||||
// sidebar. Explain that access comes from an invite, let them paste one,
|
||||
// and — since any member can — offer to start a new project.
|
||||
// and — since any member can — offer to start a new project. Both inputs
|
||||
// are RHF+zod forms with inline errors (no toast-on-typo).
|
||||
|
||||
const joinSchema = z.object({
|
||||
invite: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => /join\/([0-9a-f]+)/.test(v) || /^[0-9a-f]{8,}$/.test(v), {
|
||||
message: "That doesn't look like an invite link.",
|
||||
}),
|
||||
});
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(1, "Give the project a name.").max(60, "Keep it under 60 characters."),
|
||||
});
|
||||
|
||||
export function EmptyState({
|
||||
authEnabled,
|
||||
onCreate,
|
||||
@@ -12,18 +27,14 @@ export function EmptyState({
|
||||
authEnabled: boolean;
|
||||
onCreate: (name: string) => void;
|
||||
}) {
|
||||
const invite = useRef<HTMLInputElement>(null);
|
||||
const name = useRef<HTMLInputElement>(null);
|
||||
|
||||
const join = () => {
|
||||
const v = invite.current!.value.trim();
|
||||
const m = v.match(/join\/([0-9a-f]+)/) || v.match(/^([0-9a-f]{8,})$/);
|
||||
if (!m) {
|
||||
toast("That doesn't look like an invite link.", true);
|
||||
return;
|
||||
}
|
||||
location.href = "/join/" + m[1];
|
||||
};
|
||||
const joinForm = useForm<z.infer<typeof joinSchema>>({
|
||||
resolver: zodResolver(joinSchema),
|
||||
defaultValues: { invite: "" },
|
||||
});
|
||||
const createForm = useForm<z.infer<typeof createSchema>>({
|
||||
resolver: zodResolver(createSchema),
|
||||
defaultValues: { name: "" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="onboard">
|
||||
@@ -33,23 +44,47 @@ export function EmptyState({
|
||||
<div className="ob-card">
|
||||
<h3>Have an invite link?</h3>
|
||||
<p>A teammate can send you a join link. Paste it here:</p>
|
||||
<div className="ob-row">
|
||||
<input id="ob-invite" type="text" placeholder="https://…/join/…" autoComplete="off" ref={invite} />
|
||||
<Button id="ob-join" variant="primary" onClick={join}>
|
||||
<form
|
||||
className="ob-row"
|
||||
onSubmit={joinForm.handleSubmit(({ invite }) => {
|
||||
const m = invite.match(/join\/([0-9a-f]+)/) || invite.match(/^([0-9a-f]{8,})$/);
|
||||
location.href = "/join/" + m![1];
|
||||
})}
|
||||
>
|
||||
<input
|
||||
id="ob-invite"
|
||||
type="text"
|
||||
placeholder="https://…/join/…"
|
||||
autoComplete="off"
|
||||
{...joinForm.register("invite")}
|
||||
/>
|
||||
<Button id="ob-join" variant="primary" type="submit">
|
||||
Join
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{joinForm.formState.errors.invite && (
|
||||
<p className="field-err">{joinForm.formState.errors.invite.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="ob-card">
|
||||
<h3>Or start a new project</h3>
|
||||
<p>Create a shared space for your team's files.</p>
|
||||
<div className="ob-row">
|
||||
<input id="ob-name" type="text" placeholder="Project name, e.g. wiki" autoComplete="off" ref={name} />
|
||||
<Button id="ob-create" variant="primary" onClick={() => onCreate(name.current!.value.trim())}>
|
||||
<form className="ob-row" onSubmit={createForm.handleSubmit(({ name }) => onCreate(name))}>
|
||||
<input
|
||||
id="ob-name"
|
||||
type="text"
|
||||
placeholder="Project name, e.g. wiki"
|
||||
autoComplete="off"
|
||||
{...createForm.register("name")}
|
||||
/>
|
||||
<Button id="ob-create" variant="primary" type="submit">
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{createForm.formState.errors.name && (
|
||||
<p className="field-err">{createForm.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { getJSON, postJSON } from "../api/http";
|
||||
import type { AdminPolicy } from "../api/types";
|
||||
import { usePending } from "../hooks/useHub";
|
||||
import { toast } from "../toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const policySchema = z.object({
|
||||
require_verification: z.boolean(),
|
||||
require_approval: z.boolean(),
|
||||
});
|
||||
type PolicyForm = z.infer<typeof policySchema>;
|
||||
|
||||
/* Hub-admin settings: signup/access policy. Verification & approval are
|
||||
live toggles; the domain allowlist and admin list are shown read-only
|
||||
@@ -16,14 +26,12 @@ export function HubSettings() {
|
||||
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]);
|
||||
const form = useForm<PolicyForm>({
|
||||
resolver: zodResolver(policySchema),
|
||||
values: pol
|
||||
? { require_verification: pol.require_verification && pol.mailer, require_approval: pol.require_approval }
|
||||
: { require_verification: false, require_approval: false },
|
||||
});
|
||||
useEffect(() => {
|
||||
if (error) toast((error as Error).message, true);
|
||||
}, [error]);
|
||||
@@ -47,40 +55,38 @@ export function HubSettings() {
|
||||
</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 () => {
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (v) => {
|
||||
try {
|
||||
await postJSON("/api/admin/policy", { require_verification: ver, require_approval: app });
|
||||
await postJSON("/api/admin/policy", v);
|
||||
toast("Signup policy saved.");
|
||||
qc.invalidateQueries({ queryKey: ["admin", "policy"] });
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
}}
|
||||
})}
|
||||
>
|
||||
Save policy
|
||||
</button>
|
||||
<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."
|
||||
}
|
||||
disabled={!pol.mailer}
|
||||
inputProps={form.register("require_verification")}
|
||||
/>
|
||||
<PolicyToggle
|
||||
label="Require admin approval"
|
||||
desc="New accounts wait for a hub admin to approve them before they gain access."
|
||||
inputProps={form.register("require_approval")}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" type="submit" style={{ marginTop: 14 }}>
|
||||
Save policy
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<h3>Who can sign up</h3>
|
||||
<div className="admin-list">
|
||||
@@ -116,9 +122,9 @@ export function HubSettings() {
|
||||
{(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)}>
|
||||
<Button variant="primary" onClick={() => act(u.id, "approve", u.email)}>
|
||||
Approve
|
||||
</button>
|
||||
</Button>
|
||||
<button className="ai-del" onClick={() => act(u.id, "deny", u.email)}>
|
||||
Deny
|
||||
</button>
|
||||
@@ -132,15 +138,13 @@ export function HubSettings() {
|
||||
function PolicyToggle({
|
||||
label,
|
||||
desc,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
inputProps,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
inputProps: ReturnType<ReturnType<typeof useForm<PolicyForm>>["register"]>;
|
||||
}) {
|
||||
return (
|
||||
<label className="admin-item toggle" style={disabled ? { opacity: 0.55 } : undefined}>
|
||||
@@ -148,12 +152,7 @@ function PolicyToggle({
|
||||
<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)}
|
||||
/>
|
||||
<input type="checkbox" disabled={disabled} {...inputProps} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
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 { modalConfirm, modalPrompt } 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";
|
||||
|
||||
/* 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. */
|
||||
org-wide audit of public shares. Members and shares render through
|
||||
react-table (sortable); the rename is an RHF+zod form. */
|
||||
|
||||
const renameSchema = z.object({
|
||||
name: z.string().trim().min(1, "Give the organization a name.").max(60, "Keep it under 60 characters."),
|
||||
});
|
||||
type RenameForm = z.infer<typeof renameSchema>;
|
||||
|
||||
type Member = Org["members"][number];
|
||||
|
||||
export function OrgAdmin({
|
||||
org,
|
||||
projects,
|
||||
@@ -22,12 +44,16 @@ export function OrgAdmin({
|
||||
}) {
|
||||
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 renameForm = useForm<RenameForm>({
|
||||
resolver: zodResolver(renameSchema),
|
||||
values: { name: org.name },
|
||||
});
|
||||
|
||||
const { data: invites } = useQuery({
|
||||
queryKey: ["invites", org.id],
|
||||
queryFn: () => getJSON<{ invites: OrgInviteInfo[] }>(`/api/orgs/${org.id}/invites`),
|
||||
@@ -48,90 +74,30 @@ export function OrgAdmin({
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<form
|
||||
className="admin-row"
|
||||
onSubmit={renameForm.handleSubmit(async ({ name }) => {
|
||||
try {
|
||||
await api("PATCH", "/api/orgs/" + org.id, { name });
|
||||
toast("Renamed.");
|
||||
refreshOrgs();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<input id="org-rename" type="text" {...renameForm.register("name")} />
|
||||
<Button variant="primary" id="org-rename-btn" type="submit">
|
||||
Rename org
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
{renameForm.formState.errors.name && (
|
||||
<span className="field-err">{renameForm.formState.errors.name.message}</span>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<MembersTable org={org} owner={owner} myEmail={myEmail} onChanged={refreshOrgs} />
|
||||
|
||||
{owner && (
|
||||
<>
|
||||
@@ -141,8 +107,8 @@ export function OrgAdmin({
|
||||
{orgProjects.map((p) => (
|
||||
<div className="admin-item" key={p.id}>
|
||||
<span className="ai-main">{p.name}</span>
|
||||
<button
|
||||
className="ai-btn"
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={async () => {
|
||||
const name = await modalPrompt("Rename project", "New name", p.name, "Rename");
|
||||
if (!name || name === p.name) return;
|
||||
@@ -156,7 +122,7 @@ export function OrgAdmin({
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
</Button>
|
||||
<button
|
||||
className="ai-del"
|
||||
onClick={async () => {
|
||||
@@ -186,8 +152,8 @@ export function OrgAdmin({
|
||||
|
||||
<div className="admin-h">
|
||||
<h3>Invite links</h3>
|
||||
<button
|
||||
className="pbtn"
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const out = await postJSON<{ url: string }>(`/api/orgs/${org.id}/invites`);
|
||||
@@ -200,7 +166,7 @@ export function OrgAdmin({
|
||||
}}
|
||||
>
|
||||
New invite
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="admin-list">
|
||||
{invites && invites.length === 0 && (
|
||||
@@ -252,51 +218,240 @@ export function OrgAdmin({
|
||||
</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>
|
||||
<SharesTable shares={shares || []} onChanged={refreshShares} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- members (react-table: sortable email/role) ---- */
|
||||
|
||||
function MembersTable({
|
||||
org,
|
||||
owner,
|
||||
myEmail,
|
||||
onChanged,
|
||||
}: {
|
||||
org: Org;
|
||||
owner: boolean;
|
||||
myEmail: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "email", desc: false }]);
|
||||
const col = useMemo(() => createColumnHelper<Member>(), []);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
col.accessor("email", {
|
||||
id: "email",
|
||||
header: "Member",
|
||||
cell: (c) => {
|
||||
const isSelf = !!myEmail && c.getValue().toLowerCase() === myEmail.toLowerCase();
|
||||
return <span className="ai-main">{c.getValue() + (isSelf ? " (you)" : "")}</span>;
|
||||
},
|
||||
}),
|
||||
col.accessor("role", {
|
||||
id: "role",
|
||||
header: "Role",
|
||||
cell: (c) => {
|
||||
const m = c.row.original;
|
||||
const isSelf = !!myEmail && m.email.toLowerCase() === myEmail.toLowerCase();
|
||||
if (!owner || isSelf) return <span className="ai-tag">{m.role}</span>;
|
||||
return (
|
||||
<>
|
||||
<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);
|
||||
}
|
||||
onChanged();
|
||||
}}
|
||||
>
|
||||
<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.");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast((err as Error).message, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
}),
|
||||
],
|
||||
[col, org.id, org.name, owner, myEmail],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: org.members,
|
||||
columns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Table className="admin-table">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<TableRow key={hg.id}>
|
||||
{hg.headers.map((h) => (
|
||||
<TableHead
|
||||
key={h.id}
|
||||
onClick={h.column.getToggleSortingHandler()}
|
||||
data-sort={h.column.getIsSorted() || undefined}
|
||||
>
|
||||
{flexRender(h.column.columnDef.header, h.getContext())}
|
||||
{h.column.getIsSorted() === "asc" ? " ↑" : h.column.getIsSorted() === "desc" ? " ↓" : ""}
|
||||
</TableHead>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- 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) => (
|
||||
<span
|
||||
className="ai-main mono"
|
||||
style={{ cursor: "pointer" }}
|
||||
title={c.row.original.url}
|
||||
onClick={() => window.open(c.row.original.url, "_blank")}
|
||||
>
|
||||
{c.getValue()}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
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"
|
||||
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 (
|
||||
<Table className="admin-table">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<TableRow key={hg.id}>
|
||||
{hg.headers.map((h) => (
|
||||
<TableHead key={h.id} onClick={h.column.getToggleSortingHandler()}>
|
||||
{flexRender(h.column.columnDef.header, h.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,6 +136,21 @@ button, input, a.btn { font-family: inherit; }
|
||||
#tree .row.collapsed .chev { transform: rotate(-90deg); }
|
||||
|
||||
/* org bar */
|
||||
.field-err { color: var(--del); font-size: 12px; margin: 6px 2px 0; }
|
||||
|
||||
/* react-table admin tables ride the .admin-item/.ai-* vocabulary */
|
||||
.admin-table { width: 100%; border-collapse: collapse; }
|
||||
.admin-table th {
|
||||
text-align: left; font-size: 11px; font-weight: 600; letter-spacing: .05em;
|
||||
text-transform: uppercase; color: var(--text-ghost); padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border); cursor: pointer; user-select: none;
|
||||
}
|
||||
.admin-table th:hover { color: var(--text-dim); }
|
||||
.admin-table td { padding: 0; border-bottom: 1px solid var(--border); }
|
||||
.admin-table tr.admin-item { display: table-row; }
|
||||
.admin-table tr.admin-item td { padding: 8px 10px; }
|
||||
.admin-table tr.admin-item td:first-child { width: 100%; }
|
||||
|
||||
/* ---- project menu ---- */
|
||||
.nav-menu { list-style: none; margin: 6px 0 0; padding: 0; }
|
||||
.nav-menu .row .ico { width: 15px; height: 15px; flex: none; color: var(--text-ghost); }
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
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 100 100'><text y='.9em' font-size='90'>🐻</text></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-ESH0Hl7P.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-MFSXFxvy.css">
|
||||
<script type="module" crossorigin src="/assets/index-CKZ61mhX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DNKHQHAn.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user