@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.
Files changed (165) hide show
  1. package/README.md +3 -3
  2. package/dist/AuthComponent-BuWc2C4g.d.ts +28 -0
  3. package/dist/AuthComponent-fLVGdvSr.d.mts +28 -0
  4. package/dist/{BlockNoteEditor-EKY4AHVK.mjs → BlockNoteEditor-B3RQ4VQ7.mjs} +5 -5
  5. package/dist/{BlockNoteEditor-4G3L3LSF.js → BlockNoteEditor-VUAWVZF4.js} +15 -15
  6. package/dist/{BlockNoteEditor-4G3L3LSF.js.map → BlockNoteEditor-VUAWVZF4.js.map} +1 -1
  7. package/dist/JsonApiRequest-MUPAO7DI.js +24 -0
  8. package/dist/{JsonApiRequest-GR3L56A5.js.map → JsonApiRequest-MUPAO7DI.js.map} +1 -1
  9. package/dist/{JsonApiRequest-K5BRU7RE.mjs → JsonApiRequest-XCQHVVYD.mjs} +2 -2
  10. package/dist/auth.interface-8XglqHir.d.mts +33 -0
  11. package/dist/auth.interface-BJGKQ0zr.d.ts +33 -0
  12. package/dist/billing/index.js +409 -415
  13. package/dist/billing/index.js.map +1 -1
  14. package/dist/billing/index.mjs +4 -10
  15. package/dist/billing/index.mjs.map +1 -1
  16. package/dist/{chunk-BAOP6PTD.mjs → chunk-BJNQZGMN.mjs} +1618 -666
  17. package/dist/chunk-BJNQZGMN.mjs.map +1 -0
  18. package/dist/{chunk-U4MTVHOC.mjs → chunk-GCQUTWZ2.mjs} +11 -4
  19. package/dist/{chunk-U4MTVHOC.mjs.map → chunk-GCQUTWZ2.mjs.map} +1 -1
  20. package/dist/{chunk-ZNGEVB5M.js → chunk-L5F5ZN5F.js} +960 -140
  21. package/dist/chunk-L5F5ZN5F.js.map +1 -0
  22. package/dist/{chunk-RRIYLEY6.mjs → chunk-LBIC4GJK.mjs} +2 -2
  23. package/dist/{chunk-T5YYOT4Z.js → chunk-OODZEX6P.js} +3 -3
  24. package/dist/{chunk-T5YYOT4Z.js.map → chunk-OODZEX6P.js.map} +1 -1
  25. package/dist/{chunk-GVN7XC3U.mjs → chunk-PHNL4QUF.mjs} +835 -15
  26. package/dist/chunk-PHNL4QUF.mjs.map +1 -0
  27. package/dist/{chunk-GKY5DAIH.js → chunk-QPWHMXE2.js} +1505 -553
  28. package/dist/chunk-QPWHMXE2.js.map +1 -0
  29. package/dist/{chunk-FM6WRAN5.js → chunk-WLS4D6VG.js} +12 -5
  30. package/dist/chunk-WLS4D6VG.js.map +1 -0
  31. package/dist/client/index.d.mts +4 -4
  32. package/dist/client/index.d.ts +4 -4
  33. package/dist/client/index.js +5 -5
  34. package/dist/client/index.mjs +4 -4
  35. package/dist/components/index.d.mts +69 -8
  36. package/dist/components/index.d.ts +69 -8
  37. package/dist/components/index.js +27 -5
  38. package/dist/components/index.js.map +1 -1
  39. package/dist/components/index.mjs +26 -4
  40. package/dist/{config-BxwhHdCD.d.mts → config-BW5u1e9P.d.mts} +1 -1
  41. package/dist/{config-BbaBV_yk.d.ts → config-BozK5PY0.d.ts} +1 -1
  42. package/dist/{content.interface-CgUu4771.d.ts → content.interface-CpCDB1Uk.d.ts} +1 -1
  43. package/dist/{content.interface-CWV0q4lZ.d.mts → content.interface-b-mzkL_q.d.mts} +1 -1
  44. package/dist/contexts/index.d.mts +2 -2
  45. package/dist/contexts/index.d.ts +2 -2
  46. package/dist/contexts/index.js +5 -5
  47. package/dist/contexts/index.mjs +4 -4
  48. package/dist/core/index.d.mts +407 -7
  49. package/dist/core/index.d.ts +407 -7
  50. package/dist/core/index.js +61 -3
  51. package/dist/core/index.js.map +1 -1
  52. package/dist/core/index.mjs +60 -2
  53. package/dist/index.d.mts +8 -6
  54. package/dist/index.d.ts +8 -6
  55. package/dist/index.js +62 -4
  56. package/dist/index.js.map +1 -1
  57. package/dist/index.mjs +61 -3
  58. package/dist/{notification.interface-XARGKJAq.d.ts → notification.interface-CR2PuV6Y.d.ts} +1 -0
  59. package/dist/{notification.interface-DIln2r7X.d.mts → notification.interface-D241WNUx.d.mts} +1 -0
  60. package/dist/{s3.service-BoOF5-ln.d.mts → s3.service-D0rbmLFp.d.mts} +10 -31
  61. package/dist/{s3.service-Mxo-7wQ6.d.ts → s3.service-DOwqcUDT.d.ts} +10 -31
  62. package/dist/scripts/generate-web-module/generator.js +26 -26
  63. package/dist/scripts/generate-web-module/generator.js.map +1 -1
  64. package/dist/scripts/generate-web-module/utils/file-writer.js +9 -9
  65. package/dist/scripts/generate-web-module/utils/file-writer.js.map +1 -1
  66. package/dist/server/index.d.mts +4 -3
  67. package/dist/server/index.d.ts +4 -3
  68. package/dist/server/index.js +12 -12
  69. package/dist/server/index.mjs +2 -2
  70. package/dist/{useSocket-awibcC9B.d.ts → useSocket-CC8SkXdm.d.ts} +1 -1
  71. package/dist/{useSocket-BILAdmZ0.d.mts → useSocket-CttIHn2P.d.mts} +1 -1
  72. package/package.json +4 -1
  73. package/scripts/generate-web-module/generator.ts +26 -26
  74. package/scripts/generate-web-module/utils/file-writer.ts +9 -9
  75. package/src/components/pages/PageContentContainer.tsx +22 -9
  76. package/src/core/abstracts/AbstractService.ts +2 -0
  77. package/src/core/factories/JsonApiDataFactory.ts +2 -1
  78. package/src/core/index.ts +14 -0
  79. package/src/core/registry/DataClassRegistry.ts +7 -1
  80. package/src/core/registry/ModuleRegistry.ts +15 -0
  81. package/src/features/auth/backup-code-verify.module.ts +9 -0
  82. package/src/features/auth/components/containers/SecurityContainer.tsx +11 -0
  83. package/src/features/auth/components/containers/index.ts +1 -0
  84. package/src/features/auth/components/forms/Login.tsx +15 -3
  85. package/src/features/auth/components/forms/Register.tsx +1 -9
  86. package/src/features/auth/components/forms/TwoFactorChallenge.tsx +202 -0
  87. package/src/features/auth/components/forms/index.ts +1 -0
  88. package/src/features/auth/components/index.ts +1 -0
  89. package/src/features/auth/components/two-factor/BackupCodesDialog.tsx +148 -0
  90. package/src/features/auth/components/two-factor/DisableTwoFactorDialog.tsx +74 -0
  91. package/src/features/auth/components/two-factor/PasskeyButton.tsx +59 -0
  92. package/src/features/auth/components/two-factor/PasskeyList.tsx +172 -0
  93. package/src/features/auth/components/two-factor/PasskeySetupDialog.tsx +105 -0
  94. package/src/features/auth/components/two-factor/TotpAuthenticatorList.tsx +104 -0
  95. package/src/features/auth/components/two-factor/TotpInput.tsx +90 -0
  96. package/src/features/auth/components/two-factor/TotpSetupDialog.tsx +161 -0
  97. package/src/features/auth/components/two-factor/TwoFactorSettings.tsx +175 -0
  98. package/src/features/auth/components/two-factor/index.ts +9 -0
  99. package/src/features/auth/contexts/AuthContext.tsx +9 -0
  100. package/src/features/auth/data/auth.service.ts +18 -1
  101. package/src/features/auth/data/backup-code-verify.ts +20 -0
  102. package/src/features/auth/data/index.ts +21 -0
  103. package/src/features/auth/data/passkey-authentication-options.interface.ts +7 -0
  104. package/src/features/auth/data/passkey-authentication-options.ts +37 -0
  105. package/src/features/auth/data/passkey-registration-options.ts +46 -0
  106. package/src/features/auth/data/passkey-registration-verify.ts +62 -0
  107. package/src/features/auth/data/passkey-rename.ts +20 -0
  108. package/src/features/auth/data/passkey-verify-login.ts +23 -0
  109. package/src/features/auth/data/passkey.interface.ts +9 -0
  110. package/src/features/auth/data/passkey.ts +40 -0
  111. package/src/features/auth/data/totp-authenticator.interface.ts +7 -0
  112. package/src/features/auth/data/totp-authenticator.ts +28 -0
  113. package/src/features/auth/data/totp-setup.interface.ts +5 -0
  114. package/src/features/auth/data/totp-setup.ts +48 -0
  115. package/src/features/auth/data/totp-verify-login.ts +20 -0
  116. package/src/features/auth/data/totp-verify.ts +22 -0
  117. package/src/features/auth/data/two-factor-challenge.interface.ts +7 -0
  118. package/src/features/auth/data/two-factor-challenge.ts +45 -0
  119. package/src/features/auth/data/two-factor-enable.ts +20 -0
  120. package/src/features/auth/data/two-factor-status.interface.ts +11 -0
  121. package/src/features/auth/data/two-factor-status.ts +40 -0
  122. package/src/features/auth/data/two-factor.service.ts +331 -0
  123. package/src/features/auth/enums/AuthComponent.ts +1 -0
  124. package/src/features/auth/index.ts +13 -0
  125. package/src/features/auth/passkey-authentication-options.module.ts +9 -0
  126. package/src/features/auth/passkey-registration-options.module.ts +9 -0
  127. package/src/features/auth/passkey-registration-verify.module.ts +9 -0
  128. package/src/features/auth/passkey-rename.module.ts +9 -0
  129. package/src/features/auth/passkey-verify-login.module.ts +9 -0
  130. package/src/features/auth/passkey.module.ts +9 -0
  131. package/src/features/auth/totp-authenticator.module.ts +9 -0
  132. package/src/features/auth/totp-setup.module.ts +9 -0
  133. package/src/features/auth/totp-verify-login.module.ts +9 -0
  134. package/src/features/auth/totp-verify.module.ts +9 -0
  135. package/src/features/auth/two-factor-challenge.module.ts +9 -0
  136. package/src/features/auth/two-factor-enable.module.ts +9 -0
  137. package/src/features/auth/two-factor-status.module.ts +9 -0
  138. package/src/features/billing/modules/billing.module.ts +1 -0
  139. package/src/features/billing/stripe-customer/stripe-customer.module.ts +1 -0
  140. package/src/features/billing/stripe-customer/stripe-payment-method.module.ts +1 -0
  141. package/src/features/billing/stripe-invoice/stripe-invoice.module.ts +1 -0
  142. package/src/features/billing/stripe-price/stripe-price.module.ts +1 -0
  143. package/src/features/billing/stripe-product/stripe-product.module.ts +1 -0
  144. package/src/features/billing/stripe-promotion-code/stripe-promotion-code.module.ts +1 -0
  145. package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +0 -5
  146. package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +0 -8
  147. package/src/features/billing/stripe-subscription/stripe-subscription.module.ts +1 -0
  148. package/src/features/billing/stripe-usage/stripe-usage.module.ts +1 -0
  149. package/src/features/user/data/user.interface.ts +1 -0
  150. package/src/features/user/data/user.ts +6 -0
  151. package/src/features/waitlist/data/WaitlistService.ts +1 -8
  152. package/src/features/waitlist/waitlist-stats.module.ts +1 -0
  153. package/src/shadcnui/ui/resizable.tsx +33 -11
  154. package/src/unified/JsonApiRequest.ts +2 -1
  155. package/dist/AuthComponent-hxOPs9o8.d.mts +0 -11
  156. package/dist/AuthComponent-hxOPs9o8.d.ts +0 -11
  157. package/dist/JsonApiRequest-GR3L56A5.js +0 -24
  158. package/dist/chunk-BAOP6PTD.mjs.map +0 -1
  159. package/dist/chunk-FM6WRAN5.js.map +0 -1
  160. package/dist/chunk-GKY5DAIH.js.map +0 -1
  161. package/dist/chunk-GVN7XC3U.mjs.map +0 -1
  162. package/dist/chunk-ZNGEVB5M.js.map +0 -1
  163. /package/dist/{BlockNoteEditor-EKY4AHVK.mjs.map → BlockNoteEditor-B3RQ4VQ7.mjs.map} +0 -0
  164. /package/dist/{JsonApiRequest-K5BRU7RE.mjs.map → JsonApiRequest-XCQHVVYD.mjs.map} +0 -0
  165. /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
+ }