@carlonicora/nextjs-jsonapi 6.6.0 → 6.8.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 (127) hide show
  1. package/dist/{AssistantMessageInterface-0ZQ1EJKO.d.mts → AssistantMessageInterface-BE3QxI54.d.mts} +1 -1
  2. package/dist/{AssistantMessageInterface-CA-hZ5Wj.d.ts → AssistantMessageInterface-nXpD_d-m.d.ts} +1 -1
  3. package/dist/{BlockNoteEditor-MWGVWNSE.mjs → BlockNoteEditor-D3LDTJCP.mjs} +4 -4
  4. package/dist/{BlockNoteEditor-E4V7AEG2.js → BlockNoteEditor-FB6FQFRF.js} +19 -19
  5. package/dist/{BlockNoteEditor-E4V7AEG2.js.map → BlockNoteEditor-FB6FQFRF.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-LGRKM7OA.js → BlockNoteViewer-LGFMGTYX.js} +3 -3
  7. package/dist/{BlockNoteViewer-LGRKM7OA.js.map → BlockNoteViewer-LGFMGTYX.js.map} +1 -1
  8. package/dist/{BlockNoteViewer-VNU7WVN5.mjs → BlockNoteViewer-YSPYUDZE.mjs} +2 -2
  9. package/dist/{BlogPostInterface-CTGfsR1I.d.ts → BlogPostInterface-2sVqvXFd.d.ts} +1 -1
  10. package/dist/{BlogPostInterface-BN2mXc46.d.mts → BlogPostInterface-BgJLFpaa.d.mts} +1 -1
  11. package/dist/{auth.interface-tsOuc0Fi.d.mts → auth.interface-BAYLbYjs.d.mts} +3 -1
  12. package/dist/{auth.interface-D5606g19.d.ts → auth.interface-BjYwwfy9.d.ts} +3 -1
  13. package/dist/billing/index.js +492 -492
  14. package/dist/billing/index.mjs +3 -3
  15. package/dist/{chunk-RSMBRI3E.mjs → chunk-5HBFQEYU.mjs} +2 -2
  16. package/dist/{chunk-IDV534SJ.mjs → chunk-5HTX4RDA.mjs} +3730 -3537
  17. package/dist/chunk-5HTX4RDA.mjs.map +1 -0
  18. package/dist/{chunk-IQ2XYAB5.mjs → chunk-D44UDP4G.mjs} +2 -2
  19. package/dist/{chunk-F2MZ7GY6.js → chunk-JHT32ODN.js} +4 -4
  20. package/dist/{chunk-F2MZ7GY6.js.map → chunk-JHT32ODN.js.map} +1 -1
  21. package/dist/{chunk-AEVLFZRK.js → chunk-PSLT5JLE.js} +3 -3
  22. package/dist/{chunk-AEVLFZRK.js.map → chunk-PSLT5JLE.js.map} +1 -1
  23. package/dist/{chunk-PDS57QPD.mjs → chunk-TL5O7EGW.mjs} +2 -2
  24. package/dist/{chunk-R2XBVIO5.js → chunk-UT4GVYJE.js} +1067 -874
  25. package/dist/chunk-UT4GVYJE.js.map +1 -0
  26. package/dist/{chunk-OJAIKQR2.js → chunk-UZP7KRAV.js} +432 -233
  27. package/dist/chunk-UZP7KRAV.js.map +1 -0
  28. package/dist/{chunk-7GB5MR77.mjs → chunk-VT5HRZH7.mjs} +200 -1
  29. package/dist/chunk-VT5HRZH7.mjs.map +1 -0
  30. package/dist/{chunk-AMN2XDN5.js → chunk-ZXK2OMCC.js} +8 -8
  31. package/dist/{chunk-AMN2XDN5.js.map → chunk-ZXK2OMCC.js.map} +1 -1
  32. package/dist/client/index.d.mts +3 -3
  33. package/dist/client/index.d.ts +3 -3
  34. package/dist/client/index.js +4 -4
  35. package/dist/client/index.mjs +3 -3
  36. package/dist/components/index.d.mts +41 -6
  37. package/dist/components/index.d.ts +41 -6
  38. package/dist/components/index.js +12 -4
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/components/index.mjs +11 -3
  41. package/dist/contexts/index.d.mts +4 -4
  42. package/dist/contexts/index.d.ts +4 -4
  43. package/dist/contexts/index.js +4 -4
  44. package/dist/contexts/index.mjs +3 -3
  45. package/dist/core/index.d.mts +123 -11
  46. package/dist/core/index.d.ts +123 -11
  47. package/dist/core/index.js +14 -2
  48. package/dist/core/index.js.map +1 -1
  49. package/dist/core/index.mjs +13 -1
  50. package/dist/email-automations/index.js +92 -92
  51. package/dist/email-automations/index.mjs +3 -3
  52. package/dist/features/analytics/index.d.mts +1 -1
  53. package/dist/features/analytics/index.d.ts +1 -1
  54. package/dist/features/analytics/index.js +121 -121
  55. package/dist/features/analytics/index.mjs +4 -4
  56. package/dist/features/analytics/server.js +4 -4
  57. package/dist/features/analytics/server.mjs +2 -2
  58. package/dist/features/blog/index.d.mts +3 -3
  59. package/dist/features/blog/index.d.ts +3 -3
  60. package/dist/features/blog/index.js +150 -150
  61. package/dist/features/blog/index.mjs +3 -3
  62. package/dist/features/blog/server.d.mts +3 -3
  63. package/dist/features/blog/server.d.ts +3 -3
  64. package/dist/features/blog/server.js +16 -16
  65. package/dist/features/blog/server.mjs +1 -1
  66. package/dist/features/help/index.js +37 -37
  67. package/dist/features/help/index.mjs +3 -3
  68. package/dist/features/tokenusage/index.js +127 -127
  69. package/dist/features/tokenusage/index.mjs +4 -4
  70. package/dist/index.d.mts +7 -7
  71. package/dist/index.d.ts +7 -7
  72. package/dist/index.js +15 -3
  73. package/dist/index.js.map +1 -1
  74. package/dist/index.mjs +14 -2
  75. package/dist/{notification.interface-DQReXAD7.d.ts → notification.interface-DQItyY_Q.d.ts} +1 -1
  76. package/dist/{notification.interface-CF5h90U2.d.mts → notification.interface-DoVS5RZB.d.mts} +1 -1
  77. package/dist/{s3.service-BNBD9YTg.d.mts → s3.service-BG136Btz.d.mts} +3 -3
  78. package/dist/{s3.service-Da7C2gtt.d.ts → s3.service-CsrsRPRn.d.ts} +3 -3
  79. package/dist/server/index.d.mts +4 -4
  80. package/dist/server/index.d.ts +4 -4
  81. package/dist/server/index.js +3 -3
  82. package/dist/server/index.mjs +2 -2
  83. package/dist/{useSocket-CwSoq6VS.d.mts → useSocket-C8fqU4od.d.mts} +1 -1
  84. package/dist/{useSocket-BM3q9A0C.d.ts → useSocket-IRHtk2xc.d.ts} +1 -1
  85. package/dist/{user.interface-BYnGJ7ss.d.mts → user.interface-Bm-0TbsB.d.mts} +6 -0
  86. package/dist/{user.interface-BqPjVyJI.d.ts → user.interface-CKBIEvNu.d.ts} +6 -0
  87. package/messages/en.json +24 -0
  88. package/messages/it.json +24 -0
  89. package/package.json +1 -1
  90. package/src/components/index.ts +1 -0
  91. package/src/core/index.ts +5 -0
  92. package/src/core/registry/ModuleRegistry.ts +13 -0
  93. package/src/features/account-deletion/account-deletion.module.ts +13 -0
  94. package/src/features/account-deletion/components/AccountDeletionAdminTrigger.tsx +183 -0
  95. package/src/features/account-deletion/components/AccountDeletionCancelledNotice.tsx +51 -0
  96. package/src/features/account-deletion/components/AccountDeletionKeepContainer.tsx +74 -0
  97. package/src/features/account-deletion/components/index.ts +3 -0
  98. package/src/features/account-deletion/data/__tests__/AccountDeletion.spec.ts +89 -0
  99. package/src/features/account-deletion/data/account-deletion.interface.ts +22 -0
  100. package/src/features/account-deletion/data/account-deletion.service.ts +69 -0
  101. package/src/features/account-deletion/data/account-deletion.ts +90 -0
  102. package/src/features/account-deletion/data/index.ts +3 -0
  103. package/src/features/account-deletion/index.ts +4 -0
  104. package/src/features/account-deletion/utils/__tests__/account-deletion-notice.spec.ts +40 -0
  105. package/src/features/account-deletion/utils/account-deletion-notice.ts +27 -0
  106. package/src/features/account-deletion/utils/index.ts +1 -0
  107. package/src/features/auth/components/forms/Cookies.tsx +3 -0
  108. package/src/features/auth/components/forms/TwoFactorChallenge.tsx +2 -0
  109. package/src/features/auth/data/__tests__/auth.spec.ts +36 -0
  110. package/src/features/auth/data/auth.interface.ts +2 -0
  111. package/src/features/auth/data/auth.service.ts +4 -0
  112. package/src/features/auth/data/auth.ts +7 -0
  113. package/src/features/company/data/company.interface.ts +7 -0
  114. package/src/features/company/data/company.spec.ts +23 -0
  115. package/src/features/company/data/company.ts +6 -0
  116. package/src/features/company/hooks/useSubscriptionStatus.spec.ts +59 -0
  117. package/src/features/company/hooks/useSubscriptionStatus.ts +5 -3
  118. package/src/features/user/contexts/UserContext.tsx +8 -0
  119. package/dist/chunk-7GB5MR77.mjs.map +0 -1
  120. package/dist/chunk-IDV534SJ.mjs.map +0 -1
  121. package/dist/chunk-OJAIKQR2.js.map +0 -1
  122. package/dist/chunk-R2XBVIO5.js.map +0 -1
  123. /package/dist/{BlockNoteEditor-MWGVWNSE.mjs.map → BlockNoteEditor-D3LDTJCP.mjs.map} +0 -0
  124. /package/dist/{BlockNoteViewer-VNU7WVN5.mjs.map → BlockNoteViewer-YSPYUDZE.mjs.map} +0 -0
  125. /package/dist/{chunk-RSMBRI3E.mjs.map → chunk-5HBFQEYU.mjs.map} +0 -0
  126. /package/dist/{chunk-IQ2XYAB5.mjs.map → chunk-D44UDP4G.mjs.map} +0 -0
  127. /package/dist/{chunk-PDS57QPD.mjs.map → chunk-TL5O7EGW.mjs.map} +0 -0
package/messages/it.json CHANGED
@@ -1,4 +1,28 @@
1
1
  {
2
+ "accountDeletion": {
3
+ "admin": {
4
+ "body": "L'account sarà eliminato il {date}. L'utente riceve un'email con un link per mantenerlo, e anche accedere lo mantiene.",
5
+ "bodyNoEmail": "L'account sarà eliminato il {date}. Non abbiamo un indirizzo email per questo utente, quindi solo accedendo potrà mantenerlo.",
6
+ "confirm": "Elimina account",
7
+ "confirmLabel": "Scrivi {value} per confermare",
8
+ "confirmWord": "ELIMINA",
9
+ "pending": "Eliminazione prevista il {date}",
10
+ "subscription": "L'abbonamento viene annullato oggi, senza rimborso.",
11
+ "title": "Eliminare questo account?",
12
+ "trigger": "Elimina account"
13
+ },
14
+ "cancelled": {
15
+ "body": "Il tuo abbonamento è stato annullato quando lo hai chiesto.",
16
+ "choosePlan": "Scegli un piano",
17
+ "title": "Bentornato. Il tuo account non sarà eliminato."
18
+ },
19
+ "keep": {
20
+ "failure": "Questo link non funziona più. Se il tuo account esiste ancora, accedi per mantenerlo.",
21
+ "login": "Accedi",
22
+ "successBody": "Non lo elimineremo. L'abbonamento resta annullato: scegli di nuovo un piano da Fatturazione quando accedi.",
23
+ "successTitle": "Il tuo account è al sicuro"
24
+ }
25
+ },
2
26
  "administration": {
3
27
  "ai_connections": {
4
28
  "description": "Provider AI, modelli e catene di fallback"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "6.6.0",
3
+ "version": "6.8.0",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -20,6 +20,7 @@ export * from "./hero";
20
20
  export { parseFiscalData } from "../utils/fiscal-utils";
21
21
 
22
22
  // Feature components
23
+ export * from "../features/account-deletion/components";
23
24
  export * from "../features/auth/components";
24
25
  export * from "../features/administration";
25
26
  export * from "../features/ai-connection";
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";
@@ -96,6 +96,8 @@ export interface FoundationModuleDefinitions {
96
96
  AnalyticsBreakdown: ModuleWithPermissions;
97
97
  AnalyticsSession: ModuleWithPermissions;
98
98
  AnalyticsPageView: ModuleWithPermissions;
99
+ // Account deletion (opt-in: registered only by apps that enable it)
100
+ AccountDeletion: ModuleWithPermissions;
99
101
  }
100
102
 
101
103
  // App-specific modules - apps will augment this interface ONLY
@@ -150,6 +152,17 @@ class ModuleRegistryClass {
150
152
  return module as ModuleDefinitions[K];
151
153
  }
152
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
+
153
166
  findByName(moduleName: string): ModuleWithPermissions {
154
167
  // Self-healing: try bootstrap if registry is empty
155
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
+ }