@carlonicora/nextjs-jsonapi 6.5.0 → 6.7.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.
Files changed (107) hide show
  1. package/dist/{BlockNoteEditor-CT4XUXPC.js → BlockNoteEditor-BSFR2GPI.js} +19 -19
  2. package/dist/{BlockNoteEditor-CT4XUXPC.js.map → BlockNoteEditor-BSFR2GPI.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-M6UQRVRK.mjs → BlockNoteEditor-HWJBQAKG.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-XZJ5Y7WN.mjs → BlockNoteViewer-IIBUU7J7.mjs} +2 -2
  5. package/dist/{BlockNoteViewer-MFLWF3Y2.js → BlockNoteViewer-NHS6CCMS.js} +3 -3
  6. package/dist/{BlockNoteViewer-MFLWF3Y2.js.map → BlockNoteViewer-NHS6CCMS.js.map} +1 -1
  7. package/dist/{auth.interface-D5606g19.d.ts → auth.interface-B4tRE0k0.d.ts} +2 -0
  8. package/dist/{auth.interface-tsOuc0Fi.d.mts → auth.interface-DuX87BZ3.d.mts} +2 -0
  9. package/dist/billing/index.js +492 -492
  10. package/dist/billing/index.mjs +3 -3
  11. package/dist/{chunk-DSGP6GC3.js → chunk-3HOJNAZB.js} +8 -8
  12. package/dist/{chunk-DSGP6GC3.js.map → chunk-3HOJNAZB.js.map} +1 -1
  13. package/dist/{chunk-XZCCIKX3.js → chunk-6KE7UBYY.js} +504 -233
  14. package/dist/chunk-6KE7UBYY.js.map +1 -0
  15. package/dist/{chunk-QJIDORHM.js → chunk-73XWQA6U.js} +4 -4
  16. package/dist/{chunk-QJIDORHM.js.map → chunk-73XWQA6U.js.map} +1 -1
  17. package/dist/{chunk-DYOSXIEL.mjs → chunk-EC4PQ44E.mjs} +2 -2
  18. package/dist/{chunk-RUJCWOJE.mjs → chunk-OYRJ32KR.mjs} +2 -2
  19. package/dist/{chunk-Y7JIQNM6.js → chunk-PCCUPEBU.js} +1066 -873
  20. package/dist/chunk-PCCUPEBU.js.map +1 -0
  21. package/dist/{chunk-ELQHE2DK.js → chunk-PYDDFFQM.js} +3 -3
  22. package/dist/{chunk-ELQHE2DK.js.map → chunk-PYDDFFQM.js.map} +1 -1
  23. package/dist/{chunk-3FD6Y734.mjs → chunk-QNIWATQD.mjs} +272 -1
  24. package/dist/chunk-QNIWATQD.mjs.map +1 -0
  25. package/dist/{chunk-TIT4CJYQ.mjs → chunk-S4NKWMX5.mjs} +3729 -3536
  26. package/dist/chunk-S4NKWMX5.mjs.map +1 -0
  27. package/dist/{chunk-O2TEUH5S.mjs → chunk-XYKDBOCE.mjs} +2 -2
  28. package/dist/client/index.js +4 -4
  29. package/dist/client/index.mjs +3 -3
  30. package/dist/components/index.d.mts +39 -4
  31. package/dist/components/index.d.ts +39 -4
  32. package/dist/components/index.js +12 -4
  33. package/dist/components/index.js.map +1 -1
  34. package/dist/components/index.mjs +11 -3
  35. package/dist/contexts/index.js +4 -4
  36. package/dist/contexts/index.mjs +3 -3
  37. package/dist/core/index.d.mts +144 -5
  38. package/dist/core/index.d.ts +144 -5
  39. package/dist/core/index.js +18 -2
  40. package/dist/core/index.js.map +1 -1
  41. package/dist/core/index.mjs +17 -1
  42. package/dist/email-automations/index.js +92 -92
  43. package/dist/email-automations/index.mjs +3 -3
  44. package/dist/features/analytics/index.js +121 -121
  45. package/dist/features/analytics/index.mjs +4 -4
  46. package/dist/features/analytics/server.js +4 -4
  47. package/dist/features/analytics/server.mjs +2 -2
  48. package/dist/features/blog/index.js +150 -150
  49. package/dist/features/blog/index.mjs +3 -3
  50. package/dist/features/blog/server.js +16 -16
  51. package/dist/features/blog/server.mjs +1 -1
  52. package/dist/features/help/index.js +37 -37
  53. package/dist/features/help/index.mjs +3 -3
  54. package/dist/features/tokenusage/index.js +127 -127
  55. package/dist/features/tokenusage/index.mjs +4 -4
  56. package/dist/index.d.mts +3 -3
  57. package/dist/index.d.ts +3 -3
  58. package/dist/index.js +19 -3
  59. package/dist/index.js.map +1 -1
  60. package/dist/index.mjs +18 -2
  61. package/dist/{s3.service-CzFBDEuQ.d.mts → s3.service-CRGJbO-6.d.mts} +18 -2
  62. package/dist/{s3.service-BTCpmp5g.d.ts → s3.service-DZG-VyLY.d.ts} +18 -2
  63. package/dist/server/index.d.mts +2 -2
  64. package/dist/server/index.d.ts +2 -2
  65. package/dist/server/index.js +3 -3
  66. package/dist/server/index.mjs +2 -2
  67. package/messages/en.json +24 -0
  68. package/messages/it.json +24 -0
  69. package/package.json +1 -1
  70. package/src/components/index.ts +1 -0
  71. package/src/core/index.ts +6 -0
  72. package/src/core/registry/ModuleRegistry.ts +15 -0
  73. package/src/features/account-deletion/account-deletion.module.ts +13 -0
  74. package/src/features/account-deletion/components/AccountDeletionAdminTrigger.tsx +183 -0
  75. package/src/features/account-deletion/components/AccountDeletionCancelledNotice.tsx +51 -0
  76. package/src/features/account-deletion/components/AccountDeletionKeepContainer.tsx +74 -0
  77. package/src/features/account-deletion/components/index.ts +3 -0
  78. package/src/features/account-deletion/data/__tests__/AccountDeletion.spec.ts +89 -0
  79. package/src/features/account-deletion/data/account-deletion.interface.ts +22 -0
  80. package/src/features/account-deletion/data/account-deletion.service.ts +69 -0
  81. package/src/features/account-deletion/data/account-deletion.ts +90 -0
  82. package/src/features/account-deletion/data/index.ts +3 -0
  83. package/src/features/account-deletion/index.ts +4 -0
  84. package/src/features/account-deletion/utils/__tests__/account-deletion-notice.spec.ts +40 -0
  85. package/src/features/account-deletion/utils/account-deletion-notice.ts +27 -0
  86. package/src/features/account-deletion/utils/index.ts +1 -0
  87. package/src/features/auth/components/forms/Cookies.tsx +3 -0
  88. package/src/features/auth/components/forms/TwoFactorChallenge.tsx +2 -0
  89. package/src/features/auth/data/__tests__/auth.spec.ts +36 -0
  90. package/src/features/auth/data/__tests__/pending-registration.spec.ts +37 -0
  91. package/src/features/auth/data/auth.interface.ts +2 -0
  92. package/src/features/auth/data/auth.service.ts +23 -0
  93. package/src/features/auth/data/auth.ts +7 -0
  94. package/src/features/auth/data/index.ts +2 -0
  95. package/src/features/auth/data/pending-registration.interface.ts +10 -0
  96. package/src/features/auth/data/pending-registration.ts +57 -0
  97. package/src/features/auth/pending-registration.module.ts +9 -0
  98. package/src/features/user/contexts/UserContext.tsx +8 -0
  99. package/dist/chunk-3FD6Y734.mjs.map +0 -1
  100. package/dist/chunk-TIT4CJYQ.mjs.map +0 -1
  101. package/dist/chunk-XZCCIKX3.js.map +0 -1
  102. package/dist/chunk-Y7JIQNM6.js.map +0 -1
  103. /package/dist/{BlockNoteEditor-M6UQRVRK.mjs.map → BlockNoteEditor-HWJBQAKG.mjs.map} +0 -0
  104. /package/dist/{BlockNoteViewer-XZJ5Y7WN.mjs.map → BlockNoteViewer-IIBUU7J7.mjs.map} +0 -0
  105. /package/dist/{chunk-DYOSXIEL.mjs.map → chunk-EC4PQ44E.mjs.map} +0 -0
  106. /package/dist/{chunk-RUJCWOJE.mjs.map → chunk-OYRJ32KR.mjs.map} +0 -0
  107. /package/dist/{chunk-O2TEUH5S.mjs.map → chunk-XYKDBOCE.mjs.map} +0 -0
package/src/core/index.ts CHANGED
@@ -30,6 +30,11 @@ export * from "../utils";
30
30
  export * from "../permissions";
31
31
 
32
32
  // Feature data classes, interfaces, and modules
33
+ // Account deletion (opt-in): data, module and the login-notice flag helpers.
34
+ // Server-safe; the client components ship from the components entry.
35
+ export * from "../features/account-deletion/account-deletion.module";
36
+ export * from "../features/account-deletion/data";
37
+ export * from "../features/account-deletion/utils";
33
38
  export * from "../features/auth/auth.module";
34
39
  export * from "../features/auth/totp-authenticator.module";
35
40
  export * from "../features/auth/totp-setup.module";
@@ -45,6 +50,7 @@ export * from "../features/auth/two-factor-enable.module";
45
50
  export * from "../features/auth/two-factor-challenge.module";
46
51
  export * from "../features/auth/two-factor-status.module";
47
52
  export * from "../features/auth/backup-code-verify.module";
53
+ export * from "../features/auth/pending-registration.module";
48
54
  export * from "../features/auth/data";
49
55
  export * from "../features/auth/enums";
50
56
  export * from "../features/billing/data";
@@ -73,6 +73,8 @@ export interface FoundationModuleDefinitions {
73
73
  TwoFactorChallenge: ModuleWithPermissions;
74
74
  TwoFactorStatus: ModuleWithPermissions;
75
75
  BackupCodeVerify: ModuleWithPermissions;
76
+ // OAuth sign-up awaiting consent
77
+ PendingRegistration: ModuleWithPermissions;
76
78
  // RBAC modules
77
79
  PermissionMapping: ModuleWithPermissions;
78
80
  ModulePaths: ModuleWithPermissions;
@@ -94,6 +96,8 @@ export interface FoundationModuleDefinitions {
94
96
  AnalyticsBreakdown: ModuleWithPermissions;
95
97
  AnalyticsSession: ModuleWithPermissions;
96
98
  AnalyticsPageView: ModuleWithPermissions;
99
+ // Account deletion (opt-in: registered only by apps that enable it)
100
+ AccountDeletion: ModuleWithPermissions;
97
101
  }
98
102
 
99
103
  // App-specific modules - apps will augment this interface ONLY
@@ -148,6 +152,17 @@ class ModuleRegistryClass {
148
152
  return module as ModuleDefinitions[K];
149
153
  }
150
154
 
155
+ /**
156
+ * True when a module is registered under `name`. Never throws, so a component
157
+ * can check for an opt-in module the app may not have registered.
158
+ */
159
+ has(name: string): boolean {
160
+ // Bootstrap only an empty registry (as findByName does): an app that never
161
+ // registers `name` must not re-run its bootstrapper on every check.
162
+ if (this._modules.size === 0) tryBootstrap();
163
+ return this._modules.has(name);
164
+ }
165
+
151
166
  findByName(moduleName: string): ModuleWithPermissions {
152
167
  // Self-healing: try bootstrap if registry is empty
153
168
  if (this._modules.size === 0) {
@@ -0,0 +1,13 @@
1
+ import { ModuleFactory } from "../../permissions";
2
+ import { AccountDeletion } from "./data/account-deletion";
3
+
4
+ /**
5
+ * Opt-in: an app that turns on `accountDeletion` in its API adds
6
+ * `AccountDeletion: AccountDeletionModule(moduleFactory)` to its bootstrapper.
7
+ */
8
+ export const AccountDeletionModule = (factory: ModuleFactory) =>
9
+ factory({
10
+ name: "account-deletions",
11
+ pageUrl: "/account-deletions",
12
+ model: AccountDeletion,
13
+ });
@@ -0,0 +1,183 @@
1
+ "use client";
2
+
3
+ import { LoaderCircleIcon, UserXIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useEffect, useState } from "react";
6
+ import { errorToast } from "../../../components/errors/errorToast";
7
+ import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
8
+ import { useI18nLocale } from "../../../i18n";
9
+ import { getRoleId } from "../../../roles";
10
+ import {
11
+ AlertDialog,
12
+ AlertDialogContent,
13
+ AlertDialogDescription,
14
+ AlertDialogHeader,
15
+ AlertDialogTitle,
16
+ AlertDialogTrigger,
17
+ } from "../../../shadcnui/ui/alert-dialog";
18
+ import { Button } from "../../../shadcnui/ui/button";
19
+ import { Input } from "../../../shadcnui/ui/input";
20
+ import { Label } from "../../../shadcnui/ui/label";
21
+ import { useCurrentUserContext } from "../../user/contexts/CurrentUserContext";
22
+ import { UserInterface } from "../../user/data/user.interface";
23
+ import { AccountDeletionInterface } from "../data/account-deletion.interface";
24
+ import { AccountDeletionService } from "../data/account-deletion.service";
25
+
26
+ /** Must match the API's default `accountDeletion.graceDays`. */
27
+ const GRACE_DAYS = 7;
28
+
29
+ /** The words an undeliverable-email user (or an admin on their behalf) types. Same rule as the API. */
30
+ const CONFIRMATION_WORDS = ["DELETE", "ELIMINA"];
31
+
32
+ export type AccountDeletionAdminTriggerProps = {
33
+ user: UserInterface;
34
+ };
35
+
36
+ /**
37
+ * True when the app registered AccountDeletionModule (the feature is opt-in).
38
+ */
39
+ export function isAccountDeletionEnabled(): boolean {
40
+ return ModuleRegistry.has("AccountDeletion");
41
+ }
42
+
43
+ function matchesConfirmation(status: AccountDeletionInterface, email: string, value: string): boolean {
44
+ if (status.hasDeliverableEmail) return value.trim().toLowerCase() === email.trim().toLowerCase();
45
+ return CONFIRMATION_WORDS.includes(value.trim().toUpperCase());
46
+ }
47
+
48
+ function AccountDeletionAdminTriggerInternal({ user }: AccountDeletionAdminTriggerProps) {
49
+ const t = useTranslations();
50
+ const locale = useI18nLocale();
51
+ const [status, setStatus] = useState<AccountDeletionInterface | undefined>();
52
+ const [open, setOpen] = useState<boolean>(false);
53
+ const [confirmation, setConfirmation] = useState<string>("");
54
+ const [isRequesting, setIsRequesting] = useState<boolean>(false);
55
+
56
+ useEffect(() => {
57
+ let cancelled = false;
58
+ AccountDeletionService.findByUser({ userId: user.id })
59
+ .then((response) => {
60
+ if (!cancelled) setStatus(response);
61
+ })
62
+ .catch(() => {
63
+ if (!cancelled) setStatus(undefined);
64
+ });
65
+ return () => {
66
+ cancelled = true;
67
+ };
68
+ }, [user.id]);
69
+
70
+ if (!status) return null;
71
+
72
+ const formatDate = (date: Date) =>
73
+ date.toLocaleDateString(locale, { day: "numeric", month: "long", year: "numeric" });
74
+
75
+ if (status.scheduledAt) {
76
+ return (
77
+ <span className="text-destructive text-xs font-medium">
78
+ {t("accountDeletion.admin.pending", { date: formatDate(status.scheduledAt) })}
79
+ </span>
80
+ );
81
+ }
82
+
83
+ const deletionDate = new Date();
84
+ deletionDate.setDate(deletionDate.getDate() + GRACE_DAYS);
85
+
86
+ const expected = status.hasDeliverableEmail ? user.email : t("accountDeletion.admin.confirmWord");
87
+ const canConfirm = matchesConfirmation(status, user.email, confirmation);
88
+
89
+ const handleOpenChange = (value: boolean) => {
90
+ setOpen(value);
91
+ if (!value) setConfirmation("");
92
+ };
93
+
94
+ const handleRequest = async () => {
95
+ setIsRequesting(true);
96
+ try {
97
+ const response = await AccountDeletionService.requestForUser({ id: user.id, confirmation });
98
+ if (response) setStatus(response);
99
+ handleOpenChange(false);
100
+ } catch (error) {
101
+ errorToast({ title: t("common.errors.error"), error });
102
+ } finally {
103
+ setIsRequesting(false);
104
+ }
105
+ };
106
+
107
+ return (
108
+ <AlertDialog open={open} onOpenChange={handleOpenChange}>
109
+ <AlertDialogTrigger
110
+ render={
111
+ <Button
112
+ size="sm"
113
+ variant="ghost"
114
+ className="text-muted-foreground hover:text-destructive"
115
+ aria-label={t("accountDeletion.admin.trigger")}
116
+ title={t("accountDeletion.admin.trigger")}
117
+ />
118
+ }
119
+ >
120
+ <UserXIcon />
121
+ </AlertDialogTrigger>
122
+ <AlertDialogContent className="flex max-h-[70vh] max-w-3xl flex-col overflow-y-auto">
123
+ <AlertDialogHeader>
124
+ <AlertDialogTitle>{t("accountDeletion.admin.title")}</AlertDialogTitle>
125
+ <AlertDialogDescription>
126
+ {status.hasDeliverableEmail
127
+ ? t("accountDeletion.admin.body", { date: formatDate(deletionDate) })
128
+ : t("accountDeletion.admin.bodyNoEmail", { date: formatDate(deletionDate) })}
129
+ </AlertDialogDescription>
130
+ </AlertDialogHeader>
131
+ {status.hasActiveSubscription && (
132
+ <p className="text-destructive text-sm">{t("accountDeletion.admin.subscription")}</p>
133
+ )}
134
+ <div className="flex w-full flex-col gap-y-2">
135
+ <Label htmlFor="account-deletion-admin-confirmation">
136
+ {t("accountDeletion.admin.confirmLabel", { value: expected })}
137
+ </Label>
138
+ <Input
139
+ id="account-deletion-admin-confirmation"
140
+ className="w-full"
141
+ autoComplete="off"
142
+ value={confirmation}
143
+ onChange={(e) => setConfirmation(e.target.value)}
144
+ />
145
+ </div>
146
+ <div className="flex justify-end">
147
+ <Button
148
+ className="me-2"
149
+ variant="outline"
150
+ type="button"
151
+ onClick={() => handleOpenChange(false)}
152
+ disabled={isRequesting}
153
+ >
154
+ {t("ui.buttons.cancel")}
155
+ </Button>
156
+ <Button type="button" variant="destructive" onClick={handleRequest} disabled={!canConfirm || isRequesting}>
157
+ {isRequesting ? (
158
+ <>
159
+ {t("ui.buttons.deleting")}
160
+ <LoaderCircleIcon className="animate-spin-slow h-5 w-5" />
161
+ </>
162
+ ) : (
163
+ t("accountDeletion.admin.confirm")
164
+ )}
165
+ </Button>
166
+ </div>
167
+ </AlertDialogContent>
168
+ </AlertDialog>
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Header action on the user detail page: starts the same 7-day deletion request
174
+ * the user can make, on their behalf. Platform administrators only, and only in
175
+ * apps that registered AccountDeletionModule.
176
+ */
177
+ export function AccountDeletionAdminTrigger({ user }: AccountDeletionAdminTriggerProps) {
178
+ const { hasRole } = useCurrentUserContext<UserInterface>();
179
+
180
+ if (!hasRole(getRoleId().Administrator) || !isAccountDeletionEnabled()) return null;
181
+
182
+ return <AccountDeletionAdminTriggerInternal user={user} />;
183
+ }
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { XIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useEffect, useState } from "react";
6
+ import { Link } from "../../../shadcnui/custom/link";
7
+ import { Alert, AlertAction, AlertDescription, AlertTitle } from "../../../shadcnui/ui/alert";
8
+ import { Button } from "../../../shadcnui/ui/button";
9
+ import { consumeAccountDeletionCancelled } from "../utils/account-deletion-notice";
10
+
11
+ export type AccountDeletionCancelledNoticeProps = {
12
+ /** Where the user picks a plan again (their subscription was cancelled with the request). */
13
+ choosePlanHref: string;
14
+ };
15
+
16
+ /**
17
+ * Shown once, on the first page after a login that cancelled a pending account
18
+ * deletion. The login paths set the flag; this reads and clears it.
19
+ */
20
+ export function AccountDeletionCancelledNotice({ choosePlanHref }: AccountDeletionCancelledNoticeProps) {
21
+ const t = useTranslations();
22
+ const [visible, setVisible] = useState(false);
23
+
24
+ // Read in an effect, never during render: sessionStorage is client-only and
25
+ // the flag is consumed (a render-time read would differ between server and client).
26
+ useEffect(() => {
27
+ if (consumeAccountDeletionCancelled()) setVisible(true);
28
+ }, []);
29
+
30
+ if (!visible) return null;
31
+
32
+ return (
33
+ <Alert className="border-success/30 bg-success/15 mb-4">
34
+ <AlertTitle className="text-success">{t("accountDeletion.cancelled.title")}</AlertTitle>
35
+ <AlertDescription>
36
+ <p>{t("accountDeletion.cancelled.body")}</p>
37
+ {/* Never underlined: colour and weight carry the affordance. */}
38
+ <div className="mt-1">
39
+ <Link href={choosePlanHref} className="text-primary font-medium">
40
+ {t("accountDeletion.cancelled.choosePlan")}
41
+ </Link>
42
+ </div>
43
+ </AlertDescription>
44
+ <AlertAction>
45
+ <Button variant="ghost" size="icon-xs" aria-label={t("ui.labels.close")} onClick={() => setVisible(false)}>
46
+ <XIcon />
47
+ </Button>
48
+ </AlertAction>
49
+ </Alert>
50
+ );
51
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { CheckCircle, Loader2, XCircle } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useEffect, useRef, useState } from "react";
6
+ import { SectionHeader } from "../../../components/typography";
7
+ import { buttonVariants } from "../../../shadcnui/ui/button";
8
+ import { Link } from "../../../shadcnui/custom/link";
9
+ import { AccountDeletionService } from "../data/account-deletion.service";
10
+
11
+ export type AccountDeletionKeepContainerProps = {
12
+ /** The raw token from the "Keep my account" email link. */
13
+ token: string | undefined;
14
+ };
15
+
16
+ type KeepState = "loading" | "success" | "failure";
17
+
18
+ /**
19
+ * Landing page of the "Keep my account" email link: cancels the pending
20
+ * deletion without a login. Every failure shows the same text, so the page
21
+ * never tells whether an account exists.
22
+ */
23
+ export function AccountDeletionKeepContainer({ token }: AccountDeletionKeepContainerProps) {
24
+ const t = useTranslations();
25
+ const [state, setState] = useState<KeepState>(token ? "loading" : "failure");
26
+ // The token is single use: a second call (React Strict Mode runs effects
27
+ // twice in development) would fail and replace the success text.
28
+ const called = useRef(false);
29
+
30
+ useEffect(() => {
31
+ if (!token || called.current) return;
32
+ called.current = true;
33
+
34
+ AccountDeletionService.keep({ token })
35
+ .then(() => setState("success"))
36
+ .catch(() => setState("failure"));
37
+ }, [token]);
38
+
39
+ if (state === "loading") {
40
+ return (
41
+ <div className="flex flex-col items-center justify-center space-y-4 py-16 text-center">
42
+ <Loader2 className="text-primary h-12 w-12 animate-spin" />
43
+ <p className="text-muted-foreground">{t("ui.buttons.loading")}</p>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ if (state === "failure") {
49
+ return (
50
+ <div className="flex flex-col items-center justify-center space-y-4 py-16 text-center">
51
+ <div className="bg-destructive/10 rounded-full p-4">
52
+ <XCircle className="text-destructive h-12 w-12" />
53
+ </div>
54
+ <p className="text-muted-foreground max-w-md">{t("accountDeletion.keep.failure")}</p>
55
+ <Link href="/login" className={buttonVariants({ variant: "outline" })}>
56
+ {t("accountDeletion.keep.login")}
57
+ </Link>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <div className="flex flex-col items-center justify-center space-y-4 py-16 text-center">
64
+ <div className="bg-success/15 rounded-full p-4">
65
+ <CheckCircle className="text-success h-12 w-12" />
66
+ </div>
67
+ <SectionHeader level={2}>{t("accountDeletion.keep.successTitle")}</SectionHeader>
68
+ <p className="text-muted-foreground max-w-md">{t("accountDeletion.keep.successBody")}</p>
69
+ <Link href="/login" className={buttonVariants()}>
70
+ {t("accountDeletion.keep.login")}
71
+ </Link>
72
+ </div>
73
+ );
74
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./AccountDeletionAdminTrigger";
2
+ export * from "./AccountDeletionCancelledNotice";
3
+ export * from "./AccountDeletionKeepContainer";
@@ -0,0 +1,89 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
3
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
4
+ import { AccountDeletion } from "../account-deletion";
5
+
6
+ // createJsonApi() resolves Modules.AccountDeletion through the real ModuleRegistry,
7
+ // which is shared across test files in a worker: register only when absent.
8
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
9
+ try {
10
+ ModuleRegistry.get(key as any);
11
+ } catch {
12
+ ModuleRegistry.register(key, module);
13
+ }
14
+ };
15
+
16
+ beforeAll(() => {
17
+ registerIfAbsent("AccountDeletion", { name: "account-deletions", model: AccountDeletion } as any);
18
+ });
19
+
20
+ const USER_ID = "11111111-1111-4111-8111-111111111111";
21
+ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
22
+
23
+ describe("AccountDeletion", () => {
24
+ describe("rehydrate", () => {
25
+ it("parses scheduledAt and requestedAt into Date", () => {
26
+ const model = new AccountDeletion().rehydrate({
27
+ jsonApi: {
28
+ type: "account-deletions",
29
+ id: USER_ID,
30
+ attributes: {
31
+ requestedAt: "2026-10-25T10:00:00.000Z",
32
+ scheduledAt: "2026-11-01T10:00:00.000Z",
33
+ hasDeliverableEmail: true,
34
+ hasActiveSubscription: true,
35
+ deletesCompany: true,
36
+ ownedCount: 3,
37
+ otherTables: ["The Long Road"],
38
+ },
39
+ },
40
+ included: [],
41
+ } as any);
42
+
43
+ expect(model.requestedAt).toBeInstanceOf(Date);
44
+ expect(model.requestedAt?.toISOString()).toBe("2026-10-25T10:00:00.000Z");
45
+ expect(model.scheduledAt).toBeInstanceOf(Date);
46
+ expect(model.scheduledAt?.toISOString()).toBe("2026-11-01T10:00:00.000Z");
47
+ expect(model.hasDeliverableEmail).toBe(true);
48
+ expect(model.hasActiveSubscription).toBe(true);
49
+ expect(model.deletesCompany).toBe(true);
50
+ expect(model.ownedCount).toBe(3);
51
+ expect(model.otherTables).toEqual(["The Long Road"]);
52
+ });
53
+
54
+ it("leaves both dates undefined and defaults the rest when nothing is pending", () => {
55
+ const model = new AccountDeletion().rehydrate({
56
+ jsonApi: { type: "account-deletions", id: USER_ID, attributes: {} },
57
+ included: [],
58
+ } as any);
59
+
60
+ expect(model.requestedAt).toBeUndefined();
61
+ expect(model.scheduledAt).toBeUndefined();
62
+ expect(model.hasDeliverableEmail).toBe(false);
63
+ expect(model.hasActiveSubscription).toBe(false);
64
+ expect(model.deletesCompany).toBe(false);
65
+ expect(model.ownedCount).toBe(0);
66
+ expect(model.otherTables).toEqual([]);
67
+ });
68
+ });
69
+
70
+ describe("createJsonApi", () => {
71
+ it("type account-deletions, id, attributes.confirmation", () => {
72
+ const payload = new AccountDeletion().createJsonApi({ id: USER_ID, confirmation: "gm@example.com" });
73
+
74
+ expect(payload.data.type).toBe("account-deletions");
75
+ expect(payload.data.id).toBe(USER_ID);
76
+ expect(payload.data.attributes).toEqual({ confirmation: "gm@example.com" });
77
+ });
78
+ });
79
+
80
+ describe("createKeepJsonApi", () => {
81
+ it("type account-deletions, a v4 id, attributes.token", () => {
82
+ const payload = new AccountDeletion().createKeepJsonApi({ token: "raw-token" });
83
+
84
+ expect(payload.data.type).toBe("account-deletions");
85
+ expect(payload.data.id).toMatch(UUID_V4);
86
+ expect(payload.data.attributes).toEqual({ token: "raw-token" });
87
+ });
88
+ });
89
+ });
@@ -0,0 +1,22 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+
3
+ /**
4
+ * A user's account-deletion status and what the deletion removes. Not a stored
5
+ * entity: the API builds it for one user; its id is that user's id.
6
+ */
7
+ export interface AccountDeletionInterface extends ApiDataInterface {
8
+ get requestedAt(): Date | undefined;
9
+ get scheduledAt(): Date | undefined;
10
+ get hasDeliverableEmail(): boolean;
11
+ get hasActiveSubscription(): boolean;
12
+ get deletesCompany(): boolean;
13
+ get ownedCount(): number;
14
+ get otherTables(): string[];
15
+ }
16
+
17
+ export type AccountDeletionInput = {
18
+ /** The id of the user whose account is deleted. */
19
+ id: string;
20
+ /** The user's email, or DELETE / ELIMINA when the user has no deliverable email. */
21
+ confirmation: string;
22
+ };
@@ -0,0 +1,69 @@
1
+ import { AbstractService, EndpointCreator, HttpMethod, Modules } from "../../../core";
2
+ import { AccountDeletion } from "./account-deletion";
3
+ import { AccountDeletionInput, AccountDeletionInterface } from "./account-deletion.interface";
4
+
5
+ export class AccountDeletionService extends AbstractService {
6
+ /**
7
+ * GET the signed-in user's deletion status and what a deletion would remove.
8
+ */
9
+ static async findMine(): Promise<AccountDeletionInterface> {
10
+ return this.callApi<AccountDeletionInterface>({
11
+ type: Modules.AccountDeletion,
12
+ method: HttpMethod.GET,
13
+ endpoint: new EndpointCreator({ endpoint: Modules.AccountDeletion, id: "me" }).generate(),
14
+ });
15
+ }
16
+
17
+ /**
18
+ * GET another user's deletion status (platform administrators only).
19
+ */
20
+ static async findByUser(params: { userId: string }): Promise<AccountDeletionInterface> {
21
+ return this.callApi<AccountDeletionInterface>({
22
+ type: Modules.AccountDeletion,
23
+ method: HttpMethod.GET,
24
+ endpoint: new EndpointCreator({ endpoint: Modules.AccountDeletion, id: params.userId }).generate(),
25
+ });
26
+ }
27
+
28
+ /**
29
+ * POST the signed-in user's own deletion request. `params.id` must be the
30
+ * signed-in user's id.
31
+ */
32
+ static async request(params: AccountDeletionInput): Promise<AccountDeletionInterface> {
33
+ return this.callApi<AccountDeletionInterface>({
34
+ type: Modules.AccountDeletion,
35
+ method: HttpMethod.POST,
36
+ endpoint: new EndpointCreator({ endpoint: Modules.AccountDeletion }).generate(),
37
+ input: params,
38
+ });
39
+ }
40
+
41
+ /**
42
+ * POST a deletion request on behalf of another user (platform administrators
43
+ * only). Runs the same 7-day request as the user's own.
44
+ */
45
+ static async requestForUser(params: AccountDeletionInput): Promise<AccountDeletionInterface> {
46
+ return this.callApi<AccountDeletionInterface>({
47
+ type: Modules.AccountDeletion,
48
+ method: HttpMethod.POST,
49
+ endpoint: new EndpointCreator({ endpoint: Modules.AccountDeletion, id: params.id }).generate(),
50
+ input: params,
51
+ });
52
+ }
53
+
54
+ /**
55
+ * POST the token from the "Keep my account" email link. Public: no session.
56
+ * Throws on any failure, so the page can show the "link no longer works" text.
57
+ */
58
+ static async keep(params: { token: string }): Promise<void> {
59
+ const accountDeletion = new AccountDeletion();
60
+ await this.callApi<void>({
61
+ type: Modules.AccountDeletion,
62
+ method: HttpMethod.POST,
63
+ endpoint: new EndpointCreator({ endpoint: Modules.AccountDeletion, id: "keep" }).generate(),
64
+ input: accountDeletion.createKeepJsonApi({ token: params.token }),
65
+ overridesJsonApiCreation: true, // OK - model method provides proper structure
66
+ suppressGlobalError: true,
67
+ });
68
+ }
69
+ }
@@ -0,0 +1,90 @@
1
+ import { v4 } from "uuid";
2
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
3
+ import { AccountDeletionInput, AccountDeletionInterface } from "./account-deletion.interface";
4
+
5
+ export class AccountDeletion extends AbstractApiData implements AccountDeletionInterface {
6
+ private _requestedAt?: Date;
7
+ private _scheduledAt?: Date;
8
+ private _hasDeliverableEmail: boolean = false;
9
+ private _hasActiveSubscription: boolean = false;
10
+ private _deletesCompany: boolean = false;
11
+ private _ownedCount: number = 0;
12
+ private _otherTables?: string[];
13
+
14
+ get requestedAt(): Date | undefined {
15
+ return this._requestedAt;
16
+ }
17
+
18
+ get scheduledAt(): Date | undefined {
19
+ return this._scheduledAt;
20
+ }
21
+
22
+ get hasDeliverableEmail(): boolean {
23
+ return this._hasDeliverableEmail;
24
+ }
25
+
26
+ get hasActiveSubscription(): boolean {
27
+ return this._hasActiveSubscription;
28
+ }
29
+
30
+ get deletesCompany(): boolean {
31
+ return this._deletesCompany;
32
+ }
33
+
34
+ get ownedCount(): number {
35
+ return this._ownedCount;
36
+ }
37
+
38
+ get otherTables(): string[] {
39
+ return this._otherTables ?? [];
40
+ }
41
+
42
+ rehydrate(data: JsonApiHydratedDataInterface): this {
43
+ super.rehydrate(data);
44
+
45
+ const attributes = data.jsonApi.attributes ?? {};
46
+
47
+ this._requestedAt = attributes.requestedAt ? new Date(attributes.requestedAt) : undefined;
48
+ this._scheduledAt = attributes.scheduledAt ? new Date(attributes.scheduledAt) : undefined;
49
+ this._hasDeliverableEmail = attributes.hasDeliverableEmail === true;
50
+ this._hasActiveSubscription = attributes.hasActiveSubscription === true;
51
+ this._deletesCompany = attributes.deletesCompany === true;
52
+ this._ownedCount = typeof attributes.ownedCount === "number" ? attributes.ownedCount : 0;
53
+ this._otherTables = Array.isArray(attributes.otherTables) ? attributes.otherTables : [];
54
+
55
+ return this;
56
+ }
57
+
58
+ createJsonApi(data: AccountDeletionInput) {
59
+ const response: any = {
60
+ data: {
61
+ type: Modules.AccountDeletion.name,
62
+ id: data.id,
63
+ attributes: {
64
+ confirmation: data.confirmation,
65
+ },
66
+ meta: {},
67
+ relationships: {},
68
+ },
69
+ included: [],
70
+ };
71
+
72
+ return response;
73
+ }
74
+
75
+ /**
76
+ * Body of the public "Keep my account" call. The caller is not signed in and
77
+ * has no id to send: the API validates a client UUID and ignores its value.
78
+ */
79
+ createKeepJsonApi(params: { token: string }) {
80
+ return {
81
+ data: {
82
+ type: Modules.AccountDeletion.name,
83
+ id: v4(),
84
+ attributes: {
85
+ token: params.token,
86
+ },
87
+ },
88
+ };
89
+ }
90
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./account-deletion.interface";
2
+ export * from "./account-deletion";
3
+ export * from "./account-deletion.service";
@@ -0,0 +1,4 @@
1
+ export * from "./account-deletion.module";
2
+ export * from "./data";
3
+ export * from "./utils";
4
+ export * from "./components";