@carlonicora/nextjs-jsonapi 1.40.0 → 1.41.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/README.md +3 -3
- package/dist/AuthComponent-BuWc2C4g.d.ts +28 -0
- package/dist/AuthComponent-fLVGdvSr.d.mts +28 -0
- package/dist/{BlockNoteEditor-EKY4AHVK.mjs → BlockNoteEditor-B3RQ4VQ7.mjs} +5 -5
- package/dist/{BlockNoteEditor-4G3L3LSF.js → BlockNoteEditor-VUAWVZF4.js} +15 -15
- package/dist/{BlockNoteEditor-4G3L3LSF.js.map → BlockNoteEditor-VUAWVZF4.js.map} +1 -1
- package/dist/JsonApiRequest-MUPAO7DI.js +24 -0
- package/dist/{JsonApiRequest-GR3L56A5.js.map → JsonApiRequest-MUPAO7DI.js.map} +1 -1
- package/dist/{JsonApiRequest-K5BRU7RE.mjs → JsonApiRequest-XCQHVVYD.mjs} +2 -2
- package/dist/auth.interface-8XglqHir.d.mts +33 -0
- package/dist/auth.interface-BJGKQ0zr.d.ts +33 -0
- package/dist/billing/index.js +409 -415
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +4 -10
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-BAOP6PTD.mjs → chunk-BJNQZGMN.mjs} +1618 -666
- package/dist/chunk-BJNQZGMN.mjs.map +1 -0
- package/dist/{chunk-U4MTVHOC.mjs → chunk-GCQUTWZ2.mjs} +11 -4
- package/dist/{chunk-U4MTVHOC.mjs.map → chunk-GCQUTWZ2.mjs.map} +1 -1
- package/dist/{chunk-ZNGEVB5M.js → chunk-L5F5ZN5F.js} +960 -140
- package/dist/chunk-L5F5ZN5F.js.map +1 -0
- package/dist/{chunk-RRIYLEY6.mjs → chunk-LBIC4GJK.mjs} +2 -2
- package/dist/{chunk-T5YYOT4Z.js → chunk-OODZEX6P.js} +3 -3
- package/dist/{chunk-T5YYOT4Z.js.map → chunk-OODZEX6P.js.map} +1 -1
- package/dist/{chunk-GVN7XC3U.mjs → chunk-PHNL4QUF.mjs} +835 -15
- package/dist/chunk-PHNL4QUF.mjs.map +1 -0
- package/dist/{chunk-GKY5DAIH.js → chunk-QPWHMXE2.js} +1505 -553
- package/dist/chunk-QPWHMXE2.js.map +1 -0
- package/dist/{chunk-FM6WRAN5.js → chunk-WLS4D6VG.js} +12 -5
- package/dist/chunk-WLS4D6VG.js.map +1 -0
- package/dist/client/index.d.mts +4 -4
- package/dist/client/index.d.ts +4 -4
- package/dist/client/index.js +5 -5
- package/dist/client/index.mjs +4 -4
- package/dist/components/index.d.mts +69 -8
- package/dist/components/index.d.ts +69 -8
- package/dist/components/index.js +27 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +26 -4
- package/dist/{config-BxwhHdCD.d.mts → config-BW5u1e9P.d.mts} +1 -1
- package/dist/{config-BbaBV_yk.d.ts → config-BozK5PY0.d.ts} +1 -1
- package/dist/{content.interface-CgUu4771.d.ts → content.interface-CpCDB1Uk.d.ts} +1 -1
- package/dist/{content.interface-CWV0q4lZ.d.mts → content.interface-b-mzkL_q.d.mts} +1 -1
- package/dist/contexts/index.d.mts +2 -2
- package/dist/contexts/index.d.ts +2 -2
- package/dist/contexts/index.js +5 -5
- package/dist/contexts/index.mjs +4 -4
- package/dist/core/index.d.mts +407 -7
- package/dist/core/index.d.ts +407 -7
- package/dist/core/index.js +61 -3
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +60 -2
- package/dist/index.d.mts +8 -6
- package/dist/index.d.ts +8 -6
- package/dist/index.js +62 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +61 -3
- package/dist/{notification.interface-XARGKJAq.d.ts → notification.interface-CR2PuV6Y.d.ts} +1 -0
- package/dist/{notification.interface-DIln2r7X.d.mts → notification.interface-D241WNUx.d.mts} +1 -0
- package/dist/{s3.service-BoOF5-ln.d.mts → s3.service-D0rbmLFp.d.mts} +10 -31
- package/dist/{s3.service-Mxo-7wQ6.d.ts → s3.service-DOwqcUDT.d.ts} +10 -31
- package/dist/scripts/generate-web-module/generator.js +26 -26
- package/dist/scripts/generate-web-module/generator.js.map +1 -1
- package/dist/scripts/generate-web-module/utils/file-writer.js +9 -9
- package/dist/scripts/generate-web-module/utils/file-writer.js.map +1 -1
- package/dist/server/index.d.mts +4 -3
- package/dist/server/index.d.ts +4 -3
- package/dist/server/index.js +12 -12
- package/dist/server/index.mjs +2 -2
- package/dist/{useSocket-awibcC9B.d.ts → useSocket-CC8SkXdm.d.ts} +1 -1
- package/dist/{useSocket-BILAdmZ0.d.mts → useSocket-CttIHn2P.d.mts} +1 -1
- package/package.json +4 -1
- package/scripts/generate-web-module/generator.ts +26 -26
- package/scripts/generate-web-module/utils/file-writer.ts +9 -9
- package/src/components/pages/PageContentContainer.tsx +22 -9
- package/src/core/abstracts/AbstractService.ts +2 -0
- package/src/core/factories/JsonApiDataFactory.ts +2 -1
- package/src/core/index.ts +14 -0
- package/src/core/registry/DataClassRegistry.ts +7 -1
- package/src/core/registry/ModuleRegistry.ts +15 -0
- package/src/features/auth/backup-code-verify.module.ts +9 -0
- package/src/features/auth/components/containers/SecurityContainer.tsx +11 -0
- package/src/features/auth/components/containers/index.ts +1 -0
- package/src/features/auth/components/forms/Login.tsx +15 -3
- package/src/features/auth/components/forms/Register.tsx +1 -9
- package/src/features/auth/components/forms/TwoFactorChallenge.tsx +202 -0
- package/src/features/auth/components/forms/index.ts +1 -0
- package/src/features/auth/components/index.ts +1 -0
- package/src/features/auth/components/two-factor/BackupCodesDialog.tsx +148 -0
- package/src/features/auth/components/two-factor/DisableTwoFactorDialog.tsx +74 -0
- package/src/features/auth/components/two-factor/PasskeyButton.tsx +59 -0
- package/src/features/auth/components/two-factor/PasskeyList.tsx +172 -0
- package/src/features/auth/components/two-factor/PasskeySetupDialog.tsx +105 -0
- package/src/features/auth/components/two-factor/TotpAuthenticatorList.tsx +104 -0
- package/src/features/auth/components/two-factor/TotpInput.tsx +90 -0
- package/src/features/auth/components/two-factor/TotpSetupDialog.tsx +161 -0
- package/src/features/auth/components/two-factor/TwoFactorSettings.tsx +175 -0
- package/src/features/auth/components/two-factor/index.ts +9 -0
- package/src/features/auth/contexts/AuthContext.tsx +9 -0
- package/src/features/auth/data/auth.service.ts +18 -1
- package/src/features/auth/data/backup-code-verify.ts +20 -0
- package/src/features/auth/data/index.ts +21 -0
- package/src/features/auth/data/passkey-authentication-options.interface.ts +7 -0
- package/src/features/auth/data/passkey-authentication-options.ts +37 -0
- package/src/features/auth/data/passkey-registration-options.ts +46 -0
- package/src/features/auth/data/passkey-registration-verify.ts +62 -0
- package/src/features/auth/data/passkey-rename.ts +20 -0
- package/src/features/auth/data/passkey-verify-login.ts +23 -0
- package/src/features/auth/data/passkey.interface.ts +9 -0
- package/src/features/auth/data/passkey.ts +40 -0
- package/src/features/auth/data/totp-authenticator.interface.ts +7 -0
- package/src/features/auth/data/totp-authenticator.ts +28 -0
- package/src/features/auth/data/totp-setup.interface.ts +5 -0
- package/src/features/auth/data/totp-setup.ts +48 -0
- package/src/features/auth/data/totp-verify-login.ts +20 -0
- package/src/features/auth/data/totp-verify.ts +22 -0
- package/src/features/auth/data/two-factor-challenge.interface.ts +7 -0
- package/src/features/auth/data/two-factor-challenge.ts +45 -0
- package/src/features/auth/data/two-factor-enable.ts +20 -0
- package/src/features/auth/data/two-factor-status.interface.ts +11 -0
- package/src/features/auth/data/two-factor-status.ts +40 -0
- package/src/features/auth/data/two-factor.service.ts +331 -0
- package/src/features/auth/enums/AuthComponent.ts +1 -0
- package/src/features/auth/index.ts +13 -0
- package/src/features/auth/passkey-authentication-options.module.ts +9 -0
- package/src/features/auth/passkey-registration-options.module.ts +9 -0
- package/src/features/auth/passkey-registration-verify.module.ts +9 -0
- package/src/features/auth/passkey-rename.module.ts +9 -0
- package/src/features/auth/passkey-verify-login.module.ts +9 -0
- package/src/features/auth/passkey.module.ts +9 -0
- package/src/features/auth/totp-authenticator.module.ts +9 -0
- package/src/features/auth/totp-setup.module.ts +9 -0
- package/src/features/auth/totp-verify-login.module.ts +9 -0
- package/src/features/auth/totp-verify.module.ts +9 -0
- package/src/features/auth/two-factor-challenge.module.ts +9 -0
- package/src/features/auth/two-factor-enable.module.ts +9 -0
- package/src/features/auth/two-factor-status.module.ts +9 -0
- package/src/features/billing/modules/billing.module.ts +1 -0
- package/src/features/billing/stripe-customer/stripe-customer.module.ts +1 -0
- package/src/features/billing/stripe-customer/stripe-payment-method.module.ts +1 -0
- package/src/features/billing/stripe-invoice/stripe-invoice.module.ts +1 -0
- package/src/features/billing/stripe-price/stripe-price.module.ts +1 -0
- package/src/features/billing/stripe-product/stripe-product.module.ts +1 -0
- package/src/features/billing/stripe-promotion-code/stripe-promotion-code.module.ts +1 -0
- package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +0 -5
- package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +0 -8
- package/src/features/billing/stripe-subscription/stripe-subscription.module.ts +1 -0
- package/src/features/billing/stripe-usage/stripe-usage.module.ts +1 -0
- package/src/features/user/data/user.interface.ts +1 -0
- package/src/features/user/data/user.ts +6 -0
- package/src/features/waitlist/data/WaitlistService.ts +1 -8
- package/src/features/waitlist/waitlist-stats.module.ts +1 -0
- package/src/shadcnui/ui/resizable.tsx +33 -11
- package/src/unified/JsonApiRequest.ts +2 -1
- package/dist/AuthComponent-hxOPs9o8.d.mts +0 -11
- package/dist/AuthComponent-hxOPs9o8.d.ts +0 -11
- package/dist/JsonApiRequest-GR3L56A5.js +0 -24
- package/dist/chunk-BAOP6PTD.mjs.map +0 -1
- package/dist/chunk-FM6WRAN5.js.map +0 -1
- package/dist/chunk-GKY5DAIH.js.map +0 -1
- package/dist/chunk-GVN7XC3U.mjs.map +0 -1
- package/dist/chunk-ZNGEVB5M.js.map +0 -1
- /package/dist/{BlockNoteEditor-EKY4AHVK.mjs.map → BlockNoteEditor-B3RQ4VQ7.mjs.map} +0 -0
- /package/dist/{JsonApiRequest-K5BRU7RE.mjs.map → JsonApiRequest-XCQHVVYD.mjs.map} +0 -0
- /package/dist/{chunk-RRIYLEY6.mjs.map → chunk-LBIC4GJK.mjs.map} +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Edit, Key, Trash2 } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { errorToast } from "../../../../components/errors/errorToast";
|
|
7
|
+
import {
|
|
8
|
+
AlertDialog,
|
|
9
|
+
AlertDialogAction,
|
|
10
|
+
AlertDialogCancel,
|
|
11
|
+
AlertDialogContent,
|
|
12
|
+
AlertDialogDescription,
|
|
13
|
+
AlertDialogFooter,
|
|
14
|
+
AlertDialogHeader,
|
|
15
|
+
AlertDialogTitle,
|
|
16
|
+
AlertDialogTrigger,
|
|
17
|
+
Button,
|
|
18
|
+
Dialog,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogFooter,
|
|
21
|
+
DialogHeader,
|
|
22
|
+
DialogTitle,
|
|
23
|
+
Input,
|
|
24
|
+
} from "../../../../shadcnui";
|
|
25
|
+
import { showToast } from "../../../../utils/toast";
|
|
26
|
+
import { PasskeyInterface } from "../../data/passkey.interface";
|
|
27
|
+
import { TwoFactorService } from "../../data/two-factor.service";
|
|
28
|
+
|
|
29
|
+
interface PasskeyListProps {
|
|
30
|
+
passkeys: PasskeyInterface[];
|
|
31
|
+
onRefresh: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function PasskeyList({ passkeys, onRefresh }: PasskeyListProps) {
|
|
35
|
+
const t = useTranslations();
|
|
36
|
+
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
|
37
|
+
const [selectedPasskey, setSelectedPasskey] = useState<PasskeyInterface | null>(null);
|
|
38
|
+
const [newName, setNewName] = useState("");
|
|
39
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
40
|
+
|
|
41
|
+
const handleDelete = async (passkey: PasskeyInterface) => {
|
|
42
|
+
setIsLoading(true);
|
|
43
|
+
try {
|
|
44
|
+
await TwoFactorService.deletePasskey({ id: passkey.id });
|
|
45
|
+
|
|
46
|
+
showToast(t("common.success"), {
|
|
47
|
+
description: t("auth.two_factor.passkey_deleted"),
|
|
48
|
+
});
|
|
49
|
+
onRefresh();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
errorToast({
|
|
52
|
+
title: t("common.errors.error"),
|
|
53
|
+
error,
|
|
54
|
+
});
|
|
55
|
+
} finally {
|
|
56
|
+
setIsLoading(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleRename = async () => {
|
|
61
|
+
if (!selectedPasskey || !newName.trim()) return;
|
|
62
|
+
|
|
63
|
+
setIsLoading(true);
|
|
64
|
+
try {
|
|
65
|
+
await TwoFactorService.renamePasskey({
|
|
66
|
+
id: selectedPasskey.id,
|
|
67
|
+
name: newName.trim(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
showToast(t("common.success"), {
|
|
71
|
+
description: t("auth.two_factor.passkey_renamed"),
|
|
72
|
+
});
|
|
73
|
+
setRenameDialogOpen(false);
|
|
74
|
+
setSelectedPasskey(null);
|
|
75
|
+
setNewName("");
|
|
76
|
+
onRefresh();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
errorToast({
|
|
79
|
+
title: t("common.errors.error"),
|
|
80
|
+
error,
|
|
81
|
+
});
|
|
82
|
+
} finally {
|
|
83
|
+
setIsLoading(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const openRenameDialog = (passkey: PasskeyInterface) => {
|
|
88
|
+
setSelectedPasskey(passkey);
|
|
89
|
+
setNewName(passkey.name);
|
|
90
|
+
setRenameDialogOpen(true);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (passkeys.length === 0) {
|
|
94
|
+
return <p className="text-sm text-muted-foreground text-center py-4">{t("auth.two_factor.no_passkeys")}</p>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<div className="space-y-2">
|
|
100
|
+
{passkeys.map((passkey) => (
|
|
101
|
+
<div key={passkey.id} className="flex items-center justify-between p-3 border rounded-lg">
|
|
102
|
+
<div className="flex items-center gap-3">
|
|
103
|
+
<Key className="h-5 w-5 text-muted-foreground" />
|
|
104
|
+
<div>
|
|
105
|
+
<p className="font-medium">{passkey.name}</p>
|
|
106
|
+
<p className="text-sm text-muted-foreground">
|
|
107
|
+
{passkey.backedUp && "☁️ "}
|
|
108
|
+
{passkey.lastUsedAt
|
|
109
|
+
? `${t("auth.two_factor.last_used")}: ${new Date(passkey.lastUsedAt).toLocaleDateString()}`
|
|
110
|
+
: t("auth.two_factor.never_used")}
|
|
111
|
+
</p>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="flex gap-2">
|
|
115
|
+
<Button variant="ghost" size="icon" onClick={() => openRenameDialog(passkey)} disabled={isLoading}>
|
|
116
|
+
<Edit className="h-4 w-4" />
|
|
117
|
+
</Button>
|
|
118
|
+
<AlertDialog>
|
|
119
|
+
<AlertDialogTrigger
|
|
120
|
+
render={
|
|
121
|
+
<Button variant="ghost" size="icon" disabled={isLoading}>
|
|
122
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
123
|
+
</Button>
|
|
124
|
+
}
|
|
125
|
+
/>
|
|
126
|
+
<AlertDialogContent>
|
|
127
|
+
<AlertDialogHeader>
|
|
128
|
+
<AlertDialogTitle>{t("auth.two_factor.remove_passkey")}</AlertDialogTitle>
|
|
129
|
+
<AlertDialogDescription>{t("auth.two_factor.confirm_delete_passkey")}</AlertDialogDescription>
|
|
130
|
+
</AlertDialogHeader>
|
|
131
|
+
<AlertDialogFooter>
|
|
132
|
+
<AlertDialogCancel>{t("common.buttons.cancel")}</AlertDialogCancel>
|
|
133
|
+
<AlertDialogAction
|
|
134
|
+
onClick={() => handleDelete(passkey)}
|
|
135
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
136
|
+
>
|
|
137
|
+
{t("common.buttons.delete")}
|
|
138
|
+
</AlertDialogAction>
|
|
139
|
+
</AlertDialogFooter>
|
|
140
|
+
</AlertDialogContent>
|
|
141
|
+
</AlertDialog>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
|
|
148
|
+
<DialogContent>
|
|
149
|
+
<DialogHeader>
|
|
150
|
+
<DialogTitle>{t("auth.two_factor.rename_passkey")}</DialogTitle>
|
|
151
|
+
</DialogHeader>
|
|
152
|
+
<div className="space-y-4">
|
|
153
|
+
<Input
|
|
154
|
+
value={newName}
|
|
155
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
156
|
+
placeholder={t("auth.two_factor.passkey_name")}
|
|
157
|
+
disabled={isLoading}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
<DialogFooter>
|
|
161
|
+
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
|
|
162
|
+
{t("common.cancel")}
|
|
163
|
+
</Button>
|
|
164
|
+
<Button onClick={handleRename} disabled={!newName.trim() || isLoading}>
|
|
165
|
+
{t("common.save")}
|
|
166
|
+
</Button>
|
|
167
|
+
</DialogFooter>
|
|
168
|
+
</DialogContent>
|
|
169
|
+
</Dialog>
|
|
170
|
+
</>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { startRegistration } from "@simplewebauthn/browser";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { v4 } from "uuid";
|
|
7
|
+
import { errorToast } from "../../../../components/errors/errorToast";
|
|
8
|
+
import {
|
|
9
|
+
Button,
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogDescription,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
Input,
|
|
16
|
+
Label,
|
|
17
|
+
} from "../../../../shadcnui";
|
|
18
|
+
import { showToast } from "../../../../utils/toast";
|
|
19
|
+
import { useCurrentUserContext } from "../../../user/contexts/CurrentUserContext";
|
|
20
|
+
import { TwoFactorService } from "../../data/two-factor.service";
|
|
21
|
+
|
|
22
|
+
interface PasskeySetupDialogProps {
|
|
23
|
+
open: boolean;
|
|
24
|
+
onOpenChange: (open: boolean) => void;
|
|
25
|
+
onSuccess: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PasskeySetupDialog({ open, onOpenChange, onSuccess }: PasskeySetupDialogProps) {
|
|
29
|
+
const t = useTranslations();
|
|
30
|
+
const { currentUser } = useCurrentUserContext();
|
|
31
|
+
const [name, setName] = useState("");
|
|
32
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
33
|
+
|
|
34
|
+
const handleRegister = async () => {
|
|
35
|
+
if (!name.trim()) return;
|
|
36
|
+
|
|
37
|
+
setIsLoading(true);
|
|
38
|
+
try {
|
|
39
|
+
// 1. Get registration options from backend
|
|
40
|
+
const registrationData = await TwoFactorService.getPasskeyRegistrationOptions({
|
|
41
|
+
id: v4(),
|
|
42
|
+
userName: currentUser?.email ?? "",
|
|
43
|
+
userDisplayName: currentUser?.name,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// 2. Trigger browser WebAuthn dialog
|
|
47
|
+
const credential = await startRegistration({ optionsJSON: registrationData.options });
|
|
48
|
+
|
|
49
|
+
// 3. Verify with backend
|
|
50
|
+
await TwoFactorService.verifyPasskeyRegistration({
|
|
51
|
+
id: v4(),
|
|
52
|
+
pendingId: registrationData.pendingId,
|
|
53
|
+
name: name.trim(),
|
|
54
|
+
response: credential,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Auto-enable 2FA if not already enabled
|
|
58
|
+
const status = await TwoFactorService.getStatus();
|
|
59
|
+
if (!status.isEnabled) {
|
|
60
|
+
await TwoFactorService.enable({ id: v4(), preferredMethod: "passkey" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
showToast(t("common.success"), {
|
|
64
|
+
description: t("auth.two_factor.passkey_registered"),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
setName("");
|
|
68
|
+
onOpenChange(false);
|
|
69
|
+
onSuccess();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
errorToast({
|
|
72
|
+
title: t("common.errors.error"),
|
|
73
|
+
error,
|
|
74
|
+
});
|
|
75
|
+
} finally {
|
|
76
|
+
setIsLoading(false);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
82
|
+
<DialogContent>
|
|
83
|
+
<DialogHeader>
|
|
84
|
+
<DialogTitle>{t("auth.two_factor.setup_passkey")}</DialogTitle>
|
|
85
|
+
<DialogDescription>{t("auth.two_factor.passkey_setup_description")}</DialogDescription>
|
|
86
|
+
</DialogHeader>
|
|
87
|
+
<div className="space-y-4">
|
|
88
|
+
<div className="space-y-2">
|
|
89
|
+
<Label htmlFor="passkey-name">{t("auth.two_factor.passkey_name")}</Label>
|
|
90
|
+
<Input
|
|
91
|
+
id="passkey-name"
|
|
92
|
+
value={name}
|
|
93
|
+
onChange={(e) => setName(e.target.value)}
|
|
94
|
+
placeholder="MacBook Pro Touch ID"
|
|
95
|
+
disabled={isLoading}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
<Button onClick={handleRegister} disabled={!name.trim() || isLoading} className="w-full">
|
|
99
|
+
{isLoading ? t("common.loading") : t("auth.two_factor.register_passkey")}
|
|
100
|
+
</Button>
|
|
101
|
+
</div>
|
|
102
|
+
</DialogContent>
|
|
103
|
+
</Dialog>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Smartphone, Trash2 } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { errorToast } from "../../../../components/errors/errorToast";
|
|
7
|
+
import {
|
|
8
|
+
AlertDialog,
|
|
9
|
+
AlertDialogAction,
|
|
10
|
+
AlertDialogCancel,
|
|
11
|
+
AlertDialogContent,
|
|
12
|
+
AlertDialogDescription,
|
|
13
|
+
AlertDialogFooter,
|
|
14
|
+
AlertDialogHeader,
|
|
15
|
+
AlertDialogTitle,
|
|
16
|
+
AlertDialogTrigger,
|
|
17
|
+
Button,
|
|
18
|
+
Card,
|
|
19
|
+
CardContent,
|
|
20
|
+
} from "../../../../shadcnui";
|
|
21
|
+
import { showToast } from "../../../../utils/toast";
|
|
22
|
+
import { TotpAuthenticatorInterface } from "../../data/totp-authenticator.interface";
|
|
23
|
+
import { TwoFactorService } from "../../data/two-factor.service";
|
|
24
|
+
|
|
25
|
+
interface TotpAuthenticatorListProps {
|
|
26
|
+
authenticators: TotpAuthenticatorInterface[];
|
|
27
|
+
onDelete: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function TotpAuthenticatorList({ authenticators, onDelete }: TotpAuthenticatorListProps) {
|
|
31
|
+
const t = useTranslations();
|
|
32
|
+
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
33
|
+
|
|
34
|
+
const handleDelete = async (id: string) => {
|
|
35
|
+
setDeletingId(id);
|
|
36
|
+
try {
|
|
37
|
+
await TwoFactorService.deleteTotpAuthenticator({ id });
|
|
38
|
+
|
|
39
|
+
showToast(t("common.success"), {
|
|
40
|
+
description: t("auth.two_factor.authenticator_removed"),
|
|
41
|
+
});
|
|
42
|
+
onDelete();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
errorToast({ title: t("common.errors.error"), error });
|
|
45
|
+
} finally {
|
|
46
|
+
setDeletingId(null);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (authenticators.length === 0) {
|
|
51
|
+
return <p className="text-sm text-muted-foreground">{t("auth.two_factor.no_authenticators")}</p>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="space-y-2">
|
|
56
|
+
{authenticators.map((auth) => (
|
|
57
|
+
<Card key={auth.id} data-testid={`authenticator-${auth.id}`}>
|
|
58
|
+
<CardContent className="flex items-center justify-between p-4">
|
|
59
|
+
<div className="flex items-center gap-3">
|
|
60
|
+
<Smartphone className="h-5 w-5 text-muted-foreground" />
|
|
61
|
+
<div>
|
|
62
|
+
<p className="font-medium">{auth.name}</p>
|
|
63
|
+
{auth.lastUsedAt && (
|
|
64
|
+
<p className="text-xs text-muted-foreground">
|
|
65
|
+
{t("auth.two_factor.last_used")}: {auth.lastUsedAt.toLocaleDateString()}
|
|
66
|
+
</p>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<AlertDialog>
|
|
71
|
+
<AlertDialogTrigger
|
|
72
|
+
render={
|
|
73
|
+
<Button
|
|
74
|
+
variant="ghost"
|
|
75
|
+
size="icon"
|
|
76
|
+
disabled={deletingId === auth.id}
|
|
77
|
+
data-testid={`delete-auth-${auth.id}`}
|
|
78
|
+
>
|
|
79
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
80
|
+
</Button>
|
|
81
|
+
}
|
|
82
|
+
/>
|
|
83
|
+
<AlertDialogContent>
|
|
84
|
+
<AlertDialogHeader>
|
|
85
|
+
<AlertDialogTitle>{t("auth.two_factor.remove_authenticator")}</AlertDialogTitle>
|
|
86
|
+
<AlertDialogDescription>{t("auth.two_factor.remove_authenticator_confirm")}</AlertDialogDescription>
|
|
87
|
+
</AlertDialogHeader>
|
|
88
|
+
<AlertDialogFooter>
|
|
89
|
+
<AlertDialogCancel>{t("common.buttons.cancel")}</AlertDialogCancel>
|
|
90
|
+
<AlertDialogAction
|
|
91
|
+
onClick={() => handleDelete(auth.id)}
|
|
92
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
93
|
+
>
|
|
94
|
+
{t("common.buttons.remove")}
|
|
95
|
+
</AlertDialogAction>
|
|
96
|
+
</AlertDialogFooter>
|
|
97
|
+
</AlertDialogContent>
|
|
98
|
+
</AlertDialog>
|
|
99
|
+
</CardContent>
|
|
100
|
+
</Card>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Input } from "../../../../shadcnui";
|
|
5
|
+
|
|
6
|
+
interface TotpInputProps {
|
|
7
|
+
onComplete: (code: string) => void;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
autoFocus?: boolean;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function TotpInput({ onComplete, disabled = false, autoFocus = true, error }: TotpInputProps) {
|
|
14
|
+
const [digits, setDigits] = useState<string[]>(["", "", "", "", "", ""]);
|
|
15
|
+
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (autoFocus && inputRefs.current[0]) {
|
|
19
|
+
inputRefs.current[0].focus();
|
|
20
|
+
}
|
|
21
|
+
}, [autoFocus]);
|
|
22
|
+
|
|
23
|
+
const handleChange = (index: number, value: string) => {
|
|
24
|
+
// Only allow digits
|
|
25
|
+
const digit = value.replace(/\D/g, "").slice(-1);
|
|
26
|
+
|
|
27
|
+
const newDigits = [...digits];
|
|
28
|
+
newDigits[index] = digit;
|
|
29
|
+
setDigits(newDigits);
|
|
30
|
+
|
|
31
|
+
// Move to next input
|
|
32
|
+
if (digit && index < 5) {
|
|
33
|
+
inputRefs.current[index + 1]?.focus();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check if complete
|
|
37
|
+
const code = newDigits.join("");
|
|
38
|
+
if (code.length === 6 && newDigits.every((d) => d !== "")) {
|
|
39
|
+
onComplete(code);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
|
44
|
+
if (e.key === "Backspace" && !digits[index] && index > 0) {
|
|
45
|
+
inputRefs.current[index - 1]?.focus();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const handlePaste = (e: React.ClipboardEvent) => {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
|
|
52
|
+
|
|
53
|
+
if (pastedData.length === 6) {
|
|
54
|
+
const newDigits = pastedData.split("");
|
|
55
|
+
setDigits(newDigits);
|
|
56
|
+
inputRefs.current[5]?.focus();
|
|
57
|
+
onComplete(pastedData);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex flex-col items-center gap-4">
|
|
63
|
+
<div className="flex gap-2">
|
|
64
|
+
{digits.map((digit, index) => (
|
|
65
|
+
<Input
|
|
66
|
+
key={index}
|
|
67
|
+
ref={(el) => {
|
|
68
|
+
inputRefs.current[index] = el;
|
|
69
|
+
}}
|
|
70
|
+
type="text"
|
|
71
|
+
inputMode="numeric"
|
|
72
|
+
maxLength={1}
|
|
73
|
+
value={digit}
|
|
74
|
+
onChange={(e) => handleChange(index, e.target.value)}
|
|
75
|
+
onKeyDown={(e) => handleKeyDown(index, e)}
|
|
76
|
+
onPaste={handlePaste}
|
|
77
|
+
disabled={disabled}
|
|
78
|
+
className={`w-12 h-14 text-center text-2xl font-mono ${error ? "border-destructive" : ""}`}
|
|
79
|
+
data-testid={`totp-input-${index}`}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
{error && (
|
|
84
|
+
<p className="text-sm text-destructive" data-testid="totp-error">
|
|
85
|
+
{error}
|
|
86
|
+
</p>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { QRCodeSVG } from "qrcode.react";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { v4 } from "uuid";
|
|
7
|
+
import { errorToast } from "../../../../components/errors/errorToast";
|
|
8
|
+
import {
|
|
9
|
+
Button,
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogDescription,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
DialogTrigger,
|
|
16
|
+
Input,
|
|
17
|
+
Label,
|
|
18
|
+
} from "../../../../shadcnui";
|
|
19
|
+
import { showToast } from "../../../../utils/toast";
|
|
20
|
+
import { useCurrentUserContext } from "../../../user/contexts/CurrentUserContext";
|
|
21
|
+
import { TwoFactorService } from "../../data/two-factor.service";
|
|
22
|
+
import { TotpInput } from "./TotpInput";
|
|
23
|
+
|
|
24
|
+
interface TotpSetupDialogProps {
|
|
25
|
+
onSuccess: () => void;
|
|
26
|
+
trigger?: React.ReactElement;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function TotpSetupDialog({ onSuccess, trigger }: TotpSetupDialogProps) {
|
|
30
|
+
const t = useTranslations();
|
|
31
|
+
const { currentUser } = useCurrentUserContext();
|
|
32
|
+
const [open, setOpen] = useState(false);
|
|
33
|
+
const [step, setStep] = useState<"name" | "scan" | "verify">("name");
|
|
34
|
+
const [name, setName] = useState("");
|
|
35
|
+
const [qrCodeUri, setQrCodeUri] = useState("");
|
|
36
|
+
const [authenticatorId, setAuthenticatorId] = useState("");
|
|
37
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
38
|
+
const [verifyError, setVerifyError] = useState<string | undefined>();
|
|
39
|
+
|
|
40
|
+
const handleStartSetup = async () => {
|
|
41
|
+
if (!name.trim()) return;
|
|
42
|
+
|
|
43
|
+
setIsLoading(true);
|
|
44
|
+
try {
|
|
45
|
+
const setup = await TwoFactorService.setupTotp({
|
|
46
|
+
id: v4(),
|
|
47
|
+
name: name.trim(),
|
|
48
|
+
accountName: currentUser?.email ?? "",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
setQrCodeUri(setup.qrCodeUri);
|
|
52
|
+
setAuthenticatorId(setup.authenticatorId);
|
|
53
|
+
setStep("scan");
|
|
54
|
+
} catch (error) {
|
|
55
|
+
errorToast({ title: t("common.errors.error"), error });
|
|
56
|
+
} finally {
|
|
57
|
+
setIsLoading(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleVerify = async (code: string) => {
|
|
62
|
+
setIsLoading(true);
|
|
63
|
+
setVerifyError(undefined);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await TwoFactorService.verifyTotpSetup({
|
|
67
|
+
id: v4(),
|
|
68
|
+
authenticatorId,
|
|
69
|
+
code,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Auto-enable 2FA if not already enabled
|
|
73
|
+
const status = await TwoFactorService.getStatus();
|
|
74
|
+
if (!status.isEnabled) {
|
|
75
|
+
await TwoFactorService.enable({ id: v4(), preferredMethod: "totp" });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
showToast(t("common.success"), {
|
|
79
|
+
description: t("auth.two_factor.authenticator_added"),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
setOpen(false);
|
|
83
|
+
resetState();
|
|
84
|
+
onSuccess();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
setVerifyError(t("auth.two_factor.invalid_code"));
|
|
87
|
+
errorToast({ title: t("common.errors.error"), error });
|
|
88
|
+
} finally {
|
|
89
|
+
setIsLoading(false);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const resetState = () => {
|
|
94
|
+
setStep("name");
|
|
95
|
+
setName("");
|
|
96
|
+
setQrCodeUri("");
|
|
97
|
+
setAuthenticatorId("");
|
|
98
|
+
setVerifyError(undefined);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleOpenChange = (newOpen: boolean) => {
|
|
102
|
+
setOpen(newOpen);
|
|
103
|
+
if (!newOpen) resetState();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
108
|
+
{trigger ? (
|
|
109
|
+
<DialogTrigger render={trigger} />
|
|
110
|
+
) : (
|
|
111
|
+
<DialogTrigger render={<Button variant="outline">{t("auth.two_factor.add_authenticator")}</Button>} />
|
|
112
|
+
)}
|
|
113
|
+
<DialogContent className="sm:max-w-md">
|
|
114
|
+
<DialogHeader>
|
|
115
|
+
<DialogTitle>{t("auth.two_factor.setup_authenticator")}</DialogTitle>
|
|
116
|
+
<DialogDescription>
|
|
117
|
+
{step === "name" && t("auth.two_factor.name_your_authenticator")}
|
|
118
|
+
{step === "scan" && t("auth.two_factor.scan_qr_code")}
|
|
119
|
+
{step === "verify" && t("auth.two_factor.enter_verification_code")}
|
|
120
|
+
</DialogDescription>
|
|
121
|
+
</DialogHeader>
|
|
122
|
+
|
|
123
|
+
{step === "name" && (
|
|
124
|
+
<div className="flex flex-col gap-4">
|
|
125
|
+
<div className="flex flex-col gap-2">
|
|
126
|
+
<Label htmlFor="authenticator-name">{t("auth.two_factor.authenticator_name")}</Label>
|
|
127
|
+
<Input
|
|
128
|
+
id="authenticator-name"
|
|
129
|
+
value={name}
|
|
130
|
+
onChange={(e) => setName(e.target.value)}
|
|
131
|
+
placeholder={t("auth.two_factor.authenticator_name_placeholder")}
|
|
132
|
+
data-testid="authenticator-name-input"
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
<Button onClick={handleStartSetup} disabled={!name.trim() || isLoading} data-testid="start-setup-button">
|
|
136
|
+
{t("common.buttons.continue")}
|
|
137
|
+
</Button>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{step === "scan" && (
|
|
142
|
+
<div className="flex flex-col items-center gap-4">
|
|
143
|
+
<div className="p-4 bg-white rounded-lg">
|
|
144
|
+
<QRCodeSVG value={qrCodeUri} size={200} data-testid="totp-qr-code" />
|
|
145
|
+
</div>
|
|
146
|
+
<p className="text-sm text-muted-foreground text-center">{t("auth.two_factor.scan_with_app")}</p>
|
|
147
|
+
<Button onClick={() => setStep("verify")} data-testid="next-to-verify">
|
|
148
|
+
{t("common.buttons.next")}
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{step === "verify" && (
|
|
154
|
+
<div className="flex flex-col items-center gap-4">
|
|
155
|
+
<TotpInput onComplete={handleVerify} disabled={isLoading} error={verifyError} />
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</DialogContent>
|
|
159
|
+
</Dialog>
|
|
160
|
+
);
|
|
161
|
+
}
|