@taskforcehq/taskforce 0.3.304 → 0.3.305
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -247
- package/dist/TaskforceCore.js +165 -70
- package/dist/TaskforceCore.test.js +799 -90
- package/dist/compat/workspaceSyncCompat.js +6 -0
- package/dist/components/features/DocumentIndex.d.ts +1 -1
- package/dist/components/features/DocumentIndex.js +1 -1
- package/dist/components/features/DocumentViewer.d.ts +1 -1
- package/dist/components/features/DocumentViewer.js +32 -9
- package/dist/components/features/DocumentWorkspace.d.ts +5 -22
- package/dist/components/features/DocumentWorkspace.js +12 -155
- package/dist/components/features/DocumentWorkspace.test.js +193 -6
- package/dist/components/features/TaskSettings.js +29 -3
- package/dist/components/features/TaskSettings.test.js +42 -1
- package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
- package/dist/components/features/documentWorkspaceModel.js +57 -0
- package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
- package/dist/components/features/useDocumentWorkspaceController.js +113 -0
- package/dist/components/task/TaskForm.d.ts +8 -8
- package/dist/components/task/TaskForm.js +358 -221
- package/dist/components/task/TaskForm.test.js +456 -381
- package/dist/components/task/TaskKanban.js +2 -1
- package/dist/components/views/PlanComparisonPage.js +5 -8
- package/dist/components/views/PlanComparisonPage.test.js +51 -41
- package/dist/components/views/PlansPage.js +126 -84
- package/dist/components/views/PlansPage.test.js +308 -39
- package/dist/components/views/StandaloneLayout.js +28 -22
- package/dist/components/views/panels/FilterToolbar.test.js +6 -4
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +2 -14
- package/dist/components/views/standalone/modals/AccountHubModal.js +12 -14
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +24 -1
- package/dist/config/envSchema.js +1 -1
- package/dist/core/AiProfileService.d.ts +6 -1
- package/dist/core/AiProfileService.js +161 -16
- package/dist/core/AiProfileService.test.js +3 -1
- package/dist/core/AiProfiles.test.js +200 -0
- package/dist/core/AuthTokenService.test.d.ts +1 -0
- package/dist/core/AuthTokenService.test.js +78 -0
- package/dist/core/EntitlementsPolicy.test.js +75 -13
- package/dist/core/PlanEntitlementService.d.ts +7 -1
- package/dist/core/PlanEntitlementService.js +117 -42
- package/dist/core/PlanVersionPolicy.test.js +19 -26
- package/dist/core/SignupMonetizationSettings.test.js +46 -21
- package/dist/core/SystemAdmin.test.js +212 -56
- package/dist/core/TaskTaxonomyValidation.test.js +53 -0
- package/dist/core/Taskforce.d.ts +3 -0
- package/dist/core/Taskforce.js +50 -10
- package/dist/core/WorkspacePlanMode.test.js +12 -12
- package/dist/core/shared.d.ts +2 -0
- package/dist/core/shared.js +11 -0
- package/dist/hooks/sync/auth.js +3 -1
- package/dist/hooks/sync/transfers.d.ts +2 -1
- package/dist/hooks/sync/transfers.js +8 -4
- package/dist/hooks/useSyncOrchestrator.d.ts +7 -4
- package/dist/hooks/useSyncOrchestrator.js +75 -7
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +80 -10
- package/dist/hooks/useTaskMutations.d.ts +2 -1
- package/dist/hooks/useTaskMutations.js +12 -1
- package/dist/hooks/useTaskMutations.test.js +30 -1
- package/dist/hooks/useTaskforce.d.ts +5 -2
- package/dist/hooks/useTaskforce.js +17 -5
- package/dist/hooks/useTaskforce.sync-behavior.test.js +78 -0
- package/dist/hooks/useWorkspaceSyncController.d.ts +3 -1
- package/dist/hooks/useWorkspaceSyncController.js +11 -1
- package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
- package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
- package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
- package/dist/mcp/collaborationRegistrar.d.ts +2 -0
- package/dist/mcp/collaborationRegistrar.js +71 -0
- package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
- package/dist/mcp/documentAssetRegistrar.js +346 -0
- package/dist/mcp/runtime.js +2362 -3530
- package/dist/mcp/runtime.test.js +159 -0
- package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
- package/dist/mcp/taskPlanningRegistrar.js +418 -0
- package/dist/mcp/toolCatalog.d.ts +35 -0
- package/dist/mcp/toolCatalog.js +3 -0
- package/dist/server/routes/admin.d.ts +1 -5
- package/dist/server/routes/admin.js +138 -61
- package/dist/server/routes/annotatedAttachments.d.ts +1 -1
- package/dist/server/routes/annotatedAttachments.js +1 -1
- package/dist/server/routes/auth.d.ts +1 -4
- package/dist/server/routes/auth.js +23 -63
- package/dist/server/routes/authSupport.d.ts +30 -0
- package/dist/server/routes/authSupport.js +81 -0
- package/dist/server/routes/billing.d.ts +1 -1
- package/dist/server/routes/billing.js +104 -188
- package/dist/server/routes/billing.test.js +214 -86
- package/dist/server/routes/documentReviews.d.ts +1 -1
- package/dist/server/routes/documentReviews.js +1 -1
- package/dist/server/routes/documents.d.ts +4 -2
- package/dist/server/routes/documents.js +73 -7
- package/dist/server/routes/primitives.d.ts +11 -0
- package/dist/server/routes/primitives.js +73 -0
- package/dist/server/routes/resources.d.ts +1 -1
- package/dist/server/routes/resources.js +1 -1
- package/dist/server/routes/shared.d.ts +1 -1
- package/dist/server/routes/sync.d.ts +1 -1
- package/dist/server/routes/sync.js +1 -1
- package/dist/server/routes/tasks.d.ts +1 -1
- package/dist/server/routes/tasks.js +1 -1
- package/dist/server/routes/workspaces.d.ts +1 -1
- package/dist/server/routes/workspaces.js +1 -1
- package/dist/server/routes.d.ts +2 -9
- package/dist/server/routes.js +76 -162
- package/dist/server/routes.test.js +672 -30
- package/dist/sync/contentSyncState.js +16 -7
- package/dist/sync/syncApplyHandlers.test.js +2 -0
- package/dist/sync/syncService.d.ts +1 -0
- package/dist/sync/syncService.js +8 -0
- package/dist/sync/syncService.test.js +14 -0
- package/dist/sync/workspaceSyncModel.d.ts +3 -0
- package/dist/sync/workspaceSyncModel.js +12 -3
- package/dist/sync/workspaceSyncModel.test.js +34 -0
- package/dist/sync/workspaceSyncState.d.ts +2 -0
- package/dist/sync/workspaceSyncState.js +1 -0
- package/dist/ui/assets/{AgentsModule-BLWVbuKP.js → AgentsModule-N2MnQBZk.js} +1 -1
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BlS-cqnl.js → AnnotatedAttachmentWorkspace-BG6P_GVW.js} +1 -1
- package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +1 -0
- package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +252 -0
- package/dist/ui/assets/{InitiativesModule-BjR6iBf9.js → InitiativesModule-Dhm7M8e7.js} +1 -1
- package/dist/ui/assets/PlansPage-Cq0zXj3I.js +1 -0
- package/dist/ui/assets/TaskSettings-ln0aF7xc.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-DnFqdUME.js → WorkflowsModule-BcS4afRn.js} +1 -1
- package/dist/ui/assets/{index-Ii0B0rTO.css → index-C2-OXZV7.css} +1 -1
- package/dist/ui/assets/index-DcSI5F86.js +6 -0
- package/dist/ui/assets/{vendor-icons-I7ZwG1z_.js → vendor-icons-BSUaRwF8.js} +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
- package/dist/utils/commercialLifecyclePresentation.js +196 -0
- package/package.json +8 -1
- package/scripts/check-cloud-mcp.mjs +83 -0
- package/scripts/playwright-auth.sh +5 -1
- package/scripts/run-billing-performance-smoke.sh +24 -0
- package/dist/ui/assets/DocumentWorkspace-BH_6DF6x.js +0 -250
- package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
- package/dist/ui/assets/PlansPage-CSDQ4sLa.js +0 -1
- package/dist/ui/assets/TaskSettings-CEDy34Jo.js +0 -9
- package/dist/ui/assets/index-DXUdtH1B.js +0 -6
|
@@ -14,47 +14,173 @@ vi.mock('./PlanComparisonPage', () => ({
|
|
|
14
14
|
}));
|
|
15
15
|
describe('PlansPage', () => {
|
|
16
16
|
afterEach(() => {
|
|
17
|
+
vi.useRealTimers();
|
|
17
18
|
vi.unstubAllGlobals();
|
|
18
19
|
});
|
|
19
|
-
it('shows recovery messaging
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
20
|
+
it('shows recovery messaging only after bounded retries are exhausted', async () => {
|
|
21
|
+
let profileSummaryCalls = 0;
|
|
22
|
+
let confirmCalls = 0;
|
|
23
|
+
const fetchMock = vi.fn(async (url) => {
|
|
24
|
+
if (url.includes('/api/taskforce/account/profile-summary')) {
|
|
25
|
+
profileSummaryCalls += 1;
|
|
26
|
+
if (profileSummaryCalls <= 2) {
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
json: async () => ({
|
|
30
|
+
planId: 'basic',
|
|
31
|
+
planVersionId: 'basic-v1',
|
|
32
|
+
entitlementState: 'checkout_pending',
|
|
33
|
+
gate: 'checkout_pending',
|
|
34
|
+
message: 'Complete checkout to continue.'
|
|
35
|
+
})
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
ok: true,
|
|
40
|
+
json: async () => ({
|
|
41
|
+
planId: 'basic',
|
|
42
|
+
planVersionId: 'basic-v1',
|
|
43
|
+
entitlementState: 'pending_plan_selection',
|
|
44
|
+
gate: 'plan_selection_required',
|
|
45
|
+
message: 'Your previous checkout expired. Choose a plan to continue.',
|
|
46
|
+
planSelectionRequired: true
|
|
47
|
+
})
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (url.includes('/api/taskforce/billing/checkout-session/confirm')) {
|
|
51
|
+
confirmCalls += 1;
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
status: 409,
|
|
55
|
+
json: async () => ({
|
|
56
|
+
error: 'Checkout session has not completed yet.',
|
|
57
|
+
code: 'CHECKOUT_SESSION_INCOMPLETE'
|
|
58
|
+
})
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
json: async () => ({})
|
|
64
|
+
};
|
|
48
65
|
});
|
|
49
66
|
vi.stubGlobal('fetch', fetchMock);
|
|
50
67
|
render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=success&session_id=cs_test_123'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u1", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
|
|
51
68
|
expect(await screen.findByText('Checkout completed successfully. Finalizing your plan...')).toBeInTheDocument();
|
|
52
|
-
expect(
|
|
69
|
+
expect(confirmCalls).toBe(1);
|
|
70
|
+
await waitFor(() => {
|
|
71
|
+
expect(confirmCalls).toBe(4);
|
|
72
|
+
}, { timeout: 4500 });
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(screen.getByText('Your previous checkout expired. Choose a plan to continue.')).toBeInTheDocument();
|
|
75
|
+
});
|
|
53
76
|
expect(screen.queryByText('Checkout completed successfully.')).not.toBeInTheDocument();
|
|
77
|
+
}, 10000);
|
|
78
|
+
it('retries checkout confirmation when the first confirm is incomplete and later succeeds', async () => {
|
|
79
|
+
let profileSummaryCalls = 0;
|
|
80
|
+
let confirmCalls = 0;
|
|
81
|
+
const fetchMock = vi.fn(async (url) => {
|
|
82
|
+
if (url.includes('/api/taskforce/account/profile-summary')) {
|
|
83
|
+
profileSummaryCalls += 1;
|
|
84
|
+
if (profileSummaryCalls <= 2) {
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
json: async () => ({
|
|
88
|
+
planId: 'basic',
|
|
89
|
+
planVersionId: 'basic-v1',
|
|
90
|
+
entitlementState: 'checkout_pending',
|
|
91
|
+
gate: 'checkout_pending',
|
|
92
|
+
message: 'Complete checkout to continue.'
|
|
93
|
+
})
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
json: async () => ({
|
|
99
|
+
entitlementState: 'active',
|
|
100
|
+
planId: 'basic',
|
|
101
|
+
planVersionId: 'basic-v1',
|
|
102
|
+
billingInterval: 'month',
|
|
103
|
+
gate: 'ok',
|
|
104
|
+
message: null
|
|
105
|
+
})
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (url.includes('/api/taskforce/billing/checkout-session/confirm')) {
|
|
109
|
+
confirmCalls += 1;
|
|
110
|
+
if (confirmCalls === 1) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
status: 409,
|
|
114
|
+
json: async () => ({
|
|
115
|
+
error: 'Checkout session has not completed yet.',
|
|
116
|
+
code: 'CHECKOUT_SESSION_INCOMPLETE'
|
|
117
|
+
})
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
status: 200,
|
|
123
|
+
json: async () => ({ success: true })
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
json: async () => ({})
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
132
|
+
render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=success&session_id=cs_test_retry_success'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u1", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
|
|
133
|
+
expect(await screen.findByText('Checkout completed successfully. Finalizing your plan...')).toBeInTheDocument();
|
|
134
|
+
expect(await screen.findByText('Checkout completed successfully.')).toBeInTheDocument();
|
|
135
|
+
expect(confirmCalls).toBe(2);
|
|
136
|
+
}, 10000);
|
|
137
|
+
it('does not attempt checkout confirmation before auth session is resolved', async () => {
|
|
138
|
+
let profileSummaryCalls = 0;
|
|
139
|
+
const fetchMock = vi.fn(async (url) => {
|
|
140
|
+
if (url.includes('/api/taskforce/account/profile-summary')) {
|
|
141
|
+
profileSummaryCalls += 1;
|
|
142
|
+
if (profileSummaryCalls <= 2) {
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
json: async () => ({
|
|
146
|
+
planId: 'basic',
|
|
147
|
+
planVersionId: 'basic-v1',
|
|
148
|
+
entitlementState: 'checkout_pending',
|
|
149
|
+
gate: 'checkout_pending',
|
|
150
|
+
message: 'Complete checkout to continue.'
|
|
151
|
+
})
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
json: async () => ({
|
|
157
|
+
entitlementState: 'active',
|
|
158
|
+
planId: 'basic',
|
|
159
|
+
planVersionId: 'basic-v1',
|
|
160
|
+
gate: 'ok',
|
|
161
|
+
message: null
|
|
162
|
+
})
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (url.includes('/api/taskforce/billing/checkout-session/confirm')) {
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
status: 200,
|
|
169
|
+
json: async () => ({ success: true })
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
json: async () => ({})
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
178
|
+
const view = render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=success&session_id=cs_test_auth_wait'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u_auth", runtimeMode: "cloud", authSessionResolved: false, resolveCloudAuthUrl: (path) => path }) }));
|
|
179
|
+
expect(await screen.findByText('Checkout completed successfully. Finalizing your plan...')).toBeInTheDocument();
|
|
180
|
+
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/api/taskforce/billing/checkout-session/confirm'))).toBe(false);
|
|
181
|
+
view.rerender(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=success&session_id=cs_test_auth_wait'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u_auth", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
|
|
54
182
|
await waitFor(() => {
|
|
55
|
-
expect(fetchMock).
|
|
56
|
-
method: 'POST'
|
|
57
|
-
}));
|
|
183
|
+
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/api/taskforce/billing/checkout-session/confirm'))).toBe(true);
|
|
58
184
|
});
|
|
59
185
|
});
|
|
60
186
|
it('shows conflict-specific recovery messaging when the account lands on plans already blocked', async () => {
|
|
@@ -62,8 +188,8 @@ describe('PlansPage', () => {
|
|
|
62
188
|
.mockResolvedValueOnce({
|
|
63
189
|
ok: true,
|
|
64
190
|
json: async () => ({
|
|
65
|
-
planId: '
|
|
66
|
-
planVersionId: '
|
|
191
|
+
planId: 'basic',
|
|
192
|
+
planVersionId: 'basic-v1',
|
|
67
193
|
entitlementState: 'pending_plan_selection',
|
|
68
194
|
gate: 'plan_selection_required',
|
|
69
195
|
message: 'Choose a plan to continue.',
|
|
@@ -80,8 +206,8 @@ describe('PlansPage', () => {
|
|
|
80
206
|
.mockResolvedValueOnce({
|
|
81
207
|
ok: true,
|
|
82
208
|
json: async () => ({
|
|
83
|
-
planId: '
|
|
84
|
-
planVersionId: '
|
|
209
|
+
planId: 'basic',
|
|
210
|
+
planVersionId: 'basic-v1',
|
|
85
211
|
entitlementState: 'checkout_pending',
|
|
86
212
|
gate: 'checkout_pending',
|
|
87
213
|
message: 'Complete checkout to continue.'
|
|
@@ -90,8 +216,8 @@ describe('PlansPage', () => {
|
|
|
90
216
|
.mockResolvedValueOnce({
|
|
91
217
|
ok: true,
|
|
92
218
|
json: async () => ({
|
|
93
|
-
planId: '
|
|
94
|
-
planVersionId: '
|
|
219
|
+
planId: 'basic',
|
|
220
|
+
planVersionId: 'basic-v1',
|
|
95
221
|
entitlementState: 'pending_plan_selection',
|
|
96
222
|
gate: 'plan_selection_required',
|
|
97
223
|
message: 'Choose a plan to continue.',
|
|
@@ -154,4 +280,147 @@ describe('PlansPage', () => {
|
|
|
154
280
|
expect(await screen.findByRole('button', { name: 'Back to App' })).toBeDisabled();
|
|
155
281
|
expect(screen.getByText('Choose a plan to continue.')).toHaveAttribute('data-status-type', 'info');
|
|
156
282
|
});
|
|
283
|
+
it('does not auto-continue after an in-place paid interval update for an already active account', async () => {
|
|
284
|
+
const onContinueToTaskforce = vi.fn();
|
|
285
|
+
let profileSummaryCalls = 0;
|
|
286
|
+
const fetchMock = vi.fn(async (url) => {
|
|
287
|
+
if (url.includes('/api/taskforce/account/profile-summary')) {
|
|
288
|
+
profileSummaryCalls += 1;
|
|
289
|
+
if (profileSummaryCalls <= 2) {
|
|
290
|
+
return {
|
|
291
|
+
ok: true,
|
|
292
|
+
json: async () => ({
|
|
293
|
+
entitlementState: 'active',
|
|
294
|
+
planId: 'basic',
|
|
295
|
+
planVersionId: 'basic-v1',
|
|
296
|
+
gate: 'ok',
|
|
297
|
+
message: null
|
|
298
|
+
})
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
ok: true,
|
|
303
|
+
json: async () => ({
|
|
304
|
+
entitlementState: 'active',
|
|
305
|
+
planId: 'basic',
|
|
306
|
+
planVersionId: 'basic-v1',
|
|
307
|
+
billingInterval: 'year',
|
|
308
|
+
gate: 'ok',
|
|
309
|
+
message: null
|
|
310
|
+
})
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (url.includes('/api/taskforce/billing/checkout-session')) {
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
json: async () => ({
|
|
317
|
+
checkoutState: 'updated',
|
|
318
|
+
message: 'Your subscription was updated successfully.'
|
|
319
|
+
})
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
json: async () => ({})
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
328
|
+
render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=start&planId=basic&planVersionId=basic-v1&interval=year'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u6", runtimeMode: "local", authSessionResolved: true, resolveCloudAuthUrl: (path) => path, onContinueToTaskforce: onContinueToTaskforce }) }));
|
|
329
|
+
expect(await screen.findByText('Your subscription was updated successfully.')).toBeInTheDocument();
|
|
330
|
+
expect(onContinueToTaskforce).not.toHaveBeenCalled();
|
|
331
|
+
});
|
|
332
|
+
it('does not auto-continue after confirming checkout when the account was already active', async () => {
|
|
333
|
+
const onContinueToTaskforce = vi.fn();
|
|
334
|
+
let profileSummaryCalls = 0;
|
|
335
|
+
const fetchMock = vi.fn(async (url) => {
|
|
336
|
+
if (url.includes('/api/taskforce/account/profile-summary')) {
|
|
337
|
+
profileSummaryCalls += 1;
|
|
338
|
+
if (profileSummaryCalls <= 2) {
|
|
339
|
+
return {
|
|
340
|
+
ok: true,
|
|
341
|
+
json: async () => ({
|
|
342
|
+
entitlementState: 'active',
|
|
343
|
+
planId: 'basic',
|
|
344
|
+
planVersionId: 'basic-v1',
|
|
345
|
+
gate: 'ok',
|
|
346
|
+
message: null
|
|
347
|
+
})
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
ok: true,
|
|
352
|
+
json: async () => ({
|
|
353
|
+
entitlementState: 'active',
|
|
354
|
+
planId: 'team-5',
|
|
355
|
+
planVersionId: 'team-5-v1',
|
|
356
|
+
gate: 'ok',
|
|
357
|
+
message: null
|
|
358
|
+
})
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
if (url.includes('/api/taskforce/billing/checkout-session/confirm')) {
|
|
362
|
+
return {
|
|
363
|
+
ok: true,
|
|
364
|
+
json: async () => ({ success: true })
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
json: async () => ({})
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
373
|
+
render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=success&session_id=cs_test_existing_active'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u7", runtimeMode: "local", authSessionResolved: true, resolveCloudAuthUrl: (path) => path, onContinueToTaskforce: onContinueToTaskforce }) }));
|
|
374
|
+
expect(await screen.findByText('Checkout completed successfully.')).toBeInTheDocument();
|
|
375
|
+
expect(onContinueToTaskforce).not.toHaveBeenCalled();
|
|
376
|
+
});
|
|
377
|
+
it('shows trial-specific messaging while still allowing return to Taskforce', async () => {
|
|
378
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
379
|
+
ok: true,
|
|
380
|
+
json: async () => ({
|
|
381
|
+
entitlementState: 'trialing',
|
|
382
|
+
planId: 'starter',
|
|
383
|
+
planVersionId: 'starter-v1',
|
|
384
|
+
gate: 'ok',
|
|
385
|
+
trialEnd: '2026-05-01T12:00:00.000Z'
|
|
386
|
+
})
|
|
387
|
+
});
|
|
388
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
389
|
+
render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u8", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path, onContinueToTaskforce: vi.fn() }) }));
|
|
390
|
+
expect(await screen.findByText(/Your trial is active through/)).toBeInTheDocument();
|
|
391
|
+
expect(screen.getByRole('button', { name: 'Take Me to Taskforce' })).toBeEnabled();
|
|
392
|
+
});
|
|
393
|
+
it('shows suspended accounts a billing-recovery message and keeps return blocked', async () => {
|
|
394
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
395
|
+
ok: true,
|
|
396
|
+
json: async () => ({
|
|
397
|
+
entitlementState: 'suspended',
|
|
398
|
+
planId: 'pro',
|
|
399
|
+
planVersionId: 'pro-v1',
|
|
400
|
+
gate: 'missing_entitlement',
|
|
401
|
+
stripeCustomerId: 'cus_123'
|
|
402
|
+
})
|
|
403
|
+
});
|
|
404
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
405
|
+
render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u9", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
|
|
406
|
+
expect(await screen.findByText('Your access is suspended because billing needs attention. Update billing to restore access.')).toBeInTheDocument();
|
|
407
|
+
expect(screen.getByRole('button', { name: 'Back to App' })).toBeDisabled();
|
|
408
|
+
expect(screen.getByText('Your access is suspended because billing needs attention. Update billing to restore access.')).toHaveAttribute('data-status-type', 'error');
|
|
409
|
+
});
|
|
410
|
+
it('shows canceled accounts reactivation messaging instead of generic inactive copy', async () => {
|
|
411
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
412
|
+
ok: true,
|
|
413
|
+
json: async () => ({
|
|
414
|
+
entitlementState: 'canceled',
|
|
415
|
+
planId: 'pro',
|
|
416
|
+
planVersionId: 'pro-v1',
|
|
417
|
+
gate: 'missing_entitlement',
|
|
418
|
+
message: 'This generic message should be ignored.'
|
|
419
|
+
})
|
|
420
|
+
});
|
|
421
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
422
|
+
render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u10", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
|
|
423
|
+
expect(await screen.findByText('Your access has ended. Choose a plan to reactivate your workspace.')).toBeInTheDocument();
|
|
424
|
+
expect(screen.queryByText('This generic message should be ignored.')).not.toBeInTheDocument();
|
|
425
|
+
});
|
|
157
426
|
});
|
|
@@ -35,6 +35,7 @@ import { getLocale, t } from '../../localization';
|
|
|
35
35
|
import { resolveAnnotatedAttachmentDetail } from '../../utils/annotatedAttachments';
|
|
36
36
|
import { ACCOUNT_PROFILE_SUMMARY_PATH, invalidateAccountProfileSummaryCache, loadAccountProfileSummaryCached } from '../../utils/accountProfileSummaryCache';
|
|
37
37
|
import { fetchTaskforceApi } from '../../utils/taskforceApiClient';
|
|
38
|
+
import { resolveCommercialLifecyclePresentation } from '../../utils/commercialLifecyclePresentation';
|
|
38
39
|
import { AppShellHeader } from './AppShellHeader';
|
|
39
40
|
import { getTaskforceCloudEnvironmentConfig, parseTaskforceCloudEnvironment, resolveTaskforceCloudEnvironmentFromBaseUrl } from '../../config/cloudEnvironment';
|
|
40
41
|
import { logBootstrapDebug, persistRemoteUiState } from '../../hooks/workspace/workspaceLocalState';
|
|
@@ -56,7 +57,6 @@ const WorkflowsModule = React.lazy(() => import('../features/WorkflowsModule').t
|
|
|
56
57
|
const AgentsModule = React.lazy(() => import('../features/AgentsModule').then(m => ({ default: m.AgentsModule })));
|
|
57
58
|
const InitiativesModule = React.lazy(() => import('../features/InitiativesModule').then(m => ({ default: m.InitiativesModule })));
|
|
58
59
|
const PlansPage = React.lazy(() => import('./PlansPage').then(m => ({ default: m.PlansPage })));
|
|
59
|
-
const ACTIVE_COMMERCIAL_STATES = new Set(['active', 'trialing', 'grace']);
|
|
60
60
|
const PROFILE_AVATAR_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
|
61
61
|
const PROFILE_AVATAR_MAX_BYTES = 5 * 1024 * 1024;
|
|
62
62
|
const resolveSameOriginTaskforcePath = (path) => path;
|
|
@@ -928,28 +928,36 @@ export function StandaloneLayout(props) {
|
|
|
928
928
|
const referenceLabel = formatInitiativeReference(initiative);
|
|
929
929
|
return {
|
|
930
930
|
value: initiative.id,
|
|
931
|
-
label: referenceLabel
|
|
932
|
-
selectedLabel: referenceLabel || initiative.title,
|
|
931
|
+
label: referenceLabel || initiative.title,
|
|
933
932
|
};
|
|
934
933
|
}), [planningStructure.initiatives]);
|
|
935
|
-
const workstreamFilterOptions = useMemo(() =>
|
|
936
|
-
|
|
934
|
+
const workstreamFilterOptions = useMemo(() => {
|
|
935
|
+
const visibleInitiativeWorkstreams = selectedPlanningInitiativeId
|
|
936
|
+
? (planningStructure.initiativeById.get(selectedPlanningInitiativeId)?.workstreams || [])
|
|
937
|
+
: planningStructure.initiatives.flatMap((initiative) => initiative.workstreams);
|
|
938
|
+
const initiativeWorkstreams = visibleInitiativeWorkstreams.map((workstream) => {
|
|
937
939
|
const referenceLabel = formatWorkstreamReference(workstream);
|
|
938
940
|
return {
|
|
939
941
|
value: workstream.id,
|
|
940
|
-
label: referenceLabel
|
|
941
|
-
selectedLabel: referenceLabel || workstream.title,
|
|
942
|
+
label: referenceLabel || workstream.title,
|
|
942
943
|
};
|
|
943
|
-
})
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
944
|
+
});
|
|
945
|
+
const standaloneWorkstreams = selectedPlanningInitiativeId
|
|
946
|
+
? []
|
|
947
|
+
: planningStructure.standaloneWorkstreams.map((workstream) => {
|
|
948
|
+
const referenceLabel = formatWorkstreamReference(workstream);
|
|
949
|
+
return {
|
|
950
|
+
value: workstream.id,
|
|
951
|
+
label: referenceLabel || workstream.title,
|
|
952
|
+
};
|
|
953
|
+
});
|
|
954
|
+
return [...initiativeWorkstreams, ...standaloneWorkstreams];
|
|
955
|
+
}, [
|
|
956
|
+
planningStructure.initiativeById,
|
|
957
|
+
planningStructure.initiatives,
|
|
958
|
+
planningStructure.standaloneWorkstreams,
|
|
959
|
+
selectedPlanningInitiativeId,
|
|
960
|
+
]);
|
|
953
961
|
const selectedPlanningTaskIds = useMemo(() => {
|
|
954
962
|
if (selectedPlanningWorkstreamId) {
|
|
955
963
|
return new Set(planningStructure.workstreamTaskIds.get(selectedPlanningWorkstreamId) || []);
|
|
@@ -1630,9 +1638,8 @@ export function StandaloneLayout(props) {
|
|
|
1630
1638
|
setTeamPlanMode(nextMode);
|
|
1631
1639
|
}, []);
|
|
1632
1640
|
const hasActiveCommercialEntitlement = useMemo(() => {
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
}, [accountProfileSummary?.entitlementState]);
|
|
1641
|
+
return resolveCommercialLifecyclePresentation(accountProfileSummary).allowReturnToApp;
|
|
1642
|
+
}, [accountProfileSummary]);
|
|
1636
1643
|
const handleStartCheckout = useCallback(async () => {
|
|
1637
1644
|
const params = new URLSearchParams();
|
|
1638
1645
|
params.set('screen', 'plans');
|
|
@@ -1828,8 +1835,7 @@ export function StandaloneLayout(props) {
|
|
|
1828
1835
|
return;
|
|
1829
1836
|
}
|
|
1830
1837
|
const summary = await loadAccountProfileSummary({ force: true });
|
|
1831
|
-
|
|
1832
|
-
if (!ACTIVE_COMMERCIAL_STATES.has(entitlementState)) {
|
|
1838
|
+
if (!resolveCommercialLifecyclePresentation(summary).allowReturnToApp) {
|
|
1833
1839
|
return;
|
|
1834
1840
|
}
|
|
1835
1841
|
setPlansNavigationBusy(true);
|
|
@@ -31,15 +31,17 @@ describe('FilterToolbar', () => {
|
|
|
31
31
|
expect(screen.getByRole('button', { name: /All Initiatives/i })).toBeInTheDocument();
|
|
32
32
|
expect(screen.getByRole('button', { name: /All Workstreams/i })).toBeInTheDocument();
|
|
33
33
|
rerender(_jsx(FilterToolbar, { categoryFilterOptions: [], typeFilterOptions: [], priorities: [], filterCategories: [], setFilterCategories: vi.fn(), filterTypes: [], setFilterTypes: vi.fn(), filterPriorities: [], setFilterPriorities: vi.fn(), filterStatus: [], setFilterStatus: vi.fn(), filterAssignees: [], setFilterAssignees: vi.fn(), assigneeOptions: [], initiativeFilterOptions: [
|
|
34
|
-
{ value: 'initiative-1', label: '
|
|
34
|
+
{ value: 'initiative-1', label: 'IN-101' },
|
|
35
35
|
], selectedInitiativeId: "", setSelectedInitiativeId: setSelectedInitiativeId, workstreamFilterOptions: [
|
|
36
|
-
{ value: 'workstream-1', label: '
|
|
36
|
+
{ value: 'workstream-1', label: 'WS-202' },
|
|
37
37
|
], selectedWorkstreamId: "", setSelectedWorkstreamId: setSelectedWorkstreamId, taskScope: "open", setTaskScope: vi.fn(), showArchive: false, setShowArchive: vi.fn(), fetchArchive: vi.fn(), clearFilters: vi.fn() }));
|
|
38
38
|
await user.click(screen.getByRole('button', { name: /All Initiatives/i }));
|
|
39
|
-
|
|
39
|
+
expect(screen.queryByText('Stability Sprint')).not.toBeInTheDocument();
|
|
40
|
+
await user.click(screen.getByRole('button', { name: 'IN-101' }));
|
|
40
41
|
expect(setSelectedInitiativeId).toHaveBeenCalledWith('initiative-1');
|
|
41
42
|
await user.click(screen.getByRole('button', { name: /All Workstreams/i }));
|
|
42
|
-
|
|
43
|
+
expect(screen.queryByText('Header polish')).not.toBeInTheDocument();
|
|
44
|
+
await user.click(screen.getByRole('button', { name: 'WS-202' }));
|
|
43
45
|
expect(setSelectedWorkstreamId).toHaveBeenCalledWith('workstream-1');
|
|
44
46
|
});
|
|
45
47
|
});
|
|
@@ -1,17 +1,5 @@
|
|
|
1
|
+
import type { AccountProfileSummary } from '../../../../utils/accountProfileSummaryCache';
|
|
1
2
|
import type { ModalRuntimeMode, ModalThemeName } from './types';
|
|
2
|
-
interface AccountProfileSummaryLike {
|
|
3
|
-
planId?: string | null;
|
|
4
|
-
planName?: string | null;
|
|
5
|
-
entitlementState?: string | null;
|
|
6
|
-
stripeStatus?: string | null;
|
|
7
|
-
effectiveUntil?: string | null;
|
|
8
|
-
stripeSubscriptionId?: string | null;
|
|
9
|
-
stripeCustomerId?: string | null;
|
|
10
|
-
gate?: string | null;
|
|
11
|
-
message?: string | null;
|
|
12
|
-
billingConflictCode?: 'CHECKOUT_SESSION_EXPIRED' | 'CHECKOUT_SESSION_SUPERSEDED' | null;
|
|
13
|
-
planSelectionRequired?: boolean;
|
|
14
|
-
}
|
|
15
3
|
interface AccountHubModalProps {
|
|
16
4
|
isOpen: boolean;
|
|
17
5
|
theme: ModalThemeName;
|
|
@@ -26,7 +14,7 @@ interface AccountHubModalProps {
|
|
|
26
14
|
billingNotice: string | null;
|
|
27
15
|
billingActionBusy: boolean;
|
|
28
16
|
billingIntervalChoice: 'month' | 'year';
|
|
29
|
-
accountProfileSummary:
|
|
17
|
+
accountProfileSummary: AccountProfileSummary | null;
|
|
30
18
|
currentWorkspaceId: string;
|
|
31
19
|
canOpenTeamManagement: boolean;
|
|
32
20
|
canManageWorkspaceSync: boolean;
|
|
@@ -5,21 +5,19 @@ import accountStyles from '../../StandaloneAccount.module.css';
|
|
|
5
5
|
import authStyles from '../../AuthShell.module.css';
|
|
6
6
|
import managementStyles from '../../StandaloneManagement.module.css';
|
|
7
7
|
import styles from '../../../../Taskforce.module.css';
|
|
8
|
+
import { resolveCommercialLifecyclePresentation } from '../../../../utils/commercialLifecyclePresentation';
|
|
8
9
|
export function AccountHubModal({ isOpen, theme, runtimeMode, authRequiredForApi, isAuthenticated, hasAuthIdentity, authIdentityLabel, billingLoading, billingError, billingActionError, billingNotice, billingActionBusy, billingIntervalChoice, accountProfileSummary, currentWorkspaceId, canOpenTeamManagement, canManageWorkspaceSync, workspaceCloudSyncEnabled, syncStatusLabel, workspaceSyncError, syncControlBusy, onClose, onOpenWorkspaceAudit, onBillingIntervalChange, onRefreshBilling, onUpdateInterval, onManageBilling, onStartCheckout, onToggleWorkspaceSync, onOpenHelp, }) {
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|| (billingConflictCode === 'CHECKOUT_SESSION_EXPIRED'
|
|
13
|
-
? 'Your latest checkout expired. Choose a plan to continue.'
|
|
14
|
-
: (billingConflictCode === 'CHECKOUT_SESSION_SUPERSEDED'
|
|
15
|
-
? 'A newer checkout is already in progress for this account. Open plans to continue.'
|
|
16
|
-
: ''));
|
|
17
|
-
const startCheckoutLabel = billingConflictCode === 'CHECKOUT_SESSION_EXPIRED'
|
|
18
|
-
|| accountProfileSummary?.planSelectionRequired === true
|
|
19
|
-
|| billingGate === 'plan_selection_required'
|
|
20
|
-
? 'Choose Plan'
|
|
21
|
-
: (billingGate === 'checkout_pending' ? 'Open Plans' : 'Start Checkout');
|
|
10
|
+
const lifecyclePresentation = resolveCommercialLifecyclePresentation(accountProfileSummary);
|
|
11
|
+
const billingRecoveryMessage = lifecyclePresentation.message;
|
|
12
|
+
const startCheckoutLabel = lifecyclePresentation.primaryLabel || 'Start Checkout';
|
|
22
13
|
const resolvedPlanLabel = String(accountProfileSummary?.planName || accountProfileSummary?.planId || 'n/a').trim() || 'n/a';
|
|
14
|
+
const primaryBillingAction = () => {
|
|
15
|
+
if (lifecyclePresentation.primaryAction === 'manage_billing') {
|
|
16
|
+
onManageBilling();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
onStartCheckout();
|
|
20
|
+
};
|
|
23
21
|
return (_jsx(Modal, { isOpen: isOpen, onClose: onClose, title: "Account Hub", size: "md", theme: theme, draggable: true, children: _jsxs("div", { className: `${modalStyles.form} ${managementStyles.modalBody}`, children: [_jsx("p", { className: managementStyles.mutedText, children: "Manage sign in, profile, and workspace controls from one place." }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Authentication" }), _jsx("p", { className: managementStyles.sectionText, children: runtimeMode === 'cloud'
|
|
24
22
|
? (authRequiredForApi
|
|
25
23
|
? (isAuthenticated ? 'Signed in. API access is enabled.' : 'Cloud mode requires sign in before app usage.')
|
|
@@ -28,7 +26,7 @@ export function AccountHubModal({ isOpen, theme, runtimeMode, authRequiredForApi
|
|
|
28
26
|
? (hasAuthIdentity
|
|
29
27
|
? _jsxs(_Fragment, { children: ["Signed in as ", _jsx("span", { className: accountStyles.accountIdentityEmail, children: authIdentityLabel })] })
|
|
30
28
|
: 'Signed in')
|
|
31
|
-
: 'Signed in as: not signed in' }), runtimeMode === 'cloud' && authRequiredForApi && !isAuthenticated && (_jsx("p", { className: managementStyles.sectionTextTop, children: "Use the `/login` screen to sign in." }))] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Profile & Account" }), _jsx("p", { className: managementStyles.sectionText, children: "Profile editing and account preferences will live here." })] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Billing" }), !isAuthenticated ? (_jsx("p", { className: managementStyles.sectionText, children: "Sign in to manage subscription billing." })) : runtimeMode !== 'cloud' ? (_jsx("p", { className: managementStyles.sectionText, children: "Billing remains cloud-backed in local runtime and uses your connected cloud account." })) : (_jsxs(_Fragment, { children: [billingLoading && (_jsx("p", { className: managementStyles.sectionTextSpaced, children: "Loading billing status..." })), billingError && (_jsx("p", { className: `${authStyles.loginError} ${managementStyles.errorText}`, children: billingError })), billingActionError && (_jsx("p", { className: `${authStyles.loginError} ${managementStyles.errorText}`, children: billingActionError })), billingNotice && (_jsx("p", { className: managementStyles.errorText, children: billingNotice })), billingRecoveryMessage && (_jsx("p", { className: managementStyles.sectionText, children: billingRecoveryMessage })), _jsxs("p", { className: managementStyles.sectionTextSpaced, children: ["Plan: ", _jsx("code", { children: resolvedPlanLabel }), ' · ', "Entitlement: ", _jsx("code", { children: String(accountProfileSummary?.entitlementState || 'n/a') })] }), _jsxs("p", { className: managementStyles.sectionText, children: ["Stripe status: ", _jsx("code", { children: String(accountProfileSummary?.stripeStatus || 'n/a') }), accountProfileSummary?.effectiveUntil ? ` · Effective until ${new Date(accountProfileSummary.effectiveUntil).toLocaleString()}` : ''] }), _jsx("div", { className: managementStyles.wrapRow, children: _jsxs("label", { className: `${accountStyles.accountMenuMeta} ${managementStyles.inlineFieldLabel}`, children: ["Interval", _jsxs("select", { className: styles.input, value: billingIntervalChoice, onChange: (event) => onBillingIntervalChange(event.target.value === 'year' ? 'year' : 'month'), disabled: billingActionBusy, children: [_jsx("option", { value: "month", children: "Monthly" }), _jsx("option", { value: "year", children: "Yearly" })] })] }) }), _jsxs("div", { className: managementStyles.actionRowEnd, children: [_jsx("button", { className: styles.cancelBtn, onClick: onRefreshBilling, disabled: billingActionBusy || billingLoading, children: "Refresh Billing" }), _jsx("button", { className: styles.cancelBtn, onClick: onUpdateInterval, disabled: billingActionBusy || !accountProfileSummary?.stripeSubscriptionId, children: "Update Interval" }), _jsx("button", { className: styles.cancelBtn, onClick: onManageBilling, disabled: billingActionBusy || !accountProfileSummary?.stripeCustomerId, children: "Manage Billing" }), _jsx("button", { className: styles.submitBtn, onClick:
|
|
29
|
+
: 'Signed in as: not signed in' }), runtimeMode === 'cloud' && authRequiredForApi && !isAuthenticated && (_jsx("p", { className: managementStyles.sectionTextTop, children: "Use the `/login` screen to sign in." }))] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Profile & Account" }), _jsx("p", { className: managementStyles.sectionText, children: "Profile editing and account preferences will live here." })] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Billing" }), !isAuthenticated ? (_jsx("p", { className: managementStyles.sectionText, children: "Sign in to manage subscription billing." })) : runtimeMode !== 'cloud' ? (_jsx("p", { className: managementStyles.sectionText, children: "Billing remains cloud-backed in local runtime and uses your connected cloud account." })) : (_jsxs(_Fragment, { children: [billingLoading && (_jsx("p", { className: managementStyles.sectionTextSpaced, children: "Loading billing status..." })), billingError && (_jsx("p", { className: `${authStyles.loginError} ${managementStyles.errorText}`, children: billingError })), billingActionError && (_jsx("p", { className: `${authStyles.loginError} ${managementStyles.errorText}`, children: billingActionError })), billingNotice && (_jsx("p", { className: managementStyles.errorText, children: billingNotice })), billingRecoveryMessage && (_jsx("p", { className: managementStyles.sectionText, children: billingRecoveryMessage })), _jsxs("p", { className: managementStyles.sectionTextSpaced, children: ["Plan: ", _jsx("code", { children: resolvedPlanLabel }), ' · ', "Entitlement: ", _jsx("code", { children: String(accountProfileSummary?.entitlementState || 'n/a') })] }), _jsxs("p", { className: managementStyles.sectionText, children: ["Stripe status: ", _jsx("code", { children: String(accountProfileSummary?.stripeStatus || 'n/a') }), accountProfileSummary?.effectiveUntil ? ` · Effective until ${new Date(accountProfileSummary.effectiveUntil).toLocaleString()}` : ''] }), _jsx("div", { className: managementStyles.wrapRow, children: _jsxs("label", { className: `${accountStyles.accountMenuMeta} ${managementStyles.inlineFieldLabel}`, children: ["Interval", _jsxs("select", { className: styles.input, value: billingIntervalChoice, onChange: (event) => onBillingIntervalChange(event.target.value === 'year' ? 'year' : 'month'), disabled: billingActionBusy, children: [_jsx("option", { value: "month", children: "Monthly" }), _jsx("option", { value: "year", children: "Yearly" })] })] }) }), _jsxs("div", { className: managementStyles.actionRowEnd, children: [_jsx("button", { className: styles.cancelBtn, onClick: onRefreshBilling, disabled: billingActionBusy || billingLoading, children: "Refresh Billing" }), _jsx("button", { className: styles.cancelBtn, onClick: onUpdateInterval, disabled: billingActionBusy || !accountProfileSummary?.stripeSubscriptionId, children: "Update Interval" }), _jsx("button", { className: styles.cancelBtn, onClick: onManageBilling, disabled: billingActionBusy || !accountProfileSummary?.stripeCustomerId, children: "Manage Billing" }), _jsx("button", { className: styles.submitBtn, onClick: primaryBillingAction, disabled: billingActionBusy || (lifecyclePresentation.primaryAction === 'manage_billing' && !accountProfileSummary?.stripeCustomerId), children: billingActionBusy ? 'Working...' : startCheckoutLabel })] })] }))] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Workspace Audit Log" }), _jsx("p", { className: managementStyles.sectionText, children: "Audit entries are workspace-scoped for the active workspace, not global account history." }), _jsxs("p", { className: managementStyles.sectionText, children: ["Active workspace: ", _jsx("code", { children: currentWorkspaceId })] }), _jsx("div", { className: managementStyles.actionRowEnd, children: _jsx("button", { className: styles.cancelBtn, onClick: onOpenWorkspaceAudit, disabled: !canOpenTeamManagement, children: "Open Workspace Audit" }) })] }), _jsxs("div", { className: accountStyles.accountHubCard, children: [_jsx("strong", { children: "Workspace Cloud Sync" }), _jsx("p", { className: managementStyles.sectionText, children: canManageWorkspaceSync
|
|
32
30
|
? (workspaceCloudSyncEnabled
|
|
33
31
|
? `Enabled · ${syncStatusLabel}`
|
|
34
32
|
: 'Disabled')
|
|
@@ -17,10 +17,33 @@ describe('AccountHubModal', () => {
|
|
|
17
17
|
billingConflictCode: 'CHECKOUT_SESSION_EXPIRED',
|
|
18
18
|
planSelectionRequired: true
|
|
19
19
|
}, currentWorkspaceId: "workspace-1", canOpenTeamManagement: false, canManageWorkspaceSync: false, workspaceCloudSyncEnabled: false, syncStatusLabel: "Disabled", workspaceSyncError: null, syncControlBusy: false, onClose: () => { }, onOpenWorkspaceAudit: () => { }, onBillingIntervalChange: () => { }, onRefreshBilling: () => { }, onUpdateInterval: () => { }, onManageBilling: () => { }, onStartCheckout: onStartCheckout, onToggleWorkspaceSync: () => { }, onOpenHelp: () => { } }));
|
|
20
|
-
expect(screen.getByText('Your
|
|
20
|
+
expect(screen.getByText('Your previous checkout expired. Choose a plan to continue.')).toBeInTheDocument();
|
|
21
21
|
expect(screen.getByText('Pro Plan')).toBeInTheDocument();
|
|
22
22
|
const cta = screen.getByRole('button', { name: 'Choose Plan' });
|
|
23
23
|
fireEvent.click(cta);
|
|
24
24
|
expect(onStartCheckout).toHaveBeenCalledTimes(1);
|
|
25
25
|
});
|
|
26
|
+
it('makes billing recovery the primary action for suspended accounts', () => {
|
|
27
|
+
const onManageBilling = vi.fn();
|
|
28
|
+
render(_jsx(AccountHubModal, { isOpen: true, theme: "dark", runtimeMode: "cloud", authRequiredForApi: true, isAuthenticated: true, hasAuthIdentity: true, authIdentityLabel: "owner@example.com", billingLoading: false, billingError: null, billingActionError: null, billingNotice: null, billingActionBusy: false, billingIntervalChoice: "month", accountProfileSummary: {
|
|
29
|
+
planId: 'pro',
|
|
30
|
+
planName: 'Pro Plan',
|
|
31
|
+
entitlementState: 'suspended',
|
|
32
|
+
stripeCustomerId: 'cus_123'
|
|
33
|
+
}, currentWorkspaceId: "workspace-1", canOpenTeamManagement: false, canManageWorkspaceSync: false, workspaceCloudSyncEnabled: false, syncStatusLabel: "Disabled", workspaceSyncError: null, syncControlBusy: false, onClose: () => { }, onOpenWorkspaceAudit: () => { }, onBillingIntervalChange: () => { }, onRefreshBilling: () => { }, onUpdateInterval: () => { }, onManageBilling: onManageBilling, onStartCheckout: () => { }, onToggleWorkspaceSync: () => { }, onOpenHelp: () => { } }));
|
|
34
|
+
expect(screen.getByText('Your access is suspended because billing needs attention. Update billing to restore access.')).toBeInTheDocument();
|
|
35
|
+
fireEvent.click(screen.getAllByRole('button', { name: 'Manage Billing' })[1]);
|
|
36
|
+
expect(onManageBilling).toHaveBeenCalledTimes(1);
|
|
37
|
+
});
|
|
38
|
+
it('shows trialing accounts as active without blocked recovery copy', () => {
|
|
39
|
+
render(_jsx(AccountHubModal, { isOpen: true, theme: "dark", runtimeMode: "cloud", authRequiredForApi: true, isAuthenticated: true, hasAuthIdentity: true, authIdentityLabel: "owner@example.com", billingLoading: false, billingError: null, billingActionError: null, billingNotice: null, billingActionBusy: false, billingIntervalChoice: "month", accountProfileSummary: {
|
|
40
|
+
planId: 'starter',
|
|
41
|
+
planName: 'Starter',
|
|
42
|
+
entitlementState: 'trialing',
|
|
43
|
+
stripeCustomerId: 'cus_123',
|
|
44
|
+
trialEnd: '2026-05-01T12:00:00.000Z'
|
|
45
|
+
}, currentWorkspaceId: "workspace-1", canOpenTeamManagement: false, canManageWorkspaceSync: false, workspaceCloudSyncEnabled: false, syncStatusLabel: "Disabled", workspaceSyncError: null, syncControlBusy: false, onClose: () => { }, onOpenWorkspaceAudit: () => { }, onBillingIntervalChange: () => { }, onRefreshBilling: () => { }, onUpdateInterval: () => { }, onManageBilling: () => { }, onStartCheckout: () => { }, onToggleWorkspaceSync: () => { }, onOpenHelp: () => { } }));
|
|
46
|
+
expect(screen.getByText(/Your trial is active through/)).toBeInTheDocument();
|
|
47
|
+
expect(screen.queryByText('Your access is suspended because billing needs attention. Update billing to restore access.')).not.toBeInTheDocument();
|
|
48
|
+
});
|
|
26
49
|
});
|