import * as plugins from './plugins.js'; import type * as interfaces from '../ts_interfaces/index.js'; import type { ControllerSocketClient } from './classes.controllersocketclient.js'; import { accountsMovingNotice, accountsView, importPane, type IAccountActionSlot, type IAccountLoginView, type IAccountRow, type IAccountsView, type IImportPane, type IImportSourceRow, type TAccountsImportRead, type TAccountsRead, type TAccountsServiceState, type TAccountsTone, } from './classes.accountsreadmodel.js'; type TBadgeType = plugins.deesCatalog.DeesBadge['type']; type THintType = plugins.deesCatalog.DeesHint['type']; const badgeType = (toneArg: TAccountsTone): TBadgeType => { switch (toneArg) { case 'positive': return 'success'; case 'attention': return 'warning'; case 'critical': return 'error'; case 'neutral': return 'default'; } }; /** A state AGL is still working through is information; one it cannot leave by itself is a warning. */ const serviceHintType = (stateArg: TAccountsServiceState): THintType => ( stateArg === 'reading' || stateArg === 'connecting' ? 'info' : 'warning' ); const readErrorMessage = (errorArg: unknown): string => ( errorArg instanceof Error && errorArg.message.trim() ? errorArg.message.trim() : 'The controller did not answer.' ); /** * One reason line per distinct reason, naming the actions it applies to when there is more than * one. A disabled control whose reason is ambiguous is no better than a hidden one. */ const groupedReasons = ( slotsArg: readonly IAccountActionSlot[], ): { labels: string; reason: string }[] => { const grouped = new Map(); for (const slot of slotsArg) { grouped.set(slot.reason, [...grouped.get(slot.reason) ?? [], slot.label]); } return [...grouped.entries()].map(([reasonArg, labelsArg]) => ({ labels: grouped.size === 1 ? '' : labelsArg.join(' · '), reason: reasonArg, })); }; /** * The Accounts screen: which accounts this host has, what each is used for, and what uses it. * * It holds no account state of its own. It renders the controller's projection, re-reads it when * the controller announces a change, and runs no timer and no poll -- so a failed read is a state * the next read replaces, never a flag that can strand the screen. Every action this release cannot * perform is still on the screen, visible and disabled with its reason, because a control that * disappears in a state is indistinguishable from a state nobody designed. */ @plugins.deesElement.customElement('agl-accounts') export class AglAccounts extends plugins.deesElement.DeesElement { @plugins.deesElement.property({ attribute: false }) accessor client: ControllerSocketClient | undefined; /** Registered projects, so "Used by" can name a project instead of repeating an id. */ @plugins.deesElement.property({ attribute: false }) accessor projects: readonly interfaces.IControllerProject[] = []; @plugins.deesElement.state() private accessor read: TAccountsRead = { state: 'pending' }; @plugins.deesElement.state() private accessor importRead: TAccountsImportRead = { state: 'pending' }; @plugins.deesElement.state() private accessor selectedAccountId = ''; @plugins.deesElement.state() private accessor reading = false; /** Fences answers of a superseded read, so a slow one can never overwrite a newer screen. */ private readGeneration = 0; private configuredClient: ControllerSocketClient | undefined; public static styles = [ plugins.deesElement.cssManager.defaultStyles, plugins.deesElement.css` :host { display: block; min-width: 0; color: var(--dees-color-text-primary); font-family: var(--dees-font-family); } .screen { display: grid; gap: 14px; } .head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px; } .head h3 { margin: 0; font-size: 17px; } .freshness { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; color: var(--dees-color-text-secondary); font-size: 12px; } .freshnessDot { width: 7px; height: 7px; border-radius: 999px; background: var(--dees-color-accent-success); } .freshnessDot.stale { background: var(--dees-color-accent-warning); } /* Reserved so the service state can appear and disappear without moving the list. */ .stateSlot { min-height: 46px; } .actions { display: flex; flex-wrap: wrap; gap: 6px; } .reason { margin: 6px 0 0; color: var(--dees-color-text-secondary); font-size: 12px; line-height: 1.45; } .split { display: grid; grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); align-items: start; gap: 14px; } .listColumn { display: grid; gap: 8px; align-content: start; } .list { display: grid; gap: 6px; align-content: start; margin: 0; padding: 0; list-style: none; } .row { display: grid; gap: 3px; box-sizing: border-box; width: 100%; padding: 9px 11px; border: 1px solid var(--dees-color-border-subtle); border-radius: 9px; background: var(--dees-color-bg-secondary); color: inherit; font: inherit; text-align: left; cursor: pointer; } .row:hover { background: var(--dees-color-row-hover); } .row:focus-visible { outline: 2px solid var(--dees-color-focus-ring); outline-offset: -2px; } .row[aria-current='true'] { border-color: color-mix(in srgb, var(--dees-color-accent-primary) 55%, transparent); background: var(--dees-color-sidebar-selection); } .rowTop { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .rowLabel { min-width: 0; overflow: hidden; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } .rowMeta, .muted { color: var(--dees-color-text-secondary); font-size: 11.5px; overflow-wrap: anywhere; } .detail, .login, .source, .placeholder { border: 1px solid var(--dees-color-border-subtle); border-radius: 10px; } .detail { display: grid; gap: 12px; padding: 14px; } .placeholder { padding: 14px; color: var(--dees-color-text-secondary); font-size: 12.5px; } .detail h4 { margin: 0; font-size: 15px; } .sectionTitle { margin: 0; color: var(--dees-color-text-secondary); font-size: 11px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; } .stack { display: grid; gap: 8px; } .login, .source { display: grid; gap: 5px; padding: 10px 11px; background: var(--dees-color-bg-secondary); } .loginTop { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .loginPurpose { font-size: 13px; font-weight: 600; } .statusText { font-size: 12.5px; line-height: 1.45; } .detailGrid { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 4px 12px; margin: 0; font-size: 12px; } .detailGrid dt { color: var(--dees-color-text-secondary); } .detailGrid dd { margin: 0; overflow-wrap: anywhere; } .problems { margin: 0; padding-left: 18px; color: var(--dees-color-text-warning); font-size: 12px; } /* The one visual difference of a last-known screen: its values, and only its values. */ .dimmed .listColumn, .dimmed .detail { opacity: .62; } details > summary { cursor: pointer; color: var(--dees-color-text-secondary); font-size: 12px; } details > summary:focus-visible { outline: 2px solid var(--dees-color-focus-ring); outline-offset: 2px; } details[open] > summary { margin-bottom: 8px; } .import { display: grid; gap: 10px; } @media (max-width: 720px) { .split { grid-template-columns: minmax(0, 1fr); } .detailGrid { grid-template-columns: minmax(0, 1fr); gap: 1px; } .detailGrid dd { margin-bottom: 5px; } } `, ]; public async connectedCallback(): Promise { await super.connectedCallback(); this.adoptClient(); } public updated(changesArg: Map): void { super.updated(changesArg); this.adoptClient(); } /** * Reads once per client, whichever of the two lifecycle callbacks sees it first. * * The base class's `connectedCallback` is asynchronous, so the first render can land on either * side of it; comparing the client that was read against the current one is what keeps that from * becoming two reads of the same thing. */ private adoptClient(): void { if (this.configuredClient === this.client) return; this.configuredClient = this.client; this.read = { state: 'pending' }; this.importRead = { state: 'pending' }; void this.refresh(); } /** * Reads the account view, and then the import report when there is a service to ask. * * This is the screen's only data path: the controller announces every change with one event and * the app calls this, so nothing here polls and nothing here waits on a timer. */ public async refresh(): Promise { const client = this.client; if (!client) return; const generation = ++this.readGeneration; this.reading = true; try { const { authority } = await client.fire( 'controller.accounts.snapshot', {}, { maxRetries: 0 }, ); if (generation !== this.readGeneration) return; this.read = { state: 'answered', projection: authority }; await this.refreshImportInventory(generation, authority.state); } catch (errorArg) { if (generation !== this.readGeneration) return; this.read = { state: 'failed', message: readErrorMessage(errorArg) }; this.importRead = { state: 'pending' }; } finally { if (generation === this.readGeneration) this.reading = false; } } /** * The import report is asked for only when the service can answer it. Any other state is named * from the projection AGL already holds, so a pane that lists nothing always says why. */ private async refreshImportInventory( generationArg: number, daemonArg: interfaces.TControllerAuthorityDaemonState, ): Promise { const client = this.client; if (!client) return; if (daemonArg !== 'ready') { this.importRead = { state: 'answered', result: { state: 'unavailable', daemon: daemonArg } }; return; } try { const { result } = await client.fire( 'controller.accounts.import.inventory', {}, { maxRetries: 0 }, ); if (generationArg !== this.readGeneration) return; this.importRead = { state: 'answered', result }; } catch (errorArg) { if (generationArg !== this.readGeneration) return; this.importRead = { state: 'failed', message: readErrorMessage(errorArg) }; } } private renderActionSlots( slotsArg: readonly IAccountActionSlot[], ): plugins.deesElement.TemplateResult { return plugins.deesElement.html`
${slotsArg.map((slotArg) => plugins.deesElement.html` `)}
${groupedReasons(slotsArg).map((groupArg) => plugins.deesElement.html`

${groupArg.labels ? `${groupArg.labels}: ` : ''}${groupArg.reason}

`)} `; } private renderRow(rowArg: IAccountRow, selectedIdArg: string): plugins.deesElement.TemplateResult { return plugins.deesElement.html` `; } private renderLogin(loginArg: IAccountLoginView): plugins.deesElement.TemplateResult { return plugins.deesElement.html` `; } private renderDetail(rowArg: IAccountRow): plugins.deesElement.TemplateResult { return plugins.deesElement.html`

${rowArg.label}

${rowArg.emailText} · ${rowArg.planText}
${this.renderActionSlots(rowArg.actions)}

Used for

${rowArg.usedFor.length ? rowArg.usedFor.map((loginArg) => this.renderLogin(loginArg)) : plugins.deesElement.html`
This account has no logins yet.
`}

Used by

${rowArg.usedBy.length ? plugins.deesElement.html`
${rowArg.usedBy.map((bindingArg) => plugins.deesElement.html`
${bindingArg.harnessLabel}
${bindingArg.scopeLabel}
`)}
` : plugins.deesElement.html`
Nothing uses this account yet.
`}
`; } private renderImportSource(sourceArg: IImportSourceRow): plugins.deesElement.TemplateResult { return plugins.deesElement.html`
${sourceArg.kindLabel} ${sourceArg.statusLabel}
${sourceArg.identityLabel}
${sourceArg.treatmentLabel}
${sourceArg.treatmentDetail}
${sourceArg.problems.length ? plugins.deesElement.html`
    ${sourceArg.problems.map((problemArg) => plugins.deesElement.html`
  • ${problemArg}
  • `)}
` : ''}
`; } private renderImportPane(paneArg: IImportPane): plugins.deesElement.TemplateResult { return plugins.deesElement.html`

Logins already on this host

${paneArg.notice ? plugins.deesElement.html`
${paneArg.notice}
` : ''} ${paneArg.noticeLines.length ? plugins.deesElement.html`` : ''} ${paneArg.sources.map((sourceArg) => this.renderImportSource(sourceArg))} ${paneArg.problems.length ? plugins.deesElement.html`
    ${paneArg.problems.map((problemArg) => plugins.deesElement.html`
  • ${problemArg}
  • `)}
` : ''}
`; } private renderList(viewArg: IAccountsView, selectedIdArg: string): plugins.deesElement.TemplateResult { return plugins.deesElement.html`
    ${viewArg.list.rows.map((rowArg) => plugins.deesElement.html`
  • ${this.renderRow(rowArg, selectedIdArg)}
  • `)}
${viewArg.list.notice ? plugins.deesElement.html`
${viewArg.list.notice}
` : ''} ${viewArg.list.removedRows.length ? plugins.deesElement.html`
Removed accounts (${viewArg.list.removedRows.length})
    ${viewArg.list.removedRows.map((rowArg) => plugins.deesElement.html`
  • ${this.renderRow(rowArg, selectedIdArg)}
  • `)}
` : ''}
`; } public render(): plugins.deesElement.TemplateResult { const view = accountsView(this.read, this.projects); const pane = importPane(this.importRead); const rows = [...view.list.rows, ...view.list.removedRows]; const selected = rows.find((rowArg) => rowArg.id === this.selectedAccountId) ?? rows[0]; return plugins.deesElement.html`

Accounts

${view.service.freshness}
void this.refresh()}> ${view.actions.map((slotArg) => plugins.deesElement.html` `)}
${groupedReasons(view.actions).map((groupArg) => plugins.deesElement.html`

${groupArg.labels ? `${groupArg.labels}: ` : ''}${groupArg.reason}

`)}
${view.service.headline ? plugins.deesElement.html`` : ''}
${this.renderList(view, selected?.id ?? '')} ${selected ? this.renderDetail(selected) : plugins.deesElement.html`
${view.list.state === 'unavailable' ? 'Accounts appear here once the accounts service answers.' : 'Select an account to see what it is used for.'}
`}
${this.renderImportPane(pane)}
`; } } declare global { interface HTMLElementTagNameMap { 'agl-accounts': AglAccounts; } }