@carlonicora/nextjs-jsonapi 6.6.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.
- package/dist/{BlockNoteEditor-E4V7AEG2.js → BlockNoteEditor-BSFR2GPI.js} +19 -19
- package/dist/{BlockNoteEditor-E4V7AEG2.js.map → BlockNoteEditor-BSFR2GPI.js.map} +1 -1
- package/dist/{BlockNoteEditor-MWGVWNSE.mjs → BlockNoteEditor-HWJBQAKG.mjs} +4 -4
- package/dist/{BlockNoteViewer-VNU7WVN5.mjs → BlockNoteViewer-IIBUU7J7.mjs} +2 -2
- package/dist/{BlockNoteViewer-LGRKM7OA.js → BlockNoteViewer-NHS6CCMS.js} +3 -3
- package/dist/{BlockNoteViewer-LGRKM7OA.js.map → BlockNoteViewer-NHS6CCMS.js.map} +1 -1
- package/dist/{auth.interface-D5606g19.d.ts → auth.interface-B4tRE0k0.d.ts} +2 -0
- package/dist/{auth.interface-tsOuc0Fi.d.mts → auth.interface-DuX87BZ3.d.mts} +2 -0
- package/dist/billing/index.js +492 -492
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-AMN2XDN5.js → chunk-3HOJNAZB.js} +8 -8
- package/dist/{chunk-AMN2XDN5.js.map → chunk-3HOJNAZB.js.map} +1 -1
- package/dist/{chunk-OJAIKQR2.js → chunk-6KE7UBYY.js} +427 -233
- package/dist/chunk-6KE7UBYY.js.map +1 -0
- package/dist/{chunk-F2MZ7GY6.js → chunk-73XWQA6U.js} +4 -4
- package/dist/{chunk-F2MZ7GY6.js.map → chunk-73XWQA6U.js.map} +1 -1
- package/dist/{chunk-IQ2XYAB5.mjs → chunk-EC4PQ44E.mjs} +2 -2
- package/dist/{chunk-RSMBRI3E.mjs → chunk-OYRJ32KR.mjs} +2 -2
- package/dist/{chunk-R2XBVIO5.js → chunk-PCCUPEBU.js} +1066 -873
- package/dist/chunk-PCCUPEBU.js.map +1 -0
- package/dist/{chunk-AEVLFZRK.js → chunk-PYDDFFQM.js} +3 -3
- package/dist/{chunk-AEVLFZRK.js.map → chunk-PYDDFFQM.js.map} +1 -1
- package/dist/{chunk-7GB5MR77.mjs → chunk-QNIWATQD.mjs} +195 -1
- package/dist/chunk-QNIWATQD.mjs.map +1 -0
- package/dist/{chunk-IDV534SJ.mjs → chunk-S4NKWMX5.mjs} +3729 -3536
- package/dist/chunk-S4NKWMX5.mjs.map +1 -0
- package/dist/{chunk-PDS57QPD.mjs → chunk-XYKDBOCE.mjs} +2 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +39 -4
- package/dist/components/index.d.ts +39 -4
- 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.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +115 -5
- package/dist/core/index.d.ts +115 -5
- 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.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.js +150 -150
- package/dist/features/blog/index.mjs +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 +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +14 -2
- package/dist/{s3.service-BNBD9YTg.d.mts → s3.service-CRGJbO-6.d.mts} +1 -1
- package/dist/{s3.service-Da7C2gtt.d.ts → s3.service-DZG-VyLY.d.ts} +1 -1
- package/dist/server/index.d.mts +2 -2
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- 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/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-HWJBQAKG.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-VNU7WVN5.mjs.map → BlockNoteViewer-IIBUU7J7.mjs.map} +0 -0
- /package/dist/{chunk-IQ2XYAB5.mjs.map → chunk-EC4PQ44E.mjs.map} +0 -0
- /package/dist/{chunk-RSMBRI3E.mjs.map → chunk-OYRJ32KR.mjs.map} +0 -0
- /package/dist/{chunk-PDS57QPD.mjs.map → chunk-XYKDBOCE.mjs.map} +0 -0
|
@@ -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
|
+
}
|
|
@@ -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,40 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { consumeAccountDeletionCancelled, markAccountDeletionCancelled } from "../account-deletion-notice";
|
|
3
|
+
|
|
4
|
+
describe("account-deletion notice flag", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
window.sessionStorage.clear();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.unstubAllGlobals();
|
|
11
|
+
window.sessionStorage.clear();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("consume returns true once after mark, then false", () => {
|
|
15
|
+
expect(consumeAccountDeletionCancelled()).toBe(false);
|
|
16
|
+
|
|
17
|
+
markAccountDeletionCancelled();
|
|
18
|
+
|
|
19
|
+
expect(consumeAccountDeletionCancelled()).toBe(true);
|
|
20
|
+
expect(consumeAccountDeletionCancelled()).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("survives a throwing sessionStorage", () => {
|
|
24
|
+
const throwing = {
|
|
25
|
+
getItem: () => {
|
|
26
|
+
throw new Error("blocked");
|
|
27
|
+
},
|
|
28
|
+
setItem: () => {
|
|
29
|
+
throw new Error("blocked");
|
|
30
|
+
},
|
|
31
|
+
removeItem: () => {
|
|
32
|
+
throw new Error("blocked");
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
vi.stubGlobal("sessionStorage", throwing);
|
|
36
|
+
|
|
37
|
+
expect(() => markAccountDeletionCancelled()).not.toThrow();
|
|
38
|
+
expect(consumeAccountDeletionCancelled()).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot flag between the login that cancelled a pending account deletion and
|
|
3
|
+
* the first page after it, which shows AccountDeletionCancelledNotice. Kept in
|
|
4
|
+
* sessionStorage so it survives the redirect (and the full reload of the
|
|
5
|
+
* Discord / Google path) but not the tab. Storage can be missing or throw
|
|
6
|
+
* (private mode, blocked site data): the notice is then simply not shown.
|
|
7
|
+
*/
|
|
8
|
+
export const ACCOUNT_DELETION_CANCELLED_KEY = "nja:accountDeletionCancelled";
|
|
9
|
+
|
|
10
|
+
export function markAccountDeletionCancelled(): void {
|
|
11
|
+
try {
|
|
12
|
+
sessionStorage.setItem(ACCOUNT_DELETION_CANCELLED_KEY, "1");
|
|
13
|
+
} catch {
|
|
14
|
+
// Storage unavailable: no notice.
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function consumeAccountDeletionCancelled(): boolean {
|
|
19
|
+
try {
|
|
20
|
+
const value = sessionStorage.getItem(ACCOUNT_DELETION_CANCELLED_KEY);
|
|
21
|
+
if (value === null) return false;
|
|
22
|
+
sessionStorage.removeItem(ACCOUNT_DELETION_CANCELLED_KEY);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./account-deletion-notice";
|