@softure-ai/privacy 0.0.0-stage → 0.1.5
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 +21 -0
- package/README.md +272 -2
- package/dist/contract.d.ts +58 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/messages/en.d.ts +46 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +46 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +99 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +14 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +3 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +46 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/next/actions.d.ts +4 -0
- package/dist/next/actions.d.ts.map +1 -0
- package/dist/next/actions.js +57 -0
- package/dist/next/actions.js.map +1 -0
- package/dist/next/context.d.ts +4 -0
- package/dist/next/context.d.ts.map +1 -0
- package/dist/next/context.js +13 -0
- package/dist/next/context.js.map +1 -0
- package/dist/next/index.d.ts +5 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +7 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/messages.d.ts +5 -0
- package/dist/next/messages.d.ts.map +1 -0
- package/dist/next/messages.js +7 -0
- package/dist/next/messages.js.map +1 -0
- package/dist/next/pages.d.ts +2 -0
- package/dist/next/pages.d.ts.map +1 -0
- package/dist/next/pages.js +22 -0
- package/dist/next/pages.js.map +1 -0
- package/dist/next/route.d.ts +6 -0
- package/dist/next/route.d.ts.map +1 -0
- package/dist/next/route.js +49 -0
- package/dist/next/route.js.map +1 -0
- package/dist/next/session-cookie.d.ts +3 -0
- package/dist/next/session-cookie.d.ts.map +1 -0
- package/dist/next/session-cookie.js +17 -0
- package/dist/next/session-cookie.js.map +1 -0
- package/dist/options.d.ts +27 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +70 -0
- package/dist/options.js.map +1 -0
- package/dist/schema.d.ts +162 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +17 -0
- package/dist/schema.js.map +1 -0
- package/dist/server/collect.d.ts +16 -0
- package/dist/server/collect.d.ts.map +1 -0
- package/dist/server/collect.js +44 -0
- package/dist/server/collect.js.map +1 -0
- package/dist/server/consents-contributor.d.ts +14 -0
- package/dist/server/consents-contributor.d.ts.map +1 -0
- package/dist/server/consents-contributor.js +40 -0
- package/dist/server/consents-contributor.js.map +1 -0
- package/dist/server/consents.d.ts +42 -0
- package/dist/server/consents.d.ts.map +1 -0
- package/dist/server/consents.js +117 -0
- package/dist/server/consents.js.map +1 -0
- package/dist/server/context.d.ts +5 -0
- package/dist/server/context.d.ts.map +1 -0
- package/dist/server/context.js +2 -0
- package/dist/server/context.js.map +1 -0
- package/dist/server/contributors.d.ts +23 -0
- package/dist/server/contributors.d.ts.map +1 -0
- package/dist/server/contributors.js +38 -0
- package/dist/server/contributors.js.map +1 -0
- package/dist/server/erase.d.ts +10 -0
- package/dist/server/erase.d.ts.map +1 -0
- package/dist/server/erase.js +42 -0
- package/dist/server/erase.js.map +1 -0
- package/dist/server/health.d.ts +3 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +11 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.d.ts +11 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +12 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/legal-documents.d.ts +9 -0
- package/dist/server/legal-documents.d.ts.map +1 -0
- package/dist/server/legal-documents.js +18 -0
- package/dist/server/legal-documents.js.map +1 -0
- package/dist/server/options.d.ts +15 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/options.js +20 -0
- package/dist/server/options.js.map +1 -0
- package/dist/server/rate-limits.d.ts +13 -0
- package/dist/server/rate-limits.d.ts.map +1 -0
- package/dist/server/rate-limits.js +28 -0
- package/dist/server/rate-limits.js.map +1 -0
- package/dist/server/registration-consent.d.ts +19 -0
- package/dist/server/registration-consent.d.ts.map +1 -0
- package/dist/server/registration-consent.js +28 -0
- package/dist/server/registration-consent.js.map +1 -0
- package/dist/server/self-service.d.ts +29 -0
- package/dist/server/self-service.d.ts.map +1 -0
- package/dist/server/self-service.js +37 -0
- package/dist/server/self-service.js.map +1 -0
- package/dist/ui/delete-account-form.d.ts +16 -0
- package/dist/ui/delete-account-form.d.ts.map +1 -0
- package/dist/ui/delete-account-form.js +19 -0
- package/dist/ui/delete-account-form.js.map +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +7 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/legal-document.d.ts +51 -0
- package/dist/ui/legal-document.d.ts.map +1 -0
- package/dist/ui/legal-document.js +50 -0
- package/dist/ui/legal-document.js.map +1 -0
- package/dist/ui/legal-footer.d.ts +18 -0
- package/dist/ui/legal-footer.d.ts.map +1 -0
- package/dist/ui/legal-footer.js +13 -0
- package/dist/ui/legal-footer.js.map +1 -0
- package/migrations/0001_create_consents.sql +37 -0
- package/module.json +15 -0
- package/package.json +64 -4
- package/src/contract.ts +70 -0
- package/src/index.ts +73 -0
- package/src/messages/en.ts +45 -0
- package/src/messages/index.ts +18 -0
- package/src/messages/pl.ts +47 -0
- package/src/next/actions.ts +61 -0
- package/src/next/context.ts +14 -0
- package/src/next/index.ts +6 -0
- package/src/next/messages.ts +9 -0
- package/src/next/next-modules.d.ts +12 -0
- package/src/next/pages.tsx +36 -0
- package/src/next/route.ts +50 -0
- package/src/next/session-cookie.ts +18 -0
- package/src/options.ts +87 -0
- package/src/schema.ts +18 -0
- package/src/server/collect.ts +58 -0
- package/src/server/consents-contributor.ts +48 -0
- package/src/server/consents.ts +142 -0
- package/src/server/context.ts +5 -0
- package/src/server/contributors.ts +58 -0
- package/src/server/erase.ts +42 -0
- package/src/server/health.ts +12 -0
- package/src/server/index.ts +41 -0
- package/src/server/legal-documents.ts +24 -0
- package/src/server/options.ts +33 -0
- package/src/server/rate-limits.ts +32 -0
- package/src/server/registration-consent.ts +43 -0
- package/src/server/self-service.ts +59 -0
- package/src/ui/delete-account-form.tsx +58 -0
- package/src/ui/index.ts +21 -0
- package/src/ui/legal-document.tsx +189 -0
- package/src/ui/legal-footer.tsx +50 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// What a signed-in user does for themselves: download their data and delete their account. The
|
|
2
|
+
// caller passes the user id it took from the session (never from the request body); both
|
|
3
|
+
// operations count an attempt per user before any work.
|
|
4
|
+
import { err, ok, type Err, type Ok } from "@softure-ai/core";
|
|
5
|
+
import { isCurrentPassword } from "@softure-ai/auth/server";
|
|
6
|
+
import type { RateLimitRejection } from "@softure-ai/security";
|
|
7
|
+
import { consumeRateLimit } from "@softure-ai/security/server";
|
|
8
|
+
import { collectUserData, type CollectUserDataResult } from "./collect.js";
|
|
9
|
+
import type { PrivacyContext } from "./context.js";
|
|
10
|
+
import { eraseUserData } from "./erase.js";
|
|
11
|
+
import { assertPrivacyBuckets, BUCKETS, userSubjectKey } from "./rate-limits.js";
|
|
12
|
+
|
|
13
|
+
export interface ExportOwnDataInput {
|
|
14
|
+
/** The signed-in user, from the session. */
|
|
15
|
+
readonly userId: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ExportOwnDataResult = CollectUserDataResult | RateLimitRejection;
|
|
19
|
+
|
|
20
|
+
/** The signed-in user's export, counted in `privacy-export`. Database errors propagate. */
|
|
21
|
+
export async function exportOwnData(ctx: PrivacyContext, input: ExportOwnDataInput): Promise<ExportOwnDataResult> {
|
|
22
|
+
assertPrivacyBuckets(ctx.config);
|
|
23
|
+
const limit = await consumeRateLimit(ctx, { bucket: BUCKETS.export, key: userSubjectKey(input.userId) });
|
|
24
|
+
if (!limit.ok) return limit;
|
|
25
|
+
return collectUserData(ctx, input.userId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DeleteOwnAccountInput {
|
|
29
|
+
/** The signed-in user, from the session. */
|
|
30
|
+
readonly userId: string;
|
|
31
|
+
/** Their current password, typed again. */
|
|
32
|
+
readonly password: string;
|
|
33
|
+
/** Whether the "I understand" checkbox was ticked. */
|
|
34
|
+
readonly isConfirmed: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DeleteOwnAccountErrorCode = "privacy.confirmation_required" | "privacy.password_invalid" | "privacy.deletion_refused";
|
|
38
|
+
|
|
39
|
+
export type DeleteOwnAccountResult = Ok<undefined> | Err<DeleteOwnAccountErrorCode> | RateLimitRejection;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Deletes the signed-in user's account and data. The confirmation is checked first (cheap, and a
|
|
43
|
+
* missed checkbox should not spend an attempt), then `privacy-delete` is counted, then the password
|
|
44
|
+
* is verified: a session alone cannot delete an account, and the form is no faster a password
|
|
45
|
+
* oracle than the login. Database errors propagate after the rollback.
|
|
46
|
+
*/
|
|
47
|
+
export async function deleteOwnAccount(ctx: PrivacyContext, input: DeleteOwnAccountInput): Promise<DeleteOwnAccountResult> {
|
|
48
|
+
assertPrivacyBuckets(ctx.config);
|
|
49
|
+
if (!input.isConfirmed) return err("privacy.confirmation_required");
|
|
50
|
+
|
|
51
|
+
const limit = await consumeRateLimit(ctx, { bucket: BUCKETS.delete, key: userSubjectKey(input.userId) });
|
|
52
|
+
if (!limit.ok) return limit;
|
|
53
|
+
|
|
54
|
+
// A fresh error, not one derived from the check: the result then carries nothing of the password.
|
|
55
|
+
if (!(await isCurrentPassword(ctx, input.userId, input.password))) return err("privacy.password_invalid");
|
|
56
|
+
|
|
57
|
+
const erased = await eraseUserData(ctx, input.userId);
|
|
58
|
+
return erased.ok ? ok() : erased;
|
|
59
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Locale } from "@softure-ai/core";
|
|
4
|
+
import { Button, Checkbox, type ClassNames, createSlotClassGetter, FormError, PasswordField } from "@softure-ai/ui";
|
|
5
|
+
import { useActionState } from "react";
|
|
6
|
+
import { type DeleteAccountField, type DeleteAccountFormState, INITIAL_DELETE_ACCOUNT_STATE } from "../contract.js";
|
|
7
|
+
import { getPrivacyErrorMessage, type PrivacyMessages } from "../messages/index.js";
|
|
8
|
+
|
|
9
|
+
// The account deletion form. It submits straight to its server action through `useActionState`,
|
|
10
|
+
// so it works without JavaScript and the redirect after a deletion is followed. It asks for the
|
|
11
|
+
// current password and an explicit confirmation. Copy comes from the module's messages; styling
|
|
12
|
+
// only from @softure-ai/ui classes and tokens.
|
|
13
|
+
|
|
14
|
+
export type DeleteAccountAction = (previous: DeleteAccountFormState, formData: FormData) => Promise<DeleteAccountFormState>;
|
|
15
|
+
|
|
16
|
+
export type DeleteAccountFormSlot = "root" | "description" | "form";
|
|
17
|
+
|
|
18
|
+
export interface DeleteAccountFormProps {
|
|
19
|
+
readonly action: DeleteAccountAction;
|
|
20
|
+
readonly messages: PrivacyMessages;
|
|
21
|
+
/** Locale of the built-in copy of the ui primitives. */
|
|
22
|
+
readonly locale?: Locale;
|
|
23
|
+
readonly classNames?: ClassNames<DeleteAccountFormSlot>;
|
|
24
|
+
readonly unstyled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_CLASSES: Readonly<Record<DeleteAccountFormSlot, string>> = {
|
|
28
|
+
root: "sft:flex sft:flex-col sft:gap-3 sft:font-sans",
|
|
29
|
+
description: "sft:m-0 sft:text-sm sft:text-muted",
|
|
30
|
+
form: "sft:flex sft:flex-col sft:gap-3",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function DeleteAccountForm({ action, messages, locale, classNames, unstyled }: DeleteAccountFormProps) {
|
|
34
|
+
const [state, formAction, isPending] = useActionState(action, INITIAL_DELETE_ACCOUNT_STATE);
|
|
35
|
+
const slot = createSlotClassGetter({ defaults: DEFAULT_CLASSES, classNames, unstyled });
|
|
36
|
+
const message = state.error === undefined ? undefined : getPrivacyErrorMessage(messages, state.error);
|
|
37
|
+
const errorAt = (field: DeleteAccountField) => (state.field === field ? message : undefined);
|
|
38
|
+
return (
|
|
39
|
+
<div className={slot("root")}>
|
|
40
|
+
<p className={slot("description")}>{messages.delete.description}</p>
|
|
41
|
+
<form action={formAction} className={slot("form")}>
|
|
42
|
+
<PasswordField
|
|
43
|
+
name="password"
|
|
44
|
+
label={messages.delete.password}
|
|
45
|
+
autoComplete="current-password"
|
|
46
|
+
error={errorAt("password")}
|
|
47
|
+
unstyled={unstyled}
|
|
48
|
+
locale={locale}
|
|
49
|
+
/>
|
|
50
|
+
<Checkbox name="confirm" label={messages.delete.confirm} required error={errorAt("confirm")} unstyled={unstyled} />
|
|
51
|
+
<FormError message={state.field === undefined ? message : undefined} unstyled={unstyled} />
|
|
52
|
+
<Button type="submit" variant="danger" fullWidth pending={isPending} unstyled={unstyled}>
|
|
53
|
+
{isPending ? messages.delete.pending : messages.delete.submit}
|
|
54
|
+
</Button>
|
|
55
|
+
</form>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The privacy forms and the legal document shell, to compose into the app's own pages. The form
|
|
2
|
+
// takes the server action from `@softure-ai/privacy/next` as a prop: no `next/*` import here
|
|
3
|
+
// (docs/02-module-standard.md §5).
|
|
4
|
+
export {
|
|
5
|
+
DeleteAccountForm,
|
|
6
|
+
type DeleteAccountAction,
|
|
7
|
+
type DeleteAccountFormProps,
|
|
8
|
+
type DeleteAccountFormSlot,
|
|
9
|
+
} from "./delete-account-form.js";
|
|
10
|
+
export {
|
|
11
|
+
formatLegalDate,
|
|
12
|
+
LegalDocument,
|
|
13
|
+
LegalSection,
|
|
14
|
+
type LegalChange,
|
|
15
|
+
type LegalDocumentProps,
|
|
16
|
+
type LegalDocumentSlot,
|
|
17
|
+
type LegalSectionContent,
|
|
18
|
+
type LegalSectionProps,
|
|
19
|
+
type LegalSectionSlot,
|
|
20
|
+
} from "./legal-document.js";
|
|
21
|
+
export { LegalFooter, type LegalFooterLink, type LegalFooterProps, type LegalFooterSlot } from "./legal-footer.js";
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Locale } from "@softure-ai/core";
|
|
2
|
+
import { type ClassNames, createSlotClassGetter } from "@softure-ai/ui";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import type { PrivacyMessages } from "../messages/index.js";
|
|
5
|
+
|
|
6
|
+
// The legal document shell: a title with the version in force and its effective date, a table of
|
|
7
|
+
// contents linking to the sections, the sections, and the history of changes. Every word of the
|
|
8
|
+
// document is the app's (props); the module adds only its labels, from privacy's messages. A
|
|
9
|
+
// server component: no state and no client JavaScript.
|
|
10
|
+
|
|
11
|
+
/** One section of a document: `id` is its anchor in the table of contents. */
|
|
12
|
+
export interface LegalSectionContent {
|
|
13
|
+
/** Kebab-case anchor, unique in the document, e.g. `data-we-collect`. */
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly title: ReactNode;
|
|
16
|
+
readonly content: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One entry of the change history. */
|
|
20
|
+
export interface LegalChange {
|
|
21
|
+
readonly version: string;
|
|
22
|
+
/** ISO date, `YYYY-MM-DD`. */
|
|
23
|
+
readonly date: string;
|
|
24
|
+
readonly summary: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type LegalSectionSlot = "root" | "title" | "body";
|
|
28
|
+
|
|
29
|
+
export interface LegalSectionProps {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly title: ReactNode;
|
|
32
|
+
readonly children: ReactNode;
|
|
33
|
+
readonly classNames?: ClassNames<LegalSectionSlot>;
|
|
34
|
+
readonly unstyled?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SECTION_CLASSES: Readonly<Record<LegalSectionSlot, string>> = {
|
|
38
|
+
root: "sft:flex sft:flex-col sft:gap-2 sft:scroll-mt-4",
|
|
39
|
+
title: "sft:m-0 sft:font-heading sft:text-lg sft:font-semibold sft:text-foreground",
|
|
40
|
+
body: "sft:flex sft:flex-col sft:gap-2 sft:text-sm sft:leading-relaxed sft:text-foreground",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A titled section with an anchor; `LegalDocument` renders one per entry of `sections`. */
|
|
44
|
+
export function LegalSection({ id, title, children, classNames, unstyled }: LegalSectionProps) {
|
|
45
|
+
const slot = createSlotClassGetter({ defaults: SECTION_CLASSES, classNames, unstyled });
|
|
46
|
+
return (
|
|
47
|
+
<section id={id} aria-labelledby={`${id}-title`} className={slot("root")}>
|
|
48
|
+
<h2 id={`${id}-title`} className={slot("title")}>
|
|
49
|
+
{title}
|
|
50
|
+
</h2>
|
|
51
|
+
<div className={slot("body")}>{children}</div>
|
|
52
|
+
</section>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type LegalDocumentSlot =
|
|
57
|
+
| "root"
|
|
58
|
+
| "header"
|
|
59
|
+
| "title"
|
|
60
|
+
| "meta"
|
|
61
|
+
| "intro"
|
|
62
|
+
| "contents"
|
|
63
|
+
| "contentsTitle"
|
|
64
|
+
| "contentsList"
|
|
65
|
+
| "link"
|
|
66
|
+
| "changes"
|
|
67
|
+
| "changesTitle"
|
|
68
|
+
| "changesList"
|
|
69
|
+
| "change"
|
|
70
|
+
| "changeMeta";
|
|
71
|
+
|
|
72
|
+
export interface LegalDocumentProps {
|
|
73
|
+
readonly title: ReactNode;
|
|
74
|
+
/** The version in force; read it from the config with `getLegalDocument(config, id).version`. */
|
|
75
|
+
readonly version: string;
|
|
76
|
+
/** ISO date, `YYYY-MM-DD`, from which this version applies. */
|
|
77
|
+
readonly effectiveFrom: string;
|
|
78
|
+
readonly intro?: ReactNode;
|
|
79
|
+
readonly sections: readonly LegalSectionContent[];
|
|
80
|
+
/** Newest first, as the reader expects; none hides the history. */
|
|
81
|
+
readonly changes?: readonly LegalChange[];
|
|
82
|
+
readonly messages: PrivacyMessages;
|
|
83
|
+
/** Formats the dates. */
|
|
84
|
+
readonly locale: Locale;
|
|
85
|
+
readonly classNames?: ClassNames<LegalDocumentSlot>;
|
|
86
|
+
/** Classes of every section. */
|
|
87
|
+
readonly sectionClassNames?: ClassNames<LegalSectionSlot>;
|
|
88
|
+
readonly unstyled?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const DOCUMENT_CLASSES: Readonly<Record<LegalDocumentSlot, string>> = {
|
|
92
|
+
root: "sft:mx-auto sft:box-border sft:flex sft:w-full sft:flex-col sft:gap-4 sft:sm:max-w-3xl sft:px-4 sft:py-4 sft:font-sans",
|
|
93
|
+
header: "sft:flex sft:flex-col sft:gap-2",
|
|
94
|
+
title: "sft:m-0 sft:font-heading sft:text-2xl sft:sm:text-3xl sft:font-bold sft:tracking-tight sft:text-foreground",
|
|
95
|
+
meta: "sft:m-0 sft:text-sm sft:text-muted",
|
|
96
|
+
intro: "sft:text-sm sft:leading-relaxed sft:text-foreground",
|
|
97
|
+
contents: "sft:flex sft:flex-col sft:gap-2 sft:rounded-card sft:border sft:border-border sft:bg-surface sft:p-4",
|
|
98
|
+
contentsTitle: "sft:m-0 sft:text-base sft:font-semibold sft:text-foreground",
|
|
99
|
+
contentsList: "sft:m-0 sft:flex sft:flex-col sft:gap-1 sft:pl-8 sft:text-sm",
|
|
100
|
+
link: "sft:text-accent sft:focus-visible:outline-2 sft:focus-visible:outline-focus sft:focus-visible:outline-offset-2",
|
|
101
|
+
changes: "sft:flex sft:flex-col sft:gap-2 sft:border-t sft:border-border sft:pt-4 sft:scroll-mt-4",
|
|
102
|
+
changesTitle: "sft:m-0 sft:font-heading sft:text-lg sft:font-semibold sft:text-foreground",
|
|
103
|
+
changesList: "sft:m-0 sft:flex sft:flex-col sft:gap-2 sft:p-0 sft:list-none sft:text-sm",
|
|
104
|
+
change: "sft:flex sft:flex-col sft:gap-1",
|
|
105
|
+
changeMeta: "sft:font-medium sft:text-muted sft:tabular-nums",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
109
|
+
|
|
110
|
+
/** `2026-10-01` as the locale writes it (`October 1, 2026` in English); other text as given. */
|
|
111
|
+
export function formatLegalDate(date: string, locale: Locale): string {
|
|
112
|
+
const match = ISO_DATE.exec(date);
|
|
113
|
+
if (match === null) return date;
|
|
114
|
+
const [, year, month, day] = match.map(Number) as [number, number, number, number];
|
|
115
|
+
const value = new Date(Date.UTC(year, month - 1, day));
|
|
116
|
+
// A calendar date, so UTC: the server's time zone must not move it to the day before.
|
|
117
|
+
if (value.getUTCDate() !== day) return date;
|
|
118
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The id of the change history section; the table of contents does not list it. */
|
|
122
|
+
const CHANGES_ID = "legal-changes";
|
|
123
|
+
const CONTENTS_TITLE_ID = "legal-contents-title";
|
|
124
|
+
|
|
125
|
+
export function LegalDocument({
|
|
126
|
+
title,
|
|
127
|
+
version,
|
|
128
|
+
effectiveFrom,
|
|
129
|
+
intro,
|
|
130
|
+
sections,
|
|
131
|
+
changes = [],
|
|
132
|
+
messages,
|
|
133
|
+
locale,
|
|
134
|
+
classNames,
|
|
135
|
+
sectionClassNames,
|
|
136
|
+
unstyled,
|
|
137
|
+
}: LegalDocumentProps) {
|
|
138
|
+
const slot = createSlotClassGetter({ defaults: DOCUMENT_CLASSES, classNames, unstyled });
|
|
139
|
+
const copy = messages.legal;
|
|
140
|
+
return (
|
|
141
|
+
<article className={slot("root")}>
|
|
142
|
+
<header className={slot("header")}>
|
|
143
|
+
<h1 className={slot("title")}>{title}</h1>
|
|
144
|
+
<p className={slot("meta")}>
|
|
145
|
+
{copy.version} {version} · {copy.effectiveFrom} {formatLegalDate(effectiveFrom, locale)}
|
|
146
|
+
</p>
|
|
147
|
+
{intro === undefined ? null : <div className={slot("intro")}>{intro}</div>}
|
|
148
|
+
</header>
|
|
149
|
+
{sections.length === 0 ? null : (
|
|
150
|
+
<nav aria-labelledby={CONTENTS_TITLE_ID} className={slot("contents")}>
|
|
151
|
+
<h2 id={CONTENTS_TITLE_ID} className={slot("contentsTitle")}>
|
|
152
|
+
{copy.contents}
|
|
153
|
+
</h2>
|
|
154
|
+
<ol className={slot("contentsList")}>
|
|
155
|
+
{sections.map((section) => (
|
|
156
|
+
<li key={section.id}>
|
|
157
|
+
<a href={`#${section.id}`} className={slot("link")}>
|
|
158
|
+
{section.title}
|
|
159
|
+
</a>
|
|
160
|
+
</li>
|
|
161
|
+
))}
|
|
162
|
+
</ol>
|
|
163
|
+
</nav>
|
|
164
|
+
)}
|
|
165
|
+
{sections.map((section) => (
|
|
166
|
+
<LegalSection key={section.id} id={section.id} title={section.title} classNames={sectionClassNames} unstyled={unstyled}>
|
|
167
|
+
{section.content}
|
|
168
|
+
</LegalSection>
|
|
169
|
+
))}
|
|
170
|
+
{changes.length === 0 ? null : (
|
|
171
|
+
<section id={CHANGES_ID} aria-labelledby={`${CHANGES_ID}-title`} className={slot("changes")}>
|
|
172
|
+
<h2 id={`${CHANGES_ID}-title`} className={slot("changesTitle")}>
|
|
173
|
+
{copy.changes}
|
|
174
|
+
</h2>
|
|
175
|
+
<ol className={slot("changesList")}>
|
|
176
|
+
{changes.map((change) => (
|
|
177
|
+
<li key={change.version} className={slot("change")}>
|
|
178
|
+
<span className={slot("changeMeta")}>
|
|
179
|
+
{copy.version} {change.version} · {formatLegalDate(change.date, locale)}
|
|
180
|
+
</span>
|
|
181
|
+
<span>{change.summary}</span>
|
|
182
|
+
</li>
|
|
183
|
+
))}
|
|
184
|
+
</ol>
|
|
185
|
+
</section>
|
|
186
|
+
)}
|
|
187
|
+
</article>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ClassNames, createSlotClassGetter } from "@softure-ai/ui";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { PrivacyMessages } from "../messages/index.js";
|
|
4
|
+
|
|
5
|
+
// The links to the app's legal documents, for the bottom of every page. The app passes the links
|
|
6
|
+
// and their labels (its own copy); the navigation's accessible name comes from privacy's messages.
|
|
7
|
+
// A server component.
|
|
8
|
+
|
|
9
|
+
export interface LegalFooterLink {
|
|
10
|
+
readonly href: string;
|
|
11
|
+
readonly label: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type LegalFooterSlot = "root" | "list" | "link" | "note";
|
|
15
|
+
|
|
16
|
+
export interface LegalFooterProps {
|
|
17
|
+
readonly links: readonly LegalFooterLink[];
|
|
18
|
+
/** A line under the links, e.g. the operator's name and address. */
|
|
19
|
+
readonly note?: ReactNode;
|
|
20
|
+
readonly messages: PrivacyMessages;
|
|
21
|
+
readonly classNames?: ClassNames<LegalFooterSlot>;
|
|
22
|
+
readonly unstyled?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CLASSES: Readonly<Record<LegalFooterSlot, string>> = {
|
|
26
|
+
root: "sft:mx-auto sft:box-border sft:flex sft:w-full sft:flex-col sft:items-center sft:gap-2 sft:border-t sft:border-border sft:px-4 sft:py-4 sft:font-sans sft:text-sm sft:text-muted",
|
|
27
|
+
list: "sft:m-0 sft:flex sft:items-center sft:justify-center sft:gap-4 sft:p-0 sft:list-none",
|
|
28
|
+
link: "sft:text-muted sft:hover:text-foreground sft:focus-visible:outline-2 sft:focus-visible:outline-focus sft:focus-visible:outline-offset-2",
|
|
29
|
+
note: "sft:m-0 sft:text-xs sft:text-center",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function LegalFooter({ links, note, messages, classNames, unstyled }: LegalFooterProps) {
|
|
33
|
+
const slot = createSlotClassGetter({ defaults: DEFAULT_CLASSES, classNames, unstyled });
|
|
34
|
+
return (
|
|
35
|
+
<footer className={slot("root")}>
|
|
36
|
+
<nav aria-label={messages.legal.footer}>
|
|
37
|
+
<ul className={slot("list")}>
|
|
38
|
+
{links.map((link) => (
|
|
39
|
+
<li key={link.href}>
|
|
40
|
+
<a href={link.href} className={slot("link")}>
|
|
41
|
+
{link.label}
|
|
42
|
+
</a>
|
|
43
|
+
</li>
|
|
44
|
+
))}
|
|
45
|
+
</ul>
|
|
46
|
+
</nav>
|
|
47
|
+
{note === undefined ? null : <p className={slot("note")}>{note}</p>}
|
|
48
|
+
</footer>
|
|
49
|
+
);
|
|
50
|
+
}
|