@draftlab/auth 0.2.3 → 0.2.5
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/provider/totp.d.ts +120 -0
- package/dist/provider/totp.js +191 -0
- package/dist/ui/base.js +0 -1
- package/dist/ui/password.js +42 -97
- package/dist/ui/totp.d.ts +40 -0
- package/dist/ui/totp.js +323 -0
- package/package.json +4 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Provider } from "./provider.js";
|
|
2
|
+
|
|
3
|
+
//#region src/provider/totp.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TOTP data model stored in the database.
|
|
7
|
+
* Contains the user's TOTP configuration and backup codes.
|
|
8
|
+
*/
|
|
9
|
+
interface TOTPModel {
|
|
10
|
+
/** Base32-encoded secret key */
|
|
11
|
+
secret: string;
|
|
12
|
+
/** Whether TOTP is enabled for this user */
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
/** Array of one-time backup/recovery codes */
|
|
15
|
+
backupCodes: string[];
|
|
16
|
+
/** Timestamp when TOTP was first set up */
|
|
17
|
+
createdAt: string;
|
|
18
|
+
/** Optional user label for the TOTP */
|
|
19
|
+
label?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Configuration for the TOTPProvider.
|
|
23
|
+
* Defines how the TOTP authentication flow should behave.
|
|
24
|
+
*/
|
|
25
|
+
interface TOTPProviderConfig {
|
|
26
|
+
/**
|
|
27
|
+
* The human-readable name of the issuer (your application).
|
|
28
|
+
* This appears in authenticator apps next to the TOTP entry.
|
|
29
|
+
*/
|
|
30
|
+
issuer: string;
|
|
31
|
+
/**
|
|
32
|
+
* Custom authorize handler that generates the UI for TOTP login.
|
|
33
|
+
* Called when user wants to login with TOTP (main page).
|
|
34
|
+
*
|
|
35
|
+
* @param req - The HTTP request object
|
|
36
|
+
* @param error - Optional error message to display
|
|
37
|
+
*/
|
|
38
|
+
authorize: (req: Request, error?: string) => Promise<Response>;
|
|
39
|
+
/**
|
|
40
|
+
* Custom register handler that generates the UI for TOTP setup.
|
|
41
|
+
* Called when user is setting up TOTP for the first time.
|
|
42
|
+
*
|
|
43
|
+
* @param req - The HTTP request object
|
|
44
|
+
* @param qrCodeUrl - The otpauth:// URL for QR code generation
|
|
45
|
+
* @param secret - The raw secret (for manual entry)
|
|
46
|
+
* @param backupCodes - Array of backup/recovery codes
|
|
47
|
+
* @param error - Optional error message to display
|
|
48
|
+
*/
|
|
49
|
+
register: (req: Request, qrCodeUrl: string, secret: string, backupCodes: string[], error?: string, email?: string) => Promise<Response>;
|
|
50
|
+
/**
|
|
51
|
+
* Custom verification handler that generates the UI for TOTP verification.
|
|
52
|
+
* Called when user needs to enter their TOTP code.
|
|
53
|
+
*
|
|
54
|
+
* @param req - The HTTP request object
|
|
55
|
+
* @param error - Optional error message to display
|
|
56
|
+
*/
|
|
57
|
+
verify: (req: Request, error?: string) => Promise<Response>;
|
|
58
|
+
/**
|
|
59
|
+
* Custom recovery handler that generates the UI for backup code entry.
|
|
60
|
+
* Called when user wants to use a recovery code instead of TOTP.
|
|
61
|
+
*
|
|
62
|
+
* @param req - The HTTP request object
|
|
63
|
+
* @param error - Optional error message to display
|
|
64
|
+
*/
|
|
65
|
+
recovery: (req: Request, error?: string) => Promise<Response>;
|
|
66
|
+
/**
|
|
67
|
+
* Optional TOTP algorithm. Defaults to SHA1 for maximum compatibility.
|
|
68
|
+
* Most authenticator apps support SHA1, fewer support SHA256/SHA512.
|
|
69
|
+
*/
|
|
70
|
+
algorithm?: "SHA1" | "SHA256" | "SHA512";
|
|
71
|
+
/**
|
|
72
|
+
* Optional number of digits in TOTP codes. Defaults to 6.
|
|
73
|
+
* Some apps support 8 digits for increased security.
|
|
74
|
+
*/
|
|
75
|
+
digits?: 6 | 8;
|
|
76
|
+
/**
|
|
77
|
+
* Optional validity period for TOTP codes in seconds. Defaults to 30.
|
|
78
|
+
* Standard is 30 seconds, some high-security apps use 60.
|
|
79
|
+
*/
|
|
80
|
+
period?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Optional time window tolerance for clock drift. Defaults to 1.
|
|
83
|
+
* Allows tokens from previous/next time window to be valid.
|
|
84
|
+
*/
|
|
85
|
+
window?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Optional number of backup codes to generate. Defaults to 10.
|
|
88
|
+
*/
|
|
89
|
+
backupCodesCount?: number;
|
|
90
|
+
/**
|
|
91
|
+
* Optional function to check if a user is allowed to set up TOTP.
|
|
92
|
+
*/
|
|
93
|
+
userCanSetupTOTP?: (userId: string, req: Request) => Promise<boolean>;
|
|
94
|
+
/**
|
|
95
|
+
* Optional custom label generator for TOTP entries.
|
|
96
|
+
* Defaults to using the userId as the label.
|
|
97
|
+
*/
|
|
98
|
+
generateLabel?: (userId: string) => Promise<string>;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Creates a TOTP (Time-based One-Time Password) authentication provider.
|
|
102
|
+
*
|
|
103
|
+
* TOTP tokens. Users can set up TOTP using any compatible authenticator app
|
|
104
|
+
* and use backup codes when their primary device is unavailable.
|
|
105
|
+
*
|
|
106
|
+
* It handles:
|
|
107
|
+
* - TOTP secret generation and QR code creation
|
|
108
|
+
* - Token verification with timing attack protection
|
|
109
|
+
* - Backup code generation and one-time usage validation
|
|
110
|
+
* - Complete setup, verification, and recovery flows
|
|
111
|
+
*
|
|
112
|
+
* @param config Configuration options for the TOTP provider
|
|
113
|
+
* @returns A Provider instance configured for TOTP authentication
|
|
114
|
+
*/
|
|
115
|
+
declare const TOTPProvider: (config: TOTPProviderConfig) => Provider<{
|
|
116
|
+
email: string;
|
|
117
|
+
method: "totp" | "recovery";
|
|
118
|
+
}>;
|
|
119
|
+
//#endregion
|
|
120
|
+
export { TOTPModel, TOTPProvider, TOTPProviderConfig };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { generateSecureToken } from "../random.js";
|
|
2
|
+
import { Storage } from "../storage/storage.js";
|
|
3
|
+
import { Secret, TOTP } from "otpauth";
|
|
4
|
+
|
|
5
|
+
//#region src/provider/totp.ts
|
|
6
|
+
const totpKey = (userId) => [
|
|
7
|
+
"totp",
|
|
8
|
+
"user",
|
|
9
|
+
userId
|
|
10
|
+
];
|
|
11
|
+
const DEFAULT_CONFIG = {
|
|
12
|
+
algorithm: "SHA1",
|
|
13
|
+
digits: 6,
|
|
14
|
+
period: 30,
|
|
15
|
+
window: 1,
|
|
16
|
+
backupCodesCount: 4,
|
|
17
|
+
qrSize: 200
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Creates a TOTP (Time-based One-Time Password) authentication provider.
|
|
21
|
+
*
|
|
22
|
+
* TOTP tokens. Users can set up TOTP using any compatible authenticator app
|
|
23
|
+
* and use backup codes when their primary device is unavailable.
|
|
24
|
+
*
|
|
25
|
+
* It handles:
|
|
26
|
+
* - TOTP secret generation and QR code creation
|
|
27
|
+
* - Token verification with timing attack protection
|
|
28
|
+
* - Backup code generation and one-time usage validation
|
|
29
|
+
* - Complete setup, verification, and recovery flows
|
|
30
|
+
*
|
|
31
|
+
* @param config Configuration options for the TOTP provider
|
|
32
|
+
* @returns A Provider instance configured for TOTP authentication
|
|
33
|
+
*/
|
|
34
|
+
const TOTPProvider = (config) => {
|
|
35
|
+
const { issuer, algorithm = DEFAULT_CONFIG.algorithm, digits = DEFAULT_CONFIG.digits, period = DEFAULT_CONFIG.period, window = DEFAULT_CONFIG.window, backupCodesCount = DEFAULT_CONFIG.backupCodesCount } = config;
|
|
36
|
+
return {
|
|
37
|
+
type: "totp",
|
|
38
|
+
init(routes, ctx) {
|
|
39
|
+
const getTOTPData = async (userId) => {
|
|
40
|
+
return await Storage.get(ctx.storage, totpKey(userId));
|
|
41
|
+
};
|
|
42
|
+
const saveTOTPData = async (userId, data) => {
|
|
43
|
+
await Storage.set(ctx.storage, totpKey(userId), data);
|
|
44
|
+
};
|
|
45
|
+
const deleteTOTPData = async (userId) => {
|
|
46
|
+
await Storage.remove(ctx.storage, totpKey(userId));
|
|
47
|
+
};
|
|
48
|
+
const generateBackupCodes = (count) => {
|
|
49
|
+
const codes = [];
|
|
50
|
+
for (let i = 0; i < count; i++) {
|
|
51
|
+
const code = generateSecureToken().slice(0, 8).toUpperCase();
|
|
52
|
+
codes.push(`${code.slice(0, 4)}-${code.slice(4)}`);
|
|
53
|
+
}
|
|
54
|
+
return codes;
|
|
55
|
+
};
|
|
56
|
+
const createTOTPInstance = (secret, label) => {
|
|
57
|
+
return new TOTP({
|
|
58
|
+
issuer,
|
|
59
|
+
label,
|
|
60
|
+
algorithm,
|
|
61
|
+
digits,
|
|
62
|
+
period,
|
|
63
|
+
secret
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
routes.get("/register", async (c) => {
|
|
67
|
+
return ctx.forward(c, await config.register(c.request, "", "", []));
|
|
68
|
+
});
|
|
69
|
+
routes.post("/register-verify", async (c) => {
|
|
70
|
+
const formData = await c.formData();
|
|
71
|
+
const email = formData.get("email")?.toString();
|
|
72
|
+
const action = formData.get("action")?.toString();
|
|
73
|
+
if (!email) return ctx.forward(c, await config.register(c.request, "", "", [], "Email is required"));
|
|
74
|
+
if (action === "generate") {
|
|
75
|
+
const secret = new Secret({ size: 20 });
|
|
76
|
+
const label = config.generateLabel ? await config.generateLabel(email) : email;
|
|
77
|
+
const backupCodes = generateBackupCodes(backupCodesCount);
|
|
78
|
+
const totp$1 = createTOTPInstance(secret.base32, label);
|
|
79
|
+
const qrCodeUrl$1 = totp$1.toString();
|
|
80
|
+
const totpData$1 = {
|
|
81
|
+
secret: secret.base32,
|
|
82
|
+
enabled: false,
|
|
83
|
+
backupCodes,
|
|
84
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
85
|
+
label
|
|
86
|
+
};
|
|
87
|
+
await saveTOTPData(email, totpData$1);
|
|
88
|
+
return ctx.forward(c, await config.register(c.request, qrCodeUrl$1, secret.base32, backupCodes, void 0, email));
|
|
89
|
+
}
|
|
90
|
+
const token = formData.get("token")?.toString();
|
|
91
|
+
if (!token) return ctx.forward(c, await config.register(c.request, "", "", [], "Verification code is required"));
|
|
92
|
+
const totpData = await getTOTPData(email);
|
|
93
|
+
if (!totpData) return ctx.forward(c, await config.register(c.request, "", "", [], "TOTP setup session not found"));
|
|
94
|
+
const totp = createTOTPInstance(totpData.secret, totpData.label || email);
|
|
95
|
+
const delta = totp.validate({
|
|
96
|
+
token,
|
|
97
|
+
window
|
|
98
|
+
});
|
|
99
|
+
if (delta !== null) {
|
|
100
|
+
totpData.enabled = true;
|
|
101
|
+
await saveTOTPData(email, totpData);
|
|
102
|
+
return ctx.success(c, {
|
|
103
|
+
email,
|
|
104
|
+
method: "totp"
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const qrCodeUrl = totp.toString();
|
|
108
|
+
return ctx.forward(c, await config.register(c.request, qrCodeUrl, totpData.secret, totpData.backupCodes, "Invalid verification code. Please try again."));
|
|
109
|
+
});
|
|
110
|
+
routes.get("/authorize", async (c) => {
|
|
111
|
+
return ctx.forward(c, await config.authorize(c.request));
|
|
112
|
+
});
|
|
113
|
+
routes.post("/verify", async (c) => {
|
|
114
|
+
const formData = await c.formData();
|
|
115
|
+
const email = formData.get("email")?.toString();
|
|
116
|
+
const token = formData.get("token")?.toString();
|
|
117
|
+
if (!email || !token) return ctx.forward(c, await config.verify(c.request, "Email and verification code are required"));
|
|
118
|
+
const totpData = await getTOTPData(email);
|
|
119
|
+
if (!totpData || !totpData.enabled) return ctx.forward(c, await config.verify(c.request, "TOTP is not set up for this email"));
|
|
120
|
+
const totp = createTOTPInstance(totpData.secret, totpData.label || email);
|
|
121
|
+
const delta = totp.validate({
|
|
122
|
+
token,
|
|
123
|
+
window
|
|
124
|
+
});
|
|
125
|
+
if (delta !== null) return ctx.success(c, {
|
|
126
|
+
email,
|
|
127
|
+
method: "totp"
|
|
128
|
+
});
|
|
129
|
+
return ctx.forward(c, await config.verify(c.request, "Invalid verification code"));
|
|
130
|
+
});
|
|
131
|
+
routes.get("/recovery", async (c) => {
|
|
132
|
+
return ctx.forward(c, await config.recovery(c.request));
|
|
133
|
+
});
|
|
134
|
+
routes.post("/recovery-verify", async (c) => {
|
|
135
|
+
const formData = await c.formData();
|
|
136
|
+
const email = formData.get("email")?.toString();
|
|
137
|
+
const code = formData.get("code")?.toString()?.toUpperCase();
|
|
138
|
+
if (!email || !code) return ctx.forward(c, await config.recovery(c.request, "Email and recovery code are required"));
|
|
139
|
+
const totpData = await getTOTPData(email);
|
|
140
|
+
if (!totpData || !totpData.enabled) return ctx.forward(c, await config.recovery(c.request, "TOTP is not set up for this email"));
|
|
141
|
+
const codeIndex = totpData.backupCodes.indexOf(code);
|
|
142
|
+
if (codeIndex !== -1) {
|
|
143
|
+
totpData.backupCodes.splice(codeIndex, 1);
|
|
144
|
+
await saveTOTPData(email, totpData);
|
|
145
|
+
return ctx.success(c, {
|
|
146
|
+
email,
|
|
147
|
+
method: "recovery"
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return ctx.forward(c, await config.recovery(c.request, "Invalid or already used recovery code"));
|
|
151
|
+
});
|
|
152
|
+
routes.post("/disable", async (c) => {
|
|
153
|
+
const userId = c.query("userId");
|
|
154
|
+
if (!userId) return c.json({ error: "User ID is required" }, { status: 400 });
|
|
155
|
+
await deleteTOTPData(userId);
|
|
156
|
+
return c.json({
|
|
157
|
+
success: true,
|
|
158
|
+
message: "TOTP has been disabled"
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
routes.get("/status", async (c) => {
|
|
162
|
+
const userId = c.query("userId");
|
|
163
|
+
if (!userId) return c.json({ error: "User ID is required" }, { status: 400 });
|
|
164
|
+
const totpData = await getTOTPData(userId);
|
|
165
|
+
return c.json({
|
|
166
|
+
enabled: totpData?.enabled || false,
|
|
167
|
+
hasBackupCodes: (totpData?.backupCodes?.length || 0) > 0,
|
|
168
|
+
backupCodesCount: totpData?.backupCodes?.length || 0,
|
|
169
|
+
setupDate: totpData?.createdAt
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
routes.post("/regenerate-backup-codes", async (c) => {
|
|
173
|
+
const userId = c.query("userId");
|
|
174
|
+
if (!userId) return c.json({ error: "User ID is required" }, { status: 400 });
|
|
175
|
+
const totpData = await getTOTPData(userId);
|
|
176
|
+
if (!totpData || !totpData.enabled) return c.json({ error: "TOTP is not enabled for this user" }, { status: 400 });
|
|
177
|
+
const newBackupCodes = generateBackupCodes(backupCodesCount);
|
|
178
|
+
totpData.backupCodes = newBackupCodes;
|
|
179
|
+
await saveTOTPData(userId, totpData);
|
|
180
|
+
return c.json({
|
|
181
|
+
success: true,
|
|
182
|
+
backupCodes: newBackupCodes,
|
|
183
|
+
message: "New backup codes generated"
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
//#endregion
|
|
191
|
+
export { TOTPProvider };
|
package/dist/ui/base.js
CHANGED
package/dist/ui/password.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Layout, renderToHTML } from "./base.js";
|
|
2
|
+
import { FormAlert } from "./form.js";
|
|
2
3
|
import { Fragment, jsx, jsxs } from "preact/jsx-runtime";
|
|
3
4
|
|
|
4
5
|
//#region src/ui/password.tsx
|
|
@@ -31,80 +32,6 @@ const DEFAULT_COPY = {
|
|
|
31
32
|
logo: "A"
|
|
32
33
|
};
|
|
33
34
|
/**
|
|
34
|
-
* FormAlert component for displaying error messages
|
|
35
|
-
*/
|
|
36
|
-
const FormAlert = ({ message, color = "danger" }) => {
|
|
37
|
-
if (!message) return null;
|
|
38
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
39
|
-
"data-component": "form-alert",
|
|
40
|
-
"data-color": color,
|
|
41
|
-
children: [/* @__PURE__ */ jsx("i", {
|
|
42
|
-
"data-slot": color === "success" ? "icon-success" : "icon-danger",
|
|
43
|
-
children: color === "success" ? /* @__PURE__ */ jsx("svg", {
|
|
44
|
-
fill: "none",
|
|
45
|
-
stroke: "currentColor",
|
|
46
|
-
viewBox: "0 0 24 24",
|
|
47
|
-
xmlns: "http://www.w3.org/2000/svg",
|
|
48
|
-
"aria-label": "Success",
|
|
49
|
-
role: "img",
|
|
50
|
-
children: /* @__PURE__ */ jsx("path", {
|
|
51
|
-
strokeLinecap: "round",
|
|
52
|
-
strokeLinejoin: "round",
|
|
53
|
-
strokeWidth: 2,
|
|
54
|
-
d: "M5 13l4 4L19 7"
|
|
55
|
-
})
|
|
56
|
-
}) : /* @__PURE__ */ jsx("svg", {
|
|
57
|
-
fill: "none",
|
|
58
|
-
stroke: "currentColor",
|
|
59
|
-
viewBox: "0 0 24 24",
|
|
60
|
-
xmlns: "http://www.w3.org/2000/svg",
|
|
61
|
-
"aria-label": "Error",
|
|
62
|
-
role: "img",
|
|
63
|
-
children: /* @__PURE__ */ jsx("path", {
|
|
64
|
-
strokeLinecap: "round",
|
|
65
|
-
strokeLinejoin: "round",
|
|
66
|
-
strokeWidth: 2,
|
|
67
|
-
d: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.232 16.5c-.77.833.192 2.5 1.732 2.5z"
|
|
68
|
-
})
|
|
69
|
-
})
|
|
70
|
-
}), /* @__PURE__ */ jsx("span", {
|
|
71
|
-
"data-slot": "message",
|
|
72
|
-
children: message
|
|
73
|
-
})]
|
|
74
|
-
});
|
|
75
|
-
};
|
|
76
|
-
/**
|
|
77
|
-
* Input component with consistent styling
|
|
78
|
-
*/
|
|
79
|
-
const Input = ({ type, name, placeholder, value, required, autoComplete,...props }) => /* @__PURE__ */ jsx("input", {
|
|
80
|
-
type,
|
|
81
|
-
name,
|
|
82
|
-
placeholder,
|
|
83
|
-
value,
|
|
84
|
-
required,
|
|
85
|
-
autoComplete,
|
|
86
|
-
"data-component": "input",
|
|
87
|
-
...props
|
|
88
|
-
});
|
|
89
|
-
/**
|
|
90
|
-
* Button component with consistent styling
|
|
91
|
-
*/
|
|
92
|
-
const Button = ({ type = "submit", children,...props }) => /* @__PURE__ */ jsx("button", {
|
|
93
|
-
type,
|
|
94
|
-
"data-component": "button",
|
|
95
|
-
...props,
|
|
96
|
-
children
|
|
97
|
-
});
|
|
98
|
-
/**
|
|
99
|
-
* Link component with consistent styling
|
|
100
|
-
*/
|
|
101
|
-
const Link = ({ href, children,...props }) => /* @__PURE__ */ jsx("a", {
|
|
102
|
-
href,
|
|
103
|
-
"data-component": "link",
|
|
104
|
-
...props,
|
|
105
|
-
children
|
|
106
|
-
});
|
|
107
|
-
/**
|
|
108
35
|
* Creates a complete UI configuration for password-based authentication
|
|
109
36
|
*/
|
|
110
37
|
const PasswordUI = (options) => {
|
|
@@ -129,22 +56,25 @@ const PasswordUI = (options) => {
|
|
|
129
56
|
method: "post",
|
|
130
57
|
children: [
|
|
131
58
|
/* @__PURE__ */ jsx(FormAlert, { message: getErrorMessage(error) }),
|
|
132
|
-
/* @__PURE__ */ jsx(
|
|
59
|
+
/* @__PURE__ */ jsx("input", {
|
|
133
60
|
type: "email",
|
|
134
61
|
name: "email",
|
|
135
62
|
placeholder: copy.input_email,
|
|
136
63
|
value: form?.get("email")?.toString() || "",
|
|
137
64
|
autoComplete: "email",
|
|
65
|
+
"data-component": "input",
|
|
138
66
|
required: true
|
|
139
67
|
}),
|
|
140
|
-
/* @__PURE__ */ jsx(
|
|
68
|
+
/* @__PURE__ */ jsx("input", {
|
|
141
69
|
type: "password",
|
|
142
70
|
name: "password",
|
|
143
71
|
placeholder: copy.input_password,
|
|
144
72
|
autoComplete: "current-password",
|
|
73
|
+
"data-component": "input",
|
|
145
74
|
required: true
|
|
146
75
|
}),
|
|
147
|
-
/* @__PURE__ */ jsx(
|
|
76
|
+
/* @__PURE__ */ jsx("button", {
|
|
77
|
+
"data-component": "button",
|
|
148
78
|
type: "submit",
|
|
149
79
|
children: copy.button_continue
|
|
150
80
|
}),
|
|
@@ -153,11 +83,13 @@ const PasswordUI = (options) => {
|
|
|
153
83
|
children: [/* @__PURE__ */ jsxs("span", { children: [
|
|
154
84
|
copy.register_prompt,
|
|
155
85
|
" ",
|
|
156
|
-
/* @__PURE__ */ jsx(
|
|
86
|
+
/* @__PURE__ */ jsx("a", {
|
|
87
|
+
"data-component": "link",
|
|
157
88
|
href: "./register",
|
|
158
89
|
children: copy.register
|
|
159
90
|
})
|
|
160
|
-
] }), /* @__PURE__ */ jsx(
|
|
91
|
+
] }), /* @__PURE__ */ jsx("a", {
|
|
92
|
+
"data-component": "link",
|
|
161
93
|
href: "./change",
|
|
162
94
|
children: copy.change_prompt
|
|
163
95
|
})]
|
|
@@ -184,30 +116,34 @@ const PasswordUI = (options) => {
|
|
|
184
116
|
type: "hidden",
|
|
185
117
|
value: "register"
|
|
186
118
|
}),
|
|
187
|
-
/* @__PURE__ */ jsx(
|
|
119
|
+
/* @__PURE__ */ jsx("input", {
|
|
188
120
|
type: "email",
|
|
189
121
|
name: "email",
|
|
190
122
|
placeholder: copy.input_email,
|
|
191
123
|
value: emailError ? "" : form?.get("email")?.toString() || "",
|
|
192
124
|
autoComplete: "email",
|
|
125
|
+
"data-component": "input",
|
|
193
126
|
required: true
|
|
194
127
|
}),
|
|
195
|
-
/* @__PURE__ */ jsx(
|
|
128
|
+
/* @__PURE__ */ jsx("input", {
|
|
196
129
|
type: "password",
|
|
197
130
|
name: "password",
|
|
198
131
|
placeholder: copy.input_password,
|
|
199
132
|
value: passwordError ? "" : form?.get("password")?.toString() || "",
|
|
200
133
|
autoComplete: "new-password",
|
|
134
|
+
"data-component": "input",
|
|
201
135
|
required: true
|
|
202
136
|
}),
|
|
203
|
-
/* @__PURE__ */ jsx(
|
|
137
|
+
/* @__PURE__ */ jsx("input", {
|
|
204
138
|
type: "password",
|
|
205
139
|
name: "repeat",
|
|
206
140
|
placeholder: copy.input_repeat,
|
|
207
141
|
autoComplete: "new-password",
|
|
142
|
+
"data-component": "input",
|
|
208
143
|
required: true
|
|
209
144
|
}),
|
|
210
|
-
/* @__PURE__ */ jsx(
|
|
145
|
+
/* @__PURE__ */ jsx("button", {
|
|
146
|
+
"data-component": "button",
|
|
211
147
|
type: "submit",
|
|
212
148
|
children: copy.button_continue
|
|
213
149
|
}),
|
|
@@ -216,7 +152,8 @@ const PasswordUI = (options) => {
|
|
|
216
152
|
children: /* @__PURE__ */ jsxs("span", { children: [
|
|
217
153
|
copy.login_prompt,
|
|
218
154
|
" ",
|
|
219
|
-
/* @__PURE__ */ jsx(
|
|
155
|
+
/* @__PURE__ */ jsx("a", {
|
|
156
|
+
"data-component": "link",
|
|
220
157
|
href: "./authorize",
|
|
221
158
|
children: copy.login
|
|
222
159
|
})
|
|
@@ -233,20 +170,21 @@ const PasswordUI = (options) => {
|
|
|
233
170
|
type: "hidden",
|
|
234
171
|
value: "verify"
|
|
235
172
|
}),
|
|
236
|
-
/* @__PURE__ */ jsx(
|
|
173
|
+
/* @__PURE__ */ jsx("input", {
|
|
237
174
|
type: "text",
|
|
238
175
|
name: "code",
|
|
239
176
|
placeholder: copy.input_code,
|
|
240
177
|
"aria-label": "6-digit verification code",
|
|
241
178
|
autoComplete: "one-time-code",
|
|
179
|
+
"data-component": "input",
|
|
242
180
|
inputMode: "numeric",
|
|
243
181
|
maxLength: 6,
|
|
244
182
|
minLength: 6,
|
|
245
183
|
pattern: "[0-9]{6}",
|
|
246
|
-
autoFocus: true,
|
|
247
184
|
required: true
|
|
248
185
|
}),
|
|
249
|
-
/* @__PURE__ */ jsx(
|
|
186
|
+
/* @__PURE__ */ jsx("button", {
|
|
187
|
+
"data-component": "button",
|
|
250
188
|
type: "submit",
|
|
251
189
|
children: copy.button_continue
|
|
252
190
|
})
|
|
@@ -272,15 +210,17 @@ const PasswordUI = (options) => {
|
|
|
272
210
|
type: "hidden",
|
|
273
211
|
value: "code"
|
|
274
212
|
}),
|
|
275
|
-
/* @__PURE__ */ jsx(
|
|
213
|
+
/* @__PURE__ */ jsx("input", {
|
|
276
214
|
type: "email",
|
|
277
215
|
name: "email",
|
|
278
216
|
placeholder: copy.input_email,
|
|
279
217
|
value: form?.get("email")?.toString() || "",
|
|
280
218
|
autoComplete: "email",
|
|
219
|
+
"data-component": "input",
|
|
281
220
|
required: true
|
|
282
221
|
}),
|
|
283
|
-
/* @__PURE__ */ jsx(
|
|
222
|
+
/* @__PURE__ */ jsx("button", {
|
|
223
|
+
"data-component": "button",
|
|
284
224
|
type: "submit",
|
|
285
225
|
children: copy.button_continue
|
|
286
226
|
})
|
|
@@ -295,7 +235,7 @@ const PasswordUI = (options) => {
|
|
|
295
235
|
type: "hidden",
|
|
296
236
|
value: "verify"
|
|
297
237
|
}),
|
|
298
|
-
/* @__PURE__ */ jsx(
|
|
238
|
+
/* @__PURE__ */ jsx("input", {
|
|
299
239
|
type: "text",
|
|
300
240
|
name: "code",
|
|
301
241
|
placeholder: copy.input_code,
|
|
@@ -304,11 +244,12 @@ const PasswordUI = (options) => {
|
|
|
304
244
|
inputMode: "numeric",
|
|
305
245
|
maxLength: 6,
|
|
306
246
|
minLength: 6,
|
|
247
|
+
"data-component": "input",
|
|
307
248
|
pattern: "[0-9]{6}",
|
|
308
|
-
autoFocus: true,
|
|
309
249
|
required: true
|
|
310
250
|
}),
|
|
311
|
-
/* @__PURE__ */ jsx(
|
|
251
|
+
/* @__PURE__ */ jsx("button", {
|
|
252
|
+
"data-component": "button",
|
|
312
253
|
type: "submit",
|
|
313
254
|
children: copy.button_continue
|
|
314
255
|
})
|
|
@@ -331,11 +272,12 @@ const PasswordUI = (options) => {
|
|
|
331
272
|
children: [/* @__PURE__ */ jsxs("span", { children: [
|
|
332
273
|
copy.code_return,
|
|
333
274
|
" ",
|
|
334
|
-
/* @__PURE__ */ jsx(
|
|
275
|
+
/* @__PURE__ */ jsx("a", {
|
|
276
|
+
"data-component": "link",
|
|
335
277
|
href: "./authorize",
|
|
336
278
|
children: copy.login
|
|
337
279
|
})
|
|
338
|
-
] }), /* @__PURE__ */ jsx(
|
|
280
|
+
] }), /* @__PURE__ */ jsx("button", {
|
|
339
281
|
type: "submit",
|
|
340
282
|
"data-component": "link",
|
|
341
283
|
children: copy.code_resend
|
|
@@ -352,23 +294,26 @@ const PasswordUI = (options) => {
|
|
|
352
294
|
type: "hidden",
|
|
353
295
|
value: "update"
|
|
354
296
|
}),
|
|
355
|
-
/* @__PURE__ */ jsx(
|
|
297
|
+
/* @__PURE__ */ jsx("input", {
|
|
356
298
|
type: "password",
|
|
357
299
|
name: "password",
|
|
358
300
|
placeholder: copy.input_password,
|
|
359
301
|
value: passwordError ? "" : form?.get("password")?.toString() || "",
|
|
360
302
|
autoComplete: "new-password",
|
|
303
|
+
"data-component": "input",
|
|
361
304
|
required: true
|
|
362
305
|
}),
|
|
363
|
-
/* @__PURE__ */ jsx(
|
|
306
|
+
/* @__PURE__ */ jsx("input", {
|
|
364
307
|
type: "password",
|
|
365
308
|
name: "repeat",
|
|
366
309
|
placeholder: copy.input_repeat,
|
|
367
310
|
value: passwordError ? "" : form?.get("repeat")?.toString() || "",
|
|
368
311
|
autoComplete: "new-password",
|
|
312
|
+
"data-component": "input",
|
|
369
313
|
required: true
|
|
370
314
|
}),
|
|
371
|
-
/* @__PURE__ */ jsx(
|
|
315
|
+
/* @__PURE__ */ jsx("button", {
|
|
316
|
+
"data-component": "button",
|
|
372
317
|
type: "submit",
|
|
373
318
|
children: copy.button_continue
|
|
374
319
|
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { TOTPProviderConfig } from "../provider/totp.js";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/totp.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Strongly typed copy text configuration for TOTP UI
|
|
7
|
+
*/
|
|
8
|
+
interface TOTPUICopy {
|
|
9
|
+
readonly setup_title: string;
|
|
10
|
+
readonly setup_description: string;
|
|
11
|
+
readonly verify_title: string;
|
|
12
|
+
readonly verify_description: string;
|
|
13
|
+
readonly recovery_title: string;
|
|
14
|
+
readonly recovery_description: string;
|
|
15
|
+
readonly setup_manual_entry: string;
|
|
16
|
+
readonly setup_backup_codes_title: string;
|
|
17
|
+
readonly setup_backup_codes_description: string;
|
|
18
|
+
readonly button_continue: string;
|
|
19
|
+
readonly link_use_recovery: string;
|
|
20
|
+
readonly link_back_to_totp: string;
|
|
21
|
+
readonly input_token: string;
|
|
22
|
+
readonly input_recovery_code: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Configuration options for TOTP UI
|
|
26
|
+
*/
|
|
27
|
+
interface TOTPUIOptions {
|
|
28
|
+
/** Custom copy text overrides */
|
|
29
|
+
readonly copy?: Partial<TOTPUICopy>;
|
|
30
|
+
/** QR code image size in pixels */
|
|
31
|
+
readonly qrSize?: number;
|
|
32
|
+
/** Whether to show manual secret entry option */
|
|
33
|
+
readonly showManualEntry?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Creates a complete UI configuration for TOTP authentication
|
|
37
|
+
*/
|
|
38
|
+
declare const TOTPUI: (options?: TOTPUIOptions) => Omit<TOTPProviderConfig, "issuer">;
|
|
39
|
+
//#endregion
|
|
40
|
+
export { TOTPUI };
|
package/dist/ui/totp.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { Layout, renderToHTML } from "./base.js";
|
|
2
|
+
import { FormAlert } from "./form.js";
|
|
3
|
+
import { jsx, jsxs } from "preact/jsx-runtime";
|
|
4
|
+
import QRCode from "qrcode";
|
|
5
|
+
|
|
6
|
+
//#region src/ui/totp.tsx
|
|
7
|
+
const DEFAULT_COPY = {
|
|
8
|
+
setup_title: "Set up Two-Factor Authentication",
|
|
9
|
+
setup_description: "Scan the QR code with your authenticator app, then enter the verification code.",
|
|
10
|
+
verify_title: "Enter authentication code",
|
|
11
|
+
verify_description: "Open your authenticator app and enter the current 6-digit code.",
|
|
12
|
+
recovery_title: "Use backup code",
|
|
13
|
+
recovery_description: "Enter one of your backup codes. Each code can only be used once.",
|
|
14
|
+
setup_manual_entry: "Can't scan? Enter this code manually:",
|
|
15
|
+
setup_backup_codes_title: "Save your backup codes",
|
|
16
|
+
setup_backup_codes_description: "Store these backup codes in a safe place. You can use them to access your account if you lose your device.",
|
|
17
|
+
button_continue: "Continue",
|
|
18
|
+
link_use_recovery: "Use backup code instead",
|
|
19
|
+
link_back_to_totp: "Back to authenticator code",
|
|
20
|
+
input_token: "000000",
|
|
21
|
+
input_recovery_code: "XXXX-XXXX"
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Creates a complete UI configuration for TOTP authentication
|
|
25
|
+
*/
|
|
26
|
+
const TOTPUI = (options = {}) => {
|
|
27
|
+
const { qrSize = 200, showManualEntry = true } = options;
|
|
28
|
+
const copy = {
|
|
29
|
+
...DEFAULT_COPY,
|
|
30
|
+
...options.copy
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Generates QR code as data URL using the qrcode library
|
|
34
|
+
*/
|
|
35
|
+
const generateQRCode = async (text) => {
|
|
36
|
+
try {
|
|
37
|
+
return await QRCode.toDataURL(text, {
|
|
38
|
+
width: qrSize,
|
|
39
|
+
margin: 1,
|
|
40
|
+
color: {
|
|
41
|
+
dark: "#000000",
|
|
42
|
+
light: "#FFFFFF"
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error("QR Code generation failed:", error);
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Renders the setup form with QR code and backup codes
|
|
52
|
+
*/
|
|
53
|
+
const renderRegister = async (qrCodeUrl, secret, backupCodes, error, email) => {
|
|
54
|
+
if (!qrCodeUrl) return /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
|
|
55
|
+
"data-component": "form",
|
|
56
|
+
method: "post",
|
|
57
|
+
action: "./register-verify",
|
|
58
|
+
children: [
|
|
59
|
+
/* @__PURE__ */ jsx(FormAlert, { message: error }),
|
|
60
|
+
/* @__PURE__ */ jsx("input", {
|
|
61
|
+
type: "email",
|
|
62
|
+
name: "email",
|
|
63
|
+
placeholder: "Email",
|
|
64
|
+
autoComplete: "email",
|
|
65
|
+
"data-component": "input",
|
|
66
|
+
required: true
|
|
67
|
+
}),
|
|
68
|
+
/* @__PURE__ */ jsx("input", {
|
|
69
|
+
type: "hidden",
|
|
70
|
+
name: "action",
|
|
71
|
+
value: "generate"
|
|
72
|
+
}),
|
|
73
|
+
/* @__PURE__ */ jsx("button", {
|
|
74
|
+
type: "submit",
|
|
75
|
+
"data-component": "button",
|
|
76
|
+
children: "Generate QR Code"
|
|
77
|
+
}),
|
|
78
|
+
/* @__PURE__ */ jsx("div", {
|
|
79
|
+
"data-component": "form-footer",
|
|
80
|
+
children: /* @__PURE__ */ jsxs("span", { children: [
|
|
81
|
+
"Already have TOTP?",
|
|
82
|
+
" ",
|
|
83
|
+
/* @__PURE__ */ jsx("a", {
|
|
84
|
+
href: "./authorize",
|
|
85
|
+
"data-component": "link",
|
|
86
|
+
children: "Login"
|
|
87
|
+
})
|
|
88
|
+
] })
|
|
89
|
+
})
|
|
90
|
+
]
|
|
91
|
+
}) });
|
|
92
|
+
const qrCodeDataUrl = await generateQRCode(qrCodeUrl);
|
|
93
|
+
return /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
|
|
94
|
+
"data-component": "form",
|
|
95
|
+
method: "post",
|
|
96
|
+
action: "./register-verify",
|
|
97
|
+
children: [
|
|
98
|
+
/* @__PURE__ */ jsx(FormAlert, { message: error }),
|
|
99
|
+
email && /* @__PURE__ */ jsx("input", {
|
|
100
|
+
type: "hidden",
|
|
101
|
+
name: "email",
|
|
102
|
+
value: email
|
|
103
|
+
}),
|
|
104
|
+
qrCodeDataUrl && /* @__PURE__ */ jsx("img", {
|
|
105
|
+
src: qrCodeDataUrl,
|
|
106
|
+
alt: "TOTP QR Code",
|
|
107
|
+
width: qrSize,
|
|
108
|
+
height: qrSize,
|
|
109
|
+
style: {
|
|
110
|
+
display: "block",
|
|
111
|
+
margin: "0 auto"
|
|
112
|
+
}
|
|
113
|
+
}),
|
|
114
|
+
showManualEntry && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("p", {
|
|
115
|
+
"data-component": "description",
|
|
116
|
+
children: copy.setup_manual_entry
|
|
117
|
+
}), /* @__PURE__ */ jsx("code", {
|
|
118
|
+
style: {
|
|
119
|
+
display: "block",
|
|
120
|
+
textAlign: "center",
|
|
121
|
+
margin: "8px 0"
|
|
122
|
+
},
|
|
123
|
+
children: secret
|
|
124
|
+
})] }),
|
|
125
|
+
/* @__PURE__ */ jsx("input", {
|
|
126
|
+
type: "text",
|
|
127
|
+
name: "token",
|
|
128
|
+
placeholder: copy.input_token,
|
|
129
|
+
pattern: "[0-9]{6}",
|
|
130
|
+
maxLength: 6,
|
|
131
|
+
minLength: 6,
|
|
132
|
+
autoComplete: "one-time-code",
|
|
133
|
+
"data-component": "input",
|
|
134
|
+
required: true
|
|
135
|
+
}),
|
|
136
|
+
/* @__PURE__ */ jsx("button", {
|
|
137
|
+
type: "submit",
|
|
138
|
+
"data-component": "button",
|
|
139
|
+
children: copy.button_continue
|
|
140
|
+
}),
|
|
141
|
+
backupCodes.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
|
|
142
|
+
/* @__PURE__ */ jsx("h3", {
|
|
143
|
+
style: { textAlign: "center" },
|
|
144
|
+
children: copy.setup_backup_codes_title
|
|
145
|
+
}),
|
|
146
|
+
/* @__PURE__ */ jsx("p", {
|
|
147
|
+
"data-component": "description",
|
|
148
|
+
children: copy.setup_backup_codes_description
|
|
149
|
+
}),
|
|
150
|
+
/* @__PURE__ */ jsx("div", {
|
|
151
|
+
style: {
|
|
152
|
+
display: "grid",
|
|
153
|
+
gridTemplateColumns: "repeat(2, 1fr)",
|
|
154
|
+
gap: "8px",
|
|
155
|
+
margin: "16px 0"
|
|
156
|
+
},
|
|
157
|
+
children: backupCodes.map((code, index) => /* @__PURE__ */ jsx("code", {
|
|
158
|
+
"data-component": "button",
|
|
159
|
+
children: code
|
|
160
|
+
}, `${code}-${index + Math.random()}`))
|
|
161
|
+
})
|
|
162
|
+
] })
|
|
163
|
+
]
|
|
164
|
+
}) });
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Renders the authorize form (main TOTP login page following passkey pattern)
|
|
168
|
+
*/
|
|
169
|
+
const renderAuthorize = (error) => /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
|
|
170
|
+
"data-component": "form",
|
|
171
|
+
method: "post",
|
|
172
|
+
action: "./verify",
|
|
173
|
+
children: [
|
|
174
|
+
/* @__PURE__ */ jsx(FormAlert, { message: error }),
|
|
175
|
+
/* @__PURE__ */ jsx("input", {
|
|
176
|
+
type: "email",
|
|
177
|
+
name: "email",
|
|
178
|
+
placeholder: "Email",
|
|
179
|
+
autoComplete: "email",
|
|
180
|
+
"data-component": "input",
|
|
181
|
+
required: true
|
|
182
|
+
}),
|
|
183
|
+
/* @__PURE__ */ jsx("input", {
|
|
184
|
+
type: "text",
|
|
185
|
+
name: "token",
|
|
186
|
+
placeholder: copy.input_token,
|
|
187
|
+
pattern: "[0-9]{6}",
|
|
188
|
+
maxLength: 6,
|
|
189
|
+
minLength: 6,
|
|
190
|
+
autoComplete: "one-time-code",
|
|
191
|
+
"data-component": "input",
|
|
192
|
+
required: true
|
|
193
|
+
}),
|
|
194
|
+
/* @__PURE__ */ jsx("button", {
|
|
195
|
+
type: "submit",
|
|
196
|
+
"data-component": "button",
|
|
197
|
+
children: copy.button_continue
|
|
198
|
+
}),
|
|
199
|
+
/* @__PURE__ */ jsxs("div", {
|
|
200
|
+
"data-component": "form-footer",
|
|
201
|
+
children: [/* @__PURE__ */ jsxs("span", { children: [
|
|
202
|
+
"Don't have TOTP setup?",
|
|
203
|
+
" ",
|
|
204
|
+
/* @__PURE__ */ jsx("a", {
|
|
205
|
+
href: "./register",
|
|
206
|
+
"data-component": "link",
|
|
207
|
+
children: "Register"
|
|
208
|
+
})
|
|
209
|
+
] }), /* @__PURE__ */ jsx("a", {
|
|
210
|
+
href: "./recovery",
|
|
211
|
+
"data-component": "link",
|
|
212
|
+
children: copy.link_use_recovery
|
|
213
|
+
})]
|
|
214
|
+
})
|
|
215
|
+
]
|
|
216
|
+
}) });
|
|
217
|
+
/**
|
|
218
|
+
* Renders the verification form
|
|
219
|
+
*/
|
|
220
|
+
const renderVerify = (error) => /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
|
|
221
|
+
"data-component": "form",
|
|
222
|
+
method: "post",
|
|
223
|
+
action: "./verify",
|
|
224
|
+
children: [
|
|
225
|
+
/* @__PURE__ */ jsx(FormAlert, { message: error }),
|
|
226
|
+
/* @__PURE__ */ jsx("input", {
|
|
227
|
+
type: "email",
|
|
228
|
+
name: "email",
|
|
229
|
+
placeholder: "Email",
|
|
230
|
+
autoComplete: "email",
|
|
231
|
+
"data-component": "input",
|
|
232
|
+
required: true
|
|
233
|
+
}),
|
|
234
|
+
/* @__PURE__ */ jsx("input", {
|
|
235
|
+
type: "text",
|
|
236
|
+
name: "token",
|
|
237
|
+
placeholder: copy.input_token,
|
|
238
|
+
pattern: "[0-9]{6}",
|
|
239
|
+
maxLength: 6,
|
|
240
|
+
minLength: 6,
|
|
241
|
+
autoComplete: "one-time-code",
|
|
242
|
+
"data-component": "input",
|
|
243
|
+
required: true
|
|
244
|
+
}),
|
|
245
|
+
/* @__PURE__ */ jsx("button", {
|
|
246
|
+
type: "submit",
|
|
247
|
+
"data-component": "button",
|
|
248
|
+
children: copy.button_continue
|
|
249
|
+
}),
|
|
250
|
+
/* @__PURE__ */ jsx("div", {
|
|
251
|
+
"data-component": "form-footer",
|
|
252
|
+
children: /* @__PURE__ */ jsx("a", {
|
|
253
|
+
href: "./recovery",
|
|
254
|
+
"data-component": "link",
|
|
255
|
+
children: copy.link_use_recovery
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
]
|
|
259
|
+
}) });
|
|
260
|
+
/**
|
|
261
|
+
* Renders the recovery form
|
|
262
|
+
*/
|
|
263
|
+
const renderRecovery = (error) => /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
|
|
264
|
+
"data-component": "form",
|
|
265
|
+
method: "post",
|
|
266
|
+
action: "./recovery-verify",
|
|
267
|
+
children: [
|
|
268
|
+
/* @__PURE__ */ jsx(FormAlert, { message: error }),
|
|
269
|
+
/* @__PURE__ */ jsx("input", {
|
|
270
|
+
type: "email",
|
|
271
|
+
name: "email",
|
|
272
|
+
placeholder: "Email",
|
|
273
|
+
autoComplete: "email",
|
|
274
|
+
"data-component": "input",
|
|
275
|
+
required: true
|
|
276
|
+
}),
|
|
277
|
+
/* @__PURE__ */ jsx("input", {
|
|
278
|
+
type: "text",
|
|
279
|
+
name: "code",
|
|
280
|
+
placeholder: copy.input_recovery_code,
|
|
281
|
+
pattern: "[A-Z0-9]{4}-[A-Z0-9]{4}",
|
|
282
|
+
maxLength: 9,
|
|
283
|
+
autoComplete: "off",
|
|
284
|
+
"data-component": "input",
|
|
285
|
+
required: true
|
|
286
|
+
}),
|
|
287
|
+
/* @__PURE__ */ jsx("button", {
|
|
288
|
+
type: "submit",
|
|
289
|
+
"data-component": "button",
|
|
290
|
+
children: copy.button_continue
|
|
291
|
+
}),
|
|
292
|
+
/* @__PURE__ */ jsx("div", {
|
|
293
|
+
"data-component": "form-footer",
|
|
294
|
+
children: /* @__PURE__ */ jsx("a", {
|
|
295
|
+
href: "./authorize",
|
|
296
|
+
"data-component": "link",
|
|
297
|
+
children: copy.link_back_to_totp
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
]
|
|
301
|
+
}) });
|
|
302
|
+
return {
|
|
303
|
+
authorize: async (_req, error) => {
|
|
304
|
+
const jsx$1 = renderAuthorize(error);
|
|
305
|
+
return new Response(renderToHTML(jsx$1), { headers: { "Content-Type": "text/html" } });
|
|
306
|
+
},
|
|
307
|
+
register: async (_req, qrCodeUrl, secret, backupCodes, error, email) => {
|
|
308
|
+
const jsx$1 = await renderRegister(qrCodeUrl, secret, backupCodes, error, email);
|
|
309
|
+
return new Response(renderToHTML(jsx$1), { headers: { "Content-Type": "text/html" } });
|
|
310
|
+
},
|
|
311
|
+
verify: async (_req, error) => {
|
|
312
|
+
const jsx$1 = renderVerify(error);
|
|
313
|
+
return new Response(renderToHTML(jsx$1), { headers: { "Content-Type": "text/html" } });
|
|
314
|
+
},
|
|
315
|
+
recovery: async (_req, error) => {
|
|
316
|
+
const jsx$1 = renderRecovery(error);
|
|
317
|
+
return new Response(renderToHTML(jsx$1), { headers: { "Content-Type": "text/html" } });
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
//#endregion
|
|
323
|
+
export { TOTPUI };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@draftlab/auth",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Core implementation for @draftlab/auth",
|
|
6
6
|
"author": "Matheus Pergoli",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "^24.1.0",
|
|
41
|
+
"@types/qrcode": "^1.5.5",
|
|
41
42
|
"tsdown": "^0.13.0",
|
|
42
43
|
"typescript": "^5.8.3",
|
|
43
44
|
"@draftlab/tsconfig": "0.1.0"
|
|
@@ -58,8 +59,10 @@
|
|
|
58
59
|
"@simplewebauthn/server": "^13.1.2",
|
|
59
60
|
"@standard-schema/spec": "^1.0.0",
|
|
60
61
|
"jose": "^6.0.12",
|
|
62
|
+
"otpauth": "^9.4.0",
|
|
61
63
|
"preact": "^10.26.9",
|
|
62
64
|
"preact-render-to-string": "^6.5.13",
|
|
65
|
+
"qrcode": "^1.5.4",
|
|
63
66
|
"@draftlab/auth-router": "0.0.4"
|
|
64
67
|
},
|
|
65
68
|
"engines": {
|