import {
LockIcon,
MailIcon,
ServerIcon,
TriangleAlertIcon,
UploadIcon,
} from 'lucide-react';
import { useState } from 'react';
import { AppTile } from '#/components/atoms/app-tile.jsx';
import { Badge } from '#/components/atoms/badge.jsx';
import { TextLink } from '#/components/molecules/text-link.jsx';
import {
nsidDomain,
pickLangString,
pluralNoun,
recordNoun,
scopeTokens,
} from '#/lib/format.js';
import { cn } from '#/lib/utils.js';
const ACTIONS = ['create', 'update', 'delete'];
/** Join words with commas and "and": ["a","b","c"] -> "a, b, and c". */
function listWords(words) {
if (words.length <= 1) return words[0] || '';
if (words.length === 2) return `${words[0]} and ${words[1]}`;
return `${words.slice(0, -1).join(', ')}, and ${words.at(-1)}`;
}
/** The plural nouns for a set of collections, deduped, reading order kept. */
function nounList(entries) {
const nouns = [];
for (const entry of entries) {
const noun = pluralNoun(recordNoun(entry.collection));
if (!nouns.includes(noun)) nouns.push(noun);
}
return listWords(nouns);
}
/** An icon chip sized to sit where an app tile would, fronting a card. */
function ScopeIcon({ children }) {
return (
{children}
);
}
/** A card in the consent list: leading tile, summary prose, optional detail. */
function ScopeCard({ tile, title, subtitle, children, detail, detailLabel }) {
const [open, setOpen] = useState(false);
// A tile with no title fronts the prose itself: one sentence, one icon.
if (tile && !title && !detail) {
return (
);
}
return (
{(tile || title) && (
{tile}
{title && (
{title}
)}
{subtitle && (
{subtitle}
)}
)}
{children}
{detail && (
<>
setOpen(!open)} className="mt-2">
{open ? 'Hide details' : detailLabel}
{open &&
{detail}
}
>
)}
);
}
/**
* One details section: a faint action heading over a clean list of NSIDs.
* No per-row chips; the heading says once what applies to every name below.
*/
function DetailGroup({ label, items }) {
return (
{label}
{items.map((item) => (
{item}
))}
);
}
/** Repo detail sections, one per shared action signature. */
function repoDetailGroups(bySignature) {
return [...bySignature.values()].map(({ granted, entries }) => (
entry.collection)}
/>
));
}
/** Rpc detail sections, one per audience. */
function rpcDetailGroups(grants) {
return grants.map(({ aud, lxms }) => (
));
}
/** Collections bucketed by which actions they share. */
function groupBySignature(entries) {
const bySignature = new Map();
for (const entry of entries) {
const granted = ACTIONS.filter((action) => entry[action]);
const key = granted.join('+');
const group = bySignature.get(key) || { granted, entries: [] };
group.entries.push(entry);
bySignature.set(key, group);
}
return bySignature;
}
/** Repo entries for one app, summarized by what they let the app do. */
function RepoGroupCard({ domain, entries }) {
// Sentences group collections that share the same granted actions, so ten
// full-control record types collapse into one line instead of ten rows.
const bySignature = groupBySignature(entries);
const typeCount = entries.length;
return (
}
title={domain}
detail={repoDetailGroups(bySignature)}
detailLabel={`${typeCount} record ${typeCount === 1 ? 'type' : 'types'}. Details`}
>
{[...bySignature.values()].map(({ granted, entries: grouped }) => (
{granted.length === ACTIONS.length
? `Full control of ${nounList(grouped)}.`
: `Can ${listWords(granted)} ${nounList(grouped)}.`}
))}
);
}
/** A human name for an rpc audience DID. */
function audLabel(aud) {
if (aud === '*') return 'any service';
const web = /^did:web:([^#]+)/.exec(aud);
if (web) return web[1];
return aud.replace(/#.*$/, '');
}
/** rpc: grants, one line per audience, exact methods behind the details. */
function RpcCard({ grants }) {
return (
}
title="Services it can call"
detail={rpcDetailGroups(grants)}
detailLabel="Details"
>
{grants.map(({ aud, lxms }) => (
{lxms === '*'
? `Can call any method on ${audLabel(aud)}.`
: `Can call ${lxms.length} ${lxms.length === 1 ? 'method' : 'methods'} on ${audLabel(aud)}.`}
))}
);
}
/** A permission set the request includes, by its author's own description. */
function IncludeCard({ include }) {
const title =
pickLangString(include.title, include.titleLangs) || include.nsid;
const detailText = pickLangString(include.detail, include.detailLangs);
const sections = [
...repoDetailGroups(groupBySignature(include.repo)),
...rpcDetailGroups(include.rpc),
];
return (
}
title={title}
subtitle={title === include.nsid ? null : include.nsid}
detail={sections.length > 0 ? sections : null}
detailLabel="Details"
>
{detailText && {detailText}
}
);
}
/** What an OAuth grant covers, requested on consent or held by a session. */
export function ScopeSummary({ perms, className }) {
const [showRaw, setShowRaw] = useState(false);
const repo = perms.repo || [];
const rpc = perms.rpc || [];
const includes = perms.includes || [];
const blobs = perms.blobs || [];
const spaces = perms.spaces || [];
const nothing =
!perms.hasTransitionGeneric &&
!perms.hasEmail &&
!repo.length &&
!rpc.length &&
!includes.length &&
!blobs.length &&
!spaces.length;
if (nothing) {
return (
It only confirms who you are. No access to your data.
);
}
// Group repo grants under the app whose namespace they belong to, keeping
// wildcard grants apart: "any record type" is not an app's records.
const groups = new Map();
const wildcard = [];
for (const entry of repo) {
if (entry.collection.includes('*')) {
wildcard.push(entry);
continue;
}
const domain = nsidDomain(entry.collection);
const group = groups.get(domain) || [];
group.push(entry);
groups.set(domain, group);
}
return (
{perms.hasTransitionGeneric && (
Full access to everything in your account. This app uses broad
legacy access.
)}
{includes.map((include) => (
))}
{[...groups.entries()].map(([domain, entries]) => (
))}
{wildcard.length > 0 && (
{wildcard.map((entry) => {
const granted = ACTIONS.filter((action) => entry[action]);
return (
{granted.length === ACTIONS.length
? 'Full control of records of any type.'
: `Can ${listWords(granted)} records of any type.`}
);
})}
)}
{rpc.length > 0 &&
}
{perms.hasEmail && (
}
>
Can see your account email address.
)}
{blobs.length > 0 && (
}
>
Can upload files
{blobs.includes('*/*') ? ' of any type.' : `: ${blobs.join(', ')}.`}
)}
{spaces.length > 0 && (
}
title="Spaces"
>
{spaces.map((space, index) => (
{space.spaceType}
))}
)}
{perms.scope && (
setShowRaw(!showRaw)}>
{showRaw ? 'Hide technical details' : 'Technical details'}
{showRaw && (
{scopeTokens(perms.scope).map((token) => (
{token}
))}
)}
)}
);
}