create-ailk 0.18.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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +11 -3
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +32 -0
- package/dist/lib/scaffold-render-sweep.js +76 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +128 -3
- package/dist/programmatic.js +11 -1
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +170 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +88 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +31 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- 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 +17 -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__/deliverable-asset-store.test.ts +38 -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/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +176 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +214 -46
- 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 +19 -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 +218 -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 +173 -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 +135 -14
- 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/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- 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 +302 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- 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 +232 -29
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
- 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/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +25 -2
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +194 -33
- package/templates/database/CHANGELOG.md +37 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- 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__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -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/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +166 -25
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- 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 +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /[locale]/account/notifications — User settings › Notifications (#8339
|
|
3
|
+
* D8, NEW).
|
|
4
|
+
*
|
|
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
|
+
* section's cookie-settings link, moved here verbatim from General (#8339
|
|
8
|
+
* C6) — for now; notification preferences are a later unit's scope.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { AccountSettingsFrame, AccountSettingsPane } from "@working-theory/templates/account";
|
|
12
|
+
import { localeHref } from "@working-theory/ui";
|
|
13
|
+
import { Link } from "@working-theory/ui/primitives";
|
|
14
|
+
import type { Metadata } from "next";
|
|
15
|
+
import { getTranslations } from "next-intl/server";
|
|
16
|
+
import type { ReactElement } from "react";
|
|
17
|
+
|
|
18
|
+
import { accountSession } from "../account-data";
|
|
19
|
+
import { memberSettingsFrame, type Translate } from "../settings-frame";
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
params: Promise<{ locale: string }>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
26
|
+
const { locale } = await params;
|
|
27
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
28
|
+
return { title: t("settings.notifications.title"), description: t("settings.privacy.lead") };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default async function AccountNotificationsPage({ params }: Props): Promise<ReactElement> {
|
|
32
|
+
const { locale } = await params;
|
|
33
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
34
|
+
const session = await accountSession(locale);
|
|
35
|
+
const frame = memberSettingsFrame(locale, t as unknown as Translate, "notifications");
|
|
36
|
+
const name = session?.ctx.user.name || session?.ctx.user.email || "";
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<AccountSettingsFrame {...frame} name={name}>
|
|
40
|
+
<AccountSettingsPane
|
|
41
|
+
id="account-settings-notifications"
|
|
42
|
+
title={t("settings.notifications.title")}
|
|
43
|
+
lead={t("settings.privacy.lead")}
|
|
44
|
+
>
|
|
45
|
+
<div>
|
|
46
|
+
<Link
|
|
47
|
+
href={localeHref(locale, "/privacy#cookie-settings")}
|
|
48
|
+
className="inline-flex min-h-6 items-center"
|
|
49
|
+
data-testid="account-cookie-settings"
|
|
50
|
+
>
|
|
51
|
+
{t("settings.privacy.cookies")}
|
|
52
|
+
</Link>
|
|
53
|
+
</div>
|
|
54
|
+
</AccountSettingsPane>
|
|
55
|
+
</AccountSettingsFrame>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* /[locale]/account — the base account area's Inbox (#7430, spec 7423 U4 ·
|
|
3
|
-
* D6a, D9, D12; DESIGN.md §5.4).
|
|
3
|
+
* D6a, D9, D12; #8231 D3; DESIGN.md §5.4).
|
|
4
4
|
*
|
|
5
5
|
* RSC, rendered inside the account layout's SignedInShell. One filtered
|
|
6
|
-
* list: the
|
|
7
|
-
*
|
|
8
|
-
* (`@working-theory/templates/account`):
|
|
6
|
+
* list: the tabs All · Purchases · Scheduled, each with a count, All
|
|
7
|
+
* selected, through the shared `InboxList` (`@working-theory/templates/account`):
|
|
9
8
|
*
|
|
10
|
-
* -
|
|
11
|
-
* `
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
* - Scheduled — the kinds whose section is `upcoming` (
|
|
15
|
-
*
|
|
16
|
-
*
|
|
9
|
+
* - All — every loaded row, newest first. The kinds in `updates` or
|
|
10
|
+
* `needs-you` (`result`, `piece`, `message`) show here only.
|
|
11
|
+
* - Purchases — the kinds whose section is `purchases` (the `purchase`
|
|
12
|
+
* kind, and any a module adds), newest first.
|
|
13
|
+
* - Scheduled — the kinds whose section is `upcoming` (`appointment`):
|
|
14
|
+
* first the items still upcoming, soonest first (no time last), then the
|
|
15
|
+
* rest, newest first. Each kind's own read decides which is upcoming.
|
|
17
16
|
*
|
|
18
17
|
* What needs the person is never an Inbox row: `email-unverified`,
|
|
19
18
|
* `payment-due` and "Add your name" are For-you items (D9 as amended). From
|
|
@@ -33,16 +32,24 @@
|
|
|
33
32
|
* D12 — any read that fails (a kind or a For-you source) renders an error
|
|
34
33
|
* row with Retry above the list; the list then holds what DID load, never a
|
|
35
34
|
* silently empty Inbox.
|
|
35
|
+
*
|
|
36
|
+
* #8233 D4 — New sits in the h1's header row (and, below `md`, in the
|
|
37
|
+
* frame's phone top bar): its rows are `new-popups.tsx`'s `newActions` — the
|
|
38
|
+
* `accountNewActions` of `lib/account-config.ts` with their popups bound —
|
|
39
|
+
* whose `available` read resolved `true`, each opening its popup over the
|
|
40
|
+
* Inbox. None offered (a Free build) → no New at all. A read that rejected
|
|
41
|
+
* drops its row and shows the error row too — a failed read is never silent.
|
|
36
42
|
*/
|
|
37
43
|
|
|
38
44
|
import {
|
|
39
45
|
ForYouPanel,
|
|
40
46
|
InboxList,
|
|
41
47
|
PhoneTabPanel,
|
|
48
|
+
type AccountContext,
|
|
42
49
|
type InboxListRow,
|
|
43
50
|
} from "@working-theory/templates/account";
|
|
44
51
|
import { Heading, Text } from "@working-theory/ui/primitives";
|
|
45
|
-
import { ClipboardList, Receipt, type LucideProps } from "lucide-react";
|
|
52
|
+
import { CalendarClock, ClipboardList, Receipt, type LucideProps } from "lucide-react";
|
|
46
53
|
import type { Metadata } from "next";
|
|
47
54
|
import { getTranslations } from "next-intl/server";
|
|
48
55
|
import type { ComponentType, ReactElement } from "react";
|
|
@@ -52,10 +59,13 @@ import {
|
|
|
52
59
|
inboxItemHref,
|
|
53
60
|
loadForYou,
|
|
54
61
|
loadInboxItems,
|
|
62
|
+
logReadFailure,
|
|
55
63
|
type LoadedInboxItem,
|
|
56
64
|
} from "./account-data";
|
|
57
65
|
import { ErrorRow } from "./error-row";
|
|
58
66
|
import { ACCOUNT_PERSIST_KEY, forYouLabels } from "./for-you-labels";
|
|
67
|
+
import { NewMenu, type NewMenuAction } from "./new-menu";
|
|
68
|
+
import { newActions } from "./new-popups";
|
|
59
69
|
|
|
60
70
|
interface Props {
|
|
61
71
|
params: Promise<{ locale: string }>;
|
|
@@ -71,8 +81,37 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
71
81
|
const KIND_ICON: Record<string, ComponentType<LucideProps>> = {
|
|
72
82
|
result: ClipboardList,
|
|
73
83
|
purchase: Receipt,
|
|
84
|
+
appointment: CalendarClock,
|
|
74
85
|
};
|
|
75
86
|
|
|
87
|
+
/** Soonest first; an item with no time sorts last. */
|
|
88
|
+
function soonestFirst(a: LoadedInboxItem, b: LoadedInboxItem): number {
|
|
89
|
+
if (a.item.at === undefined) return b.item.at === undefined ? 0 : 1;
|
|
90
|
+
if (b.item.at === undefined) return -1;
|
|
91
|
+
return Date.parse(a.item.at) - Date.parse(b.item.at);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The New actions this caller is offered; a rejected read drops its row and sets `failed`. */
|
|
95
|
+
async function loadNewActions(
|
|
96
|
+
ctx: AccountContext,
|
|
97
|
+
): Promise<{ actions: NewMenuAction[]; failed: boolean }> {
|
|
98
|
+
const settled = await Promise.allSettled(newActions.map((action) => action.available(ctx)));
|
|
99
|
+
const actions: NewMenuAction[] = [];
|
|
100
|
+
settled.forEach((result, i) => {
|
|
101
|
+
const action = newActions[i]!;
|
|
102
|
+
if (result.status === "rejected") logReadFailure(`new:${action.id}`, result.reason);
|
|
103
|
+
else if (result.value)
|
|
104
|
+
actions.push({ id: action.id, label: ctx.t(action.label), popup: action.renderPopup(ctx) });
|
|
105
|
+
});
|
|
106
|
+
return { actions, failed: settled.some((result) => result.status === "rejected") };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Upcoming items first, soonest first; then the rest, as loaded (newest first). */
|
|
110
|
+
function scheduledOrder(rows: readonly LoadedInboxItem[]): LoadedInboxItem[] {
|
|
111
|
+
const upcoming = rows.filter(({ item }) => item.upcoming === true).sort(soonestFirst);
|
|
112
|
+
return [...upcoming, ...rows.filter(({ item }) => item.upcoming !== true)];
|
|
113
|
+
}
|
|
114
|
+
|
|
76
115
|
export default async function AccountInboxPage({ params }: Props): Promise<ReactElement> {
|
|
77
116
|
const { locale } = await params;
|
|
78
117
|
const t = await getTranslations({ locale, namespace: "account" });
|
|
@@ -80,9 +119,17 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
|
|
|
80
119
|
|
|
81
120
|
// The contract's translator, for keys that are data (a kind's `label`).
|
|
82
121
|
const translate = session?.ctx.t ?? ((key: string): string => key);
|
|
83
|
-
const [inbox, forYou] = session
|
|
84
|
-
? await Promise.all([
|
|
85
|
-
|
|
122
|
+
const [inbox, forYou, newMenu] = session
|
|
123
|
+
? await Promise.all([
|
|
124
|
+
loadInboxItems(session.ctx),
|
|
125
|
+
loadForYou(locale),
|
|
126
|
+
loadNewActions(session.ctx),
|
|
127
|
+
])
|
|
128
|
+
: [
|
|
129
|
+
{ items: [], failed: false },
|
|
130
|
+
{ items: [], failed: false, now: "" },
|
|
131
|
+
{ actions: [], failed: false },
|
|
132
|
+
];
|
|
86
133
|
|
|
87
134
|
const dateFormat = new Intl.DateTimeFormat(locale, {
|
|
88
135
|
month: "short",
|
|
@@ -101,19 +148,27 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
|
|
|
101
148
|
};
|
|
102
149
|
};
|
|
103
150
|
|
|
104
|
-
const recents = inbox.items.filter(({ kind }) => kind.section === "updates").map(toRow);
|
|
105
|
-
const scheduled = inbox.items.filter(({ kind }) => kind.section === "upcoming").map(toRow);
|
|
106
151
|
const all = inbox.items.map(toRow);
|
|
107
|
-
const
|
|
152
|
+
const purchases = inbox.items.filter(({ kind }) => kind.section === "purchases").map(toRow);
|
|
153
|
+
const scheduled = scheduledOrder(
|
|
154
|
+
inbox.items.filter(({ kind }) => kind.section === "upcoming"),
|
|
155
|
+
).map(toRow);
|
|
156
|
+
const failed = inbox.failed || forYou.failed || newMenu.failed;
|
|
108
157
|
|
|
109
158
|
return (
|
|
110
159
|
// The phone's top bar already clears the canvas — no top inset there.
|
|
111
160
|
<div className="max-w-3xl py-8 max-md:pt-0" data-testid="account-inbox">
|
|
112
161
|
{/* The page's one h1, on both phone tabs; visually hidden on the phone,
|
|
113
|
-
which has no large title (§5.15). */}
|
|
114
|
-
<
|
|
115
|
-
{
|
|
116
|
-
|
|
162
|
+
which has no large title (§5.15). New sits beside it (#8233 D4). */}
|
|
163
|
+
<div className="flex items-center justify-between gap-2">
|
|
164
|
+
<Heading level={1} visual={2} className="max-md:sr-only">
|
|
165
|
+
{t("inbox.title")}
|
|
166
|
+
</Heading>
|
|
167
|
+
<NewMenu
|
|
168
|
+
actions={newMenu.actions}
|
|
169
|
+
labels={{ trigger: t("new.trigger"), close: t("new.close") }}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
117
172
|
<PhoneTabPanel tabs={["inbox"]} desktop data-testid="inbox-panel-inbox">
|
|
118
173
|
<Text as="p" variant="body-sm" tone="muted" className="mt-block-tight hidden md:block">
|
|
119
174
|
{t("inbox.lead")}
|
|
@@ -129,17 +184,21 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
|
|
|
129
184
|
|
|
130
185
|
<InboxList
|
|
131
186
|
tabs={[
|
|
132
|
-
{ id: "
|
|
133
|
-
{
|
|
187
|
+
{ id: "all", label: t("inbox.tabs.all"), count: all.length, rows: all },
|
|
188
|
+
{
|
|
189
|
+
id: "purchases",
|
|
190
|
+
label: t("inbox.tabs.purchases"),
|
|
191
|
+
count: purchases.length,
|
|
192
|
+
rows: purchases,
|
|
193
|
+
},
|
|
134
194
|
{
|
|
135
195
|
id: "scheduled",
|
|
136
196
|
label: t("inbox.tabs.scheduled"),
|
|
137
197
|
count: scheduled.length,
|
|
138
198
|
rows: scheduled,
|
|
139
199
|
},
|
|
140
|
-
{ id: "all", label: t("inbox.tabs.all"), count: all.length, rows: all },
|
|
141
200
|
]}
|
|
142
|
-
defaultTab="
|
|
201
|
+
defaultTab="all"
|
|
143
202
|
{...(failed ? {} : { emptyLine: t("inbox.empty") })}
|
|
144
203
|
/>
|
|
145
204
|
</PhoneTabPanel>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* account/profile-actions.ts — User settings › General's two member writes
|
|
5
|
+
* (#8232, spec docs/specs/8232-member-settings-account-general-and-spec.md
|
|
6
|
+
* D6; the writes are #8170's).
|
|
7
|
+
*
|
|
8
|
+
* - `saveProfile` — the member's name and photo, `member.updateProfile`
|
|
9
|
+
* (`PATCH /v1/member/profile`). On a spoke the hub-session cookie is then
|
|
10
|
+
* re-sealed from the hub's `/me` (`resealHubSession`, D11), so the new
|
|
11
|
+
* name shows on the next render.
|
|
12
|
+
* - `requestAccountDeletion` — `member.requestDeletion`
|
|
13
|
+
* (`POST /v1/member/deletion-request`). The API files it with the
|
|
14
|
+
* deployment that holds the account (8170 D9) and answers which operator
|
|
15
|
+
* it went to, so nothing here tests the topology.
|
|
16
|
+
*
|
|
17
|
+
* Both send `forwardedCookieInit()`'s init: on a single site the session
|
|
18
|
+
* cookie plus the request's `Origin`, which `apps/api`'s cookie CSRF check
|
|
19
|
+
* needs; on a hub-mode spoke the hub credential. Neither takes an identity
|
|
20
|
+
* argument — who comes from the forwarded credential alone (8170 D1).
|
|
21
|
+
*
|
|
22
|
+
* Not a `*.actions.ts` file and no `*Action`-suffixed export: these writes
|
|
23
|
+
* have no MCP twin by decision (8170's header), and apps/mcp's parity test
|
|
24
|
+
* reads that suffix. `resealHubSession` is imported by its own path, never
|
|
25
|
+
* through `lib/hub-session/index.ts`: `sync-routes` never overwrites a
|
|
26
|
+
* scaffold's existing `index.ts`, so an import through it would not compile
|
|
27
|
+
* there (D13). Logs carry a status or `err.name` only — never a name, a
|
|
28
|
+
* link, an email or a credential.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { member } from "@working-theory/api-client";
|
|
32
|
+
|
|
33
|
+
import { resealHubSession } from "../../../../lib/hub-session/reseal";
|
|
34
|
+
import { forwardedCookieInit } from "../../../../lib/server-api";
|
|
35
|
+
|
|
36
|
+
/** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
|
|
37
|
+
function errorName(err: unknown): string {
|
|
38
|
+
return err instanceof Error ? err.name : "unknown";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The `error.code` of a member write's refusal body, when it has one. */
|
|
42
|
+
function errorCode(body: unknown): string | null {
|
|
43
|
+
if (typeof body !== "object" || body === null) return null;
|
|
44
|
+
const error = (body as { error?: unknown }).error;
|
|
45
|
+
if (typeof error !== "object" || error === null) return null;
|
|
46
|
+
const code = (error as { code?: unknown }).code;
|
|
47
|
+
return typeof code === "string" ? code : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type SaveProfileResult =
|
|
51
|
+
| { ok: true; name: string | null; image: string | null }
|
|
52
|
+
| { ok: false; reason: "required" | "photo" | "unverified" | "unavailable" | "failed" };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Saves the member's name, and their photo when `image` is present:
|
|
56
|
+
* `null` (or a blank link) clears it, and an absent key leaves it as it is.
|
|
57
|
+
*/
|
|
58
|
+
export async function saveProfile(input: {
|
|
59
|
+
name: string;
|
|
60
|
+
image?: string | null;
|
|
61
|
+
}): Promise<SaveProfileResult> {
|
|
62
|
+
// Action input is untrusted: a hand-built call may send anything.
|
|
63
|
+
const { name: rawName, image } = (typeof input === "object" && input !== null ? input : {}) as {
|
|
64
|
+
name?: unknown;
|
|
65
|
+
image?: unknown;
|
|
66
|
+
};
|
|
67
|
+
const name = typeof rawName === "string" ? rawName.trim() : "";
|
|
68
|
+
if (!name) return { ok: false, reason: "required" };
|
|
69
|
+
// Anything but a string or null is refused, never read as "clear the photo".
|
|
70
|
+
if (image !== undefined && image !== null && typeof image !== "string") {
|
|
71
|
+
return { ok: false, reason: "photo" };
|
|
72
|
+
}
|
|
73
|
+
const body = image === undefined ? { name } : { name, image: image?.trim() || null };
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const res = await member.updateProfile(body, await forwardedCookieInit());
|
|
77
|
+
if (res.ok) {
|
|
78
|
+
if ((await resealHubSession()) === "failed") {
|
|
79
|
+
console.error("[profile-actions] saveProfile reseal=failed");
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, name: res.body.name, image: res.body.image };
|
|
82
|
+
}
|
|
83
|
+
console.error(`[profile-actions] saveProfile status=${res.status}`);
|
|
84
|
+
if (res.status === 400 && errorCode(res.body) === "link_not_https") {
|
|
85
|
+
return { ok: false, reason: "photo" };
|
|
86
|
+
}
|
|
87
|
+
if (res.status === 403) return { ok: false, reason: "unverified" };
|
|
88
|
+
if (res.status === 404 || res.status === 503) return { ok: false, reason: "unavailable" };
|
|
89
|
+
return { ok: false, reason: "failed" };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(`[profile-actions] saveProfile errName=${errorName(err)}`);
|
|
92
|
+
return { ok: false, reason: "failed" };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type AccountDeletionResult =
|
|
97
|
+
| { ok: true; sentTo: "site-operator" | "hub-operator" }
|
|
98
|
+
| { ok: false; reason: "unverified" | "unavailable" | "failed" };
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Asks the operator who holds the account to delete it. The body is empty,
|
|
102
|
+
* so the API's own subject and message go to the operator.
|
|
103
|
+
*/
|
|
104
|
+
export async function requestAccountDeletion(): Promise<AccountDeletionResult> {
|
|
105
|
+
try {
|
|
106
|
+
const res = await member.requestDeletion({}, await forwardedCookieInit());
|
|
107
|
+
if (res.ok) return { ok: true, sentTo: res.body.sentTo };
|
|
108
|
+
console.error(`[profile-actions] requestAccountDeletion status=${res.status}`);
|
|
109
|
+
if (res.status === 403) return { ok: false, reason: "unverified" };
|
|
110
|
+
if (res.status === 404 || res.status === 503) return { ok: false, reason: "unavailable" };
|
|
111
|
+
return { ok: false, reason: "failed" };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error(`[profile-actions] requestAccountDeletion errName=${errorName(err)}`);
|
|
114
|
+
return { ok: false, reason: "failed" };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /[locale]/account/security — User settings › Security (#8339 D8; #8431 D3).
|
|
3
|
+
*
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { AccountSettingsFrame, AccountSettingsPane } from "@working-theory/templates/account";
|
|
19
|
+
import { localeHref } from "@working-theory/ui";
|
|
20
|
+
import type { Metadata } from "next";
|
|
21
|
+
import { headers } from "next/headers";
|
|
22
|
+
import { getTranslations } from "next-intl/server";
|
|
23
|
+
import type { ReactElement } from "react";
|
|
24
|
+
|
|
25
|
+
import { SIGN_IN_PATH, SIGN_OUT_ENDPOINT } from "../../../../../lib/topology.generated";
|
|
26
|
+
import { accountSession } from "../account-data";
|
|
27
|
+
import { ErrorRow } from "../error-row";
|
|
28
|
+
import { memberSettingsFrame, type Translate } from "../settings-frame";
|
|
29
|
+
import type { SignInMethodId } from "../sign-in-method";
|
|
30
|
+
|
|
31
|
+
import { SecurityPanel, type SecurityPanelVariant } from "./security-panel";
|
|
32
|
+
import { securitySource } from "./security-source";
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
params: Promise<{ locale: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
39
|
+
const { locale } = await params;
|
|
40
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
41
|
+
return { title: t("settings.security.title"), description: t("settings.signIn.lead") };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Each sign-in method's phrase in the sentence (C5). */
|
|
45
|
+
const METHOD_KEYS = {
|
|
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",
|
|
50
|
+
} as const satisfies Record<SignInMethodId, string>;
|
|
51
|
+
|
|
52
|
+
export default async function AccountSecurityPage({ params }: Props): Promise<ReactElement> {
|
|
53
|
+
const { locale } = await params;
|
|
54
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
55
|
+
const session = await accountSession(locale);
|
|
56
|
+
const frame = memberSettingsFrame(locale, t as unknown as Translate, "security");
|
|
57
|
+
const email = session?.ctx.user.email ?? "";
|
|
58
|
+
const name = session?.ctx.user.name || email;
|
|
59
|
+
|
|
60
|
+
const source = await securitySource(await headers());
|
|
61
|
+
|
|
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
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<AccountSettingsFrame {...frame} name={name}>
|
|
95
|
+
<AccountSettingsPane
|
|
96
|
+
id="account-settings-security"
|
|
97
|
+
title={t("settings.security.title")}
|
|
98
|
+
lead={t("settings.signIn.lead")}
|
|
99
|
+
>
|
|
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
|
+
/>
|
|
129
|
+
</AccountSettingsPane>
|
|
130
|
+
</AccountSettingsFrame>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -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
|
+
}
|