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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A/privacy/actions.ts — the member Privacy and Notifications tabs' one
|
|
5
|
+
* consent write path (#8438 D14, D15, spec
|
|
6
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md).
|
|
7
|
+
*
|
|
8
|
+
* `setConsentAction` posts one answer through `POST /v1/member/consents`
|
|
9
|
+
* (`member.setConsent`) and answers in `ConsentSwitchList`'s own shape, so
|
|
10
|
+
* both tabs' switch lists can call it directly with no adapter. The
|
|
11
|
+
* banner's visitor id (`ailk-vid`) rides along as `x-ailk-consent-visitor`
|
|
12
|
+
* when present, linking its cookie choices to the client record this write
|
|
13
|
+
* resolves or creates (D6, D11, D12) — never failing the write when it is
|
|
14
|
+
* absent or invalid.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { member } from "@working-theory/api-client";
|
|
18
|
+
import type { ConsentSwitchChangeResult } from "@working-theory/templates/account";
|
|
19
|
+
import { cookies } from "next/headers";
|
|
20
|
+
import { getLocale, getTranslations } from "next-intl/server";
|
|
21
|
+
|
|
22
|
+
import { consentVisitorHeader, readConsentVisitor } from "../../../../../lib/consent-visitor";
|
|
23
|
+
import { forwardedCookieInit } from "../../../../../lib/server-api";
|
|
24
|
+
|
|
25
|
+
function formatDate(iso: string, locale: string): string {
|
|
26
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(iso));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function setConsentAction(
|
|
30
|
+
purposeId: string,
|
|
31
|
+
granted: boolean,
|
|
32
|
+
): Promise<ConsentSwitchChangeResult> {
|
|
33
|
+
const [t, locale] = await Promise.all([
|
|
34
|
+
getTranslations("account"),
|
|
35
|
+
getLocale(),
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const base = await forwardedCookieInit();
|
|
40
|
+
let visitorHeaders: Record<string, string> = {};
|
|
41
|
+
try {
|
|
42
|
+
const visitorId = readConsentVisitor(await cookies());
|
|
43
|
+
if (visitorId) visitorHeaders = consentVisitorHeader(visitorId);
|
|
44
|
+
} catch {
|
|
45
|
+
// No request scope to read, or no visitor cookie — write with none.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const res = await member.setConsent(
|
|
49
|
+
{ purposeId, granted, locale },
|
|
50
|
+
{ headers: { ...base.headers, ...visitorHeaders } },
|
|
51
|
+
);
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
return { ok: false, message: t("settings.privacy.unavailable") };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const state = res.body.purpose.state;
|
|
57
|
+
const at = state?.at ?? new Date().toISOString();
|
|
58
|
+
const dateLine = state?.granted
|
|
59
|
+
? t("settings.privacy.given", { date: formatDate(at, locale) })
|
|
60
|
+
: t("settings.privacy.withdrawn", { date: formatDate(at, locale) });
|
|
61
|
+
return { ok: true, dateLine };
|
|
62
|
+
} catch {
|
|
63
|
+
return { ok: false, message: t("settings.privacy.unavailable") };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /[locale]/account/privacy — User settings › Privacy (#8438 D14, NEW).
|
|
3
|
+
*
|
|
4
|
+
* RSC, outside the account layout's SignedInShell (#8430), in the member's
|
|
5
|
+
* settings frame (D7) — standalone, the Admin's shape. In DOM order:
|
|
6
|
+
*
|
|
7
|
+
* 1. account-privacy-summary — one line with a link to the site's
|
|
8
|
+
* privacy policy.
|
|
9
|
+
* 2. account-privacy-cookies — the current cookie-banner choice, read
|
|
10
|
+
* from the request's OWN `ailk-consent` cookie (never the ledger —
|
|
11
|
+
* the cookie stays the operative switch, C3), and a Change link
|
|
12
|
+
* (moved here from Notifications, #8438 D15) to
|
|
13
|
+
* `/privacy#cookie-settings`.
|
|
14
|
+
* 3. account-privacy-consents — one `ConsentSwitchList` row per
|
|
15
|
+
* `area: "data"` purpose, or the "no other data-use consents" line.
|
|
16
|
+
* 4. account-privacy-agreements — a read-only `AgreementList` of every
|
|
17
|
+
* agreement (`service_terms`, `account_terms`, …), or "None yet".
|
|
18
|
+
* 5. account-deletion — moved unchanged from General (#8438 D16).
|
|
19
|
+
*
|
|
20
|
+
* Sections 3 and 4 read `GET /v1/member/consents`; a failed read shows one
|
|
21
|
+
* line in their place and sections 1, 2 and 5 still render (D14).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { member } from "@working-theory/api-client";
|
|
25
|
+
import {
|
|
26
|
+
AccountSettingsFrame,
|
|
27
|
+
AccountSettingsPane,
|
|
28
|
+
AgreementList,
|
|
29
|
+
ConsentSwitchList,
|
|
30
|
+
} from "@working-theory/templates/account";
|
|
31
|
+
import { localeHref } from "@working-theory/ui";
|
|
32
|
+
import { Heading, Link, Text } from "@working-theory/ui/primitives";
|
|
33
|
+
import type { Metadata } from "next";
|
|
34
|
+
import { cookies } from "next/headers";
|
|
35
|
+
import { getTranslations } from "next-intl/server";
|
|
36
|
+
import type { ReactElement } from "react";
|
|
37
|
+
|
|
38
|
+
import { accountSession } from "../account-data";
|
|
39
|
+
import { DeletionRequest } from "../general/deletion-request";
|
|
40
|
+
import { loadProfileSource } from "../general/profile-source";
|
|
41
|
+
import { memberSettingsFrame, type Translate } from "../settings-frame";
|
|
42
|
+
|
|
43
|
+
import { setConsentAction } from "./actions";
|
|
44
|
+
|
|
45
|
+
/** The cookie banner's own choice cookie (`layout.tsx`'s `CONSENT_COOKIE_NAME`, #5557). */
|
|
46
|
+
const CONSENT_COOKIE_NAME = "ailk-consent";
|
|
47
|
+
|
|
48
|
+
interface Props {
|
|
49
|
+
params: Promise<{ locale: string }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
53
|
+
const { locale } = await params;
|
|
54
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
55
|
+
return { title: t("settings.privacy.title"), description: t("settings.privacy.lead") };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default async function AccountPrivacyPage({ params }: Props): Promise<ReactElement> {
|
|
59
|
+
const { locale } = await params;
|
|
60
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
61
|
+
const session = await accountSession(locale);
|
|
62
|
+
const frame = memberSettingsFrame(locale, t as unknown as Translate, "privacy");
|
|
63
|
+
|
|
64
|
+
// Unreachable behind the (authed) gate; a session that expired between
|
|
65
|
+
// the layout and the page renders the pane's title alone.
|
|
66
|
+
if (!session) {
|
|
67
|
+
return (
|
|
68
|
+
<AccountSettingsFrame {...frame} name="">
|
|
69
|
+
<AccountSettingsPane
|
|
70
|
+
id="account-settings-privacy"
|
|
71
|
+
title={t("settings.privacy.title")}
|
|
72
|
+
lead={t("settings.privacy.lead")}
|
|
73
|
+
>
|
|
74
|
+
{null}
|
|
75
|
+
</AccountSettingsPane>
|
|
76
|
+
</AccountSettingsFrame>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { ctx } = session;
|
|
81
|
+
const name = ctx.user.name || ctx.user.email;
|
|
82
|
+
const source = await loadProfileSource(ctx);
|
|
83
|
+
|
|
84
|
+
let cookieChoice: "accepted" | "declined" | null;
|
|
85
|
+
try {
|
|
86
|
+
const value = (await cookies()).get(CONSENT_COOKIE_NAME)?.value;
|
|
87
|
+
cookieChoice = value === "accepted" || value === "declined" ? value : null;
|
|
88
|
+
} catch {
|
|
89
|
+
cookieChoice = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let dataRows: { id: string; label: string; description?: string; granted: boolean; dateLine?: string }[] = [];
|
|
93
|
+
let agreementRows: { id: string; statement: string; version: number; acceptedLine?: string }[] = [];
|
|
94
|
+
let consentsAvailable = true;
|
|
95
|
+
try {
|
|
96
|
+
const res = await member.consents(ctx.init, { locale });
|
|
97
|
+
if (res.ok) {
|
|
98
|
+
dataRows = res.body.purposes
|
|
99
|
+
.filter((purpose) => purpose.area === "data")
|
|
100
|
+
.map((purpose) => ({
|
|
101
|
+
id: purpose.id,
|
|
102
|
+
label: purpose.statement,
|
|
103
|
+
granted: purpose.effective,
|
|
104
|
+
...(purpose.description ? { description: purpose.description } : {}),
|
|
105
|
+
...(purpose.state
|
|
106
|
+
? {
|
|
107
|
+
dateLine: purpose.state.granted
|
|
108
|
+
? t("settings.privacy.given", { date: formatDate(purpose.state.at, locale) })
|
|
109
|
+
: t("settings.privacy.withdrawn", { date: formatDate(purpose.state.at, locale) }),
|
|
110
|
+
}
|
|
111
|
+
: {}),
|
|
112
|
+
}));
|
|
113
|
+
agreementRows = res.body.purposes
|
|
114
|
+
.filter((purpose) => purpose.kind === "agreement")
|
|
115
|
+
.map((purpose) => ({
|
|
116
|
+
id: purpose.id,
|
|
117
|
+
statement: purpose.statement,
|
|
118
|
+
version: purpose.policyVersion,
|
|
119
|
+
...(purpose.state?.granted
|
|
120
|
+
? { acceptedLine: t("settings.privacy.accepted", { date: formatDate(purpose.state.at, locale) }) }
|
|
121
|
+
: {}),
|
|
122
|
+
}));
|
|
123
|
+
} else {
|
|
124
|
+
consentsAvailable = false;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
consentsAvailable = false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<AccountSettingsFrame {...frame} name={name}>
|
|
132
|
+
<AccountSettingsPane
|
|
133
|
+
id="account-settings-privacy"
|
|
134
|
+
title={t("settings.privacy.title")}
|
|
135
|
+
lead={t("settings.privacy.lead")}
|
|
136
|
+
>
|
|
137
|
+
<div data-testid="account-privacy-summary" className="flex flex-wrap items-baseline gap-1">
|
|
138
|
+
<Text as="span" variant="body-sm">
|
|
139
|
+
{t("settings.privacy.summary")}
|
|
140
|
+
</Text>
|
|
141
|
+
<Link href={localeHref(locale, "/privacy")} data-testid="account-privacy-policy">
|
|
142
|
+
{t("settings.privacy.policyLink")}
|
|
143
|
+
</Link>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div data-testid="account-privacy-cookies" className="flex flex-col gap-1">
|
|
147
|
+
<Heading level={2} visual={4} className="!text-body-sm !font-medium">
|
|
148
|
+
{t("settings.privacy.cookiesTitle")}
|
|
149
|
+
</Heading>
|
|
150
|
+
<div className="flex flex-wrap items-baseline gap-3">
|
|
151
|
+
<Text as="span" variant="body-sm" tone="muted">
|
|
152
|
+
{cookieChoice === "accepted"
|
|
153
|
+
? t("settings.privacy.cookiesAccepted")
|
|
154
|
+
: cookieChoice === "declined"
|
|
155
|
+
? t("settings.privacy.cookiesDeclined")
|
|
156
|
+
: t("settings.privacy.cookiesUnset")}
|
|
157
|
+
</Text>
|
|
158
|
+
<Link
|
|
159
|
+
href={localeHref(locale, "/privacy#cookie-settings")}
|
|
160
|
+
className="inline-flex min-h-6 items-center"
|
|
161
|
+
data-testid="account-cookie-settings"
|
|
162
|
+
>
|
|
163
|
+
{t("settings.privacy.cookiesChange")}
|
|
164
|
+
</Link>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{consentsAvailable ? (
|
|
169
|
+
<>
|
|
170
|
+
<div data-testid="account-privacy-consents" className="flex flex-col gap-2">
|
|
171
|
+
<Heading level={2} visual={4} className="!text-body-sm !font-medium">
|
|
172
|
+
{t("settings.privacy.consentsTitle")}
|
|
173
|
+
</Heading>
|
|
174
|
+
<ConsentSwitchList
|
|
175
|
+
rows={dataRows}
|
|
176
|
+
onChange={setConsentAction}
|
|
177
|
+
emptyLine={t("settings.privacy.consentsEmpty")}
|
|
178
|
+
testIdPrefix="account-privacy-data-consent"
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div data-testid="account-privacy-agreements" className="flex flex-col gap-2">
|
|
183
|
+
<Heading level={2} visual={4} className="!text-body-sm !font-medium">
|
|
184
|
+
{t("settings.privacy.agreementsTitle")}
|
|
185
|
+
</Heading>
|
|
186
|
+
<AgreementList
|
|
187
|
+
rows={agreementRows}
|
|
188
|
+
notAcceptedLine={t("settings.privacy.agreementsEmpty")}
|
|
189
|
+
emptyLine={t("settings.privacy.agreementsEmpty")}
|
|
190
|
+
testIdPrefix="account-privacy-agreement"
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
</>
|
|
194
|
+
) : (
|
|
195
|
+
<span data-testid="account-privacy-unavailable">
|
|
196
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
197
|
+
{t("settings.privacy.unavailable")}
|
|
198
|
+
</Text>
|
|
199
|
+
</span>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
<div data-testid="account-deletion">
|
|
203
|
+
<DeletionRequest
|
|
204
|
+
via={source.via}
|
|
205
|
+
supportHref={localeHref(locale, "/account/support")}
|
|
206
|
+
labels={{
|
|
207
|
+
lead: t("settings.privacy.deletionLead"),
|
|
208
|
+
action: t("settings.privacy.deletion"),
|
|
209
|
+
sentSite: t("settings.privacy.deletionSentSite"),
|
|
210
|
+
sentHub: t("settings.privacy.deletionSentHub"),
|
|
211
|
+
unverified: t("settings.privacy.deletionUnverified"),
|
|
212
|
+
unavailable: t("settings.privacy.deletionUnavailable"),
|
|
213
|
+
error: t("settings.privacy.deletionError"),
|
|
214
|
+
viaSupport: t("settings.privacy.deletionViaSupport"),
|
|
215
|
+
supportLink: t("settings.privacy.deletionSupportLink"),
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
</AccountSettingsPane>
|
|
220
|
+
</AccountSettingsFrame>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function formatDate(iso: string, locale: string): string {
|
|
225
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(iso));
|
|
226
|
+
}
|
|
@@ -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
|
+
}
|