@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.
- package/dist/{AssistantMessageInterface-0ZQ1EJKO.d.mts → AssistantMessageInterface-BE3QxI54.d.mts} +1 -1
- package/dist/{AssistantMessageInterface-CA-hZ5Wj.d.ts → AssistantMessageInterface-nXpD_d-m.d.ts} +1 -1
- package/dist/{BlockNoteEditor-MWGVWNSE.mjs → BlockNoteEditor-D3LDTJCP.mjs} +4 -4
- package/dist/{BlockNoteEditor-E4V7AEG2.js → BlockNoteEditor-FB6FQFRF.js} +19 -19
- package/dist/{BlockNoteEditor-E4V7AEG2.js.map → BlockNoteEditor-FB6FQFRF.js.map} +1 -1
- package/dist/{BlockNoteViewer-LGRKM7OA.js → BlockNoteViewer-LGFMGTYX.js} +3 -3
- package/dist/{BlockNoteViewer-LGRKM7OA.js.map → BlockNoteViewer-LGFMGTYX.js.map} +1 -1
- package/dist/{BlockNoteViewer-VNU7WVN5.mjs → BlockNoteViewer-YSPYUDZE.mjs} +2 -2
- package/dist/{BlogPostInterface-CTGfsR1I.d.ts → BlogPostInterface-2sVqvXFd.d.ts} +1 -1
- package/dist/{BlogPostInterface-BN2mXc46.d.mts → BlogPostInterface-BgJLFpaa.d.mts} +1 -1
- package/dist/{auth.interface-tsOuc0Fi.d.mts → auth.interface-BAYLbYjs.d.mts} +3 -1
- package/dist/{auth.interface-D5606g19.d.ts → auth.interface-BjYwwfy9.d.ts} +3 -1
- package/dist/billing/index.js +492 -492
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-RSMBRI3E.mjs → chunk-5HBFQEYU.mjs} +2 -2
- package/dist/{chunk-IDV534SJ.mjs → chunk-5HTX4RDA.mjs} +3730 -3537
- package/dist/chunk-5HTX4RDA.mjs.map +1 -0
- package/dist/{chunk-IQ2XYAB5.mjs → chunk-D44UDP4G.mjs} +2 -2
- package/dist/{chunk-F2MZ7GY6.js → chunk-JHT32ODN.js} +4 -4
- package/dist/{chunk-F2MZ7GY6.js.map → chunk-JHT32ODN.js.map} +1 -1
- package/dist/{chunk-AEVLFZRK.js → chunk-PSLT5JLE.js} +3 -3
- package/dist/{chunk-AEVLFZRK.js.map → chunk-PSLT5JLE.js.map} +1 -1
- package/dist/{chunk-PDS57QPD.mjs → chunk-TL5O7EGW.mjs} +2 -2
- package/dist/{chunk-R2XBVIO5.js → chunk-UT4GVYJE.js} +1067 -874
- package/dist/chunk-UT4GVYJE.js.map +1 -0
- package/dist/{chunk-OJAIKQR2.js → chunk-UZP7KRAV.js} +432 -233
- package/dist/chunk-UZP7KRAV.js.map +1 -0
- package/dist/{chunk-7GB5MR77.mjs → chunk-VT5HRZH7.mjs} +200 -1
- package/dist/chunk-VT5HRZH7.mjs.map +1 -0
- package/dist/{chunk-AMN2XDN5.js → chunk-ZXK2OMCC.js} +8 -8
- package/dist/{chunk-AMN2XDN5.js.map → chunk-ZXK2OMCC.js.map} +1 -1
- package/dist/client/index.d.mts +3 -3
- package/dist/client/index.d.ts +3 -3
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +41 -6
- package/dist/components/index.d.ts +41 -6
- package/dist/components/index.js +12 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +11 -3
- package/dist/contexts/index.d.mts +4 -4
- package/dist/contexts/index.d.ts +4 -4
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +123 -11
- package/dist/core/index.d.ts +123 -11
- package/dist/core/index.js +14 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +13 -1
- package/dist/email-automations/index.js +92 -92
- package/dist/email-automations/index.mjs +3 -3
- package/dist/features/analytics/index.d.mts +1 -1
- package/dist/features/analytics/index.d.ts +1 -1
- package/dist/features/analytics/index.js +121 -121
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/blog/index.d.mts +3 -3
- package/dist/features/blog/index.d.ts +3 -3
- package/dist/features/blog/index.js +150 -150
- package/dist/features/blog/index.mjs +3 -3
- package/dist/features/blog/server.d.mts +3 -3
- package/dist/features/blog/server.d.ts +3 -3
- package/dist/features/blog/server.js +16 -16
- package/dist/features/blog/server.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +127 -127
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +7 -7
- package/dist/index.d.ts +7 -7
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +14 -2
- package/dist/{notification.interface-DQReXAD7.d.ts → notification.interface-DQItyY_Q.d.ts} +1 -1
- package/dist/{notification.interface-CF5h90U2.d.mts → notification.interface-DoVS5RZB.d.mts} +1 -1
- package/dist/{s3.service-BNBD9YTg.d.mts → s3.service-BG136Btz.d.mts} +3 -3
- package/dist/{s3.service-Da7C2gtt.d.ts → s3.service-CsrsRPRn.d.ts} +3 -3
- package/dist/server/index.d.mts +4 -4
- package/dist/server/index.d.ts +4 -4
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/dist/{useSocket-CwSoq6VS.d.mts → useSocket-C8fqU4od.d.mts} +1 -1
- package/dist/{useSocket-BM3q9A0C.d.ts → useSocket-IRHtk2xc.d.ts} +1 -1
- package/dist/{user.interface-BYnGJ7ss.d.mts → user.interface-Bm-0TbsB.d.mts} +6 -0
- package/dist/{user.interface-BqPjVyJI.d.ts → user.interface-CKBIEvNu.d.ts} +6 -0
- package/messages/en.json +24 -0
- package/messages/it.json +24 -0
- package/package.json +1 -1
- package/src/components/index.ts +1 -0
- package/src/core/index.ts +5 -0
- package/src/core/registry/ModuleRegistry.ts +13 -0
- package/src/features/account-deletion/account-deletion.module.ts +13 -0
- package/src/features/account-deletion/components/AccountDeletionAdminTrigger.tsx +183 -0
- package/src/features/account-deletion/components/AccountDeletionCancelledNotice.tsx +51 -0
- package/src/features/account-deletion/components/AccountDeletionKeepContainer.tsx +74 -0
- package/src/features/account-deletion/components/index.ts +3 -0
- package/src/features/account-deletion/data/__tests__/AccountDeletion.spec.ts +89 -0
- package/src/features/account-deletion/data/account-deletion.interface.ts +22 -0
- package/src/features/account-deletion/data/account-deletion.service.ts +69 -0
- package/src/features/account-deletion/data/account-deletion.ts +90 -0
- package/src/features/account-deletion/data/index.ts +3 -0
- package/src/features/account-deletion/index.ts +4 -0
- package/src/features/account-deletion/utils/__tests__/account-deletion-notice.spec.ts +40 -0
- package/src/features/account-deletion/utils/account-deletion-notice.ts +27 -0
- package/src/features/account-deletion/utils/index.ts +1 -0
- package/src/features/auth/components/forms/Cookies.tsx +3 -0
- package/src/features/auth/components/forms/TwoFactorChallenge.tsx +2 -0
- package/src/features/auth/data/__tests__/auth.spec.ts +36 -0
- package/src/features/auth/data/auth.interface.ts +2 -0
- package/src/features/auth/data/auth.service.ts +4 -0
- package/src/features/auth/data/auth.ts +7 -0
- package/src/features/company/data/company.interface.ts +7 -0
- package/src/features/company/data/company.spec.ts +23 -0
- package/src/features/company/data/company.ts +6 -0
- package/src/features/company/hooks/useSubscriptionStatus.spec.ts +59 -0
- package/src/features/company/hooks/useSubscriptionStatus.ts +5 -3
- package/src/features/user/contexts/UserContext.tsx +8 -0
- package/dist/chunk-7GB5MR77.mjs.map +0 -1
- package/dist/chunk-IDV534SJ.mjs.map +0 -1
- package/dist/chunk-OJAIKQR2.js.map +0 -1
- package/dist/chunk-R2XBVIO5.js.map +0 -1
- /package/dist/{BlockNoteEditor-MWGVWNSE.mjs.map → BlockNoteEditor-D3LDTJCP.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-VNU7WVN5.mjs.map → BlockNoteViewer-YSPYUDZE.mjs.map} +0 -0
- /package/dist/{chunk-RSMBRI3E.mjs.map → chunk-5HBFQEYU.mjs.map} +0 -0
- /package/dist/{chunk-IQ2XYAB5.mjs.map → chunk-D44UDP4G.mjs.map} +0 -0
- /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
package/src/components/index.ts
CHANGED
|
@@ -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,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
|
+
}
|