import type { HealthStatus, IntegrationSlug, Owner } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // Pure display logic for the admin Users section. // // Everything here is a plain function over wire data so it can be tested // without mounting the console (the package's testing doctrine: extract the // logic, test the logic). The components below it only arrange the results. // --------------------------------------------------------------------------- /** The connection shape the admin plane returns (`AdminUserConnection`), typed * structurally so these helpers don't depend on the schema module. */ export interface AdminConnectionRow { readonly owner: Owner; readonly integration: IntegrationSlug; readonly name: string; readonly oauthScope: string | null; readonly lastHealth: { readonly status: HealthStatus; /** A `HealthCheckReason` literal, tolerant of future additions (the admin * wire types it as a plain string); unrecognized values read as * unclassified. */ readonly reason?: string; } | null; } // ── Last seen ─────────────────────────────────────────────────────────────── const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; /** * Relative "last seen", phrased no more precisely than the field deserves. * * The writer throttles `lastSeenAt` to roughly an hour, so a sub-hour delta is * NOT evidence the user was here five minutes ago — it only means the sighting * landed within the last throttle window. Everything under an hour therefore * collapses to one honest bucket instead of counting minutes, and `null` (never * seen on a request — a user earns a row at connection-create, before any * sighting) reads as "Never", not "unknown". */ export const formatLastSeen = (lastSeenAt: number | null, now: number = Date.now()): string => { if (lastSeenAt === null) return "Never"; const delta = now - lastSeenAt; if (delta < HOUR) return "Within the hour"; if (delta < DAY) { const hours = Math.floor(delta / HOUR); return hours === 1 ? "About 1 hour ago" : `About ${hours} hours ago`; } const days = Math.floor(delta / DAY); if (days === 1) return "About 1 day ago"; if (days < 30) return `About ${days} days ago`; return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric", }).format(new Date(lastSeenAt)); }; /** The tooltip behind {@link formatLastSeen}: the exact recorded instant plus * the coarseness caveat, so an operator reading "Within the hour" can see what * the row actually stores. */ export const lastSeenTitle = (lastSeenAt: number | null): string => lastSeenAt === null ? "Never seen on a request" : `Recorded ${new Date(lastSeenAt).toLocaleString()} — updated at most once an hour`; /** Absolute date, for the created column. */ export const formatAdminDate = (epochMs: number): string => new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format( new Date(epochMs), ); // ── Identity ──────────────────────────────────────────────────────────────── /** * How a user's opaque `externalId` is shown. * * The id is opaque by contract — nothing may parse it — so it is rendered whole, * in mono, and only visually shortened when it is long enough to break the * column. The full value stays available via the title attribute and the copy * button. `"local"` is a host sentinel for the single-player subject and reads * better spelled out. */ export const LOCAL_SUBJECT_ID = "local"; export const isLocalSubject = (externalId: string): boolean => externalId === LOCAL_SUBJECT_ID; /** Visual truncation only — never a parse. Keeps both ends so two ids that * differ in the middle stay distinguishable. */ export const shortenExternalId = (externalId: string, max = 28): string => { if (externalId.length <= max) return externalId; const head = Math.ceil((max - 1) / 2); const tail = Math.floor((max - 1) / 2); return `${externalId.slice(0, head)}…${externalId.slice(externalId.length - tail)}`; }; /** The identity half of a user row, as the admin plane reports it. Both fields * are independently nullable: the host resolved one, the other, or neither. */ export interface AdminUserIdentityRow { readonly externalId: string; readonly email?: string | null; readonly displayName?: string | null; } /** * How one user is titled and subtitled. * * The primary line is whatever an operator would actually recognize — the email * first, since that is what they invite, search and support against; the * display name only when there is no email; and the id itself when the host * resolved neither. The secondary line carries the `externalId` whenever the * primary line is NOT already the id, so the opaque principal is always one * glance away without ever being the headline for a user who has a name. * * When identity is absent the result is exactly the pre-identity rendering — one * mono id line and no subtitle — so an unresolvable row (a member who left the * org while their connections remain, a host sentinel like "local", a directory * outage) degrades to what this table always showed rather than to a gap. * * `primaryIsId` is what tells the component which line gets mono/metadata * styling, so the caller never has to re-derive the same decision. */ export interface AdminUserTitle { readonly primary: string; readonly secondary: string | null; readonly primaryIsId: boolean; } export const adminUserTitle = (user: AdminUserIdentityRow): AdminUserTitle => { const email = user.email?.trim(); const displayName = user.displayName?.trim(); const idLabel = isLocalSubject(user.externalId) ? LOCAL_SUBJECT_ID : user.externalId; const named = email || displayName; if (!named) return { primary: idLabel, secondary: null, primaryIsId: true }; return { primary: named, secondary: idLabel, primaryIsId: false }; }; /** The email an operator can copy off a row, or `null` when the host resolved * none. Deliberately NOT the display name: a name is not an address, and a * copy button that yields something unusable is worse than no button. */ export const adminUserCopyableEmail = (user: AdminUserIdentityRow): string | null => { const email = user.email?.trim(); return email ? email : null; }; // ── Connections ───────────────────────────────────────────────────────────── /** The health status a row displays. A connection that was never probed carries * no verdict, which is `unknown` in the shared vocabulary. A tool-sync stamp * (`reason: "tool_sync_failed"`) is catalog state, not credential health — * the same rule the console's `presentableHealth` applies — so it reads * `unknown` here rather than painting the row degraded. */ export const connectionHealthStatus = (connection: AdminConnectionRow): HealthStatus => { const last = connection.lastHealth; if (last === null) return "unknown"; if (last.reason === "tool_sync_failed") return "unknown"; return last.status; }; /** The catalog row this view needs: the slug it marks, plus the `kind` that * says whether connecting is even a thing one can do to it. Structural so * these helpers stay independent of the catalog schema module. */ export interface AdminCatalogRow { readonly slug: IntegrationSlug; readonly kind: string; } /** The built-in integration's reserved slug. `kind` is the general signal, but * the product page pins the slug too, so this mirrors it exactly. */ const BUILT_IN_INTEGRATION_SLUG = "executor"; /** * Whether an integration has a connect flow at all. * * Deliberately NOT "declares no auth methods": an openapi/mcp/graphql * integration can carry `authMethods: []` and still be connectable, because its * accounts panel passes `createCustomMethod` and the user defines the method * themselves (`accounts-section.tsx`: `canAddConnection = methods.length > 0 || * createCustomMethod !== undefined`). Empty auth methods therefore means "no * method declared yet", not "cannot be connected". * * The real gate is the one the product's integration detail page applies before * any of that — `integration-detail.tsx`: `isBuiltInIntegration = namespace === * "executor" || integrationData?.kind === "built-in"`, which removes the whole * Accounts tab rather than merely disabling a button. A built-in integration is * a plugin-declared static source with a synthetic connection and no * credential: there is nothing to connect and no flow to send anyone to. */ export const isConnectableIntegration = (row: AdminCatalogRow): boolean => row.kind !== "built-in" && String(row.slug) !== BUILT_IN_INTEGRATION_SLUG; /** * The available-vs-connected view for one user: every *connectable* integration * in the tenant's catalog, marked with whether this user has connected it. * * "Available" is the catalog, so an integration nobody has connected still gets * a (greyed) slot — that absence is the point of the view. Sorted connected * first so a dense grid leads with signal, then by slug for a stable order * across users. * * Non-connectable integrations are dropped entirely — from the slots, from the * counts, and from the connect links. Listing the built-in under "not * connected" would invite an admin to send a link to a flow that doesn't exist. * The one exception is data honesty: if a user somehow HOLDS a connection on * one, it is reported as connected rather than hidden. * * Org-owned connections are deliberately excluded from the API's per-user * results, so anything appearing here is genuinely this user's own. */ export interface IntegrationConnectionState { readonly integration: IntegrationSlug; readonly connected: boolean; readonly connections: readonly AdminConnectionRow[]; } export const integrationConnectionStates = ( catalog: readonly AdminCatalogRow[], connections: readonly AdminConnectionRow[], ): readonly IntegrationConnectionState[] => { const byIntegration = new Map(); for (const connection of connections) { const existing = byIntegration.get(connection.integration); if (existing) existing.push(connection); else byIntegration.set(connection.integration, [connection]); } // A user can hold a connection on an integration the catalog read didn't // return (a catalog the viewer can't see, or one removed since). Showing it // as connected is more honest than dropping the user's own credential — and // an unknown slug is assumed connectable, since only the catalog can say // otherwise. const slugs = new Set([ ...catalog.filter(isConnectableIntegration).map((row) => row.slug), ...byIntegration.keys(), ]); return [...slugs] .map((integration) => ({ integration, connected: byIntegration.has(integration), connections: byIntegration.get(integration) ?? [], })) .sort((a, b) => { if (a.connected !== b.connected) return a.connected ? -1 : 1; return a.integration.localeCompare(b.integration); }); }; // ── Connect links ─────────────────────────────────────────────────────────── /** * The connect deep link for an integration, as an absolute URL for THIS * deployment — something an admin can paste to a user who needs to connect. * * The org segment is load-bearing and must be the SENDING admin's org. A * recipient who belongs to several orgs would otherwise land in whichever one * their session defaults to and connect the credential in the wrong workspace — * silently, since the flow itself would succeed. The admin page is org-scoped, * so the correct org is known at the call site and is carried in the link * rather than left to the recipient's session to guess. * * The slug is optional because the route's `{-$orgSlug}` segment is: a host that * renders no segment yields the bare `/connect/` form, which is the whole * route there. (Both cloud and self-host do canonicalize onto a slug in * practice — self-host onto `default` — so the prefixed form is the common * case.) It is rendered as a copyable URL rather than an in-console link * because it targets the recipient's session, not the admin's. */ export const connectLinkUrl = ( integration: IntegrationSlug, origin: string, orgSlug?: string | undefined, ): string => { const base = origin.replace(/\/+$/, ""); const org = orgSlug?.trim(); return org ? `${base}/${org}/connect/${integration}` : `${base}/connect/${integration}`; }; // ── Paging ────────────────────────────────────────────────────────────────── /** * Split an over-fetched page into the rows to render plus whether another page * exists. The contract returns no total count, so "is there a next page" is * answered by asking for one row more than the page holds. */ export const splitPage = ( rows: readonly A[], pageSize: number, ): { readonly rows: readonly A[]; readonly hasNext: boolean } => ({ rows: rows.slice(0, pageSize), hasNext: rows.length > pageSize, }); /** The 1-based page number an offset lands on, for the mono pager label. */ export const pageNumber = (offset: number, pageSize: number): number => Math.floor(offset / pageSize) + 1;