mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
Align portal contracts with access and billing model
This commit is contained in:
@@ -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`
|
||||
|
||||
+6
-6
@@ -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",
|
||||
|
||||
+204
-204
File diff suppressed because it is too large
Load Diff
+303
-303
File diff suppressed because one or more lines are too long
@@ -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 =
|
||||
'<button id="toggle" data-action="toggle-add-workspace" data-account-id="acct_1">Toggle</button>' +
|
||||
'<button id="billing" data-action="open-billing" data-account-id="acct_1">Billing</button>' +
|
||||
'<button id="team" data-action="toggle-team" data-account-id="acct_1">Team</button>' +
|
||||
'<button id="team" data-action="show-access" data-account-id="acct_1">Team</button>' +
|
||||
'<button id="invite" data-action="invite-member" data-account-id="acct_1">Invite</button>' +
|
||||
'<button id="create" data-action="create-workspace" data-account-id="acct_1">Create</button>' +
|
||||
'<button id="select" data-action="select-workspace" data-account-id="acct_1" data-workspace-id="ws_1">Manage</button>' +
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =
|
||||
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><div id="workspace-management-health-acct_1"></div><div id="workspace-management-lifecycle-acct_1"></div><div id="workspace-management-created-acct_1"></div><div id="workspace-management-guidance-acct_1"></div><button id="workspace-management-action-acct_1"></button></div></div>' +
|
||||
'<div id="team-section-acct_1" class="team-section" data-actor-role="owner">' +
|
||||
'<div id="team-stats-acct_1"></div>' +
|
||||
'<table><tbody id="team-list-acct_1"></tbody></table>' +
|
||||
'<div id="access-section-acct_1" class="access-section" data-actor-role="owner">' +
|
||||
'<div id="access-stats-acct_1"></div>' +
|
||||
'<table><tbody id="access-list-acct_1"></tbody></table>' +
|
||||
'<input id="invite-email-acct_1">' +
|
||||
'<select id="invite-role-acct_1"><option value="admin">Admin</option></select>' +
|
||||
'</div>';
|
||||
@@ -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');
|
||||
|
||||
@@ -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<void>;
|
||||
toggleTeam(accountID: string): void;
|
||||
ensureTeamVisible(accountID: string): void;
|
||||
toggleAccess(accountID: string): void;
|
||||
ensureAccessVisible(accountID: string): void;
|
||||
inviteMember(accountID: string): Promise<void>;
|
||||
createWorkspace(accountID: string): Promise<void>;
|
||||
manageWorkspaceAction(accountID: string, tenantID: string, action: string, name: string): Promise<void>;
|
||||
@@ -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<void> {
|
||||
var section = getElement<HTMLElement>('team-section-' + accountID);
|
||||
var loadAccessRoster = async function(accountID: string): Promise<void> {
|
||||
var section = getElement<HTMLElement>('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<boolean> {
|
||||
var refreshAccountAccessSection = async function(accountID: string): Promise<boolean> {
|
||||
if (!await refreshOrRedirect()) {
|
||||
return false;
|
||||
}
|
||||
var section = getElement<HTMLElement>('team-section-' + accountID);
|
||||
var section = getElement<HTMLElement>('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<void> {
|
||||
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,
|
||||
|
||||
@@ -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> = {}): PortalAccountUIEntry {
|
||||
@@ -15,8 +15,8 @@ function createEntry(overrides: Partial<PortalAccountUIEntry> = {}): 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 =
|
||||
'<div id="team-section-acct_1" class="team-section" data-actor-role="owner">' +
|
||||
'<div id="team-stats-acct_1"></div>' +
|
||||
'<table><tbody id="team-list-acct_1"></tbody></table>' +
|
||||
'<div id="access-section-acct_1" class="access-section" data-actor-role="owner">' +
|
||||
'<div id="access-stats-acct_1"></div>' +
|
||||
'<table><tbody id="access-list-acct_1"></tbody></table>' +
|
||||
'</div>';
|
||||
|
||||
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 =
|
||||
'<div id="team-section-acct_1" class="team-section" data-actor-role="owner">' +
|
||||
'<div id="team-stats-acct_1"></div>' +
|
||||
'<div id="team-list-acct_1"></div>' +
|
||||
'<div id="access-section-acct_1" class="access-section" data-actor-role="owner">' +
|
||||
'<div id="access-stats-acct_1"></div>' +
|
||||
'<div id="access-list-acct_1"></div>' +
|
||||
'</div>';
|
||||
|
||||
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 =
|
||||
'<div id="add-ws-form-acct_1" class="add-workspace-form"></div><div id="ws-spinner-acct_1"></div>' +
|
||||
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><div id="workspace-management-health-acct_1"></div><div id="workspace-management-lifecycle-acct_1"></div><div id="workspace-management-created-acct_1"></div><div id="workspace-management-guidance-acct_1"></div><button id="workspace-management-action-acct_1"></button></div></div>' +
|
||||
'<div id="team-section-acct_1" class="team-section" data-actor-role="admin"><div id="team-stats-acct_1"></div><table><tbody id="team-list-acct_1"></tbody></table></div>' +
|
||||
'<div id="access-section-acct_1" class="access-section" data-actor-role="admin"><div id="access-stats-acct_1"></div><table><tbody id="access-list-acct_1"></tbody></table></div>' +
|
||||
'<div id="add-ws-form-acct_2" class="add-workspace-form"></div><div id="ws-spinner-acct_2"></div>' +
|
||||
'<div id="workspace-management-acct_2" class="workspace-management-panel"><button id="workspace-management-close-acct_2"></button><div id="workspace-management-empty-acct_2"></div><div id="workspace-management-content-acct_2" hidden><div id="workspace-management-meta-acct_2"></div><h4 id="workspace-management-title-acct_2"></h4><p id="workspace-management-summary-acct_2"></p><div id="workspace-management-health-acct_2"></div><div id="workspace-management-lifecycle-acct_2"></div><div id="workspace-management-created-acct_2"></div><div id="workspace-management-guidance-acct_2"></div><button id="workspace-management-action-acct_2"></button></div></div>' +
|
||||
'<div id="team-section-acct_2" class="team-section" data-actor-role="owner"><div id="team-stats-acct_2"></div><table><tbody id="team-list-acct_2"></tbody></table></div>';
|
||||
'<div id="access-section-acct_2" class="access-section" data-actor-role="owner"><div id="access-stats-acct_2"></div><table><tbody id="access-list-acct_2"></tbody></table></div>';
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLElement>('team-stats-' + accountID);
|
||||
function renderAccessStats(accountID: string, entry: PortalAccountUIEntry): void {
|
||||
var stats = getElement<HTMLElement>('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 =
|
||||
'<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value">Loading…</span></div>' +
|
||||
'<div class="team-stat-card"><span class="team-stat-label">Invites</span><span class="team-stat-value">Ready</span></div>';
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value">Loading…</span></div>' +
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Invites</span><span class="access-stat-value">Ready</span></div>';
|
||||
return;
|
||||
}
|
||||
if (entry.teamQuery.status === 'error') {
|
||||
if (entry.accessQuery.status === 'error') {
|
||||
stats.innerHTML =
|
||||
'<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value team-stat-error">Needs attention</span></div>' +
|
||||
'<div class="team-stat-card"><span class="team-stat-label">Fallback</span><span class="team-stat-value">Invite only</span></div>';
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value access-stat-error">Needs attention</span></div>' +
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Fallback</span><span class="access-stat-value">Invite only</span></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var members = entry.teamQuery.data;
|
||||
var members = entry.accessQuery.data;
|
||||
stats.innerHTML =
|
||||
'<div class="team-stat-card"><span class="team-stat-label">Members</span><span class="team-stat-value">' + String(members.length) + '</span></div>' +
|
||||
'<div class="team-stat-card"><span class="team-stat-label">Owners</span><span class="team-stat-value">' + String(countMembersByRole(members, 'owner')) + '</span></div>' +
|
||||
'<div class="team-stat-card"><span class="team-stat-label">Admins</span><span class="team-stat-value">' + String(countMembersByRole(members, 'admin')) + '</span></div>' +
|
||||
'<div class="team-stat-card"><span class="team-stat-label">Operators</span><span class="team-stat-value">' + String(countMembersByRole(members, 'tech') + countMembersByRole(members, 'read_only')) + '</span></div>';
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Members</span><span class="access-stat-value">' + String(members.length) + '</span></div>' +
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Owners</span><span class="access-stat-value">' + String(countMembersByRole(members, 'owner')) + '</span></div>' +
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Admins</span><span class="access-stat-value">' + String(countMembersByRole(members, 'admin')) + '</span></div>' +
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Operators</span><span class="access-stat-value">' + String(countMembersByRole(members, 'tech') + countMembersByRole(members, 'read_only')) + '</span></div>';
|
||||
}
|
||||
|
||||
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 =
|
||||
'<span>Operator</span>' +
|
||||
'<span>Role</span>' +
|
||||
@@ -343,31 +343,31 @@ export function renderAddWorkspaceSection(accountID: string, entry: PortalAccoun
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry): void {
|
||||
var section = getElement<HTMLElement>('team-section-' + accountID);
|
||||
var roster = getElement<HTMLElement>('team-list-' + accountID);
|
||||
export function renderAccessSection(accountID: string, entry: PortalAccountUIEntry): void {
|
||||
var section = getElement<HTMLElement>('access-section-' + accountID);
|
||||
var roster = getElement<HTMLElement>('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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
deleteWorkspace(accountID: string, tenantID: string): Promise<void>;
|
||||
openBilling(accountID: string): Promise<PortalBillingResponse>;
|
||||
listMembers(accountID: string): Promise<PortalTeamMember[]>;
|
||||
listMembers(accountID: string): Promise<PortalAccessMember[]>;
|
||||
inviteMember(accountID: string, body: PortalMemberInviteRequest): Promise<void>;
|
||||
updateMemberRole(accountID: string, userID: string, body: PortalMemberRoleRequest): Promise<void>;
|
||||
removeMember(accountID: string, userID: string): Promise<void>;
|
||||
@@ -172,7 +172,7 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI {
|
||||
}, 'Failed to open billing portal.');
|
||||
},
|
||||
listMembers: function(accountID: string) {
|
||||
return request<PortalTeamMember[]>(accountURL(accountID, '/members'), {}, 'Failed to load team.');
|
||||
return request<PortalAccessMember[]>(accountURL(accountID, '/members'), {}, 'Failed to load access roster.');
|
||||
},
|
||||
inviteMember: function(accountID: string, body: PortalMemberInviteRequest) {
|
||||
return request<void>(accountURL(accountID, '/members'), {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+122
-122
@@ -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<PortalServiceFlowID, VerificationFlowDefinition> = {
|
||||
var verificationFlows: Record<PortalBillingFlowID, VerificationFlowDefinition> = {
|
||||
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<HTMLInputElement>('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);
|
||||
},
|
||||
});
|
||||
+13
-13
@@ -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 =
|
||||
'<button id="open" data-account-service-action="open-service-panel" data-account-service-panel="retrieve-service-panel" data-account-service-focus="retrieve-inline-email">Open</button>' +
|
||||
'<button id="request" data-account-service-action="manage-inline-request">Request</button>' +
|
||||
'<button id="resend" data-account-service-action="data-delete-resend">Resend</button>' +
|
||||
'<button id="confirm" data-account-service-action="retrieve-inline-confirm">Confirm</button>' +
|
||||
'<button id="copy" data-account-service-action="retrieve-inline-copy">Copy</button>' +
|
||||
'<button id="refund" data-account-service-action="refund-inline-submit">Refund</button>';
|
||||
'<button id="open" data-account-billing-action="open-billing-panel" data-account-billing-panel="retrieve-billing-panel" data-account-billing-focus="retrieve-inline-email">Open</button>' +
|
||||
'<button id="request" data-account-billing-action="manage-inline-request">Request</button>' +
|
||||
'<button id="resend" data-account-billing-action="data-delete-resend">Resend</button>' +
|
||||
'<button id="confirm" data-account-billing-action="retrieve-inline-confirm">Confirm</button>' +
|
||||
'<button id="copy" data-account-billing-action="retrieve-inline-copy">Copy</button>' +
|
||||
'<button id="refund" data-account-billing-action="refund-inline-submit">Refund</button>';
|
||||
|
||||
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 =
|
||||
'<input id="retrieve-email" data-account-service-input="retrieve-email">' +
|
||||
'<input id="retrieve-email" data-account-billing-input="retrieve-email">' +
|
||||
'<input id="data-delete-confirm-check" type="checkbox">';
|
||||
|
||||
var emailInput = document.getElementById('retrieve-email') as HTMLInputElement;
|
||||
+11
-11
@@ -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);
|
||||
});
|
||||
+24
-24
@@ -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> = {}): PortalBootstrapData {
|
||||
return {
|
||||
@@ -80,25 +80,25 @@ describe('services view', function() {
|
||||
|
||||
it('toggles the visible service panel by id', function() {
|
||||
document.body.innerHTML =
|
||||
'<div id="service-panel-empty" class="service-panel visible"></div>' +
|
||||
'<article class="service-action-row"><button data-account-service-action="open-service-panel" data-account-service-panel="manage-service-panel"></button></article>' +
|
||||
'<article class="service-action-row"><button data-account-service-action="open-service-panel" data-account-service-panel="retrieve-service-panel"></button></article>' +
|
||||
'<div id="manage-service-panel" class="service-panel"></div>' +
|
||||
'<div id="retrieve-service-panel" class="service-panel"></div>' +
|
||||
'<div id="refund-service-panel" class="service-panel"></div>' +
|
||||
'<div id="data-service-panel" class="service-panel"></div>';
|
||||
'<div id="billing-panel-empty" class="billing-panel visible"></div>' +
|
||||
'<article class="billing-action-row"><button data-account-billing-action="open-billing-panel" data-account-billing-panel="manage-billing-panel"></button></article>' +
|
||||
'<article class="billing-action-row"><button data-account-billing-action="open-billing-panel" data-account-billing-panel="retrieve-billing-panel"></button></article>' +
|
||||
'<div id="manage-billing-panel" class="billing-panel"></div>' +
|
||||
'<div id="retrieve-billing-panel" class="billing-panel"></div>' +
|
||||
'<div id="refund-billing-panel" class="billing-panel"></div>' +
|
||||
'<div id="data-billing-panel" class="billing-panel"></div>';
|
||||
|
||||
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 = '<div id="retrieve-service-root"></div>';
|
||||
document.body.innerHTML = '<div id="retrieve-billing-root"></div>';
|
||||
|
||||
renderRetrievePanel(
|
||||
createFlowState({
|
||||
@@ -130,7 +130,7 @@ describe('services view', function() {
|
||||
|
||||
it('renders refund and delete panels from owned state', function() {
|
||||
document.body.innerHTML =
|
||||
'<div id="refund-service-root"></div>' +
|
||||
'<div id="refund-billing-root"></div>' +
|
||||
'<div id="data-delete-root"></div>';
|
||||
|
||||
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 =
|
||||
'<div id="manage-service-root"></div>' +
|
||||
'<div id="manage-inline-status" class="service-status"></div>';
|
||||
'<div id="manage-billing-root"></div>' +
|
||||
'<div id="manage-inline-status" class="billing-status"></div>';
|
||||
|
||||
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');
|
||||
+47
-47
@@ -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<HTMLElement>('[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<HTMLElement>('[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
|
||||
'<div class="warning"><strong>Warning:</strong> completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="refund-inline-email">Email address</label>' +
|
||||
'<input type="email" id="refund-inline-email" value="' + escapeAttribute(refundState.emailValue || '') + '" autocomplete="email" data-account-service-input="refund-email">' +
|
||||
'<input type="email" id="refund-inline-email" value="' + escapeAttribute(refundState.emailValue || '') + '" autocomplete="email" data-account-billing-input="refund-email">' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="refund-inline-token">License key</label>' +
|
||||
'<input type="text" id="refund-inline-token" value="' + escapeAttribute(refundState.tokenValue || '') + '" placeholder="pulse_xxxxx" data-account-service-input="refund-token">' +
|
||||
'<input type="text" id="refund-inline-token" value="' + escapeAttribute(refundState.tokenValue || '') + '" placeholder="pulse_xxxxx" data-account-billing-input="refund-token">' +
|
||||
'</div>' +
|
||||
'<div class="form-actions">' +
|
||||
'<button class="btn-danger" type="button" id="refund-inline-submit" data-account-service-action="refund-inline-submit">Process Refund</button>' +
|
||||
'<button class="btn-danger" type="button" id="refund-inline-submit" data-account-billing-action="refund-inline-submit">Process Refund</button>' +
|
||||
'</div>' +
|
||||
'<div class="helper-text">If this purchase is not eligible for self-serve refund, use the public support path instead: <a href="' + escapeAttribute(refundSupportURL) + '">open refund support page</a>.</div>' +
|
||||
'<div class="service-status" id="refund-inline-status"></div>';
|
||||
'<div class="billing-status" id="refund-inline-status"></div>';
|
||||
}
|
||||
|
||||
export function renderManagePanel(flowState: VerificationFlowState): void {
|
||||
var root = getElement('manage-service-root');
|
||||
var root = getElement('manage-billing-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = '' +
|
||||
'<h3>Manage subscriptions</h3>' +
|
||||
@@ -119,27 +119,27 @@ export function renderManagePanel(flowState: VerificationFlowState): void {
|
||||
'<div id="manage-inline-step1">' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="manage-inline-email">Email address</label>' +
|
||||
'<input type="email" id="manage-inline-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="manage-email">' +
|
||||
'<input type="email" id="manage-inline-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-billing-input="manage-email">' +
|
||||
'</div>' +
|
||||
'<div class="form-actions">' +
|
||||
'<button class="btn-primary" type="button" id="manage-inline-request" data-account-service-action="manage-inline-request">Send Verification Code</button>' +
|
||||
'<button class="btn-primary" type="button" id="manage-inline-request" data-account-billing-action="manage-inline-request">Send Verification Code</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="manage-inline-step2"' + (flowState.step2Visible ? '' : ' hidden') + '>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="manage-inline-code">Verification code</label>' +
|
||||
'<input type="text" id="manage-inline-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="manage-code">' +
|
||||
'<input type="text" id="manage-inline-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-billing-input="manage-code">' +
|
||||
'</div>' +
|
||||
'<div class="form-actions">' +
|
||||
'<button class="btn-primary" type="button" id="manage-inline-confirm" data-account-service-action="manage-inline-confirm">Open Customer Portal</button>' +
|
||||
'<button class="btn-primary" type="button" id="manage-inline-confirm" data-account-billing-action="manage-inline-confirm">Open Customer Portal</button>' +
|
||||
'</div>' +
|
||||
'<div class="helper-text">Need a new code? <a href="#" id="manage-inline-resend" data-account-service-action="manage-inline-resend">Send again</a></div>' +
|
||||
'<div class="helper-text">Need a new code? <a href="#" id="manage-inline-resend" data-account-billing-action="manage-inline-resend">Send again</a></div>' +
|
||||
'</div>' +
|
||||
'<div class="service-status" id="manage-inline-status"></div>';
|
||||
'<div class="billing-status" id="manage-inline-status"></div>';
|
||||
}
|
||||
|
||||
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 {
|
||||
'<div id="retrieve-inline-step1">' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="retrieve-inline-email">Email address</label>' +
|
||||
'<input type="email" id="retrieve-inline-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="retrieve-email">' +
|
||||
'<input type="email" id="retrieve-inline-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-billing-input="retrieve-email">' +
|
||||
'</div>' +
|
||||
'<div class="form-actions">' +
|
||||
'<button class="btn-primary" type="button" id="retrieve-inline-request" data-account-service-action="retrieve-inline-request">Send Verification Code</button>' +
|
||||
'<button class="btn-primary" type="button" id="retrieve-inline-request" data-account-billing-action="retrieve-inline-request">Send Verification Code</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="retrieve-inline-step2"' + (flowState.step2Visible ? '' : ' hidden') + '>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="retrieve-inline-code">Verification code</label>' +
|
||||
'<input type="text" id="retrieve-inline-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="retrieve-code">' +
|
||||
'<input type="text" id="retrieve-inline-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-billing-input="retrieve-code">' +
|
||||
'</div>' +
|
||||
'<div class="form-actions">' +
|
||||
'<button class="btn-primary" type="button" id="retrieve-inline-confirm" data-account-service-action="retrieve-inline-confirm">Show License</button>' +
|
||||
'<button class="btn-secondary" type="button" id="retrieve-inline-copy" data-account-service-action="retrieve-inline-copy"' + (result ? '' : ' hidden') + '>Copy License Key</button>' +
|
||||
'<button class="btn-primary" type="button" id="retrieve-inline-confirm" data-account-billing-action="retrieve-inline-confirm">Show License</button>' +
|
||||
'<button class="btn-secondary" type="button" id="retrieve-inline-copy" data-account-billing-action="retrieve-inline-copy"' + (result ? '' : ' hidden') + '>Copy License Key</button>' +
|
||||
'<a class="btn-secondary" id="retrieve-inline-invoice" href="' + escapeAttribute(invoiceURL) + '" target="_blank" rel="noopener"' + (result && result.invoice_url ? '' : ' hidden') + '>View Invoice</a>' +
|
||||
'</div>' +
|
||||
'<div class="helper-text">Use the latest active self-hosted license for this commercial email.</div>' +
|
||||
'</div>' +
|
||||
'<div class="service-status" id="retrieve-inline-status"></div>' +
|
||||
'<div id="retrieve-inline-result" class="service-result"' + (result ? '' : ' hidden') + '>' +
|
||||
'<div class="billing-status" id="retrieve-inline-status"></div>' +
|
||||
'<div id="retrieve-inline-result" class="billing-result"' + (result ? '' : ' hidden') + '>' +
|
||||
'<label for="retrieve-inline-token">License key</label>' +
|
||||
'<textarea id="retrieve-inline-token" readonly>' + escapeText(result ? result.token : '') + '</textarea>' +
|
||||
'<div class="result-grid">' +
|
||||
@@ -195,24 +195,24 @@ export function renderExportPanel(flowState: VerificationFlowState): void {
|
||||
'<div id="data-export-step1">' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="data-export-email">Email address</label>' +
|
||||
'<input type="email" id="data-export-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="data-export-email">' +
|
||||
'<input type="email" id="data-export-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-billing-input="data-export-email">' +
|
||||
'</div>' +
|
||||
'<div class="form-actions">' +
|
||||
'<button class="btn-primary" type="button" id="data-export-request" data-account-service-action="data-export-request">Send Verification Code</button>' +
|
||||
'<button class="btn-primary" type="button" id="data-export-request" data-account-billing-action="data-export-request">Send Verification Code</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="data-export-step2"' + (flowState.step2Visible ? '' : ' hidden') + '>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="data-export-code">Verification code</label>' +
|
||||
'<input type="text" id="data-export-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="data-export-code">' +
|
||||
'<input type="text" id="data-export-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-billing-input="data-export-code">' +
|
||||
'</div>' +
|
||||
'<div class="form-actions">' +
|
||||
'<button class="btn-primary" type="button" id="data-export-confirm" data-account-service-action="data-export-confirm">Export My Data</button>' +
|
||||
'<button class="btn-primary" type="button" id="data-export-confirm" data-account-billing-action="data-export-confirm">Export My Data</button>' +
|
||||
'</div>' +
|
||||
'<div class="helper-text">Need a new code? <a href="#" id="data-export-resend" data-account-service-action="data-export-resend">Send again</a></div>' +
|
||||
'<div class="helper-text">Need a new code? <a href="#" id="data-export-resend" data-account-billing-action="data-export-resend">Send again</a></div>' +
|
||||
'</div>' +
|
||||
'<div class="service-status" id="data-export-status"></div>' +
|
||||
'<div id="data-export-result" class="service-result"' + (flowState.result ? '' : ' hidden') + '>' +
|
||||
'<div class="billing-status" id="data-export-status"></div>' +
|
||||
'<div id="data-export-result" class="billing-result"' + (flowState.result ? '' : ' hidden') + '>' +
|
||||
'<label for="data-export-payload">Export payload</label>' +
|
||||
'<textarea id="data-export-payload" readonly>' + escapeText(flowState.result ? JSON.stringify(flowState.result, null, 2) : '') + '</textarea>' +
|
||||
'</div>';
|
||||
@@ -232,25 +232,25 @@ export function renderDeletePanel(flowState: VerificationFlowState): void {
|
||||
'<div id="data-delete-step1">' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="data-delete-email">Email address</label>' +
|
||||
'<input type="email" id="data-delete-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-service-input="data-delete-email">' +
|
||||
'<input type="email" id="data-delete-email" value="' + escapeAttribute(flowState.emailValue || '') + '" autocomplete="email" data-account-billing-input="data-delete-email">' +
|
||||
'</div>' +
|
||||
'<div class="form-actions">' +
|
||||
'<button class="btn-danger" type="button" id="data-delete-request" data-account-service-action="data-delete-request">Send Verification Code</button>' +
|
||||
'<button class="btn-danger" type="button" id="data-delete-request" data-account-billing-action="data-delete-request">Send Verification Code</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="data-delete-step2"' + (flowState.step2Visible ? '' : ' hidden') + '>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="data-delete-code">Verification code</label>' +
|
||||
'<input type="text" id="data-delete-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-service-input="data-delete-code">' +
|
||||
'<input type="text" id="data-delete-code" value="' + escapeAttribute(flowState.codeValue || '') + '" inputmode="numeric" pattern="[0-9]{6}" placeholder="123456" data-account-billing-input="data-delete-code">' +
|
||||
'</div>' +
|
||||
'<div class="checkbox-row">' +
|
||||
'<input type="checkbox" id="data-delete-confirm-check"' + (flowState.checkboxChecked ? ' checked' : '') + '>' +
|
||||
'<span>I understand this permanently deletes my commercial data and revokes associated licenses.</span>' +
|
||||
'</div>' +
|
||||
'<div class="form-actions">' +
|
||||
'<button class="btn-danger" type="button" id="data-delete-confirm" data-account-service-action="data-delete-confirm">Delete My Data</button>' +
|
||||
'<button class="btn-danger" type="button" id="data-delete-confirm" data-account-billing-action="data-delete-confirm">Delete My Data</button>' +
|
||||
'</div>' +
|
||||
'<div class="helper-text">Need a new code? <a href="#" id="data-delete-resend" data-account-service-action="data-delete-resend">Send again</a></div>' +
|
||||
'<div class="helper-text">Need a new code? <a href="#" id="data-delete-resend" data-account-billing-action="data-delete-resend">Send again</a></div>' +
|
||||
'</div>' +
|
||||
'<div class="service-status" id="data-delete-status"></div>';
|
||||
'<div class="billing-status" id="data-delete-status"></div>';
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<');
|
||||
|
||||
@@ -123,7 +123,7 @@ function healthBadgeHTML(workspace: PortalWorkspaceSummary): string {
|
||||
return '<span class="badge badge-checking">Checking</span>';
|
||||
}
|
||||
|
||||
function renderServiceActionRow(
|
||||
function renderBillingActionRow(
|
||||
id: string,
|
||||
kicker: string,
|
||||
title: string,
|
||||
@@ -135,19 +135,19 @@ function renderServiceActionRow(
|
||||
): string {
|
||||
var meta = highlights.join(' • ');
|
||||
return (
|
||||
'<article class="service-action-row">' +
|
||||
'<div class="service-action-main">' +
|
||||
'<div class="service-action-tags service-action-tags-tight">' +
|
||||
'<span class="service-card-kicker">' + kicker + '</span>' +
|
||||
'<span class="service-action-meta-chip">' + escapeHTML(meta) + '</span>' +
|
||||
'<article class="billing-action-row">' +
|
||||
'<div class="billing-action-main">' +
|
||||
'<div class="billing-action-tags billing-action-tags-tight">' +
|
||||
'<span class="billing-card-kicker">' + kicker + '</span>' +
|
||||
'<span class="billing-action-meta-chip">' + escapeHTML(meta) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="service-action-copy">' +
|
||||
'<div class="billing-action-copy">' +
|
||||
'<h3>' + title + '</h3>' +
|
||||
'<p>' + description + '</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="service-action-cta">' +
|
||||
'<button class="btn-secondary service-action-button" type="button" id="' + id + '" data-account-service-action="open-service-panel" data-account-service-panel="' + panelID + '" data-account-service-focus="' + focusID + '" data-shell-target="billing">' + escapeHTML(actionLabel) + '</button>' +
|
||||
'<div class="billing-action-cta">' +
|
||||
'<button class="btn-secondary billing-action-button" type="button" id="' + id + '" data-account-billing-action="open-billing-panel" data-account-billing-panel="' + panelID + '" data-account-billing-focus="' + focusID + '" data-shell-target="billing">' + escapeHTML(actionLabel) + '</button>' +
|
||||
'</div>' +
|
||||
'</article>'
|
||||
);
|
||||
@@ -211,8 +211,8 @@ function renderAttentionPanel(workspaces: PortalWorkspaceSummary[]): string {
|
||||
(suspendedCount > 0
|
||||
? '<div class="overview-stable-item"><strong>Suspended stays parked</strong><span>' + escapeHTML(String(suspendedCount) + ' workspace' + (suspendedCount === 1 ? ' is' : 's are') + ' suspended and intentionally out of day-to-day use.') + '</span></div>'
|
||||
: '') +
|
||||
'<div class="overview-stable-item"><strong>Use Team only for change</strong><span>Keep roster edits explicit instead of mixing them into normal workspace work.</span></div>' +
|
||||
'<div class="overview-stable-item"><strong>Keep billing separate</strong><span>Use account services only when the task is commercial, not operational.</span></div>' +
|
||||
'<div class="overview-stable-item"><strong>Use Access only for change</strong><span>Keep roster edits explicit instead of mixing them into normal workspace work.</span></div>' +
|
||||
'<div class="overview-stable-item"><strong>Keep billing separate</strong><span>Use Billing only when the task is commercial, not operational.</span></div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
@@ -780,36 +780,36 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI
|
||||
|
||||
function renderAccountAccessSection(account: PortalAccountSummary): string {
|
||||
var accessPolicy =
|
||||
'<div class="team-policy-panel">' +
|
||||
'<div class="team-panel-heading">' +
|
||||
'<div class="access-policy-panel">' +
|
||||
'<div class="access-panel-heading">' +
|
||||
'<h4>Role rules</h4>' +
|
||||
'<p>Invite the smallest role that still lets someone do the work they actually own on this account.</p>' +
|
||||
'</div>' +
|
||||
'<div class="team-policy-list">' +
|
||||
'<div class="team-policy-row"><strong>Owner</strong><span>Billing, access control, and full account control.</span></div>' +
|
||||
'<div class="team-policy-row"><strong>Admin</strong><span>Workspace control plus billing for the account.</span></div>' +
|
||||
'<div class="team-policy-row"><strong>Tech</strong><span>Workspace control without billing ownership.</span></div>' +
|
||||
'<div class="team-policy-row"><strong>Read-only</strong><span>Workspace review and verification without control-plane changes.</span></div>' +
|
||||
'<div class="access-policy-list">' +
|
||||
'<div class="access-policy-row"><strong>Owner</strong><span>Billing, access control, and full account control.</span></div>' +
|
||||
'<div class="access-policy-row"><strong>Admin</strong><span>Workspace control plus billing for the account.</span></div>' +
|
||||
'<div class="access-policy-row"><strong>Tech</strong><span>Workspace control without billing ownership.</span></div>' +
|
||||
'<div class="access-policy-row"><strong>Read-only</strong><span>Workspace review and verification without control-plane changes.</span></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
var reviewDesk =
|
||||
'<div class="team-review-shell">' +
|
||||
'<div class="team-review-strip">' +
|
||||
'<div class="team-panel-heading team-panel-heading-tight">' +
|
||||
'<div class="access-review-shell">' +
|
||||
'<div class="access-review-strip">' +
|
||||
'<div class="access-panel-heading access-panel-heading-tight">' +
|
||||
'<div class="account-panel-kicker">Access review</div>' +
|
||||
'<h4>Keep access explicit</h4>' +
|
||||
'<p>Use the roster as a controlled access list, not a vague shared bucket.</p>' +
|
||||
'</div>' +
|
||||
'<div class="team-review-grid">' +
|
||||
'<div class="team-review-card">' +
|
||||
'<div class="access-review-grid">' +
|
||||
'<div class="access-review-card">' +
|
||||
'<strong>Owners stay rare</strong>' +
|
||||
'<span>Reserve Owner for billing, access control, and full account control. Default to Admin, Tech, or Read-only first.</span>' +
|
||||
'</div>' +
|
||||
'<div class="team-review-card">' +
|
||||
'<div class="access-review-card">' +
|
||||
'<strong>Keep access narrow</strong>' +
|
||||
'<span>Use Tech for workspace control and Read-only for verification instead of handing out broader access.</span>' +
|
||||
'</div>' +
|
||||
'<div class="team-review-card">' +
|
||||
'<div class="access-review-card">' +
|
||||
'<strong>Remove stale access fast</strong>' +
|
||||
'<span>If someone no longer owns the work, remove them instead of leaving dormant access attached to the account.</span>' +
|
||||
'</div>' +
|
||||
@@ -819,12 +819,12 @@ function renderAccountAccessSection(account: PortalAccountSummary): string {
|
||||
|
||||
return (
|
||||
'<section class="account-content-panel account-content-panel-access">' +
|
||||
'<section class="team-management-panel team-section team-section-shell" id="team-section-' +
|
||||
'<section class="access-management-panel access-section access-section-shell" id="access-section-' +
|
||||
escapeAttr(account.id) +
|
||||
'" data-actor-role="' +
|
||||
escapeAttr(account.role) +
|
||||
'">' +
|
||||
'<div class="team-management-header">' +
|
||||
'<div class="access-management-header">' +
|
||||
'<div>' +
|
||||
'<div class="account-panel-kicker">Access</div>' +
|
||||
'<h3>People and roles</h3>' +
|
||||
@@ -838,38 +838,38 @@ function renderAccountAccessSection(account: PortalAccountSummary): string {
|
||||
'</div>' +
|
||||
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="workspaces">Back to workspaces</button>' +
|
||||
'</div>' +
|
||||
'<div class="team-management-stats" id="team-stats-' +
|
||||
'<div class="access-management-stats" id="access-stats-' +
|
||||
escapeAttr(account.id) +
|
||||
'"></div>' +
|
||||
'<div class="team-management-grid">' +
|
||||
'<div class="team-roster-column">' +
|
||||
'<div class="team-roster">' +
|
||||
'<div class="team-panel-heading">' +
|
||||
'<div class="access-management-grid">' +
|
||||
'<div class="access-roster-column">' +
|
||||
'<div class="access-roster">' +
|
||||
'<div class="access-panel-heading">' +
|
||||
'<h4>People on this account</h4>' +
|
||||
'<p>Every person here can reach this hosted account. Keep the list small and the role choice explicit.</p>' +
|
||||
'</div>' +
|
||||
'<div class="team-roster-list" id="team-list-' +
|
||||
'<div class="access-roster-list" id="access-list-' +
|
||||
escapeAttr(account.id) +
|
||||
'">' +
|
||||
'<div class="team-list-message">Loading…</div>' +
|
||||
'<div class="access-list-message">Loading…</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
reviewDesk +
|
||||
'</div>' +
|
||||
'<div class="team-side-column">' +
|
||||
'<div class="team-operations-panel">' +
|
||||
'<div class="team-panel-heading team-panel-heading-tight">' +
|
||||
'<div class="access-side-column">' +
|
||||
'<div class="access-operations-panel">' +
|
||||
'<div class="access-panel-heading access-panel-heading-tight">' +
|
||||
'<div class="account-panel-kicker">Access controls</div>' +
|
||||
'<h4>Invite, role, remove</h4>' +
|
||||
'<p>Use this column when you need to add someone, tighten a role, or remove access that no longer belongs here.</p>' +
|
||||
'</div>' +
|
||||
'<div class="team-operations-grid">' +
|
||||
'<div class="team-invite-panel">' +
|
||||
'<div class="team-panel-heading">' +
|
||||
'<div class="access-operations-grid">' +
|
||||
'<div class="access-invite-panel">' +
|
||||
'<div class="access-panel-heading">' +
|
||||
'<h4>Invite someone new</h4>' +
|
||||
'<p>Add a person with the minimum role they need for this account.</p>' +
|
||||
'</div>' +
|
||||
'<div class="team-invite">' +
|
||||
'<div class="access-invite">' +
|
||||
'<div><label for="invite-email-' +
|
||||
escapeAttr(account.id) +
|
||||
'">Email</label><input type="email" id="invite-email-' +
|
||||
@@ -1043,8 +1043,8 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
|
||||
'<section class="portal-content-panel portal-content-panel-overview">' +
|
||||
'<div id="accounts-root">' + hostedContent + '</div>' +
|
||||
'</section>' +
|
||||
'<section class="portal-content-panel portal-content-panel-billing service-section" id="billing-section">' +
|
||||
'<div class="service-header billing-header">' +
|
||||
'<section class="portal-content-panel portal-content-panel-billing billing-section" id="billing-section">' +
|
||||
'<div class="billing-header">' +
|
||||
'<div>' +
|
||||
'<div class="account-panel-kicker">Billing</div>' +
|
||||
'<h2>Billing</h2>' +
|
||||
@@ -1053,84 +1053,84 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
|
||||
'Self-hosted tools',
|
||||
]) +
|
||||
'</div>' +
|
||||
'<div class="service-note">' + billingNote + '</div>' +
|
||||
'<div class="billing-note">' + billingNote + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="billing-overview-grid">' + renderHostedBillingCards(accounts) + '</div>' +
|
||||
'<div class="service-shell">' +
|
||||
'<aside class="service-shell-sidebar">' +
|
||||
'<div class="service-shell-sidebar-head">' +
|
||||
'<div class="billing-shell">' +
|
||||
'<aside class="billing-shell-sidebar">' +
|
||||
'<div class="billing-shell-sidebar-head">' +
|
||||
'<div class="account-panel-kicker">Self-hosted billing</div>' +
|
||||
'<h3>Self-hosted tools</h3>' +
|
||||
'<p>Use these only when the job is a self-hosted purchase, license, refund, or privacy request.</p>' +
|
||||
'</div>' +
|
||||
'<div class="service-action-list">' +
|
||||
renderServiceActionRow('open-manage-service', 'Self-hosted billing', 'Manage subscriptions', 'Billing', 'Open Stripe for self-hosted plan, invoice, and payment changes.', 'manage-service-panel', 'manage-inline-email', ['Plan changes', 'Invoices']) +
|
||||
renderServiceActionRow('open-retrieve-service', 'Licenses', 'Retrieve licenses', 'Licenses', 'Recover the latest active self-hosted license and invoice link.', 'retrieve-service-panel', 'retrieve-inline-email', ['Latest active license', 'Invoice lookup']) +
|
||||
renderServiceActionRow('open-refund-service', 'Refunds', 'Refund requests', 'Refunds', 'Request a self-serve refund when the purchase is still eligible.', 'refund-service-panel', 'refund-inline-email', ['Eligibility check', 'Revocation']) +
|
||||
renderServiceActionRow('open-data-service', 'Privacy', 'Data and privacy', 'Privacy', 'Request export or deletion for commercial account data.', 'data-service-panel', 'data-export-email', ['Export', 'Deletion']) +
|
||||
'<div class="billing-action-list">' +
|
||||
renderBillingActionRow('open-manage-billing', 'Self-hosted billing', 'Manage subscriptions', 'Billing', 'Open Stripe for self-hosted plan, invoice, and payment changes.', 'manage-billing-panel', 'manage-inline-email', ['Plan changes', 'Invoices']) +
|
||||
renderBillingActionRow('open-retrieve-billing', 'Licenses', 'Retrieve licenses', 'Licenses', 'Recover the latest active self-hosted license and invoice link.', 'retrieve-billing-panel', 'retrieve-inline-email', ['Latest active license', 'Invoice lookup']) +
|
||||
renderBillingActionRow('open-refund-billing', 'Refunds', 'Refund requests', 'Refunds', 'Request a self-serve refund when the purchase is still eligible.', 'refund-billing-panel', 'refund-inline-email', ['Eligibility check', 'Revocation']) +
|
||||
renderBillingActionRow('open-data-billing', 'Privacy', 'Data and privacy', 'Privacy', 'Request export or deletion for commercial account data.', 'data-billing-panel', 'data-export-email', ['Export', 'Deletion']) +
|
||||
'</div>' +
|
||||
'<div class="service-inline-support">' +
|
||||
'<div class="billing-inline-support">' +
|
||||
'<div class="account-panel-kicker">Escalation</div>' +
|
||||
'<h4>Keep the billing request contained</h4>' +
|
||||
'<p>Use Support only when a hosted billing or self-hosted billing request cannot complete cleanly from this section.</p>' +
|
||||
'<div class="service-inline-support-points">' +
|
||||
'<div class="service-inline-support-point"><strong>Hosted billing first when present</strong><span>Use the hosted billing cards above before you touch the self-hosted tools.</span></div>' +
|
||||
'<div class="service-inline-support-point"><strong>Escalate with context</strong><span>Bring the billing tool or hosted billing action and the exact failed step if you need support.</span></div>' +
|
||||
'<div class="billing-inline-support-points">' +
|
||||
'<div class="billing-inline-support-point"><strong>Hosted billing first when present</strong><span>Use the hosted billing cards above before you touch the self-hosted tools.</span></div>' +
|
||||
'<div class="billing-inline-support-point"><strong>Escalate with context</strong><span>Bring the billing tool or hosted billing action and the exact failed step if you need support.</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="service-inline-support-actions">' +
|
||||
'<div class="billing-inline-support-actions">' +
|
||||
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="support">Open support</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</aside>' +
|
||||
'<div class="service-shell-main">' +
|
||||
'<div class="service-detail-shell">' +
|
||||
'<div class="service-panel service-panel-empty visible" id="service-panel-empty">' +
|
||||
'<div class="billing-shell-main">' +
|
||||
'<div class="billing-detail-shell">' +
|
||||
'<div class="billing-panel billing-panel-empty visible" id="billing-panel-empty">' +
|
||||
'<div class="account-panel-kicker">Billing brief</div>' +
|
||||
'<h3>Choose the billing task</h3>' +
|
||||
'<p>Pick one self-hosted billing request at a time. Verification happens first so billing, license, refund, or privacy work stays contained.</p>' +
|
||||
'<div class="service-empty-shell">' +
|
||||
'<div class="service-empty-primary">' +
|
||||
'<div class="service-empty-section service-empty-section-compact">' +
|
||||
'<div class="service-empty-column-title">What each tool does</div>' +
|
||||
'<div class="service-empty-flow-list">' +
|
||||
'<div class="service-empty-flow"><strong>Billing</strong><span>Stripe customer portal access for self-hosted invoices, payment methods, and plan changes.</span></div>' +
|
||||
'<div class="service-empty-flow"><strong>Licenses</strong><span>Recover the latest active self-hosted license and the matching invoice link.</span></div>' +
|
||||
'<div class="service-empty-flow"><strong>Refunds</strong><span>Check eligibility before revoking active commercial access.</span></div>' +
|
||||
'<div class="service-empty-flow"><strong>Privacy</strong><span>Request export or deletion without leaving Pulse Account.</span></div>' +
|
||||
'<div class="billing-empty-shell">' +
|
||||
'<div class="billing-empty-primary">' +
|
||||
'<div class="billing-empty-section billing-empty-section-compact">' +
|
||||
'<div class="billing-empty-column-title">What each tool does</div>' +
|
||||
'<div class="billing-empty-flow-list">' +
|
||||
'<div class="billing-empty-flow"><strong>Billing</strong><span>Stripe customer portal access for self-hosted invoices, payment methods, and plan changes.</span></div>' +
|
||||
'<div class="billing-empty-flow"><strong>Licenses</strong><span>Recover the latest active self-hosted license and the matching invoice link.</span></div>' +
|
||||
'<div class="billing-empty-flow"><strong>Refunds</strong><span>Check eligibility before revoking active commercial access.</span></div>' +
|
||||
'<div class="billing-empty-flow"><strong>Privacy</strong><span>Request export or deletion without leaving Pulse Account.</span></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="service-empty-section service-empty-section-compact">' +
|
||||
'<div class="service-empty-column-title">Before you start</div>' +
|
||||
'<div class="service-empty-points service-empty-points-stack">' +
|
||||
'<div class="service-empty-point"><strong>One request</strong><span>Keep a single billing request active instead of bouncing across sections.</span></div>' +
|
||||
'<div class="service-empty-point"><strong>Identity first</strong><span>Verification happens before any sensitive account action opens.</span></div>' +
|
||||
'<div class="service-empty-point"><strong>Stay focused</strong><span>Use the hosted billing cards above when the request is tied to a hosted workspace account.</span></div>' +
|
||||
'<div class="billing-empty-section billing-empty-section-compact">' +
|
||||
'<div class="billing-empty-column-title">Before you start</div>' +
|
||||
'<div class="billing-empty-points billing-empty-points-stack">' +
|
||||
'<div class="billing-empty-point"><strong>One request</strong><span>Keep a single billing request active instead of bouncing across sections.</span></div>' +
|
||||
'<div class="billing-empty-point"><strong>Identity first</strong><span>Verification happens before any sensitive account action opens.</span></div>' +
|
||||
'<div class="billing-empty-point"><strong>Stay focused</strong><span>Use the hosted billing cards above when the request is tied to a hosted workspace account.</span></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="service-empty-side">' +
|
||||
'<div class="service-empty-section service-empty-section-support">' +
|
||||
'<div class="service-empty-column-title">Escalation</div>' +
|
||||
'<div class="service-empty-checklist">' +
|
||||
'<div class="service-empty-check"><strong>Escalate quickly</strong><span>If hosted billing or the self-hosted tools do not behave as expected, escalate from this section.</span></div>' +
|
||||
'<div class="service-empty-check"><strong>Billing packet</strong><span>Bring the billing tool, commercial email, and the exact failed action so support starts with the same request state.</span></div>' +
|
||||
'<div class="billing-empty-side">' +
|
||||
'<div class="billing-empty-section billing-empty-section-support">' +
|
||||
'<div class="billing-empty-column-title">Escalation</div>' +
|
||||
'<div class="billing-empty-checklist">' +
|
||||
'<div class="billing-empty-check"><strong>Escalate quickly</strong><span>If hosted billing or the self-hosted tools do not behave as expected, escalate from this section.</span></div>' +
|
||||
'<div class="billing-empty-check"><strong>Billing packet</strong><span>Bring the billing tool, commercial email, and the exact failed action so support starts with the same request state.</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="service-empty-support">Need help with billing, refund, privacy, or license requests? <a class="portal-support-link" href="mailto:' +
|
||||
'<div class="billing-empty-support">Need help with billing, refund, privacy, or license requests? <a class="portal-support-link" href="mailto:' +
|
||||
escapeAttr(context.bootstrap.support_email || '') +
|
||||
'">' +
|
||||
escapeHTML(context.bootstrap.support_email || '') +
|
||||
'</a></div>' +
|
||||
'<div class="service-empty-actions">' +
|
||||
'<div class="billing-empty-actions">' +
|
||||
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="support">Open support</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="service-panel" id="manage-service-panel"><div id="manage-service-root"></div></div>' +
|
||||
'<div class="service-panel" id="retrieve-service-panel"><div id="retrieve-service-root"></div></div>' +
|
||||
'<div class="service-panel" id="refund-service-panel"><div id="refund-service-root"></div></div>' +
|
||||
'<div class="service-panel" id="data-service-panel">' +
|
||||
'<div class="billing-panel" id="manage-billing-panel"><div id="manage-billing-root"></div></div>' +
|
||||
'<div class="billing-panel" id="retrieve-billing-panel"><div id="retrieve-billing-root"></div></div>' +
|
||||
'<div class="billing-panel" id="refund-billing-panel"><div id="refund-billing-root"></div></div>' +
|
||||
'<div class="billing-panel" id="data-billing-panel">' +
|
||||
'<h3>Data and privacy</h3>' +
|
||||
'<p>Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.</p>' +
|
||||
'<div class="subsection"><div id="data-export-root"></div></div>' +
|
||||
@@ -1157,11 +1157,11 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
|
||||
export function renderSignedOutPortalHTML(context: ShellViewContext): string {
|
||||
var statusHTML = '';
|
||||
if (context.loginState.request.error) {
|
||||
statusHTML = '<div class="service-status visible error">' + escapeHTML(context.loginState.request.error) + '</div>';
|
||||
statusHTML = '<div class="billing-status visible error">' + escapeHTML(context.loginState.request.error) + '</div>';
|
||||
} else if (context.loginState.success) {
|
||||
var successMessage = context.loginState.successMessage || 'If that email is registered, a magic link is on the way.';
|
||||
statusHTML =
|
||||
'<div class="service-status visible success">' +
|
||||
'<div class="billing-status visible success">' +
|
||||
escapeHTML(successMessage) +
|
||||
'<br><br><strong>Don\'t see it?</strong> <a href="#" data-portal-action="resend-magic-link">Send a new link</a>.' +
|
||||
'</div>';
|
||||
@@ -1172,8 +1172,8 @@ export function renderSignedOutPortalHTML(context: ShellViewContext): string {
|
||||
'<h1>Sign in to Pulse Account</h1>' +
|
||||
'<p>Use one commercial email address to get into workspaces, MSP access, billing, license recovery, refunds, and privacy actions.</p>' +
|
||||
'</section>' +
|
||||
'<section class="service-section service-section-auth">' +
|
||||
'<div class="service-panel visible auth-panel">' +
|
||||
'<section class="billing-section billing-section-auth">' +
|
||||
'<div class="billing-panel visible auth-panel">' +
|
||||
'<h3>Sign in</h3>' +
|
||||
'<p>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.</p>' +
|
||||
'<div class="form-group">' +
|
||||
|
||||
@@ -3,14 +3,14 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
clearFlowStatus,
|
||||
createPortalLoginState,
|
||||
createPortalServiceState,
|
||||
createPortalBillingState,
|
||||
resetVerificationFlowState,
|
||||
setFlowStatus,
|
||||
syncLoginStateBootstrapEmail,
|
||||
syncServiceStateBootstrapEmail,
|
||||
toggleServicePanelState,
|
||||
syncBillingStateBootstrapEmail,
|
||||
toggleBillingPanelState,
|
||||
updateDeleteConfirmation,
|
||||
updateServiceInputValue,
|
||||
updateBillingInputValue,
|
||||
} from './state';
|
||||
|
||||
describe('portal state', function() {
|
||||
@@ -25,63 +25,63 @@ describe('portal state', function() {
|
||||
});
|
||||
|
||||
it('creates service state with independent flows and bootstrap email defaults', function() {
|
||||
var serviceState = createPortalServiceState();
|
||||
syncServiceStateBootstrapEmail(serviceState, 'owner@example.com');
|
||||
var billingState = createPortalBillingState();
|
||||
syncBillingStateBootstrapEmail(billingState, 'owner@example.com');
|
||||
|
||||
expect(serviceState.flows.manage.emailValue).toBe('owner@example.com');
|
||||
expect(serviceState.flows.retrieve.emailValue).toBe('owner@example.com');
|
||||
expect(serviceState.refund.emailValue).toBe('owner@example.com');
|
||||
expect(serviceState.flows.manage.request.pending).toBe(false);
|
||||
expect(serviceState.flows.manage.confirm.pending).toBe(false);
|
||||
expect(serviceState.refund.submit.pending).toBe(false);
|
||||
expect(billingState.flows.manage.emailValue).toBe('owner@example.com');
|
||||
expect(billingState.flows.retrieve.emailValue).toBe('owner@example.com');
|
||||
expect(billingState.refund.emailValue).toBe('owner@example.com');
|
||||
expect(billingState.flows.manage.request.pending).toBe(false);
|
||||
expect(billingState.flows.manage.confirm.pending).toBe(false);
|
||||
expect(billingState.refund.submit.pending).toBe(false);
|
||||
|
||||
serviceState.flows.manage.emailValue = 'override@example.com';
|
||||
syncServiceStateBootstrapEmail(serviceState, 'owner@example.com');
|
||||
expect(serviceState.flows.manage.emailValue).toBe('override@example.com');
|
||||
billingState.flows.manage.emailValue = 'override@example.com';
|
||||
syncBillingStateBootstrapEmail(billingState, 'owner@example.com');
|
||||
expect(billingState.flows.manage.emailValue).toBe('override@example.com');
|
||||
});
|
||||
|
||||
it('toggles service panels and routes input updates by field kind', function() {
|
||||
var serviceState = createPortalServiceState();
|
||||
var billingState = createPortalBillingState();
|
||||
|
||||
toggleServicePanelState(serviceState, 'manage-service-panel');
|
||||
expect(serviceState.openPanelID).toBe('manage-service-panel');
|
||||
toggleServicePanelState(serviceState, 'manage-service-panel');
|
||||
expect(serviceState.openPanelID).toBe('');
|
||||
toggleBillingPanelState(billingState, 'manage-billing-panel');
|
||||
expect(billingState.openBillingPanelID).toBe('manage-billing-panel');
|
||||
toggleBillingPanelState(billingState, 'manage-billing-panel');
|
||||
expect(billingState.openBillingPanelID).toBe('');
|
||||
|
||||
updateServiceInputValue(serviceState, 'retrieve-email', 'buyer@example.com');
|
||||
updateServiceInputValue(serviceState, 'retrieve-code', '123456');
|
||||
updateServiceInputValue(serviceState, 'refund-token', 'pulse_token');
|
||||
updateDeleteConfirmation(serviceState, true);
|
||||
updateBillingInputValue(billingState, 'retrieve-email', 'buyer@example.com');
|
||||
updateBillingInputValue(billingState, 'retrieve-code', '123456');
|
||||
updateBillingInputValue(billingState, 'refund-token', 'pulse_token');
|
||||
updateDeleteConfirmation(billingState, true);
|
||||
|
||||
expect(serviceState.flows.retrieve.emailValue).toBe('buyer@example.com');
|
||||
expect(serviceState.flows.retrieve.codeValue).toBe('123456');
|
||||
expect(serviceState.refund.tokenValue).toBe('pulse_token');
|
||||
expect(serviceState.flows.delete.checkboxChecked).toBe(true);
|
||||
expect(billingState.flows.retrieve.emailValue).toBe('buyer@example.com');
|
||||
expect(billingState.flows.retrieve.codeValue).toBe('123456');
|
||||
expect(billingState.refund.tokenValue).toBe('pulse_token');
|
||||
expect(billingState.flows.delete.checkboxChecked).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves email while resetting verification flow state', function() {
|
||||
var serviceState = createPortalServiceState();
|
||||
serviceState.flows.export.emailValue = 'buyer@example.com';
|
||||
serviceState.flows.export.codeValue = '999999';
|
||||
serviceState.flows.export.pendingEmail = 'buyer@example.com';
|
||||
serviceState.flows.export.result = { ok: true };
|
||||
serviceState.flows.export.request.pending = true;
|
||||
serviceState.flows.export.confirm.pending = true;
|
||||
setFlowStatus(serviceState, 'export', 'done', false);
|
||||
var billingState = createPortalBillingState();
|
||||
billingState.flows.export.emailValue = 'buyer@example.com';
|
||||
billingState.flows.export.codeValue = '999999';
|
||||
billingState.flows.export.pendingEmail = 'buyer@example.com';
|
||||
billingState.flows.export.result = { ok: true };
|
||||
billingState.flows.export.request.pending = true;
|
||||
billingState.flows.export.confirm.pending = true;
|
||||
setFlowStatus(billingState, 'export', 'done', false);
|
||||
|
||||
resetVerificationFlowState(serviceState, 'export');
|
||||
resetVerificationFlowState(billingState, 'export');
|
||||
|
||||
expect(serviceState.flows.export.emailValue).toBe('buyer@example.com');
|
||||
expect(serviceState.flows.export.codeValue).toBe('');
|
||||
expect(serviceState.flows.export.pendingEmail).toBe('');
|
||||
expect(serviceState.flows.export.result).toBeNull();
|
||||
expect(serviceState.flows.export.request.pending).toBe(false);
|
||||
expect(serviceState.flows.export.confirm.pending).toBe(false);
|
||||
expect(serviceState.flows.export.status.visible).toBe(false);
|
||||
expect(billingState.flows.export.emailValue).toBe('buyer@example.com');
|
||||
expect(billingState.flows.export.codeValue).toBe('');
|
||||
expect(billingState.flows.export.pendingEmail).toBe('');
|
||||
expect(billingState.flows.export.result).toBeNull();
|
||||
expect(billingState.flows.export.request.pending).toBe(false);
|
||||
expect(billingState.flows.export.confirm.pending).toBe(false);
|
||||
expect(billingState.flows.export.status.visible).toBe(false);
|
||||
|
||||
setFlowStatus(serviceState, 'export', 'broken', true);
|
||||
expect(serviceState.flows.export.status.message).toBe('broken');
|
||||
clearFlowStatus(serviceState, 'export');
|
||||
expect(serviceState.flows.export.status.visible).toBe(false);
|
||||
setFlowStatus(billingState, 'export', 'broken', true);
|
||||
expect(billingState.flows.export.status.message).toBe('broken');
|
||||
clearFlowStatus(billingState, 'export');
|
||||
expect(billingState.flows.export.status.visible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,16 +4,16 @@ import type {
|
||||
PortalAccountUIEntry,
|
||||
PortalLoginState,
|
||||
PortalShellState,
|
||||
PortalServiceFlowID,
|
||||
PortalServiceState,
|
||||
PortalBillingFlowID,
|
||||
PortalBillingState,
|
||||
PortalQueryState,
|
||||
RefundState,
|
||||
ServiceStatus,
|
||||
PortalTeamMember,
|
||||
BillingStatus,
|
||||
PortalAccessMember,
|
||||
VerificationFlowState,
|
||||
} from './types';
|
||||
|
||||
export function emptyStatus(): ServiceStatus {
|
||||
export function emptyStatus(): BillingStatus {
|
||||
return {
|
||||
visible: false,
|
||||
message: '',
|
||||
@@ -78,16 +78,16 @@ export function ensurePortalAccountUIEntry(accountState: PortalAccountState, acc
|
||||
createWorkspace: createMutationState(),
|
||||
selectedWorkspaceID: '',
|
||||
manageWorkspace: createMutationState(),
|
||||
teamVisible: false,
|
||||
teamQuery: createQueryState<PortalTeamMember[]>([]),
|
||||
accessVisible: false,
|
||||
accessQuery: createQueryState<PortalAccessMember[]>([]),
|
||||
};
|
||||
}
|
||||
return accountState.byAccountID[accountID];
|
||||
}
|
||||
|
||||
export function createPortalServiceState(): PortalServiceState {
|
||||
export function createPortalBillingState(): PortalBillingState {
|
||||
return {
|
||||
openPanelID: '',
|
||||
openBillingPanelID: '',
|
||||
flows: {
|
||||
manage: newVerificationFlowState(),
|
||||
retrieve: newVerificationFlowState(),
|
||||
@@ -109,81 +109,81 @@ export function syncLoginStateBootstrapEmail(loginState: PortalLoginState, email
|
||||
}
|
||||
}
|
||||
|
||||
export function syncServiceStateBootstrapEmail(serviceState: PortalServiceState, email: string): void {
|
||||
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 || '';
|
||||
export function syncBillingStateBootstrapEmail(billingState: PortalBillingState, email: string): void {
|
||||
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 || '';
|
||||
}
|
||||
|
||||
export function setFlowStatus(serviceState: PortalServiceState, flowID: PortalServiceFlowID, message: string, isError: boolean): void {
|
||||
serviceState.flows[flowID].status = {
|
||||
export function setFlowStatus(billingState: PortalBillingState, flowID: PortalBillingFlowID, message: string, isError: boolean): void {
|
||||
billingState.flows[flowID].status = {
|
||||
visible: true,
|
||||
message,
|
||||
error: !!isError,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearFlowStatus(serviceState: PortalServiceState, flowID: PortalServiceFlowID): void {
|
||||
serviceState.flows[flowID].status = emptyStatus();
|
||||
export function clearFlowStatus(billingState: PortalBillingState, flowID: PortalBillingFlowID): void {
|
||||
billingState.flows[flowID].status = emptyStatus();
|
||||
}
|
||||
|
||||
export function setRefundStatus(serviceState: PortalServiceState, message: string, isError: boolean): void {
|
||||
serviceState.refund.status = {
|
||||
export function setRefundStatus(billingState: PortalBillingState, message: string, isError: boolean): void {
|
||||
billingState.refund.status = {
|
||||
visible: true,
|
||||
message,
|
||||
error: !!isError,
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleServicePanelState(serviceState: PortalServiceState, panelID: string): void {
|
||||
serviceState.openPanelID = serviceState.openPanelID === panelID ? '' : panelID;
|
||||
export function toggleBillingPanelState(billingState: PortalBillingState, panelID: string): void {
|
||||
billingState.openBillingPanelID = billingState.openBillingPanelID === panelID ? '' : panelID;
|
||||
}
|
||||
|
||||
export function resetVerificationFlowState(serviceState: PortalServiceState, flowID: PortalServiceFlowID): void {
|
||||
var previous = serviceState.flows[flowID];
|
||||
serviceState.flows[flowID] = newVerificationFlowState();
|
||||
serviceState.flows[flowID].emailValue = previous.emailValue;
|
||||
export function resetVerificationFlowState(billingState: PortalBillingState, flowID: PortalBillingFlowID): void {
|
||||
var previous = billingState.flows[flowID];
|
||||
billingState.flows[flowID] = newVerificationFlowState();
|
||||
billingState.flows[flowID].emailValue = previous.emailValue;
|
||||
}
|
||||
|
||||
export function updateServiceInputValue(serviceState: PortalServiceState, inputKind: string, value: string): void {
|
||||
export function updateBillingInputValue(billingState: PortalBillingState, inputKind: string, value: string): void {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDeleteConfirmation(serviceState: PortalServiceState, checked: boolean): void {
|
||||
serviceState.flows.delete.checkboxChecked = checked;
|
||||
export function updateDeleteConfirmation(billingState: PortalBillingState, checked: boolean): void {
|
||||
billingState.flows.delete.checkboxChecked = checked;
|
||||
}
|
||||
|
||||
@@ -78,21 +78,21 @@ describe('portal store', function() {
|
||||
it('keeps login and service state in the same owned store with selective notifications', function() {
|
||||
var store = createPortalStore(bootstrapDefaults, null);
|
||||
var loginListener = vi.fn();
|
||||
var serviceListener = vi.fn();
|
||||
var billingListener = vi.fn();
|
||||
|
||||
store.subscribeLogin(loginListener);
|
||||
store.subscribeServices(serviceListener);
|
||||
store.subscribeBilling(billingListener);
|
||||
|
||||
store.updateLoginState(function(loginState) {
|
||||
loginState.emailValue = 'typed@example.com';
|
||||
}, { notify: false });
|
||||
store.updateServiceState(function(serviceState) {
|
||||
serviceState.openPanelID = 'retrieve-service-panel';
|
||||
store.updateBillingState(function(billingState) {
|
||||
billingState.openBillingPanelID = 'retrieve-billing-panel';
|
||||
});
|
||||
|
||||
expect(store.getLoginState().emailValue).toBe('typed@example.com');
|
||||
expect(store.getServiceState().openPanelID).toBe('retrieve-service-panel');
|
||||
expect(store.getBillingState().openBillingPanelID).toBe('retrieve-billing-panel');
|
||||
expect(loginListener).not.toHaveBeenCalled();
|
||||
expect(serviceListener).toHaveBeenCalledTimes(1);
|
||||
expect(billingListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,11 @@ import {
|
||||
createPortalAccountState,
|
||||
createPortalLoginState,
|
||||
createPortalShellState,
|
||||
createPortalServiceState,
|
||||
createPortalBillingState,
|
||||
syncLoginStateBootstrapEmail,
|
||||
syncServiceStateBootstrapEmail,
|
||||
syncBillingStateBootstrapEmail,
|
||||
} from './state';
|
||||
import type { PortalAccountState, PortalBootstrapData, PortalLoginState, PortalServiceState, PortalShellSection, PortalShellState } from './types';
|
||||
import type { PortalAccountState, PortalBootstrapData, PortalLoginState, PortalBillingState, PortalShellSection, PortalShellState } from './types';
|
||||
|
||||
interface MutationOptions {
|
||||
notify?: boolean;
|
||||
@@ -17,17 +17,17 @@ export interface PortalStore {
|
||||
getAccountState(): PortalAccountState;
|
||||
getLoginState(): PortalLoginState;
|
||||
getShellState(): PortalShellState;
|
||||
getServiceState(): PortalServiceState;
|
||||
getBillingState(): PortalBillingState;
|
||||
setBootstrap(nextBootstrap: Partial<PortalBootstrapData> | PortalBootstrapData): PortalBootstrapData;
|
||||
updateAccountState(mutator: (state: PortalAccountState) => void, options?: MutationOptions): PortalAccountState;
|
||||
updateLoginState(mutator: (state: PortalLoginState) => void, options?: MutationOptions): PortalLoginState;
|
||||
setActiveShellSection(section: PortalShellSection): PortalShellState;
|
||||
updateServiceState(mutator: (state: PortalServiceState) => void, options?: MutationOptions): PortalServiceState;
|
||||
updateBillingState(mutator: (state: PortalBillingState) => void, options?: MutationOptions): PortalBillingState;
|
||||
subscribeAccount(listener: () => void): () => void;
|
||||
subscribeBootstrap(listener: () => void): () => void;
|
||||
subscribeLogin(listener: () => void): () => void;
|
||||
subscribeShell(listener: () => void): () => void;
|
||||
subscribeServices(listener: () => void): () => void;
|
||||
subscribeBilling(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
export function createAnonymousBootstrap(
|
||||
@@ -58,14 +58,14 @@ export function createPortalStore(
|
||||
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 = new Set<() => void>();
|
||||
var bootstrapSubscribers = new Set<() => void>();
|
||||
var loginSubscribers = new Set<() => void>();
|
||||
var shellSubscribers = new Set<() => void>();
|
||||
var serviceSubscribers = new Set<() => void>();
|
||||
var billingSubscribers = new Set<() => void>();
|
||||
|
||||
function notify(subscribers: Set<() => void>) {
|
||||
subscribers.forEach(function(listener) {
|
||||
@@ -86,13 +86,13 @@ export function createPortalStore(
|
||||
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;
|
||||
},
|
||||
@@ -115,12 +115,12 @@ export function createPortalStore(
|
||||
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);
|
||||
@@ -146,10 +146,10 @@ export function createPortalStore(
|
||||
shellSubscribers.delete(listener);
|
||||
};
|
||||
},
|
||||
subscribeServices: function(listener) {
|
||||
serviceSubscribers.add(listener);
|
||||
subscribeBilling: function(listener) {
|
||||
billingSubscribers.add(listener);
|
||||
return function() {
|
||||
serviceSubscribers.delete(listener);
|
||||
billingSubscribers.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ export interface PortalLoginState {
|
||||
successMessage: string;
|
||||
}
|
||||
|
||||
export interface PortalTeamMember {
|
||||
export interface PortalAccessMember {
|
||||
email: string;
|
||||
role: string;
|
||||
user_id: string;
|
||||
@@ -66,8 +66,8 @@ export interface PortalAccountUIEntry {
|
||||
createWorkspace: PortalMutationState;
|
||||
selectedWorkspaceID: string;
|
||||
manageWorkspace: PortalMutationState;
|
||||
teamVisible: boolean;
|
||||
teamQuery: PortalQueryState<PortalTeamMember[]>;
|
||||
accessVisible: boolean;
|
||||
accessQuery: PortalQueryState<PortalAccessMember[]>;
|
||||
}
|
||||
|
||||
export interface PortalAccountState {
|
||||
@@ -80,9 +80,9 @@ export interface PortalShellState {
|
||||
activeSection: PortalShellSection;
|
||||
}
|
||||
|
||||
export type PortalServiceFlowID = 'manage' | 'retrieve' | 'export' | 'delete';
|
||||
export type PortalBillingFlowID = 'manage' | 'retrieve' | 'export' | 'delete';
|
||||
|
||||
export interface ServiceStatus {
|
||||
export interface BillingStatus {
|
||||
visible: boolean;
|
||||
message: string;
|
||||
error: boolean;
|
||||
@@ -93,7 +93,7 @@ export interface VerificationFlowState {
|
||||
request: PortalMutationState;
|
||||
confirm: PortalMutationState;
|
||||
step2Visible: boolean;
|
||||
status: ServiceStatus;
|
||||
status: BillingStatus;
|
||||
result: unknown;
|
||||
emailValue: string;
|
||||
codeValue: string;
|
||||
@@ -104,11 +104,11 @@ export interface RefundState {
|
||||
emailValue: string;
|
||||
tokenValue: string;
|
||||
submit: PortalMutationState;
|
||||
status: ServiceStatus;
|
||||
status: BillingStatus;
|
||||
}
|
||||
|
||||
export interface PortalServiceState {
|
||||
openPanelID: string;
|
||||
flows: Record<PortalServiceFlowID, VerificationFlowState>;
|
||||
export interface PortalBillingState {
|
||||
openBillingPanelID: string;
|
||||
flows: Record<PortalBillingFlowID, VerificationFlowState>;
|
||||
refund: RefundState;
|
||||
}
|
||||
|
||||
@@ -668,7 +668,7 @@ func TestPortalPageTemplate_AccessManagementRendered(t *testing.T) {
|
||||
`id="portal-app-root"`,
|
||||
`if (account.can_manage) {`,
|
||||
`data-actor-role="`,
|
||||
`id="team-list-`,
|
||||
`id="access-list-`,
|
||||
`data-shell-section="access"`,
|
||||
`data-action="invite-member"`,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user