create-ailk 0.19.0 → 0.20.0
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 +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for /[locale]/account/security — User settings › Security (#8339
|
|
3
|
-
* D8
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* D8; #8431 D2, D3, AC-2, AC-3, AC-4).
|
|
4
|
+
*
|
|
5
|
+
* - the frame with Security current (#8339);
|
|
6
|
+
* - AC-2: a site or hub that can email a password link shows the Password
|
|
7
|
+
* row from the session's own accounts, and its button requests a link
|
|
8
|
+
* for the person's own address with the server-built `redirectTo`; a
|
|
9
|
+
* seam rendered before #8431 degrades to the sentence;
|
|
10
|
+
* - AC-3: any other site shows one sentence naming the deployment's
|
|
11
|
+
* methods, never a provider list;
|
|
12
|
+
* - AC-4 (site half): Log out signs out through Better Auth and lands on
|
|
13
|
+
* the sign-in page, and a failed log-out navigates nowhere. The spoke
|
|
14
|
+
* half is hub-mode.test.tsx's.
|
|
7
15
|
*/
|
|
8
16
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
17
|
import { tmpdir } from "node:os";
|
|
10
18
|
import { join } from "node:path";
|
|
11
19
|
|
|
12
|
-
import { render, screen, within } from "@testing-library/react";
|
|
20
|
+
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
|
13
21
|
|
|
14
22
|
jest.mock("next-intl/server", () => {
|
|
15
23
|
const msgs: unknown = require("../../../../../messages/en.json");
|
|
@@ -41,12 +49,32 @@ jest.mock("next/headers", () => ({
|
|
|
41
49
|
}));
|
|
42
50
|
|
|
43
51
|
const mockGetSession = jest.fn();
|
|
52
|
+
const mockListUserAccounts = jest.fn();
|
|
44
53
|
// eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
|
|
45
54
|
jest.mock("../../../../../auth", () => ({
|
|
46
55
|
__esModule: true,
|
|
47
|
-
auth: {
|
|
56
|
+
auth: {
|
|
57
|
+
api: {
|
|
58
|
+
getSession: (...args: unknown[]) => mockGetSession(...args),
|
|
59
|
+
listUserAccounts: (...args: unknown[]) => mockListUserAccounts(...args),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
48
62
|
}));
|
|
49
63
|
|
|
64
|
+
/** `stale: true` serves the seam as one rendered before #8431 — no accounts reader (D2b). */
|
|
65
|
+
const mockSeam = { stale: false };
|
|
66
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); a seam rendered before #8431 (D2b) is the boundary under test, and no env or HTTP surface selects it.
|
|
67
|
+
jest.mock("../../../../../lib/topology.generated", () => {
|
|
68
|
+
const real = jest.requireActual("../../../../../lib/topology.generated") as Record<string, unknown>;
|
|
69
|
+
return {
|
|
70
|
+
...real,
|
|
71
|
+
__esModule: true,
|
|
72
|
+
get readOwnAccounts(): unknown {
|
|
73
|
+
return mockSeam.stale ? undefined : real.readOwnAccounts;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
|
|
50
78
|
jest.mock("@working-theory/api-client", () => ({
|
|
51
79
|
__esModule: true,
|
|
52
80
|
waitlistSignups: { me: jest.fn() },
|
|
@@ -58,9 +86,21 @@ jest.mock("@working-theory/api-client", () => ({
|
|
|
58
86
|
visitorForwardHeaders: () => null,
|
|
59
87
|
}));
|
|
60
88
|
|
|
89
|
+
const mockRequestPasswordReset = jest.fn();
|
|
90
|
+
const mockSignOut = jest.fn();
|
|
91
|
+
jest.mock("better-auth/react", () => ({
|
|
92
|
+
__esModule: true,
|
|
93
|
+
createAuthClient: () => ({
|
|
94
|
+
requestPasswordReset: (...args: unknown[]) => mockRequestPasswordReset(...args),
|
|
95
|
+
signOut: (...args: unknown[]) => mockSignOut(...args),
|
|
96
|
+
}),
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const mockPush = jest.fn();
|
|
100
|
+
const mockRefresh = jest.fn();
|
|
61
101
|
jest.mock("next/navigation", () => ({
|
|
62
102
|
__esModule: true,
|
|
63
|
-
useRouter: () => ({ push:
|
|
103
|
+
useRouter: () => ({ push: mockPush, refresh: mockRefresh, replace: jest.fn() }),
|
|
64
104
|
usePathname: () => "/account/security",
|
|
65
105
|
redirect: jest.fn(),
|
|
66
106
|
}));
|
|
@@ -77,6 +117,7 @@ const SESSION = {
|
|
|
77
117
|
},
|
|
78
118
|
};
|
|
79
119
|
|
|
120
|
+
const BASE_URL = "https://site.example.com";
|
|
80
121
|
const ENV = process.env;
|
|
81
122
|
const ENV_KEYS = [
|
|
82
123
|
"GOOGLE_CLIENT_ID",
|
|
@@ -86,6 +127,9 @@ const ENV_KEYS = [
|
|
|
86
127
|
"AUTH_EMAIL_SERVER",
|
|
87
128
|
"AUTH_EMAIL_FROM",
|
|
88
129
|
"AUTH_EMAIL_WEBHOOK_SECRET",
|
|
130
|
+
"AILK_AUTH_PASSWORD_RESET",
|
|
131
|
+
"BETTER_AUTH_URL",
|
|
132
|
+
"BETTER_AUTH_SECRET",
|
|
89
133
|
] as const;
|
|
90
134
|
|
|
91
135
|
let contentRoot: string;
|
|
@@ -97,6 +141,12 @@ beforeEach(() => {
|
|
|
97
141
|
process.env.AILK_CONTENT_ROOT = contentRoot;
|
|
98
142
|
writeFileSync(join(contentRoot, "_site.mdx"), '---\nsiteName: "Test"\n---\n');
|
|
99
143
|
mockGetSession.mockReset().mockResolvedValue(SESSION);
|
|
144
|
+
mockListUserAccounts.mockReset().mockResolvedValue([]);
|
|
145
|
+
mockRequestPasswordReset.mockReset().mockResolvedValue({ data: { status: true }, error: null });
|
|
146
|
+
mockSignOut.mockReset().mockResolvedValue({ data: { success: true }, error: null });
|
|
147
|
+
mockPush.mockReset();
|
|
148
|
+
mockRefresh.mockReset();
|
|
149
|
+
mockSeam.stale = false;
|
|
100
150
|
});
|
|
101
151
|
|
|
102
152
|
afterEach(() => {
|
|
@@ -108,6 +158,26 @@ async function renderPage(locale = "en"): Promise<void> {
|
|
|
108
158
|
render(await Page({ params: Promise.resolve({ locale }) }));
|
|
109
159
|
}
|
|
110
160
|
|
|
161
|
+
/** The five `resetSendConfigured()` conjuncts (C1). */
|
|
162
|
+
function enableReset(): void {
|
|
163
|
+
process.env.AILK_AUTH_PASSWORD_RESET = "true";
|
|
164
|
+
process.env.AUTH_EMAIL_SERVER = "smtp://x";
|
|
165
|
+
process.env.AUTH_EMAIL_FROM = "site@example.com";
|
|
166
|
+
process.env.AUTH_EMAIL_WEBHOOK_SECRET = "w".repeat(32);
|
|
167
|
+
process.env.BETTER_AUTH_URL = BASE_URL;
|
|
168
|
+
process.env.BETTER_AUTH_SECRET = "s".repeat(32);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function enableGoogle(): void {
|
|
172
|
+
process.env.GOOGLE_CLIENT_ID = "id";
|
|
173
|
+
process.env.GOOGLE_CLIENT_SECRET = "secret";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function enableGitHub(): void {
|
|
177
|
+
process.env.GITHUB_CLIENT_ID = "id";
|
|
178
|
+
process.env.GITHUB_CLIENT_SECRET = "secret";
|
|
179
|
+
}
|
|
180
|
+
|
|
111
181
|
describe("the frame (D7, D8)", () => {
|
|
112
182
|
it("renders in the settings frame with Security current", async () => {
|
|
113
183
|
await renderPage();
|
|
@@ -118,22 +188,222 @@ describe("the frame (D7, D8)", () => {
|
|
|
118
188
|
});
|
|
119
189
|
});
|
|
120
190
|
|
|
121
|
-
describe("
|
|
122
|
-
it("
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
191
|
+
describe("the standalone shape (#8430)", () => {
|
|
192
|
+
it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
|
|
193
|
+
await renderPage();
|
|
194
|
+
const settings = screen.getByTestId("account-settings");
|
|
195
|
+
expect(settings).toHaveClass("min-h-screen");
|
|
196
|
+
const column = within(settings).getByRole("complementary");
|
|
197
|
+
expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
|
|
198
|
+
expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
|
|
199
|
+
expect(within(settings).getByRole("main")).toContainElement(
|
|
200
|
+
screen.getByRole("heading", { level: 1 }),
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("AC-2 — a site or hub that can email a password link", () => {
|
|
206
|
+
beforeEach(enableReset);
|
|
207
|
+
|
|
208
|
+
it("with no password: Not set, the create note and Set password — read from the session's own accounts", async () => {
|
|
209
|
+
await renderPage();
|
|
210
|
+
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Sign-in methods");
|
|
211
|
+
expect(screen.getByTestId("account-security-password-status")).toHaveTextContent("Not set");
|
|
212
|
+
expect(screen.getByTestId("account-security-password-note")).toHaveTextContent(
|
|
213
|
+
"We'll email ada@example.com a secure link to create your password.",
|
|
214
|
+
);
|
|
215
|
+
expect(screen.getByTestId("account-security-password-request")).toHaveTextContent("Set password");
|
|
216
|
+
expect(mockListUserAccounts).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(mockListUserAccounts.mock.calls[0]?.[0]).toEqual({ headers: expect.any(Headers) });
|
|
218
|
+
expect(document.querySelector('input[type="password"]')).toBeNull();
|
|
219
|
+
expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("with a credential account: Set, the change note and Change password", async () => {
|
|
223
|
+
mockListUserAccounts.mockResolvedValue([{ providerId: "google" }, { providerId: "credential" }]);
|
|
224
|
+
await renderPage();
|
|
225
|
+
expect(screen.getByTestId("account-security-password-status")).toHaveTextContent("Set");
|
|
226
|
+
expect(screen.getByTestId("account-security-password-note")).toHaveTextContent(
|
|
227
|
+
"We'll email ada@example.com a secure link to change your password.",
|
|
228
|
+
);
|
|
229
|
+
expect(screen.getByTestId("account-security-password-request")).toHaveTextContent("Change password");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("a failed accounts read renders the error row in the block's place, with Log out still rendered", async () => {
|
|
233
|
+
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
234
|
+
mockListUserAccounts.mockRejectedValue(new TypeError("db down"));
|
|
235
|
+
await renderPage();
|
|
236
|
+
expect(within(screen.getByTestId("account-signin-method")).getByTestId("inbox-error")).toHaveTextContent(
|
|
237
|
+
"We couldn't check your password settings.",
|
|
238
|
+
);
|
|
239
|
+
expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
|
|
240
|
+
expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
|
|
241
|
+
expect(errorSpy.mock.calls.map((args) => args.join(" "))).toEqual([
|
|
242
|
+
"[account] read failed source=security-password errName=TypeError",
|
|
243
|
+
]);
|
|
244
|
+
errorSpy.mockRestore();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it.each([
|
|
248
|
+
["en", `${BASE_URL}/reset-password`],
|
|
249
|
+
["fr", `${BASE_URL}/fr/reset-password`],
|
|
250
|
+
])("Set password (%s) requests a link for the session's own address with the server-built redirectTo", async (locale, redirectTo) => {
|
|
251
|
+
await renderPage(locale);
|
|
252
|
+
await act(async () => {
|
|
253
|
+
fireEvent.click(screen.getByTestId("account-security-password-request"));
|
|
254
|
+
});
|
|
255
|
+
expect(mockRequestPasswordReset).toHaveBeenCalledTimes(1);
|
|
256
|
+
expect(mockRequestPasswordReset).toHaveBeenCalledWith({ email: "ada@example.com", redirectTo });
|
|
257
|
+
expect(screen.getByTestId("account-security-password-sent")).toBeInTheDocument();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("a refused request shows the error, not the sent line", async () => {
|
|
261
|
+
mockRequestPasswordReset.mockResolvedValue({ data: null, error: { status: 429 } });
|
|
262
|
+
await renderPage();
|
|
263
|
+
await act(async () => {
|
|
264
|
+
fireEvent.click(screen.getByTestId("account-security-password-request"));
|
|
265
|
+
});
|
|
266
|
+
expect(screen.getByTestId("account-security-password-error")).toBeInTheDocument();
|
|
267
|
+
expect(screen.queryByTestId("account-security-password-sent")).not.toBeInTheDocument();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("with Google configured as well, no provider list renders", async () => {
|
|
271
|
+
enableGoogle();
|
|
272
|
+
await renderPage();
|
|
273
|
+
expect(screen.getByTestId("account-security-password")).toBeInTheDocument();
|
|
274
|
+
expect(screen.queryByText(/Google/)).not.toBeInTheDocument();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("a seam rendered before #8431 (no accounts reader) degrades to the sentence, never the error row (D2b)", async () => {
|
|
278
|
+
mockSeam.stale = true;
|
|
279
|
+
await renderPage();
|
|
280
|
+
// The five reset conjuncts include the email link's four, so a site that
|
|
281
|
+
// can email a password link always offers the email link too.
|
|
282
|
+
expect(screen.getByTestId("account-security-sentence")).toHaveTextContent(
|
|
283
|
+
"You sign in with a link sent to ada@example.com or your email and password.",
|
|
284
|
+
);
|
|
285
|
+
expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
|
|
286
|
+
expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
|
|
287
|
+
expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
|
|
288
|
+
expect(mockListUserAccounts).not.toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("AC-3 — a site that cannot email a password link shows one sentence", () => {
|
|
293
|
+
it.each([
|
|
294
|
+
["Google alone", [enableGoogle], "You sign in with Google."],
|
|
295
|
+
["Google and GitHub", [enableGoogle, enableGitHub], "You sign in with Google or GitHub."],
|
|
296
|
+
[
|
|
297
|
+
"the email link alone",
|
|
298
|
+
[
|
|
299
|
+
() => {
|
|
300
|
+
process.env.AUTH_EMAIL_WEBHOOK_SECRET = "w".repeat(32);
|
|
301
|
+
process.env.AUTH_EMAIL_FROM = "site@example.com";
|
|
302
|
+
process.env.BETTER_AUTH_URL = BASE_URL;
|
|
303
|
+
process.env.BETTER_AUTH_SECRET = "s".repeat(32);
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
"You sign in with a link sent to ada@example.com.",
|
|
307
|
+
],
|
|
308
|
+
[
|
|
309
|
+
"the password method without the reset flag",
|
|
310
|
+
[
|
|
311
|
+
() => {
|
|
312
|
+
process.env.AUTH_EMAIL_SERVER = "smtp://x";
|
|
313
|
+
process.env.AUTH_EMAIL_FROM = "site@example.com";
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
"You sign in with your email and password.",
|
|
317
|
+
],
|
|
318
|
+
])("%s", async (_label, arrange, sentence) => {
|
|
319
|
+
for (const step of arrange) step();
|
|
320
|
+
await renderPage();
|
|
321
|
+
expect(screen.getByTestId("account-security-sentence")).toHaveTextContent(sentence);
|
|
322
|
+
expect(screen.queryByText("You're signed in as ada@example.com.")).not.toBeInTheDocument();
|
|
323
|
+
expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
|
|
324
|
+
expect(screen.queryByText("Sign-in methods")).not.toBeInTheDocument();
|
|
325
|
+
expect(screen.queryByText("Available")).not.toBeInTheDocument();
|
|
326
|
+
expect(mockListUserAccounts).not.toHaveBeenCalled();
|
|
327
|
+
expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("with no configured method it names the signed-in address (C5)", async () => {
|
|
331
|
+
await renderPage();
|
|
332
|
+
expect(screen.getByTestId("account-security-sentence")).toHaveTextContent(
|
|
333
|
+
"You're signed in as ada@example.com.",
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("the old Sign-in keys are gone and every added key carries its D5 value", () => {
|
|
338
|
+
const msgs = require("../../../../../messages/en.json") as {
|
|
339
|
+
account: { settings: { signIn: Record<string, unknown>; security: Record<string, unknown> } };
|
|
340
|
+
};
|
|
341
|
+
const { signIn, security } = msgs.account.settings;
|
|
342
|
+
expect(Object.keys(signIn)).toEqual(["lead"]);
|
|
343
|
+
expect(security).toEqual({
|
|
344
|
+
title: "Security",
|
|
345
|
+
methodsHeading: "Sign-in methods",
|
|
346
|
+
passwordLabel: "Password",
|
|
347
|
+
passwordSet: "Set",
|
|
348
|
+
passwordNotSet: "Not set",
|
|
349
|
+
setHint: "Add a password so you can also sign in with your email and password.",
|
|
350
|
+
changeHint: "Change the password you sign in with.",
|
|
351
|
+
setNote: "We'll email {email} a secure link to create your password.",
|
|
352
|
+
changeNote: "We'll email {email} a secure link to change your password.",
|
|
353
|
+
setButton: "Set password",
|
|
354
|
+
changeButton: "Change password",
|
|
355
|
+
sending: "Sending…",
|
|
356
|
+
sent: "Link sent. It works once, for an hour. Saving a password signs you out everywhere.",
|
|
357
|
+
sendFailed: "We couldn't send the email. Try again in a few minutes.",
|
|
358
|
+
logOut: "Log out",
|
|
359
|
+
logOutFailed: "Log out didn't finish. Try again.",
|
|
360
|
+
signsInWith: "You sign in with {methods}.",
|
|
361
|
+
signedInAs: "You're signed in as {email}.",
|
|
362
|
+
methods: {
|
|
363
|
+
google: "Google",
|
|
364
|
+
github: "GitHub",
|
|
365
|
+
magicLink: "a link sent to {email}",
|
|
366
|
+
password: "your email and password",
|
|
367
|
+
},
|
|
368
|
+
managedAt: "Your sign-in is managed at {hub}.",
|
|
369
|
+
managedUnnamed: "Your sign-in is managed by our main site.",
|
|
370
|
+
manageOnHub: "Manage sign-in on {hub}",
|
|
371
|
+
passwordUnavailable: "We couldn't check your password settings.",
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe("AC-4 — Log out on a site", () => {
|
|
377
|
+
it.each([
|
|
378
|
+
["en", "/sign-in"],
|
|
379
|
+
["fr", "/fr/sign-in"],
|
|
380
|
+
])("(%s) signs out through Better Auth once and lands on the sign-in page", async (locale, href) => {
|
|
381
|
+
enableGoogle();
|
|
382
|
+
await renderPage(locale);
|
|
383
|
+
await act(async () => {
|
|
384
|
+
fireEvent.click(screen.getByTestId("account-security-log-out"));
|
|
385
|
+
});
|
|
386
|
+
expect(mockSignOut).toHaveBeenCalledTimes(1);
|
|
387
|
+
expect(mockPush).toHaveBeenCalledWith(href);
|
|
388
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("a refused sign-out navigates nowhere and shows the error", async () => {
|
|
392
|
+
mockSignOut.mockResolvedValue({ data: null, error: { status: 500 } });
|
|
127
393
|
await renderPage();
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
expect(
|
|
132
|
-
expect(
|
|
394
|
+
await act(async () => {
|
|
395
|
+
fireEvent.click(screen.getByTestId("account-security-log-out"));
|
|
396
|
+
});
|
|
397
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
398
|
+
expect(screen.getByTestId("account-security-log-out-error")).toBeInTheDocument();
|
|
133
399
|
});
|
|
134
400
|
|
|
135
|
-
it(
|
|
401
|
+
it.each([
|
|
402
|
+
["password", enableReset],
|
|
403
|
+
["sentence", enableGoogle],
|
|
404
|
+
])("the %s variant has no 'other devices' control", async (_label, arrange) => {
|
|
405
|
+
arrange();
|
|
136
406
|
await renderPage();
|
|
137
|
-
expect(screen.
|
|
407
|
+
expect(screen.queryByText(/other devices/i)).toBeNull();
|
|
138
408
|
});
|
|
139
409
|
});
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* for D2–D9. A `#billing` fragment never reaches the server, so the server
|
|
10
10
|
* redirect is to General and `BillingAnchor` forwards the fragment there
|
|
11
11
|
* (`general.test.tsx` § AC-6).
|
|
12
|
+
*
|
|
13
|
+
* #8441 — `SETTINGS_ROUTES` (settings-frame.tsx) is the one list of User
|
|
14
|
+
* settings' routes: the tabs read it, and so does `isSettingsPath`, the
|
|
15
|
+
* exact, locale-aware membership test the account frame takes the settings
|
|
16
|
+
* routes out of its shell with (layout.test.tsx renders each route).
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
const mockRedirect = jest.fn();
|
|
@@ -18,6 +23,7 @@ jest.mock("next/navigation", () => ({
|
|
|
18
23
|
}));
|
|
19
24
|
|
|
20
25
|
import Page from "../settings/page";
|
|
26
|
+
import { isSettingsPath, memberSettingsFrame, SETTINGS_ROUTES, type Translate } from "../settings-frame";
|
|
21
27
|
|
|
22
28
|
beforeEach(() => {
|
|
23
29
|
mockRedirect.mockReset();
|
|
@@ -35,3 +41,53 @@ describe("AC-6 — /account/settings redirects to /account/general (D8)", () =>
|
|
|
35
41
|
expect(mockRedirect).toHaveBeenCalledWith("/fr/account/general");
|
|
36
42
|
});
|
|
37
43
|
});
|
|
44
|
+
|
|
45
|
+
describe("the one list of User settings' routes (#8441; #8438 D14 adds Privacy)", () => {
|
|
46
|
+
it("names the five tabs, in the reference's order", () => {
|
|
47
|
+
expect(SETTINGS_ROUTES.map(({ key, path }) => [key, path])).toEqual([
|
|
48
|
+
["general", "/account/general"],
|
|
49
|
+
["security", "/account/security"],
|
|
50
|
+
["notifications", "/account/notifications"],
|
|
51
|
+
["privacy", "/account/privacy"],
|
|
52
|
+
["billing", "/account/billing"],
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it.each(["en", "fr"])("the settings frame's tabs are that list, in %s", (locale) => {
|
|
57
|
+
const t: Translate = (key) => key;
|
|
58
|
+
const { tabs } = memberSettingsFrame(locale, t, "security");
|
|
59
|
+
expect(tabs.map(({ key, href, label }) => [key, href, label])).toEqual(
|
|
60
|
+
SETTINGS_ROUTES.map(({ key, path, titleKey }) => [
|
|
61
|
+
key,
|
|
62
|
+
locale === "en" ? path : `/${locale}${path}`,
|
|
63
|
+
titleKey,
|
|
64
|
+
]),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("isSettingsPath — an exact, locale-aware match (#8441)", () => {
|
|
70
|
+
it.each(SETTINGS_ROUTES.map(({ path }) => path))("%s is a settings route, bare or prefixed", (path) => {
|
|
71
|
+
expect(isSettingsPath(path, "en")).toBe(true);
|
|
72
|
+
expect(isSettingsPath(`/fr${path}`, "fr")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it.each([
|
|
76
|
+
["/account", "en"],
|
|
77
|
+
["/account/settings", "en"],
|
|
78
|
+
["/account/support", "en"],
|
|
79
|
+
["/account/artifacts", "en"],
|
|
80
|
+
// A sub-route stays inside the shell unless it is listed.
|
|
81
|
+
["/account/billing/history", "en"],
|
|
82
|
+
["/fr/account/billing/history", "fr"],
|
|
83
|
+
["/account/general/", "en"],
|
|
84
|
+
["/account/generalx", "en"],
|
|
85
|
+
// Only the request's own locale is a prefix.
|
|
86
|
+
["/fr/account/general", "en"],
|
|
87
|
+
["/de/account/general", "fr"],
|
|
88
|
+
["/frx/account/general", "fr"],
|
|
89
|
+
["", "en"],
|
|
90
|
+
])("%s (locale %s) is not", (path, locale) => {
|
|
91
|
+
expect(isSettingsPath(path, locale)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"use client"; // the shell owns collapse state; sign-out is a client-side Better Auth call + router navigation
|
|
1
|
+
"use client"; // the shell owns collapse state; sign-out is a client-side hub-session POST or Better Auth call + router navigation
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* AccountFrame — the account area's SignedInShell composition (#7430, spec
|
|
@@ -7,8 +7,21 @@
|
|
|
7
7
|
* what only the client has: the current path and the sign-out call.
|
|
8
8
|
*
|
|
9
9
|
* `createAuthClient()` at module scope is the `sign-in-gate.tsx` idiom. Log
|
|
10
|
-
* out ends the session, then navigates
|
|
11
|
-
*
|
|
10
|
+
* out ends the session, then navigates and refreshes so every RSC under the
|
|
11
|
+
* `(authed)` gate re-reads the (now absent) session.
|
|
12
|
+
*
|
|
13
|
+
* #8465 — a spoke that signs in through its hub (#7894) holds its session in
|
|
14
|
+
* a sealed cookie only `POST /api/hub-session/sign-out` clears (it also
|
|
15
|
+
* revokes the credential at the hub); Better Auth's `signOut()` hits a route
|
|
16
|
+
* that spoke does not serve. The server layout reads the generated seam's
|
|
17
|
+
* `SIGN_OUT_ENDPOINT` and hands it down as `signOutEndpoint` — the signed-in
|
|
18
|
+
* Nav's own pattern, because a client component cannot import the seam. With
|
|
19
|
+
* an endpoint, Log out POSTs it and navigates to the sign-in page only when
|
|
20
|
+
* the answer was 2xx; a refused or failed sign-out (a 403 on an Origin
|
|
21
|
+
* mismatch, a 5xx, a network error) leaves the cookie valid, so the frame
|
|
22
|
+
* stays on the page and says so, never routing away with a live session.
|
|
23
|
+
* Without an endpoint (a hub or a single site) nothing changes: Better Auth,
|
|
24
|
+
* then home.
|
|
12
25
|
*
|
|
13
26
|
* #8231 D1, as amended by #8334 — the rail reads Inbox, a Library group
|
|
14
27
|
* holding Artifacts, and the footer's ?. Settings is not a rail row: it lives
|
|
@@ -35,6 +48,15 @@
|
|
|
35
48
|
* portals to `document.body` (the avatar/help menus, For-you, New, the phone
|
|
36
49
|
* sheets) without the wrapper adding a box — `contents` keeps the shell's own
|
|
37
50
|
* layout (`min-h-screen`) unchanged.
|
|
51
|
+
*
|
|
52
|
+
* #8430 — the User settings routes take the screen as the Admin's do: the
|
|
53
|
+
* frame renders the page alone, inside the marker only — no rail, no Inbox,
|
|
54
|
+
* no AppShell — and the page draws the shared `AccountSettingsFrame` in its
|
|
55
|
+
* standalone shape, its own column flush and full height with Back to the
|
|
56
|
+
* Inbox at its top. The marker stays, so the Admin's tokens still reach the
|
|
57
|
+
* settings in both modes. Which routes those are is `isSettingsPath` — an
|
|
58
|
+
* exact match on `SETTINGS_ROUTES`, the list the settings tabs read too
|
|
59
|
+
* (#8441), so a sub-route stays inside the shell.
|
|
38
60
|
*/
|
|
39
61
|
|
|
40
62
|
import {
|
|
@@ -50,7 +72,7 @@ import {
|
|
|
50
72
|
type SignedInShellProps,
|
|
51
73
|
} from "@working-theory/templates/shells";
|
|
52
74
|
import { localeHref } from "@working-theory/ui";
|
|
53
|
-
import { Link } from "@working-theory/ui/primitives";
|
|
75
|
+
import { InlineMessage, Link } from "@working-theory/ui/primitives";
|
|
54
76
|
import { createAuthClient } from "better-auth/react";
|
|
55
77
|
import { Inbox, Layers, Settings, Sparkles } from "lucide-react";
|
|
56
78
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
@@ -58,6 +80,7 @@ import { useState, type ReactElement, type ReactNode } from "react";
|
|
|
58
80
|
|
|
59
81
|
import { ACCOUNT_PERSIST_KEY } from "./for-you-labels";
|
|
60
82
|
import { NewMenuSlotProvider } from "./new-menu";
|
|
83
|
+
import { isSettingsPath } from "./settings-frame";
|
|
61
84
|
|
|
62
85
|
const authClient = createAuthClient();
|
|
63
86
|
|
|
@@ -74,6 +97,8 @@ export interface AccountFrameLabels extends SignedInShellLabels {
|
|
|
74
97
|
/** The rail's Library group heading (#8231 D1). */
|
|
75
98
|
library: string;
|
|
76
99
|
artifacts: string;
|
|
100
|
+
/** Shown when a hub sign-out is refused or fails (#8465). */
|
|
101
|
+
signOutFailed: string;
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
export interface AccountFrameProps {
|
|
@@ -84,6 +109,14 @@ export interface AccountFrameProps {
|
|
|
84
109
|
helpItems: NonNullable<SignedInShellProps["helpItems"]>;
|
|
85
110
|
forYou: { items: readonly ForYouItem[]; now: string; labels: ForYouLabels };
|
|
86
111
|
labels: AccountFrameLabels;
|
|
112
|
+
/**
|
|
113
|
+
* Where Log out POSTs on a spoke that signs in through its hub (the seam's
|
|
114
|
+
* `SIGN_OUT_ENDPOINT`, read by the server layout). Absent or `null`: Better
|
|
115
|
+
* Auth's client signs out (#8465).
|
|
116
|
+
*/
|
|
117
|
+
signOutEndpoint?: string | null;
|
|
118
|
+
/** Where Log out lands after a hub sign-out; defaults to the site home. */
|
|
119
|
+
signInHref?: string;
|
|
87
120
|
children: ReactNode;
|
|
88
121
|
}
|
|
89
122
|
|
|
@@ -94,6 +127,8 @@ export function AccountFrame({
|
|
|
94
127
|
helpItems,
|
|
95
128
|
forYou,
|
|
96
129
|
labels,
|
|
130
|
+
signOutEndpoint,
|
|
131
|
+
signInHref,
|
|
97
132
|
children,
|
|
98
133
|
}: AccountFrameProps): ReactElement {
|
|
99
134
|
const router = useRouter();
|
|
@@ -101,12 +136,23 @@ export function AccountFrame({
|
|
|
101
136
|
const searchParams = useSearchParams();
|
|
102
137
|
const [dismissed] = useForYouDismissed(ACCOUNT_PERSIST_KEY);
|
|
103
138
|
const [topBarActions, setTopBarActions] = useState<HTMLElement | null>(null);
|
|
139
|
+
const [signOutFailed, setSignOutFailed] = useState(false);
|
|
104
140
|
|
|
105
141
|
const homeHref = localeHref(locale, "/");
|
|
106
142
|
const inboxHref = localeHref(locale, "/account");
|
|
107
143
|
const artifactsHref = localeHref(locale, "/account/artifacts");
|
|
108
144
|
const settingsHref = localeHref(locale, "/account/general");
|
|
109
145
|
const within = (href: string): boolean => pathname === href || pathname.startsWith(`${href}/`);
|
|
146
|
+
|
|
147
|
+
// #8430 — a Settings page draws its own standalone frame: the marker only.
|
|
148
|
+
if (isSettingsPath(pathname, locale)) {
|
|
149
|
+
return (
|
|
150
|
+
<div className="contents" data-ailk-shell="account" data-testid="account-shell">
|
|
151
|
+
{children}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
110
156
|
// The Inbox row stays selected on an item's own page (/account/inbox/…).
|
|
111
157
|
const onInbox = pathname === inboxHref || pathname.startsWith(`${inboxHref}/inbox/`);
|
|
112
158
|
const activePath = onInbox ? inboxHref : within(artifactsHref) ? artifactsHref : undefined;
|
|
@@ -118,6 +164,24 @@ export function AccountFrame({
|
|
|
118
164
|
|
|
119
165
|
const onSignOut = (): void => {
|
|
120
166
|
void (async () => {
|
|
167
|
+
setSignOutFailed(false);
|
|
168
|
+
if (signOutEndpoint) {
|
|
169
|
+
// A refused or failed sign-out leaves the session cookie valid, so the
|
|
170
|
+
// page must not pretend otherwise by navigating away (#8465).
|
|
171
|
+
try {
|
|
172
|
+
const res = await fetch(signOutEndpoint, { method: "POST", credentials: "same-origin" });
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
setSignOutFailed(true);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
setSignOutFailed(true);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
router.push(signInHref ?? homeHref);
|
|
182
|
+
router.refresh();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
121
185
|
try {
|
|
122
186
|
await authClient.signOut();
|
|
123
187
|
} catch {
|
|
@@ -136,6 +200,7 @@ export function AccountFrame({
|
|
|
136
200
|
settings,
|
|
137
201
|
library,
|
|
138
202
|
artifacts,
|
|
203
|
+
signOutFailed: signOutFailedLabel,
|
|
139
204
|
...shellLabels
|
|
140
205
|
} = labels;
|
|
141
206
|
|
|
@@ -222,6 +287,11 @@ export function AccountFrame({
|
|
|
222
287
|
: {})}
|
|
223
288
|
labels={shellLabels}
|
|
224
289
|
>
|
|
290
|
+
{signOutFailed ? (
|
|
291
|
+
<InlineMessage variant="error" className="px-4 pt-3" data-testid="account-sign-out-error">
|
|
292
|
+
{signOutFailedLabel}
|
|
293
|
+
</InlineMessage>
|
|
294
|
+
) : null}
|
|
225
295
|
<PhoneTabProvider tab={phoneTab}>
|
|
226
296
|
<NewMenuSlotProvider node={onInboxRoute ? topBarActions : null}>
|
|
227
297
|
{children}
|
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
* `redirect()` throws `NEXT_REDIRECT` by design — called OUTSIDE any
|
|
34
34
|
* try/catch (mirrors `checkout.actions.ts`'s idiom) so a catch never
|
|
35
35
|
* swallows the navigation this action exists to perform.
|
|
36
|
+
*
|
|
37
|
+
* This file does NOT hold the carried sign-up-agreement consume step
|
|
38
|
+
* (`acceptCarriedAgreementsAction`, #8438 D13) — that lives in this
|
|
39
|
+
* directory's own `agreements-actions.ts` instead, deliberately kept apart
|
|
40
|
+
* from this file's `billing-source.ts` import: that module pulls in
|
|
41
|
+
* `account-data.ts`, `@working-theory/ui` and `lucide-react`, a far heavier
|
|
42
|
+
* footprint than the small, self-contained cookie codec needs. See
|
|
43
|
+
* `agreements-actions.ts`'s own header.
|
|
36
44
|
*/
|
|
37
45
|
|
|
38
46
|
import { redirect } from "next/navigation";
|