From 7e81a0cd49edc76bc3dbdb2d00c6d1ef86ccbf77 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 28 Mar 2026 15:28:13 +0000 Subject: [PATCH] Align portal contracts with access and billing model --- .../v6/internal/subsystems/api-contracts.md | 2 +- .../cloudcp/portal/dist/build_manifest.json | 12 +- internal/cloudcp/portal/dist/portal_app.css | 408 ++++++------ internal/cloudcp/portal/dist/portal_app.js | 606 +++++++++--------- .../frontend/src/account_controller.test.ts | 8 +- .../portal/frontend/src/account_controller.ts | 4 +- .../frontend/src/account_runtime.test.ts | 28 +- .../portal/frontend/src/account_runtime.ts | 66 +- .../portal/frontend/src/account_view.test.ts | 78 +-- .../portal/frontend/src/account_view.ts | 126 ++-- internal/cloudcp/portal/frontend/src/api.ts | 6 +- .../cloudcp/portal/frontend/src/app.test.ts | 16 +- internal/cloudcp/portal/frontend/src/app.ts | 6 +- .../frontend/src/{services.ts => billing.ts} | 244 +++---- ...ler.test.ts => billing_controller.test.ts} | 26 +- ...es_controller.ts => billing_controller.ts} | 22 +- ...ices_view.test.ts => billing_view.test.ts} | 48 +- .../src/{services_view.ts => billing_view.ts} | 94 +-- .../portal/frontend/src/runtime.test.ts | 12 +- .../cloudcp/portal/frontend/src/runtime.ts | 34 +- .../portal/frontend/src/shell_view.test.ts | 10 +- .../cloudcp/portal/frontend/src/shell_view.ts | 182 +++--- .../cloudcp/portal/frontend/src/state.test.ts | 96 +-- internal/cloudcp/portal/frontend/src/state.ts | 80 +-- .../cloudcp/portal/frontend/src/store.test.ts | 12 +- internal/cloudcp/portal/frontend/src/store.ts | 38 +- .../cloudcp/portal/frontend/src/styles.css | 408 ++++++------ internal/cloudcp/portal/frontend/src/types.ts | 20 +- internal/cloudcp/portal/handlers_test.go | 2 +- 29 files changed, 1347 insertions(+), 1347 deletions(-) rename internal/cloudcp/portal/frontend/src/{services.ts => billing.ts} (61%) rename internal/cloudcp/portal/frontend/src/{services_controller.test.ts => billing_controller.test.ts} (77%) rename internal/cloudcp/portal/frontend/src/{services_controller.ts => billing_controller.ts} (84%) rename internal/cloudcp/portal/frontend/src/{services_view.test.ts => billing_view.test.ts} (80%) rename internal/cloudcp/portal/frontend/src/{services_view.ts => billing_view.ts} (79%) diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 57655efdd..820764062 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -65,7 +65,7 @@ Own canonical runtime payload shapes between backend and frontend. 42. `internal/cloudcp/portal/page_templates.go` 43. `internal/cloudcp/portal/frontend/src/index.ts` 44. `internal/cloudcp/portal/frontend/src/shell.ts` -45. `internal/cloudcp/portal/frontend/src/services.ts` +45. `internal/cloudcp/portal/frontend/src/billing.ts` 46. `internal/cloudcp/portal/frontend/src/runtime.ts` 47. `internal/cloudcp/portal/frontend/src/types.ts` 48. `internal/cloudcp/portal/frontend/src/styles.css` diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 4741528a6..2701b9608 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "8b864021aef6e9d6dd5967577c1ba7540113e4ce64ed0099e5aebc0214530d61", + "source_hash": "5b98384650d14d35c5791370b574cb384cb74e4d362bffa49d14cdd8722a6e86", "build_inputs": [ "package.json", "tsconfig.json", @@ -18,16 +18,16 @@ "src/async_state.ts", "src/auth_controller.test.ts", "src/auth_controller.ts", + "src/billing.ts", + "src/billing_controller.test.ts", + "src/billing_controller.ts", + "src/billing_view.test.ts", + "src/billing_view.ts", "src/fonts/roboto-latin-ext.woff2", "src/fonts/roboto-latin.woff2", "src/index.ts", "src/runtime.test.ts", "src/runtime.ts", - "src/services.ts", - "src/services_controller.test.ts", - "src/services_controller.ts", - "src/services_view.test.ts", - "src/services_view.ts", "src/shell.ts", "src/shell_view.test.ts", "src/shell_view.ts", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index 2d80ae561..854f116d6 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -230,7 +230,7 @@ header .logout-btn:hover, } .portal-shell-head-kicker, .account-panel-kicker, -.service-card-kicker, +.billing-card-kicker, .account-eyebrow { font-size: 11px; font-weight: 800; @@ -239,7 +239,7 @@ header .logout-btn:hover, } .portal-shell-head-kicker, .account-panel-kicker, -.service-card-kicker { +.billing-card-kicker { color: var(--accent); } .account-eyebrow { @@ -320,7 +320,7 @@ header .logout-btn:hover, } .portal-command-strip, .intro-card, -.service-panel, +.billing-panel, .account-surface, .empty-state { border: 1px solid var(--line); @@ -632,7 +632,7 @@ header .logout-btn:hover, } .portal-support-link, .support-link, -.service-panel .helper-text a { +.billing-panel .helper-text a { color: var(--accent-strong); font-weight: 600; } @@ -645,13 +645,13 @@ header .logout-btn:hover, padding: 12px 14px 0; } .portal-top-section, -.service-section { +.billing-section { display: flex; flex-direction: column; gap: 12px; } .portal-top-section-header h2, -.service-header h2 { +.billing-header h2 { margin: 4px 0 0; font-family: var(--font-display); font-size: clamp(1.34rem, 1.7vw, 1.72rem); @@ -660,14 +660,14 @@ header .logout-btn:hover, font-weight: 720; } .portal-top-section-header p, -.service-note { +.billing-note { margin: 6px 0 0; font-size: 12px; line-height: 1.42; color: var(--ink-soft); max-width: 520px; } -.service-header { +.billing-header { display: flex; align-items: start; justify-content: space-between; @@ -744,12 +744,12 @@ header .logout-btn:hover, .account-stat-card, .workspace-row, .workspace-management-panel, -.team-management-panel, +.access-management-panel, .add-workspace-form, -.team-roster, -.team-invite-panel, -.service-card, -.service-panel .subsection { +.access-roster, +.access-invite-panel, +.billing-card, +.billing-panel .subsection { border: 1px solid var(--line); background: var(--panel); border-radius: var(--radius-lg); @@ -759,27 +759,27 @@ header .logout-btn:hover, .account-overview-card:hover, .account-stat-card:hover, .workspace-row:hover, -.service-card:hover { +.billing-card:hover { transform: none; box-shadow: var(--shadow-sm); } .account-overview-card, .account-stat-card, .workspace-management-panel, -.team-management-panel, +.access-management-panel, .add-workspace-form, -.service-panel, -.team-roster, -.team-invite-panel { +.billing-panel, +.access-roster, +.access-invite-panel { padding: 14px; } .account-overview-card h3, .account-stage-header h3, .workspace-management-header h3, -.team-management-header h3, -.service-card h3, -.service-panel h3, -.service-panel .subsection h4 { +.access-management-header h3, +.billing-card h3, +.billing-panel h3, +.billing-panel .subsection h4 { margin: 8px 0 0; font-family: var(--font-display); font-size: 18px; @@ -790,11 +790,11 @@ header .logout-btn:hover, .account-overview-card p, .account-stage-header p, .workspace-management-header p, -.team-management-header p, -.team-panel-heading p, -.service-card p, -.service-panel p, -.service-panel .helper-text { +.access-management-header p, +.access-panel-heading p, +.billing-card p, +.billing-panel p, +.billing-panel .helper-text { margin: 10px 0 0; font-size: 13px; line-height: 1.55; @@ -1070,7 +1070,7 @@ header .logout-btn:hover, border-left: none; } .account-stat-label, -.team-stat-label { +.access-stat-label { display: block; font-size: 11px; font-weight: 800; @@ -1079,7 +1079,7 @@ header .logout-btn:hover, color: var(--ink-muted); } .account-stat-value, -.team-stat-value { +.access-stat-value { display: block; margin-top: 8px; font-size: 24px; @@ -1093,7 +1093,7 @@ header .logout-btn:hover, color: var(--accent); } .account-stat-unhealthy, -.team-stat-error { +.access-stat-error { color: var(--danger); } .account-action-strip { @@ -1619,7 +1619,7 @@ header .logout-btn:hover, } .workspace-name, .workspace-management-content h4, -.team-panel-heading h4 { +.access-panel-heading h4 { margin: 0; font-size: 17px; line-height: 1.1; @@ -1709,7 +1709,7 @@ header .logout-btn:hover, box-shadow: var(--shadow-sm); } .workspace-management-panel.visible, -.team-section.visible .team-management-panel { +.access-section.visible .access-management-panel { box-shadow: var(--shadow-md); } .workspace-management-panel { @@ -1730,7 +1730,7 @@ header .logout-btn:hover, gap: 14px; } .workspace-management-header, -.team-management-header { +.access-management-header { display: flex; align-items: start; justify-content: space-between; @@ -1883,18 +1883,18 @@ header .logout-btn:hover, max-width: 220px; } .add-workspace-form, -.team-section, -.service-panel { +.access-section, +.billing-panel { display: none; } .add-workspace-form.visible, -.team-section.visible, -.service-panel.visible { +.access-section.visible, +.billing-panel.visible { display: block; } .add-workspace-form label, -.service-panel label, -.team-invite label { +.billing-panel label, +.access-invite label { display: block; margin-bottom: 6px; font-size: 12px; @@ -1904,11 +1904,11 @@ header .logout-btn:hover, color: var(--ink-muted); } .add-workspace-form input, -.service-panel input, -.service-panel textarea, -.team-invite input, -.team-invite select, -.team-table select { +.billing-panel input, +.billing-panel textarea, +.access-invite input, +.access-invite select, +.access-table select { width: 100%; border: 1px solid var(--line-strong); border-radius: 12px; @@ -1918,76 +1918,76 @@ header .logout-btn:hover, font: inherit; } .add-workspace-form input:focus, -.service-panel input:focus, -.service-panel textarea:focus, -.team-invite input:focus, -.team-invite select:focus, -.team-table select:focus { +.billing-panel input:focus, +.billing-panel textarea:focus, +.access-invite input:focus, +.access-invite select:focus, +.access-table select:focus { outline: 2px solid rgba(15, 109, 115, 0.18); border-color: var(--accent); } -.team-stat-card { +.access-stat-card { min-width: 0; padding: 10px 12px; border-radius: var(--radius-sm); background: var(--panel-muted); } -.team-management-stats { +.access-management-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 12px 0; } -.team-stat-card { +.access-stat-card { border: 1px solid var(--line); border-radius: 10px; background: #fbfcfd; } -.team-stat-value { +.access-stat-value { margin-top: 6px; font-size: 22px; } -.team-management-grid { +.access-management-grid { display: grid; grid-template-columns: minmax(0, 1.82fr) minmax(292px, 0.84fr); gap: 14px; align-items: start; } -.team-roster-column { +.access-roster-column { display: flex; flex-direction: column; gap: 14px; min-width: 0; } -.team-management-panel { +.access-management-panel { box-shadow: var(--shadow-sm); } -.team-side-column { +.access-side-column { display: flex; flex-direction: column; align-self: start; position: sticky; top: 14px; } -.team-roster, -.team-invite-panel, -.team-operations-panel { +.access-roster, +.access-invite-panel, +.access-operations-panel { background: #fff; border-color: var(--line); } -.team-roster { +.access-roster { align-self: start; display: flex; flex-direction: column; gap: 12px; } -.team-roster.state-only { +.access-roster.state-only { border: none; background: transparent; box-shadow: none; padding: 0; } -.team-operations-panel { +.access-operations-panel { display: flex; flex-direction: column; gap: 12px; @@ -1995,35 +1995,35 @@ header .logout-btn:hover, border: 1px solid var(--line); border-radius: 12px; } -.team-operations-grid { +.access-operations-grid { display: flex; flex-direction: column; gap: 14px; } -.team-policy-panel { +.access-policy-panel { border: 1px solid var(--line); border-radius: 12px; background: var(--panel-muted); padding: 14px; } -.team-review-strip { +.access-review-strip { display: flex; flex-direction: column; gap: 10px; padding-top: 2px; } -.team-review-shell { +.access-review-shell { padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: #fff; } -.team-review-grid { +.access-review-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } -.team-review-card { +.access-review-card { display: flex; flex-direction: column; gap: 6px; @@ -2032,26 +2032,26 @@ header .logout-btn:hover, border-radius: 10px; background: var(--panel-muted); } -.team-review-card strong { +.access-review-card strong { font-size: 13px; line-height: 1.25; color: var(--ink); } -.team-review-card span { +.access-review-card span { font-size: 12px; line-height: 1.55; color: var(--ink-soft); } -.team-panel-heading { +.access-panel-heading { margin-bottom: 10px; } -.team-panel-heading-tight { +.access-panel-heading-tight { margin-bottom: 0; } -.team-panel-heading p { +.access-panel-heading p { max-width: 720px; } -.team-roster-list { +.access-roster-list { display: flex; flex-direction: column; gap: 0; @@ -2060,13 +2060,13 @@ header .logout-btn:hover, background: #fff; overflow: hidden; } -.team-roster-list.state-only { +.access-roster-list.state-only { border: none; border-radius: 0; background: transparent; overflow: visible; } -.team-roster-head { +.access-roster-head { display: grid; grid-template-columns: minmax(0, 1fr) 142px 122px; gap: 14px; @@ -2078,7 +2078,7 @@ header .logout-btn:hover, text-transform: uppercase; color: var(--ink-muted); } -.team-member-row { +.access-member-row { display: grid; grid-template-columns: minmax(0, 1fr) 142px 122px; align-items: center; @@ -2088,47 +2088,47 @@ header .logout-btn:hover, border-top: 1px solid var(--line); background: #fff; } -.team-member-row:first-of-type { +.access-member-row:first-of-type { border-top: none; } -.team-member-row:hover { +.access-member-row:hover { background: #fbfdff; } -.team-member-identity { +.access-member-identity { display: flex; flex-direction: column; gap: 4px; min-width: 0; } -.team-member-topline { +.access-member-topline { display: flex; align-items: center; justify-content: flex-start; gap: 8px; min-width: 0; } -.team-member-email { +.access-member-email { font-size: 12px; font-weight: 700; color: var(--ink); } -.team-member-caption { +.access-member-caption { font-size: 10px; line-height: 1.4; color: var(--ink-soft); } -.team-control-cell { +.access-control-cell { display: flex; align-items: center; min-width: 0; } -.team-control-cell-role { +.access-control-cell-role { justify-content: flex-start; } -.team-control-cell-access { +.access-control-cell-access { justify-content: flex-end; } -.team-role-badge { +.access-role-badge { display: inline-flex; align-items: center; min-height: 32px; @@ -2142,7 +2142,7 @@ header .logout-btn:hover, letter-spacing: 0.08em; text-transform: uppercase; } -.team-inline-role-badge { +.access-inline-role-badge { display: inline-flex; align-items: center; min-height: 20px; @@ -2157,7 +2157,7 @@ header .logout-btn:hover, text-transform: uppercase; white-space: nowrap; } -.team-role-select { +.access-role-select { width: 100%; min-width: 0; min-height: 32px; @@ -2169,7 +2169,7 @@ header .logout-btn:hover, font-size: 11px; font-weight: 700; } -.team-control-locked { +.access-control-locked { display: inline-flex; align-items: center; justify-content: center; @@ -2185,7 +2185,7 @@ header .logout-btn:hover, letter-spacing: 0.08em; text-transform: uppercase; } -.team-list-message { +.access-list-message { display: flex; flex-direction: column; align-items: flex-start; @@ -2198,24 +2198,24 @@ header .logout-btn:hover, background: #f8fbfd; box-shadow: inset 0 0 0 1px rgba(11, 106, 114, 0.04); } -.team-list-message-title { +.access-list-message-title { font-size: 16px; line-height: 1.1; letter-spacing: -0.02em; color: var(--ink); } -.team-list-message-copy { +.access-list-message-copy { font-size: 13px; line-height: 1.55; color: var(--ink-soft); } -.team-list-message.error { +.access-list-message.error { border-color: rgba(166, 27, 27, 0.18); background: var(--danger-soft); box-shadow: inset 4px 0 0 rgba(166, 27, 27, 0.26); } -.team-list-message.error .team-list-message-title, -.team-list-message.error .team-list-message-copy { +.access-list-message.error .access-list-message-title, +.access-list-message.error .access-list-message-copy { color: var(--danger); } .btn-remove { @@ -2235,12 +2235,12 @@ header .logout-btn:hover, .btn-remove:hover { background: rgba(166, 27, 27, 0.08); } -.team-invite { +.access-invite { display: flex; flex-direction: column; gap: 12px; } -.team-policy-list { +.access-policy-list { display: flex; flex-direction: column; gap: 0; @@ -2249,28 +2249,28 @@ header .logout-btn:hover, background: var(--panel-muted); overflow: hidden; } -.team-policy-row { +.access-policy-row { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border-top: 1px solid var(--line); } -.team-policy-row:first-child { +.access-policy-row:first-child { border-top: none; } -.team-policy-row strong { +.access-policy-row strong { font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-muted); } -.team-policy-row span { +.access-policy-row span { font-size: 12px; line-height: 1.4; color: var(--ink-soft); } -.service-action-list { +.billing-action-list { display: grid; grid-template-columns: 1fr; gap: 0; @@ -2279,7 +2279,7 @@ header .logout-btn:hover, background: #fff; overflow: hidden; } -.service-shell { +.billing-shell { display: grid; grid-template-columns: minmax(280px, 0.82fr) minmax(0, 1.18fr); gap: 12px; @@ -2289,45 +2289,45 @@ header .logout-btn:hover, border-radius: 12px; background: #fff; } -.service-shell-main, -.service-shell-sidebar { +.billing-shell-main, +.billing-shell-sidebar { display: flex; flex-direction: column; gap: 8px; } -.service-shell-sidebar-head, -.service-inline-support { +.billing-shell-sidebar-head, +.billing-inline-support { padding: 11px 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel-strong); } -.service-shell-sidebar-head h3 { +.billing-shell-sidebar-head h3 { margin: 4px 0 0; font-size: 16px; line-height: 1.08; letter-spacing: -0.03em; } -.service-shell-sidebar-head p, -.service-inline-support p { +.billing-shell-sidebar-head p, +.billing-inline-support p { margin: 5px 0 0; font-size: 11px; line-height: 1.45; color: var(--ink-soft); } -.service-inline-support h4 { +.billing-inline-support h4 { margin: 4px 0 0; font-size: 16px; line-height: 1.08; letter-spacing: -0.03em; } -.service-inline-support-points { +.billing-inline-support-points { display: flex; flex-direction: column; gap: 0; margin-top: 8px; } -.service-inline-support-point { +.billing-inline-support-point { display: flex; flex-direction: column; gap: 3px; @@ -2335,70 +2335,70 @@ header .logout-btn:hover, border-top: 1px solid var(--line); border-left: 2px solid rgba(15, 118, 110, 0.16); } -.service-inline-support-point:first-child { +.billing-inline-support-point:first-child { padding-top: 0; border-top: none; } -.service-inline-support-point strong { +.billing-inline-support-point strong { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-muted); } -.service-inline-support-point span { +.billing-inline-support-point span { font-size: 11px; line-height: 1.45; color: var(--ink-soft); } -.service-inline-support-actions { +.billing-inline-support-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; } -.service-detail-shell { +.billing-detail-shell { border: 1px solid var(--line); border-radius: 12px; background: #fff; box-shadow: none; overflow: hidden; } -.service-brief-card { +.billing-brief-card { padding: 16px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--panel-muted); } -.service-brief-card-primary { +.billing-brief-card-primary { background: #fff; } -.service-brief-card h3 { +.billing-brief-card h3 { margin: 8px 0 0; font-size: 18px; line-height: 1.08; letter-spacing: -0.03em; } -.service-brief-card p { +.billing-brief-card p { margin: 10px 0 0; font-size: 14px; line-height: 1.6; color: var(--ink-soft); } -.service-empty-shell { +.billing-empty-shell { display: grid; grid-template-columns: minmax(0, 1.08fr) minmax(260px, 0.92fr); gap: 12px; margin-top: 12px; align-items: start; } -.service-empty-intro { +.billing-empty-intro { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } -.service-empty-intro-item { +.billing-empty-intro-item { display: flex; flex-direction: column; gap: 4px; @@ -2411,25 +2411,25 @@ header .logout-btn:hover, #fbfdfd 0%, #f5faf9 100%); } -.service-empty-intro-item strong { +.billing-empty-intro-item strong { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent-strong); } -.service-empty-intro-item span { +.billing-empty-intro-item span { font-size: 11px; line-height: 1.45; color: var(--ink-soft); } -.service-empty-primary, -.service-empty-side { +.billing-empty-primary, +.billing-empty-side { display: flex; flex-direction: column; gap: 12px; min-width: 0; } -.service-empty-section { +.billing-empty-section { display: flex; flex-direction: column; gap: 10px; @@ -2438,17 +2438,17 @@ header .logout-btn:hover, border-radius: 12px; background: #fbfcfd; } -.service-empty-section-compact { +.billing-empty-section-compact { gap: 8px; } -.service-empty-column-title { +.billing-empty-column-title { font-size: 12px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-muted); } -.service-action-row { +.billing-action-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; @@ -2462,31 +2462,31 @@ header .logout-btn:hover, background 140ms ease, box-shadow 140ms ease; } -.service-action-row:first-child { +.billing-action-row:first-child { border-top: none; } -.service-action-row:hover, -.service-action-row.active { +.billing-action-row:hover, +.billing-action-row.active { border-color: rgba(15, 109, 115, 0.28); background: #f8fbfd; box-shadow: inset 3px 0 0 rgba(15, 109, 115, 0.62); } -.service-action-main { +.billing-action-main { display: flex; flex-direction: column; gap: 5px; min-width: 0; } -.service-action-tags { +.billing-action-tags { display: flex; align-items: center; justify-content: flex-start; gap: 6px; } -.service-action-tags-tight { +.billing-action-tags-tight { flex-wrap: wrap; } -.service-action-meta-chip { +.billing-action-meta-chip { display: inline-flex; align-items: center; min-height: 20px; @@ -2498,29 +2498,29 @@ header .logout-btn:hover, font-weight: 700; letter-spacing: 0.04em; } -.service-action-copy { +.billing-action-copy { display: flex; flex-direction: column; gap: 2px; } -.service-action-copy h3 { +.billing-action-copy h3 { margin: 0; font-size: 14px; line-height: 1.16; letter-spacing: -0.03em; } -.service-action-copy p { +.billing-action-copy p { margin: 0; font-size: 11px; line-height: 1.35; color: var(--ink-soft); max-width: 520px; } -.service-action-cta { +.billing-action-cta { display: flex; align-items: center; } -.service-action-button { +.billing-action-button { min-width: 92px; min-height: 32px; padding: 0 10px; @@ -2529,42 +2529,42 @@ header .logout-btn:hover, letter-spacing: 0.08em; text-transform: uppercase; } -.service-panel { +.billing-panel { padding: 16px; border-radius: var(--radius-md); background: #fff; } -.service-panel.visible.auth-panel { +.billing-panel.visible.auth-panel { display: block; } -.service-panel-empty { +.billing-panel-empty { display: none; } -.service-panel-empty.visible { +.billing-panel-empty.visible { display: block; } -.service-empty-points { +.billing-empty-points { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } -.service-empty-points-stack { +.billing-empty-points-stack { grid-template-columns: 1fr; } -.service-empty-support { +.billing-empty-support { margin-top: 2px; font-size: 13px; line-height: 1.55; color: var(--ink-soft); } -.service-empty-actions { +.billing-empty-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; } -.service-empty-point { +.billing-empty-point { display: flex; flex-direction: column; gap: 3px; @@ -2573,27 +2573,27 @@ header .logout-btn:hover, border-left: 2px solid rgba(15, 118, 110, 0.18); background: transparent; } -.service-empty-point:first-child { +.billing-empty-point:first-child { padding-top: 0; border-top: none; } -.service-empty-point strong { +.billing-empty-point strong { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-muted); } -.service-empty-point span { +.billing-empty-point span { font-size: 11px; line-height: 1.45; color: var(--ink-soft); } -.service-empty-checklist { +.billing-empty-checklist { display: flex; flex-direction: column; gap: 0; } -.service-empty-check { +.billing-empty-check { display: flex; flex-direction: column; gap: 3px; @@ -2602,25 +2602,25 @@ header .logout-btn:hover, border-left: 2px solid rgba(154, 52, 18, 0.14); background: transparent; } -.service-empty-check:first-child { +.billing-empty-check:first-child { padding-top: 0; border-top: none; } -.service-empty-check strong { +.billing-empty-check strong { font-size: 12px; color: var(--ink); } -.service-empty-check span { +.billing-empty-check span { font-size: 11px; line-height: 1.5; color: var(--ink-soft); } -.service-empty-flow-list { +.billing-empty-flow-list { display: flex; flex-direction: column; gap: 0; } -.service-empty-flow { +.billing-empty-flow { display: flex; flex-direction: column; gap: 3px; @@ -2629,56 +2629,56 @@ header .logout-btn:hover, border-left: 2px solid #dbe5ee; background: transparent; } -.service-empty-flow:first-child { +.billing-empty-flow:first-child { border-top: none; padding-top: 0; } -.service-empty-flow strong { +.billing-empty-flow strong { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-muted); } -.service-empty-flow span { +.billing-empty-flow span { font-size: 11px; line-height: 1.45; color: var(--ink-soft); } -.service-section-auth { +.billing-section-auth { max-width: 760px; } -.service-panel textarea { +.billing-panel textarea { min-height: 120px; resize: vertical; } -.service-panel .form-group { +.billing-panel .form-group { margin-bottom: 14px; } -.service-panel .helper-text { +.billing-panel .helper-text { font-size: 13px; } -.service-panel .subsection { +.billing-panel .subsection { margin-top: 18px; } -.service-panel .warning { +.billing-panel .warning { margin-bottom: 14px; padding: 14px; border-radius: var(--radius-sm); background: var(--warn-soft); color: var(--warn); } -.service-panel .checkbox-row { +.billing-panel .checkbox-row { display: flex; align-items: start; gap: 10px; margin: 12px 0; } -.service-panel .checkbox-row input[type=checkbox] { +.billing-panel .checkbox-row input[type=checkbox] { width: 18px; height: 18px; margin-top: 2px; } -.service-status { +.billing-status { display: none; margin-top: 14px; padding: 14px; @@ -2686,14 +2686,14 @@ header .logout-btn:hover, font-size: 14px; line-height: 1.6; } -.service-status.visible { +.billing-status.visible { display: block; } -.service-status.success { +.billing-status.success { background: var(--success-soft); color: var(--success); } -.service-status.error { +.billing-status.error { background: var(--danger-soft); color: var(--danger); } @@ -2797,16 +2797,16 @@ header .logout-btn:hover, } @media (max-width: 1180px) { .account-management-grid, - .team-management-grid, - .team-review-grid, + .access-management-grid, + .access-review-grid, .account-overview-secondary, .overview-task-grid, .billing-overview-grid, - .service-shell, - .service-action-list, - .service-empty-shell, - .service-empty-intro, - .service-empty-points, + .billing-shell, + .billing-action-list, + .billing-empty-shell, + .billing-empty-intro, + .billing-empty-points, .workspace-operations-shell { grid-template-columns: 1fr; } @@ -2834,7 +2834,7 @@ header .logout-btn:hover, .workspace-management-facts { grid-template-columns: 1fr; } - .team-side-column { + .access-side-column { position: static; } .workspace-operations-shell-idle .workspace-management-empty-shell { @@ -2899,7 +2899,7 @@ header .logout-btn:hover, grid-template-columns: 1fr; } .intro-card, - .service-panel { + .billing-panel { padding: 18px; } .portal-shell-head, @@ -2909,9 +2909,9 @@ header .logout-btn:hover, padding-right: 16px; } .workspace-management-header, - .team-management-header, - .service-header, - .service-action-tags { + .access-management-header, + .billing-header, + .billing-action-tags { flex-direction: column; align-items: flex-start; } @@ -2925,31 +2925,31 @@ header .logout-btn:hover, .workspace-actions .btn-secondary { width: 100%; } - .team-member-row, - .team-roster-head { + .access-member-row, + .access-roster-head { grid-template-columns: 1fr; } - .team-control-cell { + .access-control-cell { width: 100%; } - .team-control-cell-access { + .access-control-cell-access { justify-content: start; width: 100%; } - .service-action-row, - .service-shell, - .service-empty-shell, - .service-empty-intro, + .billing-action-row, + .billing-shell, + .billing-empty-shell, + .billing-empty-intro, .workspace-operations-shell, .overview-task-grid, .account-overview-card, .account-overview-secondary, .overview-quick-actions, - .service-empty-points { + .billing-empty-points { grid-template-columns: 1fr; } - .service-action-cta, - .service-action-button { + .billing-action-cta, + .billing-action-button { width: 100%; justify-content: start; } diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index ea7c8f3cf..c15f30dec 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -1,11 +1,11 @@ (() => { // src/account_view.ts - function normalizedTeamRole(role) { + function normalizedAccessRole(role) { if (role === "member") return "read_only"; return role || "read_only"; } function roleLabel(role) { - switch (normalizedTeamRole(role)) { + switch (normalizedAccessRole(role)) { case "owner": return "Owner"; case "admin": @@ -21,7 +21,7 @@ } } function roleCapabilityCopy(role) { - switch (normalizedTeamRole(role)) { + switch (normalizedAccessRole(role)) { case "owner": return "Full account control, including billing, access control, and workspace control."; case "admin": @@ -158,12 +158,12 @@ container.textContent = ""; container.classList.add("state-only"); var message = document.createElement("div"); - message.className = "team-list-message" + (isError ? " error" : ""); + message.className = "access-list-message" + (isError ? " error" : ""); var heading = document.createElement("strong"); - heading.className = "team-list-message-title"; + heading.className = "access-list-message-title"; heading.textContent = title; var copy = document.createElement("span"); - copy.className = "team-list-message-copy"; + copy.className = "access-list-message-copy"; copy.textContent = msg; message.appendChild(heading); message.appendChild(copy); @@ -172,45 +172,45 @@ function countMembersByRole(members, role) { var count = 0; for (var i = 0; i < members.length; i += 1) { - if (normalizedTeamRole(members[i].role) === role) count += 1; + if (normalizedAccessRole(members[i].role) === role) count += 1; } return count; } - function renderTeamStats(accountID, entry) { - var stats = getElement("team-stats-" + accountID); + function renderAccessStats(accountID, entry) { + var stats = getElement("access-stats-" + accountID); if (!stats) return; - if (!entry.teamVisible) { + if (!entry.accessVisible) { stats.innerHTML = ""; return; } - if (entry.teamQuery.status === "loading") { - stats.innerHTML = '
RosterLoading\u2026
InvitesReady
'; + if (entry.accessQuery.status === "loading") { + stats.innerHTML = '
RosterLoading\u2026
InvitesReady
'; return; } - if (entry.teamQuery.status === "error") { - stats.innerHTML = '
RosterNeeds attention
FallbackInvite only
'; + if (entry.accessQuery.status === "error") { + stats.innerHTML = '
RosterNeeds attention
FallbackInvite only
'; return; } - var members = entry.teamQuery.data; - stats.innerHTML = '
Members' + String(members.length) + '
Owners' + String(countMembersByRole(members, "owner")) + '
Admins' + String(countMembersByRole(members, "admin")) + '
Operators' + String(countMembersByRole(members, "tech") + countMembersByRole(members, "read_only")) + "
"; + var members = entry.accessQuery.data; + stats.innerHTML = '
Members' + String(members.length) + '
Owners' + String(countMembersByRole(members, "owner")) + '
Admins' + String(countMembersByRole(members, "admin")) + '
Operators' + String(countMembersByRole(members, "tech") + countMembersByRole(members, "read_only")) + "
"; } - function createTeamControlCell(className) { + function createAccessControlCell(className) { var cell = document.createElement("div"); - cell.className = "team-control-cell " + className; + cell.className = "access-control-cell " + className; return cell; } - function renderTeamRoleControl(accountID, member, isOwner) { - var currentRole = normalizedTeamRole(member.role); - var group = createTeamControlCell("team-control-cell-role"); + function renderAccessRoleControl(accountID, member, isOwner) { + var currentRole = normalizedAccessRole(member.role); + var group = createAccessControlCell("access-control-cell-role"); if (currentRole === "owner" && !isOwner) { var locked = document.createElement("span"); - locked.className = "team-role-badge"; + locked.className = "access-role-badge"; locked.textContent = roleLabel(currentRole); group.appendChild(locked); return group; } var sel = document.createElement("select"); - sel.className = "team-role-select"; + sel.className = "access-role-select"; var roles = isOwner ? ["owner", "admin", "tech", "read_only"] : ["admin", "tech", "read_only"]; for (var j = 0; j < roles.length; j += 1) { var opt = document.createElement("option"); @@ -225,11 +225,11 @@ group.appendChild(sel); return group; } - function renderTeamMemberAction(accountID, member, isOwner) { - if (normalizedTeamRole(member.role) === "owner" && !isOwner) { - var locked = createTeamControlCell("team-control-cell-access"); + function renderAccessMemberAction(accountID, member, isOwner) { + if (normalizedAccessRole(member.role) === "owner" && !isOwner) { + var locked = createAccessControlCell("access-control-cell-access"); var lockedText = document.createElement("span"); - lockedText.className = "team-control-locked"; + lockedText.className = "access-control-locked"; lockedText.textContent = "Locked"; locked.appendChild(lockedText); return locked; @@ -242,40 +242,40 @@ btn.setAttribute("data-account-id", accountID); btn.setAttribute("data-user-id", member.user_id); btn.setAttribute("data-member-email", member.email); - var group = createTeamControlCell("team-control-cell-access"); + var group = createAccessControlCell("access-control-cell-access"); group.appendChild(btn); return group; } - function renderTeamMemberRow(accountID, member, isOwner) { + function renderAccessMemberRow(accountID, member, isOwner) { var row = document.createElement("div"); - row.className = "team-member-row"; + row.className = "access-member-row"; var identity = document.createElement("div"); - identity.className = "team-member-identity"; + identity.className = "access-member-identity"; var topline = document.createElement("div"); - topline.className = "team-member-topline"; + topline.className = "access-member-topline"; var email = document.createElement("div"); - email.className = "team-member-email"; + email.className = "access-member-email"; email.textContent = member.email; topline.appendChild(email); var roleBadge = document.createElement("span"); - roleBadge.className = "team-inline-role-badge"; + roleBadge.className = "access-inline-role-badge"; roleBadge.textContent = roleLabel(member.role); topline.appendChild(roleBadge); identity.appendChild(topline); var caption = document.createElement("div"); - caption.className = "team-member-caption"; + caption.className = "access-member-caption"; caption.textContent = roleCapabilityCopy(member.role); identity.appendChild(caption); row.appendChild(identity); - row.appendChild(renderTeamRoleControl(accountID, member, isOwner)); - row.appendChild(renderTeamMemberAction(accountID, member, isOwner) || createTeamControlCell("team-control-cell-access")); + row.appendChild(renderAccessRoleControl(accountID, member, isOwner)); + row.appendChild(renderAccessMemberAction(accountID, member, isOwner) || createAccessControlCell("access-control-cell-access")); return row; } - function ensureRosterHead(container) { - var existing = container.querySelector(".team-roster-head"); + function ensureAccessRosterHead(container) { + var existing = container.querySelector(".access-roster-head"); if (existing) return; var head = document.createElement("div"); - head.className = "team-roster-head"; + head.className = "access-roster-head"; head.innerHTML = "OperatorRoleAccess"; container.appendChild(head); } @@ -288,29 +288,29 @@ spinner.hidden = !entry.createWorkspace.pending; } } - function renderTeamSection(accountID, entry) { - var section = getElement("team-section-" + accountID); - var roster = getElement("team-list-" + accountID); + function renderAccessSection(accountID, entry) { + var section = getElement("access-section-" + accountID); + var roster = getElement("access-list-" + accountID); if (!section || !roster) return; - var rosterPanel = roster.closest(".team-roster"); + var rosterPanel = roster.closest(".access-roster"); var actorRole = section.getAttribute("data-actor-role") || ""; var isOwner = actorRole === "owner"; - section.classList.toggle("visible", entry.teamVisible); - renderTeamStats(accountID, entry); - if (!entry.teamVisible) { + section.classList.toggle("visible", entry.accessVisible); + renderAccessStats(accountID, entry); + if (!entry.accessVisible) { return; } - if (entry.teamQuery.status === "loading") { + if (entry.accessQuery.status === "loading") { if (rosterPanel) rosterPanel.classList.add("state-only"); setContainerMessage(roster, "Loading roster", "Checking who currently has access to this account.", false); return; } - if (entry.teamQuery.status === "error") { + if (entry.accessQuery.status === "error") { if (rosterPanel) rosterPanel.classList.add("state-only"); - setContainerMessage(roster, "Roster needs attention", entry.teamQuery.error, true); + setContainerMessage(roster, "Roster needs attention", entry.accessQuery.error, true); return; } - if (!entry.teamQuery.data.length) { + if (!entry.accessQuery.data.length) { if (rosterPanel) rosterPanel.classList.add("state-only"); setContainerMessage(roster, "No one added yet", "Invite someone new when this account needs shared access.", false); return; @@ -318,10 +318,10 @@ roster.textContent = ""; roster.classList.remove("state-only"); if (rosterPanel) rosterPanel.classList.remove("state-only"); - ensureRosterHead(roster); - for (var i = 0; i < entry.teamQuery.data.length; i += 1) { - var member = entry.teamQuery.data[i]; - roster.appendChild(renderTeamMemberRow(accountID, member, isOwner)); + ensureAccessRosterHead(roster); + for (var i = 0; i < entry.accessQuery.data.length; i += 1) { + var member = entry.accessQuery.data[i]; + roster.appendChild(renderAccessMemberRow(accountID, member, isOwner)); } } function renderAccountUI(accountState, accounts) { @@ -338,7 +338,7 @@ } renderAddWorkspaceSection(accountID, entry); if (account) renderWorkspaceManagement(account, entry); - renderTeamSection(accountID, entry); + renderAccessSection(accountID, entry); } } @@ -361,10 +361,10 @@ event.preventDefault(); void deps.runtime.openBilling(accountID); return; - case "toggle-team": + case "show-access": event.preventDefault(); deps.setShellSection("access"); - deps.runtime.ensureTeamVisible(accountID); + deps.runtime.ensureAccessVisible(accountID); return; case "invite-member": event.preventDefault(); @@ -574,7 +574,7 @@ }, "Failed to open billing portal."); }, listMembers: function(accountID) { - return request(accountURL(accountID, "/members"), {}, "Failed to load team."); + return request(accountURL(accountID, "/members"), {}, "Failed to load access roster."); }, inviteMember: function(accountID, body) { return request(accountURL(accountID, "/members"), { @@ -657,15 +657,15 @@ createWorkspace: createMutationState(), selectedWorkspaceID: "", manageWorkspace: createMutationState(), - teamVisible: false, - teamQuery: createQueryState([]) + accessVisible: false, + accessQuery: createQueryState([]) }; } return accountState.byAccountID[accountID]; } - function createPortalServiceState() { + function createPortalBillingState() { return { - openPanelID: "", + openBillingPanelID: "", flows: { manage: newVerificationFlowState(), retrieve: newVerificationFlowState(), @@ -685,76 +685,76 @@ loginState.emailValue = email || ""; } } - function syncServiceStateBootstrapEmail(serviceState, email) { - if (!serviceState.flows.manage.emailValue) serviceState.flows.manage.emailValue = email || ""; - if (!serviceState.flows.retrieve.emailValue) serviceState.flows.retrieve.emailValue = email || ""; - if (!serviceState.flows.export.emailValue) serviceState.flows.export.emailValue = email || ""; - if (!serviceState.flows.delete.emailValue) serviceState.flows.delete.emailValue = email || ""; - if (!serviceState.refund.emailValue) serviceState.refund.emailValue = email || ""; + function syncBillingStateBootstrapEmail(billingState, email) { + if (!billingState.flows.manage.emailValue) billingState.flows.manage.emailValue = email || ""; + if (!billingState.flows.retrieve.emailValue) billingState.flows.retrieve.emailValue = email || ""; + if (!billingState.flows.export.emailValue) billingState.flows.export.emailValue = email || ""; + if (!billingState.flows.delete.emailValue) billingState.flows.delete.emailValue = email || ""; + if (!billingState.refund.emailValue) billingState.refund.emailValue = email || ""; } - function setFlowStatus(serviceState, flowID, message, isError) { - serviceState.flows[flowID].status = { + function setFlowStatus(billingState, flowID, message, isError) { + billingState.flows[flowID].status = { visible: true, message, error: !!isError }; } - function clearFlowStatus(serviceState, flowID) { - serviceState.flows[flowID].status = emptyStatus(); + function clearFlowStatus(billingState, flowID) { + billingState.flows[flowID].status = emptyStatus(); } - function setRefundStatus(serviceState, message, isError) { - serviceState.refund.status = { + function setRefundStatus(billingState, message, isError) { + billingState.refund.status = { visible: true, message, error: !!isError }; } - function toggleServicePanelState(serviceState, panelID) { - serviceState.openPanelID = serviceState.openPanelID === panelID ? "" : panelID; + function toggleBillingPanelState(billingState, panelID) { + billingState.openBillingPanelID = billingState.openBillingPanelID === panelID ? "" : panelID; } - function resetVerificationFlowState(serviceState, flowID) { - var previous = serviceState.flows[flowID]; - serviceState.flows[flowID] = newVerificationFlowState(); - serviceState.flows[flowID].emailValue = previous.emailValue; + function resetVerificationFlowState(billingState, flowID) { + var previous = billingState.flows[flowID]; + billingState.flows[flowID] = newVerificationFlowState(); + billingState.flows[flowID].emailValue = previous.emailValue; } - function updateServiceInputValue(serviceState, inputKind, value) { + function updateBillingInputValue(billingState, inputKind, value) { switch (inputKind) { case "manage-email": - serviceState.flows.manage.emailValue = value; + billingState.flows.manage.emailValue = value; return; case "manage-code": - serviceState.flows.manage.codeValue = value; + billingState.flows.manage.codeValue = value; return; case "retrieve-email": - serviceState.flows.retrieve.emailValue = value; + billingState.flows.retrieve.emailValue = value; return; case "retrieve-code": - serviceState.flows.retrieve.codeValue = value; + billingState.flows.retrieve.codeValue = value; return; case "refund-email": - serviceState.refund.emailValue = value; + billingState.refund.emailValue = value; return; case "refund-token": - serviceState.refund.tokenValue = value; + billingState.refund.tokenValue = value; return; case "data-export-email": - serviceState.flows.export.emailValue = value; + billingState.flows.export.emailValue = value; return; case "data-export-code": - serviceState.flows.export.codeValue = value; + billingState.flows.export.codeValue = value; return; case "data-delete-email": - serviceState.flows.delete.emailValue = value; + billingState.flows.delete.emailValue = value; return; case "data-delete-code": - serviceState.flows.delete.codeValue = value; + billingState.flows.delete.codeValue = value; return; default: return; } } - function updateDeleteConfirmation(serviceState, checked) { - serviceState.flows.delete.checkboxChecked = checked; + function updateDeleteConfirmation(billingState, checked) { + billingState.flows.delete.checkboxChecked = checked; } // src/account_runtime.ts @@ -772,40 +772,40 @@ var renderAccountRuntime = function() { renderAccountUI(deps.store.getAccountState(), deps.store.getBootstrap().accounts || []); }; - var loadTeam = async function(accountID) { - var section = getElement("team-section-" + accountID); + var loadAccessRoster = async function(accountID) { + var section = getElement("access-section-" + accountID); if (!section) return; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.teamVisible = true; - beginQueryState(entry.teamQuery, []); + entry.accessVisible = true; + beginQueryState(entry.accessQuery, []); }); try { var members = await deps.api.listMembers(accountID); deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - resolveQueryState(entry.teamQuery, Array.isArray(members) ? members : []); + resolveQueryState(entry.accessQuery, Array.isArray(members) ? members : []); }); } catch (error) { deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - failQueryState(entry.teamQuery, [], error instanceof Error ? error.message : "Network error."); + failQueryState(entry.accessQuery, [], error instanceof Error ? error.message : "Network error."); }); } }; - var refreshAccountTeamSection = async function(accountID) { + var refreshAccountAccessSection = async function(accountID) { if (!await refreshOrRedirect()) { return false; } - var section = getElement("team-section-" + accountID); + var section = getElement("access-section-" + accountID); if (!section) { return true; } deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.teamVisible = true; + entry.accessVisible = true; }); - await loadTeam(accountID); + await loadAccessRoster(accountID); return true; }; var toggleAddWorkspace = function(accountID) { @@ -814,7 +814,7 @@ var entry = ensurePortalAccountUIEntry(accountState, accountID); entry.addWorkspaceOpen = !entry.addWorkspaceOpen; if (entry.addWorkspaceOpen) { - entry.teamVisible = false; + entry.accessVisible = false; entry.selectedWorkspaceID = ""; } shouldFocus = entry.addWorkspaceOpen; @@ -828,7 +828,7 @@ var entry = ensurePortalAccountUIEntry(accountState, accountID); entry.selectedWorkspaceID = entry.selectedWorkspaceID === workspaceID ? "" : workspaceID; if (entry.selectedWorkspaceID) { - entry.teamVisible = false; + entry.accessVisible = false; entry.addWorkspaceOpen = false; } }); @@ -922,34 +922,34 @@ deps.showToast(error instanceof Error ? error.message : "Failed to open billing portal.", true); } }; - var toggleTeam = function(accountID) { + var toggleAccess = function(accountID) { var nextVisible = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.teamVisible = !entry.teamVisible; - if (entry.teamVisible) { + entry.accessVisible = !entry.accessVisible; + if (entry.accessVisible) { entry.selectedWorkspaceID = ""; entry.addWorkspaceOpen = false; } - nextVisible = entry.teamVisible; + nextVisible = entry.accessVisible; }); if (nextVisible) { - void loadTeam(accountID); + void loadAccessRoster(accountID); } }; - var ensureTeamVisible = function(accountID) { + var ensureAccessVisible = function(accountID) { var shouldLoad = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - if (!entry.teamVisible) { - entry.teamVisible = true; + if (!entry.accessVisible) { + entry.accessVisible = true; entry.selectedWorkspaceID = ""; entry.addWorkspaceOpen = false; } - shouldLoad = entry.teamQuery.status === "idle" || entry.teamQuery.status === "error"; + shouldLoad = entry.accessQuery.status === "idle" || entry.accessQuery.status === "error"; }); if (shouldLoad) { - void loadTeam(accountID); + void loadAccessRoster(accountID); } }; var inviteMember = async function(accountID) { @@ -964,7 +964,7 @@ try { await deps.api.inviteMember(accountID, { email, role: roleEl.value }); emailEl.value = ""; - if (!await refreshAccountTeamSection(accountID)) { + if (!await refreshAccountAccessSection(accountID)) { return; } deps.showToast("Member invited!"); @@ -979,25 +979,25 @@ var changeRole = async function(accountID, userID, newRole) { try { await deps.api.updateMemberRole(accountID, userID, { role: newRole }); - if (!await refreshAccountTeamSection(accountID)) { + if (!await refreshAccountAccessSection(accountID)) { return; } deps.showToast("Role updated."); } catch (error) { if (error instanceof PortalAPIError && error.status === 409) { deps.showToast("Cannot demote last owner.", true); - await loadTeam(accountID); + await loadAccessRoster(accountID); return; } deps.showToast(error instanceof Error ? error.message : "Failed to update role.", true); - await loadTeam(accountID); + await loadAccessRoster(accountID); } }; var removeMember = async function(accountID, userID, email) { if (!window.confirm("Remove " + email + " from this account?")) return; try { await deps.api.removeMember(accountID, userID); - if (!await refreshAccountTeamSection(accountID)) { + if (!await refreshAccountAccessSection(accountID)) { return; } deps.showToast("Member removed."); @@ -1016,8 +1016,8 @@ selectWorkspace, clearWorkspaceSelection, openBilling, - toggleTeam, - ensureTeamVisible, + toggleAccess, + ensureAccessVisible, inviteMember, createWorkspace, manageWorkspaceAction, @@ -1129,7 +1129,7 @@ }; } - // src/services_view.ts + // src/billing_view.ts function getElement3(id) { return document.getElementById(id); } @@ -1162,16 +1162,16 @@ el.value = value; } } - function renderStatus(id, status) { + function renderBillingStatus(id, status) { var el = getElement3(id); if (!el) return; if (!status.visible) { el.textContent = ""; - el.className = "service-status"; + el.className = "billing-status"; return; } el.textContent = status.message; - el.className = "service-status visible" + (status.error ? " error" : " success"); + el.className = "billing-status visible" + (status.error ? " error" : " success"); } function renderButton(id, disabled, label) { if (!id || !label) return; @@ -1180,47 +1180,47 @@ button.disabled = disabled; button.textContent = label; } - function renderOpenPanels(openPanelID) { - var panels = ["manage-service-panel", "retrieve-service-panel", "refund-service-panel", "data-service-panel"]; - var emptyPanel = getElement3("service-panel-empty"); + function renderOpenBillingPanels(openBillingPanelID) { + var panels = ["manage-billing-panel", "retrieve-billing-panel", "refund-billing-panel", "data-billing-panel"]; + var emptyPanel = getElement3("billing-panel-empty"); if (emptyPanel) { - emptyPanel.classList.toggle("visible", !openPanelID); + emptyPanel.classList.toggle("visible", !openBillingPanelID); } for (var i = 0; i < panels.length; i++) { var panel = getElement3(panels[i]); if (!panel) continue; - panel.classList.toggle("visible", panels[i] === openPanelID); + panel.classList.toggle("visible", panels[i] === openBillingPanelID); } - var serviceButtons = document.querySelectorAll('[data-account-service-action="open-service-panel"]'); - for (var j = 0; j < serviceButtons.length; j += 1) { - var button = serviceButtons[j]; - var row = button.closest(".service-action-row"); + var billingButtons = document.querySelectorAll('[data-account-billing-action="open-billing-panel"]'); + for (var j = 0; j < billingButtons.length; j += 1) { + var button = billingButtons[j]; + var row = button.closest(".billing-action-row"); if (!row) continue; - row.classList.toggle("active", button.getAttribute("data-account-service-panel") === openPanelID); + row.classList.toggle("active", button.getAttribute("data-account-billing-panel") === openBillingPanelID); } } function renderRefundPanel(refundState, bootstrap) { - var root = getElement3("refund-service-root"); + var root = getElement3("refund-billing-root"); if (!root) return; var refundSupportURL = (bootstrap.public_site_url || "") + "/refund.html?email=" + encodeURIComponent(refundState.emailValue || ""); - root.innerHTML = '

Refund requests

Process an eligible self-serve refund for a self-hosted purchase. This revokes the associated license immediately.

Warning: completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.
If this purchase is not eligible for self-serve refund, use the public support path instead: open refund support page.
'; + root.innerHTML = '

Refund requests

Process an eligible self-serve refund for a self-hosted purchase. This revokes the associated license immediately.

Warning: completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.
If this purchase is not eligible for self-serve refund, use the public support path instead: open refund support page.
'; } function renderManagePanel(flowState) { - var root = getElement3("manage-service-root"); + var root = getElement3("manage-billing-root"); if (!root) return; - root.innerHTML = '

Manage subscriptions

Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.

Need a new code? Send again
'; + root.innerHTML = '

Manage subscriptions

Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.

Need a new code? Send again
'; } function renderRetrievePanel(flowState) { - var root = getElement3("retrieve-service-root"); + var root = getElement3("retrieve-billing-root"); if (!root) return; var result = flowState.result; var invoiceURL = result && result.invoice_url ? result.invoice_url : "#"; - root.innerHTML = '

Retrieve licenses

Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.

View Invoice
Use the latest active self-hosted license for this commercial email.
Plan
' + escapeText(result ? result.tier : "") + '
Issued
' + escapeText(result ? new Date(result.issued_at).toLocaleString() : "") + '
Expires
' + escapeText(result ? result.expires_at ? new Date(result.expires_at).toLocaleString() : "Does not expire" : "") + '
Purchase Email
' + escapeText(result ? result.email : "") + "
"; + root.innerHTML = '

Retrieve licenses

Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.

View Invoice
Use the latest active self-hosted license for this commercial email.
Plan
' + escapeText(result ? result.tier : "") + '
Issued
' + escapeText(result ? new Date(result.issued_at).toLocaleString() : "") + '
Expires
' + escapeText(result ? result.expires_at ? new Date(result.expires_at).toLocaleString() : "Does not expire" : "") + '
Purchase Email
' + escapeText(result ? result.email : "") + "
"; } function renderExportPanel(flowState) { var root = getElement3("data-export-root"); if (!root) return; - root.innerHTML = '

Export My Data

Need a new code? Send again
"; + root.innerHTML = '

Export My Data

Need a new code? Send again
"; } function renderExportResult(result) { setVisible("data-export-result", !!result); @@ -1229,22 +1229,22 @@ function renderDeletePanel(flowState) { var root = getElement3("data-delete-root"); if (!root) return; - root.innerHTML = '

Delete My Data

Warning: deleting commercial data also revokes license records and cannot be undone.
I understand this permanently deletes my commercial data and revokes associated licenses.
Need a new code? Send again
'; + root.innerHTML = '

Delete My Data

Warning: deleting commercial data also revokes license records and cannot be undone.
I understand this permanently deletes my commercial data and revokes associated licenses.
Need a new code? Send again
'; } - // src/services_controller.ts - function installServicesController(deps) { + // src/billing_controller.ts + function installBillingController(deps) { document.addEventListener("click", function(event) { - var target = asHTMLElement3(event.target)?.closest("[data-account-service-action]"); + var target = asHTMLElement3(event.target)?.closest("[data-account-billing-action]"); if (!target) return; - var action = target.getAttribute("data-account-service-action") || ""; - var panelID = target.getAttribute("data-account-service-panel") || ""; - var focusID = target.getAttribute("data-account-service-focus") || ""; + var action = target.getAttribute("data-account-billing-action") || ""; + var panelID = target.getAttribute("data-account-billing-panel") || ""; + var focusID = target.getAttribute("data-account-billing-focus") || ""; switch (action) { - case "open-service-panel": + case "open-billing-panel": event.preventDefault(); deps.setShellSection("billing"); - deps.toggleServicePanel(panelID); + deps.toggleBillingPanel(panelID); deps.focusElement(focusID); return; case "manage-inline-request": @@ -1303,7 +1303,7 @@ document.addEventListener("input", function(event) { var target = asHTMLElement3(event.target); if (!target) return; - var inputKind = target.getAttribute("data-account-service-input") || ""; + var inputKind = target.getAttribute("data-account-billing-input") || ""; if (!inputKind) return; deps.updateInputValue(inputKind, target.value); }); @@ -1314,39 +1314,39 @@ }); } - // src/services.ts - function installServicesRuntime(deps) { + // src/billing.ts + function installBillingRuntime(deps) { var api = deps.api; var store = deps.store; - store.updateServiceState(function(serviceState) { - if (!serviceState.flows) { - var nextState = createPortalServiceState(); - serviceState.openPanelID = nextState.openPanelID; - serviceState.flows = nextState.flows; - serviceState.refund = nextState.refund; + store.updateBillingState(function(billingState) { + if (!billingState.flows) { + var nextState = createPortalBillingState(); + billingState.openBillingPanelID = nextState.openBillingPanelID; + billingState.flows = nextState.flows; + billingState.refund = nextState.refund; } }, { notify: false }); - function getServiceState() { - return store.getServiceState(); + function getBillingState() { + return store.getBillingState(); } - function updateServiceState(mutator, notify = true) { - return store.updateServiceState(mutator, { notify }); + function updateBillingState(mutator, notify = true) { + return store.updateBillingState(mutator, { notify }); } - function toggleServicePanel(panelID) { - updateServiceState(function(serviceState) { - toggleServicePanelState(serviceState, panelID); + function toggleBillingPanel(panelID) { + updateBillingState(function(billingState) { + toggleBillingPanelState(billingState, panelID); }); } function renderFlow(flowID) { var flow = verificationFlows[flowID]; if (!flow) return; - var flowState = getServiceState().flows[flowID]; + var flowState = getBillingState().flows[flowID]; if (flow.renderPanel) { flow.renderPanel(flowState); } renderButton(flow.requestButtonID, flowState.request.pending, flowState.request.pending ? flow.requestPendingLabel : flow.requestLabel); renderButton(flow.confirmButtonID, flowState.confirm.pending, flowState.confirm.pending ? flow.confirmPendingLabel : flow.confirmLabel); - renderStatus(flow.statusID, flowState.status); + renderBillingStatus(flow.statusID, flowState.status); if (flow.step2ID) { setVisible(flow.step2ID, flowState.step2Visible); } @@ -1362,16 +1362,16 @@ renderRefund(); } function renderRefund() { - var refundState = getServiceState().refund; + var refundState = getBillingState().refund; renderRefundPanel(refundState, store.getBootstrap()); renderButton("refund-inline-submit", refundState.submit.pending, refundState.submit.pending ? "Processing..." : "Process Refund"); - renderStatus("refund-inline-status", refundState.status); + renderBillingStatus("refund-inline-status", refundState.status); } function resetVerificationFlow(flowID) { var flow = verificationFlows[flowID]; if (!flow) return; - updateServiceState(function(serviceState) { - resetVerificationFlowState(serviceState, flowID); + updateBillingState(function(billingState) { + resetVerificationFlowState(billingState, flowID); }, false); if (flow.codeInputID) { setValue(flow.codeInputID, ""); @@ -1381,7 +1381,7 @@ manage: { requestPath: "/v1/manage/request", confirmPath: "/v1/manage", - panelID: "manage-service-panel", + panelID: "manage-billing-panel", emailInputID: "manage-inline-email", codeInputID: "manage-inline-code", requestButtonID: "manage-inline-request", @@ -1397,10 +1397,10 @@ requestErrorMessage: "Failed to send verification code", confirmErrorMessage: "Failed to open customer portal", readEmailValue: function() { - return getServiceState().flows.manage.emailValue; + return getBillingState().flows.manage.emailValue; }, readCodeValue: function() { - return getServiceState().flows.manage.codeValue; + return getBillingState().flows.manage.codeValue; }, onRequestStart: function() { }, @@ -1412,7 +1412,7 @@ retrieve: { requestPath: "/v1/retrieve-license/request", confirmPath: "/v1/retrieve-license", - panelID: "retrieve-service-panel", + panelID: "retrieve-billing-panel", emailInputID: "retrieve-inline-email", codeInputID: "retrieve-inline-code", requestButtonID: "retrieve-inline-request", @@ -1428,27 +1428,27 @@ requestErrorMessage: "Failed to send verification code", confirmErrorMessage: "Failed to retrieve license", readEmailValue: function() { - return getServiceState().flows.retrieve.emailValue; + return getBillingState().flows.retrieve.emailValue; }, readCodeValue: function() { - return getServiceState().flows.retrieve.codeValue; + return getBillingState().flows.retrieve.codeValue; }, onRequestStart: function() { - updateServiceState(function(serviceState) { - serviceState.flows.retrieve.result = null; + updateBillingState(function(billingState) { + billingState.flows.retrieve.result = null; }, false); }, - applyConfirmSuccessState: function(serviceState, data) { - serviceState.flows.retrieve.result = data.license; - serviceState.flows.retrieve.codeValue = ""; - setFlowStatus(serviceState, "retrieve", "License retrieved successfully.", false); + applyConfirmSuccessState: function(billingState, data) { + billingState.flows.retrieve.result = data.license; + billingState.flows.retrieve.codeValue = ""; + setFlowStatus(billingState, "retrieve", "License retrieved successfully.", false); }, renderPanel: renderRetrievePanel }, export: { requestPath: "/v1/gdpr/request-export", confirmPath: "/v1/gdpr/export", - panelID: "data-service-panel", + panelID: "data-billing-panel", emailInputID: "data-export-email", codeInputID: "data-export-code", requestButtonID: "data-export-request", @@ -1464,22 +1464,22 @@ requestErrorMessage: "Request failed", confirmErrorMessage: "Export failed", readEmailValue: function() { - return getServiceState().flows.export.emailValue; + return getBillingState().flows.export.emailValue; }, readCodeValue: function() { - return getServiceState().flows.export.codeValue; + return getBillingState().flows.export.codeValue; }, onRequestStart: function() { - updateServiceState(function(serviceState) { - serviceState.flows.export.result = null; + updateBillingState(function(billingState) { + billingState.flows.export.result = null; }, false); }, - applyConfirmSuccessState: function(serviceState, data) { - var emailValue = serviceState.flows.export.emailValue; - resetVerificationFlowState(serviceState, "export"); - serviceState.flows.export.emailValue = emailValue; - serviceState.flows.export.result = data; - setFlowStatus(serviceState, "export", "Data export retrieved successfully.", false); + applyConfirmSuccessState: function(billingState, data) { + var emailValue = billingState.flows.export.emailValue; + resetVerificationFlowState(billingState, "export"); + billingState.flows.export.emailValue = emailValue; + billingState.flows.export.result = data; + setFlowStatus(billingState, "export", "Data export retrieved successfully.", false); }, renderPanel: renderExportPanel, renderResult: renderExportResult @@ -1487,7 +1487,7 @@ delete: { requestPath: "/v1/gdpr/request-delete", confirmPath: "/v1/gdpr/confirm-delete", - panelID: "data-service-panel", + panelID: "data-billing-panel", emailInputID: "data-delete-email", codeInputID: "data-delete-code", requestButtonID: "data-delete-request", @@ -1503,26 +1503,26 @@ requestErrorMessage: "Request failed", confirmErrorMessage: "Deletion failed", readEmailValue: function() { - return getServiceState().flows.delete.emailValue; + return getBillingState().flows.delete.emailValue; }, readCodeValue: function() { - return getServiceState().flows.delete.codeValue; + return getBillingState().flows.delete.codeValue; }, beforeConfirm: function() { if (!getElement3("data-delete-confirm-check")?.checked) { - updateServiceState(function(serviceState) { - setFlowStatus(serviceState, "delete", "You must confirm that you understand this action is permanent.", true); + updateBillingState(function(billingState) { + setFlowStatus(billingState, "delete", "You must confirm that you understand this action is permanent.", true); }); return false; } return true; }, - applyConfirmSuccessState: function(serviceState, data) { - var emailValue = serviceState.flows.delete.emailValue; - resetVerificationFlowState(serviceState, "delete"); - serviceState.flows.delete.emailValue = emailValue; + applyConfirmSuccessState: function(billingState, data) { + var emailValue = billingState.flows.delete.emailValue; + resetVerificationFlowState(billingState, "delete"); + billingState.flows.delete.emailValue = emailValue; setFlowStatus( - serviceState, + billingState, "delete", data.deleted_count > 0 && data.stripe_reminder ? data.message + " " + data.stripe_reminder : data.message, false @@ -1548,23 +1548,23 @@ if (flow.onRequestStart) { flow.onRequestStart(); } - updateServiceState(function(serviceState) { - beginMutationState(serviceState.flows[flowID].request); - clearFlowStatus(serviceState, flowID); + updateBillingState(function(billingState) { + beginMutationState(billingState.flows[flowID].request); + clearFlowStatus(billingState, flowID); }); try { await api.postCommercialJSON(flow.requestPath, { email }); - updateServiceState(function(serviceState) { - serviceState.flows[flowID].pendingEmail = email; - serviceState.flows[flowID].step2Visible = !!flow.step2ID; - succeedMutationState(serviceState.flows[flowID].request); - setFlowStatus(serviceState, flowID, flow.requestSuccessMessage, false); + updateBillingState(function(billingState) { + billingState.flows[flowID].pendingEmail = email; + billingState.flows[flowID].step2Visible = !!flow.step2ID; + succeedMutationState(billingState.flows[flowID].request); + setFlowStatus(billingState, flowID, flow.requestSuccessMessage, false); }); } catch (err) { var message = err instanceof Error ? err.message : flow.requestErrorMessage; - updateServiceState(function(serviceState) { - failMutationState(serviceState.flows[flowID].request, message); - setFlowStatus(serviceState, flowID, message, true); + updateBillingState(function(billingState) { + failMutationState(billingState.flows[flowID].request, message); + setFlowStatus(billingState, flowID, message, true); }); } } @@ -1572,37 +1572,37 @@ if (event) event.preventDefault(); var flow = verificationFlows[flowID]; if (!flow) return; - var email = getServiceState().flows[flowID].pendingEmail; + var email = getBillingState().flows[flowID].pendingEmail; if (!email) return; try { await api.postCommercialJSON(flow.requestPath, { email }); - updateServiceState(function(serviceState) { - setFlowStatus(serviceState, flowID, flow.resendSuccessMessage, false); + updateBillingState(function(billingState) { + setFlowStatus(billingState, flowID, flow.resendSuccessMessage, false); }); } catch (err) { - updateServiceState(function(serviceState) { - setFlowStatus(serviceState, flowID, err instanceof Error ? err.message : flow.requestErrorMessage, true); + updateBillingState(function(billingState) { + setFlowStatus(billingState, flowID, err instanceof Error ? err.message : flow.requestErrorMessage, true); }); } } async function confirmVerificationCode(flowID) { var flow = verificationFlows[flowID]; if (!flow) return; - var email = getServiceState().flows[flowID].pendingEmail; + var email = getBillingState().flows[flowID].pendingEmail; var code = flow.readCodeValue ? flow.readCodeValue() : readValue(flow.codeInputID); if (!email || !code) return; if (flow.beforeConfirm && flow.beforeConfirm() === false) { return; } - updateServiceState(function(serviceState) { - beginMutationState(serviceState.flows[flowID].confirm); + updateBillingState(function(billingState) { + beginMutationState(billingState.flows[flowID].confirm); }); try { var data = await api.postCommercialJSON(flow.confirmPath, { email, code }); - updateServiceState(function(serviceState) { - succeedMutationState(serviceState.flows[flowID].confirm); + updateBillingState(function(billingState) { + succeedMutationState(billingState.flows[flowID].confirm); if (flow.applyConfirmSuccessState) { - flow.applyConfirmSuccessState(serviceState, data, email); + flow.applyConfirmSuccessState(billingState, data, email); } }); if (flow.afterConfirmSuccess) { @@ -1610,63 +1610,63 @@ } } catch (err) { var message = err instanceof Error ? err.message : flow.confirmErrorMessage; - updateServiceState(function(serviceState) { - failMutationState(serviceState.flows[flowID].confirm, message); - setFlowStatus(serviceState, flowID, message, true); + updateBillingState(function(billingState) { + failMutationState(billingState.flows[flowID].confirm, message); + setFlowStatus(billingState, flowID, message, true); }); } } async function copyRetrievedLicense() { - var result = getServiceState().flows.retrieve.result; + var result = getBillingState().flows.retrieve.result; var token = result && result.token ? result.token : ""; if (!token) return; try { await navigator.clipboard.writeText(token); - updateServiceState(function(serviceState) { - setFlowStatus(serviceState, "retrieve", "License key copied to clipboard.", false); + updateBillingState(function(billingState) { + setFlowStatus(billingState, "retrieve", "License key copied to clipboard.", false); }); } catch (_) { - updateServiceState(function(serviceState) { - setFlowStatus(serviceState, "retrieve", "Failed to copy automatically. Please copy the key manually.", true); + updateBillingState(function(billingState) { + setFlowStatus(billingState, "retrieve", "Failed to copy automatically. Please copy the key manually.", true); }); } } async function submitRefund() { - var email = getServiceState().refund.emailValue; - var token = getServiceState().refund.tokenValue; + var email = getBillingState().refund.emailValue; + var token = getBillingState().refund.tokenValue; if (!email || !token) return; if (!confirm("Are you sure? This will immediately revoke the license and request the refund.")) return; - updateServiceState(function(serviceState) { - beginMutationState(serviceState.refund.submit); - serviceState.refund.status = emptyStatus(); + updateBillingState(function(billingState) { + beginMutationState(billingState.refund.submit); + billingState.refund.status = emptyStatus(); }); try { await api.postCommercialJSON("/v1/self-refund", { email, token }); - updateServiceState(function(serviceState) { - serviceState.refund.tokenValue = ""; - succeedMutationState(serviceState.refund.submit); - setRefundStatus(serviceState, "Success! Your refund has been processed. Stripe will follow up by email.", false); + updateBillingState(function(billingState) { + billingState.refund.tokenValue = ""; + succeedMutationState(billingState.refund.submit); + setRefundStatus(billingState, "Success! Your refund has been processed. Stripe will follow up by email.", false); }); } catch (err) { var message = err instanceof Error ? err.message : "Refund failed"; - updateServiceState(function(serviceState) { - failMutationState(serviceState.refund.submit, message); - setRefundStatus(serviceState, message, true); + updateBillingState(function(billingState) { + failMutationState(billingState.refund.submit, message); + setRefundStatus(billingState, message, true); }); } } - function renderServiceRuntime() { - renderOpenPanels(getServiceState().openPanelID); + function renderBillingRuntime() { + renderOpenBillingPanels(getBillingState().openBillingPanelID); renderAllFlows(); } - renderServiceRuntime(); - store.subscribeBootstrap(renderServiceRuntime); - store.subscribeServices(renderServiceRuntime); - installServicesController({ + renderBillingRuntime(); + store.subscribeBootstrap(renderBillingRuntime); + store.subscribeBilling(renderBillingRuntime); + installBillingController({ setShellSection: function(section) { store.setActiveShellSection(section); }, - toggleServicePanel, + toggleBillingPanel, focusElement: focusElement2, requestVerificationCode: function(flowID) { void requestVerificationCode(flowID); @@ -1684,13 +1684,13 @@ void submitRefund(); }, updateInputValue: function(inputKind, value) { - updateServiceState(function(serviceState) { - updateServiceInputValue(serviceState, inputKind, value); + updateBillingState(function(billingState) { + updateBillingInputValue(billingState, inputKind, value); }, false); }, updateDeleteConfirmation: function(checked) { - updateServiceState(function(serviceState) { - updateDeleteConfirmation(serviceState, checked); + updateBillingState(function(billingState) { + updateDeleteConfirmation(billingState, checked); }, false); } }); @@ -1776,9 +1776,9 @@ } return 'Checking'; } - function renderServiceActionRow(id, kicker, title, actionLabel, description, panelID, focusID, highlights) { + function renderBillingActionRow(id, kicker, title, actionLabel, description, panelID, focusID, highlights) { var meta = highlights.join(" \u2022 "); - return '
' + kicker + '' + escapeHTML(meta) + '

' + title + "

" + description + '

"; + return '
' + kicker + '' + escapeHTML(meta) + '

' + title + "

" + description + '

"; } function renderSectionContextChips(chips) { if (!chips.length) return ""; @@ -1973,14 +1973,14 @@ ]) + '
' + workspaceHeaderActions + '
' + workspaceHTML + '
' + workspaceManagement + "
"; } function renderAccountAccessSection(account) { - var accessPolicy = '

Role rules

Invite the smallest role that still lets someone do the work they actually own on this account.

OwnerBilling, access control, and full account control.
AdminWorkspace control plus billing for the account.
TechWorkspace control without billing ownership.
Read-onlyWorkspace review and verification without control-plane changes.
'; - var reviewDesk = '

Keep access explicit

Use the roster as a controlled access list, not a vague shared bucket.

Owners stay rareReserve Owner for billing, access control, and full account control. Default to Admin, Tech, or Read-only first.
Keep access narrowUse Tech for workspace control and Read-only for verification instead of handing out broader access.
Remove stale access fastIf someone no longer owns the work, remove them instead of leaving dormant access attached to the account.
'; - return '

People and roles

Invite people, change roles, and remove stale access without mixing that work into workspace operations.

' + renderSectionContextChips([ + var accessPolicy = '

Role rules

Invite the smallest role that still lets someone do the work they actually own on this account.

OwnerBilling, access control, and full account control.
AdminWorkspace control plus billing for the account.
TechWorkspace control without billing ownership.
Read-onlyWorkspace review and verification without control-plane changes.
'; + var reviewDesk = '

Keep access explicit

Use the roster as a controlled access list, not a vague shared bucket.

Owners stay rareReserve Owner for billing, access control, and full account control. Default to Admin, Tech, or Read-only first.
Keep access narrowUse Tech for workspace control and Read-only for verification instead of handing out broader access.
Remove stale access fastIf someone no longer owns the work, remove them instead of leaving dormant access attached to the account.
'; + return '"; + ]) + '

People on this account

Every person here can reach this hosted account. Keep the list small and the role choice explicit.

Loading\u2026
' + reviewDesk + '

Invite, role, remove

Use this column when you need to add someone, tighten a role, or remove access that no longer belongs here.

Invite someone new

Add a person with the minimum role they need for this account.

' + accessPolicy + "
"; } function renderHostedBillingCards(accounts) { var hostedBillingAccounts = accounts.filter(function(account) { @@ -2014,20 +2014,20 @@ var hostedContent = accounts.map(function(account) { return '
"; }).join(""); - return '
' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '
' + (accounts.length === 1 ? renderAccountContextStrip(accounts[0]) : "") + '
' + hostedContent + '

Billing

' + renderSectionContextChips([ + return '
' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '
' + (accounts.length === 1 ? renderAccountContextStrip(accounts[0]) : "") + '
' + hostedContent + '

Billing

' + renderSectionContextChips([ hostedBillingCount > 0 ? "Hosted billing" : "No hosted billing", "Self-hosted tools" - ]) + '
' + billingNote + '
' + renderHostedBillingCards(accounts) + '

Choose the billing task

Pick one self-hosted billing request at a time. Verification happens first so billing, license, refund, or privacy work stays contained.

What each tool does
BillingStripe customer portal access for self-hosted invoices, payment methods, and plan changes.
LicensesRecover the latest active self-hosted license and the matching invoice link.
RefundsCheck eligibility before revoking active commercial access.
PrivacyRequest export or deletion without leaving Pulse Account.
Before you start
One requestKeep a single billing request active instead of bouncing across sections.
Identity firstVerification happens before any sensitive account action opens.
Stay focusedUse the hosted billing cards above when the request is tied to a hosted workspace account.
Escalation
Escalate quicklyIf hosted billing or the self-hosted tools do not behave as expected, escalate from this section.
Billing packetBring the billing tool, commercial email, and the exact failed action so support starts with the same request state.
Need help with billing, refund, privacy, or license requests? ' + escapeHTML(context.bootstrap.support_email || "") + '

Data and privacy

Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.

Payment-card data stays with Stripe. For Stripe deletion support, contact ' + escapeHTML(context.bootstrap.support_email || "") + '.
' + renderSupportSection(context) + "
"; + ]) + '
' + billingNote + '
' + renderHostedBillingCards(accounts) + '

Choose the billing task

Pick one self-hosted billing request at a time. Verification happens first so billing, license, refund, or privacy work stays contained.

What each tool does
BillingStripe customer portal access for self-hosted invoices, payment methods, and plan changes.
LicensesRecover the latest active self-hosted license and the matching invoice link.
RefundsCheck eligibility before revoking active commercial access.
PrivacyRequest export or deletion without leaving Pulse Account.
Before you start
One requestKeep a single billing request active instead of bouncing across sections.
Identity firstVerification happens before any sensitive account action opens.
Stay focusedUse the hosted billing cards above when the request is tied to a hosted workspace account.
Escalation
Escalate quicklyIf hosted billing or the self-hosted tools do not behave as expected, escalate from this section.
Billing packetBring the billing tool, commercial email, and the exact failed action so support starts with the same request state.
Need help with billing, refund, privacy, or license requests? ' + escapeHTML(context.bootstrap.support_email || "") + '

Data and privacy

Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.

Payment-card data stays with Stripe. For Stripe deletion support, contact ' + escapeHTML(context.bootstrap.support_email || "") + '.
' + renderSupportSection(context) + "
"; } function renderSignedOutPortalHTML(context) { var statusHTML = ""; if (context.loginState.request.error) { - statusHTML = '
' + escapeHTML(context.loginState.request.error) + "
"; + statusHTML = '
' + escapeHTML(context.loginState.request.error) + "
"; } else if (context.loginState.success) { var successMessage = context.loginState.successMessage || "If that email is registered, a magic link is on the way."; - statusHTML = '
' + escapeHTML(successMessage) + `

Don't see it? Send a new link.
`; + statusHTML = '
' + escapeHTML(successMessage) + `

Don't see it? Send a new link.
`; } - return '

Sign in to Pulse Account

Use one commercial email address to get into workspaces, MSP access, billing, license recovery, refunds, and privacy actions.

Sign in

Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.

Create an account
' + statusHTML + "
"; + return '

Sign in to Pulse Account

Use one commercial email address to get into workspaces, MSP access, billing, license recovery, refunds, and privacy actions.

Sign in

Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.

Create an account
' + statusHTML + "
"; } // src/shell.ts @@ -2110,14 +2110,14 @@ var accountState = createPortalAccountState(); var loginState = createPortalLoginState(); var shellState = createPortalShellState(); - var serviceState = createPortalServiceState(); + var billingState = createPortalBillingState(); syncLoginStateBootstrapEmail(loginState, bootstrapState.email || ""); - syncServiceStateBootstrapEmail(serviceState, bootstrapState.email || ""); + syncBillingStateBootstrapEmail(billingState, bootstrapState.email || ""); var accountSubscribers = /* @__PURE__ */ new Set(); var bootstrapSubscribers = /* @__PURE__ */ new Set(); var loginSubscribers = /* @__PURE__ */ new Set(); var shellSubscribers = /* @__PURE__ */ new Set(); - var serviceSubscribers = /* @__PURE__ */ new Set(); + var billingSubscribers = /* @__PURE__ */ new Set(); function notify(subscribers) { subscribers.forEach(function(listener) { listener(); @@ -2136,13 +2136,13 @@ getShellState: function() { return shellState; }, - getServiceState: function() { - return serviceState; + getBillingState: function() { + return billingState; }, setBootstrap: function(nextBootstrap) { bootstrapState = normalizeBootstrap(bootstrapDefaults, nextBootstrap); syncLoginStateBootstrapEmail(loginState, bootstrapState.email || ""); - syncServiceStateBootstrapEmail(serviceState, bootstrapState.email || ""); + syncBillingStateBootstrapEmail(billingState, bootstrapState.email || ""); notify(bootstrapSubscribers); return bootstrapState; }, @@ -2165,12 +2165,12 @@ notify(shellSubscribers); return shellState; }, - updateServiceState: function(mutator, options) { - mutator(serviceState); + updateBillingState: function(mutator, options) { + mutator(billingState); if (!options || options.notify !== false) { - notify(serviceSubscribers); + notify(billingSubscribers); } - return serviceState; + return billingState; }, subscribeBootstrap: function(listener) { bootstrapSubscribers.add(listener); @@ -2196,10 +2196,10 @@ shellSubscribers.delete(listener); }; }, - subscribeServices: function(listener) { - serviceSubscribers.add(listener); + subscribeBilling: function(listener) { + billingSubscribers.add(listener); return function() { - serviceSubscribers.delete(listener); + billingSubscribers.delete(listener); }; } }; @@ -2223,16 +2223,16 @@ function normalizeHandoffEmail(value) { return String(value || "").trim(); } - function normalizeHandoffService(value) { + function normalizeHandoffBillingPanel(value) { switch (String(value || "").trim()) { case "manage": - return "manage-service-panel"; + return "manage-billing-panel"; case "retrieve": - return "retrieve-service-panel"; + return "retrieve-billing-panel"; case "refund": - return "refund-service-panel"; + return "refund-billing-panel"; case "data": - return "data-service-panel"; + return "data-billing-panel"; default: return ""; } @@ -2242,12 +2242,12 @@ var params = new URL(locationHref).searchParams; return { email: normalizeHandoffEmail(params.get("email")), - openPanelID: normalizeHandoffService(params.get("service")) + openBillingPanelID: normalizeHandoffBillingPanel(params.get("service")) }; } catch { return { email: "", - openPanelID: "" + openBillingPanelID: "" }; } } @@ -2272,17 +2272,17 @@ store.updateLoginState(function(loginState) { loginState.emailValue = handoff.email; }, { notify: false }); - store.updateServiceState(function(serviceState) { - serviceState.flows.manage.emailValue = handoff.email; - serviceState.flows.retrieve.emailValue = handoff.email; - serviceState.flows.export.emailValue = handoff.email; - serviceState.flows.delete.emailValue = handoff.email; - serviceState.refund.emailValue = handoff.email; + store.updateBillingState(function(billingState) { + billingState.flows.manage.emailValue = handoff.email; + billingState.flows.retrieve.emailValue = handoff.email; + billingState.flows.export.emailValue = handoff.email; + billingState.flows.delete.emailValue = handoff.email; + billingState.refund.emailValue = handoff.email; }, { notify: false }); } - if (handoff.openPanelID) { - store.updateServiceState(function(serviceState) { - serviceState.openPanelID = handoff.openPanelID; + if (handoff.openBillingPanelID) { + store.updateBillingState(function(billingState) { + billingState.openBillingPanelID = handoff.openBillingPanelID; }, { notify: false }); } return { @@ -2340,12 +2340,12 @@ if (section === "access") { var accounts = deps.store.getBootstrap().accounts || []; for (var i = 0; i < accounts.length; i += 1) { - accountRuntime.ensureTeamVisible(accounts[i].id); + accountRuntime.ensureAccessVisible(accounts[i].id); } } } }); - installServicesRuntime({ + installBillingRuntime({ api, store: deps.store }); diff --git a/internal/cloudcp/portal/frontend/src/account_controller.test.ts b/internal/cloudcp/portal/frontend/src/account_controller.test.ts index 210ff0ecf..9de2d90c0 100644 --- a/internal/cloudcp/portal/frontend/src/account_controller.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_controller.test.ts @@ -14,8 +14,8 @@ describe('account controller', function() { selectWorkspace: vi.fn(), clearWorkspaceSelection: vi.fn(), openBilling: vi.fn(), - toggleTeam: vi.fn(), - ensureTeamVisible: vi.fn(), + toggleAccess: vi.fn(), + ensureAccessVisible: vi.fn(), inviteMember: vi.fn(), createWorkspace: vi.fn(), manageWorkspaceAction: vi.fn(), @@ -30,7 +30,7 @@ describe('account controller', function() { document.body.innerHTML = '' + '' + - '' + + '' + '' + '' + '' + @@ -48,7 +48,7 @@ describe('account controller', function() { document.getElementById('team')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(setShellSection).toHaveBeenCalledWith('access'); - expect(runtime.ensureTeamVisible).toHaveBeenCalledWith('acct_1'); + expect(runtime.ensureAccessVisible).toHaveBeenCalledWith('acct_1'); document.getElementById('invite')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(runtime.inviteMember).toHaveBeenCalledWith('acct_1'); diff --git a/internal/cloudcp/portal/frontend/src/account_controller.ts b/internal/cloudcp/portal/frontend/src/account_controller.ts index 4e75bed03..a3a13bfbd 100644 --- a/internal/cloudcp/portal/frontend/src/account_controller.ts +++ b/internal/cloudcp/portal/frontend/src/account_controller.ts @@ -25,10 +25,10 @@ export function installAccountController(deps: AccountControllerDeps): void { event.preventDefault(); void deps.runtime.openBilling(accountID); return; - case 'toggle-team': + case 'show-access': event.preventDefault(); deps.setShellSection('access'); - deps.runtime.ensureTeamVisible(accountID); + deps.runtime.ensureAccessVisible(accountID); return; case 'invite-member': event.preventDefault(); diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts index e90ec5889..01d3e1492 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts @@ -157,16 +157,16 @@ describe('account runtime', function() { createWorkspace: { pending: false, error: '' }, selectedWorkspaceID: '', manageWorkspace: { pending: false, error: '' }, - teamVisible: true, - teamQuery: { status: 'idle', error: '', data: [] }, + accessVisible: true, + accessQuery: { status: 'idle', error: '', data: [] }, }); entry.addWorkspaceOpen = true; - entry.teamVisible = true; + entry.accessVisible = true; }, { notify: false }); runtime.selectWorkspace('acct_1', 'ws_2'); expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe('ws_2'); - expect(deps.store.getAccountState().byAccountID.acct_1.teamVisible).toBe(false); + expect(deps.store.getAccountState().byAccountID.acct_1.accessVisible).toBe(false); expect(deps.store.getAccountState().byAccountID.acct_1.addWorkspaceOpen).toBe(false); expect(document.getElementById('workspace-operations-shell-acct_1')?.classList.contains('workspace-operations-shell-selected')).toBe(true); expect(document.getElementById('workspace-management-title-acct_1')?.textContent).toContain('Alpha Workspace'); @@ -199,9 +199,9 @@ describe('account runtime', function() { document.body.innerHTML = '
' + - '
' + - '
' + - '
' + + '
' + + '
' + + '
' + '' + '' + '
'; @@ -212,22 +212,22 @@ describe('account runtime', function() { createWorkspace: { pending: false, error: '' }, selectedWorkspaceID: 'ws_1', manageWorkspace: { pending: false, error: '' }, - teamVisible: false, - teamQuery: { status: 'idle', error: '', data: [] }, + accessVisible: false, + accessQuery: { status: 'idle', error: '', data: [] }, }); entry.addWorkspaceOpen = true; entry.selectedWorkspaceID = 'ws_1'; }, { notify: false }); - runtime.toggleTeam('acct_1'); + runtime.toggleAccess('acct_1'); await flushAsync(); - expect(deps.store.getAccountState().byAccountID.acct_1.teamVisible).toBe(true); + expect(deps.store.getAccountState().byAccountID.acct_1.accessVisible).toBe(true); expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe(''); expect(deps.store.getAccountState().byAccountID.acct_1.addWorkspaceOpen).toBe(false); - expect(deps.store.getAccountState().byAccountID.acct_1.teamQuery.status).toBe('ready'); - expect(deps.store.getAccountState().byAccountID.acct_1.teamQuery.data).toHaveLength(1); - expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Members'); + expect(deps.store.getAccountState().byAccountID.acct_1.accessQuery.status).toBe('ready'); + expect(deps.store.getAccountState().byAccountID.acct_1.accessQuery.data).toHaveLength(1); + expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Members'); var roleSelect = document.querySelector('[data-action="change-role"]') as HTMLSelectElement | null; expect(roleSelect).not.toBeNull(); expect(roleSelect?.value).toBe('owner'); diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.ts b/internal/cloudcp/portal/frontend/src/account_runtime.ts index 01a3a03b2..e048f04ba 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.ts @@ -4,7 +4,7 @@ import type { PortalAPI } from './api'; import { focusElement, getElement, renderAccountUI as renderAccountUIState } from './account_view'; import { ensurePortalAccountUIEntry } from './state'; import type { PortalStore } from './store'; -import type { PortalTeamMember } from './types'; +import type { PortalAccessMember } from './types'; export interface AccountRuntimeDeps { api: PortalAPI; @@ -18,8 +18,8 @@ export interface AccountRuntime { selectWorkspace(accountID: string, workspaceID: string): void; clearWorkspaceSelection(accountID: string): void; openBilling(accountID: string): Promise; - toggleTeam(accountID: string): void; - ensureTeamVisible(accountID: string): void; + toggleAccess(accountID: string): void; + ensureAccessVisible(accountID: string): void; inviteMember(accountID: string): Promise; createWorkspace(accountID: string): Promise; manageWorkspaceAction(accountID: string, tenantID: string, action: string, name: string): Promise; @@ -44,41 +44,41 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime renderAccountUIState(deps.store.getAccountState(), deps.store.getBootstrap().accounts || []); }; - var loadTeam = async function(accountID: string): Promise { - var section = getElement('team-section-' + accountID); + var loadAccessRoster = async function(accountID: string): Promise { + var section = getElement('access-section-' + accountID); if (!section) return; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.teamVisible = true; - beginQueryState(entry.teamQuery, []); + entry.accessVisible = true; + beginQueryState(entry.accessQuery, []); }); try { - var members = await deps.api.listMembers(accountID) as PortalTeamMember[]; + var members = await deps.api.listMembers(accountID) as PortalAccessMember[]; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - resolveQueryState(entry.teamQuery, Array.isArray(members) ? members : []); + resolveQueryState(entry.accessQuery, Array.isArray(members) ? members : []); }); } catch (error) { deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - failQueryState(entry.teamQuery, [], error instanceof Error ? error.message : 'Network error.'); + failQueryState(entry.accessQuery, [], error instanceof Error ? error.message : 'Network error.'); }); } }; - var refreshAccountTeamSection = async function(accountID: string): Promise { + var refreshAccountAccessSection = async function(accountID: string): Promise { if (!await refreshOrRedirect()) { return false; } - var section = getElement('team-section-' + accountID); + var section = getElement('access-section-' + accountID); if (!section) { return true; } deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.teamVisible = true; + entry.accessVisible = true; }); - await loadTeam(accountID); + await loadAccessRoster(accountID); return true; }; @@ -88,7 +88,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime var entry = ensurePortalAccountUIEntry(accountState, accountID); entry.addWorkspaceOpen = !entry.addWorkspaceOpen; if (entry.addWorkspaceOpen) { - entry.teamVisible = false; + entry.accessVisible = false; entry.selectedWorkspaceID = ''; } shouldFocus = entry.addWorkspaceOpen; @@ -103,7 +103,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime var entry = ensurePortalAccountUIEntry(accountState, accountID); entry.selectedWorkspaceID = entry.selectedWorkspaceID === workspaceID ? '' : workspaceID; if (entry.selectedWorkspaceID) { - entry.teamVisible = false; + entry.accessVisible = false; entry.addWorkspaceOpen = false; } }); @@ -202,35 +202,35 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime } }; - var toggleTeam = function(accountID: string): void { + var toggleAccess = function(accountID: string): void { var nextVisible = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - entry.teamVisible = !entry.teamVisible; - if (entry.teamVisible) { + entry.accessVisible = !entry.accessVisible; + if (entry.accessVisible) { entry.selectedWorkspaceID = ''; entry.addWorkspaceOpen = false; } - nextVisible = entry.teamVisible; + nextVisible = entry.accessVisible; }); if (nextVisible) { - void loadTeam(accountID); + void loadAccessRoster(accountID); } }; - var ensureTeamVisible = function(accountID: string): void { + var ensureAccessVisible = function(accountID: string): void { var shouldLoad = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); - if (!entry.teamVisible) { - entry.teamVisible = true; + if (!entry.accessVisible) { + entry.accessVisible = true; entry.selectedWorkspaceID = ''; entry.addWorkspaceOpen = false; } - shouldLoad = entry.teamQuery.status === 'idle' || entry.teamQuery.status === 'error'; + shouldLoad = entry.accessQuery.status === 'idle' || entry.accessQuery.status === 'error'; }); if (shouldLoad) { - void loadTeam(accountID); + void loadAccessRoster(accountID); } }; @@ -246,7 +246,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime try { await deps.api.inviteMember(accountID, { email: email, role: roleEl.value }); emailEl.value = ''; - if (!await refreshAccountTeamSection(accountID)) { + if (!await refreshAccountAccessSection(accountID)) { return; } deps.showToast('Member invited!'); @@ -262,18 +262,18 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime var changeRole = async function(accountID: string, userID: string, newRole: string): Promise { try { await deps.api.updateMemberRole(accountID, userID, { role: newRole }); - if (!await refreshAccountTeamSection(accountID)) { + if (!await refreshAccountAccessSection(accountID)) { return; } deps.showToast('Role updated.'); } catch (error) { if (error instanceof PortalAPIError && error.status === 409) { deps.showToast('Cannot demote last owner.', true); - await loadTeam(accountID); + await loadAccessRoster(accountID); return; } deps.showToast(error instanceof Error ? error.message : 'Failed to update role.', true); - await loadTeam(accountID); + await loadAccessRoster(accountID); } }; @@ -281,7 +281,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime if (!window.confirm('Remove ' + email + ' from this account?')) return; try { await deps.api.removeMember(accountID, userID); - if (!await refreshAccountTeamSection(accountID)) { + if (!await refreshAccountAccessSection(accountID)) { return; } deps.showToast('Member removed.'); @@ -302,8 +302,8 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime selectWorkspace: selectWorkspace, clearWorkspaceSelection: clearWorkspaceSelection, openBilling: openBilling, - toggleTeam: toggleTeam, - ensureTeamVisible: ensureTeamVisible, + toggleAccess: toggleAccess, + ensureAccessVisible: ensureAccessVisible, inviteMember: inviteMember, createWorkspace: createWorkspace, manageWorkspaceAction: manageWorkspaceAction, diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts index d18b64e3a..fc1863cac 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { renderAccountUI, renderAddWorkspaceSection, renderTeamSection, renderWorkspaceManagement } from './account_view'; +import { renderAccountUI, renderAddWorkspaceSection, renderAccessSection, renderWorkspaceManagement } from './account_view'; import type { PortalAccountState, PortalAccountSummary, PortalAccountUIEntry } from './types'; function createEntry(overrides: Partial = {}): PortalAccountUIEntry { @@ -15,8 +15,8 @@ function createEntry(overrides: Partial = {}): PortalAccou pending: false, error: '', }, - teamVisible: false, - teamQuery: { + accessVisible: false, + accessQuery: { status: 'idle', error: '', data: [], @@ -74,31 +74,31 @@ describe('account view', function() { it('renders team loading, error, and populated member states', function() { document.body.innerHTML = - '
' + - '
' + - '
' + + '
' + + '
' + + '
' + '
'; - renderTeamSection('acct_1', createEntry({ - teamVisible: true, - teamQuery: { status: 'loading', error: '', data: [] }, + renderAccessSection('acct_1', createEntry({ + accessVisible: true, + accessQuery: { status: 'loading', error: '', data: [] }, })); - expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Loading roster'); - expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Invites'); + expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Loading roster'); + expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Invites'); - renderTeamSection('acct_1', createEntry({ - teamVisible: true, - teamQuery: { status: 'error', error: 'Failed to load team.', data: [] }, + renderAccessSection('acct_1', createEntry({ + accessVisible: true, + accessQuery: { status: 'error', error: 'Failed to load access roster.', data: [] }, })); - expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Roster needs attention'); - expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Failed to load team.'); - expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Invite only'); + expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Roster needs attention'); + expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Failed to load access roster.'); + expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Invite only'); - renderTeamSection( + renderAccessSection( 'acct_1', createEntry({ - teamVisible: true, - teamQuery: { + accessVisible: true, + accessQuery: { status: 'ready', error: '', data: [ @@ -110,22 +110,22 @@ describe('account view', function() { ); expect(document.querySelector('[data-action="change-role"]')).not.toBeNull(); expect(document.querySelector('[data-action="remove-member"]')).not.toBeNull(); - expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Members'); - expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('2'); + expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Members'); + expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('2'); }); it('normalizes legacy member roles into the current read-only operator model', function() { document.body.innerHTML = - '
' + - '
' + - '
' + + '
' + + '
' + + '
' + '
'; - renderTeamSection( + renderAccessSection( 'acct_1', createEntry({ - teamVisible: true, - teamQuery: { + accessVisible: true, + accessQuery: { status: 'ready', error: '', data: [ @@ -135,12 +135,12 @@ describe('account view', function() { }) ); - var roleSelect = document.querySelector('.team-role-select') as HTMLSelectElement; - expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Read-only'); - expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Role'); + var roleSelect = document.querySelector('.access-role-select') as HTMLSelectElement; + expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Read-only'); + expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Role'); expect(roleSelect.value).toBe('read_only'); - expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Operators'); - expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('1'); + expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Operators'); + expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('1'); }); it('renders workspace management selection from account UI state', function() { @@ -179,19 +179,19 @@ describe('account view', function() { document.body.innerHTML = '
' + '
' + - '
' + + '
' + '
' + '
' + - '
'; + '
'; var state: PortalAccountState = { byAccountID: { - acct_1: createEntry({ addWorkspaceOpen: true, teamVisible: true }), + acct_1: createEntry({ addWorkspaceOpen: true, accessVisible: true }), acct_2: createEntry({ addWorkspaceOpen: false, selectedWorkspaceID: 'ws_2', - teamVisible: true, - teamQuery: { + accessVisible: true, + accessQuery: { status: 'ready', error: '', data: [{ email: 'tech@example.com', role: 'tech', user_id: 'u2' }], @@ -220,7 +220,7 @@ describe('account view', function() { expect(document.getElementById('add-ws-form-acct_1')?.classList.contains('visible')).toBe(true); expect(document.getElementById('add-ws-form-acct_2')?.classList.contains('visible')).toBe(false); expect(document.getElementById('workspace-management-acct_1')?.classList.contains('workspace-management-panel-idle')).toBe(true); - expect(document.getElementById('team-list-acct_2')?.textContent).toContain('tech@example.com'); + expect(document.getElementById('access-list-acct_2')?.textContent).toContain('tech@example.com'); expect(document.getElementById('workspace-management-title-acct_2')?.textContent).toContain('Beta Workspace'); }); }); diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index 8be733d0e..c4e6fedd6 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -2,19 +2,19 @@ import type { PortalAccountState, PortalAccountSummary, PortalAccountUIEntry, - PortalTeamMember, + PortalAccessMember, PortalWorkspaceSummary, } from './types'; type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; -function normalizedTeamRole(role: string): string { +function normalizedAccessRole(role: string): string { if (role === 'member') return 'read_only'; return role || 'read_only'; } function roleLabel(role: string): string { - switch (normalizedTeamRole(role)) { + switch (normalizedAccessRole(role)) { case 'owner': return 'Owner'; case 'admin': @@ -31,7 +31,7 @@ function roleLabel(role: string): string { } function roleCapabilityCopy(role: string): string { - switch (normalizedTeamRole(role)) { + switch (normalizedAccessRole(role)) { case 'owner': return 'Full account control, including billing, access control, and workspace control.'; case 'admin': @@ -183,73 +183,73 @@ function setContainerMessage(container: HTMLElement, title: string, msg: string, container.textContent = ''; container.classList.add('state-only'); var message = document.createElement('div'); - message.className = 'team-list-message' + (isError ? ' error' : ''); + message.className = 'access-list-message' + (isError ? ' error' : ''); var heading = document.createElement('strong'); - heading.className = 'team-list-message-title'; + heading.className = 'access-list-message-title'; heading.textContent = title; var copy = document.createElement('span'); - copy.className = 'team-list-message-copy'; + copy.className = 'access-list-message-copy'; copy.textContent = msg; message.appendChild(heading); message.appendChild(copy); container.appendChild(message); } -function countMembersByRole(members: PortalTeamMember[], role: string): number { +function countMembersByRole(members: PortalAccessMember[], role: string): number { var count = 0; for (var i = 0; i < members.length; i += 1) { - if (normalizedTeamRole(members[i].role) === role) count += 1; + if (normalizedAccessRole(members[i].role) === role) count += 1; } return count; } -function renderTeamStats(accountID: string, entry: PortalAccountUIEntry): void { - var stats = getElement('team-stats-' + accountID); +function renderAccessStats(accountID: string, entry: PortalAccountUIEntry): void { + var stats = getElement('access-stats-' + accountID); if (!stats) return; - if (!entry.teamVisible) { + if (!entry.accessVisible) { stats.innerHTML = ''; return; } - if (entry.teamQuery.status === 'loading') { + if (entry.accessQuery.status === 'loading') { stats.innerHTML = - '
RosterLoading…
' + - '
InvitesReady
'; + '
RosterLoading…
' + + '
InvitesReady
'; return; } - if (entry.teamQuery.status === 'error') { + if (entry.accessQuery.status === 'error') { stats.innerHTML = - '
RosterNeeds attention
' + - '
FallbackInvite only
'; + '
RosterNeeds attention
' + + '
FallbackInvite only
'; return; } - var members = entry.teamQuery.data; + var members = entry.accessQuery.data; stats.innerHTML = - '
Members' + String(members.length) + '
' + - '
Owners' + String(countMembersByRole(members, 'owner')) + '
' + - '
Admins' + String(countMembersByRole(members, 'admin')) + '
' + - '
Operators' + String(countMembersByRole(members, 'tech') + countMembersByRole(members, 'read_only')) + '
'; + '
Members' + String(members.length) + '
' + + '
Owners' + String(countMembersByRole(members, 'owner')) + '
' + + '
Admins' + String(countMembersByRole(members, 'admin')) + '
' + + '
Operators' + String(countMembersByRole(members, 'tech') + countMembersByRole(members, 'read_only')) + '
'; } -function createTeamControlCell(className: string): HTMLDivElement { +function createAccessControlCell(className: string): HTMLDivElement { var cell = document.createElement('div'); - cell.className = 'team-control-cell ' + className; + cell.className = 'access-control-cell ' + className; return cell; } -function renderTeamRoleControl(accountID: string, member: PortalTeamMember, isOwner: boolean): HTMLElement { - var currentRole = normalizedTeamRole(member.role); - var group = createTeamControlCell('team-control-cell-role'); +function renderAccessRoleControl(accountID: string, member: PortalAccessMember, isOwner: boolean): HTMLElement { + var currentRole = normalizedAccessRole(member.role); + var group = createAccessControlCell('access-control-cell-role'); if (currentRole === 'owner' && !isOwner) { var locked = document.createElement('span'); - locked.className = 'team-role-badge'; + locked.className = 'access-role-badge'; locked.textContent = roleLabel(currentRole); group.appendChild(locked); return group; } var sel = document.createElement('select'); - sel.className = 'team-role-select'; + sel.className = 'access-role-select'; var roles = isOwner ? ['owner', 'admin', 'tech', 'read_only'] : ['admin', 'tech', 'read_only']; for (var j = 0; j < roles.length; j += 1) { var opt = document.createElement('option'); @@ -265,11 +265,11 @@ function renderTeamRoleControl(accountID: string, member: PortalTeamMember, isOw return group; } -function renderTeamMemberAction(accountID: string, member: PortalTeamMember, isOwner: boolean): HTMLElement | null { - if (normalizedTeamRole(member.role) === 'owner' && !isOwner) { - var locked = createTeamControlCell('team-control-cell-access'); +function renderAccessMemberAction(accountID: string, member: PortalAccessMember, isOwner: boolean): HTMLElement | null { + if (normalizedAccessRole(member.role) === 'owner' && !isOwner) { + var locked = createAccessControlCell('access-control-cell-access'); var lockedText = document.createElement('span'); - lockedText.className = 'team-control-locked'; + lockedText.className = 'access-control-locked'; lockedText.textContent = 'Locked'; locked.appendChild(lockedText); return locked; @@ -283,49 +283,49 @@ function renderTeamMemberAction(accountID: string, member: PortalTeamMember, isO btn.setAttribute('data-account-id', accountID); btn.setAttribute('data-user-id', member.user_id); btn.setAttribute('data-member-email', member.email); - var group = createTeamControlCell('team-control-cell-access'); + var group = createAccessControlCell('access-control-cell-access'); group.appendChild(btn); return group; } -function renderTeamMemberRow(accountID: string, member: PortalTeamMember, isOwner: boolean): HTMLElement { +function renderAccessMemberRow(accountID: string, member: PortalAccessMember, isOwner: boolean): HTMLElement { var row = document.createElement('div'); - row.className = 'team-member-row'; + row.className = 'access-member-row'; var identity = document.createElement('div'); - identity.className = 'team-member-identity'; + identity.className = 'access-member-identity'; var topline = document.createElement('div'); - topline.className = 'team-member-topline'; + topline.className = 'access-member-topline'; var email = document.createElement('div'); - email.className = 'team-member-email'; + email.className = 'access-member-email'; email.textContent = member.email; topline.appendChild(email); var roleBadge = document.createElement('span'); - roleBadge.className = 'team-inline-role-badge'; + roleBadge.className = 'access-inline-role-badge'; roleBadge.textContent = roleLabel(member.role); topline.appendChild(roleBadge); identity.appendChild(topline); var caption = document.createElement('div'); - caption.className = 'team-member-caption'; + caption.className = 'access-member-caption'; caption.textContent = roleCapabilityCopy(member.role); identity.appendChild(caption); row.appendChild(identity); - row.appendChild(renderTeamRoleControl(accountID, member, isOwner)); - row.appendChild(renderTeamMemberAction(accountID, member, isOwner) || createTeamControlCell('team-control-cell-access')); + row.appendChild(renderAccessRoleControl(accountID, member, isOwner)); + row.appendChild(renderAccessMemberAction(accountID, member, isOwner) || createAccessControlCell('access-control-cell-access')); return row; } -function ensureRosterHead(container: HTMLElement): void { - var existing = container.querySelector('.team-roster-head'); +function ensureAccessRosterHead(container: HTMLElement): void { + var existing = container.querySelector('.access-roster-head'); if (existing) return; var head = document.createElement('div'); - head.className = 'team-roster-head'; + head.className = 'access-roster-head'; head.innerHTML = 'Operator' + 'Role' + @@ -343,31 +343,31 @@ export function renderAddWorkspaceSection(accountID: string, entry: PortalAccoun } } -export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry): void { - var section = getElement('team-section-' + accountID); - var roster = getElement('team-list-' + accountID); +export function renderAccessSection(accountID: string, entry: PortalAccountUIEntry): void { + var section = getElement('access-section-' + accountID); + var roster = getElement('access-list-' + accountID); if (!section || !roster) return; - var rosterPanel = roster.closest('.team-roster') as HTMLElement | null; + var rosterPanel = roster.closest('.access-roster') as HTMLElement | null; var actorRole = section.getAttribute('data-actor-role') || ''; var isOwner = actorRole === 'owner'; - section.classList.toggle('visible', entry.teamVisible); - renderTeamStats(accountID, entry); + section.classList.toggle('visible', entry.accessVisible); + renderAccessStats(accountID, entry); - if (!entry.teamVisible) { + if (!entry.accessVisible) { return; } - if (entry.teamQuery.status === 'loading') { + if (entry.accessQuery.status === 'loading') { if (rosterPanel) rosterPanel.classList.add('state-only'); setContainerMessage(roster, 'Loading roster', 'Checking who currently has access to this account.', false); return; } - if (entry.teamQuery.status === 'error') { + if (entry.accessQuery.status === 'error') { if (rosterPanel) rosterPanel.classList.add('state-only'); - setContainerMessage(roster, 'Roster needs attention', entry.teamQuery.error, true); + setContainerMessage(roster, 'Roster needs attention', entry.accessQuery.error, true); return; } - if (!entry.teamQuery.data.length) { + if (!entry.accessQuery.data.length) { if (rosterPanel) rosterPanel.classList.add('state-only'); setContainerMessage(roster, 'No one added yet', 'Invite someone new when this account needs shared access.', false); return; @@ -376,10 +376,10 @@ export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry roster.textContent = ''; roster.classList.remove('state-only'); if (rosterPanel) rosterPanel.classList.remove('state-only'); - ensureRosterHead(roster); - for (var i = 0; i < entry.teamQuery.data.length; i += 1) { - var member = entry.teamQuery.data[i]; - roster.appendChild(renderTeamMemberRow(accountID, member, isOwner)); + ensureAccessRosterHead(roster); + for (var i = 0; i < entry.accessQuery.data.length; i += 1) { + var member = entry.accessQuery.data[i]; + roster.appendChild(renderAccessMemberRow(accountID, member, isOwner)); } } @@ -397,6 +397,6 @@ export function renderAccountUI(accountState: PortalAccountState, accounts: Port } renderAddWorkspaceSection(accountID, entry); if (account) renderWorkspaceManagement(account, entry); - renderTeamSection(accountID, entry); + renderAccessSection(accountID, entry); } } diff --git a/internal/cloudcp/portal/frontend/src/api.ts b/internal/cloudcp/portal/frontend/src/api.ts index ab1c1d8be..c4b62c1ab 100644 --- a/internal/cloudcp/portal/frontend/src/api.ts +++ b/internal/cloudcp/portal/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { PortalBootstrapData, PortalTeamMember } from './types'; +import type { PortalBootstrapData, PortalAccessMember } from './types'; interface PortalAPIContext { getBootstrap(): PortalBootstrapData; @@ -46,7 +46,7 @@ export interface PortalAPI { suspendWorkspace(accountID: string, tenantID: string): Promise; deleteWorkspace(accountID: string, tenantID: string): Promise; openBilling(accountID: string): Promise; - listMembers(accountID: string): Promise; + listMembers(accountID: string): Promise; inviteMember(accountID: string, body: PortalMemberInviteRequest): Promise; updateMemberRole(accountID: string, userID: string, body: PortalMemberRoleRequest): Promise; removeMember(accountID: string, userID: string): Promise; @@ -172,7 +172,7 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI { }, 'Failed to open billing portal.'); }, listMembers: function(accountID: string) { - return request(accountURL(accountID, '/members'), {}, 'Failed to load team.'); + return request(accountURL(accountID, '/members'), {}, 'Failed to load access roster.'); }, inviteMember: function(accountID: string, body: PortalMemberInviteRequest) { return request(accountURL(accountID, '/members'), { diff --git a/internal/cloudcp/portal/frontend/src/app.test.ts b/internal/cloudcp/portal/frontend/src/app.test.ts index a39fb07b5..e84998dbd 100644 --- a/internal/cloudcp/portal/frontend/src/app.test.ts +++ b/internal/cloudcp/portal/frontend/src/app.test.ts @@ -147,7 +147,7 @@ describe('portal app', function() { }, { email: 'buyer@example.com', - openPanelID: 'retrieve-service-panel', + openBillingPanelID: 'retrieve-billing-panel', } ); @@ -175,9 +175,9 @@ describe('portal app', function() { ], }); - expect(document.getElementById('retrieve-service-panel')?.classList.contains('visible')).toBe(true); + expect(document.getElementById('retrieve-billing-panel')?.classList.contains('visible')).toBe(true); expect((document.getElementById('retrieve-inline-email') as HTMLInputElement | null)?.value).toBe('buyer@example.com'); - expect(runtime.store.getServiceState().openPanelID).toBe('retrieve-service-panel'); + expect(runtime.store.getBillingState().openBillingPanelID).toBe('retrieve-billing-panel'); }); it('completes the signed-out magic-link flow through the real shell and auth runtime', async function() { @@ -263,8 +263,8 @@ describe('portal app', function() { }); await app.startupRefresh; - document.getElementById('open-retrieve-service')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - var retrievePanel = document.getElementById('retrieve-service-panel'); + document.getElementById('open-retrieve-billing')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + var retrievePanel = document.getElementById('retrieve-billing-panel'); expect(retrievePanel?.classList.contains('visible')).toBe(true); var emailInput = document.getElementById('retrieve-inline-email') as HTMLInputElement | null; @@ -294,7 +294,7 @@ describe('portal app', function() { body: JSON.stringify({ email: 'owner@example.com', code: '123456' }), }), ]); - expect((store.getServiceState().flows.retrieve.result as { token?: string } | null)?.token).toBe('pulse_token_123'); + expect((store.getBillingState().flows.retrieve.result as { token?: string } | null)?.token).toBe('pulse_token_123'); expect((document.getElementById('retrieve-inline-result') as HTMLElement | null)?.hidden).toBe(false); expect((document.getElementById('retrieve-inline-copy') as HTMLButtonElement | null)?.hidden).toBe(false); expect(document.getElementById('retrieve-inline-status')?.textContent).toContain('License retrieved successfully.'); @@ -339,7 +339,7 @@ describe('portal app', function() { await flushAsync(); expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/accounts/acct_1/members'); - expect(document.getElementById('team-section-acct_1')?.classList.contains('visible')).toBe(true); - expect(document.getElementById('team-list-acct_1')?.textContent).toContain('owner@example.com'); + expect(document.getElementById('access-section-acct_1')?.classList.contains('visible')).toBe(true); + expect(document.getElementById('access-list-acct_1')?.textContent).toContain('owner@example.com'); }); }); diff --git a/internal/cloudcp/portal/frontend/src/app.ts b/internal/cloudcp/portal/frontend/src/app.ts index f1ec173ac..aa6403751 100644 --- a/internal/cloudcp/portal/frontend/src/app.ts +++ b/internal/cloudcp/portal/frontend/src/app.ts @@ -2,7 +2,7 @@ import { installAccountController } from './account_controller'; import { installAccountRuntime } from './account_runtime'; import { createPortalAPI, PortalAPIError } from './api'; import { installAuthController } from './auth_controller'; -import { installServicesRuntime } from './services'; +import { installBillingRuntime } from './billing'; import { installShell } from './shell'; import { createAnonymousBootstrap } from './store'; import { createPortalRuntime } from './runtime'; @@ -74,13 +74,13 @@ export function installPortalApp(deps: PortalAppDeps): PortalApp { if (section === 'access') { var accounts = deps.store.getBootstrap().accounts || []; for (var i = 0; i < accounts.length; i += 1) { - accountRuntime.ensureTeamVisible(accounts[i].id); + accountRuntime.ensureAccessVisible(accounts[i].id); } } }, }); - installServicesRuntime({ + installBillingRuntime({ api: api, store: deps.store, }); diff --git a/internal/cloudcp/portal/frontend/src/services.ts b/internal/cloudcp/portal/frontend/src/billing.ts similarity index 61% rename from internal/cloudcp/portal/frontend/src/services.ts rename to internal/cloudcp/portal/frontend/src/billing.ts index a843757b2..f18f5cf80 100644 --- a/internal/cloudcp/portal/frontend/src/services.ts +++ b/internal/cloudcp/portal/frontend/src/billing.ts @@ -1,16 +1,16 @@ import { beginMutationState, failMutationState, succeedMutationState } from './async_state'; import type { PortalAPI } from './api'; -import { installServicesController } from './services_controller'; +import { installBillingController } from './billing_controller'; import { clearFlowStatus, - createPortalServiceState, + createPortalBillingState, emptyStatus, resetVerificationFlowState, setFlowStatus, setRefundStatus, - toggleServicePanelState, + toggleBillingPanelState, updateDeleteConfirmation, - updateServiceInputValue, + updateBillingInputValue, } from './state'; import type { PortalStore } from './store'; import { @@ -22,14 +22,14 @@ import { renderExportPanel, renderExportResult, renderManagePanel, - renderOpenPanels, + renderOpenBillingPanels, renderRefundPanel, renderRetrievePanel, - renderStatus, + renderBillingStatus, setValue, setVisible, -} from './services_view'; -import type { PortalServiceFlowID, PortalServiceState, VerificationFlowState } from './types'; +} from './billing_view'; +import type { PortalBillingFlowID, PortalBillingState, VerificationFlowState } from './types'; interface VerificationFlowDefinition { requestPath: string; @@ -53,54 +53,54 @@ interface VerificationFlowDefinition { readCodeValue?: () => string; onRequestStart?: () => void; beforeConfirm?: () => boolean; - applyConfirmSuccessState?: (serviceState: PortalServiceState, data: any, email?: string) => void; + applyConfirmSuccessState?: (billingState: PortalBillingState, data: any, email?: string) => void; afterConfirmSuccess?: (data: any, email?: string) => void; renderPanel: (flowState: VerificationFlowState) => void; renderResult?: (result: unknown) => void; } -export interface ServicesRuntimeDeps { +export interface BillingRuntimeDeps { api: PortalAPI; store: PortalStore; } -export function installServicesRuntime(deps: ServicesRuntimeDeps): void { +export function installBillingRuntime(deps: BillingRuntimeDeps): void { var api = deps.api; var store = deps.store; - store.updateServiceState(function(serviceState) { - if (!serviceState.flows) { - var nextState = createPortalServiceState(); - serviceState.openPanelID = nextState.openPanelID; - serviceState.flows = nextState.flows; - serviceState.refund = nextState.refund; + store.updateBillingState(function(billingState) { + if (!billingState.flows) { + var nextState = createPortalBillingState(); + billingState.openBillingPanelID = nextState.openBillingPanelID; + billingState.flows = nextState.flows; + billingState.refund = nextState.refund; } }, { notify: false }); - function getServiceState() { - return store.getServiceState(); + function getBillingState() { + return store.getBillingState(); } - function updateServiceState(mutator, notify = true) { - return store.updateServiceState(mutator, { notify: notify }); + function updateBillingState(mutator, notify = true) { + return store.updateBillingState(mutator, { notify: notify }); } - function toggleServicePanel(panelID) { - updateServiceState(function(serviceState) { - toggleServicePanelState(serviceState, panelID); + function toggleBillingPanel(panelID) { + updateBillingState(function(billingState) { + toggleBillingPanelState(billingState, panelID); }); } - function renderFlow(flowID: PortalServiceFlowID) { + function renderFlow(flowID: PortalBillingFlowID) { var flow = verificationFlows[flowID]; if (!flow) return; - var flowState = getServiceState().flows[flowID]; + var flowState = getBillingState().flows[flowID]; if (flow.renderPanel) { flow.renderPanel(flowState); } renderButton(flow.requestButtonID, flowState.request.pending, flowState.request.pending ? flow.requestPendingLabel : flow.requestLabel); renderButton(flow.confirmButtonID, flowState.confirm.pending, flowState.confirm.pending ? flow.confirmPendingLabel : flow.confirmLabel); - renderStatus(flow.statusID, flowState.status); + renderBillingStatus(flow.statusID, flowState.status); if (flow.step2ID) { setVisible(flow.step2ID, flowState.step2Visible); } @@ -118,28 +118,28 @@ export function installServicesRuntime(deps: ServicesRuntimeDeps): void { } function renderRefund() { - var refundState = getServiceState().refund; + var refundState = getBillingState().refund; renderRefundPanel(refundState, store.getBootstrap()); renderButton('refund-inline-submit', refundState.submit.pending, refundState.submit.pending ? 'Processing...' : 'Process Refund'); - renderStatus('refund-inline-status', refundState.status); + renderBillingStatus('refund-inline-status', refundState.status); } - function resetVerificationFlow(flowID: PortalServiceFlowID) { + function resetVerificationFlow(flowID: PortalBillingFlowID) { var flow = verificationFlows[flowID]; if (!flow) return; - updateServiceState(function(serviceState) { - resetVerificationFlowState(serviceState, flowID); + updateBillingState(function(billingState) { + resetVerificationFlowState(billingState, flowID); }, false); if (flow.codeInputID) { setValue(flow.codeInputID, ''); } } - var verificationFlows: Record = { + var verificationFlows: Record = { manage: { requestPath: '/v1/manage/request', confirmPath: '/v1/manage', - panelID: 'manage-service-panel', + panelID: 'manage-billing-panel', emailInputID: 'manage-inline-email', codeInputID: 'manage-inline-code', requestButtonID: 'manage-inline-request', @@ -155,10 +155,10 @@ export function installServicesRuntime(deps: ServicesRuntimeDeps): void { requestErrorMessage: 'Failed to send verification code', confirmErrorMessage: 'Failed to open customer portal', readEmailValue: function() { - return getServiceState().flows.manage.emailValue; + return getBillingState().flows.manage.emailValue; }, readCodeValue: function() { - return getServiceState().flows.manage.codeValue; + return getBillingState().flows.manage.codeValue; }, onRequestStart: function() {}, afterConfirmSuccess: function(data) { @@ -169,7 +169,7 @@ export function installServicesRuntime(deps: ServicesRuntimeDeps): void { retrieve: { requestPath: '/v1/retrieve-license/request', confirmPath: '/v1/retrieve-license', - panelID: 'retrieve-service-panel', + panelID: 'retrieve-billing-panel', emailInputID: 'retrieve-inline-email', codeInputID: 'retrieve-inline-code', requestButtonID: 'retrieve-inline-request', @@ -185,27 +185,27 @@ export function installServicesRuntime(deps: ServicesRuntimeDeps): void { requestErrorMessage: 'Failed to send verification code', confirmErrorMessage: 'Failed to retrieve license', readEmailValue: function() { - return getServiceState().flows.retrieve.emailValue; + return getBillingState().flows.retrieve.emailValue; }, readCodeValue: function() { - return getServiceState().flows.retrieve.codeValue; + return getBillingState().flows.retrieve.codeValue; }, onRequestStart: function() { - updateServiceState(function(serviceState) { - serviceState.flows.retrieve.result = null; + updateBillingState(function(billingState) { + billingState.flows.retrieve.result = null; }, false); }, - applyConfirmSuccessState: function(serviceState, data) { - serviceState.flows.retrieve.result = data.license; - serviceState.flows.retrieve.codeValue = ''; - setFlowStatus(serviceState, 'retrieve', 'License retrieved successfully.', false); + applyConfirmSuccessState: function(billingState, data) { + billingState.flows.retrieve.result = data.license; + billingState.flows.retrieve.codeValue = ''; + setFlowStatus(billingState, 'retrieve', 'License retrieved successfully.', false); }, renderPanel: renderRetrievePanel, }, export: { requestPath: '/v1/gdpr/request-export', confirmPath: '/v1/gdpr/export', - panelID: 'data-service-panel', + panelID: 'data-billing-panel', emailInputID: 'data-export-email', codeInputID: 'data-export-code', requestButtonID: 'data-export-request', @@ -221,22 +221,22 @@ export function installServicesRuntime(deps: ServicesRuntimeDeps): void { requestErrorMessage: 'Request failed', confirmErrorMessage: 'Export failed', readEmailValue: function() { - return getServiceState().flows.export.emailValue; + return getBillingState().flows.export.emailValue; }, readCodeValue: function() { - return getServiceState().flows.export.codeValue; + return getBillingState().flows.export.codeValue; }, onRequestStart: function() { - updateServiceState(function(serviceState) { - serviceState.flows.export.result = null; + updateBillingState(function(billingState) { + billingState.flows.export.result = null; }, false); }, - applyConfirmSuccessState: function(serviceState, data) { - var emailValue = serviceState.flows.export.emailValue; - resetVerificationFlowState(serviceState, 'export'); - serviceState.flows.export.emailValue = emailValue; - serviceState.flows.export.result = data; - setFlowStatus(serviceState, 'export', 'Data export retrieved successfully.', false); + applyConfirmSuccessState: function(billingState, data) { + var emailValue = billingState.flows.export.emailValue; + resetVerificationFlowState(billingState, 'export'); + billingState.flows.export.emailValue = emailValue; + billingState.flows.export.result = data; + setFlowStatus(billingState, 'export', 'Data export retrieved successfully.', false); }, renderPanel: renderExportPanel, renderResult: renderExportResult, @@ -244,7 +244,7 @@ export function installServicesRuntime(deps: ServicesRuntimeDeps): void { delete: { requestPath: '/v1/gdpr/request-delete', confirmPath: '/v1/gdpr/confirm-delete', - panelID: 'data-service-panel', + panelID: 'data-billing-panel', emailInputID: 'data-delete-email', codeInputID: 'data-delete-code', requestButtonID: 'data-delete-request', @@ -260,26 +260,26 @@ export function installServicesRuntime(deps: ServicesRuntimeDeps): void { requestErrorMessage: 'Request failed', confirmErrorMessage: 'Deletion failed', readEmailValue: function() { - return getServiceState().flows.delete.emailValue; + return getBillingState().flows.delete.emailValue; }, readCodeValue: function() { - return getServiceState().flows.delete.codeValue; + return getBillingState().flows.delete.codeValue; }, beforeConfirm: function() { if (!getElement('data-delete-confirm-check')?.checked) { - updateServiceState(function(serviceState) { - setFlowStatus(serviceState, 'delete', 'You must confirm that you understand this action is permanent.', true); + updateBillingState(function(billingState) { + setFlowStatus(billingState, 'delete', 'You must confirm that you understand this action is permanent.', true); }); return false; } return true; }, - applyConfirmSuccessState: function(serviceState, data) { - var emailValue = serviceState.flows.delete.emailValue; - resetVerificationFlowState(serviceState, 'delete'); - serviceState.flows.delete.emailValue = emailValue; + applyConfirmSuccessState: function(billingState, data) { + var emailValue = billingState.flows.delete.emailValue; + resetVerificationFlowState(billingState, 'delete'); + billingState.flows.delete.emailValue = emailValue; setFlowStatus( - serviceState, + billingState, 'delete', data.deleted_count > 0 && data.stripe_reminder ? data.message + ' ' + data.stripe_reminder : data.message, false @@ -295,7 +295,7 @@ export function installServicesRuntime(deps: ServicesRuntimeDeps): void { }, }; - async function requestVerificationCode(flowID: PortalServiceFlowID) { + async function requestVerificationCode(flowID: PortalBillingFlowID) { var flow = verificationFlows[flowID]; if (!flow) return; var email = flow.readEmailValue ? flow.readEmailValue() : readValue(flow.emailInputID); @@ -306,63 +306,63 @@ export function installServicesRuntime(deps: ServicesRuntimeDeps): void { if (flow.onRequestStart) { flow.onRequestStart(); } - updateServiceState(function(serviceState) { - beginMutationState(serviceState.flows[flowID].request); - clearFlowStatus(serviceState, flowID); + updateBillingState(function(billingState) { + beginMutationState(billingState.flows[flowID].request); + clearFlowStatus(billingState, flowID); }); try { await api.postCommercialJSON(flow.requestPath, { email: email }); - updateServiceState(function(serviceState) { - serviceState.flows[flowID].pendingEmail = email; - serviceState.flows[flowID].step2Visible = !!flow.step2ID; - succeedMutationState(serviceState.flows[flowID].request); - setFlowStatus(serviceState, flowID, flow.requestSuccessMessage, false); + updateBillingState(function(billingState) { + billingState.flows[flowID].pendingEmail = email; + billingState.flows[flowID].step2Visible = !!flow.step2ID; + succeedMutationState(billingState.flows[flowID].request); + setFlowStatus(billingState, flowID, flow.requestSuccessMessage, false); }); } catch (err) { var message = err instanceof Error ? err.message : flow.requestErrorMessage; - updateServiceState(function(serviceState) { - failMutationState(serviceState.flows[flowID].request, message); - setFlowStatus(serviceState, flowID, message, true); + updateBillingState(function(billingState) { + failMutationState(billingState.flows[flowID].request, message); + setFlowStatus(billingState, flowID, message, true); }); } } - async function resendVerificationCode(flowID: PortalServiceFlowID, event?: Event) { + async function resendVerificationCode(flowID: PortalBillingFlowID, event?: Event) { if (event) event.preventDefault(); var flow = verificationFlows[flowID]; if (!flow) return; - var email = getServiceState().flows[flowID].pendingEmail; + var email = getBillingState().flows[flowID].pendingEmail; if (!email) return; try { await api.postCommercialJSON(flow.requestPath, { email: email }); - updateServiceState(function(serviceState) { - setFlowStatus(serviceState, flowID, flow.resendSuccessMessage, false); + updateBillingState(function(billingState) { + setFlowStatus(billingState, flowID, flow.resendSuccessMessage, false); }); } catch (err) { - updateServiceState(function(serviceState) { - setFlowStatus(serviceState, flowID, err instanceof Error ? err.message : flow.requestErrorMessage, true); + updateBillingState(function(billingState) { + setFlowStatus(billingState, flowID, err instanceof Error ? err.message : flow.requestErrorMessage, true); }); } } - async function confirmVerificationCode(flowID: PortalServiceFlowID) { + async function confirmVerificationCode(flowID: PortalBillingFlowID) { var flow = verificationFlows[flowID]; if (!flow) return; - var email = getServiceState().flows[flowID].pendingEmail; + var email = getBillingState().flows[flowID].pendingEmail; var code = flow.readCodeValue ? flow.readCodeValue() : readValue(flow.codeInputID); if (!email || !code) return; if (flow.beforeConfirm && flow.beforeConfirm() === false) { return; } - updateServiceState(function(serviceState) { - beginMutationState(serviceState.flows[flowID].confirm); + updateBillingState(function(billingState) { + beginMutationState(billingState.flows[flowID].confirm); }); try { var data = await api.postCommercialJSON(flow.confirmPath, { email: email, code: code }); - updateServiceState(function(serviceState) { - succeedMutationState(serviceState.flows[flowID].confirm); + updateBillingState(function(billingState) { + succeedMutationState(billingState.flows[flowID].confirm); if (flow.applyConfirmSuccessState) { - flow.applyConfirmSuccessState(serviceState, data, email); + flow.applyConfirmSuccessState(billingState, data, email); } }); if (flow.afterConfirmSuccess) { @@ -370,68 +370,68 @@ export function installServicesRuntime(deps: ServicesRuntimeDeps): void { } } catch (err) { var message = err instanceof Error ? err.message : flow.confirmErrorMessage; - updateServiceState(function(serviceState) { - failMutationState(serviceState.flows[flowID].confirm, message); - setFlowStatus(serviceState, flowID, message, true); + updateBillingState(function(billingState) { + failMutationState(billingState.flows[flowID].confirm, message); + setFlowStatus(billingState, flowID, message, true); }); } } async function copyRetrievedLicense() { - var result = getServiceState().flows.retrieve.result as { token?: string } | null; + var result = getBillingState().flows.retrieve.result as { token?: string } | null; var token = result && result.token ? result.token : ''; if (!token) return; try { await navigator.clipboard.writeText(token); - updateServiceState(function(serviceState) { - setFlowStatus(serviceState, 'retrieve', 'License key copied to clipboard.', false); + updateBillingState(function(billingState) { + setFlowStatus(billingState, 'retrieve', 'License key copied to clipboard.', false); }); } catch (_) { - updateServiceState(function(serviceState) { - setFlowStatus(serviceState, 'retrieve', 'Failed to copy automatically. Please copy the key manually.', true); + updateBillingState(function(billingState) { + setFlowStatus(billingState, 'retrieve', 'Failed to copy automatically. Please copy the key manually.', true); }); } } async function submitRefund() { - var email = getServiceState().refund.emailValue; - var token = getServiceState().refund.tokenValue; + var email = getBillingState().refund.emailValue; + var token = getBillingState().refund.tokenValue; if (!email || !token) return; if (!confirm('Are you sure? This will immediately revoke the license and request the refund.')) return; - updateServiceState(function(serviceState) { - beginMutationState(serviceState.refund.submit); - serviceState.refund.status = emptyStatus(); + updateBillingState(function(billingState) { + beginMutationState(billingState.refund.submit); + billingState.refund.status = emptyStatus(); }); try { await api.postCommercialJSON('/v1/self-refund', { email: email, token: token }); - updateServiceState(function(serviceState) { - serviceState.refund.tokenValue = ''; - succeedMutationState(serviceState.refund.submit); - setRefundStatus(serviceState, 'Success! Your refund has been processed. Stripe will follow up by email.', false); + updateBillingState(function(billingState) { + billingState.refund.tokenValue = ''; + succeedMutationState(billingState.refund.submit); + setRefundStatus(billingState, 'Success! Your refund has been processed. Stripe will follow up by email.', false); }); } catch (err) { var message = err instanceof Error ? err.message : 'Refund failed'; - updateServiceState(function(serviceState) { - failMutationState(serviceState.refund.submit, message); - setRefundStatus(serviceState, message, true); + updateBillingState(function(billingState) { + failMutationState(billingState.refund.submit, message); + setRefundStatus(billingState, message, true); }); } } - function renderServiceRuntime() { - renderOpenPanels(getServiceState().openPanelID); + function renderBillingRuntime() { + renderOpenBillingPanels(getBillingState().openBillingPanelID); renderAllFlows(); } - renderServiceRuntime(); - store.subscribeBootstrap(renderServiceRuntime); - store.subscribeServices(renderServiceRuntime); + renderBillingRuntime(); + store.subscribeBootstrap(renderBillingRuntime); + store.subscribeBilling(renderBillingRuntime); - installServicesController({ + installBillingController({ setShellSection: function(section) { store.setActiveShellSection(section); }, - toggleServicePanel, + toggleBillingPanel, focusElement, requestVerificationCode: function(flowID) { void requestVerificationCode(flowID); @@ -449,13 +449,13 @@ export function installServicesRuntime(deps: ServicesRuntimeDeps): void { void submitRefund(); }, updateInputValue: function(inputKind, value) { - updateServiceState(function(serviceState) { - updateServiceInputValue(serviceState, inputKind, value); + updateBillingState(function(billingState) { + updateBillingInputValue(billingState, inputKind, value); }, false); }, updateDeleteConfirmation: function(checked) { - updateServiceState(function(serviceState) { - updateDeleteConfirmation(serviceState, checked); + updateBillingState(function(billingState) { + updateDeleteConfirmation(billingState, checked); }, false); }, }); diff --git a/internal/cloudcp/portal/frontend/src/services_controller.test.ts b/internal/cloudcp/portal/frontend/src/billing_controller.test.ts similarity index 77% rename from internal/cloudcp/portal/frontend/src/services_controller.test.ts rename to internal/cloudcp/portal/frontend/src/billing_controller.test.ts index e37ea52af..102df989c 100644 --- a/internal/cloudcp/portal/frontend/src/services_controller.test.ts +++ b/internal/cloudcp/portal/frontend/src/billing_controller.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { installServicesController } from './services_controller'; +import { installBillingController } from './billing_controller'; describe('services controller', function() { beforeEach(function() { @@ -11,7 +11,7 @@ describe('services controller', function() { it('routes service actions to the matching handlers', function() { var deps = { setShellSection: vi.fn(), - toggleServicePanel: vi.fn(), + toggleBillingPanel: vi.fn(), focusElement: vi.fn(), requestVerificationCode: vi.fn(), resendVerificationCode: vi.fn(), @@ -22,19 +22,19 @@ describe('services controller', function() { updateDeleteConfirmation: vi.fn(), }; - installServicesController(deps); + installBillingController(deps); document.body.innerHTML = - '' + - '' + - '' + - '' + - '' + - ''; + '' + + '' + + '' + + '' + + '' + + ''; document.getElementById('open')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(deps.setShellSection).toHaveBeenCalledWith('billing'); - expect(deps.toggleServicePanel).toHaveBeenCalledWith('retrieve-service-panel'); + expect(deps.toggleBillingPanel).toHaveBeenCalledWith('retrieve-billing-panel'); expect(deps.focusElement).toHaveBeenCalledWith('retrieve-inline-email'); document.getElementById('request')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -56,7 +56,7 @@ describe('services controller', function() { it('routes input and checkbox changes into state update hooks', function() { var deps = { setShellSection: vi.fn(), - toggleServicePanel: vi.fn(), + toggleBillingPanel: vi.fn(), focusElement: vi.fn(), requestVerificationCode: vi.fn(), resendVerificationCode: vi.fn(), @@ -67,10 +67,10 @@ describe('services controller', function() { updateDeleteConfirmation: vi.fn(), }; - installServicesController(deps); + installBillingController(deps); document.body.innerHTML = - '' + + '' + ''; var emailInput = document.getElementById('retrieve-email') as HTMLInputElement; diff --git a/internal/cloudcp/portal/frontend/src/services_controller.ts b/internal/cloudcp/portal/frontend/src/billing_controller.ts similarity index 84% rename from internal/cloudcp/portal/frontend/src/services_controller.ts rename to internal/cloudcp/portal/frontend/src/billing_controller.ts index 58a46de56..60102f8da 100644 --- a/internal/cloudcp/portal/frontend/src/services_controller.ts +++ b/internal/cloudcp/portal/frontend/src/billing_controller.ts @@ -1,8 +1,8 @@ -import { asHTMLElement } from './services_view'; +import { asHTMLElement } from './billing_view'; -export interface ServicesControllerDeps { +export interface BillingControllerDeps { setShellSection: (section: 'overview' | 'workspaces' | 'access' | 'billing' | 'support') => void; - toggleServicePanel: (panelID: string) => void; + toggleBillingPanel: (panelID: string) => void; focusElement: (id: string) => void; requestVerificationCode: (flowID: 'manage' | 'retrieve' | 'export' | 'delete') => void; resendVerificationCode: (flowID: 'manage' | 'export' | 'delete', event?: Event) => void; @@ -13,19 +13,19 @@ export interface ServicesControllerDeps { updateDeleteConfirmation: (checked: boolean) => void; } -export function installServicesController(deps: ServicesControllerDeps): void { +export function installBillingController(deps: BillingControllerDeps): void { document.addEventListener('click', function(event) { - var target = asHTMLElement(event.target)?.closest('[data-account-service-action]'); + var target = asHTMLElement(event.target)?.closest('[data-account-billing-action]'); if (!target) return; - var action = target.getAttribute('data-account-service-action') || ''; - var panelID = target.getAttribute('data-account-service-panel') || ''; - var focusID = target.getAttribute('data-account-service-focus') || ''; + var action = target.getAttribute('data-account-billing-action') || ''; + var panelID = target.getAttribute('data-account-billing-panel') || ''; + var focusID = target.getAttribute('data-account-billing-focus') || ''; switch (action) { - case 'open-service-panel': + case 'open-billing-panel': event.preventDefault(); deps.setShellSection('billing'); - deps.toggleServicePanel(panelID); + deps.toggleBillingPanel(panelID); deps.focusElement(focusID); return; case 'manage-inline-request': @@ -85,7 +85,7 @@ export function installServicesController(deps: ServicesControllerDeps): void { document.addEventListener('input', function(event) { var target = asHTMLElement(event.target) as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null; if (!target) return; - var inputKind = target.getAttribute('data-account-service-input') || ''; + var inputKind = target.getAttribute('data-account-billing-input') || ''; if (!inputKind) return; deps.updateInputValue(inputKind, target.value); }); diff --git a/internal/cloudcp/portal/frontend/src/services_view.test.ts b/internal/cloudcp/portal/frontend/src/billing_view.test.ts similarity index 80% rename from internal/cloudcp/portal/frontend/src/services_view.test.ts rename to internal/cloudcp/portal/frontend/src/billing_view.test.ts index b7737789c..3617009c9 100644 --- a/internal/cloudcp/portal/frontend/src/services_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/billing_view.test.ts @@ -5,12 +5,12 @@ import { renderExportPanel, renderExportResult, renderManagePanel, - renderOpenPanels, + renderOpenBillingPanels, renderRefundPanel, renderRetrievePanel, - renderStatus, -} from './services_view'; -import type { PortalBootstrapData, RefundState, ServiceStatus, VerificationFlowState } from './types'; + renderBillingStatus, +} from './billing_view'; +import type { PortalBootstrapData, RefundState, BillingStatus, VerificationFlowState } from './types'; function createBootstrap(overrides: Partial = {}): PortalBootstrapData { return { @@ -80,25 +80,25 @@ describe('services view', function() { it('toggles the visible service panel by id', function() { document.body.innerHTML = - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
'; + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; - renderOpenPanels('retrieve-service-panel'); + renderOpenBillingPanels('retrieve-billing-panel'); - expect(document.getElementById('retrieve-service-panel')?.classList.contains('visible')).toBe(true); - expect(document.getElementById('manage-service-panel')?.classList.contains('visible')).toBe(false); - expect(document.getElementById('refund-service-panel')?.classList.contains('visible')).toBe(false); - expect(document.getElementById('service-panel-empty')?.classList.contains('visible')).toBe(false); - expect(document.querySelectorAll('.service-action-row.active')).toHaveLength(1); + expect(document.getElementById('retrieve-billing-panel')?.classList.contains('visible')).toBe(true); + expect(document.getElementById('manage-billing-panel')?.classList.contains('visible')).toBe(false); + expect(document.getElementById('refund-billing-panel')?.classList.contains('visible')).toBe(false); + expect(document.getElementById('billing-panel-empty')?.classList.contains('visible')).toBe(false); + expect(document.querySelectorAll('.billing-action-row.active')).toHaveLength(1); }); it('renders retrieve panel result state with invoice and token metadata', function() { - document.body.innerHTML = '
'; + document.body.innerHTML = '
'; renderRetrievePanel( createFlowState({ @@ -130,7 +130,7 @@ describe('services view', function() { it('renders refund and delete panels from owned state', function() { document.body.innerHTML = - '
' + + '
' + '
'; renderRefundPanel( @@ -155,7 +155,7 @@ describe('services view', function() { expect(refundEmail.value).toBe('owner@example.com'); expect(refundToken.value).toBe('pulse_token'); - expect(document.getElementById('refund-service-root')?.innerHTML).toContain('/refund.html?email=owner%40example.com'); + expect(document.getElementById('refund-billing-root')?.innerHTML).toContain('/refund.html?email=owner%40example.com'); expect(deleteCheck.checked).toBe(true); expect((document.getElementById('data-delete-step2') as HTMLElement).hidden).toBe(false); }); @@ -189,8 +189,8 @@ describe('services view', function() { it('renders manage panel and status classes from service state', function() { document.body.innerHTML = - '
' + - '
'; + '
' + + '
'; renderManagePanel( createFlowState({ @@ -199,11 +199,11 @@ describe('services view', function() { step2Visible: true, }) ); - renderStatus('manage-inline-status', { + renderBillingStatus('manage-inline-status', { visible: true, message: 'Code sent.', error: false, - } satisfies ServiceStatus); + } satisfies BillingStatus); expect((document.getElementById('manage-inline-email') as HTMLInputElement).value).toBe('owner@example.com'); expect((document.getElementById('manage-inline-code') as HTMLInputElement).value).toBe('123456'); diff --git a/internal/cloudcp/portal/frontend/src/services_view.ts b/internal/cloudcp/portal/frontend/src/billing_view.ts similarity index 79% rename from internal/cloudcp/portal/frontend/src/services_view.ts rename to internal/cloudcp/portal/frontend/src/billing_view.ts index a8fab11b4..1b30be7c8 100644 --- a/internal/cloudcp/portal/frontend/src/services_view.ts +++ b/internal/cloudcp/portal/frontend/src/billing_view.ts @@ -1,4 +1,4 @@ -import type { PortalBootstrapData, RefundState, ServiceStatus, VerificationFlowState } from './types'; +import type { PortalBootstrapData, RefundState, BillingStatus, VerificationFlowState } from './types'; type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; @@ -47,16 +47,16 @@ export function setValue(id: string, value: string): void { } } -export function renderStatus(id: string, status: ServiceStatus): void { +export function renderBillingStatus(id: string, status: BillingStatus): void { var el = getElement(id); if (!el) return; if (!status.visible) { el.textContent = ''; - el.className = 'service-status'; + el.className = 'billing-status'; return; } el.textContent = status.message; - el.className = 'service-status visible' + (status.error ? ' error' : ' success'); + el.className = 'billing-status visible' + (status.error ? ' error' : ' success'); } export function renderButton(id: string | undefined, disabled: boolean, label: string | undefined): void { @@ -67,28 +67,28 @@ export function renderButton(id: string | undefined, disabled: boolean, label: s button.textContent = label; } -export function renderOpenPanels(openPanelID: string): void { - var panels = ['manage-service-panel', 'retrieve-service-panel', 'refund-service-panel', 'data-service-panel']; - var emptyPanel = getElement('service-panel-empty'); +export function renderOpenBillingPanels(openBillingPanelID: string): void { + var panels = ['manage-billing-panel', 'retrieve-billing-panel', 'refund-billing-panel', 'data-billing-panel']; + var emptyPanel = getElement('billing-panel-empty'); if (emptyPanel) { - emptyPanel.classList.toggle('visible', !openPanelID); + emptyPanel.classList.toggle('visible', !openBillingPanelID); } for (var i = 0; i < panels.length; i++) { var panel = getElement(panels[i]); if (!panel) continue; - panel.classList.toggle('visible', panels[i] === openPanelID); + panel.classList.toggle('visible', panels[i] === openBillingPanelID); } - var serviceButtons = document.querySelectorAll('[data-account-service-action="open-service-panel"]'); - for (var j = 0; j < serviceButtons.length; j += 1) { - var button = serviceButtons[j]; - var row = button.closest('.service-action-row'); + var billingButtons = document.querySelectorAll('[data-account-billing-action="open-billing-panel"]'); + for (var j = 0; j < billingButtons.length; j += 1) { + var button = billingButtons[j]; + var row = button.closest('.billing-action-row'); if (!row) continue; - row.classList.toggle('active', button.getAttribute('data-account-service-panel') === openPanelID); + row.classList.toggle('active', button.getAttribute('data-account-billing-panel') === openBillingPanelID); } } export function renderRefundPanel(refundState: RefundState, bootstrap: PortalBootstrapData): void { - var root = getElement('refund-service-root'); + var root = getElement('refund-billing-root'); if (!root) return; var refundSupportURL = (bootstrap.public_site_url || '') + '/refund.html?email=' + encodeURIComponent(refundState.emailValue || ''); root.innerHTML = '' + @@ -97,21 +97,21 @@ export function renderRefundPanel(refundState: RefundState, bootstrap: PortalBoo '
Warning: completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.
' + '
' + '' + - '' + + '' + '
' + '
' + '' + - '' + + '' + '
' + '
' + - '' + + '' + '
' + '
If this purchase is not eligible for self-serve refund, use the public support path instead: open refund support page.
' + - '
'; + '
'; } export function renderManagePanel(flowState: VerificationFlowState): void { - var root = getElement('manage-service-root'); + var root = getElement('manage-billing-root'); if (!root) return; root.innerHTML = '' + '

Manage subscriptions

' + @@ -119,27 +119,27 @@ export function renderManagePanel(flowState: VerificationFlowState): void { '
' + '
' + '' + - '' + + '' + '
' + '
' + - '' + + '' + '
' + '
' + '
' + '
' + '' + - '' + + '' + '
' + '
' + - '' + + '' + '
' + - '
Need a new code? Send again
' + + '
Need a new code? Send again
' + '
' + - '
'; + '
'; } export function renderRetrievePanel(flowState: VerificationFlowState): void { - var root = getElement('retrieve-service-root'); + var root = getElement('retrieve-billing-root'); if (!root) return; var result = flowState.result as { invoice_url?: string; @@ -156,26 +156,26 @@ export function renderRetrievePanel(flowState: VerificationFlowState): void { '
' + '
' + '' + - '' + + '' + '
' + '
' + - '' + + '' + '
' + '
' + '
' + '
' + '' + - '' + + '' + '
' + '
' + - '' + - '' + + '' + + '' + 'View Invoice' + '
' + '
Use the latest active self-hosted license for this commercial email.
' + '
' + - '
' + - '
' + + '
' + + '
' + '' + '' + '
' + @@ -195,24 +195,24 @@ export function renderExportPanel(flowState: VerificationFlowState): void { '
' + '
' + '' + - '' + + '' + '
' + '
' + - '' + + '' + '
' + '
' + '
' + '
' + '' + - '' + + '' + '
' + '
' + - '' + + '' + '
' + - '
Need a new code? Send again
' + + '
Need a new code? Send again
' + '
' + - '
' + - '
' + + '
' + + '
' + '' + '' + '
'; @@ -232,25 +232,25 @@ export function renderDeletePanel(flowState: VerificationFlowState): void { '
' + '
' + '' + - '' + + '' + '
' + '
' + - '' + + '' + '
' + '
' + '
' + '
' + '' + - '' + + '' + '
' + '
' + '' + 'I understand this permanently deletes my commercial data and revokes associated licenses.' + '
' + '
' + - '' + + '' + '
' + - '
Need a new code? Send again
' + + '
Need a new code? Send again
' + '
' + - '
'; + '
'; } diff --git a/internal/cloudcp/portal/frontend/src/runtime.test.ts b/internal/cloudcp/portal/frontend/src/runtime.test.ts index 63800642d..eb9290b09 100644 --- a/internal/cloudcp/portal/frontend/src/runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/runtime.test.ts @@ -36,14 +36,14 @@ describe('portal runtime', function() { expect(runtime.bootstrapDefaults.commercial_api_base_url).toBe('/api/portal/commercial'); }); - it('derives canonical email and service handoff from the portal URL', function() { + it('derives canonical email and billing handoff from the portal URL', function() { var handoff = readPortalRuntimeHandoff('https://cloud.pulserelay.pro/portal?email=buyer%40example.com&service=retrieve'); expect(handoff.email).toBe('buyer@example.com'); - expect(handoff.openPanelID).toBe('retrieve-service-panel'); + expect(handoff.openBillingPanelID).toBe('retrieve-billing-panel'); }); - it('applies email and service handoff to the initial portal store', function() { + it('applies email and billing handoff to the initial portal store', function() { var runtime = createPortalRuntime( { authenticated: false, @@ -52,13 +52,13 @@ describe('portal runtime', function() { }, { email: 'buyer@example.com', - openPanelID: 'refund-service-panel', + openBillingPanelID: 'refund-billing-panel', } ); expect(runtime.handoff.email).toBe('buyer@example.com'); expect(runtime.store.getLoginState().emailValue).toBe('buyer@example.com'); - expect(runtime.store.getServiceState().refund.emailValue).toBe('buyer@example.com'); - expect(runtime.store.getServiceState().openPanelID).toBe('refund-service-panel'); + expect(runtime.store.getBillingState().refund.emailValue).toBe('buyer@example.com'); + expect(runtime.store.getBillingState().openBillingPanelID).toBe('refund-billing-panel'); }); }); diff --git a/internal/cloudcp/portal/frontend/src/runtime.ts b/internal/cloudcp/portal/frontend/src/runtime.ts index 6c4f47fe3..df3c5de45 100644 --- a/internal/cloudcp/portal/frontend/src/runtime.ts +++ b/internal/cloudcp/portal/frontend/src/runtime.ts @@ -4,7 +4,7 @@ import type { PortalBootstrapData } from './types'; export interface PortalRuntimeHandoff { email: string; - openPanelID: string; + openBillingPanelID: string; } export interface PortalRuntime { @@ -30,16 +30,16 @@ function normalizeHandoffEmail(value: string | null): string { return String(value || '').trim(); } -function normalizeHandoffService(value: string | null): string { +function normalizeHandoffBillingPanel(value: string | null): string { switch (String(value || '').trim()) { case 'manage': - return 'manage-service-panel'; + return 'manage-billing-panel'; case 'retrieve': - return 'retrieve-service-panel'; + return 'retrieve-billing-panel'; case 'refund': - return 'refund-service-panel'; + return 'refund-billing-panel'; case 'data': - return 'data-service-panel'; + return 'data-billing-panel'; default: return ''; } @@ -50,12 +50,12 @@ export function readPortalRuntimeHandoff(locationHref: string | undefined = wind var params = new URL(locationHref).searchParams; return { email: normalizeHandoffEmail(params.get('email')), - openPanelID: normalizeHandoffService(params.get('service')), + openBillingPanelID: normalizeHandoffBillingPanel(params.get('service')), }; } catch { return { email: '', - openPanelID: '', + openBillingPanelID: '', }; } } @@ -87,17 +87,17 @@ export function createPortalRuntime( store.updateLoginState(function(loginState) { loginState.emailValue = handoff.email; }, { notify: false }); - store.updateServiceState(function(serviceState) { - serviceState.flows.manage.emailValue = handoff.email; - serviceState.flows.retrieve.emailValue = handoff.email; - serviceState.flows.export.emailValue = handoff.email; - serviceState.flows.delete.emailValue = handoff.email; - serviceState.refund.emailValue = handoff.email; + store.updateBillingState(function(billingState) { + billingState.flows.manage.emailValue = handoff.email; + billingState.flows.retrieve.emailValue = handoff.email; + billingState.flows.export.emailValue = handoff.email; + billingState.flows.delete.emailValue = handoff.email; + billingState.refund.emailValue = handoff.email; }, { notify: false }); } - if (handoff.openPanelID) { - store.updateServiceState(function(serviceState) { - serviceState.openPanelID = handoff.openPanelID; + if (handoff.openBillingPanelID) { + store.updateBillingState(function(billingState) { + billingState.openBillingPanelID = handoff.openBillingPanelID; }, { notify: false }); } return { diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index a751dfbe8..115adecde 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -199,9 +199,9 @@ describe('shell view', function() { expect(html).toContain('Workspace review and verification without control-plane changes.'); expect(html).toContain('People on this account'); expect(html).toContain('data-action="workspace-action"'); - expect(html).toContain('service-action-row'); - expect(html).toContain('service-action-button'); - expect(html).toContain('id="service-panel-empty"'); + expect(html).toContain('billing-action-row'); + expect(html).toContain('billing-action-button'); + expect(html).toContain('id="billing-panel-empty"'); expect(html).toContain('Choose the billing task'); expect(html).toContain('Billing brief'); expect(html).toContain('Hosted billing'); @@ -214,8 +214,8 @@ describe('shell view', function() { expect(html).toContain('Before you start'); expect(html).toContain('Escalate quickly'); expect(html).toContain('Identity first'); - expect(html).toContain('id="open-retrieve-service"'); - expect(html).toContain('id="data-service-panel"'); + expect(html).toContain('id="open-retrieve-billing"'); + expect(html).toContain('id="data-billing-panel"'); expect(html).toContain('>Billing<'); expect(html).toContain('>Licenses<'); expect(html).toContain('>Refunds<'); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 6895d53c4..3bbf7786e 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -123,7 +123,7 @@ function healthBadgeHTML(workspace: PortalWorkspaceSummary): string { return 'Checking'; } -function renderServiceActionRow( +function renderBillingActionRow( id: string, kicker: string, title: string, @@ -135,19 +135,19 @@ function renderServiceActionRow( ): string { var meta = highlights.join(' • '); return ( - '
' + - '
' + - '
' + - '' + kicker + '' + - '' + escapeHTML(meta) + '' + + '
' + + '
' + + '
' + + '' + kicker + '' + + '' + escapeHTML(meta) + '' + '
' + - '
' + + '
' + '

' + title + '

' + '

' + description + '

' + '
' + '
' + - '
' + - '' + + '
' + + '' + '
' + '
' ); @@ -211,8 +211,8 @@ function renderAttentionPanel(workspaces: PortalWorkspaceSummary[]): string { (suspendedCount > 0 ? '
Suspended stays parked' + escapeHTML(String(suspendedCount) + ' workspace' + (suspendedCount === 1 ? ' is' : 's are') + ' suspended and intentionally out of day-to-day use.') + '
' : '') + - '
Use Team only for changeKeep roster edits explicit instead of mixing them into normal workspace work.
' + - '
Keep billing separateUse account services only when the task is commercial, not operational.
' + + '
Use Access only for changeKeep roster edits explicit instead of mixing them into normal workspace work.
' + + '
Keep billing separateUse Billing only when the task is commercial, not operational.
' + '
' + '
' ); @@ -780,36 +780,36 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI function renderAccountAccessSection(account: PortalAccountSummary): string { var accessPolicy = - '
' + - '
' + + '
' + + '
' + '

Role rules

' + '

Invite the smallest role that still lets someone do the work they actually own on this account.

' + '
' + - '
' + - '
OwnerBilling, access control, and full account control.
' + - '
AdminWorkspace control plus billing for the account.
' + - '
TechWorkspace control without billing ownership.
' + - '
Read-onlyWorkspace review and verification without control-plane changes.
' + + '
' + + '
OwnerBilling, access control, and full account control.
' + + '
AdminWorkspace control plus billing for the account.
' + + '
TechWorkspace control without billing ownership.
' + + '
Read-onlyWorkspace review and verification without control-plane changes.
' + '
' + '
'; var reviewDesk = - '
' + - '
' + - '
' + + '
' + + '
' + + '
' + '' + '

Keep access explicit

' + '

Use the roster as a controlled access list, not a vague shared bucket.

' + '
' + - '
' + - '
' + + '
' + + '
' + 'Owners stay rare' + 'Reserve Owner for billing, access control, and full account control. Default to Admin, Tech, or Read-only first.' + '
' + - '
' + + '
' + 'Keep access narrow' + 'Use Tech for workspace control and Read-only for verification instead of handing out broader access.' + '
' + - '
' + + '
' + 'Remove stale access fast' + 'If someone no longer owns the work, remove them instead of leaving dormant access attached to the account.' + '
' + @@ -819,12 +819,12 @@ function renderAccountAccessSection(account: PortalAccountSummary): string { return ( '