Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
93553e6
feat(tags): let people suggest a tag, and admins review suggestions
alukach Oct 9, 2026
4248928
feat(tags): show tags pending review only in the product's settings
alukach Oct 19, 2026
083a6d0
fix(tags): show products as they are when the tag table can't be read
alukach Oct 19, 2026
e5859dd
refactor(tags): send the form only the product's own pending tags
alukach Oct 19, 2026
108722a
feat(tags): show admins a dot when suggested tags await review
alukach Oct 20, 2026
5a3581b
docs(storybook): file TagPicker under Settings/Products
alukach Oct 26, 2026
da21de6
feat(api): list, suggest, approve, merge and reject tags under /api/v…
alukach Oct 26, 2026
42581ab
feat(tags): review suggestions with likely matches and a staged merge
alukach Oct 27, 2026
45fd962
feat(tags): record who approved a tag, and list every tag for admins
alukach Oct 27, 2026
7141045
fix(tags): suggest tags with hyphens between words
alukach Oct 27, 2026
d432f8e
fix(tags): take #719's stricter tag spelling
alukach Oct 27, 2026
056f11f
fix(tags): call seeded tags' origin "Initial import"
alukach Oct 27, 2026
51f2c5e
feat(tags): link each tag in the admin list to its filtered products
alukach Oct 27, 2026
3c8a18c
feat(tags): suggest new tags when the product is saved, not when picked
alukach Oct 27, 2026
067414f
feat(forms): ask before leaving any form with unsaved changes
alukach Oct 27, 2026
f40bfa0
feat(forms): show unsaved changes by Save, and enable Save only when …
alukach Oct 27, 2026
bbb4602
test(tags): mock the tag table's rows in #719's spelling tests
alukach Oct 27, 2026
cdd90b1
feat(tags): make the merge-target picker read as a button
alukach Oct 27, 2026
87f98d4
fix(tags): match a suggestion to the plural of an approved tag
alukach Oct 27, 2026
b1a2409
feat(tags): merge a suggestion into several tags, and reset a staged …
alukach Oct 27, 2026
efa0c47
feat(tags): keep reviewed suggestions on record, with undo
alukach Oct 27, 2026
675e56e
feat(tags): show an account's card on hover wherever a tag page names…
alukach Oct 27, 2026
8055f6f
feat(tags): keyboard-driven tag menus, and the products a reviewed ta…
alukach Oct 28, 2026
8106e78
fix(tags): use Radix's TextField for TagMenu's search field
alukach Oct 28, 2026
4514f42
fix(tags): keep pending tags out of tag filters, and review cleanups
alukach Oct 28, 2026
9d61a6f
feat(ui): show the account card wherever an account is named (#724)
alukach Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ sb.mock("../src/lib/actions/products.ts");
sb.mock("../src/lib/actions/memberships.ts");
sb.mock("../src/lib/actions/service-accounts.ts");
sb.mock("../src/lib/actions/service-account-keys.ts");
// Reached through the uploader providers the account menu sits in.
sb.mock("../src/lib/actions/credentials.ts");

// Mirrors src/styles/theme.tsx. Without it every Radix control renders
// unstyled, and a story would tell you nothing about how the app looks.
Expand Down
5 changes: 5 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ between the two, so nothing above it sees the stored name.
| `PUT` | replace a whole resource at once (no endpoint uses it yet) | the resource as set |
| `DELETE` | delete | the resource as it was |

A create whose resource is named by its own content, so that a second request
can only mean the same thing, answers 200 and the existing resource rather
than 409. Retrying it is then safe without an `Idempotency-Key` (#708).
`POST /tags` is one: suggesting a tag that exists answers that tag.

Deactivating is not deleting: it is `PATCH { "disabled": true }`, for products
and accounts alike. A `DELETE` that can't delete answers 409 and says what to
do instead.
Expand Down
3 changes: 1 addition & 2 deletions deploy/lib/api-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,10 @@ export class ApiStack extends cdk.Stack {
database.membershipsTable,
database.accountTrustsTable,
database.serviceAccountKeysTable,
database.tagsTable,
]) {
table.grantReadWriteData(vercel.vercelRole);
}
// The tag corpus is curated outside the app, which only reads it.
database.tagsTable.grantReadData(vercel.vercelRole);

// Grant Vercel role write access to assets bucket (for profile image uploads)
assets.bucket.grantPut(vercel.vercelRole);
Expand Down
1 change: 1 addition & 0 deletions public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A product is addressed as `{account_id}/{product_id}`, for example `kerner-lab/f
- Product metadata as JSON: `https://source.coop/api/v1/products/{account_id}/{product_id}`
- An account's public products as JSON: `https://source.coop/api/v1/products/{account_id}`, a page at a time as `{"items": [...], "next_cursor": ...}`; pass `next_cursor` back as `?cursor=` until it's null, and `?limit=` up to 100
- Search all public products: `https://source.coop/api/v1/products?q={text}&tags={tag,tag}`, paged the same way
- The tags products may carry, which `?tags=` filters on: `https://source.coop/api/v1/tags`, as one page
Comment thread
alukach marked this conversation as resolved.
Resolved
- A person's or organization's public profile as JSON: `https://source.coop/api/v1/accounts/{account_id}`
- Where a product's data is stored: `https://source.coop/api/v1/products/{account_id}/{product_id}/mirrors`, as `{"items": [...]}`: each mirror's data connection and prefix, with `is_primary` on the one files are served from
- Files, over HTTP: `https://data.source.coop/{account_id}/{product_id}/{path}`
Expand Down
5 changes: 4 additions & 1 deletion src/app/(app)/[account_id]/IndividualProfilePage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
accountsTable,
hidePendingTags,
isOrganizationalAccount,
membershipsTable,
productsTable,
Expand Down Expand Up @@ -29,7 +30,9 @@ export async function IndividualProfilePage({
1000
);

products = products.filter((product) => canListOnProfile(session, product));
products = await hidePendingTags(
products.filter((product) => canListOnProfile(session, product))
);

const memberships = (
await membershipsTable.listByUser(account.account_id)
Expand Down
5 changes: 4 additions & 1 deletion src/app/(app)/[account_id]/OrganizationProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { OrganizationProfile } from "@/components/features/profiles/Organization
import { PendingInvitationBanner } from "@/components/features/memberships/PendingInvitationBanner";
import {
accountsTable,
hidePendingTags,
isIndividualAccount,
membershipsTable,
productsTable,
Expand Down Expand Up @@ -74,7 +75,9 @@ export async function OrganizationProfilePage({
!!account && isIndividualAccount(account)
);

products = products.filter((product) => canListOnProfile(session, product));
products = await hidePendingTags(
products.filter((product) => canListOnProfile(session, product))
);

// Check for pending invitation
const pendingInvitation = await getPendingInvitation(account.account_id);
Expand Down
19 changes: 18 additions & 1 deletion src/app/(app)/admin/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ import { AdminFiltersForm } from "@/components/features/analytics/AdminFiltersFo
import { GroupByChips } from "@/components/features/analytics/GroupByChips";
import { adminAnalyticsUrl, formatBytes } from "@/lib";
import { accountUrl } from "@/lib/urls";
import { accountsTable } from "@/lib/clients/database";
import { publicView } from "@/lib/operations/accounts";
import { AccountMention } from "@/components/core/AccountMention";

export const metadata: Metadata = { title: "Admin — Analytics" };

Expand Down Expand Up @@ -220,6 +223,18 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) {
queryError = error instanceof Error ? error.message : String(error);
}

// Grouped by account, each row names one, so it gets the account's card.
const accounts =
breakdown && state.groupBy.length === 1 && state.groupBy[0] === "account"
? Object.fromEntries(
(
await accountsTable.fetchManyByIds(
breakdown.groups.map((g) => g.key).filter((k) => k !== OTHER_KEY)
)
).map((a) => [a.account_id, publicView(a)])
)
: {};

const seriesColors = new Map(
breakdown?.series.map((key, i) => [key, seriesColor(key, i, OTHER_KEY)]),
);
Expand Down Expand Up @@ -531,7 +546,9 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) {
}}
/>
<Text size="1" style={mono()}>
{href ? (
{accounts[group.key] ? (
<AccountMention id={group.key} accounts={accounts} />
) : href ? (
<Link href={href}>{group.key}</Link>
) : (
group.key
Expand Down
16 changes: 14 additions & 2 deletions src/app/(app)/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { ReactNode } from "react";
import { Heading } from "@radix-ui/themes";
import { Flex, Heading } from "@radix-ui/themes";
import { getPageSession } from "@/lib/api/utils";
import { isAdmin } from "@/lib/api/authz";
import { NotAuthorizedPage } from "@/components/core";
import { SettingsHeader, SettingsLayout } from "@/components/features/settings";
import { ADMIN_TOOLS } from "@/components/features/admin/tools";
import { anyPendingTags } from "@/lib/clients/database";
import { adminTagsUrl } from "@/lib";
import styles from "@/components/layout/Navigation.module.css";

// Gates every route under /admin and presents the admin tools in a settings-
// style layout (left sidebar menu + content area), matching the account and
Expand All @@ -20,9 +23,18 @@ export default async function AdminLayout({
return <NotAuthorizedPage />;
}

const hasPendingTags = await anyPendingTags();
const menuItems = ADMIN_TOOLS.map((tool) => ({
id: tool.href,
label: tool.name,
label:
hasPendingTags && tool.href === adminTagsUrl() ? (
<Flex as="span" align="center" gap="2">
{tool.name}
<span className={styles.mobileDot} />
</Flex>
) : (
tool.name
),
href: tool.href,
icon: <tool.Icon width="16" height="16" />,
condition: true,
Expand Down
113 changes: 113 additions & 0 deletions src/app/(app)/admin/tags/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Metadata } from "next";
import { Flex, Heading } from "@radix-ui/themes";
import { FormTitle } from "@/components/core";
import { AdminReviewedTags } from "@/components/features/admin/AdminReviewedTags";
import { AdminTagList } from "@/components/features/admin/AdminTagList";
import { AdminTagSuggestions } from "@/components/features/admin/AdminTagSuggestions";
import { accountsTable, productsTable } from "@/lib/clients/database";
import { publicView } from "@/lib/operations/accounts";
import { getPageSession } from "@/lib/api/utils";
import { reviewTag } from "@/lib/actions/tags";
import { listTags } from "@/lib/operations/tags";
import { similarTags, splitTag } from "@/lib/tags/similar";

export const metadata: Metadata = {
title: "Admin — Tags",
};

// Access is gated by the /admin layout.
export default async function AdminTagsPage() {
const session = await getPageSession();
const [pending, approved, reviewed, tagged] = await Promise.all([
listTags(session, { pending: "true" }),
listTags(session, {}),
listTags(session, { reviewed: "true" }),
productsTable.listTagged(),
]);
if (!pending.ok) throw new Error(pending.message);
if (!approved.ok) throw new Error(approved.message);
if (!reviewed.ok) throw new Error(reviewed.message);

// ponytail: one scan of every tagged product, grouped here; a per-tag count
// on the tags table if the products table outgrows a page render.
const byTag = new Map<string, typeof tagged>();
for (const p of tagged) {
for (const t of p.metadata.tags!) {
if (!byTag.has(t)) byTag.set(t, []);
byTag.get(t)!.push(p);
}
}
const carrying = (tag: string) => byTag.get(tag) ?? [];
// Everyone named on the page, for their account cards on hover; public
// views only, since they go to the browser.
const named = [...pending.value.items, ...approved.value.items, ...reviewed.value.items]
.flatMap((t) => [t.suggested_by, t.reviewed_by])
.filter((id): id is string => !!id);
const accounts = Object.fromEntries(
(await accountsTable.fetchManyByIds([...new Set(named)])).map((a) => [
a.account_id,
publicView(a),
])
);
const approvedTags = approved.value.items.map((t) => t.tag_id);
const counted = approved.value.items.map((t) => ({
tag: t.tag_id,
products: carrying(t.tag_id).length,
suggested_by: t.suggested_by,
reviewed_by: t.reviewed_by,
reviewed_at: t.reviewed_at,
}));

return (
<Flex direction="column" gap="6">
<FormTitle
title="Tags"
description="Review tags people have suggested. Approving adds a tag to the list everyone picks from; merging replaces it with an approved tag on every product that carries it; rejecting takes it off them."
/>
<Flex direction="column" gap="3">
<Heading size="4">
Awaiting review ({pending.value.items.length})
</Heading>
<AdminTagSuggestions
suggestions={pending.value.items.map((t) => ({
tag: t.tag_id,
suggested_by: t.suggested_by,
suggested_at: t.suggested_at,
products: carrying(t.tag_id).map(({ account_id, product_id }) => ({
account_id,
product_id,
})),
matches: similarTags(t.tag_id, approvedTags),
split: splitTag(t.tag_id, approvedTags),
}))}
approved={counted}
action={reviewTag}
accounts={accounts}
/>
</Flex>
<Flex direction="column" gap="3">
<Heading size="4">Reviewed ({reviewed.value.items.length})</Heading>
<AdminReviewedTags
reviewed={reviewed.value.items.map((t) => ({
tag: t.tag_id,
outcome: t.outcome!,
reviewed_by: t.reviewed_by,
reviewed_at: t.reviewed_at,
suggested_by: t.suggested_by,
merged_into: t.merged_into,
products: (t.changed ?? carrying(t.tag_id)).map(({ account_id, product_id }) => ({
account_id,
product_id,
})),
}))}
action={reviewTag}
accounts={accounts}
/>
</Flex>
<Flex direction="column" gap="3">
<Heading size="4">All tags</Heading>
<AdminTagList tags={counted} accounts={accounts} />
</Flex>
</Flex>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { Box, Button } from "@radix-ui/themes";
import { ArrowLeftIcon } from "@radix-ui/react-icons";
import { ServiceAccountDetail } from "@/components/features/service-accounts";
import {
accountsTable,
accountTrustsTable,
membershipsTable,
productsTable,
serviceAccountKeysTable,
} from "@/lib/clients/database";
import { CONFIG } from "@/lib/config";
import { publicView } from "@/lib/operations/accounts";
import { getPageSession } from "@/lib/api/utils";
import { managedServiceAccount } from "@/lib/accounts/service-accounts";
import { editAccountServiceAccountsUrl } from "@/lib/urls";
Expand All @@ -34,6 +36,14 @@ export default async function ServiceAccountPage({ params }: PageProps) {
productsTable.listByAccountAll(account_id),
serviceAccountKeysTable.listByAccount(account.account_id),
]);
// The owner, and whoever issued or revoked a key, for their cards.
const named = [account_id, ...keys.flatMap((k) => [k.created_by, k.revoked_by])];
const accounts = Object.fromEntries(
(await accountsTable.fetchManyByIds([...new Set(named.filter((id) => id != null))])).map((a) => [
a.account_id,
publicView(a),
])
);

return (
<Box>
Expand All @@ -52,6 +62,7 @@ export default async function ServiceAccountPage({ params }: PageProps) {
}}
products={products.map(({ product_id, title }) => ({ product_id, title }))}
proxyOrigin={CONFIG.storage.endpoint}
accounts={accounts}
/>
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { accountsTable, productsTable } from "@/lib/clients/database";
import { getPageSession } from "@/lib/api/utils";
import { canManageAccountServiceAccounts } from "@/lib/api/authz";
import { CONFIG } from "@/lib/config";
import { publicView } from "@/lib/operations/accounts";

export const metadata: Metadata = { title: "Create service account" };

Expand All @@ -33,6 +34,7 @@ export default async function CreateServiceAccountPage({ params }: PageProps) {
/>
<ServiceAccountForm
ownerAccountId={account_id}
accounts={{ [account_id]: publicView(owner) }}
products={products}
proxyOrigin={CONFIG.storage.endpoint}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
listUsableDataConnections,
} from "@/lib/data-connections";
import { accountsTable, productsTable, dataConnectionsTable } from "@/lib/clients";
import { publicView } from "@/lib/operations/accounts";
import { notFound } from "next/navigation";
import { ProductMirrorsManager } from "@/components/features/data-connections";
import { toDataConnectionOption } from "@/components/features/data-connections/redact";
Expand Down Expand Up @@ -99,6 +100,7 @@ export default async function ProductDataConnectionsPage({
ownedConnectionIds={ownedConnectionIds}
connectionInfo={connectionInfo}
editablePrefixConnectionIds={editablePrefixConnectionIds}
accounts={ownerAccount ? { [ownerAccount.account_id]: publicView(ownerAccount) } : {}}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
accountsTable,
dataConnectionsTable,
tagsTable,
tagProps,
} from "@/lib/clients/database";
import { getPageSession } from "@/lib";
import { isAuthorized, isAdmin } from "@/lib/api/authz";
Expand All @@ -32,7 +33,7 @@ interface PageProps {
export default async function DetailsPage({ params }: PageProps) {
const { account_id, product_id } = await params;

const [product, account, session, tagOptions] = await Promise.all([
const [product, account, session, tags] = await Promise.all([
productsTable.fetchById(account_id, product_id),
accountsTable.fetchById(account_id),
getPageSession(),
Expand Down Expand Up @@ -74,7 +75,7 @@ export default async function DetailsPage({ params }: PageProps) {
product={product}
dataConnections={dataConnections}
mode="edit"
tagOptions={tagOptions}
{...tagProps(tags, product.metadata.tags)}
/>

{canDelete && (
Expand Down
3 changes: 2 additions & 1 deletion src/app/(app)/products/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getPageSession,
membershipsTable,
tagsTable,
tagProps,
} from "@/lib";
import { canCreateProductForAccount, isAuthorized } from "@/lib/api/authz";
import { listUsableDataConnections } from "@/lib/data-connections";
Expand Down Expand Up @@ -82,7 +83,7 @@ export default async function NewProductPage({
<ProductCreationForm
potentialOwnerAccounts={potentialOwnerAccounts}
dataConnections={dataConnections}
tagOptions={await tagsTable.listAll()}
{...tagProps(await tagsTable.listAll())}
defaultOwnerId={
// Preselect the owner from ?owner=… (e.g. "New product" opened from an
// org's menu), but only if the user can actually own products there.
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/openapi/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import "../v1/products/[account_id]/[product_id]/mirrors/[connection_id]/route";
import "../v1/secret-scanning/github/route";
import "../v1/service-account-keys/exchanges/route";
import "../v1/service-account-keys/revocations/route";
import "../v1/tags/route";
import "../v1/tags/[tag_id]/route";
import "../v1/whoami/route";

// The registry is complete once the imports above have run, so the document
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ jest.mock("@/lib/clients/database", () => ({
listByUser: jest.fn(),
},
isIndividualAccount: jest.fn(),
// No product here has tags awaiting review.
hidePendingTags: async (products: unknown[]) => products,
}));

// Mock Ory dependencies to prevent Next.js context errors
Expand Down
Loading
Loading