@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
|
@@ -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";
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import { JsonApiHydratedDataInterface, Modules, rehydrate } from "../../../../core";
|
|
5
5
|
import { useI18nRouter } from "../../../../i18n";
|
|
6
|
+
import { markAccountDeletionCancelled } from "../../../account-deletion/utils/account-deletion-notice";
|
|
6
7
|
import { UserInterface } from "../../../user";
|
|
7
8
|
import { useCurrentUserContext } from "../../../user/contexts";
|
|
8
9
|
import { AuthInterface } from "../../data";
|
|
@@ -18,6 +19,8 @@ export function Cookies({ dehydratedAuth, page }: { dehydratedAuth: JsonApiHydra
|
|
|
18
19
|
async function saveTokenOnServer() {
|
|
19
20
|
await AuthService.saveToken({ dehydratedAuth });
|
|
20
21
|
const auth: AuthInterface = rehydrate(Modules.Auth, dehydratedAuth) as AuthInterface;
|
|
22
|
+
// Discord / Google path: the flag is on the Auth document from the code exchange.
|
|
23
|
+
if (auth.deletionCancelled) markAccountDeletionCancelled();
|
|
21
24
|
setUser(auth.user as UserInterface);
|
|
22
25
|
setHasSaved(true);
|
|
23
26
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
TabsList,
|
|
19
19
|
TabsTrigger,
|
|
20
20
|
} from "../../../../shadcnui";
|
|
21
|
+
import { markAccountDeletionCancelled } from "../../../account-deletion/utils/account-deletion-notice";
|
|
21
22
|
import { UserInterface } from "../../../user";
|
|
22
23
|
import { useCurrentUserContext } from "../../../user/contexts";
|
|
23
24
|
import { useAuthContext } from "../../contexts";
|
|
@@ -46,6 +47,7 @@ export function TwoFactorChallenge() {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
const handleSuccess = (auth: AuthInterface) => {
|
|
50
|
+
if (auth.deletionCancelled) markAccountDeletionCancelled();
|
|
49
51
|
setPendingTwoFactor(undefined);
|
|
50
52
|
setUser(auth.user);
|
|
51
53
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
3
|
+
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { Auth } from "../auth";
|
|
5
|
+
|
|
6
|
+
// Auth.rehydrate() resolves Modules.User through the real ModuleRegistry (the
|
|
7
|
+
// argument of `_readIncluded`), so it must be registered even though these
|
|
8
|
+
// fixtures carry no included user.
|
|
9
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
10
|
+
try {
|
|
11
|
+
ModuleRegistry.get(key as any);
|
|
12
|
+
} catch {
|
|
13
|
+
ModuleRegistry.register(key, module);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
registerIfAbsent("Auth", { name: "auth", model: Auth } as any);
|
|
19
|
+
registerIfAbsent("User", { name: "users", model: class {} } as any);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const rehydrateWith = (attributes: Record<string, any>) =>
|
|
23
|
+
new Auth().rehydrate({
|
|
24
|
+
jsonApi: { type: "auth", id: "auth-1", attributes: { token: "t", refreshToken: "r", ...attributes } },
|
|
25
|
+
included: [],
|
|
26
|
+
} as any);
|
|
27
|
+
|
|
28
|
+
describe("Auth", () => {
|
|
29
|
+
describe("rehydrate", () => {
|
|
30
|
+
it("deletionCancelled true when the attribute is true, false otherwise", () => {
|
|
31
|
+
expect(rehydrateWith({ deletionCancelled: true }).deletionCancelled).toBe(true);
|
|
32
|
+
expect(rehydrateWith({ deletionCancelled: false }).deletionCancelled).toBe(false);
|
|
33
|
+
expect(rehydrateWith({}).deletionCancelled).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -35,4 +35,6 @@ export interface AuthInterface extends ApiDataInterface {
|
|
|
35
35
|
get token(): string;
|
|
36
36
|
get refreshToken(): string;
|
|
37
37
|
get user(): UserInterface;
|
|
38
|
+
/** True on the session created by the login that cancelled a pending account deletion. */
|
|
39
|
+
get deletionCancelled(): boolean;
|
|
38
40
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
rehydrate,
|
|
10
10
|
} from "../../../core";
|
|
11
11
|
import { JsonApiDelete, JsonApiGet, JsonApiPost } from "../../../unified";
|
|
12
|
+
import { markAccountDeletionCancelled } from "../../account-deletion/utils/account-deletion-notice";
|
|
12
13
|
import { UserInterface } from "../../user";
|
|
13
14
|
import { getTokenHandler } from "../config";
|
|
14
15
|
import { PendingRegistrationInterface } from "./pending-registration.interface";
|
|
@@ -45,6 +46,9 @@ export class AuthService extends AbstractService {
|
|
|
45
46
|
// Normal auth response
|
|
46
47
|
const auth = apiResponse.data as AuthInterface;
|
|
47
48
|
|
|
49
|
+
// Login returns only the user to Login.tsx, so the flag is read here.
|
|
50
|
+
if (auth.deletionCancelled) markAccountDeletionCancelled();
|
|
51
|
+
|
|
48
52
|
// Use injected token handler if configured
|
|
49
53
|
const handler = getTokenHandler();
|
|
50
54
|
if (handler) {
|
|
@@ -6,6 +6,12 @@ export class Auth extends AbstractApiData implements AuthInterface {
|
|
|
6
6
|
private _token?: string;
|
|
7
7
|
private _refreshToken?: string;
|
|
8
8
|
private _user?: UserInterface;
|
|
9
|
+
private _deletionCancelled: boolean = false;
|
|
10
|
+
|
|
11
|
+
/** True on the session created by the login that cancelled a pending account deletion. */
|
|
12
|
+
get deletionCancelled(): boolean {
|
|
13
|
+
return this._deletionCancelled;
|
|
14
|
+
}
|
|
9
15
|
|
|
10
16
|
get token(): string {
|
|
11
17
|
if (!this._token) throw new Error("Token is not defined");
|
|
@@ -27,6 +33,7 @@ export class Auth extends AbstractApiData implements AuthInterface {
|
|
|
27
33
|
|
|
28
34
|
this._token = data.jsonApi.attributes.token ?? undefined;
|
|
29
35
|
this._refreshToken = data.jsonApi.attributes.refreshToken ?? undefined;
|
|
36
|
+
this._deletionCancelled = data.jsonApi.attributes.deletionCancelled === true;
|
|
30
37
|
|
|
31
38
|
this._user = this._readIncluded(data, "user", Modules.User) as UserInterface;
|
|
32
39
|
|
|
@@ -36,6 +36,13 @@ export interface CompanyInterface extends ApiDataInterface {
|
|
|
36
36
|
|
|
37
37
|
get isActiveSubscription(): boolean;
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* When the company's trial ends, written by the server only (never sent by
|
|
41
|
+
* `createJsonApi()`). Absent for companies whose trial end was never stored;
|
|
42
|
+
* callers then derive it from `createdAt`.
|
|
43
|
+
*/
|
|
44
|
+
get trialEndsAt(): Date | undefined;
|
|
45
|
+
|
|
39
46
|
get monthlyCredits(): number;
|
|
40
47
|
get availableMonthlyCredits(): number;
|
|
41
48
|
get availableExtraCredits(): number;
|
|
@@ -67,3 +67,26 @@ describe("Company credit-warning fields", () => {
|
|
|
67
67
|
expect(company.creditWarningOffer).toBeUndefined();
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("Company.trialEndsAt", () => {
|
|
72
|
+
it("rehydrates trialEndsAt as a Date", () => {
|
|
73
|
+
const company = new Company().rehydrate({
|
|
74
|
+
jsonApi: { attributes: { trialEndsAt: "2026-11-09T10:00:00.000Z" } },
|
|
75
|
+
} as any);
|
|
76
|
+
|
|
77
|
+
expect(company.trialEndsAt).toBeInstanceOf(Date);
|
|
78
|
+
expect(company.trialEndsAt?.toISOString()).toBe("2026-11-09T10:00:00.000Z");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("is undefined when the attribute is absent", () => {
|
|
82
|
+
const company = new Company().rehydrate({ jsonApi: { attributes: {} } } as any);
|
|
83
|
+
|
|
84
|
+
expect(company.trialEndsAt).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is never sent by createJsonApi", () => {
|
|
88
|
+
const payload = new Company().createJsonApi({ id: "company_1", name: "Acme" });
|
|
89
|
+
|
|
90
|
+
expect(payload.data.attributes).not.toHaveProperty("trialEndsAt");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -11,6 +11,7 @@ export class Company extends AbstractApiData implements CompanyInterface {
|
|
|
11
11
|
private _configurations?: any;
|
|
12
12
|
|
|
13
13
|
private _isActiveSubscription: boolean = false;
|
|
14
|
+
private _trialEndsAt?: Date;
|
|
14
15
|
|
|
15
16
|
private _monthlyCredits: number = 0;
|
|
16
17
|
private _availableMonthlyCredits: number = 0;
|
|
@@ -53,6 +54,10 @@ export class Company extends AbstractApiData implements CompanyInterface {
|
|
|
53
54
|
return this._isActiveSubscription ?? false;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
get trialEndsAt(): Date | undefined {
|
|
58
|
+
return this._trialEndsAt;
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
get monthlyCredits(): number {
|
|
57
62
|
return this._monthlyCredits ?? 0;
|
|
58
63
|
}
|
|
@@ -147,6 +152,7 @@ export class Company extends AbstractApiData implements CompanyInterface {
|
|
|
147
152
|
this._logo = data.jsonApi.attributes.logo;
|
|
148
153
|
this._logoUrl = data.jsonApi.attributes.logoUrl;
|
|
149
154
|
this._isActiveSubscription = data.jsonApi.attributes.isActiveSubscription ?? false;
|
|
155
|
+
this._trialEndsAt = data.jsonApi.attributes.trialEndsAt ? new Date(data.jsonApi.attributes.trialEndsAt) : undefined;
|
|
150
156
|
this._monthlyCredits = data.jsonApi.attributes.monthlyCredits ?? 0;
|
|
151
157
|
this._availableMonthlyCredits = data.jsonApi.attributes.availableMonthlyCredits ?? 0;
|
|
152
158
|
this._availableExtraCredits = data.jsonApi.attributes.availableExtraCredits ?? 0;
|
|
@@ -193,4 +193,63 @@ describe("useSubscriptionStatus", () => {
|
|
|
193
193
|
expect(after.result.current.isBlocked).toBe(true);
|
|
194
194
|
expect(after.result.current.status).toBe("expired");
|
|
195
195
|
});
|
|
196
|
+
describe("stored trialEndsAt", () => {
|
|
197
|
+
const NOW = new Date("2026-10-26T10:00:00.000Z");
|
|
198
|
+
|
|
199
|
+
beforeEach(() => {
|
|
200
|
+
vi.setSystemTime(NOW);
|
|
201
|
+
userMock.currentUser = { roles: [] };
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("not active, trialEndsAt in the future → trial, not blocked", () => {
|
|
205
|
+
const trialEndsAt = new Date("2026-11-05T10:00:00.000Z");
|
|
206
|
+
userMock.company = { isActiveSubscription: false, createdAt: daysAgo(60, NOW), trialEndsAt };
|
|
207
|
+
|
|
208
|
+
const { result } = renderHook(() => useSubscriptionStatus());
|
|
209
|
+
|
|
210
|
+
expect(result.current.status).toBe("trial");
|
|
211
|
+
expect(result.current.daysRemaining).toBe(10);
|
|
212
|
+
expect(result.current.isBlocked).toBe(false);
|
|
213
|
+
expect(result.current.trialEndsAt?.getTime()).toBe(trialEndsAt.getTime());
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("not active, trialEndsAt in the past → expired, blocked", () => {
|
|
217
|
+
userMock.company = {
|
|
218
|
+
isActiveSubscription: false,
|
|
219
|
+
createdAt: daysAgo(60, NOW),
|
|
220
|
+
trialEndsAt: new Date("2026-10-20T10:00:00.000Z"),
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const { result } = renderHook(() => useSubscriptionStatus());
|
|
224
|
+
|
|
225
|
+
expect(result.current.status).toBe("expired");
|
|
226
|
+
expect(result.current.isBlocked).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("not active, no trialEndsAt → createdAt + 14 days (unchanged)", () => {
|
|
230
|
+
const createdAt = daysAgo(4, NOW);
|
|
231
|
+
userMock.company = { isActiveSubscription: false, createdAt, trialEndsAt: undefined };
|
|
232
|
+
|
|
233
|
+
const { result } = renderHook(() => useSubscriptionStatus());
|
|
234
|
+
|
|
235
|
+
expect(result.current.status).toBe("trial");
|
|
236
|
+
expect(result.current.daysRemaining).toBe(10);
|
|
237
|
+
expect(result.current.isBlocked).toBe(false);
|
|
238
|
+
expect(result.current.trialEndsAt?.getTime()).toBe(createdAt.getTime() + 14 * DAY_MS);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("active ignores trialEndsAt", () => {
|
|
242
|
+
userMock.company = {
|
|
243
|
+
isActiveSubscription: true,
|
|
244
|
+
createdAt: daysAgo(60, NOW),
|
|
245
|
+
trialEndsAt: new Date("2026-11-05T10:00:00.000Z"),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const { result } = renderHook(() => useSubscriptionStatus());
|
|
249
|
+
|
|
250
|
+
expect(result.current.status).toBe("active");
|
|
251
|
+
expect(result.current.trialEndsAt).toBeNull();
|
|
252
|
+
expect(result.current.isBlocked).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
196
255
|
});
|
|
@@ -86,8 +86,10 @@ export function useSubscriptionStatus(): TrialSubscriptionStatus {
|
|
|
86
86
|
|
|
87
87
|
// No active subscription — but that alone is NOT a block. The trial window
|
|
88
88
|
// has to have elapsed too, and this mirrors the server rule in
|
|
89
|
-
// apps/api
|
|
90
|
-
// step:
|
|
89
|
+
// apps/api/src/features/campaign/entities/campaign-lock.cypher.ts. Keep the
|
|
90
|
+
// two in step: the trial ends at the stored company.trialEndsAt when the
|
|
91
|
+
// server wrote one, otherwise at createdAt + TRIAL_DAYS. If either the
|
|
92
|
+
// fallback or TRIAL_DAYS changes here it must change there.
|
|
91
93
|
//
|
|
92
94
|
// Blocking on the flag alone is wrong in practice. isActiveSubscription is
|
|
93
95
|
// only written true once a Stripe subscription actually reaches
|
|
@@ -100,7 +102,7 @@ export function useSubscriptionStatus(): TrialSubscriptionStatus {
|
|
|
100
102
|
// viewer's local clock: across a DST change the window grows or shrinks by
|
|
101
103
|
// an hour, and dividing that by a fixed 24h day then shows one day too many.
|
|
102
104
|
const createdAt = new Date(company.createdAt);
|
|
103
|
-
const trialEndsAt = new Date(createdAt.getTime() + TRIAL_DAYS * DAY_MS);
|
|
105
|
+
const trialEndsAt = company.trialEndsAt ?? new Date(createdAt.getTime() + TRIAL_DAYS * DAY_MS);
|
|
104
106
|
|
|
105
107
|
const now = new Date();
|
|
106
108
|
const msRemaining = trialEndsAt.getTime() - now.getTime();
|
|
@@ -8,6 +8,10 @@ import { usePageUrlGenerator } from "../../../hooks";
|
|
|
8
8
|
import { BreadcrumbItemData } from "../../../interfaces";
|
|
9
9
|
import { Action } from "../../../permissions";
|
|
10
10
|
import { getRoleId } from "../../../roles";
|
|
11
|
+
import {
|
|
12
|
+
AccountDeletionAdminTrigger,
|
|
13
|
+
isAccountDeletionEnabled,
|
|
14
|
+
} from "../../account-deletion/components/AccountDeletionAdminTrigger";
|
|
11
15
|
import { UserDeleter, UserEditor, UserReactivator, UserResentInvitationEmail } from "../components/forms";
|
|
12
16
|
import { UserInterface } from "../data";
|
|
13
17
|
import { useCurrentUserContext } from "./CurrentUserContext";
|
|
@@ -83,6 +87,10 @@ export const UserProvider = ({ children, dehydratedUser }: UserProviderProps) =>
|
|
|
83
87
|
functions.push(<UserResentInvitationEmail key={`UserResentInvitationEmail`} user={user} />);
|
|
84
88
|
|
|
85
89
|
functions.push(<UserDeleter key={`UserDeleter`} user={user} />);
|
|
90
|
+
|
|
91
|
+
// Opt-in: only apps that registered AccountDeletionModule, platform administrators only.
|
|
92
|
+
if (hasRole(getRoleId().Administrator) && isAccountDeletionEnabled())
|
|
93
|
+
functions.push(<AccountDeletionAdminTrigger key={`AccountDeletionAdminTrigger`} user={user} />);
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
functions.push(<UserEditor key={`UserEditor`} user={user} propagateChanges={setUser} />);
|