strapi-plugin-magic-link-v5 5.5.8 → 6.0.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/CHANGELOG.md +740 -0
- package/README.md +662 -928
- package/dist/admin/LicenseGuard-BgLA9go3.mjs +495 -0
- package/dist/admin/LicenseGuard-ewCjpc17.js +513 -0
- package/dist/admin/de-B1FNErPH.mjs +673 -0
- package/dist/admin/de-e75OECdq.js +673 -0
- package/dist/admin/en-CABKGbxH.js +673 -0
- package/dist/admin/en-HqDOFBCc.mjs +673 -0
- package/dist/admin/es-DpWl0Fax.js +673 -0
- package/dist/admin/es-jb94jqPR.mjs +673 -0
- package/dist/admin/fr-BQhLPvYw.js +673 -0
- package/dist/admin/fr-BseH1wrr.mjs +673 -0
- package/dist/admin/index-6ot9j1cN.js +5268 -0
- package/dist/admin/index-B390rccl.js +622 -0
- package/dist/admin/index-BPGKtruf.mjs +5266 -0
- package/dist/admin/index-BaTIQtnA.js +3869 -0
- package/dist/admin/index-CmG-93yx.js +141 -0
- package/dist/admin/index-DXe_ge4v.mjs +620 -0
- package/dist/admin/index-Dv5PVT1E.mjs +3867 -0
- package/dist/admin/index-k240ls6p.mjs +142 -0
- package/dist/admin/index.js +3 -3
- package/dist/admin/index.mjs +1 -2
- package/dist/admin/pt-CPkaveti.js +673 -0
- package/dist/admin/pt-DhlSCJn2.mjs +673 -0
- package/dist/admin/useUnsavedChangesGuard-BVZ4Mg4E.js +225 -0
- package/dist/admin/useUnsavedChangesGuard-QnkiZTLP.mjs +224 -0
- package/dist/server/i18n/de.json +32 -1
- package/dist/server/i18n/en.json +32 -1
- package/dist/server/i18n/es.json +32 -1
- package/dist/server/i18n/fr.json +32 -1
- package/dist/server/i18n/pt.json +32 -1
- package/dist/server/index.js +7092 -5979
- package/dist/server/index.mjs +7114 -5979
- package/package.json +42 -41
- package/strapi-admin.js +3 -1
- package/strapi-server.js +3 -2
- package/dist/_chunks/LicenseGuard-CiyjFnrw.js +0 -54
- package/dist/_chunks/LicenseGuard-DJ5fq2UZ.mjs +0 -55
- package/dist/_chunks/de-BSOk5suQ.js +0 -454
- package/dist/_chunks/de-CwhYs3O3.mjs +0 -454
- package/dist/_chunks/en-DnvSpXvu.js +0 -454
- package/dist/_chunks/en-gXXgGDV1.mjs +0 -454
- package/dist/_chunks/es-B-tnwByt.js +0 -407
- package/dist/_chunks/es-DG5gW3RZ.mjs +0 -407
- package/dist/_chunks/fr-BKo8t7Fx.js +0 -407
- package/dist/_chunks/fr-BQn1OHOR.mjs +0 -407
- package/dist/_chunks/index-01GHNxSr.js +0 -307
- package/dist/_chunks/index-B9Ch8Jpe.mjs +0 -5066
- package/dist/_chunks/index-Bj-erY7g.mjs +0 -143
- package/dist/_chunks/index-CGxbhJfg.js +0 -142
- package/dist/_chunks/index-DF3Edydl.mjs +0 -330
- package/dist/_chunks/index-Dufv-daf.mjs +0 -305
- package/dist/_chunks/index-I72rBTkL.js +0 -332
- package/dist/_chunks/index-SCxv154G.mjs +0 -2904
- package/dist/_chunks/index-XYWLmmEs.js +0 -5068
- package/dist/_chunks/index-zzoovbM0.js +0 -2906
- package/dist/_chunks/pt-CjEkXBAz.js +0 -407
- package/dist/_chunks/pt-trziAOzT.mjs +0 -407
|
@@ -0,0 +1,3867 @@
|
|
|
1
|
+
import { jsxs, jsx, Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useId, useRef, useState, useMemo, useCallback, useEffect } from "react";
|
|
3
|
+
import styled, { css } from "styled-components";
|
|
4
|
+
import { Field, Flex, Typography, Switch, SingleSelect, SingleSelectOption, NumberInput, TextInput, Badge, Radio, Button, Loader, Alert, Box, Accordion } from "@strapi/design-system";
|
|
5
|
+
import { Link, Mail, Lock, Lightning, Information, WarningCircle, CheckCircle, Key, Check, Duplicate, ArrowClockwise, Trash, Code, Cog, Clock } from "@strapi/icons";
|
|
6
|
+
import { useNotification, useFetchClient, Page } from "@strapi/strapi/admin";
|
|
7
|
+
import { s as space, t as theme, c as color, S as SubsectionHeading, f as fontSize, r as reducedMotion, g as byScheme, a as brand, e as tint, u as useMagicLinkIntl, l as usePluginLanguage, d as accent, j as useCopyToClipboard, h as useFormatters, i as useMagicLinkRBAC, P as PluginPageTitle, L as LicenseGuard, k as LanguageProvider } from "./LicenseGuard-BgLA9go3.mjs";
|
|
8
|
+
import { C as ConfirmDialog, b as useUnsavedChangesGuard, U as UnsavedChangesDialog } from "./useUnsavedChangesGuard-QnkiZTLP.mjs";
|
|
9
|
+
const EMAIL_TEMPLATES = {
|
|
10
|
+
modern: {
|
|
11
|
+
name: "Modern Gradient",
|
|
12
|
+
preview: "Modern design with gradient header",
|
|
13
|
+
html: `<!DOCTYPE html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="UTF-8">
|
|
17
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
18
|
+
<title>Magic Link Login</title>
|
|
19
|
+
</head>
|
|
20
|
+
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
|
21
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f7fafc; padding: 40px 20px;">
|
|
22
|
+
<tr>
|
|
23
|
+
<td align="center">
|
|
24
|
+
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);">
|
|
25
|
+
<!-- Header with Gradient -->
|
|
26
|
+
<tr>
|
|
27
|
+
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;">
|
|
28
|
+
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700; display: flex; align-items: center; justify-content: center; gap: 8px;">
|
|
29
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle;"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
|
30
|
+
Magic Link Login
|
|
31
|
+
</h1>
|
|
32
|
+
<p style="color: rgba(255, 255, 255, 0.9); margin: 10px 0 0; font-size: 16px;">
|
|
33
|
+
Secure passwordless authentication
|
|
34
|
+
</p>
|
|
35
|
+
</td>
|
|
36
|
+
</tr>
|
|
37
|
+
|
|
38
|
+
<!-- Content -->
|
|
39
|
+
<tr>
|
|
40
|
+
<td style="padding: 40px 30px;">
|
|
41
|
+
<h2 style="color: #2d3748; margin: 0 0 20px; font-size: 22px; font-weight: 600;">
|
|
42
|
+
Click to login
|
|
43
|
+
</h2>
|
|
44
|
+
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 30px; font-size: 16px;">
|
|
45
|
+
We received a login request for your account. Click the button below to securely sign in.
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
<!-- Button -->
|
|
49
|
+
<table width="100%" cellpadding="0" cellspacing="0">
|
|
50
|
+
<tr>
|
|
51
|
+
<td align="center" style="padding: 20px 0;">
|
|
52
|
+
<a href="{{LOGIN_URL}}"
|
|
53
|
+
style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; padding: 16px 48px; border-radius: 12px; text-decoration: none; font-weight: 600; font-size: 16px; box-shadow: 0 4px 14px rgba(102, 126, 234, 0.4);">
|
|
54
|
+
Sign In Securely
|
|
55
|
+
</a>
|
|
56
|
+
</td>
|
|
57
|
+
</tr>
|
|
58
|
+
</table>
|
|
59
|
+
|
|
60
|
+
<p style="color: #718096; line-height: 1.6; margin: 30px 0 0; font-size: 14px;">
|
|
61
|
+
Or copy and paste this link into your browser:<br>
|
|
62
|
+
<a href="{{LOGIN_URL}}" style="color: #667eea; word-break: break-all;">{{LOGIN_URL}}</a>
|
|
63
|
+
</p>
|
|
64
|
+
</td>
|
|
65
|
+
</tr>
|
|
66
|
+
|
|
67
|
+
<!-- Footer -->
|
|
68
|
+
<tr>
|
|
69
|
+
<td style="background-color: #f7fafc; padding: 30px; text-align: center; border-top: 1px solid #e2e8f0;">
|
|
70
|
+
<p style="color: #718096; margin: 0 0 10px; font-size: 13px; display: flex; align-items: center; justify-content: center; gap: 6px;">
|
|
71
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle;"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
|
72
|
+
This link expires in {{EXPIRY_TEXT}}
|
|
73
|
+
</p>
|
|
74
|
+
<p style="color: #a0aec0; margin: 0; font-size: 12px;">
|
|
75
|
+
If you didn't request this, you can safely ignore this email.
|
|
76
|
+
</p>
|
|
77
|
+
</td>
|
|
78
|
+
</tr>
|
|
79
|
+
</table>
|
|
80
|
+
</td>
|
|
81
|
+
</tr>
|
|
82
|
+
</table>
|
|
83
|
+
</body>
|
|
84
|
+
</html>`,
|
|
85
|
+
text: `Magic Link Login
|
|
86
|
+
|
|
87
|
+
Click to login:
|
|
88
|
+
{{LOGIN_URL}}
|
|
89
|
+
|
|
90
|
+
This link expires in {{EXPIRY_TEXT}}.
|
|
91
|
+
|
|
92
|
+
If you didn't request this, you can safely ignore this email.`
|
|
93
|
+
},
|
|
94
|
+
minimal: {
|
|
95
|
+
name: "Clean Minimal",
|
|
96
|
+
preview: "Simple and elegant design",
|
|
97
|
+
html: `<!DOCTYPE html>
|
|
98
|
+
<html lang="en">
|
|
99
|
+
<head>
|
|
100
|
+
<meta charset="UTF-8">
|
|
101
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
102
|
+
<title>Your Magic Link</title>
|
|
103
|
+
</head>
|
|
104
|
+
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
|
105
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; padding: 60px 20px;">
|
|
106
|
+
<tr>
|
|
107
|
+
<td align="center">
|
|
108
|
+
<table width="500" cellpadding="0" cellspacing="0">
|
|
109
|
+
<tr>
|
|
110
|
+
<td style="text-align: center; padding-bottom: 40px;">
|
|
111
|
+
<div style="width: 64px; height: 64px; background: #0EA5E9; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;">
|
|
112
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
|
113
|
+
</div>
|
|
114
|
+
</td>
|
|
115
|
+
</tr>
|
|
116
|
+
<tr>
|
|
117
|
+
<td style="text-align: center;">
|
|
118
|
+
<h1 style="color: #111827; margin: 0 0 16px; font-size: 32px; font-weight: 700;">
|
|
119
|
+
Your Magic Link
|
|
120
|
+
</h1>
|
|
121
|
+
<p style="color: #6B7280; line-height: 1.6; margin: 0 0 40px; font-size: 16px;">
|
|
122
|
+
Click the button below to sign in to your account
|
|
123
|
+
</p>
|
|
124
|
+
|
|
125
|
+
<a href="{{LOGIN_URL}}"
|
|
126
|
+
style="display: inline-block; background-color: #0EA5E9; color: #ffffff; padding: 16px 40px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 16px;">
|
|
127
|
+
Sign In
|
|
128
|
+
</a>
|
|
129
|
+
|
|
130
|
+
<p style="color: #9CA3AF; margin: 40px 0 0; font-size: 14px;">
|
|
131
|
+
Link expires in {{EXPIRY_TEXT}}
|
|
132
|
+
</p>
|
|
133
|
+
</td>
|
|
134
|
+
</tr>
|
|
135
|
+
</table>
|
|
136
|
+
</td>
|
|
137
|
+
</tr>
|
|
138
|
+
</table>
|
|
139
|
+
</body>
|
|
140
|
+
</html>`,
|
|
141
|
+
text: `Your Magic Link
|
|
142
|
+
|
|
143
|
+
Click to sign in:
|
|
144
|
+
{{LOGIN_URL}}
|
|
145
|
+
|
|
146
|
+
Link expires in {{EXPIRY_TEXT}}.`
|
|
147
|
+
},
|
|
148
|
+
professional: {
|
|
149
|
+
name: "Professional Blue",
|
|
150
|
+
preview: "Corporate and trustworthy",
|
|
151
|
+
html: `<!DOCTYPE html>
|
|
152
|
+
<html lang="en">
|
|
153
|
+
<head>
|
|
154
|
+
<meta charset="UTF-8">
|
|
155
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
156
|
+
<title>Secure Login Request</title>
|
|
157
|
+
</head>
|
|
158
|
+
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #F3F4F6;">
|
|
159
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="padding: 40px 20px;">
|
|
160
|
+
<tr>
|
|
161
|
+
<td align="center">
|
|
162
|
+
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden;">
|
|
163
|
+
<!-- Logo/Brand Area -->
|
|
164
|
+
<tr>
|
|
165
|
+
<td style="background-color: #1E40AF; padding: 30px; text-align: center;">
|
|
166
|
+
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600; letter-spacing: 0.5px;">
|
|
167
|
+
SECURE ACCESS
|
|
168
|
+
</h1>
|
|
169
|
+
</td>
|
|
170
|
+
</tr>
|
|
171
|
+
|
|
172
|
+
<!-- Content -->
|
|
173
|
+
<tr>
|
|
174
|
+
<td style="padding: 40px 40px 30px;">
|
|
175
|
+
<p style="color: #111827; margin: 0 0 20px; font-size: 16px; font-weight: 600;">
|
|
176
|
+
Hello,
|
|
177
|
+
</p>
|
|
178
|
+
<p style="color: #4B5563; line-height: 1.6; margin: 0 0 30px; font-size: 15px;">
|
|
179
|
+
You have requested secure access to your account. Please click the button below to proceed with authentication.
|
|
180
|
+
</p>
|
|
181
|
+
|
|
182
|
+
<!-- Button Container -->
|
|
183
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 30px;">
|
|
184
|
+
<tr>
|
|
185
|
+
<td align="center" style="padding: 10px 0;">
|
|
186
|
+
<a href="{{LOGIN_URL}}"
|
|
187
|
+
style="display: inline-block; background-color: #1E40AF; color: #ffffff; padding: 14px 32px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 15px;">
|
|
188
|
+
Verify & Sign In
|
|
189
|
+
</a>
|
|
190
|
+
</td>
|
|
191
|
+
</tr>
|
|
192
|
+
</table>
|
|
193
|
+
|
|
194
|
+
<!-- Security Info Box -->
|
|
195
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #EFF6FF; border-left: 4px solid #3B82F6; padding: 16px; margin: 0 0 20px;">
|
|
196
|
+
<tr>
|
|
197
|
+
<td>
|
|
198
|
+
<p style="color: #1E40AF; margin: 0; font-size: 13px; line-height: 1.5;">
|
|
199
|
+
<strong>Security Notice:</strong><br>
|
|
200
|
+
This link will expire in {{EXPIRY_TEXT}}. For your security, do not share this link with anyone.
|
|
201
|
+
</p>
|
|
202
|
+
</td>
|
|
203
|
+
</tr>
|
|
204
|
+
</table>
|
|
205
|
+
|
|
206
|
+
<p style="color: #6B7280; line-height: 1.6; margin: 0; font-size: 13px;">
|
|
207
|
+
If you did not request this authentication, please disregard this email or contact support if you have concerns.
|
|
208
|
+
</p>
|
|
209
|
+
</td>
|
|
210
|
+
</tr>
|
|
211
|
+
|
|
212
|
+
<!-- Footer -->
|
|
213
|
+
<tr>
|
|
214
|
+
<td style="background-color: #F9FAFB; padding: 20px 40px; border-top: 1px solid #E5E7EB;">
|
|
215
|
+
<p style="color: #9CA3AF; margin: 0; font-size: 12px; text-align: center;">
|
|
216
|
+
This is an automated message. Please do not reply to this email.
|
|
217
|
+
</p>
|
|
218
|
+
</td>
|
|
219
|
+
</tr>
|
|
220
|
+
</table>
|
|
221
|
+
</td>
|
|
222
|
+
</tr>
|
|
223
|
+
</table>
|
|
224
|
+
</body>
|
|
225
|
+
</html>`,
|
|
226
|
+
text: `SECURE ACCESS
|
|
227
|
+
|
|
228
|
+
Hello,
|
|
229
|
+
|
|
230
|
+
You have requested secure access to your account.
|
|
231
|
+
|
|
232
|
+
Click to verify and sign in:
|
|
233
|
+
{{LOGIN_URL}}
|
|
234
|
+
|
|
235
|
+
Security Notice:
|
|
236
|
+
This link will expire in {{EXPIRY_TEXT}}. Do not share this link.
|
|
237
|
+
|
|
238
|
+
If you did not request this, please disregard this email.`
|
|
239
|
+
},
|
|
240
|
+
dark: {
|
|
241
|
+
name: "Dark Mode",
|
|
242
|
+
preview: "Modern dark theme",
|
|
243
|
+
html: `<!DOCTYPE html>
|
|
244
|
+
<html lang="en">
|
|
245
|
+
<head>
|
|
246
|
+
<meta charset="UTF-8">
|
|
247
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
248
|
+
<title>Magic Link</title>
|
|
249
|
+
</head>
|
|
250
|
+
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
|
251
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #0F172A; padding: 40px 20px;">
|
|
252
|
+
<tr>
|
|
253
|
+
<td align="center">
|
|
254
|
+
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #1E293B; border-radius: 16px; overflow: hidden; border: 1px solid #334155;">
|
|
255
|
+
<!-- Header -->
|
|
256
|
+
<tr>
|
|
257
|
+
<td style="padding: 40px 30px; text-align: center; background: linear-gradient(135deg, #312E81 0%, #1E293B 100%);">
|
|
258
|
+
<h1 style="color: #E0E7FF; margin: 0; font-size: 28px; font-weight: 700; display: flex; align-items: center; justify-content: center; gap: 8px;">
|
|
259
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle;"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
|
|
260
|
+
Magic Link
|
|
261
|
+
</h1>
|
|
262
|
+
<p style="color: #A5B4FC; margin: 10px 0 0; font-size: 16px;">
|
|
263
|
+
Passwordless Authentication
|
|
264
|
+
</p>
|
|
265
|
+
</td>
|
|
266
|
+
</tr>
|
|
267
|
+
|
|
268
|
+
<!-- Content -->
|
|
269
|
+
<tr>
|
|
270
|
+
<td style="padding: 40px 30px;">
|
|
271
|
+
<p style="color: #E2E8F0; line-height: 1.6; margin: 0 0 24px; font-size: 16px;">
|
|
272
|
+
Hey there!
|
|
273
|
+
</p>
|
|
274
|
+
<p style="color: #CBD5E1; line-height: 1.6; margin: 0 0 32px; font-size: 15px;">
|
|
275
|
+
Click the magic button below to access your account. No passwords, no hassle.
|
|
276
|
+
</p>
|
|
277
|
+
|
|
278
|
+
<!-- Button -->
|
|
279
|
+
<table width="100%" cellpadding="0" cellspacing="0">
|
|
280
|
+
<tr>
|
|
281
|
+
<td align="center" style="padding: 20px 0;">
|
|
282
|
+
<a href="{{LOGIN_URL}}"
|
|
283
|
+
style="display: inline-flex; align-items: center; gap: 8px; background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%); color: #ffffff; padding: 16px 48px; border-radius: 12px; text-decoration: none; font-weight: 600; font-size: 16px; box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);">
|
|
284
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path></svg>
|
|
285
|
+
Launch Access
|
|
286
|
+
</a>
|
|
287
|
+
</td>
|
|
288
|
+
</tr>
|
|
289
|
+
</table>
|
|
290
|
+
|
|
291
|
+
<p style="color: #94A3B8; line-height: 1.6; margin: 32px 0 0; font-size: 13px; text-align: center;">
|
|
292
|
+
Or paste this URL in your browser:<br>
|
|
293
|
+
<span style="color: #818CF8; word-break: break-all;">{{LOGIN_URL}}</span>
|
|
294
|
+
</p>
|
|
295
|
+
</td>
|
|
296
|
+
</tr>
|
|
297
|
+
|
|
298
|
+
<!-- Footer -->
|
|
299
|
+
<tr>
|
|
300
|
+
<td style="background-color: #0F172A; padding: 24px 30px; text-align: center; border-top: 1px solid #334155;">
|
|
301
|
+
<p style="color: #64748B; margin: 0 0 8px; font-size: 13px;">
|
|
302
|
+
Expires in {{EXPIRY_TEXT}}
|
|
303
|
+
</p>
|
|
304
|
+
<p style="color: #475569; margin: 0; font-size: 12px;">
|
|
305
|
+
Didn't request this? Just ignore this email.
|
|
306
|
+
</p>
|
|
307
|
+
</td>
|
|
308
|
+
</tr>
|
|
309
|
+
</table>
|
|
310
|
+
</td>
|
|
311
|
+
</tr>
|
|
312
|
+
</table>
|
|
313
|
+
</body>
|
|
314
|
+
</html>`,
|
|
315
|
+
text: `Magic Link
|
|
316
|
+
|
|
317
|
+
Hey there!
|
|
318
|
+
|
|
319
|
+
Click the link below to access your account:
|
|
320
|
+
{{LOGIN_URL}}
|
|
321
|
+
|
|
322
|
+
Expires in {{EXPIRY_TEXT}}.
|
|
323
|
+
|
|
324
|
+
Didn't request this? Just ignore this email.`
|
|
325
|
+
},
|
|
326
|
+
playful: {
|
|
327
|
+
name: "Playful & Fun",
|
|
328
|
+
preview: "Colorful and engaging",
|
|
329
|
+
html: `<!DOCTYPE html>
|
|
330
|
+
<html lang="en">
|
|
331
|
+
<head>
|
|
332
|
+
<meta charset="UTF-8">
|
|
333
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
334
|
+
<title>Your Magic Link is Ready!</title>
|
|
335
|
+
</head>
|
|
336
|
+
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
|
337
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="background: linear-gradient(135deg, #FEF3C7 0%, #FED7AA 50%, #FECACA 100%); padding: 40px 20px;">
|
|
338
|
+
<tr>
|
|
339
|
+
<td align="center">
|
|
340
|
+
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);">
|
|
341
|
+
<!-- Fun Header -->
|
|
342
|
+
<tr>
|
|
343
|
+
<td style="padding: 40px 30px 30px; text-align: center;">
|
|
344
|
+
<div style="margin-bottom: 16px; display: inline-block;">
|
|
345
|
+
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#DC2626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
|
346
|
+
</div>
|
|
347
|
+
<h1 style="color: #DC2626; margin: 0; font-size: 32px; font-weight: 800;">
|
|
348
|
+
Your Magic Link is Ready!
|
|
349
|
+
</h1>
|
|
350
|
+
<p style="color: #F59E0B; margin: 12px 0 0; font-size: 18px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 6px;">
|
|
351
|
+
Let's get you signed in
|
|
352
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle;"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
|
|
353
|
+
</p>
|
|
354
|
+
</td>
|
|
355
|
+
</tr>
|
|
356
|
+
|
|
357
|
+
<!-- Content -->
|
|
358
|
+
<tr>
|
|
359
|
+
<td style="padding: 20px 40px 40px;">
|
|
360
|
+
<p style="color: #374151; line-height: 1.7; margin: 0 0 30px; font-size: 16px; text-align: center;">
|
|
361
|
+
Hey friend! We've prepared something special for you. Click the shiny button below to access your account!
|
|
362
|
+
</p>
|
|
363
|
+
|
|
364
|
+
<!-- Colorful Button -->
|
|
365
|
+
<table width="100%" cellpadding="0" cellspacing="0">
|
|
366
|
+
<tr>
|
|
367
|
+
<td align="center" style="padding: 10px 0 30px;">
|
|
368
|
+
<a href="{{LOGIN_URL}}"
|
|
369
|
+
style="display: inline-flex; align-items: center; gap: 8px; background: linear-gradient(135deg, #F59E0B 0%, #DC2626 100%); color: #ffffff; padding: 18px 54px; border-radius: 16px; text-decoration: none; font-weight: 700; font-size: 18px; box-shadow: 0 8px 16px rgba(245, 158, 11, 0.4); text-transform: uppercase; letter-spacing: 0.5px;">
|
|
370
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="8 12 12 16 16 12"></polyline><line x1="12" y1="8" x2="12" y2="16"></line></svg>
|
|
371
|
+
Click Here!
|
|
372
|
+
</a>
|
|
373
|
+
</td>
|
|
374
|
+
</tr>
|
|
375
|
+
</table>
|
|
376
|
+
|
|
377
|
+
<!-- Fun Fact Box -->
|
|
378
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="background: linear-gradient(135deg, #DBEAFE 0%, #E0E7FF 100%); border-radius: 12px; padding: 20px; margin: 20px 0;">
|
|
379
|
+
<tr>
|
|
380
|
+
<td style="text-align: center;">
|
|
381
|
+
<p style="color: #1E40AF; margin: 0; font-size: 14px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 6px;">
|
|
382
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle;"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
|
|
383
|
+
Pro Tip: Save this email for quick access later!
|
|
384
|
+
</p>
|
|
385
|
+
</td>
|
|
386
|
+
</tr>
|
|
387
|
+
</table>
|
|
388
|
+
|
|
389
|
+
<p style="color: #6B7280; line-height: 1.6; margin: 20px 0 0; font-size: 13px; text-align: center;">
|
|
390
|
+
Can't click? Copy & paste: <span style="color: #F59E0B; word-break: break-all;">{{LOGIN_URL}}</span>
|
|
391
|
+
</p>
|
|
392
|
+
</td>
|
|
393
|
+
</tr>
|
|
394
|
+
|
|
395
|
+
<!-- Footer -->
|
|
396
|
+
<tr>
|
|
397
|
+
<td style="background: linear-gradient(135deg, #FEF3C7 0%, #FECACA 100%); padding: 24px; text-align: center;">
|
|
398
|
+
<p style="color: #B91C1C; margin: 0 0 8px; font-size: 14px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 6px;">
|
|
399
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle;"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
|
400
|
+
Hurry! Link expires in {{EXPIRY_TEXT}}
|
|
401
|
+
</p>
|
|
402
|
+
<p style="color: #DC2626; margin: 0; font-size: 12px;">
|
|
403
|
+
Not you? No worries, just delete this email!
|
|
404
|
+
</p>
|
|
405
|
+
</td>
|
|
406
|
+
</tr>
|
|
407
|
+
</table>
|
|
408
|
+
</td>
|
|
409
|
+
</tr>
|
|
410
|
+
</table>
|
|
411
|
+
</body>
|
|
412
|
+
</html>`,
|
|
413
|
+
text: `Your Magic Link is Ready!
|
|
414
|
+
|
|
415
|
+
Let's get you signed in!
|
|
416
|
+
|
|
417
|
+
Click here:
|
|
418
|
+
{{LOGIN_URL}}
|
|
419
|
+
|
|
420
|
+
Pro Tip: Save this email for quick access later!
|
|
421
|
+
|
|
422
|
+
Hurry! Link expires in {{EXPIRY_TEXT}}.
|
|
423
|
+
|
|
424
|
+
Not you? No worries, just delete this email!`
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
const LEGACY_PLACEHOLDER = /<%=\s*([A-Za-z_]+)\s*%>/g;
|
|
428
|
+
const LEGACY_LINK = /<%=\s*URL\s*%>\?loginToken=<%=\s*CODE\s*%>/g;
|
|
429
|
+
const toPlaceholderSyntax = (template) => String(template ?? "").replace(LEGACY_LINK, "{{LOGIN_URL}}").replace(LEGACY_PLACEHOLDER, (_match, name) => `{{${name.toUpperCase()}}}`);
|
|
430
|
+
const getTemplateList = () => Object.entries(EMAIL_TEMPLATES).map(([key, template]) => ({
|
|
431
|
+
value: key,
|
|
432
|
+
label: template.name,
|
|
433
|
+
preview: template.preview
|
|
434
|
+
}));
|
|
435
|
+
const getTemplate = (key) => Object.hasOwn(EMAIL_TEMPLATES, key) ? EMAIL_TEMPLATES[key] : EMAIL_TEMPLATES.modern;
|
|
436
|
+
const DEFAULT_SETTINGS = Object.freeze({
|
|
437
|
+
createUserIfNotExists: true,
|
|
438
|
+
expire_period: 3600,
|
|
439
|
+
confirmationUrl: "",
|
|
440
|
+
from_name: "",
|
|
441
|
+
from_email: "",
|
|
442
|
+
response_email: "",
|
|
443
|
+
token_length: 32,
|
|
444
|
+
stays_valid: false,
|
|
445
|
+
object: "",
|
|
446
|
+
message_html: "",
|
|
447
|
+
message_text: "",
|
|
448
|
+
context_whitelist: [],
|
|
449
|
+
context_blacklist: ["password", "secret", "apiKey", "token"],
|
|
450
|
+
user_creation_strategy: "email",
|
|
451
|
+
verify_email: false,
|
|
452
|
+
welcome_email: false,
|
|
453
|
+
use_jwt_token: true,
|
|
454
|
+
jwt_token_expires_in: "30d",
|
|
455
|
+
allow_magic_links_on_public_registration: false,
|
|
456
|
+
store_login_info: true,
|
|
457
|
+
rate_limit_enabled: true,
|
|
458
|
+
rate_limit_max_attempts: 5,
|
|
459
|
+
rate_limit_window_minutes: 15,
|
|
460
|
+
use_email_designer: false,
|
|
461
|
+
email_designer_template_id: "",
|
|
462
|
+
use_magic_mail: false,
|
|
463
|
+
magic_mail_template_id: "",
|
|
464
|
+
otp_enabled: false,
|
|
465
|
+
otp_type: "email",
|
|
466
|
+
otp_length: 6,
|
|
467
|
+
otp_expiry: 300,
|
|
468
|
+
otp_max_attempts: 3,
|
|
469
|
+
otp_resend_cooldown: 60,
|
|
470
|
+
mfa_mode: "disabled",
|
|
471
|
+
mfa_require_totp: false,
|
|
472
|
+
totp_as_primary_auth: false,
|
|
473
|
+
totp_issuer: "Magic Link",
|
|
474
|
+
totp_digits: 6,
|
|
475
|
+
totp_period: 30,
|
|
476
|
+
frontend_url: "",
|
|
477
|
+
frontend_login_path: "/auth/magic-link",
|
|
478
|
+
passwordlessCompatibility: false
|
|
479
|
+
});
|
|
480
|
+
const REMOVED_KEY_PATTERN = /^(whatsapp_|sms_)/;
|
|
481
|
+
const REMOVED_KEYS = /* @__PURE__ */ new Set(["delivery", "phoneNumber", "phone", "via", "settings"]);
|
|
482
|
+
const FIELD_SECTIONS = Object.freeze({
|
|
483
|
+
expire_period: "general",
|
|
484
|
+
token_length: "general",
|
|
485
|
+
context_whitelist: "general",
|
|
486
|
+
context_blacklist: "general",
|
|
487
|
+
frontend_url: "general",
|
|
488
|
+
frontend_login_path: "general",
|
|
489
|
+
confirmationUrl: "general",
|
|
490
|
+
jwt_token_expires_in: "auth",
|
|
491
|
+
from_name: "email",
|
|
492
|
+
from_email: "email",
|
|
493
|
+
response_email: "email",
|
|
494
|
+
object: "email",
|
|
495
|
+
message_html: "email",
|
|
496
|
+
message_text: "email",
|
|
497
|
+
otp_length: "mfa",
|
|
498
|
+
otp_expiry: "mfa",
|
|
499
|
+
otp_max_attempts: "mfa",
|
|
500
|
+
otp_resend_cooldown: "mfa",
|
|
501
|
+
totp_issuer: "mfa",
|
|
502
|
+
totp_period: "mfa",
|
|
503
|
+
totp_digits: "mfa",
|
|
504
|
+
mfa_mode: "mfa",
|
|
505
|
+
rate_limit_max_attempts: "ratelimit",
|
|
506
|
+
rate_limit_window_minutes: "ratelimit"
|
|
507
|
+
});
|
|
508
|
+
const NUMBER_RANGES = Object.freeze({
|
|
509
|
+
expire_period: [10, 31536e3],
|
|
510
|
+
token_length: [22, 128],
|
|
511
|
+
otp_length: [4, 16],
|
|
512
|
+
otp_expiry: [30, 86400],
|
|
513
|
+
otp_max_attempts: [1, 20],
|
|
514
|
+
otp_resend_cooldown: [0, 86400],
|
|
515
|
+
rate_limit_max_attempts: [1, 1e3],
|
|
516
|
+
rate_limit_window_minutes: [1, 10080],
|
|
517
|
+
totp_period: [15, 300]
|
|
518
|
+
});
|
|
519
|
+
const STRING_LIMITS = Object.freeze({
|
|
520
|
+
from_name: 200,
|
|
521
|
+
object: 500,
|
|
522
|
+
message_html: 1e5,
|
|
523
|
+
message_text: 5e4,
|
|
524
|
+
totp_issuer: 128
|
|
525
|
+
});
|
|
526
|
+
const JWT_EXPIRY_PATTERN = /^[1-9]\d{0,5}[mhd]$/;
|
|
527
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
528
|
+
const FRONTEND_PATH_PATTERN = /^(\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$/;
|
|
529
|
+
const LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
530
|
+
const CONTEXT_KEY_MAX = 128;
|
|
531
|
+
const CONTEXT_LIST_MAX = 100;
|
|
532
|
+
const isValidJwtExpiry = (value) => typeof value === "string" && value.length <= 16 && JWT_EXPIRY_PATTERN.test(value);
|
|
533
|
+
const isValidOptionalEmail = (value) => {
|
|
534
|
+
if (value === void 0 || value === null) return true;
|
|
535
|
+
if (typeof value !== "string") return false;
|
|
536
|
+
const address = value.trim();
|
|
537
|
+
return address === "" || address.length <= 320 && EMAIL_PATTERN.test(address);
|
|
538
|
+
};
|
|
539
|
+
const isValidLinkUrl = (value) => {
|
|
540
|
+
if (value === void 0 || value === null) return true;
|
|
541
|
+
if (typeof value !== "string") return false;
|
|
542
|
+
const trimmed = value.trim();
|
|
543
|
+
if (trimmed === "") return true;
|
|
544
|
+
if (trimmed.length > 2048) return false;
|
|
545
|
+
let parsed;
|
|
546
|
+
try {
|
|
547
|
+
parsed = new URL(trimmed);
|
|
548
|
+
} catch {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
if (parsed.username || parsed.password) return false;
|
|
552
|
+
if (parsed.protocol === "https:") return true;
|
|
553
|
+
if (parsed.protocol === "http:") {
|
|
554
|
+
const host = parsed.hostname.toLowerCase();
|
|
555
|
+
return LOCAL_HOSTNAMES.has(host) || host.endsWith(".localhost");
|
|
556
|
+
}
|
|
557
|
+
return false;
|
|
558
|
+
};
|
|
559
|
+
const isValidFrontendPath = (value) => value === void 0 || value === null || typeof value === "string" && value.length <= 256 && FRONTEND_PATH_PATTERN.test(value);
|
|
560
|
+
const parseList = (value) => {
|
|
561
|
+
const items = Array.isArray(value) ? value : String(value ?? "").split(",");
|
|
562
|
+
return items.map((item) => String(item).trim()).filter(Boolean);
|
|
563
|
+
};
|
|
564
|
+
const formatList = (value) => typeof value === "string" ? value : parseList(value).join(", ");
|
|
565
|
+
const validateSettings = (settings = {}) => {
|
|
566
|
+
const errors = {};
|
|
567
|
+
for (const key of ["frontend_url", "confirmationUrl"]) {
|
|
568
|
+
if (!isValidLinkUrl(settings[key])) errors[key] = { code: "url" };
|
|
569
|
+
}
|
|
570
|
+
if (!isValidFrontendPath(settings.frontend_login_path)) {
|
|
571
|
+
errors.frontend_login_path = { code: "path" };
|
|
572
|
+
}
|
|
573
|
+
if (settings.jwt_token_expires_in !== void 0 && !isValidJwtExpiry(settings.jwt_token_expires_in)) {
|
|
574
|
+
errors.jwt_token_expires_in = { code: "jwtExpiry" };
|
|
575
|
+
}
|
|
576
|
+
for (const key of ["from_email", "response_email"]) {
|
|
577
|
+
if (!isValidOptionalEmail(settings[key])) errors[key] = { code: "email" };
|
|
578
|
+
}
|
|
579
|
+
for (const [key, [min, max]] of Object.entries(NUMBER_RANGES)) {
|
|
580
|
+
if (!Object.hasOwn(settings, key)) continue;
|
|
581
|
+
const value = settings[key];
|
|
582
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
583
|
+
errors[key] = { code: "range", min, max };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (settings.totp_digits !== void 0 && settings.totp_digits !== 6 && settings.totp_digits !== 8) {
|
|
587
|
+
errors.totp_digits = { code: "digits" };
|
|
588
|
+
}
|
|
589
|
+
for (const [key, max] of Object.entries(STRING_LIMITS)) {
|
|
590
|
+
const value = settings[key];
|
|
591
|
+
if (typeof value === "string" && value.length > max) errors[key] = { code: "length", max };
|
|
592
|
+
}
|
|
593
|
+
for (const key of ["context_whitelist", "context_blacklist"]) {
|
|
594
|
+
if (settings[key] === void 0) continue;
|
|
595
|
+
const list = parseList(settings[key]);
|
|
596
|
+
if (list.length > CONTEXT_LIST_MAX || list.some((item) => item.length > CONTEXT_KEY_MAX)) {
|
|
597
|
+
errors[key] = { code: "list", max: CONTEXT_LIST_MAX };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return errors;
|
|
601
|
+
};
|
|
602
|
+
const orderedErrorKeys = (errors) => {
|
|
603
|
+
const order = Object.keys(FIELD_SECTIONS);
|
|
604
|
+
return Object.keys(errors).sort((a, b) => {
|
|
605
|
+
const ia = order.indexOf(a);
|
|
606
|
+
const ib = order.indexOf(b);
|
|
607
|
+
return (ia === -1 ? order.length : ia) - (ib === -1 ? order.length : ib);
|
|
608
|
+
});
|
|
609
|
+
};
|
|
610
|
+
const sanitizeLoadedSettings = (raw) => {
|
|
611
|
+
const source = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
612
|
+
const clean = { ...DEFAULT_SETTINGS };
|
|
613
|
+
for (const [key, value] of Object.entries(source)) {
|
|
614
|
+
if (REMOVED_KEY_PATTERN.test(key) || REMOVED_KEYS.has(key)) continue;
|
|
615
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
|
616
|
+
clean[key] = value;
|
|
617
|
+
}
|
|
618
|
+
clean.otp_type = "email";
|
|
619
|
+
const mfaMode = resolveMfaMode(clean);
|
|
620
|
+
clean.mfa_mode = mfaMode;
|
|
621
|
+
clean.mfa_require_totp = mfaMode === "required";
|
|
622
|
+
for (const key of ["email_designer_template_id", "magic_mail_template_id"]) {
|
|
623
|
+
clean[key] = clean[key] === null || clean[key] === void 0 ? "" : String(clean[key]);
|
|
624
|
+
}
|
|
625
|
+
return clean;
|
|
626
|
+
};
|
|
627
|
+
const stableStringify = (value) => {
|
|
628
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
629
|
+
if (value && typeof value === "object") {
|
|
630
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
631
|
+
}
|
|
632
|
+
return JSON.stringify(value);
|
|
633
|
+
};
|
|
634
|
+
const sameValue = (key, a, b) => {
|
|
635
|
+
if (key === "context_whitelist" || key === "context_blacklist") {
|
|
636
|
+
return stableStringify(parseList(a)) === stableStringify(parseList(b));
|
|
637
|
+
}
|
|
638
|
+
return stableStringify(a) === stableStringify(b);
|
|
639
|
+
};
|
|
640
|
+
const diffSettings = (initial, current) => {
|
|
641
|
+
if (!initial) return {};
|
|
642
|
+
const patch = {};
|
|
643
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(initial), ...Object.keys(current ?? {})]);
|
|
644
|
+
for (const key of keys) {
|
|
645
|
+
if (!sameValue(key, initial[key], current?.[key])) patch[key] = current?.[key];
|
|
646
|
+
}
|
|
647
|
+
return patch;
|
|
648
|
+
};
|
|
649
|
+
const isSettingsDirty = (initial, current) => initial !== null && Object.keys(diffSettings(initial, current)).length > 0;
|
|
650
|
+
const buildSavePayload = (initial, current) => {
|
|
651
|
+
const patch = diffSettings(initial, current);
|
|
652
|
+
for (const key of ["context_whitelist", "context_blacklist"]) {
|
|
653
|
+
if (key in patch) patch[key] = parseList(patch[key]);
|
|
654
|
+
}
|
|
655
|
+
for (const key of ["frontend_url", "confirmationUrl", "from_email", "response_email"]) {
|
|
656
|
+
if (typeof patch[key] === "string") patch[key] = patch[key].trim();
|
|
657
|
+
}
|
|
658
|
+
for (const key of Object.keys(patch)) {
|
|
659
|
+
if (REMOVED_KEY_PATTERN.test(key) || REMOVED_KEYS.has(key) || patch[key] === void 0) {
|
|
660
|
+
delete patch[key];
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return patch;
|
|
664
|
+
};
|
|
665
|
+
const getServerErrorField = (error) => {
|
|
666
|
+
const message = error?.response?.data?.error?.message ?? error?.response?.data?.message ?? error?.message ?? "";
|
|
667
|
+
const match = /at '([A-Za-z0-9_]+)(?:\.[^']*)?'/.exec(String(message));
|
|
668
|
+
return match && match[1] !== "(root)" ? match[1] : null;
|
|
669
|
+
};
|
|
670
|
+
const FIELD_LIST_ERRORS = Object.freeze({
|
|
671
|
+
"settings.security.forbidden": "securityForbidden",
|
|
672
|
+
"settings.linkOrigin.notAllowed": "linkOrigin",
|
|
673
|
+
"settings.mfa.noEnrolledUsers": "mfaNoEnrolled"
|
|
674
|
+
});
|
|
675
|
+
const getServerErrorFields = (error) => {
|
|
676
|
+
const details = error?.response?.data?.error?.details;
|
|
677
|
+
const code = FIELD_LIST_ERRORS[details?.key];
|
|
678
|
+
if (!code) return null;
|
|
679
|
+
const list = Array.isArray(details.fields) ? details.fields : [];
|
|
680
|
+
const fields = list.filter(
|
|
681
|
+
(field) => typeof field === "string" && Object.hasOwn(FIELD_SECTIONS, field)
|
|
682
|
+
);
|
|
683
|
+
return { code, fields };
|
|
684
|
+
};
|
|
685
|
+
const MFA_MODES = /* @__PURE__ */ new Set(["disabled", "optional", "required"]);
|
|
686
|
+
const resolveMfaMode = (settings = {}) => {
|
|
687
|
+
if (settings.mfa_require_totp === true) return "required";
|
|
688
|
+
return MFA_MODES.has(settings.mfa_mode) ? settings.mfa_mode : "disabled";
|
|
689
|
+
};
|
|
690
|
+
const getAuthMode = (settings = {}) => {
|
|
691
|
+
if (resolveMfaMode(settings) !== "disabled") return "totp";
|
|
692
|
+
if (settings.otp_enabled === true) return "email-otp";
|
|
693
|
+
return "magic-link";
|
|
694
|
+
};
|
|
695
|
+
const AUTH_MODES = Object.freeze(["magic-link", "email-otp", "totp"]);
|
|
696
|
+
const applyAuthMode = (mode, settings = {}) => {
|
|
697
|
+
if (mode === "email-otp") {
|
|
698
|
+
return { otp_enabled: true, mfa_mode: "disabled", mfa_require_totp: false };
|
|
699
|
+
}
|
|
700
|
+
if (mode === "totp") {
|
|
701
|
+
const current = resolveMfaMode(settings);
|
|
702
|
+
const policy = current === "disabled" ? "optional" : current;
|
|
703
|
+
return { otp_enabled: false, mfa_mode: policy, mfa_require_totp: policy === "required" };
|
|
704
|
+
}
|
|
705
|
+
return { otp_enabled: false, mfa_mode: "disabled", mfa_require_totp: false };
|
|
706
|
+
};
|
|
707
|
+
const applyTotpPolicy = (policy) => policy === "required" ? { mfa_mode: "required", mfa_require_totp: true } : { mfa_mode: "optional", mfa_require_totp: false };
|
|
708
|
+
const parseEnrollmentSummary = (payload) => {
|
|
709
|
+
const data = payload?.data && typeof payload.data === "object" ? payload.data : payload;
|
|
710
|
+
const enrolled = Number(data?.enrolled);
|
|
711
|
+
return Number.isInteger(enrolled) && enrolled >= 0 ? { enrolled } : null;
|
|
712
|
+
};
|
|
713
|
+
const canRequireTotp = (summary) => {
|
|
714
|
+
if (!summary) return { allowed: true, reason: "unknown" };
|
|
715
|
+
if (summary.enrolled === 0) return { allowed: false, reason: "none-enrolled" };
|
|
716
|
+
return { allowed: true, reason: "some-enrolled" };
|
|
717
|
+
};
|
|
718
|
+
const joinUrl = (base, path) => {
|
|
719
|
+
const cleanBase = String(base || "").replace(/\/+$/, "");
|
|
720
|
+
const cleanPath = String(path || "").trim();
|
|
721
|
+
if (!cleanPath) return cleanBase;
|
|
722
|
+
return `${cleanBase}/${cleanPath.replace(/^\/+/, "")}`;
|
|
723
|
+
};
|
|
724
|
+
const buildLoginUrlPreview = (settings = {}, origin = "http://localhost:1337") => {
|
|
725
|
+
const sample = "loginToken=EXAMPLE";
|
|
726
|
+
const withToken = (base) => {
|
|
727
|
+
try {
|
|
728
|
+
const url = new URL(base);
|
|
729
|
+
url.search = url.search ? `${url.search.slice(1)}&${sample}` : sample;
|
|
730
|
+
return url.toString();
|
|
731
|
+
} catch {
|
|
732
|
+
return `${base}?${sample}`;
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
const frontend = typeof settings.frontend_url === "string" ? settings.frontend_url.trim() : "";
|
|
736
|
+
if (frontend && isValidLinkUrl(frontend)) {
|
|
737
|
+
return {
|
|
738
|
+
url: withToken(joinUrl(frontend, settings.frontend_login_path || "")),
|
|
739
|
+
source: "frontend"
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
const confirmation = typeof settings.confirmationUrl === "string" ? settings.confirmationUrl.trim() : "";
|
|
743
|
+
if (confirmation && isValidLinkUrl(confirmation)) {
|
|
744
|
+
return { url: withToken(confirmation), source: "confirmation" };
|
|
745
|
+
}
|
|
746
|
+
return { url: withToken(joinUrl(joinUrl(origin, "/api"), "/magic-link/login")), source: "api" };
|
|
747
|
+
};
|
|
748
|
+
const HTML_ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
749
|
+
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
|
|
750
|
+
const buildDefaultTemplates = (copy) => {
|
|
751
|
+
const html = `<!DOCTYPE html>
|
|
752
|
+
<html>
|
|
753
|
+
<head>
|
|
754
|
+
<meta charset="utf-8">
|
|
755
|
+
</head>
|
|
756
|
+
<body style="font-family: Arial, sans-serif; padding: 24px; color: #1F2937;">
|
|
757
|
+
<h2>${escapeHtml(copy.heading)}</h2>
|
|
758
|
+
<p>${escapeHtml(copy.intro)}</p>
|
|
759
|
+
<p><a href="{{LOGIN_URL}}">${escapeHtml(copy.button)}</a></p>
|
|
760
|
+
<p style="color: #4B5563; font-size: 14px;">${escapeHtml(copy.expiry)}</p>
|
|
761
|
+
<p style="color: #4B5563; font-size: 14px;">${escapeHtml(copy.ignore)}</p>
|
|
762
|
+
</body>
|
|
763
|
+
</html>`;
|
|
764
|
+
const text = `${copy.heading}
|
|
765
|
+
|
|
766
|
+
${copy.intro}
|
|
767
|
+
|
|
768
|
+
{{LOGIN_URL}}
|
|
769
|
+
|
|
770
|
+
${copy.expiry}
|
|
771
|
+
|
|
772
|
+
${copy.ignore}`;
|
|
773
|
+
return { html, text };
|
|
774
|
+
};
|
|
775
|
+
const isCustomTemplate = (current, known, normalize = (value) => value) => {
|
|
776
|
+
const value = normalize(String(current ?? "")).trim();
|
|
777
|
+
if (value === "") return false;
|
|
778
|
+
return !known.some((template) => normalize(String(template ?? "")).trim() === value);
|
|
779
|
+
};
|
|
780
|
+
const templateStats = (value) => {
|
|
781
|
+
const text = String(value ?? "");
|
|
782
|
+
return { lines: text === "" ? 0 : text.split("\n").length, chars: text.length };
|
|
783
|
+
};
|
|
784
|
+
const fieldId = (key) => `magic-link-settings-${String(key).replace(/[^A-Za-z0-9_-]/g, "-")}`;
|
|
785
|
+
const SectionBody = styled.div`
|
|
786
|
+
display: flex;
|
|
787
|
+
flex-direction: column;
|
|
788
|
+
gap: ${space(6)};
|
|
789
|
+
padding: ${space(6)};
|
|
790
|
+
|
|
791
|
+
@media (max-width: 480px) {
|
|
792
|
+
padding: ${space(4)};
|
|
793
|
+
}
|
|
794
|
+
`;
|
|
795
|
+
const Panel = styled.div`
|
|
796
|
+
display: flex;
|
|
797
|
+
flex-direction: column;
|
|
798
|
+
gap: ${space(4)};
|
|
799
|
+
padding: ${space(6)};
|
|
800
|
+
border-radius: ${theme.borderRadius.md};
|
|
801
|
+
background: ${color("neutral100")};
|
|
802
|
+
border: 1px solid ${color("neutral150")};
|
|
803
|
+
|
|
804
|
+
@media (max-width: 480px) {
|
|
805
|
+
padding: ${space(4)};
|
|
806
|
+
}
|
|
807
|
+
`;
|
|
808
|
+
const Card = styled.div`
|
|
809
|
+
display: flex;
|
|
810
|
+
flex-direction: column;
|
|
811
|
+
gap: ${space(4)};
|
|
812
|
+
min-width: 0;
|
|
813
|
+
padding: ${space(6)};
|
|
814
|
+
border-radius: ${theme.borderRadius.lg};
|
|
815
|
+
background: ${color("neutral0")};
|
|
816
|
+
border: 1px solid ${color("neutral200")};
|
|
817
|
+
|
|
818
|
+
@media (max-width: 480px) {
|
|
819
|
+
padding: ${space(4)};
|
|
820
|
+
}
|
|
821
|
+
`;
|
|
822
|
+
const CardGrid = styled.div`
|
|
823
|
+
display: grid;
|
|
824
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, ${(p) => p.$min ?? "220px"}), 1fr));
|
|
825
|
+
gap: ${(p) => p.$gap === 6 ? space(6) : space(4)};
|
|
826
|
+
align-items: stretch;
|
|
827
|
+
`;
|
|
828
|
+
const FieldGrid = styled.div`
|
|
829
|
+
display: grid;
|
|
830
|
+
grid-template-columns: repeat(${(p) => p.$columns ?? 2}, minmax(0, 1fr));
|
|
831
|
+
gap: ${space(6)};
|
|
832
|
+
align-items: start;
|
|
833
|
+
|
|
834
|
+
@media (max-width: 1024px) {
|
|
835
|
+
grid-template-columns: repeat(${(p) => Math.min(p.$columns ?? 2, 2)}, minmax(0, 1fr));
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
@media (max-width: 768px) {
|
|
839
|
+
grid-template-columns: minmax(0, 1fr);
|
|
840
|
+
}
|
|
841
|
+
`;
|
|
842
|
+
const GroupTitle = ({ title, subtitle, align = "left", id }) => /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 1, alignItems: align === "center" ? "center" : "flex-start", children: [
|
|
843
|
+
/* @__PURE__ */ jsx(
|
|
844
|
+
SubsectionHeading,
|
|
845
|
+
{
|
|
846
|
+
tag: "h4",
|
|
847
|
+
id,
|
|
848
|
+
variant: "sigma",
|
|
849
|
+
textColor: "neutral700",
|
|
850
|
+
style: align === "center" ? { textAlign: "center" } : void 0,
|
|
851
|
+
children: title
|
|
852
|
+
}
|
|
853
|
+
),
|
|
854
|
+
subtitle ? /* @__PURE__ */ jsx(
|
|
855
|
+
Typography,
|
|
856
|
+
{
|
|
857
|
+
variant: "pi",
|
|
858
|
+
textColor: "neutral600",
|
|
859
|
+
style: align === "center" ? { textAlign: "center" } : void 0,
|
|
860
|
+
children: subtitle
|
|
861
|
+
}
|
|
862
|
+
) : null
|
|
863
|
+
] });
|
|
864
|
+
const NoteBox = styled.div`
|
|
865
|
+
display: flex;
|
|
866
|
+
gap: ${space(2)};
|
|
867
|
+
align-items: flex-start;
|
|
868
|
+
padding: ${(p) => p.$compact ? `${space(2)(p)} ${space(3)(p)}` : space(4)(p)};
|
|
869
|
+
border-radius: ${theme.borderRadius.md};
|
|
870
|
+
background: ${(p) => tint[p.$tone].bg(p)};
|
|
871
|
+
border: 1px solid ${(p) => tint[p.$tone].border(p)};
|
|
872
|
+
color: ${(p) => tint[p.$tone].fg(p)};
|
|
873
|
+
font-size: ${fontSize(1)};
|
|
874
|
+
line-height: 1.5;
|
|
875
|
+
min-width: 0;
|
|
876
|
+
overflow-wrap: anywhere;
|
|
877
|
+
|
|
878
|
+
> svg {
|
|
879
|
+
flex-shrink: 0;
|
|
880
|
+
width: 16px;
|
|
881
|
+
height: 16px;
|
|
882
|
+
margin-top: 2px;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
> svg path {
|
|
886
|
+
fill: currentColor;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
strong {
|
|
890
|
+
font-weight: 600;
|
|
891
|
+
}
|
|
892
|
+
`;
|
|
893
|
+
const Note = ({ tone = "primary", icon: Icon, title, children, compact = false, role }) => /* @__PURE__ */ jsxs(NoteBox, { $tone: tone, $compact: compact, role, children: [
|
|
894
|
+
Icon ? /* @__PURE__ */ jsx(Icon, { "aria-hidden": "true", focusable: "false" }) : null,
|
|
895
|
+
/* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 1, alignItems: "flex-start", style: { minWidth: 0 }, children: [
|
|
896
|
+
title ? /* @__PURE__ */ jsx(Typography, { variant: "pi", fontWeight: "bold", style: { color: "inherit" }, children: title }) : null,
|
|
897
|
+
children ? /* @__PURE__ */ jsx(Typography, { variant: "pi", style: { color: "inherit" }, tag: "div", children }) : null
|
|
898
|
+
] })
|
|
899
|
+
] });
|
|
900
|
+
const BulletList = styled.ul`
|
|
901
|
+
margin: 0;
|
|
902
|
+
padding-left: ${space(4)};
|
|
903
|
+
list-style: disc;
|
|
904
|
+
|
|
905
|
+
li + li {
|
|
906
|
+
margin-top: ${space(1)};
|
|
907
|
+
}
|
|
908
|
+
`;
|
|
909
|
+
const StepList = styled.ol`
|
|
910
|
+
margin: 0;
|
|
911
|
+
padding-left: ${space(4)};
|
|
912
|
+
list-style: decimal;
|
|
913
|
+
|
|
914
|
+
li + li {
|
|
915
|
+
margin-top: ${space(1)};
|
|
916
|
+
}
|
|
917
|
+
`;
|
|
918
|
+
const Mono = styled.code`
|
|
919
|
+
font-family: 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace;
|
|
920
|
+
font-size: ${fontSize(1)};
|
|
921
|
+
overflow-wrap: anywhere;
|
|
922
|
+
word-break: break-all;
|
|
923
|
+
`;
|
|
924
|
+
const SettingField = ({ name, label, hint, error, required = false, children }) => /* @__PURE__ */ jsxs(Field.Root, { name, id: fieldId(name), hint, error, required, children: [
|
|
925
|
+
/* @__PURE__ */ jsx(Field.Label, { children: label }),
|
|
926
|
+
children,
|
|
927
|
+
/* @__PURE__ */ jsx(Field.Hint, {}),
|
|
928
|
+
/* @__PURE__ */ jsx(Field.Error, {})
|
|
929
|
+
] });
|
|
930
|
+
const ToggleCardLabel = styled.label`
|
|
931
|
+
position: relative;
|
|
932
|
+
display: flex;
|
|
933
|
+
flex-direction: column;
|
|
934
|
+
align-items: center;
|
|
935
|
+
gap: ${space(3)};
|
|
936
|
+
height: 100%;
|
|
937
|
+
min-width: 0;
|
|
938
|
+
padding: ${space(6)} ${space(4)} ${space(4)};
|
|
939
|
+
border-radius: ${theme.borderRadius.md};
|
|
940
|
+
text-align: center;
|
|
941
|
+
user-select: none;
|
|
942
|
+
cursor: ${(p) => p.$disabled ? "not-allowed" : "pointer"};
|
|
943
|
+
background: ${(p) => p.$active ? "rgba(22, 163, 74, 0.08)" : "rgba(128, 128, 128, 0.06)"};
|
|
944
|
+
border: 2px solid ${(p) => p.$active ? "rgba(22, 163, 74, 0.4)" : color("neutral200")(p)};
|
|
945
|
+
box-shadow: ${(p) => p.$active ? "0 4px 12px rgba(34, 197, 94, 0.15)" : theme.shadows.sm};
|
|
946
|
+
transition:
|
|
947
|
+
border-color ${theme.transitions.fast},
|
|
948
|
+
box-shadow ${theme.transitions.fast},
|
|
949
|
+
transform ${theme.transitions.fast};
|
|
950
|
+
|
|
951
|
+
${(p) => !p.$disabled && css`
|
|
952
|
+
&:hover {
|
|
953
|
+
transform: translateY(-2px);
|
|
954
|
+
border-color: ${p.$active ? "rgba(22, 163, 74, 0.6)" : color("neutral300")(p)};
|
|
955
|
+
}
|
|
956
|
+
`}
|
|
957
|
+
|
|
958
|
+
&:focus-within {
|
|
959
|
+
border-color: ${byScheme(brand.primary, "#7DD3FC")};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
${reducedMotion}
|
|
963
|
+
`;
|
|
964
|
+
const StatusPill = styled.span`
|
|
965
|
+
position: absolute;
|
|
966
|
+
top: ${space(2)};
|
|
967
|
+
right: ${space(2)};
|
|
968
|
+
padding: 2px ${space(2)};
|
|
969
|
+
border-radius: ${theme.borderRadius.sm};
|
|
970
|
+
font-size: ${fontSize(0)};
|
|
971
|
+
font-weight: 700;
|
|
972
|
+
letter-spacing: 0.04em;
|
|
973
|
+
text-transform: uppercase;
|
|
974
|
+
line-height: 1.6;
|
|
975
|
+
background: ${(p) => p.$active ? brand.success : color("neutral150")(p)};
|
|
976
|
+
color: ${(p) => p.$active ? brand.onFill : color("neutral700")(p)};
|
|
977
|
+
`;
|
|
978
|
+
const ToggleCard = ({
|
|
979
|
+
title,
|
|
980
|
+
description,
|
|
981
|
+
checked,
|
|
982
|
+
onCheckedChange,
|
|
983
|
+
disabled = false,
|
|
984
|
+
statusOn,
|
|
985
|
+
statusOff,
|
|
986
|
+
children
|
|
987
|
+
}) => {
|
|
988
|
+
const id = useId();
|
|
989
|
+
const switchId = `${id}-switch`;
|
|
990
|
+
const titleId = `${id}-title`;
|
|
991
|
+
const descriptionId = `${id}-description`;
|
|
992
|
+
const isOn = Boolean(checked);
|
|
993
|
+
return /* @__PURE__ */ jsxs(ToggleCardLabel, { htmlFor: switchId, $active: isOn, $disabled: disabled, children: [
|
|
994
|
+
/* @__PURE__ */ jsx(StatusPill, { $active: isOn, "aria-hidden": "true", children: isOn ? statusOn : statusOff }),
|
|
995
|
+
/* @__PURE__ */ jsx(
|
|
996
|
+
Switch,
|
|
997
|
+
{
|
|
998
|
+
id: switchId,
|
|
999
|
+
checked: isOn,
|
|
1000
|
+
disabled,
|
|
1001
|
+
onCheckedChange: (value) => onCheckedChange(Boolean(value)),
|
|
1002
|
+
"aria-labelledby": titleId,
|
|
1003
|
+
"aria-describedby": description ? descriptionId : void 0
|
|
1004
|
+
}
|
|
1005
|
+
),
|
|
1006
|
+
/* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 1, alignItems: "center", children: [
|
|
1007
|
+
/* @__PURE__ */ jsx(Typography, { id: titleId, variant: "omega", fontWeight: "bold", textColor: "neutral800", children: title }),
|
|
1008
|
+
description ? /* @__PURE__ */ jsx(Typography, { id: descriptionId, variant: "pi", textColor: "neutral600", children: description }) : null
|
|
1009
|
+
] }),
|
|
1010
|
+
children
|
|
1011
|
+
] });
|
|
1012
|
+
};
|
|
1013
|
+
const SwitchRow = ({
|
|
1014
|
+
title,
|
|
1015
|
+
description,
|
|
1016
|
+
checked,
|
|
1017
|
+
onCheckedChange,
|
|
1018
|
+
disabled = false,
|
|
1019
|
+
icon
|
|
1020
|
+
}) => {
|
|
1021
|
+
const id = useId();
|
|
1022
|
+
return /* @__PURE__ */ jsxs(Flex, { gap: 4, alignItems: "flex-start", justifyContent: "space-between", children: [
|
|
1023
|
+
/* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 1, alignItems: "flex-start", style: { minWidth: 0, flex: 1 }, children: [
|
|
1024
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "center", children: [
|
|
1025
|
+
icon,
|
|
1026
|
+
/* @__PURE__ */ jsx(
|
|
1027
|
+
Typography,
|
|
1028
|
+
{
|
|
1029
|
+
tag: "label",
|
|
1030
|
+
htmlFor: `${id}-switch`,
|
|
1031
|
+
id: `${id}-title`,
|
|
1032
|
+
variant: "omega",
|
|
1033
|
+
fontWeight: "bold",
|
|
1034
|
+
textColor: "neutral800",
|
|
1035
|
+
children: title
|
|
1036
|
+
}
|
|
1037
|
+
)
|
|
1038
|
+
] }),
|
|
1039
|
+
description ? /* @__PURE__ */ jsx(Typography, { id: `${id}-description`, variant: "pi", textColor: "neutral600", children: description }) : null
|
|
1040
|
+
] }),
|
|
1041
|
+
/* @__PURE__ */ jsx(
|
|
1042
|
+
Switch,
|
|
1043
|
+
{
|
|
1044
|
+
id: `${id}-switch`,
|
|
1045
|
+
checked: Boolean(checked),
|
|
1046
|
+
disabled,
|
|
1047
|
+
onCheckedChange: (value) => onCheckedChange(Boolean(value)),
|
|
1048
|
+
"aria-labelledby": `${id}-title`,
|
|
1049
|
+
"aria-describedby": description ? `${id}-description` : void 0
|
|
1050
|
+
}
|
|
1051
|
+
)
|
|
1052
|
+
] });
|
|
1053
|
+
};
|
|
1054
|
+
const EditorFrame = styled.div`
|
|
1055
|
+
display: flex;
|
|
1056
|
+
flex-direction: column;
|
|
1057
|
+
min-width: 0;
|
|
1058
|
+
border-radius: ${theme.borderRadius.md};
|
|
1059
|
+
border: 1px solid ${color("neutral200")};
|
|
1060
|
+
overflow: hidden;
|
|
1061
|
+
`;
|
|
1062
|
+
const EditorBar = styled.div`
|
|
1063
|
+
padding: ${space(2)} ${space(3)};
|
|
1064
|
+
font-family: 'SFMono-Regular', Menlo, Consolas, monospace;
|
|
1065
|
+
font-size: ${fontSize(1)};
|
|
1066
|
+
background: ${(p) => p.$dark ? "#2D2D2D" : color("neutral150")(p)};
|
|
1067
|
+
color: ${(p) => p.$dark ? "#C8C8C8" : color("neutral700")(p)};
|
|
1068
|
+
border-bottom: 1px solid ${(p) => p.$dark ? "#3C3C3C" : color("neutral200")(p)};
|
|
1069
|
+
`;
|
|
1070
|
+
const EditorTextarea = styled.textarea`
|
|
1071
|
+
display: block;
|
|
1072
|
+
width: 100%;
|
|
1073
|
+
min-height: ${(p) => p.$height};
|
|
1074
|
+
height: ${(p) => p.$height};
|
|
1075
|
+
margin: 0;
|
|
1076
|
+
padding: ${space(4)};
|
|
1077
|
+
border: none;
|
|
1078
|
+
resize: vertical;
|
|
1079
|
+
box-sizing: border-box;
|
|
1080
|
+
font-family: 'SFMono-Regular', Menlo, Consolas, 'Courier New', monospace;
|
|
1081
|
+
font-size: ${fontSize(2)};
|
|
1082
|
+
line-height: 1.7;
|
|
1083
|
+
/* The HTML editor keeps its dark code-editor look in both schemes. */
|
|
1084
|
+
background: ${(p) => p.$dark ? "#1E1E1E" : color("neutral100")(p)};
|
|
1085
|
+
color: ${(p) => p.$dark ? "#D4D4D4" : color("neutral800")(p)};
|
|
1086
|
+
|
|
1087
|
+
&:focus-visible {
|
|
1088
|
+
outline: 2px solid ${byScheme(brand.primary, "#7DD3FC")};
|
|
1089
|
+
outline-offset: -2px;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
&:disabled {
|
|
1093
|
+
cursor: not-allowed;
|
|
1094
|
+
opacity: 0.8;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
@media (max-width: 768px) {
|
|
1098
|
+
height: ${(p) => p.$mobileHeight ?? p.$height};
|
|
1099
|
+
min-height: ${(p) => p.$mobileHeight ?? p.$height};
|
|
1100
|
+
}
|
|
1101
|
+
`;
|
|
1102
|
+
const CodeEditor = ({
|
|
1103
|
+
id,
|
|
1104
|
+
labelledBy,
|
|
1105
|
+
describedBy,
|
|
1106
|
+
filename,
|
|
1107
|
+
value,
|
|
1108
|
+
onChange,
|
|
1109
|
+
placeholder,
|
|
1110
|
+
disabled = false,
|
|
1111
|
+
dark = false,
|
|
1112
|
+
height = "480px",
|
|
1113
|
+
mobileHeight = "320px"
|
|
1114
|
+
}) => /* @__PURE__ */ jsxs(EditorFrame, { children: [
|
|
1115
|
+
/* @__PURE__ */ jsx(EditorBar, { $dark: dark, "aria-hidden": "true", children: filename }),
|
|
1116
|
+
/* @__PURE__ */ jsx(
|
|
1117
|
+
EditorTextarea,
|
|
1118
|
+
{
|
|
1119
|
+
id,
|
|
1120
|
+
"aria-labelledby": labelledBy,
|
|
1121
|
+
"aria-describedby": describedBy,
|
|
1122
|
+
value: value ?? "",
|
|
1123
|
+
onChange: (event) => onChange(event.target.value),
|
|
1124
|
+
placeholder,
|
|
1125
|
+
disabled,
|
|
1126
|
+
spellCheck: false,
|
|
1127
|
+
$dark: dark,
|
|
1128
|
+
$height: height,
|
|
1129
|
+
$mobileHeight: mobileHeight
|
|
1130
|
+
}
|
|
1131
|
+
)
|
|
1132
|
+
] });
|
|
1133
|
+
const Tile = styled.button`
|
|
1134
|
+
display: flex;
|
|
1135
|
+
flex-direction: column;
|
|
1136
|
+
align-items: flex-start;
|
|
1137
|
+
gap: ${space(2)};
|
|
1138
|
+
/* A button shrinks to its content by default; fill the grid cell like a card. */
|
|
1139
|
+
width: 100%;
|
|
1140
|
+
height: 100%;
|
|
1141
|
+
min-width: 0;
|
|
1142
|
+
padding: ${space(4)};
|
|
1143
|
+
border-radius: ${theme.borderRadius.md};
|
|
1144
|
+
text-align: left;
|
|
1145
|
+
font: inherit;
|
|
1146
|
+
cursor: pointer;
|
|
1147
|
+
background: ${(p) => p.$active ? "rgba(14, 165, 233, 0.08)" : color("neutral0")(p)};
|
|
1148
|
+
border: 2px solid
|
|
1149
|
+
${(p) => p.$active ? byScheme("rgba(2, 132, 199, 0.6)", "#38BDF8")(p) : color("neutral200")(p)};
|
|
1150
|
+
box-shadow: ${(p) => p.$active ? "0 8px 20px rgba(2, 132, 199, 0.2)" : theme.shadows.sm};
|
|
1151
|
+
transition:
|
|
1152
|
+
border-color ${theme.transitions.fast},
|
|
1153
|
+
box-shadow ${theme.transitions.fast},
|
|
1154
|
+
transform ${theme.transitions.fast};
|
|
1155
|
+
|
|
1156
|
+
&:hover:not(:disabled) {
|
|
1157
|
+
transform: translateY(-2px);
|
|
1158
|
+
border-color: ${byScheme("rgba(2, 132, 199, 0.6)", "#38BDF8")};
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
&:focus-visible {
|
|
1162
|
+
outline: 2px solid ${byScheme(brand.primary, "#7DD3FC")};
|
|
1163
|
+
outline-offset: 2px;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
&:disabled {
|
|
1167
|
+
cursor: not-allowed;
|
|
1168
|
+
opacity: 0.6;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
${reducedMotion}
|
|
1172
|
+
`;
|
|
1173
|
+
const StatBox = styled.div`
|
|
1174
|
+
display: flex;
|
|
1175
|
+
flex-direction: column;
|
|
1176
|
+
align-items: center;
|
|
1177
|
+
justify-content: center;
|
|
1178
|
+
gap: ${space(1)};
|
|
1179
|
+
height: 100%;
|
|
1180
|
+
padding: ${space(4)};
|
|
1181
|
+
border-radius: ${theme.borderRadius.md};
|
|
1182
|
+
text-align: center;
|
|
1183
|
+
background: ${(p) => p.$tone === "neutral" ? color("neutral100")(p) : tint[p.$tone].bg(p)};
|
|
1184
|
+
border: 1px solid
|
|
1185
|
+
${(p) => p.$tone === "neutral" ? color("neutral200")(p) : tint[p.$tone].border(p)};
|
|
1186
|
+
`;
|
|
1187
|
+
const StatValue = styled.span`
|
|
1188
|
+
font-size: ${fontSize(5)};
|
|
1189
|
+
font-weight: 700;
|
|
1190
|
+
line-height: 1.2;
|
|
1191
|
+
color: ${(p) => p.$tone === "neutral" ? color("neutral800")(p) : tint[p.$tone].fg(p)};
|
|
1192
|
+
`;
|
|
1193
|
+
const Stat = ({ value, label, tone = "neutral" }) => /* @__PURE__ */ jsxs(StatBox, { $tone: tone, children: [
|
|
1194
|
+
/* @__PURE__ */ jsx(StatValue, { $tone: tone, children: value }),
|
|
1195
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: label })
|
|
1196
|
+
] });
|
|
1197
|
+
const IconBadge = styled.span`
|
|
1198
|
+
display: inline-flex;
|
|
1199
|
+
align-items: center;
|
|
1200
|
+
justify-content: center;
|
|
1201
|
+
width: 48px;
|
|
1202
|
+
height: 48px;
|
|
1203
|
+
flex-shrink: 0;
|
|
1204
|
+
border-radius: ${theme.borderRadius.lg};
|
|
1205
|
+
background: ${(p) => typeof p.$background === "function" ? p.$background(p) : p.$background};
|
|
1206
|
+
|
|
1207
|
+
svg {
|
|
1208
|
+
width: 24px;
|
|
1209
|
+
height: 24px;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
svg path {
|
|
1213
|
+
fill: ${(p) => typeof p.$color === "function" ? p.$color(p) : p.$color};
|
|
1214
|
+
}
|
|
1215
|
+
`;
|
|
1216
|
+
const FOLLOW_ADMIN = "admin";
|
|
1217
|
+
const LANGUAGE_LABELS = {
|
|
1218
|
+
en: "English",
|
|
1219
|
+
de: "Deutsch",
|
|
1220
|
+
fr: "Français",
|
|
1221
|
+
es: "Español",
|
|
1222
|
+
pt: "Português"
|
|
1223
|
+
};
|
|
1224
|
+
const GeneralSection = ({ settings, update, disabled, errorFor, statusOn, statusOff }) => {
|
|
1225
|
+
const { t } = useMagicLinkIntl();
|
|
1226
|
+
const { language, isFollowingAdmin, changeLanguage, languages } = usePluginLanguage();
|
|
1227
|
+
const origin = typeof window === "undefined" ? void 0 : window.location.origin;
|
|
1228
|
+
const preview = buildLoginUrlPreview(settings, origin);
|
|
1229
|
+
const previewSource = {
|
|
1230
|
+
frontend: t(
|
|
1231
|
+
"settings.links.preview.source.frontend",
|
|
1232
|
+
"Built from the frontend URL and login path."
|
|
1233
|
+
),
|
|
1234
|
+
confirmation: t(
|
|
1235
|
+
"settings.links.preview.source.confirmation",
|
|
1236
|
+
"Built from the confirmation URL."
|
|
1237
|
+
),
|
|
1238
|
+
api: t(
|
|
1239
|
+
"settings.links.preview.source.api",
|
|
1240
|
+
"No valid frontend or confirmation URL is set, so the link opens the Strapi API login route."
|
|
1241
|
+
)
|
|
1242
|
+
}[preview.source];
|
|
1243
|
+
return /* @__PURE__ */ jsxs(SectionBody, { children: [
|
|
1244
|
+
/* @__PURE__ */ jsx(Panel, { children: /* @__PURE__ */ jsx(FieldGrid, { $columns: 2, children: /* @__PURE__ */ jsx(
|
|
1245
|
+
SettingField,
|
|
1246
|
+
{
|
|
1247
|
+
name: "ui_language",
|
|
1248
|
+
label: t("settings.language.label", "Interface Language"),
|
|
1249
|
+
hint: t(
|
|
1250
|
+
"settings.language.hint",
|
|
1251
|
+
"Language of the Magic Link admin pages in this browser. It applies immediately."
|
|
1252
|
+
),
|
|
1253
|
+
children: /* @__PURE__ */ jsxs(
|
|
1254
|
+
SingleSelect,
|
|
1255
|
+
{
|
|
1256
|
+
value: isFollowingAdmin ? FOLLOW_ADMIN : language,
|
|
1257
|
+
onChange: (value) => changeLanguage(value === FOLLOW_ADMIN ? null : value),
|
|
1258
|
+
children: [
|
|
1259
|
+
/* @__PURE__ */ jsx(SingleSelectOption, { value: FOLLOW_ADMIN, children: t("common.language.followAdmin", "Same as admin interface") }),
|
|
1260
|
+
languages.map((code) => /* @__PURE__ */ jsx(SingleSelectOption, { value: code, children: LANGUAGE_LABELS[code] ?? code }, code))
|
|
1261
|
+
]
|
|
1262
|
+
}
|
|
1263
|
+
)
|
|
1264
|
+
}
|
|
1265
|
+
) }) }),
|
|
1266
|
+
/* @__PURE__ */ jsxs(Panel, { children: [
|
|
1267
|
+
/* @__PURE__ */ jsx(
|
|
1268
|
+
GroupTitle,
|
|
1269
|
+
{
|
|
1270
|
+
align: "center",
|
|
1271
|
+
title: t("settings.features.title", "MAIN FEATURES"),
|
|
1272
|
+
subtitle: t(
|
|
1273
|
+
"settings.features.subtitle",
|
|
1274
|
+
"Enable or disable the main features of the Magic Link plugin"
|
|
1275
|
+
)
|
|
1276
|
+
}
|
|
1277
|
+
),
|
|
1278
|
+
/* @__PURE__ */ jsxs(CardGrid, { $min: "240px", children: [
|
|
1279
|
+
/* @__PURE__ */ jsx(
|
|
1280
|
+
ToggleCard,
|
|
1281
|
+
{
|
|
1282
|
+
title: t("settings.feature.createUser.title", "Automatically Create Users"),
|
|
1283
|
+
description: t(
|
|
1284
|
+
"settings.feature.createUser.description",
|
|
1285
|
+
"Creates an account when the email address is unknown. Follows the Users & Permissions registration and email confirmation settings."
|
|
1286
|
+
),
|
|
1287
|
+
checked: settings.createUserIfNotExists,
|
|
1288
|
+
onCheckedChange: (checked) => update("createUserIfNotExists", checked),
|
|
1289
|
+
disabled,
|
|
1290
|
+
statusOn,
|
|
1291
|
+
statusOff
|
|
1292
|
+
}
|
|
1293
|
+
),
|
|
1294
|
+
/* @__PURE__ */ jsx(
|
|
1295
|
+
ToggleCard,
|
|
1296
|
+
{
|
|
1297
|
+
title: t("settings.feature.staysValid.title", "Reusable links"),
|
|
1298
|
+
description: t(
|
|
1299
|
+
"settings.feature.staysValid.description",
|
|
1300
|
+
"A link can be used more than once until it expires. Less secure; keep it off unless users need to open the link on several devices."
|
|
1301
|
+
),
|
|
1302
|
+
checked: settings.stays_valid,
|
|
1303
|
+
onCheckedChange: (checked) => update("stays_valid", checked),
|
|
1304
|
+
disabled,
|
|
1305
|
+
statusOn,
|
|
1306
|
+
statusOff
|
|
1307
|
+
}
|
|
1308
|
+
)
|
|
1309
|
+
] })
|
|
1310
|
+
] }),
|
|
1311
|
+
/* @__PURE__ */ jsx(GroupTitle, { title: t("settings.time.title", "Link lifetime and length") }),
|
|
1312
|
+
/* @__PURE__ */ jsxs(FieldGrid, { $columns: 2, children: [
|
|
1313
|
+
/* @__PURE__ */ jsx(
|
|
1314
|
+
SettingField,
|
|
1315
|
+
{
|
|
1316
|
+
name: "expire_period",
|
|
1317
|
+
label: t("settings.expire.label", "Link lifetime (seconds)"),
|
|
1318
|
+
hint: t(
|
|
1319
|
+
"settings.expire.hint",
|
|
1320
|
+
"Recommended: 3600 seconds (1 hour). Allowed: 10 to 31,536,000."
|
|
1321
|
+
),
|
|
1322
|
+
error: errorFor("expire_period"),
|
|
1323
|
+
required: true,
|
|
1324
|
+
children: /* @__PURE__ */ jsx(
|
|
1325
|
+
NumberInput,
|
|
1326
|
+
{
|
|
1327
|
+
value: settings.expire_period,
|
|
1328
|
+
onValueChange: (value) => update("expire_period", value),
|
|
1329
|
+
placeholder: "3600",
|
|
1330
|
+
disabled,
|
|
1331
|
+
min: 10,
|
|
1332
|
+
step: 60
|
|
1333
|
+
}
|
|
1334
|
+
)
|
|
1335
|
+
}
|
|
1336
|
+
),
|
|
1337
|
+
/* @__PURE__ */ jsx(
|
|
1338
|
+
SettingField,
|
|
1339
|
+
{
|
|
1340
|
+
name: "token_length",
|
|
1341
|
+
label: t("settings.tokenLength.label", "Token length (characters)"),
|
|
1342
|
+
hint: t(
|
|
1343
|
+
"settings.tokenLength.hint",
|
|
1344
|
+
"Longer tokens are harder to guess. Allowed: 22 to 128. Current: {length} characters.",
|
|
1345
|
+
{ length: settings.token_length ?? "-" }
|
|
1346
|
+
),
|
|
1347
|
+
error: errorFor("token_length"),
|
|
1348
|
+
required: true,
|
|
1349
|
+
children: /* @__PURE__ */ jsx(
|
|
1350
|
+
NumberInput,
|
|
1351
|
+
{
|
|
1352
|
+
value: settings.token_length,
|
|
1353
|
+
onValueChange: (value) => update("token_length", value),
|
|
1354
|
+
placeholder: "32",
|
|
1355
|
+
disabled,
|
|
1356
|
+
min: 22,
|
|
1357
|
+
max: 128
|
|
1358
|
+
}
|
|
1359
|
+
)
|
|
1360
|
+
}
|
|
1361
|
+
)
|
|
1362
|
+
] }),
|
|
1363
|
+
/* @__PURE__ */ jsx(
|
|
1364
|
+
GroupTitle,
|
|
1365
|
+
{
|
|
1366
|
+
title: t("settings.context.title", "Context field policy"),
|
|
1367
|
+
subtitle: t(
|
|
1368
|
+
"settings.context.subtitle",
|
|
1369
|
+
"Applies to every login link, whether your frontend or an administrator creates it. Reserved fields such as userId or otpVerified are always removed."
|
|
1370
|
+
)
|
|
1371
|
+
}
|
|
1372
|
+
),
|
|
1373
|
+
/* @__PURE__ */ jsxs(FieldGrid, { $columns: 2, children: [
|
|
1374
|
+
/* @__PURE__ */ jsx(
|
|
1375
|
+
SettingField,
|
|
1376
|
+
{
|
|
1377
|
+
name: "context_whitelist",
|
|
1378
|
+
label: t("settings.context.whitelist.label", "Allowed fields"),
|
|
1379
|
+
hint: t(
|
|
1380
|
+
"settings.context.whitelist.hint",
|
|
1381
|
+
"Comma-separated. When set, only these fields are kept. Leave empty to allow all fields that are not blocked."
|
|
1382
|
+
),
|
|
1383
|
+
error: errorFor("context_whitelist"),
|
|
1384
|
+
children: /* @__PURE__ */ jsx(
|
|
1385
|
+
TextInput,
|
|
1386
|
+
{
|
|
1387
|
+
value: formatList(settings.context_whitelist),
|
|
1388
|
+
onChange: (event) => update("context_whitelist", event.target.value),
|
|
1389
|
+
onBlur: () => update("context_whitelist", parseList(settings.context_whitelist)),
|
|
1390
|
+
placeholder: "redirectTo, role, source",
|
|
1391
|
+
disabled
|
|
1392
|
+
}
|
|
1393
|
+
)
|
|
1394
|
+
}
|
|
1395
|
+
),
|
|
1396
|
+
/* @__PURE__ */ jsx(
|
|
1397
|
+
SettingField,
|
|
1398
|
+
{
|
|
1399
|
+
name: "context_blacklist",
|
|
1400
|
+
label: t("settings.context.blacklist.label", "Blocked fields"),
|
|
1401
|
+
hint: t(
|
|
1402
|
+
"settings.context.blacklist.hint",
|
|
1403
|
+
"Comma-separated. These fields are always removed, even when they are also allowed."
|
|
1404
|
+
),
|
|
1405
|
+
error: errorFor("context_blacklist"),
|
|
1406
|
+
children: /* @__PURE__ */ jsx(
|
|
1407
|
+
TextInput,
|
|
1408
|
+
{
|
|
1409
|
+
value: formatList(settings.context_blacklist),
|
|
1410
|
+
onChange: (event) => update("context_blacklist", event.target.value),
|
|
1411
|
+
onBlur: () => update("context_blacklist", parseList(settings.context_blacklist)),
|
|
1412
|
+
placeholder: "password, secret, apiKey, token",
|
|
1413
|
+
disabled
|
|
1414
|
+
}
|
|
1415
|
+
)
|
|
1416
|
+
}
|
|
1417
|
+
)
|
|
1418
|
+
] }),
|
|
1419
|
+
/* @__PURE__ */ jsx(
|
|
1420
|
+
GroupTitle,
|
|
1421
|
+
{
|
|
1422
|
+
title: t("settings.links.title", "Login link URLs"),
|
|
1423
|
+
subtitle: t(
|
|
1424
|
+
"settings.links.subtitle",
|
|
1425
|
+
"Use https. http is only accepted for localhost. The frontend URL wins over the confirmation URL."
|
|
1426
|
+
)
|
|
1427
|
+
}
|
|
1428
|
+
),
|
|
1429
|
+
/* @__PURE__ */ jsxs(FieldGrid, { $columns: 2, children: [
|
|
1430
|
+
/* @__PURE__ */ jsx(
|
|
1431
|
+
SettingField,
|
|
1432
|
+
{
|
|
1433
|
+
name: "frontend_url",
|
|
1434
|
+
label: t("settings.links.frontendUrl.label", "Frontend base URL"),
|
|
1435
|
+
hint: t(
|
|
1436
|
+
"settings.links.frontendUrl.hint",
|
|
1437
|
+
"Base URL of your frontend app (React, Vue, mobile deep link page). Leave empty to use the confirmation URL."
|
|
1438
|
+
),
|
|
1439
|
+
error: errorFor("frontend_url"),
|
|
1440
|
+
children: /* @__PURE__ */ jsx(
|
|
1441
|
+
TextInput,
|
|
1442
|
+
{
|
|
1443
|
+
type: "url",
|
|
1444
|
+
inputMode: "url",
|
|
1445
|
+
value: settings.frontend_url ?? "",
|
|
1446
|
+
onChange: (event) => update("frontend_url", event.target.value),
|
|
1447
|
+
placeholder: "https://app.example.com",
|
|
1448
|
+
disabled
|
|
1449
|
+
}
|
|
1450
|
+
)
|
|
1451
|
+
}
|
|
1452
|
+
),
|
|
1453
|
+
/* @__PURE__ */ jsx(
|
|
1454
|
+
SettingField,
|
|
1455
|
+
{
|
|
1456
|
+
name: "frontend_login_path",
|
|
1457
|
+
label: t("settings.links.frontendPath.label", "Frontend login path"),
|
|
1458
|
+
hint: t(
|
|
1459
|
+
"settings.links.frontendPath.hint",
|
|
1460
|
+
"Path of the page that receives the link, appended to the frontend URL. Starts with /."
|
|
1461
|
+
),
|
|
1462
|
+
error: errorFor("frontend_login_path"),
|
|
1463
|
+
children: /* @__PURE__ */ jsx(
|
|
1464
|
+
TextInput,
|
|
1465
|
+
{
|
|
1466
|
+
value: settings.frontend_login_path ?? "",
|
|
1467
|
+
onChange: (event) => update("frontend_login_path", event.target.value),
|
|
1468
|
+
placeholder: "/auth/magic-link",
|
|
1469
|
+
disabled
|
|
1470
|
+
}
|
|
1471
|
+
)
|
|
1472
|
+
}
|
|
1473
|
+
)
|
|
1474
|
+
] }),
|
|
1475
|
+
/* @__PURE__ */ jsx(
|
|
1476
|
+
SettingField,
|
|
1477
|
+
{
|
|
1478
|
+
name: "confirmationUrl",
|
|
1479
|
+
label: t("settings.confirmationUrl.label", "Confirmation URL"),
|
|
1480
|
+
hint: t(
|
|
1481
|
+
"settings.confirmationUrl.hint",
|
|
1482
|
+
"Full URL of the page that receives the link when no frontend URL is set."
|
|
1483
|
+
),
|
|
1484
|
+
error: errorFor("confirmationUrl"),
|
|
1485
|
+
children: /* @__PURE__ */ jsx(
|
|
1486
|
+
TextInput,
|
|
1487
|
+
{
|
|
1488
|
+
type: "url",
|
|
1489
|
+
inputMode: "url",
|
|
1490
|
+
value: settings.confirmationUrl ?? "",
|
|
1491
|
+
onChange: (event) => update("confirmationUrl", event.target.value),
|
|
1492
|
+
placeholder: "https://app.example.com/auth/magic-link",
|
|
1493
|
+
disabled
|
|
1494
|
+
}
|
|
1495
|
+
)
|
|
1496
|
+
}
|
|
1497
|
+
),
|
|
1498
|
+
/* @__PURE__ */ jsxs(
|
|
1499
|
+
Note,
|
|
1500
|
+
{
|
|
1501
|
+
tone: "primary",
|
|
1502
|
+
icon: Link,
|
|
1503
|
+
title: t("settings.links.preview.title", "Resulting login link"),
|
|
1504
|
+
children: [
|
|
1505
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", tag: "p", style: { color: "inherit", margin: 0 }, children: /* @__PURE__ */ jsx(Mono, { children: preview.url }) }),
|
|
1506
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", tag: "p", style: { color: "inherit", margin: 0 }, children: previewSource })
|
|
1507
|
+
]
|
|
1508
|
+
}
|
|
1509
|
+
)
|
|
1510
|
+
] });
|
|
1511
|
+
};
|
|
1512
|
+
const AuthSection = ({ settings, update, disabled, errorFor, statusOn, statusOff }) => {
|
|
1513
|
+
const { t } = useMagicLinkIntl();
|
|
1514
|
+
const toggles = [
|
|
1515
|
+
{
|
|
1516
|
+
key: "verify_email",
|
|
1517
|
+
title: t("settings.auth.verifyEmail.title", "Email Verification"),
|
|
1518
|
+
description: t(
|
|
1519
|
+
"settings.auth.verifyEmail.description",
|
|
1520
|
+
"Users must confirm their email address before they can sign in."
|
|
1521
|
+
)
|
|
1522
|
+
},
|
|
1523
|
+
{
|
|
1524
|
+
key: "welcome_email",
|
|
1525
|
+
title: t("settings.auth.welcomeEmail.title", "Welcome Email"),
|
|
1526
|
+
description: t(
|
|
1527
|
+
"settings.auth.welcomeEmail.description",
|
|
1528
|
+
"Sends a welcome email to newly created users."
|
|
1529
|
+
)
|
|
1530
|
+
},
|
|
1531
|
+
{
|
|
1532
|
+
key: "use_jwt_token",
|
|
1533
|
+
title: t("settings.auth.useJwt.title", "Issue a JWT"),
|
|
1534
|
+
description: t(
|
|
1535
|
+
"settings.auth.useJwt.description",
|
|
1536
|
+
"Returns a Users & Permissions JWT after login, like the native login. Recommended for SPAs and mobile apps."
|
|
1537
|
+
)
|
|
1538
|
+
},
|
|
1539
|
+
{
|
|
1540
|
+
key: "store_login_info",
|
|
1541
|
+
title: t("settings.auth.storeLoginInfo.title", "Store Login Data"),
|
|
1542
|
+
description: t(
|
|
1543
|
+
"settings.auth.storeLoginInfo.description",
|
|
1544
|
+
"Stores the IP address and browser of each login for security monitoring."
|
|
1545
|
+
)
|
|
1546
|
+
}
|
|
1547
|
+
];
|
|
1548
|
+
return /* @__PURE__ */ jsxs(SectionBody, { children: [
|
|
1549
|
+
/* @__PURE__ */ jsxs(Panel, { children: [
|
|
1550
|
+
/* @__PURE__ */ jsx(
|
|
1551
|
+
GroupTitle,
|
|
1552
|
+
{
|
|
1553
|
+
align: "center",
|
|
1554
|
+
title: t("settings.auth.options.title", "AUTHENTICATION OPTIONS"),
|
|
1555
|
+
subtitle: t(
|
|
1556
|
+
"settings.auth.options.subtitle",
|
|
1557
|
+
"Configure how users are authenticated and managed"
|
|
1558
|
+
)
|
|
1559
|
+
}
|
|
1560
|
+
),
|
|
1561
|
+
/* @__PURE__ */ jsx(CardGrid, { $min: "200px", children: toggles.map((toggle) => /* @__PURE__ */ jsx(
|
|
1562
|
+
ToggleCard,
|
|
1563
|
+
{
|
|
1564
|
+
title: toggle.title,
|
|
1565
|
+
description: toggle.description,
|
|
1566
|
+
checked: settings[toggle.key],
|
|
1567
|
+
onCheckedChange: (checked) => update(toggle.key, checked),
|
|
1568
|
+
disabled,
|
|
1569
|
+
statusOn,
|
|
1570
|
+
statusOff
|
|
1571
|
+
},
|
|
1572
|
+
toggle.key
|
|
1573
|
+
)) })
|
|
1574
|
+
] }),
|
|
1575
|
+
/* @__PURE__ */ jsx(GroupTitle, { title: t("settings.auth.management.title", "User management and sessions") }),
|
|
1576
|
+
/* @__PURE__ */ jsxs(FieldGrid, { $columns: 2, children: [
|
|
1577
|
+
/* @__PURE__ */ jsx(
|
|
1578
|
+
SettingField,
|
|
1579
|
+
{
|
|
1580
|
+
name: "user_creation_strategy",
|
|
1581
|
+
label: t("settings.auth.strategy.label", "User Creation Strategy"),
|
|
1582
|
+
hint: t(
|
|
1583
|
+
"settings.auth.strategy.hint",
|
|
1584
|
+
"Used when an administrator creates a link for an unknown email address."
|
|
1585
|
+
),
|
|
1586
|
+
children: /* @__PURE__ */ jsxs(
|
|
1587
|
+
SingleSelect,
|
|
1588
|
+
{
|
|
1589
|
+
value: settings.user_creation_strategy,
|
|
1590
|
+
onChange: (value) => update("user_creation_strategy", value),
|
|
1591
|
+
disabled,
|
|
1592
|
+
children: [
|
|
1593
|
+
/* @__PURE__ */ jsx(SingleSelectOption, { value: "email", children: t(
|
|
1594
|
+
"settings.auth.strategy.email",
|
|
1595
|
+
"Email only: the account is created with the email address"
|
|
1596
|
+
) }),
|
|
1597
|
+
/* @__PURE__ */ jsx(SingleSelectOption, { value: "emailUsername", children: t(
|
|
1598
|
+
"settings.auth.strategy.emailUsername",
|
|
1599
|
+
"Email and username: the username is generated from the email address"
|
|
1600
|
+
) }),
|
|
1601
|
+
/* @__PURE__ */ jsx(SingleSelectOption, { value: "manual", children: t(
|
|
1602
|
+
"settings.auth.strategy.manual",
|
|
1603
|
+
"Manual: no automatic creation, users must exist"
|
|
1604
|
+
) })
|
|
1605
|
+
]
|
|
1606
|
+
}
|
|
1607
|
+
)
|
|
1608
|
+
}
|
|
1609
|
+
),
|
|
1610
|
+
/* @__PURE__ */ jsx(
|
|
1611
|
+
SettingField,
|
|
1612
|
+
{
|
|
1613
|
+
name: "jwt_token_expires_in",
|
|
1614
|
+
label: t("settings.auth.jwtExpiration.label", "JWT lifetime"),
|
|
1615
|
+
hint: t(
|
|
1616
|
+
"settings.auth.jwtExpiration.hint",
|
|
1617
|
+
"A number followed by m, h or d, such as 15m, 1h, 7d or 30d. Revoked tokens stay blocked until they expire."
|
|
1618
|
+
),
|
|
1619
|
+
error: errorFor("jwt_token_expires_in"),
|
|
1620
|
+
required: true,
|
|
1621
|
+
children: /* @__PURE__ */ jsx(
|
|
1622
|
+
TextInput,
|
|
1623
|
+
{
|
|
1624
|
+
value: settings.jwt_token_expires_in ?? "",
|
|
1625
|
+
onChange: (event) => update("jwt_token_expires_in", event.target.value.trim()),
|
|
1626
|
+
placeholder: "30d",
|
|
1627
|
+
disabled,
|
|
1628
|
+
maxLength: 16
|
|
1629
|
+
}
|
|
1630
|
+
)
|
|
1631
|
+
}
|
|
1632
|
+
)
|
|
1633
|
+
] })
|
|
1634
|
+
] });
|
|
1635
|
+
};
|
|
1636
|
+
const ModeCard = styled.button`
|
|
1637
|
+
position: relative;
|
|
1638
|
+
display: flex;
|
|
1639
|
+
flex-direction: column;
|
|
1640
|
+
align-items: flex-start;
|
|
1641
|
+
gap: ${space(3)};
|
|
1642
|
+
height: 100%;
|
|
1643
|
+
min-width: 0;
|
|
1644
|
+
padding: ${space(6)} ${space(4)} ${space(4)};
|
|
1645
|
+
border-radius: ${theme.borderRadius.md};
|
|
1646
|
+
text-align: left;
|
|
1647
|
+
font: inherit;
|
|
1648
|
+
color: inherit;
|
|
1649
|
+
cursor: pointer;
|
|
1650
|
+
background: ${(p) => p.$active ? "rgba(22, 163, 74, 0.08)" : "rgba(128, 128, 128, 0.06)"};
|
|
1651
|
+
border: 2px solid ${(p) => p.$active ? "rgba(22, 163, 74, 0.5)" : color("neutral200")(p)};
|
|
1652
|
+
box-shadow: ${(p) => p.$active ? "0 4px 12px rgba(34, 197, 94, 0.15)" : theme.shadows.sm};
|
|
1653
|
+
transition:
|
|
1654
|
+
border-color ${theme.transitions.fast},
|
|
1655
|
+
box-shadow ${theme.transitions.fast},
|
|
1656
|
+
transform ${theme.transitions.fast};
|
|
1657
|
+
|
|
1658
|
+
&:hover:not(:disabled) {
|
|
1659
|
+
transform: translateY(-2px);
|
|
1660
|
+
border-color: ${(p) => p.$active ? "rgba(22, 163, 74, 0.7)" : color("neutral300")(p)};
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
&:focus-visible {
|
|
1664
|
+
outline: 2px solid ${byScheme(brand.primary, "#7DD3FC")};
|
|
1665
|
+
outline-offset: 2px;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
&:disabled {
|
|
1669
|
+
cursor: not-allowed;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
${reducedMotion}
|
|
1673
|
+
`;
|
|
1674
|
+
const ModePill = styled.span`
|
|
1675
|
+
position: absolute;
|
|
1676
|
+
top: ${space(2)};
|
|
1677
|
+
right: ${space(2)};
|
|
1678
|
+
padding: 2px ${space(2)};
|
|
1679
|
+
border-radius: ${theme.borderRadius.sm};
|
|
1680
|
+
font-size: ${fontSize(0)};
|
|
1681
|
+
font-weight: 700;
|
|
1682
|
+
letter-spacing: 0.04em;
|
|
1683
|
+
text-transform: uppercase;
|
|
1684
|
+
line-height: 1.6;
|
|
1685
|
+
background: ${(p) => p.$active ? brand.success : color("neutral150")(p)};
|
|
1686
|
+
color: ${(p) => p.$active ? brand.onFill : color("neutral700")(p)};
|
|
1687
|
+
`;
|
|
1688
|
+
const FeatureList = styled.ul`
|
|
1689
|
+
margin: 0;
|
|
1690
|
+
padding: 0;
|
|
1691
|
+
list-style: none;
|
|
1692
|
+
display: flex;
|
|
1693
|
+
flex-direction: column;
|
|
1694
|
+
gap: ${space(1)};
|
|
1695
|
+
|
|
1696
|
+
li {
|
|
1697
|
+
display: flex;
|
|
1698
|
+
gap: ${space(2)};
|
|
1699
|
+
align-items: center;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
svg {
|
|
1703
|
+
flex-shrink: 0;
|
|
1704
|
+
width: 12px;
|
|
1705
|
+
height: 12px;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
svg path {
|
|
1709
|
+
fill: ${(p) => typeof p.$accent === "function" ? p.$accent(p) : p.$accent};
|
|
1710
|
+
}
|
|
1711
|
+
`;
|
|
1712
|
+
const CardFooter = styled.div`
|
|
1713
|
+
margin-top: auto;
|
|
1714
|
+
`;
|
|
1715
|
+
const MfaSection = ({
|
|
1716
|
+
settings,
|
|
1717
|
+
update,
|
|
1718
|
+
patch,
|
|
1719
|
+
disabled,
|
|
1720
|
+
errorFor,
|
|
1721
|
+
enrollment,
|
|
1722
|
+
savedMfaMode,
|
|
1723
|
+
statusOn,
|
|
1724
|
+
statusOff
|
|
1725
|
+
}) => {
|
|
1726
|
+
const { t } = useMagicLinkIntl();
|
|
1727
|
+
const id = useId();
|
|
1728
|
+
const cardRefs = useRef([]);
|
|
1729
|
+
const [confirmRequired, setConfirmRequired] = useState(false);
|
|
1730
|
+
const mode = getAuthMode(settings);
|
|
1731
|
+
const mfaMode = resolveMfaMode(settings);
|
|
1732
|
+
const requireCheck = canRequireTotp(enrollment.status === "ready" ? enrollment.summary : null);
|
|
1733
|
+
const enrolled = enrollment.summary?.enrolled;
|
|
1734
|
+
const modes = {
|
|
1735
|
+
"magic-link": {
|
|
1736
|
+
icon: Link,
|
|
1737
|
+
accent: accent.primary,
|
|
1738
|
+
background: "rgba(2, 132, 199, 0.12)",
|
|
1739
|
+
title: t("settings.mfa.mode.magicLink.title", "Magic link only"),
|
|
1740
|
+
description: t(
|
|
1741
|
+
"settings.mfa.mode.magicLink.description",
|
|
1742
|
+
"One-click sign-in by email. Fast and easy to use."
|
|
1743
|
+
),
|
|
1744
|
+
features: [
|
|
1745
|
+
t("settings.mfa.mode.magicLink.feature1", "No passwords"),
|
|
1746
|
+
t("settings.mfa.mode.magicLink.feature2", "Fast sign-in"),
|
|
1747
|
+
t("settings.mfa.mode.magicLink.feature3", "Works on every device")
|
|
1748
|
+
],
|
|
1749
|
+
badge: t("settings.mfa.mode.magicLink.badge", "Fast")
|
|
1750
|
+
},
|
|
1751
|
+
"email-otp": {
|
|
1752
|
+
icon: Mail,
|
|
1753
|
+
accent: accent.secondary,
|
|
1754
|
+
background: "rgba(124, 58, 237, 0.1)",
|
|
1755
|
+
title: t("settings.mfa.mode.emailOtp.title", "Magic link + email code"),
|
|
1756
|
+
description: t(
|
|
1757
|
+
"settings.mfa.mode.emailOtp.description",
|
|
1758
|
+
"After opening the link, users enter a one-time code sent by email."
|
|
1759
|
+
),
|
|
1760
|
+
features: [
|
|
1761
|
+
t("settings.mfa.mode.emailOtp.feature1", "Code sent by email"),
|
|
1762
|
+
t("settings.mfa.mode.emailOtp.feature2", "Configurable code length"),
|
|
1763
|
+
t("settings.mfa.mode.emailOtp.feature3", "Configurable lifetime")
|
|
1764
|
+
],
|
|
1765
|
+
badge: t("settings.mfa.mode.emailOtp.badge", "Stronger")
|
|
1766
|
+
},
|
|
1767
|
+
totp: {
|
|
1768
|
+
icon: Lock,
|
|
1769
|
+
accent: accent.warning,
|
|
1770
|
+
background: "rgba(217, 119, 6, 0.1)",
|
|
1771
|
+
title: t("settings.mfa.mode.totp.title", "Magic link + authenticator app"),
|
|
1772
|
+
description: t(
|
|
1773
|
+
"settings.mfa.mode.totp.description",
|
|
1774
|
+
"After opening the link, users enter a code from an authenticator app such as Google Authenticator or Authy."
|
|
1775
|
+
),
|
|
1776
|
+
features: [
|
|
1777
|
+
t("settings.mfa.mode.totp.feature1", "Authenticator app codes"),
|
|
1778
|
+
t("settings.mfa.mode.totp.feature2", "Set up by QR code"),
|
|
1779
|
+
t("settings.mfa.mode.totp.feature3", "Codes rotate every 30 seconds")
|
|
1780
|
+
],
|
|
1781
|
+
badge: t("settings.mfa.mode.totp.badge", "Strongest")
|
|
1782
|
+
}
|
|
1783
|
+
};
|
|
1784
|
+
const selectMode = (next) => {
|
|
1785
|
+
if (disabled || next === mode) return;
|
|
1786
|
+
patch(applyAuthMode(next, settings));
|
|
1787
|
+
};
|
|
1788
|
+
const handleKeyDown = (event, index) => {
|
|
1789
|
+
const keys = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
|
1790
|
+
let nextIndex = null;
|
|
1791
|
+
if (event.key in keys)
|
|
1792
|
+
nextIndex = (index + keys[event.key] + AUTH_MODES.length) % AUTH_MODES.length;
|
|
1793
|
+
if (event.key === "Home") nextIndex = 0;
|
|
1794
|
+
if (event.key === "End") nextIndex = AUTH_MODES.length - 1;
|
|
1795
|
+
if (nextIndex === null) return;
|
|
1796
|
+
event.preventDefault();
|
|
1797
|
+
selectMode(AUTH_MODES[nextIndex]);
|
|
1798
|
+
cardRefs.current[nextIndex]?.focus();
|
|
1799
|
+
};
|
|
1800
|
+
const handlePolicyChange = (value) => {
|
|
1801
|
+
if (disabled || value === mfaMode) return;
|
|
1802
|
+
if (value === "required") {
|
|
1803
|
+
if (!requireCheck.allowed) return;
|
|
1804
|
+
setConfirmRequired(true);
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
patch(applyTotpPolicy("optional"));
|
|
1808
|
+
};
|
|
1809
|
+
const enrollmentText = (() => {
|
|
1810
|
+
if (enrollment.status === "loading") {
|
|
1811
|
+
return t(
|
|
1812
|
+
"settings.mfa.enrollment.loading",
|
|
1813
|
+
"Checking how many users have set up an authenticator app..."
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
if (enrollment.status !== "ready") {
|
|
1817
|
+
return t(
|
|
1818
|
+
"settings.mfa.enrollment.unavailable",
|
|
1819
|
+
"The number of users with an authenticator app could not be determined."
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
if (enrolled === 0) {
|
|
1823
|
+
return t("settings.mfa.enrollment.none", "No user has set up an authenticator app yet.");
|
|
1824
|
+
}
|
|
1825
|
+
return t(
|
|
1826
|
+
"settings.mfa.enrollment.count",
|
|
1827
|
+
"{count, plural, one {# user has} other {# users have}} set up an authenticator app.",
|
|
1828
|
+
{ count: enrolled }
|
|
1829
|
+
);
|
|
1830
|
+
})();
|
|
1831
|
+
const savedLockout = savedMfaMode === "required" && enrollment.status === "ready" && enrolled === 0;
|
|
1832
|
+
const emailOtpSteps = [
|
|
1833
|
+
t("settings.mfa.otp.step1", "The user opens the magic link from the email."),
|
|
1834
|
+
t("settings.mfa.otp.step2", "Your app shows the code entry page."),
|
|
1835
|
+
t(
|
|
1836
|
+
"settings.mfa.otp.step3",
|
|
1837
|
+
"The user enters the {length}-digit code sent to their email address.",
|
|
1838
|
+
{
|
|
1839
|
+
length: settings.otp_length ?? 6
|
|
1840
|
+
}
|
|
1841
|
+
),
|
|
1842
|
+
t("settings.mfa.otp.step4", "After a correct code, the user is signed in.")
|
|
1843
|
+
];
|
|
1844
|
+
const summary = [];
|
|
1845
|
+
if (mode === "magic-link") {
|
|
1846
|
+
summary.push({
|
|
1847
|
+
icon: Link,
|
|
1848
|
+
text: t("settings.mfa.summary.magicLink", "Users sign in with the magic link only.")
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
if (mode === "email-otp") {
|
|
1852
|
+
summary.push({
|
|
1853
|
+
icon: Mail,
|
|
1854
|
+
text: t(
|
|
1855
|
+
"settings.mfa.summary.emailOtp",
|
|
1856
|
+
"Users open the magic link, then enter the code from the email."
|
|
1857
|
+
)
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
if (mode === "totp") {
|
|
1861
|
+
summary.push({
|
|
1862
|
+
icon: Lock,
|
|
1863
|
+
text: mfaMode === "required" ? t(
|
|
1864
|
+
"settings.mfa.summary.totpRequired",
|
|
1865
|
+
"Users open the magic link, then enter a code from their authenticator app. Users without an app cannot sign in."
|
|
1866
|
+
) : t(
|
|
1867
|
+
"settings.mfa.summary.totpOptional",
|
|
1868
|
+
"Users open the magic link. Users who set up an authenticator app must also enter its code."
|
|
1869
|
+
)
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
if (settings.totp_as_primary_auth) {
|
|
1873
|
+
summary.push({
|
|
1874
|
+
icon: Lightning,
|
|
1875
|
+
text: t(
|
|
1876
|
+
"settings.mfa.summary.totpOnly",
|
|
1877
|
+
"Users with an authenticator app can also sign in with email and code, without waiting for an email."
|
|
1878
|
+
)
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
return /* @__PURE__ */ jsxs(SectionBody, { children: [
|
|
1882
|
+
/* @__PURE__ */ jsx(Note, { tone: "primary", icon: Information, children: t(
|
|
1883
|
+
"settings.mfa.intro",
|
|
1884
|
+
"Choose how users sign in. The selection takes effect when you save. Email codes and authenticator apps are separate modes."
|
|
1885
|
+
) }),
|
|
1886
|
+
savedLockout ? /* @__PURE__ */ jsx(
|
|
1887
|
+
Note,
|
|
1888
|
+
{
|
|
1889
|
+
tone: "danger",
|
|
1890
|
+
icon: WarningCircle,
|
|
1891
|
+
role: "alert",
|
|
1892
|
+
title: t("settings.mfa.lockout.title", "Every sign-in currently fails"),
|
|
1893
|
+
children: t(
|
|
1894
|
+
"settings.mfa.lockout.body",
|
|
1895
|
+
'The saved policy requires an authenticator app, but no user has set one up. Choose "Users who set up an app" or another mode and save.'
|
|
1896
|
+
)
|
|
1897
|
+
}
|
|
1898
|
+
) : null,
|
|
1899
|
+
/* @__PURE__ */ jsx(GroupTitle, { id: `${id}-modes`, title: t("settings.mfa.modes.title", "Sign-in mode") }),
|
|
1900
|
+
/* @__PURE__ */ jsx(CardGrid, { $min: "240px", role: "radiogroup", "aria-labelledby": `${id}-modes`, children: AUTH_MODES.map((key, index) => {
|
|
1901
|
+
const item = modes[key];
|
|
1902
|
+
const Icon = item.icon;
|
|
1903
|
+
const isActive = mode === key;
|
|
1904
|
+
return /* @__PURE__ */ jsxs(
|
|
1905
|
+
ModeCard,
|
|
1906
|
+
{
|
|
1907
|
+
ref: (node) => {
|
|
1908
|
+
cardRefs.current[index] = node;
|
|
1909
|
+
},
|
|
1910
|
+
type: "button",
|
|
1911
|
+
role: "radio",
|
|
1912
|
+
"aria-checked": isActive,
|
|
1913
|
+
"aria-labelledby": `${id}-${key}-title`,
|
|
1914
|
+
"aria-describedby": `${id}-${key}-description`,
|
|
1915
|
+
tabIndex: isActive ? 0 : -1,
|
|
1916
|
+
$active: isActive,
|
|
1917
|
+
disabled,
|
|
1918
|
+
onClick: () => selectMode(key),
|
|
1919
|
+
onKeyDown: (event) => handleKeyDown(event, index),
|
|
1920
|
+
children: [
|
|
1921
|
+
/* @__PURE__ */ jsx(ModePill, { $active: isActive, "aria-hidden": "true", children: isActive ? statusOn : statusOff }),
|
|
1922
|
+
/* @__PURE__ */ jsx(IconBadge, { $background: item.background, $color: item.accent, "aria-hidden": "true", children: /* @__PURE__ */ jsx(Icon, {}) }),
|
|
1923
|
+
/* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 1, alignItems: "flex-start", children: [
|
|
1924
|
+
/* @__PURE__ */ jsx(
|
|
1925
|
+
Typography,
|
|
1926
|
+
{
|
|
1927
|
+
id: `${id}-${key}-title`,
|
|
1928
|
+
variant: "omega",
|
|
1929
|
+
fontWeight: "bold",
|
|
1930
|
+
textColor: "neutral800",
|
|
1931
|
+
children: item.title
|
|
1932
|
+
}
|
|
1933
|
+
),
|
|
1934
|
+
/* @__PURE__ */ jsx(Typography, { id: `${id}-${key}-description`, variant: "pi", textColor: "neutral600", children: item.description })
|
|
1935
|
+
] }),
|
|
1936
|
+
/* @__PURE__ */ jsx(FeatureList, { $accent: item.accent, children: item.features.map((feature) => /* @__PURE__ */ jsxs("li", { children: [
|
|
1937
|
+
/* @__PURE__ */ jsx(CheckCircle, { "aria-hidden": "true" }),
|
|
1938
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral700", children: feature })
|
|
1939
|
+
] }, feature)) }),
|
|
1940
|
+
/* @__PURE__ */ jsx(CardFooter, { children: /* @__PURE__ */ jsx(Badge, { children: item.badge }) })
|
|
1941
|
+
]
|
|
1942
|
+
},
|
|
1943
|
+
key
|
|
1944
|
+
);
|
|
1945
|
+
}) }),
|
|
1946
|
+
mode === "email-otp" ? /* @__PURE__ */ jsxs(Panel, { children: [
|
|
1947
|
+
/* @__PURE__ */ jsx(
|
|
1948
|
+
GroupTitle,
|
|
1949
|
+
{
|
|
1950
|
+
title: t("settings.mfa.otp.title", "Email code settings"),
|
|
1951
|
+
subtitle: t(
|
|
1952
|
+
"settings.mfa.otp.delivery",
|
|
1953
|
+
"Delivery: email. Codes are sent to the address of the account."
|
|
1954
|
+
)
|
|
1955
|
+
}
|
|
1956
|
+
),
|
|
1957
|
+
/* @__PURE__ */ jsxs(FieldGrid, { $columns: 2, children: [
|
|
1958
|
+
/* @__PURE__ */ jsx(
|
|
1959
|
+
SettingField,
|
|
1960
|
+
{
|
|
1961
|
+
name: "otp_length",
|
|
1962
|
+
label: t("settings.mfa.otp.length.label", "Code length (digits)"),
|
|
1963
|
+
hint: t("settings.mfa.otp.length.hint", "Default: 6 digits. Allowed: 4 to 16."),
|
|
1964
|
+
error: errorFor("otp_length"),
|
|
1965
|
+
required: true,
|
|
1966
|
+
children: /* @__PURE__ */ jsx(
|
|
1967
|
+
NumberInput,
|
|
1968
|
+
{
|
|
1969
|
+
value: settings.otp_length,
|
|
1970
|
+
onValueChange: (value) => update("otp_length", value),
|
|
1971
|
+
min: 4,
|
|
1972
|
+
max: 16,
|
|
1973
|
+
disabled
|
|
1974
|
+
}
|
|
1975
|
+
)
|
|
1976
|
+
}
|
|
1977
|
+
),
|
|
1978
|
+
/* @__PURE__ */ jsx(
|
|
1979
|
+
SettingField,
|
|
1980
|
+
{
|
|
1981
|
+
name: "otp_expiry",
|
|
1982
|
+
label: t("settings.mfa.otp.expiry.label", "Code lifetime (seconds)"),
|
|
1983
|
+
hint: t(
|
|
1984
|
+
"settings.mfa.otp.expiry.hint",
|
|
1985
|
+
"Default: 300 seconds (5 minutes). Allowed: 30 to 86,400."
|
|
1986
|
+
),
|
|
1987
|
+
error: errorFor("otp_expiry"),
|
|
1988
|
+
required: true,
|
|
1989
|
+
children: /* @__PURE__ */ jsx(
|
|
1990
|
+
NumberInput,
|
|
1991
|
+
{
|
|
1992
|
+
value: settings.otp_expiry,
|
|
1993
|
+
onValueChange: (value) => update("otp_expiry", value),
|
|
1994
|
+
min: 30,
|
|
1995
|
+
max: 86400,
|
|
1996
|
+
step: 30,
|
|
1997
|
+
disabled
|
|
1998
|
+
}
|
|
1999
|
+
)
|
|
2000
|
+
}
|
|
2001
|
+
),
|
|
2002
|
+
/* @__PURE__ */ jsx(
|
|
2003
|
+
SettingField,
|
|
2004
|
+
{
|
|
2005
|
+
name: "otp_max_attempts",
|
|
2006
|
+
label: t("settings.mfa.otp.attempts.label", "Maximum wrong attempts"),
|
|
2007
|
+
hint: t(
|
|
2008
|
+
"settings.mfa.otp.attempts.hint",
|
|
2009
|
+
"The code becomes invalid after this many wrong entries. Default: 3."
|
|
2010
|
+
),
|
|
2011
|
+
error: errorFor("otp_max_attempts"),
|
|
2012
|
+
required: true,
|
|
2013
|
+
children: /* @__PURE__ */ jsx(
|
|
2014
|
+
NumberInput,
|
|
2015
|
+
{
|
|
2016
|
+
value: settings.otp_max_attempts,
|
|
2017
|
+
onValueChange: (value) => update("otp_max_attempts", value),
|
|
2018
|
+
min: 1,
|
|
2019
|
+
max: 20,
|
|
2020
|
+
disabled
|
|
2021
|
+
}
|
|
2022
|
+
)
|
|
2023
|
+
}
|
|
2024
|
+
),
|
|
2025
|
+
/* @__PURE__ */ jsx(
|
|
2026
|
+
SettingField,
|
|
2027
|
+
{
|
|
2028
|
+
name: "otp_resend_cooldown",
|
|
2029
|
+
label: t("settings.mfa.otp.cooldown.label", "Wait before a new code (seconds)"),
|
|
2030
|
+
hint: t(
|
|
2031
|
+
"settings.mfa.otp.cooldown.hint",
|
|
2032
|
+
"How long users wait before they can request a new code. Default: 60."
|
|
2033
|
+
),
|
|
2034
|
+
error: errorFor("otp_resend_cooldown"),
|
|
2035
|
+
required: true,
|
|
2036
|
+
children: /* @__PURE__ */ jsx(
|
|
2037
|
+
NumberInput,
|
|
2038
|
+
{
|
|
2039
|
+
value: settings.otp_resend_cooldown,
|
|
2040
|
+
onValueChange: (value) => update("otp_resend_cooldown", value),
|
|
2041
|
+
min: 0,
|
|
2042
|
+
max: 86400,
|
|
2043
|
+
disabled
|
|
2044
|
+
}
|
|
2045
|
+
)
|
|
2046
|
+
}
|
|
2047
|
+
)
|
|
2048
|
+
] }),
|
|
2049
|
+
/* @__PURE__ */ jsx(
|
|
2050
|
+
Note,
|
|
2051
|
+
{
|
|
2052
|
+
tone: "success",
|
|
2053
|
+
icon: CheckCircle,
|
|
2054
|
+
title: t("settings.mfa.otp.howItWorks", "How it works"),
|
|
2055
|
+
children: /* @__PURE__ */ jsx(StepList, { children: emailOtpSteps.map((step) => /* @__PURE__ */ jsx("li", { children: step }, step)) })
|
|
2056
|
+
}
|
|
2057
|
+
)
|
|
2058
|
+
] }) : null,
|
|
2059
|
+
mode === "totp" ? /* @__PURE__ */ jsxs(Panel, { children: [
|
|
2060
|
+
/* @__PURE__ */ jsx(
|
|
2061
|
+
GroupTitle,
|
|
2062
|
+
{
|
|
2063
|
+
id: `${id}-policy`,
|
|
2064
|
+
title: t("settings.mfa.policy.title", "Who must use an authenticator app?")
|
|
2065
|
+
}
|
|
2066
|
+
),
|
|
2067
|
+
/* @__PURE__ */ jsx(
|
|
2068
|
+
Note,
|
|
2069
|
+
{
|
|
2070
|
+
tone: enrollment.status === "ready" && enrolled === 0 ? "warning" : "primary",
|
|
2071
|
+
icon: Key,
|
|
2072
|
+
title: t("settings.mfa.enrollment.title", "Enrollment"),
|
|
2073
|
+
children: /* @__PURE__ */ jsxs(BulletList, { children: [
|
|
2074
|
+
/* @__PURE__ */ jsx("li", { "aria-live": "polite", children: enrollmentText }),
|
|
2075
|
+
/* @__PURE__ */ jsx("li", { children: t(
|
|
2076
|
+
"settings.mfa.enrollment.howTo",
|
|
2077
|
+
"Users set up their app while signed in, from your frontend. They cannot set it up during a sign-in that already requires a code."
|
|
2078
|
+
) })
|
|
2079
|
+
] })
|
|
2080
|
+
}
|
|
2081
|
+
),
|
|
2082
|
+
/* @__PURE__ */ jsxs(
|
|
2083
|
+
Radio.Group,
|
|
2084
|
+
{
|
|
2085
|
+
value: mfaMode === "required" ? "required" : "optional",
|
|
2086
|
+
onValueChange: handlePolicyChange,
|
|
2087
|
+
"aria-labelledby": `${id}-policy`,
|
|
2088
|
+
disabled,
|
|
2089
|
+
children: [
|
|
2090
|
+
/* @__PURE__ */ jsx(Radio.Item, { value: "optional", children: /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 1, alignItems: "flex-start", tag: "span", children: [
|
|
2091
|
+
/* @__PURE__ */ jsx(Typography, { variant: "omega", fontWeight: "semiBold", textColor: "neutral800", children: t("settings.mfa.policy.optional", "Users who set up an app (recommended)") }),
|
|
2092
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: t(
|
|
2093
|
+
"settings.mfa.policy.optionalHint",
|
|
2094
|
+
"Users with an authenticator app must enter its code. Everyone else signs in with the magic link."
|
|
2095
|
+
) })
|
|
2096
|
+
] }) }),
|
|
2097
|
+
/* @__PURE__ */ jsx(Radio.Item, { value: "required", disabled: disabled || !requireCheck.allowed, children: /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 1, alignItems: "flex-start", tag: "span", children: [
|
|
2098
|
+
/* @__PURE__ */ jsx(Typography, { variant: "omega", fontWeight: "semiBold", textColor: "neutral800", children: t("settings.mfa.policy.required", "Every user") }),
|
|
2099
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: requireCheck.allowed ? t(
|
|
2100
|
+
"settings.mfa.policy.requiredHint",
|
|
2101
|
+
"Users without an authenticator app can no longer sign in."
|
|
2102
|
+
) : t(
|
|
2103
|
+
"settings.mfa.policy.requiredBlocked",
|
|
2104
|
+
"Unavailable: no user has set up an authenticator app, so every sign-in would fail."
|
|
2105
|
+
) })
|
|
2106
|
+
] }) })
|
|
2107
|
+
]
|
|
2108
|
+
}
|
|
2109
|
+
)
|
|
2110
|
+
] }) : null,
|
|
2111
|
+
/* @__PURE__ */ jsx(CardGrid, { $min: "260px", children: /* @__PURE__ */ jsx(
|
|
2112
|
+
ToggleCard,
|
|
2113
|
+
{
|
|
2114
|
+
title: t("settings.mfa.totpOnly.title", "Sign-in with authenticator code only"),
|
|
2115
|
+
description: t(
|
|
2116
|
+
"settings.mfa.totpOnly.description",
|
|
2117
|
+
"Users who set up an authenticator app can sign in with their email address and a current code, without waiting for an email."
|
|
2118
|
+
),
|
|
2119
|
+
checked: settings.totp_as_primary_auth,
|
|
2120
|
+
onCheckedChange: (checked) => update("totp_as_primary_auth", checked),
|
|
2121
|
+
disabled,
|
|
2122
|
+
statusOn,
|
|
2123
|
+
statusOff
|
|
2124
|
+
}
|
|
2125
|
+
) }),
|
|
2126
|
+
mode === "totp" || settings.totp_as_primary_auth ? /* @__PURE__ */ jsxs(Panel, { children: [
|
|
2127
|
+
/* @__PURE__ */ jsx(
|
|
2128
|
+
GroupTitle,
|
|
2129
|
+
{
|
|
2130
|
+
title: t("settings.mfa.totp.title", "Authenticator app settings"),
|
|
2131
|
+
subtitle: t(
|
|
2132
|
+
"settings.mfa.totp.subtitle",
|
|
2133
|
+
"Changes apply to new setups. Apps that are already set up keep working."
|
|
2134
|
+
)
|
|
2135
|
+
}
|
|
2136
|
+
),
|
|
2137
|
+
/* @__PURE__ */ jsxs(FieldGrid, { $columns: 3, children: [
|
|
2138
|
+
/* @__PURE__ */ jsx(
|
|
2139
|
+
SettingField,
|
|
2140
|
+
{
|
|
2141
|
+
name: "totp_issuer",
|
|
2142
|
+
label: t("settings.mfa.totp.issuer.label", "Issuer name"),
|
|
2143
|
+
hint: t(
|
|
2144
|
+
"settings.mfa.totp.issuer.hint",
|
|
2145
|
+
"Shown in the authenticator app, for example your app name."
|
|
2146
|
+
),
|
|
2147
|
+
error: errorFor("totp_issuer"),
|
|
2148
|
+
children: /* @__PURE__ */ jsx(
|
|
2149
|
+
TextInput,
|
|
2150
|
+
{
|
|
2151
|
+
value: settings.totp_issuer ?? "",
|
|
2152
|
+
onChange: (event) => update("totp_issuer", event.target.value),
|
|
2153
|
+
placeholder: "Magic Link",
|
|
2154
|
+
disabled,
|
|
2155
|
+
maxLength: 128
|
|
2156
|
+
}
|
|
2157
|
+
)
|
|
2158
|
+
}
|
|
2159
|
+
),
|
|
2160
|
+
/* @__PURE__ */ jsx(
|
|
2161
|
+
SettingField,
|
|
2162
|
+
{
|
|
2163
|
+
name: "totp_digits",
|
|
2164
|
+
label: t("settings.mfa.totp.digits.label", "Code digits"),
|
|
2165
|
+
error: errorFor("totp_digits"),
|
|
2166
|
+
children: /* @__PURE__ */ jsxs(
|
|
2167
|
+
SingleSelect,
|
|
2168
|
+
{
|
|
2169
|
+
value: String(settings.totp_digits ?? 6),
|
|
2170
|
+
onChange: (value) => update("totp_digits", Number.parseInt(value, 10)),
|
|
2171
|
+
disabled,
|
|
2172
|
+
children: [
|
|
2173
|
+
/* @__PURE__ */ jsx(SingleSelectOption, { value: "6", children: t("settings.mfa.totp.digits.option", "{count} digits", { count: 6 }) }),
|
|
2174
|
+
/* @__PURE__ */ jsx(SingleSelectOption, { value: "8", children: t("settings.mfa.totp.digits.option", "{count} digits", { count: 8 }) })
|
|
2175
|
+
]
|
|
2176
|
+
}
|
|
2177
|
+
)
|
|
2178
|
+
}
|
|
2179
|
+
),
|
|
2180
|
+
/* @__PURE__ */ jsx(
|
|
2181
|
+
SettingField,
|
|
2182
|
+
{
|
|
2183
|
+
name: "totp_period",
|
|
2184
|
+
label: t("settings.mfa.totp.period.label", "Code period (seconds)"),
|
|
2185
|
+
hint: t(
|
|
2186
|
+
"settings.mfa.totp.period.hint",
|
|
2187
|
+
"Default: 30 seconds. Most apps only support 30."
|
|
2188
|
+
),
|
|
2189
|
+
error: errorFor("totp_period"),
|
|
2190
|
+
required: true,
|
|
2191
|
+
children: /* @__PURE__ */ jsx(
|
|
2192
|
+
NumberInput,
|
|
2193
|
+
{
|
|
2194
|
+
value: settings.totp_period,
|
|
2195
|
+
onValueChange: (value) => update("totp_period", value),
|
|
2196
|
+
min: 15,
|
|
2197
|
+
max: 300,
|
|
2198
|
+
disabled
|
|
2199
|
+
}
|
|
2200
|
+
)
|
|
2201
|
+
}
|
|
2202
|
+
)
|
|
2203
|
+
] })
|
|
2204
|
+
] }) : null,
|
|
2205
|
+
/* @__PURE__ */ jsx(
|
|
2206
|
+
Note,
|
|
2207
|
+
{
|
|
2208
|
+
tone: "primary",
|
|
2209
|
+
icon: CheckCircle,
|
|
2210
|
+
title: t("settings.mfa.summary.title", "Current selection"),
|
|
2211
|
+
children: /* @__PURE__ */ jsx(Flex, { direction: "column", gap: 2, alignItems: "flex-start", children: summary.map(({ icon: Icon, text }) => /* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "flex-start", children: [
|
|
2212
|
+
/* @__PURE__ */ jsx(
|
|
2213
|
+
Icon,
|
|
2214
|
+
{
|
|
2215
|
+
"aria-hidden": "true",
|
|
2216
|
+
width: "14px",
|
|
2217
|
+
height: "14px",
|
|
2218
|
+
style: { flexShrink: 0, marginTop: 2 }
|
|
2219
|
+
}
|
|
2220
|
+
),
|
|
2221
|
+
/* @__PURE__ */ jsx("span", { children: text })
|
|
2222
|
+
] }, text)) })
|
|
2223
|
+
}
|
|
2224
|
+
),
|
|
2225
|
+
/* @__PURE__ */ jsx(
|
|
2226
|
+
ConfirmDialog,
|
|
2227
|
+
{
|
|
2228
|
+
isOpen: !disabled && confirmRequired,
|
|
2229
|
+
variant: "danger",
|
|
2230
|
+
title: t(
|
|
2231
|
+
"settings.mfa.confirmRequired.title",
|
|
2232
|
+
"Require an authenticator app for every user?"
|
|
2233
|
+
),
|
|
2234
|
+
description: enrollment.status === "ready" ? t(
|
|
2235
|
+
"settings.mfa.confirmRequired.bodyCount",
|
|
2236
|
+
"Only {count, plural, one {# user has} other {# users have}} set up an authenticator app. Everyone else can no longer sign in after you save.",
|
|
2237
|
+
{ count: enrolled }
|
|
2238
|
+
) : t(
|
|
2239
|
+
"settings.mfa.confirmRequired.bodyUnknown",
|
|
2240
|
+
"Users without an authenticator app can no longer sign in after you save. The number of enrolled users is unknown."
|
|
2241
|
+
),
|
|
2242
|
+
confirmLabel: t("settings.mfa.confirmRequired.confirm", "Require for everyone"),
|
|
2243
|
+
onConfirm: () => {
|
|
2244
|
+
patch(applyTotpPolicy("required"));
|
|
2245
|
+
setConfirmRequired(false);
|
|
2246
|
+
},
|
|
2247
|
+
onClose: () => setConfirmRequired(false),
|
|
2248
|
+
children: t(
|
|
2249
|
+
"settings.mfa.confirmRequired.recovery",
|
|
2250
|
+
'To undo it, choose "Users who set up an app" or another sign-in mode and save again.'
|
|
2251
|
+
)
|
|
2252
|
+
}
|
|
2253
|
+
)
|
|
2254
|
+
] });
|
|
2255
|
+
};
|
|
2256
|
+
const PLACEHOLDERS = ["LOGIN_URL", "EXPIRY_TEXT", "EMAIL", "USER", "URL", "CODE", "EXPIRES_AT"];
|
|
2257
|
+
const templateValue = (template) => String(template?.templateReferenceId ?? template?.id ?? "");
|
|
2258
|
+
const templateName = (template, fallback) => template?.name || template?.subject || fallback.replace("{id}", String(template?.id ?? ""));
|
|
2259
|
+
const EmailSection = ({
|
|
2260
|
+
settings,
|
|
2261
|
+
update,
|
|
2262
|
+
patch,
|
|
2263
|
+
disabled,
|
|
2264
|
+
errorFor,
|
|
2265
|
+
integrations,
|
|
2266
|
+
emailDesignerTemplates,
|
|
2267
|
+
magicMailTemplates
|
|
2268
|
+
}) => {
|
|
2269
|
+
const { t } = useMagicLinkIntl();
|
|
2270
|
+
const copy = useCopyToClipboard();
|
|
2271
|
+
const [pendingTemplate, setPendingTemplate] = useState(null);
|
|
2272
|
+
const { emailDesignerInstalled, magicMailInstalled, emailProviderConfigured, emailProviderName } = integrations;
|
|
2273
|
+
const gallery = useMemo(() => getTemplateList(), []);
|
|
2274
|
+
const defaults = buildDefaultTemplates({
|
|
2275
|
+
heading: t("settings.email.default.heading", "Sign in"),
|
|
2276
|
+
intro: t(
|
|
2277
|
+
"settings.email.default.intro",
|
|
2278
|
+
"Use the button below to sign in. No password needed."
|
|
2279
|
+
),
|
|
2280
|
+
button: t("settings.email.default.button", "Sign in now"),
|
|
2281
|
+
expiry: t("settings.email.default.expiry", "The link expires in {expiry}.", {
|
|
2282
|
+
expiry: "{{EXPIRY_TEXT}}"
|
|
2283
|
+
}),
|
|
2284
|
+
ignore: t(
|
|
2285
|
+
"settings.email.default.ignore",
|
|
2286
|
+
"If you did not request this email, you can ignore it."
|
|
2287
|
+
)
|
|
2288
|
+
});
|
|
2289
|
+
const currentHtml = toPlaceholderSyntax(settings.message_html);
|
|
2290
|
+
const currentText = toPlaceholderSyntax(settings.message_text);
|
|
2291
|
+
const activeGalleryKey = gallery.find(({ value }) => {
|
|
2292
|
+
const template = getTemplate(value);
|
|
2293
|
+
return template.html === currentHtml && template.text === currentText;
|
|
2294
|
+
})?.value ?? null;
|
|
2295
|
+
const knownHtml = [...gallery.map(({ value }) => getTemplate(value).html), defaults.html];
|
|
2296
|
+
const knownText = [...gallery.map(({ value }) => getTemplate(value).text), defaults.text];
|
|
2297
|
+
const htmlIsCustom = isCustomTemplate(settings.message_html, knownHtml, toPlaceholderSyntax);
|
|
2298
|
+
const textIsCustom = isCustomTemplate(settings.message_text, knownText, toPlaceholderSyntax);
|
|
2299
|
+
const requestTemplate = (request) => {
|
|
2300
|
+
if (disabled) return;
|
|
2301
|
+
const overwrites = "message_html" in request.values && htmlIsCustom && request.values.message_html !== settings.message_html || "message_text" in request.values && textIsCustom && request.values.message_text !== settings.message_text;
|
|
2302
|
+
if (overwrites) {
|
|
2303
|
+
setPendingTemplate(request);
|
|
2304
|
+
} else {
|
|
2305
|
+
patch(request.values);
|
|
2306
|
+
}
|
|
2307
|
+
};
|
|
2308
|
+
const designerSelected = emailDesignerTemplates.some(
|
|
2309
|
+
(template) => templateValue(template) === String(settings.email_designer_template_id ?? "")
|
|
2310
|
+
);
|
|
2311
|
+
const magicMailSelected = magicMailTemplates.some(
|
|
2312
|
+
(template) => String(template.id) === String(settings.magic_mail_template_id ?? "")
|
|
2313
|
+
);
|
|
2314
|
+
const htmlStats = templateStats(settings.message_html);
|
|
2315
|
+
const usingThirdParty = emailDesignerInstalled && settings.use_email_designer || settings.use_magic_mail;
|
|
2316
|
+
return /* @__PURE__ */ jsxs(SectionBody, { children: [
|
|
2317
|
+
emailProviderConfigured || magicMailInstalled ? /* @__PURE__ */ jsx(Note, { tone: "success", icon: Check, compact: true, children: magicMailInstalled ? t(
|
|
2318
|
+
"settings.email.provider.magicMail",
|
|
2319
|
+
"Email provider: Magic Mail (advanced routing)"
|
|
2320
|
+
) : t("settings.email.provider.configured", "Email provider: {name}", {
|
|
2321
|
+
name: emailProviderName || t("settings.email.provider.defaultName", "configured")
|
|
2322
|
+
}) }) : /* @__PURE__ */ jsx(
|
|
2323
|
+
Note,
|
|
2324
|
+
{
|
|
2325
|
+
tone: "danger",
|
|
2326
|
+
icon: WarningCircle,
|
|
2327
|
+
role: "alert",
|
|
2328
|
+
title: t("settings.email.provider.missing.title", "No email provider configured"),
|
|
2329
|
+
children: /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 2, alignItems: "flex-start", children: [
|
|
2330
|
+
/* @__PURE__ */ jsx("span", { children: t(
|
|
2331
|
+
"settings.email.provider.missing.body",
|
|
2332
|
+
"Magic Link cannot send emails until an email provider is configured. Choose one of these options:"
|
|
2333
|
+
) }),
|
|
2334
|
+
/* @__PURE__ */ jsxs(BulletList, { children: [
|
|
2335
|
+
/* @__PURE__ */ jsxs("li", { children: [
|
|
2336
|
+
t(
|
|
2337
|
+
"settings.email.provider.missing.nodemailer",
|
|
2338
|
+
"Install Nodemailer (recommended):"
|
|
2339
|
+
),
|
|
2340
|
+
" ",
|
|
2341
|
+
/* @__PURE__ */ jsx(Mono, { children: "npm install @strapi/provider-email-nodemailer" }),
|
|
2342
|
+
" ",
|
|
2343
|
+
t("settings.email.provider.missing.configure", "and configure it in {file}.", {
|
|
2344
|
+
file: "config/plugins.js"
|
|
2345
|
+
})
|
|
2346
|
+
] }),
|
|
2347
|
+
/* @__PURE__ */ jsx("li", { children: t(
|
|
2348
|
+
"settings.email.provider.missing.magicMail",
|
|
2349
|
+
"Install the Magic Mail plugin for routing, OAuth2 accounts and analytics."
|
|
2350
|
+
) }),
|
|
2351
|
+
/* @__PURE__ */ jsx("li", { children: t(
|
|
2352
|
+
"settings.email.provider.missing.other",
|
|
2353
|
+
"Use any other Strapi email provider, such as SendGrid, Mailgun or Amazon SES."
|
|
2354
|
+
) })
|
|
2355
|
+
] })
|
|
2356
|
+
] })
|
|
2357
|
+
}
|
|
2358
|
+
),
|
|
2359
|
+
/* @__PURE__ */ jsx(
|
|
2360
|
+
GroupTitle,
|
|
2361
|
+
{
|
|
2362
|
+
title: t("settings.email.sender.title", "SENDER INFORMATION"),
|
|
2363
|
+
subtitle: t(
|
|
2364
|
+
"settings.email.sender.subtitle",
|
|
2365
|
+
"This information appears in emails sent to users"
|
|
2366
|
+
)
|
|
2367
|
+
}
|
|
2368
|
+
),
|
|
2369
|
+
/* @__PURE__ */ jsxs(FieldGrid, { $columns: 3, children: [
|
|
2370
|
+
/* @__PURE__ */ jsx(
|
|
2371
|
+
SettingField,
|
|
2372
|
+
{
|
|
2373
|
+
name: "from_name",
|
|
2374
|
+
label: t("settings.email.fromName.label", "Sender Name"),
|
|
2375
|
+
hint: t(
|
|
2376
|
+
"settings.email.fromName.hint",
|
|
2377
|
+
"The name users see as the sender, such as your app name."
|
|
2378
|
+
),
|
|
2379
|
+
error: errorFor("from_name"),
|
|
2380
|
+
children: /* @__PURE__ */ jsx(
|
|
2381
|
+
TextInput,
|
|
2382
|
+
{
|
|
2383
|
+
value: settings.from_name ?? "",
|
|
2384
|
+
onChange: (event) => update("from_name", event.target.value),
|
|
2385
|
+
placeholder: "My App",
|
|
2386
|
+
disabled,
|
|
2387
|
+
maxLength: 200
|
|
2388
|
+
}
|
|
2389
|
+
)
|
|
2390
|
+
}
|
|
2391
|
+
),
|
|
2392
|
+
/* @__PURE__ */ jsx(
|
|
2393
|
+
SettingField,
|
|
2394
|
+
{
|
|
2395
|
+
name: "from_email",
|
|
2396
|
+
label: t("settings.email.fromEmail.label", "Sender Email"),
|
|
2397
|
+
hint: t(
|
|
2398
|
+
"settings.email.fromEmail.hint",
|
|
2399
|
+
"Must be an address your email provider may send from. Leave empty to use the provider default."
|
|
2400
|
+
),
|
|
2401
|
+
error: errorFor("from_email"),
|
|
2402
|
+
children: /* @__PURE__ */ jsx(
|
|
2403
|
+
TextInput,
|
|
2404
|
+
{
|
|
2405
|
+
type: "email",
|
|
2406
|
+
value: settings.from_email ?? "",
|
|
2407
|
+
onChange: (event) => update("from_email", event.target.value),
|
|
2408
|
+
placeholder: "noreply@example.com",
|
|
2409
|
+
disabled
|
|
2410
|
+
}
|
|
2411
|
+
)
|
|
2412
|
+
}
|
|
2413
|
+
),
|
|
2414
|
+
/* @__PURE__ */ jsx(
|
|
2415
|
+
SettingField,
|
|
2416
|
+
{
|
|
2417
|
+
name: "response_email",
|
|
2418
|
+
label: t("settings.email.responseEmail.label", "Reply-to email"),
|
|
2419
|
+
hint: t(
|
|
2420
|
+
"settings.email.responseEmail.hint",
|
|
2421
|
+
"Optional. Replies from users go to this address."
|
|
2422
|
+
),
|
|
2423
|
+
error: errorFor("response_email"),
|
|
2424
|
+
children: /* @__PURE__ */ jsx(
|
|
2425
|
+
TextInput,
|
|
2426
|
+
{
|
|
2427
|
+
type: "email",
|
|
2428
|
+
value: settings.response_email ?? "",
|
|
2429
|
+
onChange: (event) => update("response_email", event.target.value),
|
|
2430
|
+
placeholder: "support@example.com",
|
|
2431
|
+
disabled
|
|
2432
|
+
}
|
|
2433
|
+
)
|
|
2434
|
+
}
|
|
2435
|
+
)
|
|
2436
|
+
] }),
|
|
2437
|
+
/* @__PURE__ */ jsx(
|
|
2438
|
+
SettingField,
|
|
2439
|
+
{
|
|
2440
|
+
name: "object",
|
|
2441
|
+
label: t("settings.email.subject.label", "Subject line"),
|
|
2442
|
+
hint: t(
|
|
2443
|
+
"settings.email.subject.hint",
|
|
2444
|
+
"Keep it clear so the email is not marked as spam. Placeholders such as {placeholder} work here too.",
|
|
2445
|
+
{ placeholder: "{{EMAIL}}" }
|
|
2446
|
+
),
|
|
2447
|
+
error: errorFor("object"),
|
|
2448
|
+
children: /* @__PURE__ */ jsx(
|
|
2449
|
+
TextInput,
|
|
2450
|
+
{
|
|
2451
|
+
value: settings.object ?? "",
|
|
2452
|
+
onChange: (event) => update("object", event.target.value),
|
|
2453
|
+
placeholder: t("settings.email.subject.placeholder", "Your sign-in link"),
|
|
2454
|
+
disabled,
|
|
2455
|
+
maxLength: 500
|
|
2456
|
+
}
|
|
2457
|
+
)
|
|
2458
|
+
}
|
|
2459
|
+
),
|
|
2460
|
+
emailDesignerInstalled ? /* @__PURE__ */ jsxs(Card, { children: [
|
|
2461
|
+
/* @__PURE__ */ jsx(
|
|
2462
|
+
SwitchRow,
|
|
2463
|
+
{
|
|
2464
|
+
icon: /* @__PURE__ */ jsx(Mail, { "aria-hidden": "true", width: "16px", height: "16px" }),
|
|
2465
|
+
title: t("settings.email.designer.useTemplate", "Use an Email Designer 5 template"),
|
|
2466
|
+
description: t(
|
|
2467
|
+
"settings.email.designer.useTemplateHint",
|
|
2468
|
+
"Login emails use the selected Email Designer template. The templates below stay as the fallback."
|
|
2469
|
+
),
|
|
2470
|
+
checked: settings.use_email_designer,
|
|
2471
|
+
onCheckedChange: (checked) => update("use_email_designer", checked),
|
|
2472
|
+
disabled
|
|
2473
|
+
}
|
|
2474
|
+
),
|
|
2475
|
+
settings.use_email_designer ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2476
|
+
/* @__PURE__ */ jsx(
|
|
2477
|
+
SettingField,
|
|
2478
|
+
{
|
|
2479
|
+
name: "email_designer_template_id",
|
|
2480
|
+
label: t("settings.email.designer.selectTemplate", "Email Designer template"),
|
|
2481
|
+
hint: emailDesignerTemplates.length > 0 ? t(
|
|
2482
|
+
"settings.email.designer.templatesAvailable",
|
|
2483
|
+
"{count, plural, one {# template available} other {# templates available}}. The choice applies after you save.",
|
|
2484
|
+
{ count: emailDesignerTemplates.length }
|
|
2485
|
+
) : void 0,
|
|
2486
|
+
error: settings.email_designer_template_id && !designerSelected && emailDesignerTemplates.length > 0 ? t(
|
|
2487
|
+
"settings.email.template.missing",
|
|
2488
|
+
"The saved template no longer exists. Select another one."
|
|
2489
|
+
) : void 0,
|
|
2490
|
+
children: /* @__PURE__ */ jsx(
|
|
2491
|
+
SingleSelect,
|
|
2492
|
+
{
|
|
2493
|
+
value: designerSelected ? String(settings.email_designer_template_id) : null,
|
|
2494
|
+
onChange: (value) => update("email_designer_template_id", String(value)),
|
|
2495
|
+
placeholder: t("settings.email.designer.placeholder", "Choose a template..."),
|
|
2496
|
+
disabled: disabled || emailDesignerTemplates.length === 0,
|
|
2497
|
+
children: emailDesignerTemplates.map((template) => /* @__PURE__ */ jsx(
|
|
2498
|
+
SingleSelectOption,
|
|
2499
|
+
{
|
|
2500
|
+
value: templateValue(template),
|
|
2501
|
+
children: templateName(
|
|
2502
|
+
template,
|
|
2503
|
+
t("settings.email.template.fallbackName", "Template {id}")
|
|
2504
|
+
)
|
|
2505
|
+
},
|
|
2506
|
+
templateValue(template)
|
|
2507
|
+
))
|
|
2508
|
+
}
|
|
2509
|
+
)
|
|
2510
|
+
}
|
|
2511
|
+
),
|
|
2512
|
+
emailDesignerTemplates.length === 0 ? /* @__PURE__ */ jsx(Note, { tone: "warning", icon: WarningCircle, compact: true, children: t(
|
|
2513
|
+
"settings.email.designer.noTemplates",
|
|
2514
|
+
"No templates found. Create one in Email Designer 5 first."
|
|
2515
|
+
) }) : null,
|
|
2516
|
+
/* @__PURE__ */ jsx(Note, { tone: "primary", compact: true, children: t(
|
|
2517
|
+
"settings.email.designer.variables",
|
|
2518
|
+
"Template variables: '{{magicLink}}' (login link), '{{expiryText}}', '{{user.email}}' and '{{user.username}}'."
|
|
2519
|
+
) })
|
|
2520
|
+
] }) : null
|
|
2521
|
+
] }) : null,
|
|
2522
|
+
magicMailInstalled ? /* @__PURE__ */ jsxs(Card, { children: [
|
|
2523
|
+
/* @__PURE__ */ jsx(
|
|
2524
|
+
SwitchRow,
|
|
2525
|
+
{
|
|
2526
|
+
icon: /* @__PURE__ */ jsx(Mail, { "aria-hidden": "true", width: "16px", height: "16px" }),
|
|
2527
|
+
title: t("settings.email.magicMail.use", "Send with Magic Mail"),
|
|
2528
|
+
description: t(
|
|
2529
|
+
"settings.email.magicMail.description",
|
|
2530
|
+
"Uses your Magic Mail accounts, routing rules and failover. Make sure at least one account is active."
|
|
2531
|
+
),
|
|
2532
|
+
checked: settings.use_magic_mail,
|
|
2533
|
+
onCheckedChange: (checked) => update("use_magic_mail", checked),
|
|
2534
|
+
disabled
|
|
2535
|
+
}
|
|
2536
|
+
),
|
|
2537
|
+
settings.use_magic_mail ? /* @__PURE__ */ jsx(
|
|
2538
|
+
SettingField,
|
|
2539
|
+
{
|
|
2540
|
+
name: "magic_mail_template_id",
|
|
2541
|
+
label: t("settings.email.magicMail.template", "Magic Mail template"),
|
|
2542
|
+
hint: t(
|
|
2543
|
+
"settings.email.magicMail.templateHint",
|
|
2544
|
+
"Optional. Templates are managed in Magic Mail. The choice applies after you save."
|
|
2545
|
+
),
|
|
2546
|
+
error: settings.magic_mail_template_id && !magicMailSelected && magicMailTemplates.length > 0 ? t(
|
|
2547
|
+
"settings.email.template.missing",
|
|
2548
|
+
"The saved template no longer exists. Select another one."
|
|
2549
|
+
) : void 0,
|
|
2550
|
+
children: /* @__PURE__ */ jsx(
|
|
2551
|
+
SingleSelect,
|
|
2552
|
+
{
|
|
2553
|
+
value: magicMailSelected ? String(settings.magic_mail_template_id) : null,
|
|
2554
|
+
onChange: (value) => {
|
|
2555
|
+
const template = magicMailTemplates.find(
|
|
2556
|
+
(item) => String(item.id) === String(value)
|
|
2557
|
+
);
|
|
2558
|
+
patch({
|
|
2559
|
+
magic_mail_template_id: String(value),
|
|
2560
|
+
magic_mail_template_reference_id: template?.templateReferenceId ?? ""
|
|
2561
|
+
});
|
|
2562
|
+
},
|
|
2563
|
+
placeholder: t("settings.email.magicMail.placeholder", "Choose a template"),
|
|
2564
|
+
disabled: disabled || magicMailTemplates.length === 0,
|
|
2565
|
+
children: magicMailTemplates.map((template) => /* @__PURE__ */ jsx(SingleSelectOption, { value: String(template.id), children: templateName(
|
|
2566
|
+
template,
|
|
2567
|
+
t("settings.email.template.fallbackName", "Template {id}")
|
|
2568
|
+
) }, template.id))
|
|
2569
|
+
}
|
|
2570
|
+
)
|
|
2571
|
+
}
|
|
2572
|
+
) : null
|
|
2573
|
+
] }) : null,
|
|
2574
|
+
/* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 4, alignItems: "stretch", children: [
|
|
2575
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "center", wrap: "wrap", children: [
|
|
2576
|
+
/* @__PURE__ */ jsx(
|
|
2577
|
+
GroupTitle,
|
|
2578
|
+
{
|
|
2579
|
+
title: t("settings.email.templates.title", "EMAIL TEMPLATES"),
|
|
2580
|
+
subtitle: usingThirdParty ? t(
|
|
2581
|
+
"settings.email.designer.fallbackDescription",
|
|
2582
|
+
"These templates are used when the selected integration cannot send."
|
|
2583
|
+
) : t(
|
|
2584
|
+
"settings.email.templates.subtitle",
|
|
2585
|
+
"Design the emails sent to users. Use placeholders for dynamic content."
|
|
2586
|
+
)
|
|
2587
|
+
}
|
|
2588
|
+
),
|
|
2589
|
+
usingThirdParty ? /* @__PURE__ */ jsx(Badge, { children: t("settings.email.designer.fallbackBadge", "Fallback only") }) : null
|
|
2590
|
+
] }),
|
|
2591
|
+
/* @__PURE__ */ jsxs(Card, { children: [
|
|
2592
|
+
/* @__PURE__ */ jsx(SubsectionHeading, { tag: "h4", children: t("settings.email.placeholders.title", "Available placeholders") }),
|
|
2593
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: t(
|
|
2594
|
+
"settings.email.placeholders.intro",
|
|
2595
|
+
"Values are inserted as plain text and escaped in HTML. Select a placeholder to copy it."
|
|
2596
|
+
) }),
|
|
2597
|
+
/* @__PURE__ */ jsx(CardGrid, { $min: "200px", children: PLACEHOLDERS.map((name) => {
|
|
2598
|
+
const code = `{{${name}}}`;
|
|
2599
|
+
return /* @__PURE__ */ jsxs(
|
|
2600
|
+
Tile,
|
|
2601
|
+
{
|
|
2602
|
+
type: "button",
|
|
2603
|
+
onClick: () => copy(code, {
|
|
2604
|
+
success: t("settings.email.placeholders.copied", "{placeholder} copied", {
|
|
2605
|
+
placeholder: code
|
|
2606
|
+
})
|
|
2607
|
+
}),
|
|
2608
|
+
"aria-label": t("settings.email.placeholders.copyLabel", "Copy {placeholder}", {
|
|
2609
|
+
placeholder: code
|
|
2610
|
+
}),
|
|
2611
|
+
children: [
|
|
2612
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "center", justifyContent: "space-between", width: "100%", children: [
|
|
2613
|
+
/* @__PURE__ */ jsx(Typography, { variant: "omega", fontWeight: "bold", textColor: "neutral800", children: /* @__PURE__ */ jsx(Mono, { children: code }) }),
|
|
2614
|
+
/* @__PURE__ */ jsx(Duplicate, { "aria-hidden": "true", width: "14px", height: "14px" })
|
|
2615
|
+
] }),
|
|
2616
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: {
|
|
2617
|
+
LOGIN_URL: t(
|
|
2618
|
+
"settings.email.placeholders.loginUrl.description",
|
|
2619
|
+
"Complete login link with the token. Use it for buttons and links."
|
|
2620
|
+
),
|
|
2621
|
+
EXPIRY_TEXT: t(
|
|
2622
|
+
"settings.email.placeholders.expiry.description",
|
|
2623
|
+
"How long the link stays valid, such as 1 hour."
|
|
2624
|
+
),
|
|
2625
|
+
EMAIL: t(
|
|
2626
|
+
"settings.email.placeholders.email.description",
|
|
2627
|
+
"Email address of the recipient."
|
|
2628
|
+
),
|
|
2629
|
+
USER: t(
|
|
2630
|
+
"settings.email.placeholders.username.description",
|
|
2631
|
+
"Username of the recipient, or the email address when there is none."
|
|
2632
|
+
),
|
|
2633
|
+
URL: t(
|
|
2634
|
+
"settings.email.placeholders.url.description",
|
|
2635
|
+
"Login URL without the token."
|
|
2636
|
+
),
|
|
2637
|
+
CODE: t(
|
|
2638
|
+
"settings.email.placeholders.code.description",
|
|
2639
|
+
"The token alone, for custom link formats only."
|
|
2640
|
+
),
|
|
2641
|
+
EXPIRES_AT: t(
|
|
2642
|
+
"settings.email.placeholders.expiresAt.description",
|
|
2643
|
+
"Expiry date and time in ISO 8601 format."
|
|
2644
|
+
)
|
|
2645
|
+
}[name] })
|
|
2646
|
+
]
|
|
2647
|
+
},
|
|
2648
|
+
name
|
|
2649
|
+
);
|
|
2650
|
+
}) })
|
|
2651
|
+
] }),
|
|
2652
|
+
!settings.use_magic_mail ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2653
|
+
/* @__PURE__ */ jsxs(Card, { children: [
|
|
2654
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "center", wrap: "wrap", children: [
|
|
2655
|
+
/* @__PURE__ */ jsx(Mail, { "aria-hidden": "true", width: "20px", height: "20px" }),
|
|
2656
|
+
/* @__PURE__ */ jsx(SubsectionHeading, { tag: "h4", variant: "delta", children: t("settings.email.gallery.title", "Template gallery") })
|
|
2657
|
+
] }),
|
|
2658
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: t(
|
|
2659
|
+
"settings.email.gallery.description",
|
|
2660
|
+
"Load a ready-made design into the editors below. Save to apply it."
|
|
2661
|
+
) }),
|
|
2662
|
+
/* @__PURE__ */ jsx(CardGrid, { $min: "180px", role: "list", children: gallery.map((template) => {
|
|
2663
|
+
const isActive = activeGalleryKey === template.value;
|
|
2664
|
+
return /* @__PURE__ */ jsx("div", { role: "listitem", style: { minWidth: 0 }, children: /* @__PURE__ */ jsxs(
|
|
2665
|
+
Tile,
|
|
2666
|
+
{
|
|
2667
|
+
type: "button",
|
|
2668
|
+
$active: isActive,
|
|
2669
|
+
"aria-pressed": isActive,
|
|
2670
|
+
disabled,
|
|
2671
|
+
onClick: () => {
|
|
2672
|
+
const selected = getTemplate(template.value);
|
|
2673
|
+
requestTemplate({
|
|
2674
|
+
label: template.label,
|
|
2675
|
+
values: { message_html: selected.html, message_text: selected.text }
|
|
2676
|
+
});
|
|
2677
|
+
},
|
|
2678
|
+
children: [
|
|
2679
|
+
/* @__PURE__ */ jsxs(
|
|
2680
|
+
Flex,
|
|
2681
|
+
{
|
|
2682
|
+
gap: 2,
|
|
2683
|
+
alignItems: "center",
|
|
2684
|
+
justifyContent: "space-between",
|
|
2685
|
+
width: "100%",
|
|
2686
|
+
children: [
|
|
2687
|
+
/* @__PURE__ */ jsx(Typography, { variant: "omega", fontWeight: "bold", textColor: "neutral800", children: template.label }),
|
|
2688
|
+
isActive ? /* @__PURE__ */ jsx(Badge, { variant: "success", size: "S", children: t("settings.email.gallery.active", "In use") }) : null
|
|
2689
|
+
]
|
|
2690
|
+
}
|
|
2691
|
+
),
|
|
2692
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: template.preview })
|
|
2693
|
+
]
|
|
2694
|
+
}
|
|
2695
|
+
) }, template.value);
|
|
2696
|
+
}) })
|
|
2697
|
+
] }),
|
|
2698
|
+
/* @__PURE__ */ jsxs(Card, { children: [
|
|
2699
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, justifyContent: "space-between", alignItems: "center", wrap: "wrap", children: [
|
|
2700
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "center", wrap: "wrap", children: [
|
|
2701
|
+
/* @__PURE__ */ jsx(
|
|
2702
|
+
SubsectionHeading,
|
|
2703
|
+
{
|
|
2704
|
+
tag: "h4",
|
|
2705
|
+
variant: "delta",
|
|
2706
|
+
id: `${fieldId("message_html")}-label`,
|
|
2707
|
+
children: t("settings.email.html.title", "HTML template")
|
|
2708
|
+
}
|
|
2709
|
+
),
|
|
2710
|
+
/* @__PURE__ */ jsx(Badge, { variant: "success", children: t("settings.email.html.badge", "Main Template") })
|
|
2711
|
+
] }),
|
|
2712
|
+
/* @__PURE__ */ jsx(
|
|
2713
|
+
Button,
|
|
2714
|
+
{
|
|
2715
|
+
variant: "tertiary",
|
|
2716
|
+
size: "S",
|
|
2717
|
+
disabled,
|
|
2718
|
+
onClick: () => requestTemplate({
|
|
2719
|
+
label: t("settings.email.default.name", "Default template"),
|
|
2720
|
+
values: { message_html: defaults.html }
|
|
2721
|
+
}),
|
|
2722
|
+
children: t("settings.email.html.loadDefault", "Load Default Template")
|
|
2723
|
+
}
|
|
2724
|
+
)
|
|
2725
|
+
] }),
|
|
2726
|
+
/* @__PURE__ */ jsx(
|
|
2727
|
+
Typography,
|
|
2728
|
+
{
|
|
2729
|
+
variant: "pi",
|
|
2730
|
+
textColor: "neutral600",
|
|
2731
|
+
id: `${fieldId("message_html")}-hint`,
|
|
2732
|
+
children: t(
|
|
2733
|
+
"settings.email.html.description",
|
|
2734
|
+
"HTML version of the login email. Use the placeholders above, at least {placeholder}.",
|
|
2735
|
+
{ placeholder: "{{LOGIN_URL}}" }
|
|
2736
|
+
)
|
|
2737
|
+
}
|
|
2738
|
+
),
|
|
2739
|
+
/* @__PURE__ */ jsx(
|
|
2740
|
+
CodeEditor,
|
|
2741
|
+
{
|
|
2742
|
+
id: fieldId("message_html"),
|
|
2743
|
+
labelledBy: `${fieldId("message_html")}-label`,
|
|
2744
|
+
describedBy: `${fieldId("message_html")}-hint`,
|
|
2745
|
+
filename: "email.html",
|
|
2746
|
+
value: settings.message_html,
|
|
2747
|
+
onChange: (value) => update("message_html", value),
|
|
2748
|
+
placeholder: '<p><a href="{{LOGIN_URL}}">...</a></p>',
|
|
2749
|
+
disabled,
|
|
2750
|
+
dark: true,
|
|
2751
|
+
height: "560px"
|
|
2752
|
+
}
|
|
2753
|
+
),
|
|
2754
|
+
errorFor("message_html") ? /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "danger600", role: "alert", children: errorFor("message_html") }) : null,
|
|
2755
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, justifyContent: "space-between", alignItems: "center", wrap: "wrap", children: [
|
|
2756
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: t(
|
|
2757
|
+
"settings.email.html.stats",
|
|
2758
|
+
"{lines, plural, one {# line} other {# lines}}, {chars, plural, one {# character} other {# characters}}",
|
|
2759
|
+
htmlStats
|
|
2760
|
+
) }),
|
|
2761
|
+
/* @__PURE__ */ jsx(
|
|
2762
|
+
Button,
|
|
2763
|
+
{
|
|
2764
|
+
variant: "secondary",
|
|
2765
|
+
size: "S",
|
|
2766
|
+
startIcon: /* @__PURE__ */ jsx(Duplicate, {}),
|
|
2767
|
+
onClick: () => copy(settings.message_html ?? "", {
|
|
2768
|
+
success: t("settings.email.html.copied", "HTML template copied")
|
|
2769
|
+
}),
|
|
2770
|
+
children: t("settings.email.html.copy", "Copy Template")
|
|
2771
|
+
}
|
|
2772
|
+
)
|
|
2773
|
+
] })
|
|
2774
|
+
] }),
|
|
2775
|
+
/* @__PURE__ */ jsxs(Card, { children: [
|
|
2776
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, justifyContent: "space-between", alignItems: "center", wrap: "wrap", children: [
|
|
2777
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "center", wrap: "wrap", children: [
|
|
2778
|
+
/* @__PURE__ */ jsx(
|
|
2779
|
+
SubsectionHeading,
|
|
2780
|
+
{
|
|
2781
|
+
tag: "h4",
|
|
2782
|
+
variant: "delta",
|
|
2783
|
+
id: `${fieldId("message_text")}-label`,
|
|
2784
|
+
children: t("settings.email.text.title", "Plain text template")
|
|
2785
|
+
}
|
|
2786
|
+
),
|
|
2787
|
+
/* @__PURE__ */ jsx(Badge, { children: t("settings.email.text.badge", "Fallback") })
|
|
2788
|
+
] }),
|
|
2789
|
+
/* @__PURE__ */ jsx(
|
|
2790
|
+
Button,
|
|
2791
|
+
{
|
|
2792
|
+
variant: "tertiary",
|
|
2793
|
+
size: "S",
|
|
2794
|
+
disabled,
|
|
2795
|
+
onClick: () => requestTemplate({
|
|
2796
|
+
label: t("settings.email.default.name", "Default template"),
|
|
2797
|
+
values: { message_text: defaults.text }
|
|
2798
|
+
}),
|
|
2799
|
+
children: t("settings.email.text.loadDefault", "Load Default Template")
|
|
2800
|
+
}
|
|
2801
|
+
)
|
|
2802
|
+
] }),
|
|
2803
|
+
/* @__PURE__ */ jsx(
|
|
2804
|
+
Typography,
|
|
2805
|
+
{
|
|
2806
|
+
variant: "pi",
|
|
2807
|
+
textColor: "neutral600",
|
|
2808
|
+
id: `${fieldId("message_text")}-hint`,
|
|
2809
|
+
children: t(
|
|
2810
|
+
"settings.email.text.description",
|
|
2811
|
+
"Text version for email clients without HTML. Placeholders are inserted as they are."
|
|
2812
|
+
)
|
|
2813
|
+
}
|
|
2814
|
+
),
|
|
2815
|
+
/* @__PURE__ */ jsx(
|
|
2816
|
+
CodeEditor,
|
|
2817
|
+
{
|
|
2818
|
+
id: fieldId("message_text"),
|
|
2819
|
+
labelledBy: `${fieldId("message_text")}-label`,
|
|
2820
|
+
describedBy: `${fieldId("message_text")}-hint`,
|
|
2821
|
+
filename: "email.txt",
|
|
2822
|
+
value: settings.message_text,
|
|
2823
|
+
onChange: (value) => update("message_text", value),
|
|
2824
|
+
placeholder: "{{LOGIN_URL}}",
|
|
2825
|
+
disabled,
|
|
2826
|
+
height: "320px",
|
|
2827
|
+
mobileHeight: "240px"
|
|
2828
|
+
}
|
|
2829
|
+
),
|
|
2830
|
+
errorFor("message_text") ? /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "danger600", role: "alert", children: errorFor("message_text") }) : null,
|
|
2831
|
+
/* @__PURE__ */ jsx(Flex, { justifyContent: "flex-end", children: /* @__PURE__ */ jsx(
|
|
2832
|
+
Button,
|
|
2833
|
+
{
|
|
2834
|
+
variant: "secondary",
|
|
2835
|
+
size: "S",
|
|
2836
|
+
startIcon: /* @__PURE__ */ jsx(Duplicate, {}),
|
|
2837
|
+
onClick: () => copy(settings.message_text ?? "", {
|
|
2838
|
+
success: t("settings.email.text.copied", "Text template copied")
|
|
2839
|
+
}),
|
|
2840
|
+
children: t("settings.email.text.copy", "Copy Template")
|
|
2841
|
+
}
|
|
2842
|
+
) })
|
|
2843
|
+
] })
|
|
2844
|
+
] }) : null
|
|
2845
|
+
] }),
|
|
2846
|
+
/* @__PURE__ */ jsx(
|
|
2847
|
+
ConfirmDialog,
|
|
2848
|
+
{
|
|
2849
|
+
isOpen: !disabled && pendingTemplate !== null,
|
|
2850
|
+
variant: "danger",
|
|
2851
|
+
title: t("settings.email.overwrite.title", "Replace your template?"),
|
|
2852
|
+
description: t(
|
|
2853
|
+
"settings.email.overwrite.body",
|
|
2854
|
+
'Loading "{name}" replaces your custom template in the editor. Your saved settings stay unchanged until you save.',
|
|
2855
|
+
{ name: pendingTemplate?.label ?? "" }
|
|
2856
|
+
),
|
|
2857
|
+
confirmLabel: t("settings.email.overwrite.confirm", "Replace template"),
|
|
2858
|
+
onConfirm: () => {
|
|
2859
|
+
if (pendingTemplate) patch(pendingTemplate.values);
|
|
2860
|
+
setPendingTemplate(null);
|
|
2861
|
+
},
|
|
2862
|
+
onClose: () => setPendingTemplate(null)
|
|
2863
|
+
}
|
|
2864
|
+
)
|
|
2865
|
+
] });
|
|
2866
|
+
};
|
|
2867
|
+
const RateLimitSection = ({
|
|
2868
|
+
settings,
|
|
2869
|
+
update,
|
|
2870
|
+
disabled,
|
|
2871
|
+
canManage,
|
|
2872
|
+
errorFor,
|
|
2873
|
+
stats,
|
|
2874
|
+
onRetryStats,
|
|
2875
|
+
onCleanup,
|
|
2876
|
+
onRequestReset,
|
|
2877
|
+
isCleaning,
|
|
2878
|
+
statusOn,
|
|
2879
|
+
statusOff
|
|
2880
|
+
}) => {
|
|
2881
|
+
const { t } = useMagicLinkIntl();
|
|
2882
|
+
const { formatNumber } = useFormatters();
|
|
2883
|
+
const value = (key) => formatNumber(Number(stats.data?.[key] ?? 0));
|
|
2884
|
+
return /* @__PURE__ */ jsxs(SectionBody, { children: [
|
|
2885
|
+
/* @__PURE__ */ jsxs(Panel, { children: [
|
|
2886
|
+
/* @__PURE__ */ jsx(
|
|
2887
|
+
GroupTitle,
|
|
2888
|
+
{
|
|
2889
|
+
align: "center",
|
|
2890
|
+
title: t("settings.rateLimit.title", "RATE LIMITING"),
|
|
2891
|
+
subtitle: t(
|
|
2892
|
+
"settings.rateLimit.subtitle",
|
|
2893
|
+
"Prevent abuse by limiting the number of requests per IP address and email"
|
|
2894
|
+
)
|
|
2895
|
+
}
|
|
2896
|
+
),
|
|
2897
|
+
/* @__PURE__ */ jsx(CardGrid, { $min: "260px", children: /* @__PURE__ */ jsx(
|
|
2898
|
+
ToggleCard,
|
|
2899
|
+
{
|
|
2900
|
+
title: t("settings.rateLimit.enable.title", "Enable Rate Limiting"),
|
|
2901
|
+
description: t(
|
|
2902
|
+
"settings.rateLimit.enable.description",
|
|
2903
|
+
"Protects against brute-force attacks and spam by limiting login link requests."
|
|
2904
|
+
),
|
|
2905
|
+
checked: settings.rate_limit_enabled,
|
|
2906
|
+
onCheckedChange: (checked) => update("rate_limit_enabled", checked),
|
|
2907
|
+
disabled,
|
|
2908
|
+
statusOn,
|
|
2909
|
+
statusOff
|
|
2910
|
+
}
|
|
2911
|
+
) })
|
|
2912
|
+
] }),
|
|
2913
|
+
/* @__PURE__ */ jsx(GroupTitle, { title: t("settings.rateLimit.config.title", "Limits") }),
|
|
2914
|
+
/* @__PURE__ */ jsxs(FieldGrid, { $columns: 2, children: [
|
|
2915
|
+
/* @__PURE__ */ jsx(
|
|
2916
|
+
SettingField,
|
|
2917
|
+
{
|
|
2918
|
+
name: "rate_limit_max_attempts",
|
|
2919
|
+
label: t("settings.rateLimit.maxAttempts.label", "Maximum requests"),
|
|
2920
|
+
hint: t(
|
|
2921
|
+
"settings.rateLimit.maxAttempts.hint",
|
|
2922
|
+
"Requests allowed per time window before blocking (1 to 1,000)."
|
|
2923
|
+
),
|
|
2924
|
+
error: errorFor("rate_limit_max_attempts"),
|
|
2925
|
+
required: true,
|
|
2926
|
+
children: /* @__PURE__ */ jsx(
|
|
2927
|
+
NumberInput,
|
|
2928
|
+
{
|
|
2929
|
+
value: settings.rate_limit_max_attempts,
|
|
2930
|
+
onValueChange: (next) => update("rate_limit_max_attempts", next),
|
|
2931
|
+
min: 1,
|
|
2932
|
+
max: 1e3,
|
|
2933
|
+
disabled
|
|
2934
|
+
}
|
|
2935
|
+
)
|
|
2936
|
+
}
|
|
2937
|
+
),
|
|
2938
|
+
/* @__PURE__ */ jsx(
|
|
2939
|
+
SettingField,
|
|
2940
|
+
{
|
|
2941
|
+
name: "rate_limit_window_minutes",
|
|
2942
|
+
label: t("settings.rateLimit.windowMinutes.label", "Time Window (Minutes)"),
|
|
2943
|
+
hint: t(
|
|
2944
|
+
"settings.rateLimit.windowMinutes.hint",
|
|
2945
|
+
"Period in which requests are counted (1 to 10,080 minutes)."
|
|
2946
|
+
),
|
|
2947
|
+
error: errorFor("rate_limit_window_minutes"),
|
|
2948
|
+
required: true,
|
|
2949
|
+
children: /* @__PURE__ */ jsx(
|
|
2950
|
+
NumberInput,
|
|
2951
|
+
{
|
|
2952
|
+
value: settings.rate_limit_window_minutes,
|
|
2953
|
+
onValueChange: (next) => update("rate_limit_window_minutes", next),
|
|
2954
|
+
min: 1,
|
|
2955
|
+
max: 10080,
|
|
2956
|
+
disabled
|
|
2957
|
+
}
|
|
2958
|
+
)
|
|
2959
|
+
}
|
|
2960
|
+
)
|
|
2961
|
+
] }),
|
|
2962
|
+
/* @__PURE__ */ jsx(GroupTitle, { title: t("settings.rateLimit.stats.title", "CURRENT STATISTICS") }),
|
|
2963
|
+
stats.status === "loading" || stats.status === "idle" ? /* @__PURE__ */ jsx(Flex, { justifyContent: "center", padding: 4, children: /* @__PURE__ */ jsx(Loader, { small: true, children: t("settings.rateLimit.stats.loading", "Loading statistics...") }) }) : null,
|
|
2964
|
+
stats.status === "error" ? /* @__PURE__ */ jsx(Note, { tone: "danger", role: "alert", children: /* @__PURE__ */ jsxs(Flex, { gap: 3, alignItems: "center", wrap: "wrap", children: [
|
|
2965
|
+
/* @__PURE__ */ jsx("span", { children: t("settings.rateLimit.stats.error", "Statistics could not be loaded.") }),
|
|
2966
|
+
/* @__PURE__ */ jsx(
|
|
2967
|
+
Button,
|
|
2968
|
+
{
|
|
2969
|
+
variant: "tertiary",
|
|
2970
|
+
size: "S",
|
|
2971
|
+
startIcon: /* @__PURE__ */ jsx(ArrowClockwise, {}),
|
|
2972
|
+
onClick: onRetryStats,
|
|
2973
|
+
children: t("settings.retry", "Try again")
|
|
2974
|
+
}
|
|
2975
|
+
)
|
|
2976
|
+
] }) }) : null,
|
|
2977
|
+
stats.status === "ready" ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2978
|
+
/* @__PURE__ */ jsxs(CardGrid, { $min: "160px", children: [
|
|
2979
|
+
/* @__PURE__ */ jsx(
|
|
2980
|
+
Stat,
|
|
2981
|
+
{
|
|
2982
|
+
tone: "neutral",
|
|
2983
|
+
value: value("totalEntries"),
|
|
2984
|
+
label: t("settings.rateLimit.stats.total", "Total Entries")
|
|
2985
|
+
}
|
|
2986
|
+
),
|
|
2987
|
+
/* @__PURE__ */ jsx(
|
|
2988
|
+
Stat,
|
|
2989
|
+
{
|
|
2990
|
+
tone: "danger",
|
|
2991
|
+
value: value("blocked"),
|
|
2992
|
+
label: t("settings.rateLimit.stats.blocked", "Currently Blocked")
|
|
2993
|
+
}
|
|
2994
|
+
),
|
|
2995
|
+
/* @__PURE__ */ jsx(
|
|
2996
|
+
Stat,
|
|
2997
|
+
{
|
|
2998
|
+
tone: "primary",
|
|
2999
|
+
value: value("ipLimits"),
|
|
3000
|
+
label: t("settings.rateLimit.stats.ipLimits", "IP address limits")
|
|
3001
|
+
}
|
|
3002
|
+
),
|
|
3003
|
+
/* @__PURE__ */ jsx(
|
|
3004
|
+
Stat,
|
|
3005
|
+
{
|
|
3006
|
+
tone: "success",
|
|
3007
|
+
value: value("emailLimits"),
|
|
3008
|
+
label: t("settings.rateLimit.stats.emailLimits", "Email Limits")
|
|
3009
|
+
}
|
|
3010
|
+
)
|
|
3011
|
+
] }),
|
|
3012
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: t(
|
|
3013
|
+
"settings.rateLimit.stats.note",
|
|
3014
|
+
"Statistics cover this server process only. Cleanup and reset do not change your unsaved settings."
|
|
3015
|
+
) }),
|
|
3016
|
+
canManage ? /* @__PURE__ */ jsxs(Flex, { gap: 2, justifyContent: "center", wrap: "wrap", children: [
|
|
3017
|
+
/* @__PURE__ */ jsx(
|
|
3018
|
+
Button,
|
|
3019
|
+
{
|
|
3020
|
+
variant: "secondary",
|
|
3021
|
+
size: "M",
|
|
3022
|
+
onClick: onCleanup,
|
|
3023
|
+
loading: isCleaning,
|
|
3024
|
+
startIcon: /* @__PURE__ */ jsx(ArrowClockwise, {}),
|
|
3025
|
+
children: t("settings.rateLimit.cleanup", "Clean up expired entries")
|
|
3026
|
+
}
|
|
3027
|
+
),
|
|
3028
|
+
/* @__PURE__ */ jsx(
|
|
3029
|
+
Button,
|
|
3030
|
+
{
|
|
3031
|
+
variant: "danger-light",
|
|
3032
|
+
size: "M",
|
|
3033
|
+
onClick: onRequestReset,
|
|
3034
|
+
startIcon: /* @__PURE__ */ jsx(Trash, {}),
|
|
3035
|
+
children: t("settings.rateLimit.reset", "Reset All Limits")
|
|
3036
|
+
}
|
|
3037
|
+
)
|
|
3038
|
+
] }) : null
|
|
3039
|
+
] }) : null
|
|
3040
|
+
] });
|
|
3041
|
+
};
|
|
3042
|
+
const AdvancedSection = ({ settings, update, disabled, statusOn, statusOff }) => {
|
|
3043
|
+
const { t } = useMagicLinkIntl();
|
|
3044
|
+
return /* @__PURE__ */ jsxs(SectionBody, { children: [
|
|
3045
|
+
/* @__PURE__ */ jsx(
|
|
3046
|
+
GroupTitle,
|
|
3047
|
+
{
|
|
3048
|
+
title: t("settings.advanced.title", "ADDITIONAL OPTIONS"),
|
|
3049
|
+
subtitle: t("settings.advanced.subtitle", "Advanced features for special use cases")
|
|
3050
|
+
}
|
|
3051
|
+
),
|
|
3052
|
+
/* @__PURE__ */ jsxs(CardGrid, { $min: "260px", children: [
|
|
3053
|
+
/* @__PURE__ */ jsx(
|
|
3054
|
+
ToggleCard,
|
|
3055
|
+
{
|
|
3056
|
+
title: t(
|
|
3057
|
+
"settings.advanced.publicRegistration.title",
|
|
3058
|
+
"Magic Links on Public Registration"
|
|
3059
|
+
),
|
|
3060
|
+
description: t(
|
|
3061
|
+
"settings.advanced.publicRegistration.description",
|
|
3062
|
+
"Allows users to sign in with a magic link from public registration forms without a password."
|
|
3063
|
+
),
|
|
3064
|
+
checked: settings.allow_magic_links_on_public_registration,
|
|
3065
|
+
onCheckedChange: (checked) => update("allow_magic_links_on_public_registration", checked),
|
|
3066
|
+
disabled,
|
|
3067
|
+
statusOn,
|
|
3068
|
+
statusOff
|
|
3069
|
+
}
|
|
3070
|
+
),
|
|
3071
|
+
/* @__PURE__ */ jsx(
|
|
3072
|
+
ToggleCard,
|
|
3073
|
+
{
|
|
3074
|
+
title: t(
|
|
3075
|
+
"settings.compatibility.passwordless.title",
|
|
3076
|
+
"Passwordless Plugin Compatibility"
|
|
3077
|
+
),
|
|
3078
|
+
description: t(
|
|
3079
|
+
"settings.compatibility.passwordless.description",
|
|
3080
|
+
"Keeps the /api/passwordless routes of strapi-plugin-passwordless working while you migrate."
|
|
3081
|
+
),
|
|
3082
|
+
checked: settings.passwordlessCompatibility,
|
|
3083
|
+
onCheckedChange: (checked) => update("passwordlessCompatibility", checked),
|
|
3084
|
+
disabled,
|
|
3085
|
+
statusOn,
|
|
3086
|
+
statusOff,
|
|
3087
|
+
children: settings.passwordlessCompatibility ? /* @__PURE__ */ jsxs(Note, { tone: "success", compact: true, children: [
|
|
3088
|
+
/* @__PURE__ */ jsx(Mono, { children: "POST /api/passwordless/send-link" }),
|
|
3089
|
+
/* @__PURE__ */ jsx("br", {}),
|
|
3090
|
+
/* @__PURE__ */ jsx(Mono, { children: "GET /api/passwordless/login" })
|
|
3091
|
+
] }) : null
|
|
3092
|
+
}
|
|
3093
|
+
)
|
|
3094
|
+
] }),
|
|
3095
|
+
/* @__PURE__ */ jsx(
|
|
3096
|
+
Note,
|
|
3097
|
+
{
|
|
3098
|
+
tone: "warning",
|
|
3099
|
+
icon: Code,
|
|
3100
|
+
title: t("settings.compatibility.migration.title", "Migration Note"),
|
|
3101
|
+
children: t(
|
|
3102
|
+
"settings.compatibility.migration.description",
|
|
3103
|
+
"After the migration, switch your clients to the Magic Link API (/api/magic-link/*) and turn compatibility mode off."
|
|
3104
|
+
)
|
|
3105
|
+
}
|
|
3106
|
+
)
|
|
3107
|
+
] });
|
|
3108
|
+
};
|
|
3109
|
+
const Container = styled.form`
|
|
3110
|
+
max-width: 1440px;
|
|
3111
|
+
margin: 0 auto;
|
|
3112
|
+
`;
|
|
3113
|
+
const StickySaveBar = styled.div`
|
|
3114
|
+
position: sticky;
|
|
3115
|
+
top: 0;
|
|
3116
|
+
z-index: 10;
|
|
3117
|
+
display: flex;
|
|
3118
|
+
flex-wrap: wrap;
|
|
3119
|
+
align-items: center;
|
|
3120
|
+
justify-content: space-between;
|
|
3121
|
+
gap: ${space(4)};
|
|
3122
|
+
padding: ${space(6)};
|
|
3123
|
+
background: ${color("neutral0")};
|
|
3124
|
+
border-bottom: 1px solid ${color("neutral150")};
|
|
3125
|
+
box-shadow: ${theme.shadows.sm};
|
|
3126
|
+
|
|
3127
|
+
@media (max-width: 768px) {
|
|
3128
|
+
position: static;
|
|
3129
|
+
padding: ${space(4)};
|
|
3130
|
+
}
|
|
3131
|
+
`;
|
|
3132
|
+
const HeaderActions = styled.div`
|
|
3133
|
+
display: flex;
|
|
3134
|
+
flex-wrap: wrap;
|
|
3135
|
+
align-items: center;
|
|
3136
|
+
justify-content: flex-end;
|
|
3137
|
+
gap: ${space(2)};
|
|
3138
|
+
min-width: 0;
|
|
3139
|
+
`;
|
|
3140
|
+
const Content = styled.div`
|
|
3141
|
+
display: flex;
|
|
3142
|
+
flex-direction: column;
|
|
3143
|
+
gap: ${space(6)};
|
|
3144
|
+
padding: ${space(6)} ${space(6)} ${space(7)};
|
|
3145
|
+
|
|
3146
|
+
@media (max-width: 768px) {
|
|
3147
|
+
padding: ${space(4)};
|
|
3148
|
+
}
|
|
3149
|
+
`;
|
|
3150
|
+
const InfoBanner = styled.div`
|
|
3151
|
+
display: flex;
|
|
3152
|
+
gap: ${space(3)};
|
|
3153
|
+
align-items: flex-start;
|
|
3154
|
+
padding: ${space(6)};
|
|
3155
|
+
border-radius: ${theme.borderRadius.lg};
|
|
3156
|
+
background: ${tint.primary.bg};
|
|
3157
|
+
border: 1px solid ${tint.primary.border};
|
|
3158
|
+
|
|
3159
|
+
@media (max-width: 480px) {
|
|
3160
|
+
padding: ${space(4)};
|
|
3161
|
+
}
|
|
3162
|
+
`;
|
|
3163
|
+
const BannerIcon = styled.span`
|
|
3164
|
+
display: inline-flex;
|
|
3165
|
+
align-items: center;
|
|
3166
|
+
justify-content: center;
|
|
3167
|
+
flex-shrink: 0;
|
|
3168
|
+
width: 40px;
|
|
3169
|
+
height: 40px;
|
|
3170
|
+
border-radius: 50%;
|
|
3171
|
+
background: ${brand.primary};
|
|
3172
|
+
|
|
3173
|
+
svg {
|
|
3174
|
+
width: 20px;
|
|
3175
|
+
height: 20px;
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
svg path {
|
|
3179
|
+
fill: ${brand.onFill};
|
|
3180
|
+
}
|
|
3181
|
+
`;
|
|
3182
|
+
const FooterBox = styled.div`
|
|
3183
|
+
display: flex;
|
|
3184
|
+
gap: ${space(3)};
|
|
3185
|
+
align-items: center;
|
|
3186
|
+
justify-content: center;
|
|
3187
|
+
padding: ${space(6)};
|
|
3188
|
+
border-radius: ${theme.borderRadius.lg};
|
|
3189
|
+
background: ${tint.success.bg};
|
|
3190
|
+
border: 1px solid ${tint.success.border};
|
|
3191
|
+
text-align: left;
|
|
3192
|
+
|
|
3193
|
+
> svg {
|
|
3194
|
+
flex-shrink: 0;
|
|
3195
|
+
width: 24px;
|
|
3196
|
+
height: 24px;
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
> svg path {
|
|
3200
|
+
fill: ${tint.success.fg};
|
|
3201
|
+
}
|
|
3202
|
+
`;
|
|
3203
|
+
const UnsavedBadge = styled(Badge)`
|
|
3204
|
+
&& {
|
|
3205
|
+
background: ${tint.warning.bg};
|
|
3206
|
+
box-shadow: inset 0 0 0 1px ${tint.warning.border};
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
&& span {
|
|
3210
|
+
color: ${tint.warning.fg};
|
|
3211
|
+
}
|
|
3212
|
+
`;
|
|
3213
|
+
const SectionErrorMark = styled.span`
|
|
3214
|
+
display: inline-block;
|
|
3215
|
+
margin-left: ${space(1)};
|
|
3216
|
+
padding: 0 ${space(2)};
|
|
3217
|
+
border-radius: ${theme.borderRadius.sm};
|
|
3218
|
+
font-size: 1.1rem;
|
|
3219
|
+
font-weight: 700;
|
|
3220
|
+
line-height: 1.8rem;
|
|
3221
|
+
text-transform: uppercase;
|
|
3222
|
+
vertical-align: middle;
|
|
3223
|
+
background: ${tint.danger.bg};
|
|
3224
|
+
box-shadow: inset 0 0 0 1px ${tint.danger.border};
|
|
3225
|
+
color: ${tint.danger.fg};
|
|
3226
|
+
`;
|
|
3227
|
+
const SaveButton = styled(Button)`
|
|
3228
|
+
&& {
|
|
3229
|
+
background: ${brand.gradient};
|
|
3230
|
+
border: none;
|
|
3231
|
+
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.3);
|
|
3232
|
+
transition:
|
|
3233
|
+
box-shadow ${theme.transitions.fast},
|
|
3234
|
+
transform ${theme.transitions.fast};
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
&& span,
|
|
3238
|
+
&& svg path {
|
|
3239
|
+
color: ${brand.onFill};
|
|
3240
|
+
fill: ${brand.onFill};
|
|
3241
|
+
font-weight: 700;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
&&:hover:not([aria-disabled='true']) {
|
|
3245
|
+
background: ${brand.gradientHover};
|
|
3246
|
+
transform: translateY(-1px);
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
&&[aria-disabled='true'] {
|
|
3250
|
+
opacity: 0.5;
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
${reducedMotion}
|
|
3254
|
+
`;
|
|
3255
|
+
const toFormState = (raw) => {
|
|
3256
|
+
const clean = sanitizeLoadedSettings(raw);
|
|
3257
|
+
for (const key of ["message_html", "message_text", "object"]) {
|
|
3258
|
+
if (typeof clean[key] === "string") clean[key] = toPlaceholderSyntax(clean[key]);
|
|
3259
|
+
}
|
|
3260
|
+
return clean;
|
|
3261
|
+
};
|
|
3262
|
+
const listFrom = (payload) => {
|
|
3263
|
+
const data = payload?.data ?? payload;
|
|
3264
|
+
if (Array.isArray(data)) return data;
|
|
3265
|
+
if (Array.isArray(data?.data)) return data.data;
|
|
3266
|
+
if (Array.isArray(data?.templates)) return data.templates;
|
|
3267
|
+
return [];
|
|
3268
|
+
};
|
|
3269
|
+
const NO_INTEGRATIONS = Object.freeze({
|
|
3270
|
+
emailDesignerInstalled: false,
|
|
3271
|
+
magicMailInstalled: false,
|
|
3272
|
+
emailProviderConfigured: false,
|
|
3273
|
+
emailProviderName: null
|
|
3274
|
+
});
|
|
3275
|
+
const SettingsModern = () => {
|
|
3276
|
+
const { t } = useMagicLinkIntl();
|
|
3277
|
+
const { formatNumber } = useFormatters();
|
|
3278
|
+
const { toggleNotification } = useNotification();
|
|
3279
|
+
const { get, put, post } = useFetchClient();
|
|
3280
|
+
const {
|
|
3281
|
+
canReadSettings,
|
|
3282
|
+
canUpdateSettings,
|
|
3283
|
+
isLoading: isPermissionLoading,
|
|
3284
|
+
error: permissionError
|
|
3285
|
+
} = useMagicLinkRBAC();
|
|
3286
|
+
const canWriteSettings = canUpdateSettings;
|
|
3287
|
+
const { language } = usePluginLanguage();
|
|
3288
|
+
const [loadState, setLoadState] = useState("loading");
|
|
3289
|
+
const [settings, setSettings] = useState(null);
|
|
3290
|
+
const [initialSettings, setInitialSettings] = useState(null);
|
|
3291
|
+
const [integrations, setIntegrations] = useState(NO_INTEGRATIONS);
|
|
3292
|
+
const [emailDesignerTemplates, setEmailDesignerTemplates] = useState([]);
|
|
3293
|
+
const [magicMailTemplates, setMagicMailTemplates] = useState([]);
|
|
3294
|
+
const [rateLimitStats, setRateLimitStats] = useState({ status: "idle", data: null });
|
|
3295
|
+
const [enrollment, setEnrollment] = useState({ status: "loading", summary: null });
|
|
3296
|
+
const [serverErrors, setServerErrors] = useState({});
|
|
3297
|
+
const [openSection, setOpenSection] = useState("general");
|
|
3298
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
3299
|
+
const [isCleaning, setIsCleaning] = useState(false);
|
|
3300
|
+
const [confirmReset, setConfirmReset] = useState(false);
|
|
3301
|
+
const focusRequest = useRef(null);
|
|
3302
|
+
const [, setFocusTick] = useState(0);
|
|
3303
|
+
const formRef = useRef(null);
|
|
3304
|
+
const focusBeforeSave = useRef(null);
|
|
3305
|
+
const getSectionFocusTarget = useCallback(
|
|
3306
|
+
() => formRef.current?.querySelector('h3 button[aria-expanded="true"]') ?? formRef.current?.querySelector("h3 button"),
|
|
3307
|
+
[]
|
|
3308
|
+
);
|
|
3309
|
+
const isReady = loadState === "ready" && settings !== null;
|
|
3310
|
+
const isDirty = isReady && isSettingsDirty(initialSettings, settings);
|
|
3311
|
+
const guard = useUnsavedChangesGuard(isDirty);
|
|
3312
|
+
const formDisabled = !canWriteSettings || isSaving;
|
|
3313
|
+
const clientErrors = useMemo(() => settings ? validateSettings(settings) : {}, [settings]);
|
|
3314
|
+
const errorKeys = useMemo(
|
|
3315
|
+
() => orderedErrorKeys({ ...clientErrors, ...serverErrors }),
|
|
3316
|
+
[clientErrors, serverErrors]
|
|
3317
|
+
);
|
|
3318
|
+
const settingsFormValid = Object.keys(clientErrors).length === 0;
|
|
3319
|
+
const errorFor = useCallback(
|
|
3320
|
+
(key) => {
|
|
3321
|
+
const error = clientErrors[key];
|
|
3322
|
+
if (!error) {
|
|
3323
|
+
switch (serverErrors[key]) {
|
|
3324
|
+
case void 0:
|
|
3325
|
+
return void 0;
|
|
3326
|
+
case "securityForbidden":
|
|
3327
|
+
return t(
|
|
3328
|
+
"settings.validation.securityForbidden",
|
|
3329
|
+
'Changing this value needs the "Settings: Security" permission.'
|
|
3330
|
+
);
|
|
3331
|
+
case "linkOrigin":
|
|
3332
|
+
return t(
|
|
3333
|
+
"settings.validation.linkOrigin",
|
|
3334
|
+
"This origin is not in the allowed link origins of the server configuration."
|
|
3335
|
+
);
|
|
3336
|
+
case "mfaNoEnrolled":
|
|
3337
|
+
return t(
|
|
3338
|
+
"settings.validation.mfaNoEnrolled",
|
|
3339
|
+
'No user has an authenticator app yet, so "Every user" cannot be saved.'
|
|
3340
|
+
);
|
|
3341
|
+
default:
|
|
3342
|
+
return t(
|
|
3343
|
+
"settings.validation.server",
|
|
3344
|
+
"The server rejected this value. Check it and save again."
|
|
3345
|
+
);
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
switch (error.code) {
|
|
3349
|
+
case "url":
|
|
3350
|
+
return t(
|
|
3351
|
+
"settings.validation.url",
|
|
3352
|
+
"Enter an https URL. http is only allowed for localhost."
|
|
3353
|
+
);
|
|
3354
|
+
case "path":
|
|
3355
|
+
return t(
|
|
3356
|
+
"settings.validation.path",
|
|
3357
|
+
"Enter a path that starts with / and contains only URL characters."
|
|
3358
|
+
);
|
|
3359
|
+
case "jwtExpiry":
|
|
3360
|
+
return t(
|
|
3361
|
+
"settings.auth.jwtExpiration.invalid",
|
|
3362
|
+
"Enter a number followed by m, h or d, such as 15m, 1h or 30d."
|
|
3363
|
+
);
|
|
3364
|
+
case "email":
|
|
3365
|
+
return t(
|
|
3366
|
+
"settings.email.invalidEmail",
|
|
3367
|
+
"Enter a valid email address or leave this field blank."
|
|
3368
|
+
);
|
|
3369
|
+
case "range":
|
|
3370
|
+
return t("settings.validation.range", "Enter a whole number from {min} to {max}.", {
|
|
3371
|
+
min: formatNumber(error.min),
|
|
3372
|
+
max: formatNumber(error.max)
|
|
3373
|
+
});
|
|
3374
|
+
case "length":
|
|
3375
|
+
return t("settings.validation.length", "Use at most {max} characters.", {
|
|
3376
|
+
max: formatNumber(error.max)
|
|
3377
|
+
});
|
|
3378
|
+
case "list":
|
|
3379
|
+
return t(
|
|
3380
|
+
"settings.validation.list",
|
|
3381
|
+
"Use at most {max} fields with up to 128 characters each.",
|
|
3382
|
+
{
|
|
3383
|
+
max: error.max
|
|
3384
|
+
}
|
|
3385
|
+
);
|
|
3386
|
+
case "digits":
|
|
3387
|
+
return t("settings.validation.digits", "Choose 6 or 8 digits.");
|
|
3388
|
+
default:
|
|
3389
|
+
return t(
|
|
3390
|
+
"settings.validation.server",
|
|
3391
|
+
"The server rejected this value. Check it and save again."
|
|
3392
|
+
);
|
|
3393
|
+
}
|
|
3394
|
+
},
|
|
3395
|
+
[clientErrors, serverErrors, t, formatNumber]
|
|
3396
|
+
);
|
|
3397
|
+
const fetchSettings = useCallback(async () => {
|
|
3398
|
+
try {
|
|
3399
|
+
const res = await get("/magic-link/settings");
|
|
3400
|
+
const payload = res?.data ?? {};
|
|
3401
|
+
const formState = toFormState(payload.settings ?? payload.data ?? payload);
|
|
3402
|
+
setSettings(formState);
|
|
3403
|
+
setInitialSettings(formState);
|
|
3404
|
+
setServerErrors({});
|
|
3405
|
+
setIntegrations({
|
|
3406
|
+
emailDesignerInstalled: payload.emailDesignerInstalled === true,
|
|
3407
|
+
magicMailInstalled: payload.magicMailInstalled === true,
|
|
3408
|
+
emailProviderConfigured: payload.emailProviderConfigured === true,
|
|
3409
|
+
emailProviderName: typeof payload.emailProviderName === "string" ? payload.emailProviderName : null
|
|
3410
|
+
});
|
|
3411
|
+
setLoadState("ready");
|
|
3412
|
+
} catch {
|
|
3413
|
+
setLoadState("error");
|
|
3414
|
+
}
|
|
3415
|
+
}, [get]);
|
|
3416
|
+
const retrySettings = useCallback(() => {
|
|
3417
|
+
setLoadState("loading");
|
|
3418
|
+
return fetchSettings();
|
|
3419
|
+
}, [fetchSettings]);
|
|
3420
|
+
const fetchStats = useCallback(async () => {
|
|
3421
|
+
try {
|
|
3422
|
+
const res = await get("/magic-link/rate-limit/stats");
|
|
3423
|
+
setRateLimitStats({ status: "ready", data: res?.data?.data ?? {} });
|
|
3424
|
+
} catch {
|
|
3425
|
+
setRateLimitStats({ status: "error", data: null });
|
|
3426
|
+
}
|
|
3427
|
+
}, [get]);
|
|
3428
|
+
const loadStats = useCallback(() => {
|
|
3429
|
+
setRateLimitStats((current) => ({ status: "loading", data: current.data }));
|
|
3430
|
+
return fetchStats();
|
|
3431
|
+
}, [fetchStats]);
|
|
3432
|
+
const fetchEnrollment = useCallback(async () => {
|
|
3433
|
+
try {
|
|
3434
|
+
const res = await get("/magic-link/totp/enrollment-summary");
|
|
3435
|
+
const summary = parseEnrollmentSummary(res?.data);
|
|
3436
|
+
setEnrollment(
|
|
3437
|
+
summary ? { status: "ready", summary } : { status: "unavailable", summary: null }
|
|
3438
|
+
);
|
|
3439
|
+
} catch {
|
|
3440
|
+
setEnrollment({ status: "unavailable", summary: null });
|
|
3441
|
+
}
|
|
3442
|
+
}, [get]);
|
|
3443
|
+
useEffect(() => {
|
|
3444
|
+
if (!canReadSettings) return;
|
|
3445
|
+
const load = async () => {
|
|
3446
|
+
await Promise.all([fetchSettings(), fetchStats(), fetchEnrollment()]);
|
|
3447
|
+
};
|
|
3448
|
+
load();
|
|
3449
|
+
}, [canReadSettings, fetchSettings, fetchStats, fetchEnrollment]);
|
|
3450
|
+
useEffect(() => {
|
|
3451
|
+
if (!integrations.emailDesignerInstalled) return void 0;
|
|
3452
|
+
let active = true;
|
|
3453
|
+
get("/email-designer-5/templates").then((res) => active && setEmailDesignerTemplates(listFrom(res?.data))).catch(() => active && setEmailDesignerTemplates([]));
|
|
3454
|
+
return () => {
|
|
3455
|
+
active = false;
|
|
3456
|
+
};
|
|
3457
|
+
}, [integrations.emailDesignerInstalled, get]);
|
|
3458
|
+
useEffect(() => {
|
|
3459
|
+
if (!integrations.magicMailInstalled) return void 0;
|
|
3460
|
+
let active = true;
|
|
3461
|
+
get("/magic-mail/designer/templates").then((res) => active && setMagicMailTemplates(listFrom(res?.data))).catch(() => active && setMagicMailTemplates([]));
|
|
3462
|
+
return () => {
|
|
3463
|
+
active = false;
|
|
3464
|
+
};
|
|
3465
|
+
}, [integrations.magicMailInstalled, get]);
|
|
3466
|
+
const updateSetting = useCallback((key, value) => {
|
|
3467
|
+
setSettings((current) => ({ ...current, [key]: value }));
|
|
3468
|
+
setServerErrors((current) => {
|
|
3469
|
+
if (!current[key]) return current;
|
|
3470
|
+
const next = { ...current };
|
|
3471
|
+
delete next[key];
|
|
3472
|
+
return next;
|
|
3473
|
+
});
|
|
3474
|
+
}, []);
|
|
3475
|
+
const patchSettings = useCallback((values) => {
|
|
3476
|
+
setSettings((current) => ({ ...current, ...values }));
|
|
3477
|
+
}, []);
|
|
3478
|
+
const focusField = useCallback((key) => {
|
|
3479
|
+
const section = FIELD_SECTIONS[key];
|
|
3480
|
+
if (section) setOpenSection(section);
|
|
3481
|
+
focusRequest.current = key;
|
|
3482
|
+
setFocusTick((tick) => tick + 1);
|
|
3483
|
+
}, []);
|
|
3484
|
+
useEffect(() => {
|
|
3485
|
+
const key = focusRequest.current;
|
|
3486
|
+
if (!key) return void 0;
|
|
3487
|
+
const frame = window.requestAnimationFrame(() => {
|
|
3488
|
+
const element = document.getElementById(fieldId(key));
|
|
3489
|
+
if (element) {
|
|
3490
|
+
element.focus();
|
|
3491
|
+
element.scrollIntoView?.({ block: "center" });
|
|
3492
|
+
}
|
|
3493
|
+
focusRequest.current = null;
|
|
3494
|
+
});
|
|
3495
|
+
return () => window.cancelAnimationFrame(frame);
|
|
3496
|
+
});
|
|
3497
|
+
useEffect(() => {
|
|
3498
|
+
if (isSaving || !focusBeforeSave.current) return;
|
|
3499
|
+
const previous = focusBeforeSave.current;
|
|
3500
|
+
focusBeforeSave.current = null;
|
|
3501
|
+
const active = document.activeElement;
|
|
3502
|
+
if (active && active !== document.body) return;
|
|
3503
|
+
if (previous.isConnected && !previous.disabled) previous.focus();
|
|
3504
|
+
else getSectionFocusTarget()?.focus();
|
|
3505
|
+
}, [isSaving, getSectionFocusTarget]);
|
|
3506
|
+
const discardChanges = () => {
|
|
3507
|
+
setSettings(initialSettings);
|
|
3508
|
+
setServerErrors({});
|
|
3509
|
+
};
|
|
3510
|
+
const handleSave = async (event) => {
|
|
3511
|
+
event.preventDefault();
|
|
3512
|
+
if (!canWriteSettings || isPermissionLoading) return;
|
|
3513
|
+
if (!isDirty || isSaving) return;
|
|
3514
|
+
if (!settingsFormValid) {
|
|
3515
|
+
focusField(errorKeys[0]);
|
|
3516
|
+
toggleNotification({
|
|
3517
|
+
type: "warning",
|
|
3518
|
+
message: t(
|
|
3519
|
+
"settings.save.invalid",
|
|
3520
|
+
"Some fields need attention. Fix them before you save."
|
|
3521
|
+
)
|
|
3522
|
+
});
|
|
3523
|
+
return;
|
|
3524
|
+
}
|
|
3525
|
+
focusBeforeSave.current = document.activeElement;
|
|
3526
|
+
const payload = buildSavePayload(initialSettings, settings);
|
|
3527
|
+
if (language && language !== initialSettings.ui_language) payload.ui_language = language;
|
|
3528
|
+
setIsSaving(true);
|
|
3529
|
+
try {
|
|
3530
|
+
const response = await put("/magic-link/settings", payload);
|
|
3531
|
+
const saved = toFormState(response?.data?.settings ?? { ...initialSettings, ...payload });
|
|
3532
|
+
setSettings(saved);
|
|
3533
|
+
setInitialSettings(saved);
|
|
3534
|
+
setServerErrors({});
|
|
3535
|
+
toggleNotification({
|
|
3536
|
+
type: "success",
|
|
3537
|
+
message: t("settings.save.success", "Settings saved")
|
|
3538
|
+
});
|
|
3539
|
+
} catch (error) {
|
|
3540
|
+
const field = getServerErrorField(error);
|
|
3541
|
+
const rejected = getServerErrorFields(error);
|
|
3542
|
+
if (rejected) {
|
|
3543
|
+
if (rejected.fields.length > 0) {
|
|
3544
|
+
const named = Object.fromEntries(rejected.fields.map((name) => [name, rejected.code]));
|
|
3545
|
+
setServerErrors(named);
|
|
3546
|
+
focusField(orderedErrorKeys(named)[0]);
|
|
3547
|
+
}
|
|
3548
|
+
toggleNotification({
|
|
3549
|
+
type: "danger",
|
|
3550
|
+
message: {
|
|
3551
|
+
securityForbidden: t(
|
|
3552
|
+
"settings.save.securityForbidden",
|
|
3553
|
+
'Changing link targets, templates, sender, account, MFA, code or rate-limit rules needs the "Settings: Security" permission.'
|
|
3554
|
+
),
|
|
3555
|
+
linkOrigin: t(
|
|
3556
|
+
"settings.save.linkOriginNotAllowed",
|
|
3557
|
+
"Login link URLs must use an origin allowed in the server configuration."
|
|
3558
|
+
),
|
|
3559
|
+
mfaNoEnrolled: t(
|
|
3560
|
+
"settings.save.mfaNoEnrolled",
|
|
3561
|
+
'No user has an authenticator app yet. Choose "Users who set up an app" first so users can set one up, then require it for every user.'
|
|
3562
|
+
)
|
|
3563
|
+
}[rejected.code]
|
|
3564
|
+
});
|
|
3565
|
+
} else if (field) {
|
|
3566
|
+
setServerErrors({ [field]: "server" });
|
|
3567
|
+
focusField(field);
|
|
3568
|
+
toggleNotification({
|
|
3569
|
+
type: "danger",
|
|
3570
|
+
message: t(
|
|
3571
|
+
"settings.save.rejected",
|
|
3572
|
+
"The server rejected a value. Check the highlighted field."
|
|
3573
|
+
)
|
|
3574
|
+
});
|
|
3575
|
+
} else {
|
|
3576
|
+
toggleNotification({
|
|
3577
|
+
type: "danger",
|
|
3578
|
+
message: t("settings.save.error", "Settings could not be saved. Please try again.")
|
|
3579
|
+
});
|
|
3580
|
+
}
|
|
3581
|
+
} finally {
|
|
3582
|
+
setIsSaving(false);
|
|
3583
|
+
}
|
|
3584
|
+
};
|
|
3585
|
+
const handleRateLimitCleanup = async () => {
|
|
3586
|
+
if (!canWriteSettings || isPermissionLoading) return;
|
|
3587
|
+
setIsCleaning(true);
|
|
3588
|
+
try {
|
|
3589
|
+
await post("/magic-link/rate-limit/cleanup");
|
|
3590
|
+
toggleNotification({
|
|
3591
|
+
type: "success",
|
|
3592
|
+
message: t("settings.rateLimit.cleanupSuccess", "Expired entries removed")
|
|
3593
|
+
});
|
|
3594
|
+
await loadStats();
|
|
3595
|
+
} catch {
|
|
3596
|
+
toggleNotification({
|
|
3597
|
+
type: "danger",
|
|
3598
|
+
message: t("settings.rateLimit.cleanupError", "Expired entries could not be removed")
|
|
3599
|
+
});
|
|
3600
|
+
} finally {
|
|
3601
|
+
setIsCleaning(false);
|
|
3602
|
+
}
|
|
3603
|
+
};
|
|
3604
|
+
const handleRateLimitReset = async () => {
|
|
3605
|
+
if (!canWriteSettings || isPermissionLoading) return;
|
|
3606
|
+
try {
|
|
3607
|
+
await post("/magic-link/rate-limit/reset");
|
|
3608
|
+
toggleNotification({
|
|
3609
|
+
type: "success",
|
|
3610
|
+
message: t("settings.rateLimit.resetSuccess", "All rate limits reset")
|
|
3611
|
+
});
|
|
3612
|
+
setConfirmReset(false);
|
|
3613
|
+
await loadStats();
|
|
3614
|
+
} catch {
|
|
3615
|
+
toggleNotification({
|
|
3616
|
+
type: "danger",
|
|
3617
|
+
message: t("settings.rateLimit.resetError", "Rate limits could not be reset")
|
|
3618
|
+
});
|
|
3619
|
+
}
|
|
3620
|
+
};
|
|
3621
|
+
const pageTitle = /* @__PURE__ */ jsx(PluginPageTitle, { children: t("settings.pageTitle", "Settings") });
|
|
3622
|
+
if (isPermissionLoading) {
|
|
3623
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
3624
|
+
pageTitle,
|
|
3625
|
+
/* @__PURE__ */ jsx(Page.Loading, {})
|
|
3626
|
+
] });
|
|
3627
|
+
}
|
|
3628
|
+
if (permissionError) {
|
|
3629
|
+
return /* @__PURE__ */ jsxs(Page.Main, { children: [
|
|
3630
|
+
pageTitle,
|
|
3631
|
+
/* @__PURE__ */ jsx(
|
|
3632
|
+
Page.Error,
|
|
3633
|
+
{
|
|
3634
|
+
content: t(
|
|
3635
|
+
"common.permissions.loadError",
|
|
3636
|
+
"Your permissions could not be loaded. Reload the page to try again."
|
|
3637
|
+
)
|
|
3638
|
+
}
|
|
3639
|
+
)
|
|
3640
|
+
] });
|
|
3641
|
+
}
|
|
3642
|
+
if (!canReadSettings) {
|
|
3643
|
+
return /* @__PURE__ */ jsxs(Page.Main, { children: [
|
|
3644
|
+
pageTitle,
|
|
3645
|
+
/* @__PURE__ */ jsx(Page.NoPermissions, {})
|
|
3646
|
+
] });
|
|
3647
|
+
}
|
|
3648
|
+
if (loadState === "error") {
|
|
3649
|
+
return /* @__PURE__ */ jsxs(Page.Main, { children: [
|
|
3650
|
+
pageTitle,
|
|
3651
|
+
/* @__PURE__ */ jsx(
|
|
3652
|
+
Page.Error,
|
|
3653
|
+
{
|
|
3654
|
+
content: t("settings.load.error", "Settings could not be loaded."),
|
|
3655
|
+
action: /* @__PURE__ */ jsx(Button, { variant: "secondary", startIcon: /* @__PURE__ */ jsx(ArrowClockwise, {}), onClick: retrySettings, children: t("settings.retry", "Try again") })
|
|
3656
|
+
}
|
|
3657
|
+
)
|
|
3658
|
+
] });
|
|
3659
|
+
}
|
|
3660
|
+
if (!isReady) {
|
|
3661
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
3662
|
+
pageTitle,
|
|
3663
|
+
/* @__PURE__ */ jsx(Page.Loading, { children: t("settings.load.loading", "Loading settings...") })
|
|
3664
|
+
] });
|
|
3665
|
+
}
|
|
3666
|
+
const statusOn = t("status.active", "ACTIVE");
|
|
3667
|
+
const statusOff = t("status.inactive", "INACTIVE");
|
|
3668
|
+
const sectionProps = {
|
|
3669
|
+
settings,
|
|
3670
|
+
update: updateSetting,
|
|
3671
|
+
patch: patchSettings,
|
|
3672
|
+
disabled: formDisabled,
|
|
3673
|
+
errorFor,
|
|
3674
|
+
statusOn,
|
|
3675
|
+
statusOff
|
|
3676
|
+
};
|
|
3677
|
+
const sections = [
|
|
3678
|
+
{
|
|
3679
|
+
value: "general",
|
|
3680
|
+
icon: Cog,
|
|
3681
|
+
title: t("settings.section.general", "General"),
|
|
3682
|
+
content: /* @__PURE__ */ jsx(GeneralSection, { ...sectionProps })
|
|
3683
|
+
},
|
|
3684
|
+
{
|
|
3685
|
+
value: "auth",
|
|
3686
|
+
icon: Key,
|
|
3687
|
+
title: t("settings.section.auth", "Authentication"),
|
|
3688
|
+
content: /* @__PURE__ */ jsx(AuthSection, { ...sectionProps })
|
|
3689
|
+
},
|
|
3690
|
+
{
|
|
3691
|
+
value: "mfa",
|
|
3692
|
+
icon: Lock,
|
|
3693
|
+
title: t("settings.section.mfa", "Sign-in modes and MFA"),
|
|
3694
|
+
content: /* @__PURE__ */ jsx(
|
|
3695
|
+
MfaSection,
|
|
3696
|
+
{
|
|
3697
|
+
...sectionProps,
|
|
3698
|
+
enrollment,
|
|
3699
|
+
savedMfaMode: resolveMfaMode(initialSettings)
|
|
3700
|
+
}
|
|
3701
|
+
)
|
|
3702
|
+
},
|
|
3703
|
+
{
|
|
3704
|
+
value: "email",
|
|
3705
|
+
icon: Mail,
|
|
3706
|
+
title: t("settings.section.email", "Email"),
|
|
3707
|
+
content: /* @__PURE__ */ jsx(
|
|
3708
|
+
EmailSection,
|
|
3709
|
+
{
|
|
3710
|
+
...sectionProps,
|
|
3711
|
+
integrations,
|
|
3712
|
+
emailDesignerTemplates,
|
|
3713
|
+
magicMailTemplates
|
|
3714
|
+
}
|
|
3715
|
+
)
|
|
3716
|
+
},
|
|
3717
|
+
{
|
|
3718
|
+
value: "ratelimit",
|
|
3719
|
+
icon: Clock,
|
|
3720
|
+
title: t("settings.section.rateLimit", "Rate limiting"),
|
|
3721
|
+
content: /* @__PURE__ */ jsx(
|
|
3722
|
+
RateLimitSection,
|
|
3723
|
+
{
|
|
3724
|
+
...sectionProps,
|
|
3725
|
+
canManage: canWriteSettings,
|
|
3726
|
+
stats: rateLimitStats,
|
|
3727
|
+
onRetryStats: loadStats,
|
|
3728
|
+
onCleanup: handleRateLimitCleanup,
|
|
3729
|
+
onRequestReset: () => setConfirmReset(true),
|
|
3730
|
+
isCleaning
|
|
3731
|
+
}
|
|
3732
|
+
)
|
|
3733
|
+
},
|
|
3734
|
+
{
|
|
3735
|
+
value: "advanced",
|
|
3736
|
+
icon: Code,
|
|
3737
|
+
title: t("settings.section.advanced", "Advanced"),
|
|
3738
|
+
content: /* @__PURE__ */ jsx(AdvancedSection, { ...sectionProps })
|
|
3739
|
+
}
|
|
3740
|
+
];
|
|
3741
|
+
const invalidSections = new Set(errorKeys.map((key) => FIELD_SECTIONS[key]).filter(Boolean));
|
|
3742
|
+
return /* @__PURE__ */ jsxs(Page.Main, { children: [
|
|
3743
|
+
pageTitle,
|
|
3744
|
+
/* @__PURE__ */ jsxs(Container, { ref: formRef, onSubmit: handleSave, noValidate: true, "aria-busy": isSaving, children: [
|
|
3745
|
+
/* @__PURE__ */ jsxs(StickySaveBar, { children: [
|
|
3746
|
+
/* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 1, alignItems: "flex-start", style: { minWidth: 0 }, children: [
|
|
3747
|
+
/* @__PURE__ */ jsx(Typography, { variant: "alpha", tag: "h1", textColor: "neutral800", children: t("settings.page.title", "Magic Link Settings") }),
|
|
3748
|
+
/* @__PURE__ */ jsx(Typography, { variant: "epsilon", textColor: "neutral600", tag: "p", children: t("settings.page.subtitle", "Configure passwordless authentication for your users") })
|
|
3749
|
+
] }),
|
|
3750
|
+
/* @__PURE__ */ jsxs(HeaderActions, { children: [
|
|
3751
|
+
errorKeys.length > 0 ? /* @__PURE__ */ jsx(
|
|
3752
|
+
Button,
|
|
3753
|
+
{
|
|
3754
|
+
variant: "danger-light",
|
|
3755
|
+
size: "M",
|
|
3756
|
+
startIcon: /* @__PURE__ */ jsx(WarningCircle, {}),
|
|
3757
|
+
onClick: () => focusField(errorKeys[0]),
|
|
3758
|
+
children: t(
|
|
3759
|
+
"settings.errors.summary",
|
|
3760
|
+
"{count, plural, one {# field needs attention} other {# fields need attention}}",
|
|
3761
|
+
{ count: errorKeys.length }
|
|
3762
|
+
)
|
|
3763
|
+
}
|
|
3764
|
+
) : null,
|
|
3765
|
+
isDirty ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
3766
|
+
/* @__PURE__ */ jsx(UnsavedBadge, { role: "status", children: t("common.unsaved.badge", "Unsaved changes") }),
|
|
3767
|
+
/* @__PURE__ */ jsx(
|
|
3768
|
+
Button,
|
|
3769
|
+
{
|
|
3770
|
+
variant: "tertiary",
|
|
3771
|
+
size: "M",
|
|
3772
|
+
onClick: () => guard.runGuarded(discardChanges),
|
|
3773
|
+
disabled: isSaving,
|
|
3774
|
+
children: t("settings.discard", "Discard")
|
|
3775
|
+
}
|
|
3776
|
+
)
|
|
3777
|
+
] }) : null,
|
|
3778
|
+
canWriteSettings ? /* @__PURE__ */ jsx(
|
|
3779
|
+
SaveButton,
|
|
3780
|
+
{
|
|
3781
|
+
type: "submit",
|
|
3782
|
+
size: "M",
|
|
3783
|
+
loading: isSaving,
|
|
3784
|
+
startIcon: /* @__PURE__ */ jsx(Check, {}),
|
|
3785
|
+
disabled: isSaving || isPermissionLoading || !canWriteSettings || !isDirty,
|
|
3786
|
+
children: isSaving ? t("settings.save.saving", "Saving...") : t("settings.save.button", "Save Changes")
|
|
3787
|
+
}
|
|
3788
|
+
) : null
|
|
3789
|
+
] })
|
|
3790
|
+
] }),
|
|
3791
|
+
/* @__PURE__ */ jsxs(Content, { children: [
|
|
3792
|
+
!canWriteSettings ? /* @__PURE__ */ jsx(
|
|
3793
|
+
Alert,
|
|
3794
|
+
{
|
|
3795
|
+
variant: "default",
|
|
3796
|
+
closeLabel: t("common.close", "Close"),
|
|
3797
|
+
title: t("settings.permission.readOnlyTitle", "Read-only"),
|
|
3798
|
+
children: t(
|
|
3799
|
+
"settings.permission.readOnly",
|
|
3800
|
+
"You have read-only access. Changes cannot be saved."
|
|
3801
|
+
)
|
|
3802
|
+
}
|
|
3803
|
+
) : null,
|
|
3804
|
+
/* @__PURE__ */ jsxs(InfoBanner, { children: [
|
|
3805
|
+
/* @__PURE__ */ jsx(BannerIcon, { "aria-hidden": "true", children: /* @__PURE__ */ jsx(Cog, {}) }),
|
|
3806
|
+
/* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 1, alignItems: "flex-start", style: { minWidth: 0 }, children: [
|
|
3807
|
+
/* @__PURE__ */ jsx(Typography, { variant: "omega", fontWeight: "bold", textColor: "neutral800", tag: "p", children: t("settings.info.header", "Important Configuration Notes") }),
|
|
3808
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral700", tag: "div", children: /* @__PURE__ */ jsxs(BulletList, { children: [
|
|
3809
|
+
/* @__PURE__ */ jsx("li", { children: t("settings.info.line1", "Changes take effect after you save.") }),
|
|
3810
|
+
/* @__PURE__ */ jsx("li", { children: t("settings.info.line2", "Make sure all URLs are correctly configured") }),
|
|
3811
|
+
/* @__PURE__ */ jsx("li", { children: t(
|
|
3812
|
+
"settings.info.line3",
|
|
3813
|
+
"Test the configuration after saving with a test token"
|
|
3814
|
+
) }),
|
|
3815
|
+
/* @__PURE__ */ jsx("li", { children: t(
|
|
3816
|
+
"settings.info.line4",
|
|
3817
|
+
"Green cards are active features, grey cards are inactive."
|
|
3818
|
+
) })
|
|
3819
|
+
] }) })
|
|
3820
|
+
] })
|
|
3821
|
+
] }),
|
|
3822
|
+
/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Accordion.Root, { value: openSection, onValueChange: setOpenSection, children: sections.map((section) => /* @__PURE__ */ jsxs(Accordion.Item, { value: section.value, children: [
|
|
3823
|
+
/* @__PURE__ */ jsx(Accordion.Header, { children: /* @__PURE__ */ jsxs(Accordion.Trigger, { icon: section.icon, children: [
|
|
3824
|
+
section.title,
|
|
3825
|
+
invalidSections.has(section.value) ? (
|
|
3826
|
+
// The space keeps "General Fields to fix" apart in the accessible name.
|
|
3827
|
+
/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
3828
|
+
" ",
|
|
3829
|
+
/* @__PURE__ */ jsx(SectionErrorMark, { children: t("settings.section.fieldsToFix", "Fields to fix") })
|
|
3830
|
+
] })
|
|
3831
|
+
) : null
|
|
3832
|
+
] }) }),
|
|
3833
|
+
/* @__PURE__ */ jsx(Accordion.Content, { children: section.content })
|
|
3834
|
+
] }, section.value)) }) }),
|
|
3835
|
+
/* @__PURE__ */ jsxs(FooterBox, { children: [
|
|
3836
|
+
/* @__PURE__ */ jsx(Check, { "aria-hidden": "true" }),
|
|
3837
|
+
/* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 1, alignItems: "flex-start", children: [
|
|
3838
|
+
/* @__PURE__ */ jsx(Typography, { variant: "omega", fontWeight: "bold", textColor: "neutral800", children: t("settings.footer.quickhelp", "Quick Help") }),
|
|
3839
|
+
/* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral700", children: t(
|
|
3840
|
+
"settings.footer.reminder",
|
|
3841
|
+
"After saving, create a test token and try the magic link."
|
|
3842
|
+
) })
|
|
3843
|
+
] })
|
|
3844
|
+
] })
|
|
3845
|
+
] })
|
|
3846
|
+
] }),
|
|
3847
|
+
/* @__PURE__ */ jsx(
|
|
3848
|
+
ConfirmDialog,
|
|
3849
|
+
{
|
|
3850
|
+
isOpen: canWriteSettings && confirmReset,
|
|
3851
|
+
title: t("settings.rateLimit.resetTitle", "Reset all rate limits?"),
|
|
3852
|
+
description: t(
|
|
3853
|
+
"settings.rateLimit.resetConfirm",
|
|
3854
|
+
"All blocked IP addresses and email addresses can send requests again immediately."
|
|
3855
|
+
),
|
|
3856
|
+
confirmLabel: t("settings.rateLimit.reset", "Reset All Limits"),
|
|
3857
|
+
onConfirm: handleRateLimitReset,
|
|
3858
|
+
onClose: () => setConfirmReset(false)
|
|
3859
|
+
}
|
|
3860
|
+
),
|
|
3861
|
+
/* @__PURE__ */ jsx(UnsavedChangesDialog, { guard, getReturnFocusFallback: getSectionFocusTarget })
|
|
3862
|
+
] });
|
|
3863
|
+
};
|
|
3864
|
+
const SettingsWithLanguage = () => /* @__PURE__ */ jsx(LicenseGuard, { children: /* @__PURE__ */ jsx(LanguageProvider, { children: /* @__PURE__ */ jsx(SettingsModern, {}) }) });
|
|
3865
|
+
export {
|
|
3866
|
+
SettingsWithLanguage as default
|
|
3867
|
+
};
|