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 (
{tile}
{children}
); } 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} ))}
)}
)}
); }