create-ailk 0.19.0 → 0.19.1
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/dist/add-module.js +7 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +9 -3
- package/dist/programmatic.js +8 -1
- package/dist/site-roster.js +21 -4
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +31 -1
- package/dist/topology.js +29 -1
- package/package.json +3 -3
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +10 -4
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -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/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +10 -4
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +11 -3
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- 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/__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-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +111 -16
- 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/consent.ts +488 -3
- 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/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- 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__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +89 -14
- 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 +122 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +15 -1
- 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 +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +4 -7
- 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 +66 -4
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +2 -2
- 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 +41 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +54 -14
- package/templates/database/CHANGELOG.md +16 -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__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/inbox/schema.prisma +76 -10
- 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/package.json +1 -1
- package/versions.json +35 -35
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* general/contact-actions.ts — User settings › General's contact-details write
|
|
5
|
+
* (#8435, spec docs/specs/8435-contact-details-on-the-client-spec.md D13; the
|
|
6
|
+
* route is `PATCH /v1/member/profile/contact`).
|
|
7
|
+
*
|
|
8
|
+
* `saveContact` replaces the member's contact details on THIS site —
|
|
9
|
+
* `member.updateContact`, never the hub account's name and photo
|
|
10
|
+
* (`profile-actions.ts`'s `saveProfile` is that) and never another site's
|
|
11
|
+
* record. It sends `forwardedCookieInit()`'s init: on a single site the session
|
|
12
|
+
* cookie plus the request's `Origin`, which `apps/api`'s cookie CSRF check
|
|
13
|
+
* needs; on a hub-mode spoke the hub credential. It takes no identity
|
|
14
|
+
* argument — who comes from the forwarded credential alone.
|
|
15
|
+
*
|
|
16
|
+
* It answers `{ ok: true }` or a refusal `reason` the form maps to its own
|
|
17
|
+
* line: `telephone` (a number that is not E.164, `400 telephone_invalid`),
|
|
18
|
+
* `invalid` (any other refused body), `unverified` (`403`), `unavailable`
|
|
19
|
+
* (`404`, `503`, or `409 contact_unavailable` — a Person of that email held by
|
|
20
|
+
* another account), else `failed`. Logs carry a status or `err.name` only —
|
|
21
|
+
* never a name, a number, an address or an email.
|
|
22
|
+
*
|
|
23
|
+
* Not a `*.actions.ts` file and no `*Action`-suffixed export: the write has no
|
|
24
|
+
* MCP twin by decision (8435 D12), and apps/mcp's parity test reads that
|
|
25
|
+
* suffix.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { member } from "@working-theory/api-client";
|
|
29
|
+
|
|
30
|
+
import { forwardedCookieInit } from "../../../../../lib/server-api";
|
|
31
|
+
|
|
32
|
+
/** PII-safe error identity for logs (the `profile-actions.ts` idiom: `err.name` only). */
|
|
33
|
+
function errorName(err: unknown): string {
|
|
34
|
+
return err instanceof Error ? err.name : "unknown";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The `error.code` of a member write's refusal body, when it has one. */
|
|
38
|
+
function errorCode(body: unknown): string | null {
|
|
39
|
+
if (typeof body !== "object" || body === null) return null;
|
|
40
|
+
const error = (body as { error?: unknown }).error;
|
|
41
|
+
if (typeof error !== "object" || error === null) return null;
|
|
42
|
+
const code = (error as { code?: unknown }).code;
|
|
43
|
+
return typeof code === "string" ? code : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SaveContactResult =
|
|
47
|
+
| { ok: true }
|
|
48
|
+
| {
|
|
49
|
+
ok: false;
|
|
50
|
+
reason: "telephone" | "invalid" | "unverified" | "unavailable" | "failed";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export async function saveContact(
|
|
54
|
+
input: Parameters<typeof member.updateContact>[0],
|
|
55
|
+
): Promise<SaveContactResult> {
|
|
56
|
+
// Action input is untrusted: a hand-built call may send anything, and the
|
|
57
|
+
// API's own schema is the validator — a non-object is refused before any call.
|
|
58
|
+
if (typeof input !== "object" || input === null)
|
|
59
|
+
return { ok: false, reason: "invalid" };
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const res = await member.updateContact(input, await forwardedCookieInit());
|
|
63
|
+
if (res.ok) return { ok: true };
|
|
64
|
+
console.error(`[contact-actions] saveContact status=${res.status}`);
|
|
65
|
+
if (res.status === 400) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason:
|
|
69
|
+
errorCode(res.body) === "telephone_invalid" ? "telephone" : "invalid",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (res.status === 403) return { ok: false, reason: "unverified" };
|
|
73
|
+
if (res.status === 404 || res.status === 409 || res.status === 503) {
|
|
74
|
+
return { ok: false, reason: "unavailable" };
|
|
75
|
+
}
|
|
76
|
+
return { ok: false, reason: "failed" };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error(`[contact-actions] saveContact errName=${errorName(err)}`);
|
|
79
|
+
return { ok: false, reason: "failed" };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
* /[locale]/account/general — User settings › General (#8339 D8; builds on
|
|
3
3
|
* #8232 D2).
|
|
4
4
|
*
|
|
5
|
-
* RSC,
|
|
6
|
-
*
|
|
5
|
+
* RSC, outside the account layout's SignedInShell (#8430), in the member's
|
|
6
|
+
* four-tab settings frame (D7) — standalone, the Admin's shape. Renders the shared
|
|
7
7
|
* `AccountSettings` composition (@working-theory/templates/account, spec
|
|
8
8
|
* 8339 D4) — the same General form the Admin renders — through the client
|
|
9
9
|
* adapter `GeneralSettings` (`profile-form.tsx`). Below the form:
|
|
10
10
|
*
|
|
11
|
+
* - the contact details (`AccountContactDetails`, #8435 D13) — only on the
|
|
12
|
+
* member path, seeded from `member.contact` and saved by `saveContact`;
|
|
11
13
|
* - Request account deletion (`DeletionRequest`, #8232 D5);
|
|
12
14
|
* - any section a module contributes (`accountSettingsSections`);
|
|
13
15
|
* - `BillingAnchor`, which forwards a `#billing` fragment to Billing.
|
|
@@ -19,9 +21,15 @@
|
|
|
19
21
|
* `loadProfileSource` (D3), never a topology test here.
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
|
-
import {
|
|
24
|
+
import { member } from "@working-theory/api-client";
|
|
25
|
+
import {
|
|
26
|
+
AccountContactDetails,
|
|
27
|
+
AccountSettingsFrame,
|
|
28
|
+
AccountSettingsPane,
|
|
29
|
+
type AccountContact,
|
|
30
|
+
} from "@working-theory/templates/account";
|
|
23
31
|
import { localeHref } from "@working-theory/ui";
|
|
24
|
-
import { Separator } from "@working-theory/ui/primitives";
|
|
32
|
+
import { Separator, Text } from "@working-theory/ui/primitives";
|
|
25
33
|
import type { Metadata } from "next";
|
|
26
34
|
import { getTranslations } from "next-intl/server";
|
|
27
35
|
import type { ReactElement } from "react";
|
|
@@ -32,6 +40,7 @@ import { siteThemeModeCount } from "../settings/theme-modes";
|
|
|
32
40
|
import { memberSettingsFrame, SettingsSection, type Translate } from "../settings-frame";
|
|
33
41
|
|
|
34
42
|
import { BillingAnchor } from "./billing-anchor";
|
|
43
|
+
import { saveContact } from "./contact-actions";
|
|
35
44
|
import { DeletionRequest } from "./deletion-request";
|
|
36
45
|
import { GeneralSettings } from "./profile-form";
|
|
37
46
|
import { loadProfileSource } from "./profile-source";
|
|
@@ -70,6 +79,19 @@ export default async function AccountGeneralPage({ params }: Props): Promise<Rea
|
|
|
70
79
|
const { ctx } = session;
|
|
71
80
|
const source = await loadProfileSource(ctx);
|
|
72
81
|
|
|
82
|
+
// #8435 D13 — the person's confirmed contact on this site. A failed read
|
|
83
|
+
// renders the section's "unavailable" line, never the form: a form seeded
|
|
84
|
+
// from nothing would offer to replace details it never showed.
|
|
85
|
+
let contactRead: { ok: true; contact: AccountContact | null } | { ok: false } | null = null;
|
|
86
|
+
if (source.via === "member") {
|
|
87
|
+
try {
|
|
88
|
+
const res = await member.contact(ctx.init);
|
|
89
|
+
contactRead = res.ok ? { ok: true, contact: res.body.contact } : { ok: false };
|
|
90
|
+
} catch {
|
|
91
|
+
contactRead = { ok: false };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
73
95
|
return (
|
|
74
96
|
<GeneralSettings
|
|
75
97
|
frame={memberSettingsFrame(locale, t as unknown as Translate, "general")}
|
|
@@ -102,6 +124,46 @@ export default async function AccountGeneralPage({ params }: Props): Promise<Rea
|
|
|
102
124
|
}
|
|
103
125
|
via={source.via}
|
|
104
126
|
>
|
|
127
|
+
{contactRead ? (
|
|
128
|
+
<>
|
|
129
|
+
<Separator />
|
|
130
|
+
<div data-testid="account-contact-section">
|
|
131
|
+
{contactRead.ok ? (
|
|
132
|
+
<AccountContactDetails
|
|
133
|
+
contact={contactRead.contact}
|
|
134
|
+
onSave={saveContact}
|
|
135
|
+
labels={{
|
|
136
|
+
title: t("settings.general.contact.title"),
|
|
137
|
+
lead: t("settings.general.contact.lead"),
|
|
138
|
+
givenName: t("settings.general.contact.givenName"),
|
|
139
|
+
familyName: t("settings.general.contact.familyName"),
|
|
140
|
+
telephone: t("settings.general.contact.telephone"),
|
|
141
|
+
address: t("settings.general.contact.address"),
|
|
142
|
+
companyName: t("settings.general.contact.companyName"),
|
|
143
|
+
jobTitle: t("settings.general.contact.jobTitle"),
|
|
144
|
+
save: t("settings.general.contact.save"),
|
|
145
|
+
saving: t("settings.general.contact.saving"),
|
|
146
|
+
saved: t("settings.general.contact.saved"),
|
|
147
|
+
addressIncomplete: t("settings.general.contact.addressIncomplete"),
|
|
148
|
+
errors: {
|
|
149
|
+
telephone: t("settings.general.contact.errors.telephone"),
|
|
150
|
+
invalid: t("settings.general.contact.errors.invalid"),
|
|
151
|
+
unverified: t("settings.general.contact.errors.unverified"),
|
|
152
|
+
unavailable: t("settings.general.contact.errors.unavailable"),
|
|
153
|
+
failed: t("settings.general.contact.errors.failed"),
|
|
154
|
+
},
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
) : (
|
|
158
|
+
<span data-testid="account-contact-unavailable">
|
|
159
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
160
|
+
{t("settings.general.contact.unavailable")}
|
|
161
|
+
</Text>
|
|
162
|
+
</span>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</>
|
|
166
|
+
) : null}
|
|
105
167
|
<Separator />
|
|
106
168
|
<div data-testid="account-deletion">
|
|
107
169
|
<DeletionRequest
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* The account layout — the base account area's frame (#7430, spec 7423 U4 ·
|
|
3
3
|
* D4, D13; DESIGN.md §2b).
|
|
4
4
|
*
|
|
5
|
-
* RSC. Every `/account` route
|
|
6
|
-
*
|
|
5
|
+
* RSC. Every `/account` route but the four Settings tabs renders inside
|
|
6
|
+
* `SignedInShell` (through the client `AccountFrame`; the tabs draw their own
|
|
7
|
+
* standalone settings frame, #8430): the site's logo as the rail's top tile, linking
|
|
7
8
|
* home; the rail's rows — Inbox, a Library group holding Artifacts, then
|
|
8
9
|
* Settings (#8231 D1); a footer of the avatar and ? alone;
|
|
9
10
|
* the card avatar menu (name, then Settings and Log out); the ? help menu
|
|
@@ -16,6 +17,10 @@
|
|
|
16
17
|
* area's own "Add your name" item (`loadForYou`, deduped per request with
|
|
17
18
|
* the Inbox's phone For you tab); with no items there is no aside.
|
|
18
19
|
*
|
|
20
|
+
* #8465 — `SIGN_OUT_ENDPOINT` (set only on a spoke that signs in through its
|
|
21
|
+
* hub) and `SIGN_IN_PATH` ride down as props; the client frame cannot import
|
|
22
|
+
* the generated seam, and the signed-in Nav reads it the same way.
|
|
23
|
+
*
|
|
19
24
|
* The `(authed)` layout above owns the signed-out gate, so a missing session
|
|
20
25
|
* here is a race (it expired between the two layouts): the page renders
|
|
21
26
|
* unframed and degrades on its own.
|
|
@@ -26,11 +31,22 @@ import { getTranslations } from "next-intl/server";
|
|
|
26
31
|
import type { ReactElement, ReactNode } from "react";
|
|
27
32
|
|
|
28
33
|
import { siteLogo } from "../../../../lib/site-logo";
|
|
34
|
+
import { SIGN_IN_PATH, SIGN_OUT_ENDPOINT } from "../../../../lib/topology.generated";
|
|
29
35
|
|
|
30
36
|
import { accountSession, loadForYou } from "./account-data";
|
|
31
37
|
import { AccountFrame } from "./account-frame";
|
|
32
38
|
import { forYouLabels } from "./for-you-labels";
|
|
33
39
|
|
|
40
|
+
/** The value when it parses as an https URL, else undefined: operator config is rendered as an href. */
|
|
41
|
+
function httpsUrl(value: string | undefined): string | undefined {
|
|
42
|
+
if (!value) return undefined;
|
|
43
|
+
try {
|
|
44
|
+
return new URL(value).protocol === "https:" ? value : undefined;
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
interface Props {
|
|
35
51
|
children: ReactNode;
|
|
36
52
|
params: Promise<{ locale: string }>;
|
|
@@ -47,7 +63,7 @@ export default async function AccountLayout({
|
|
|
47
63
|
const t = await getTranslations({ locale, namespace: "account" });
|
|
48
64
|
const forYou = await loadForYou(locale);
|
|
49
65
|
const { name, email } = session.ctx.user;
|
|
50
|
-
const helpCenterUrl = process.env.AILK_HELP_CENTER_URL;
|
|
66
|
+
const helpCenterUrl = httpsUrl(process.env.AILK_HELP_CENTER_URL);
|
|
51
67
|
|
|
52
68
|
return (
|
|
53
69
|
<AccountFrame
|
|
@@ -65,6 +81,12 @@ export default async function AccountLayout({
|
|
|
65
81
|
: []),
|
|
66
82
|
]}
|
|
67
83
|
forYou={{ items: forYou.items, now: forYou.now, labels: forYouLabels(session.ctx.t) }}
|
|
84
|
+
{...(SIGN_OUT_ENDPOINT
|
|
85
|
+
? {
|
|
86
|
+
signOutEndpoint: SIGN_OUT_ENDPOINT,
|
|
87
|
+
signInHref: localeHref(locale, SIGN_IN_PATH ?? "/"),
|
|
88
|
+
}
|
|
89
|
+
: {})}
|
|
68
90
|
labels={{
|
|
69
91
|
home: t("frame.home"),
|
|
70
92
|
inbox: t("frame.inbox"),
|
|
@@ -72,6 +94,7 @@ export default async function AccountLayout({
|
|
|
72
94
|
settings: t("frame.settings"),
|
|
73
95
|
library: t("frame.library"),
|
|
74
96
|
artifacts: t("frame.artifacts"),
|
|
97
|
+
signOutFailed: t("frame.signOutFailed"),
|
|
75
98
|
signOut: t("frame.signOut"),
|
|
76
99
|
help: t("frame.help"),
|
|
77
100
|
accountMenu: t("frame.accountMenu"),
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* /[locale]/account/notifications — User settings › Notifications (#8339
|
|
3
3
|
* D8, NEW).
|
|
4
4
|
*
|
|
5
|
-
* RSC,
|
|
6
|
-
*
|
|
5
|
+
* RSC, outside the account layout's SignedInShell (#8430), in the member's
|
|
6
|
+
* four-tab settings frame (D7) — standalone, the Admin's shape. Holds the former Privacy
|
|
7
7
|
* section's cookie-settings link, moved here verbatim from General (#8339
|
|
8
8
|
* C6) — for now; notification preferences are a later unit's scope.
|
|
9
9
|
*/
|
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /[locale]/account/security — User settings › Security (#8339 D8
|
|
2
|
+
* /[locale]/account/security — User settings › Security (#8339 D8; #8431 D3).
|
|
3
3
|
*
|
|
4
|
-
* RSC,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* RSC, outside the account layout's SignedInShell (#8430), in the member's
|
|
5
|
+
* four-tab settings frame (D7) — standalone, the Admin's shape. It shows how
|
|
6
|
+
* this person signs in, then Log out, through the shared `SecuritySettings`
|
|
7
|
+
* (`security-panel.tsx` supplies its actions):
|
|
8
|
+
*
|
|
9
|
+
* - a site or hub that can email a password link: the Password row, set
|
|
10
|
+
* or changed by an emailed link to the person's own address;
|
|
11
|
+
* - any other site: one sentence naming the deployment's methods;
|
|
12
|
+
* - a spoke that signs in through its hub: "Your sign-in is managed at
|
|
13
|
+
* <hub>", with a link to the hub's own Security page.
|
|
14
|
+
*
|
|
15
|
+
* `security-source.ts` picks the variant; this page turns it into text.
|
|
9
16
|
*/
|
|
10
17
|
|
|
11
18
|
import { AccountSettingsFrame, AccountSettingsPane } from "@working-theory/templates/account";
|
|
12
|
-
import {
|
|
13
|
-
import { Text } from "@working-theory/ui/primitives";
|
|
19
|
+
import { localeHref } from "@working-theory/ui";
|
|
14
20
|
import type { Metadata } from "next";
|
|
21
|
+
import { headers } from "next/headers";
|
|
15
22
|
import { getTranslations } from "next-intl/server";
|
|
16
23
|
import type { ReactElement } from "react";
|
|
17
24
|
|
|
25
|
+
import { SIGN_IN_PATH, SIGN_OUT_ENDPOINT } from "../../../../../lib/topology.generated";
|
|
18
26
|
import { accountSession } from "../account-data";
|
|
27
|
+
import { ErrorRow } from "../error-row";
|
|
19
28
|
import { memberSettingsFrame, type Translate } from "../settings-frame";
|
|
20
|
-
import {
|
|
29
|
+
import type { SignInMethodId } from "../sign-in-method";
|
|
30
|
+
|
|
31
|
+
import { SecurityPanel, type SecurityPanelVariant } from "./security-panel";
|
|
32
|
+
import { securitySource } from "./security-source";
|
|
21
33
|
|
|
22
34
|
interface Props {
|
|
23
35
|
params: Promise<{ locale: string }>;
|
|
@@ -29,12 +41,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
29
41
|
return { title: t("settings.security.title"), description: t("settings.signIn.lead") };
|
|
30
42
|
}
|
|
31
43
|
|
|
32
|
-
/** Each sign-in method's
|
|
44
|
+
/** Each sign-in method's phrase in the sentence (C5). */
|
|
33
45
|
const METHOD_KEYS = {
|
|
34
|
-
google: "settings.
|
|
35
|
-
github: "settings.
|
|
36
|
-
"magic-link": "settings.
|
|
37
|
-
email: "settings.
|
|
46
|
+
google: "settings.security.methods.google",
|
|
47
|
+
github: "settings.security.methods.github",
|
|
48
|
+
"magic-link": "settings.security.methods.magicLink",
|
|
49
|
+
email: "settings.security.methods.password",
|
|
38
50
|
} as const satisfies Record<SignInMethodId, string>;
|
|
39
51
|
|
|
40
52
|
export default async function AccountSecurityPage({ params }: Props): Promise<ReactElement> {
|
|
@@ -42,9 +54,41 @@ export default async function AccountSecurityPage({ params }: Props): Promise<Re
|
|
|
42
54
|
const t = await getTranslations({ locale, namespace: "account" });
|
|
43
55
|
const session = await accountSession(locale);
|
|
44
56
|
const frame = memberSettingsFrame(locale, t as unknown as Translate, "security");
|
|
45
|
-
const
|
|
57
|
+
const email = session?.ctx.user.email ?? "";
|
|
58
|
+
const name = session?.ctx.user.name || email;
|
|
59
|
+
|
|
60
|
+
const source = await securitySource(await headers());
|
|
46
61
|
|
|
47
|
-
|
|
62
|
+
let variant: SecurityPanelVariant | null;
|
|
63
|
+
if (source.kind === "password") {
|
|
64
|
+
variant = source.hasPassword === null ? null : { kind: "password", hasPassword: source.hasPassword };
|
|
65
|
+
} else if (source.kind === "sentence") {
|
|
66
|
+
const methods = source.methods.map((id) => t(METHOD_KEYS[id], { email }));
|
|
67
|
+
variant = {
|
|
68
|
+
kind: "sentence",
|
|
69
|
+
text:
|
|
70
|
+
methods.length > 0
|
|
71
|
+
? t("settings.security.signsInWith", {
|
|
72
|
+
methods: new Intl.ListFormat(locale, { type: "disjunction" }).format(methods),
|
|
73
|
+
})
|
|
74
|
+
: t("settings.security.signedInAs", { email }),
|
|
75
|
+
};
|
|
76
|
+
} else {
|
|
77
|
+
variant = {
|
|
78
|
+
kind: "hub",
|
|
79
|
+
text:
|
|
80
|
+
source.hubHost !== null
|
|
81
|
+
? t("settings.security.managedAt", { hub: source.hubHost })
|
|
82
|
+
: t("settings.security.managedUnnamed"),
|
|
83
|
+
link:
|
|
84
|
+
source.hubHost !== null && source.hubOrigin !== null
|
|
85
|
+
? {
|
|
86
|
+
href: new URL("/account/security", source.hubOrigin).href,
|
|
87
|
+
label: t("settings.security.manageOnHub", { hub: source.hubHost }),
|
|
88
|
+
}
|
|
89
|
+
: null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
48
92
|
|
|
49
93
|
return (
|
|
50
94
|
<AccountSettingsFrame {...frame} name={name}>
|
|
@@ -53,27 +97,35 @@ export default async function AccountSecurityPage({ params }: Props): Promise<Re
|
|
|
53
97
|
title={t("settings.security.title")}
|
|
54
98
|
lead={t("settings.signIn.lead")}
|
|
55
99
|
>
|
|
56
|
-
<
|
|
57
|
-
{
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
100
|
+
<SecurityPanel
|
|
101
|
+
variant={variant}
|
|
102
|
+
failed={
|
|
103
|
+
<ErrorRow message={t("settings.security.passwordUnavailable")} retryLabel={t("inbox.retry")} />
|
|
104
|
+
}
|
|
105
|
+
email={email}
|
|
106
|
+
// Absolute and same-origin, built here from BETTER_AUTH_URL — the
|
|
107
|
+
// `(auth)/forgot-password/page.tsx` derivation.
|
|
108
|
+
resetUrl={`${process.env.BETTER_AUTH_URL ?? ""}${localeHref(locale, "/reset-password")}`}
|
|
109
|
+
signOutEndpoint={SIGN_OUT_ENDPOINT}
|
|
110
|
+
signInHref={localeHref(locale, SIGN_IN_PATH ?? "/")}
|
|
111
|
+
labels={{
|
|
112
|
+
methodsHeading: t("settings.security.methodsHeading"),
|
|
113
|
+
passwordLabel: t("settings.security.passwordLabel"),
|
|
114
|
+
passwordSet: t("settings.security.passwordSet"),
|
|
115
|
+
passwordNotSet: t("settings.security.passwordNotSet"),
|
|
116
|
+
setHint: t("settings.security.setHint"),
|
|
117
|
+
changeHint: t("settings.security.changeHint"),
|
|
118
|
+
setNote: t("settings.security.setNote", { email }),
|
|
119
|
+
changeNote: t("settings.security.changeNote", { email }),
|
|
120
|
+
setButton: t("settings.security.setButton"),
|
|
121
|
+
changeButton: t("settings.security.changeButton"),
|
|
122
|
+
sending: t("settings.security.sending"),
|
|
123
|
+
sent: t("settings.security.sent"),
|
|
124
|
+
sendFailed: t("settings.security.sendFailed"),
|
|
125
|
+
logOut: t("settings.security.logOut"),
|
|
126
|
+
logOutFailed: t("settings.security.logOutFailed"),
|
|
127
|
+
}}
|
|
128
|
+
/>
|
|
77
129
|
</AccountSettingsPane>
|
|
78
130
|
</AccountSettingsFrame>
|
|
79
131
|
);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client"; // Better Auth's client calls (requestPasswordReset, signOut) and router navigation need the browser
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SecurityPanel — the member's adapter over `SecuritySettings` (#8431 D3).
|
|
5
|
+
*
|
|
6
|
+
* The page (RSC) resolves the variant, the strings, the person's own address,
|
|
7
|
+
* the reset link's absolute `redirectTo` and where Log out lands; this island
|
|
8
|
+
* supplies the two actions:
|
|
9
|
+
*
|
|
10
|
+
* - the Password row's request: Better Auth's `requestPasswordReset` for
|
|
11
|
+
* the person's OWN address, with the server-built `redirectTo` (never one
|
|
12
|
+
* derived from `window.location` — `forgot-password-card.tsx`'s rule);
|
|
13
|
+
* - Log out: the spoke's same-origin sign-out endpoint when the page passes
|
|
14
|
+
* one, else Better Auth's `signOut`. Only a log-out that succeeded
|
|
15
|
+
* navigates (the `SignOutTrigger` rule).
|
|
16
|
+
*
|
|
17
|
+
* Its `authClient` is its own module-scope `createAuthClient()`, the
|
|
18
|
+
* `account-frame.tsx` idiom — never imported from `(auth)/`, a tree a spoke
|
|
19
|
+
* drops.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
SecuritySettings,
|
|
24
|
+
type SecuritySettingsLabels,
|
|
25
|
+
type SecuritySignIn,
|
|
26
|
+
} from "@working-theory/templates/account";
|
|
27
|
+
import { createAuthClient } from "better-auth/react";
|
|
28
|
+
import { useRouter } from "next/navigation";
|
|
29
|
+
import type { ReactElement, ReactNode } from "react";
|
|
30
|
+
|
|
31
|
+
const authClient = createAuthClient();
|
|
32
|
+
|
|
33
|
+
/** The variant as the page resolved it — serializable, no functions. */
|
|
34
|
+
export type SecurityPanelVariant =
|
|
35
|
+
| { kind: "password"; hasPassword: boolean }
|
|
36
|
+
| { kind: "sentence"; text: string }
|
|
37
|
+
| { kind: "hub"; text: string; link: { href: string; label: string } | null };
|
|
38
|
+
|
|
39
|
+
export interface SecurityPanelProps {
|
|
40
|
+
/** `null`: the accounts read failed; `failed` renders in its place. */
|
|
41
|
+
variant: SecurityPanelVariant | null;
|
|
42
|
+
failed?: ReactNode;
|
|
43
|
+
/** The session's own address — the only one a link is ever requested for. */
|
|
44
|
+
email: string;
|
|
45
|
+
/** The absolute reset page the emailed link returns to. */
|
|
46
|
+
resetUrl: string;
|
|
47
|
+
/** A spoke's same-origin sign-out route; `null` signs out through Better Auth. */
|
|
48
|
+
signOutEndpoint: string | null;
|
|
49
|
+
/** The site's sign-in page, where Log out lands. */
|
|
50
|
+
signInHref: string;
|
|
51
|
+
labels: SecuritySettingsLabels;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function SecurityPanel({
|
|
55
|
+
variant,
|
|
56
|
+
failed,
|
|
57
|
+
email,
|
|
58
|
+
resetUrl,
|
|
59
|
+
signOutEndpoint,
|
|
60
|
+
signInHref,
|
|
61
|
+
labels,
|
|
62
|
+
}: SecurityPanelProps): ReactElement {
|
|
63
|
+
const router = useRouter();
|
|
64
|
+
|
|
65
|
+
async function requestLink(): Promise<boolean> {
|
|
66
|
+
// A refusal (the rate limit, the method off) resolves with `{ error }`; a
|
|
67
|
+
// network failure rejects. Both read as not sent.
|
|
68
|
+
return authClient.requestPasswordReset({ email, redirectTo: resetUrl }).then(
|
|
69
|
+
(res) => !res?.error,
|
|
70
|
+
() => false,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function logOut(): Promise<boolean> {
|
|
75
|
+
let ok: boolean;
|
|
76
|
+
try {
|
|
77
|
+
if (signOutEndpoint !== null) {
|
|
78
|
+
const res = await fetch(signOutEndpoint, { method: "POST", credentials: "same-origin" });
|
|
79
|
+
ok = res.ok;
|
|
80
|
+
} else {
|
|
81
|
+
const res = await authClient.signOut();
|
|
82
|
+
ok = !res?.error;
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
ok = false;
|
|
86
|
+
}
|
|
87
|
+
// A failed log-out leaves the person signed in: navigate nowhere.
|
|
88
|
+
if (!ok) return false;
|
|
89
|
+
router.push(signInHref);
|
|
90
|
+
router.refresh();
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const signIn: SecuritySignIn | null =
|
|
95
|
+
variant === null
|
|
96
|
+
? null
|
|
97
|
+
: variant.kind === "password"
|
|
98
|
+
? { kind: "password", hasPassword: variant.hasPassword, onRequestLink: requestLink }
|
|
99
|
+
: variant;
|
|
100
|
+
|
|
101
|
+
return <SecuritySettings signIn={signIn} failed={failed} onLogOut={logOut} labels={labels} />;
|
|
102
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* account/security/security-source.ts (server code) — which sign-in variant
|
|
3
|
+
* the member's Security tab shows (#8431 D2, spec
|
|
4
|
+
* docs/specs/8431-account-security-tab-human-sign-spec.md).
|
|
5
|
+
*
|
|
6
|
+
* - hub — a spoke that signs in through its hub: the hub's host and
|
|
7
|
+
* origin, from `signInMethod()` and `hubSessionConfig()`.
|
|
8
|
+
* - password — a site or hub that can email a password link
|
|
9
|
+
* (`resetSendConfigured()`, C1): whether this person has a
|
|
10
|
+
* password, from the session's own accounts.
|
|
11
|
+
* - sentence — any other site: the deployment's configured methods.
|
|
12
|
+
*
|
|
13
|
+
* It tests no topology constant and imports nothing that loads `auth.ts`:
|
|
14
|
+
* `sign-in-method.ts` and the generated seam stay the account tree's seams
|
|
15
|
+
* (a hub-sign-in spoke drops `auth.ts`, and `spokeExclusions` refuses any
|
|
16
|
+
* surviving account file that reaches it).
|
|
17
|
+
*
|
|
18
|
+
* The seam is read as a namespace, never by a named import: a site whose
|
|
19
|
+
* seam was rendered before #8431 lacks the accounts reader (D2a), and a
|
|
20
|
+
* Security route `sync-routes` copies into it must still compile. There it
|
|
21
|
+
* shows the sentence — a retry cannot fix a stale seam (D2b).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { resetSendConfigured } from "../../../../../lib/auth-env";
|
|
25
|
+
import { hubSessionConfig } from "../../../../../lib/hub-session/config";
|
|
26
|
+
import * as seam from "../../../../../lib/topology.generated";
|
|
27
|
+
import { logReadFailure } from "../account-data";
|
|
28
|
+
import { signInMethod, type SignInMethodId } from "../sign-in-method";
|
|
29
|
+
|
|
30
|
+
export type SecuritySource =
|
|
31
|
+
/** `hasPassword: null` — the accounts read failed. */
|
|
32
|
+
| { kind: "password"; hasPassword: boolean | null }
|
|
33
|
+
| { kind: "sentence"; methods: SignInMethodId[] }
|
|
34
|
+
| { kind: "hub"; hubHost: string | null; hubOrigin: string | null };
|
|
35
|
+
|
|
36
|
+
type OwnAccountsReader = (headers: Headers) => Promise<ReadonlyArray<{ providerId: string }> | null>;
|
|
37
|
+
|
|
38
|
+
/** The seam's accounts reader (D2a), or `null` on a seam rendered before it existed. */
|
|
39
|
+
function ownAccountsReader(): OwnAccountsReader | null {
|
|
40
|
+
const reader: unknown = Reflect.get(seam, "readOwnAccounts");
|
|
41
|
+
return typeof reader === "function" ? (reader as OwnAccountsReader) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function securitySource(headers: Headers): Promise<SecuritySource> {
|
|
45
|
+
const signIn = signInMethod();
|
|
46
|
+
if (signIn.kind === "hub") {
|
|
47
|
+
return { kind: "hub", hubHost: signIn.hubHost, hubOrigin: hubSessionConfig()?.hubOrigin ?? null };
|
|
48
|
+
}
|
|
49
|
+
if (!resetSendConfigured()) return { kind: "sentence", methods: signIn.methods };
|
|
50
|
+
|
|
51
|
+
const read = ownAccountsReader();
|
|
52
|
+
if (read === null) return { kind: "sentence", methods: signIn.methods };
|
|
53
|
+
try {
|
|
54
|
+
const accounts = await read(headers);
|
|
55
|
+
// `null` is a topology with no local accounts, which never reaches here.
|
|
56
|
+
if (accounts === null) return { kind: "password", hasPassword: null };
|
|
57
|
+
return { kind: "password", hasPassword: accounts.some((a) => a.providerId === "credential") };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
logReadFailure("security-password", err);
|
|
60
|
+
return { kind: "password", hasPassword: null };
|
|
61
|
+
}
|
|
62
|
+
}
|