@authhero/react-admin 0.30.0 → 0.32.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 +24 -0
- package/package.json +1 -1
- package/src/App.tsx +10 -0
- package/src/auth0DataProvider.ts +187 -0
- package/src/components/prompts/edit.tsx +1155 -0
- package/src/components/prompts/index.ts +2 -0
- package/src/components/prompts/list.tsx +14 -0
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Edit,
|
|
3
|
+
TabbedForm,
|
|
4
|
+
SelectInput,
|
|
5
|
+
BooleanInput,
|
|
6
|
+
useRecordContext,
|
|
7
|
+
useDataProvider,
|
|
8
|
+
useNotify,
|
|
9
|
+
useRefresh,
|
|
10
|
+
} from "react-admin";
|
|
11
|
+
import {
|
|
12
|
+
Stack,
|
|
13
|
+
Typography,
|
|
14
|
+
Box,
|
|
15
|
+
Button,
|
|
16
|
+
Table,
|
|
17
|
+
TableBody,
|
|
18
|
+
TableCell,
|
|
19
|
+
TableContainer,
|
|
20
|
+
TableHead,
|
|
21
|
+
TableRow,
|
|
22
|
+
Paper,
|
|
23
|
+
IconButton,
|
|
24
|
+
Dialog,
|
|
25
|
+
DialogTitle,
|
|
26
|
+
DialogContent,
|
|
27
|
+
DialogActions,
|
|
28
|
+
TextField,
|
|
29
|
+
MenuItem,
|
|
30
|
+
ToggleButton,
|
|
31
|
+
ToggleButtonGroup,
|
|
32
|
+
Alert,
|
|
33
|
+
Accordion,
|
|
34
|
+
AccordionSummary,
|
|
35
|
+
AccordionDetails,
|
|
36
|
+
Chip,
|
|
37
|
+
} from "@mui/material";
|
|
38
|
+
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
39
|
+
import EditIcon from "@mui/icons-material/Edit";
|
|
40
|
+
import DeleteIcon from "@mui/icons-material/Delete";
|
|
41
|
+
import AddIcon from "@mui/icons-material/Add";
|
|
42
|
+
import { useState, useCallback } from "react";
|
|
43
|
+
|
|
44
|
+
// Available prompt screens
|
|
45
|
+
const PROMPT_SCREENS = [
|
|
46
|
+
{ id: "login", name: "Login" },
|
|
47
|
+
{ id: "login-id", name: "Login - Identifier" },
|
|
48
|
+
{ id: "login-password", name: "Login - Password" },
|
|
49
|
+
{ id: "signup", name: "Sign Up" },
|
|
50
|
+
{ id: "signup-id", name: "Sign Up - Identifier" },
|
|
51
|
+
{ id: "signup-password", name: "Sign Up - Password" },
|
|
52
|
+
{ id: "reset-password", name: "Reset Password" },
|
|
53
|
+
{ id: "consent", name: "Consent" },
|
|
54
|
+
{ id: "mfa", name: "MFA" },
|
|
55
|
+
{ id: "mfa-push", name: "MFA - Push" },
|
|
56
|
+
{ id: "mfa-otp", name: "MFA - OTP" },
|
|
57
|
+
{ id: "mfa-voice", name: "MFA - Voice" },
|
|
58
|
+
{ id: "mfa-phone", name: "MFA - Phone" },
|
|
59
|
+
{ id: "mfa-webauthn", name: "MFA - WebAuthn" },
|
|
60
|
+
{ id: "mfa-sms", name: "MFA - SMS" },
|
|
61
|
+
{ id: "mfa-email", name: "MFA - Email" },
|
|
62
|
+
{ id: "mfa-recovery-code", name: "MFA - Recovery Code" },
|
|
63
|
+
{ id: "status", name: "Status" },
|
|
64
|
+
{ id: "device-flow", name: "Device Flow" },
|
|
65
|
+
{ id: "email-verification", name: "Email Verification" },
|
|
66
|
+
{ id: "email-otp-challenge", name: "Email OTP Challenge" },
|
|
67
|
+
{ id: "organizations", name: "Organizations" },
|
|
68
|
+
{ id: "invitation", name: "Invitation" },
|
|
69
|
+
{ id: "common", name: "Common" },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// Common languages
|
|
73
|
+
const LANGUAGES = [
|
|
74
|
+
{ id: "en", name: "English" },
|
|
75
|
+
{ id: "es", name: "Spanish" },
|
|
76
|
+
{ id: "fr", name: "French" },
|
|
77
|
+
{ id: "de", name: "German" },
|
|
78
|
+
{ id: "it", name: "Italian" },
|
|
79
|
+
{ id: "pt", name: "Portuguese" },
|
|
80
|
+
{ id: "nl", name: "Dutch" },
|
|
81
|
+
{ id: "ja", name: "Japanese" },
|
|
82
|
+
{ id: "ko", name: "Korean" },
|
|
83
|
+
{ id: "zh", name: "Chinese" },
|
|
84
|
+
{ id: "sv", name: "Swedish" },
|
|
85
|
+
{ id: "nb", name: "Norwegian" },
|
|
86
|
+
{ id: "fi", name: "Finnish" },
|
|
87
|
+
{ id: "da", name: "Danish" },
|
|
88
|
+
{ id: "pl", name: "Polish" },
|
|
89
|
+
{ id: "cs", name: "Czech" },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
// Default text keys for each screen type with their default values
|
|
93
|
+
const DEFAULT_TEXT_KEYS: Record<string, Record<string, string>> = {
|
|
94
|
+
login: {
|
|
95
|
+
pageTitle: "Log in | ${clientName}",
|
|
96
|
+
title: "Welcome",
|
|
97
|
+
description: "Log in to continue",
|
|
98
|
+
separatorText: "Or",
|
|
99
|
+
buttonText: "Continue",
|
|
100
|
+
federatedConnectionButtonText: "Continue with ${connectionName}",
|
|
101
|
+
footerLinkText: "Sign up",
|
|
102
|
+
signupActionLinkText: "${footerLinkText}",
|
|
103
|
+
footerText: "Don't have an account?",
|
|
104
|
+
signupActionText: "${footerText}",
|
|
105
|
+
forgotPasswordText: "Forgot password?",
|
|
106
|
+
passwordPlaceholder: "Password",
|
|
107
|
+
usernamePlaceholder: "Username or email address",
|
|
108
|
+
emailPlaceholder: "Email address",
|
|
109
|
+
phonePlaceholder: "Phone number",
|
|
110
|
+
editEmailText: "Edit",
|
|
111
|
+
alertListTitle: "Alerts",
|
|
112
|
+
invitationTitle: "You've Been Invited!",
|
|
113
|
+
invitationDescription:
|
|
114
|
+
"Log in to accept ${inviterName}'s invitation to join ${companyName} on ${clientName}.",
|
|
115
|
+
logoAltText: "${companyName}",
|
|
116
|
+
showPasswordText: "Show password",
|
|
117
|
+
hidePasswordText: "Hide password",
|
|
118
|
+
},
|
|
119
|
+
"login-id": {
|
|
120
|
+
pageTitle: "Log in | ${clientName}",
|
|
121
|
+
title: "Welcome",
|
|
122
|
+
description: "Login to continue",
|
|
123
|
+
separatorText: "Or",
|
|
124
|
+
buttonText: "Continue",
|
|
125
|
+
federatedConnectionButtonText: "Continue with ${connectionName}",
|
|
126
|
+
footerLinkText: "Sign up",
|
|
127
|
+
signupActionLinkText: "${footerLinkText}",
|
|
128
|
+
footerText: "Don't have an account?",
|
|
129
|
+
signupActionText: "${footerText}",
|
|
130
|
+
forgotPasswordText: "Forgot password?",
|
|
131
|
+
passwordPlaceholder: "Password",
|
|
132
|
+
usernamePlaceholder: "Username or email address",
|
|
133
|
+
emailPlaceholder: "Email address",
|
|
134
|
+
phonePlaceholder: "Phone number",
|
|
135
|
+
usernameOnlyPlaceholder: "Username",
|
|
136
|
+
phoneOrUsernameOrEmailPlaceholder: "Phone or Username or Email",
|
|
137
|
+
phoneOrEmailPlaceholder: "Phone number or Email address",
|
|
138
|
+
phoneOrUsernamePlaceholder: "Phone Number or Username",
|
|
139
|
+
usernameOrEmailPlaceholder: "Username or Email address",
|
|
140
|
+
editEmailText: "Edit",
|
|
141
|
+
alertListTitle: "Alerts",
|
|
142
|
+
invitationTitle: "You've Been Invited!",
|
|
143
|
+
invitationDescription:
|
|
144
|
+
"Log in to accept ${inviterName}'s invitation to join ${companyName} on ${clientName}.",
|
|
145
|
+
captchaCodePlaceholder: "Enter the code shown above",
|
|
146
|
+
logoAltText: "${companyName}",
|
|
147
|
+
showPasswordText: "Show password",
|
|
148
|
+
hidePasswordText: "Hide password",
|
|
149
|
+
selectCountryCode:
|
|
150
|
+
"Select country code, currently set to ${countryName}, ${countryCode}, +${countryPrefix}",
|
|
151
|
+
"wrong-credentials": "Wrong username or password",
|
|
152
|
+
"wrong-email-credentials": "Wrong email or password",
|
|
153
|
+
"wrong-username-credentials": "Incorrect username or password",
|
|
154
|
+
"wrong-phone-credentials": "Incorrect phone number or password",
|
|
155
|
+
"wrong-email-username-credentials":
|
|
156
|
+
"Incorrect email address, username, or password",
|
|
157
|
+
"wrong-email-phone-username-credentials":
|
|
158
|
+
"Incorrect email address, phone number, username, or password. Phone numbers must include the country code.",
|
|
159
|
+
"wrong-email-phone-credentials":
|
|
160
|
+
"Incorrect email address, phone number, or password. Phone numbers must include the country code.",
|
|
161
|
+
"wrong-phone-username-credentials":
|
|
162
|
+
"Incorrect phone number, username or password. Phone numbers must include the country code.",
|
|
163
|
+
"invalid-code": "The code you entered is invalid",
|
|
164
|
+
"invalid-expired-code": "Invalid or expired user code",
|
|
165
|
+
"custom-script-error-code": "Something went wrong, please try again later.",
|
|
166
|
+
"auth0-users-validation": "Something went wrong, please try again later",
|
|
167
|
+
"authentication-failure":
|
|
168
|
+
"We are sorry, something went wrong when attempting to log in",
|
|
169
|
+
"invalid-connection": "Invalid connection",
|
|
170
|
+
"ip-blocked":
|
|
171
|
+
"We have detected suspicious login behavior and further attempts will be blocked. Please contact the administrator.",
|
|
172
|
+
"no-db-connection": "Invalid connection",
|
|
173
|
+
"password-breached":
|
|
174
|
+
"We have detected a potential security issue with this account. To protect your account, we have prevented this login. Please reset your password to proceed.",
|
|
175
|
+
"user-blocked":
|
|
176
|
+
"Your account has been blocked after multiple consecutive login attempts.",
|
|
177
|
+
"same-user-login":
|
|
178
|
+
"Too many login attempts for this user. Please wait, and try again later.",
|
|
179
|
+
"invalid-email-format": "Email is not valid.",
|
|
180
|
+
"invalid-username":
|
|
181
|
+
"Username can only contain alphanumeric characters or: '${characters}'. Username should have between ${min} and ${max} characters.",
|
|
182
|
+
"invalid-login-id": "Invalid Login ID entered",
|
|
183
|
+
"invalid-email-phone":
|
|
184
|
+
"Enter a valid email address or phone number. Phone numbers must include the country code.",
|
|
185
|
+
"invalid-email-username": "Enter a valid email address or username",
|
|
186
|
+
"invalid-phone-username":
|
|
187
|
+
"Enter a valid phone number or username. Phone numbers must include the country code.",
|
|
188
|
+
"invalid-email-phone-username":
|
|
189
|
+
"Enter a valid email address, phone number or username. Phone numbers must include the country code.",
|
|
190
|
+
"no-email": "Please enter an email address",
|
|
191
|
+
"no-password": "Password is required",
|
|
192
|
+
"no-username": "Username is required",
|
|
193
|
+
"no-phone": "Please enter a phone number",
|
|
194
|
+
"no-email-username": "Email address or username is required",
|
|
195
|
+
"no-email-phone": "Email address or phone number is required",
|
|
196
|
+
"no-phone-username": "Phone number or username is required",
|
|
197
|
+
"no-email-phone-username":
|
|
198
|
+
"Phone number, username, or email address is required",
|
|
199
|
+
"captcha-validation-failure":
|
|
200
|
+
"We are sorry, something went wrong while validating the captcha response. Please try again.",
|
|
201
|
+
"invalid-recaptcha": "Select the checkbox to verify you are not a robot.",
|
|
202
|
+
"invalid-captcha":
|
|
203
|
+
"Solve the challenge question to verify you are not a robot.",
|
|
204
|
+
"captcha-client-failure":
|
|
205
|
+
"We couldn't load the security challenge. Please try again. (Error code: #{errorCode})",
|
|
206
|
+
},
|
|
207
|
+
"login-password": {
|
|
208
|
+
pageTitle: "Log in | ${clientName}",
|
|
209
|
+
title: "Enter your password",
|
|
210
|
+
description: "Log in to ${clientName}",
|
|
211
|
+
buttonText: "Continue",
|
|
212
|
+
forgotPasswordText: "Forgot password?",
|
|
213
|
+
passwordPlaceholder: "Password",
|
|
214
|
+
showPasswordText: "Show password",
|
|
215
|
+
hidePasswordText: "Hide password",
|
|
216
|
+
"wrong-credentials": "Wrong password",
|
|
217
|
+
"no-password": "Password is required",
|
|
218
|
+
"user-blocked":
|
|
219
|
+
"Your account has been blocked after multiple consecutive login attempts.",
|
|
220
|
+
"password-breached":
|
|
221
|
+
"We have detected a potential security issue with this account. To protect your account, we have prevented this login. Please reset your password to proceed.",
|
|
222
|
+
},
|
|
223
|
+
signup: {
|
|
224
|
+
pageTitle: "Sign up | ${clientName}",
|
|
225
|
+
title: "Create your account",
|
|
226
|
+
description: "Sign up to continue",
|
|
227
|
+
buttonText: "Continue",
|
|
228
|
+
loginActionLinkText: "Log in",
|
|
229
|
+
loginActionText: "Already have an account?",
|
|
230
|
+
separatorText: "Or",
|
|
231
|
+
federatedConnectionButtonText: "Continue with ${connectionName}",
|
|
232
|
+
emailPlaceholder: "Email address",
|
|
233
|
+
passwordPlaceholder: "Password",
|
|
234
|
+
usernamePlaceholder: "Username",
|
|
235
|
+
phonePlaceholder: "Phone number",
|
|
236
|
+
showPasswordText: "Show password",
|
|
237
|
+
hidePasswordText: "Hide password",
|
|
238
|
+
termsText: "By signing up, you agree to our",
|
|
239
|
+
termsOfServiceLinkText: "Terms of Service",
|
|
240
|
+
privacyPolicyLinkText: "Privacy Policy",
|
|
241
|
+
"invalid-email-format": "Email is not valid.",
|
|
242
|
+
"no-email": "Please enter an email address",
|
|
243
|
+
"no-password": "Password is required",
|
|
244
|
+
"no-username": "Username is required",
|
|
245
|
+
"email-already-exists": "This email is already registered",
|
|
246
|
+
"username-already-exists": "This username is already taken",
|
|
247
|
+
},
|
|
248
|
+
"signup-id": {
|
|
249
|
+
pageTitle: "Sign up | ${clientName}",
|
|
250
|
+
title: "Create your account",
|
|
251
|
+
description: "Sign up to continue",
|
|
252
|
+
buttonText: "Continue",
|
|
253
|
+
loginActionLinkText: "Log in",
|
|
254
|
+
loginActionText: "Already have an account?",
|
|
255
|
+
separatorText: "Or",
|
|
256
|
+
federatedConnectionButtonText: "Continue with ${connectionName}",
|
|
257
|
+
emailPlaceholder: "Email address",
|
|
258
|
+
usernamePlaceholder: "Username",
|
|
259
|
+
phonePlaceholder: "Phone number",
|
|
260
|
+
"invalid-email-format": "Email is not valid.",
|
|
261
|
+
"no-email": "Please enter an email address",
|
|
262
|
+
"email-already-exists": "This email is already registered",
|
|
263
|
+
},
|
|
264
|
+
"signup-password": {
|
|
265
|
+
pageTitle: "Sign up | ${clientName}",
|
|
266
|
+
title: "Create your password",
|
|
267
|
+
description: "Sign up to continue",
|
|
268
|
+
buttonText: "Continue",
|
|
269
|
+
passwordPlaceholder: "Password",
|
|
270
|
+
showPasswordText: "Show password",
|
|
271
|
+
hidePasswordText: "Hide password",
|
|
272
|
+
"no-password": "Password is required",
|
|
273
|
+
"password-too-weak": "Password is too weak",
|
|
274
|
+
"password-policy-not-met": "Password does not meet the requirements",
|
|
275
|
+
},
|
|
276
|
+
"reset-password": {
|
|
277
|
+
pageTitle: "Reset Password | ${clientName}",
|
|
278
|
+
title: "Forgot your password?",
|
|
279
|
+
description: "Enter your email to reset your password",
|
|
280
|
+
buttonText: "Continue",
|
|
281
|
+
backToLoginText: "Back to login",
|
|
282
|
+
emailPlaceholder: "Email address",
|
|
283
|
+
successTitle: "Check your email",
|
|
284
|
+
successDescription:
|
|
285
|
+
"We have sent a password reset link to your email address.",
|
|
286
|
+
"invalid-email-format": "Email is not valid.",
|
|
287
|
+
"no-email": "Please enter an email address",
|
|
288
|
+
"user-not-found": "User not found",
|
|
289
|
+
},
|
|
290
|
+
consent: {
|
|
291
|
+
pageTitle: "Authorize | ${clientName}",
|
|
292
|
+
title: "Authorize ${clientName}",
|
|
293
|
+
description: "${clientName} is requesting access to your account",
|
|
294
|
+
buttonText: "Accept",
|
|
295
|
+
cancelButtonText: "Deny",
|
|
296
|
+
scopesTitle: "This will allow ${clientName} to:",
|
|
297
|
+
},
|
|
298
|
+
mfa: {
|
|
299
|
+
pageTitle: "Multi-Factor Authentication | ${clientName}",
|
|
300
|
+
title: "Verify your identity",
|
|
301
|
+
description: "Choose a verification method",
|
|
302
|
+
backupCodeText: "Use backup code",
|
|
303
|
+
},
|
|
304
|
+
"mfa-otp": {
|
|
305
|
+
pageTitle: "Enter Code | ${clientName}",
|
|
306
|
+
title: "Enter your code",
|
|
307
|
+
description: "Enter the 6-digit code from your authenticator app",
|
|
308
|
+
buttonText: "Continue",
|
|
309
|
+
codePlaceholder: "Enter code",
|
|
310
|
+
"invalid-code": "The code you entered is invalid",
|
|
311
|
+
},
|
|
312
|
+
"mfa-sms": {
|
|
313
|
+
pageTitle: "SMS Verification | ${clientName}",
|
|
314
|
+
title: "Check your phone",
|
|
315
|
+
description: "We sent a code to ${phoneNumber}",
|
|
316
|
+
buttonText: "Continue",
|
|
317
|
+
resendText: "Resend code",
|
|
318
|
+
codePlaceholder: "Enter code",
|
|
319
|
+
"invalid-code": "The code you entered is invalid",
|
|
320
|
+
},
|
|
321
|
+
"mfa-email": {
|
|
322
|
+
pageTitle: "Email Verification | ${clientName}",
|
|
323
|
+
title: "Check your email",
|
|
324
|
+
description: "We sent a code to ${email}",
|
|
325
|
+
buttonText: "Continue",
|
|
326
|
+
resendText: "Resend code",
|
|
327
|
+
codePlaceholder: "Enter code",
|
|
328
|
+
"invalid-code": "The code you entered is invalid",
|
|
329
|
+
},
|
|
330
|
+
"mfa-push": {
|
|
331
|
+
pageTitle: "Push Notification | ${clientName}",
|
|
332
|
+
title: "Approve the request",
|
|
333
|
+
description: "We sent a notification to your device",
|
|
334
|
+
resendText: "Resend notification",
|
|
335
|
+
useCodeText: "Enter code manually",
|
|
336
|
+
},
|
|
337
|
+
"mfa-webauthn": {
|
|
338
|
+
pageTitle: "Security Key | ${clientName}",
|
|
339
|
+
title: "Use your security key",
|
|
340
|
+
description: "Insert your security key and follow the instructions",
|
|
341
|
+
buttonText: "Try again",
|
|
342
|
+
},
|
|
343
|
+
"mfa-voice": {
|
|
344
|
+
pageTitle: "Voice Call | ${clientName}",
|
|
345
|
+
title: "Receive a phone call",
|
|
346
|
+
description: "We will call ${phoneNumber} with your code",
|
|
347
|
+
buttonText: "Call me",
|
|
348
|
+
codePlaceholder: "Enter code",
|
|
349
|
+
"invalid-code": "The code you entered is invalid",
|
|
350
|
+
},
|
|
351
|
+
"mfa-phone": {
|
|
352
|
+
pageTitle: "Phone Verification | ${clientName}",
|
|
353
|
+
title: "Verify your phone",
|
|
354
|
+
description: "Enter your phone number to receive a verification code",
|
|
355
|
+
buttonText: "Continue",
|
|
356
|
+
phonePlaceholder: "Phone number",
|
|
357
|
+
smsOptionText: "Text me",
|
|
358
|
+
voiceOptionText: "Call me",
|
|
359
|
+
},
|
|
360
|
+
"mfa-recovery-code": {
|
|
361
|
+
pageTitle: "Recovery Code | ${clientName}",
|
|
362
|
+
title: "Enter recovery code",
|
|
363
|
+
description: "Enter one of your recovery codes",
|
|
364
|
+
buttonText: "Continue",
|
|
365
|
+
codePlaceholder: "Recovery code",
|
|
366
|
+
"invalid-code": "The recovery code you entered is invalid",
|
|
367
|
+
},
|
|
368
|
+
status: {
|
|
369
|
+
pageTitle: "Status | ${clientName}",
|
|
370
|
+
title: "Status",
|
|
371
|
+
successTitle: "Success",
|
|
372
|
+
errorTitle: "Error",
|
|
373
|
+
continueButtonText: "Continue",
|
|
374
|
+
},
|
|
375
|
+
"device-flow": {
|
|
376
|
+
pageTitle: "Device Activation | ${clientName}",
|
|
377
|
+
title: "Activate your device",
|
|
378
|
+
description: "Enter the code shown on your device",
|
|
379
|
+
buttonText: "Continue",
|
|
380
|
+
codePlaceholder: "Enter code",
|
|
381
|
+
"invalid-code": "The code you entered is invalid",
|
|
382
|
+
"expired-code": "The code has expired",
|
|
383
|
+
},
|
|
384
|
+
"email-verification": {
|
|
385
|
+
pageTitle: "Verify Email | ${clientName}",
|
|
386
|
+
title: "Verify your email",
|
|
387
|
+
description: "We sent an email to ${email}",
|
|
388
|
+
resendText: "Resend email",
|
|
389
|
+
successTitle: "Email verified",
|
|
390
|
+
successDescription: "Your email has been verified successfully.",
|
|
391
|
+
},
|
|
392
|
+
"email-otp-challenge": {
|
|
393
|
+
pageTitle: "Enter Code | ${clientName}",
|
|
394
|
+
title: "Check your email",
|
|
395
|
+
description: "We sent a code to ${email}",
|
|
396
|
+
buttonText: "Continue",
|
|
397
|
+
resendText: "Resend code",
|
|
398
|
+
codePlaceholder: "Enter code",
|
|
399
|
+
"invalid-code": "The code you entered is invalid",
|
|
400
|
+
},
|
|
401
|
+
organizations: {
|
|
402
|
+
pageTitle: "Select Organization | ${clientName}",
|
|
403
|
+
title: "Select your organization",
|
|
404
|
+
description: "Choose which organization to log in to",
|
|
405
|
+
searchPlaceholder: "Search organizations",
|
|
406
|
+
},
|
|
407
|
+
invitation: {
|
|
408
|
+
pageTitle: "Invitation | ${clientName}",
|
|
409
|
+
title: "You've been invited",
|
|
410
|
+
description:
|
|
411
|
+
"${inviterName} has invited you to join ${organizationName} on ${clientName}",
|
|
412
|
+
acceptButtonText: "Accept invitation",
|
|
413
|
+
},
|
|
414
|
+
common: {
|
|
415
|
+
alertListTitle: "Alerts",
|
|
416
|
+
showPasswordText: "Show password",
|
|
417
|
+
hidePasswordText: "Hide password",
|
|
418
|
+
continueText: "Continue",
|
|
419
|
+
orText: "or",
|
|
420
|
+
termsOfServiceText: "Terms of Service",
|
|
421
|
+
privacyPolicyText: "Privacy Policy",
|
|
422
|
+
contactSupportText: "Contact Support",
|
|
423
|
+
copyrightText: "© ${currentYear} ${companyName}",
|
|
424
|
+
backText: "Back",
|
|
425
|
+
cancelText: "Cancel",
|
|
426
|
+
closeText: "Close",
|
|
427
|
+
loadingText: "Loading...",
|
|
428
|
+
errorText: "An error occurred",
|
|
429
|
+
tryAgainText: "Try again",
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Remove null/undefined values from an object
|
|
434
|
+
function removeNullValues(
|
|
435
|
+
obj: Record<string, unknown>,
|
|
436
|
+
): Record<string, unknown> {
|
|
437
|
+
const result: Record<string, unknown> = {};
|
|
438
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
439
|
+
if (value === null || value === undefined) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
443
|
+
const cleaned = removeNullValues(value as Record<string, unknown>);
|
|
444
|
+
if (Object.keys(cleaned).length > 0) {
|
|
445
|
+
result[key] = cleaned;
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
result[key] = value;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return result;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
interface CustomTextEntry {
|
|
455
|
+
prompt: string;
|
|
456
|
+
language: string;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function CustomTextTab() {
|
|
460
|
+
const record = useRecordContext();
|
|
461
|
+
const dataProvider = useDataProvider();
|
|
462
|
+
const notify = useNotify();
|
|
463
|
+
const refresh = useRefresh();
|
|
464
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
465
|
+
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
466
|
+
const [selectedEntry, setSelectedEntry] = useState<CustomTextEntry | null>(
|
|
467
|
+
null,
|
|
468
|
+
);
|
|
469
|
+
const [editingTexts, setEditingTexts] = useState<Record<string, string>>({});
|
|
470
|
+
const [newPrompt, setNewPrompt] = useState("");
|
|
471
|
+
const [newLanguage, setNewLanguage] = useState("en");
|
|
472
|
+
const [loading, setLoading] = useState(false);
|
|
473
|
+
const [viewMode, setViewMode] = useState<"form" | "json">("form");
|
|
474
|
+
const [jsonValue, setJsonValue] = useState("");
|
|
475
|
+
const [jsonError, setJsonError] = useState<string | null>(null);
|
|
476
|
+
|
|
477
|
+
const customTextEntries: CustomTextEntry[] = record?.customTextEntries || [];
|
|
478
|
+
|
|
479
|
+
// Get default texts for a screen, merged with existing values
|
|
480
|
+
const getTextsWithDefaults = useCallback(
|
|
481
|
+
(prompt: string, existingTexts: Record<string, string>) => {
|
|
482
|
+
const defaults = DEFAULT_TEXT_KEYS[prompt] || {};
|
|
483
|
+
const result: Record<string, string> = {};
|
|
484
|
+
|
|
485
|
+
// Add all default keys first (preserving order)
|
|
486
|
+
for (const key of Object.keys(defaults)) {
|
|
487
|
+
result[key] = existingTexts[key] ?? "";
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Add any custom keys that aren't in defaults
|
|
491
|
+
for (const key of Object.keys(existingTexts)) {
|
|
492
|
+
if (!(key in result) && existingTexts[key] !== undefined) {
|
|
493
|
+
result[key] = existingTexts[key]!;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return result;
|
|
498
|
+
},
|
|
499
|
+
[],
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
const handleAdd = useCallback(() => {
|
|
503
|
+
setNewPrompt("");
|
|
504
|
+
setNewLanguage("en");
|
|
505
|
+
setDialogOpen(true);
|
|
506
|
+
}, []);
|
|
507
|
+
|
|
508
|
+
const handleCreate = useCallback(async () => {
|
|
509
|
+
if (!newPrompt || !newLanguage) {
|
|
510
|
+
notify("Please select a screen and language", { type: "warning" });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
setLoading(true);
|
|
515
|
+
try {
|
|
516
|
+
// Get default text keys for this screen (empty values)
|
|
517
|
+
const defaults = DEFAULT_TEXT_KEYS[newPrompt] || {};
|
|
518
|
+
const initialTexts: Record<string, string> = {};
|
|
519
|
+
Object.keys(defaults).forEach((key) => {
|
|
520
|
+
initialTexts[key] = "";
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
await dataProvider.create("custom-text", {
|
|
524
|
+
data: {
|
|
525
|
+
prompt: newPrompt,
|
|
526
|
+
language: newLanguage,
|
|
527
|
+
texts: initialTexts,
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
notify("Custom text created successfully", { type: "success" });
|
|
531
|
+
setDialogOpen(false);
|
|
532
|
+
refresh();
|
|
533
|
+
} catch (error) {
|
|
534
|
+
notify("Error creating custom text", { type: "error" });
|
|
535
|
+
} finally {
|
|
536
|
+
setLoading(false);
|
|
537
|
+
}
|
|
538
|
+
}, [dataProvider, newPrompt, newLanguage, notify, refresh]);
|
|
539
|
+
|
|
540
|
+
const handleEdit = useCallback(
|
|
541
|
+
async (entry: CustomTextEntry) => {
|
|
542
|
+
setLoading(true);
|
|
543
|
+
try {
|
|
544
|
+
const result = await dataProvider.getOne("custom-text", {
|
|
545
|
+
id: `${entry.prompt}:${entry.language}`,
|
|
546
|
+
});
|
|
547
|
+
setSelectedEntry(entry);
|
|
548
|
+
const textsWithDefaults = getTextsWithDefaults(
|
|
549
|
+
entry.prompt,
|
|
550
|
+
result.data.texts || {},
|
|
551
|
+
);
|
|
552
|
+
setEditingTexts(textsWithDefaults);
|
|
553
|
+
setJsonValue(JSON.stringify(textsWithDefaults, null, 2));
|
|
554
|
+
setJsonError(null);
|
|
555
|
+
setViewMode("form");
|
|
556
|
+
setEditDialogOpen(true);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
notify("Error loading custom text", { type: "error" });
|
|
559
|
+
} finally {
|
|
560
|
+
setLoading(false);
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
[dataProvider, notify, getTextsWithDefaults],
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
const handleSave = useCallback(async () => {
|
|
567
|
+
if (!selectedEntry) return;
|
|
568
|
+
|
|
569
|
+
// If in JSON mode, parse and validate JSON first
|
|
570
|
+
let textsToSave = editingTexts;
|
|
571
|
+
if (viewMode === "json") {
|
|
572
|
+
try {
|
|
573
|
+
textsToSave = JSON.parse(jsonValue);
|
|
574
|
+
if (
|
|
575
|
+
textsToSave === null ||
|
|
576
|
+
typeof textsToSave !== "object" ||
|
|
577
|
+
Array.isArray(textsToSave)
|
|
578
|
+
) {
|
|
579
|
+
notify("JSON must be an object with string values", { type: "error" });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
// Validate all values are strings
|
|
583
|
+
const invalidKeys = Object.entries(textsToSave)
|
|
584
|
+
.filter(([, value]) => value !== null && typeof value !== "string")
|
|
585
|
+
.map(([key]) => key);
|
|
586
|
+
if (invalidKeys.length > 0) {
|
|
587
|
+
notify(
|
|
588
|
+
`Invalid values for keys: ${invalidKeys.join(", ")}. All values must be strings.`,
|
|
589
|
+
{ type: "error" },
|
|
590
|
+
);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
} catch (e) {
|
|
594
|
+
notify("Invalid JSON format", { type: "error" });
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Filter out empty/null values before saving
|
|
600
|
+
const filteredTexts: Record<string, string> = {};
|
|
601
|
+
for (const [key, value] of Object.entries(textsToSave)) {
|
|
602
|
+
if (value && typeof value === "string" && value.trim() !== "") {
|
|
603
|
+
filteredTexts[key] = value;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
setLoading(true);
|
|
608
|
+
try {
|
|
609
|
+
await dataProvider.update("custom-text", {
|
|
610
|
+
id: `${selectedEntry.prompt}:${selectedEntry.language}`,
|
|
611
|
+
data: { texts: filteredTexts },
|
|
612
|
+
previousData: {},
|
|
613
|
+
});
|
|
614
|
+
notify("Custom text updated successfully", { type: "success" });
|
|
615
|
+
setEditDialogOpen(false);
|
|
616
|
+
refresh();
|
|
617
|
+
} catch (error) {
|
|
618
|
+
notify("Error updating custom text", { type: "error" });
|
|
619
|
+
} finally {
|
|
620
|
+
setLoading(false);
|
|
621
|
+
}
|
|
622
|
+
}, [dataProvider, selectedEntry, editingTexts, jsonValue, viewMode, notify, refresh]);
|
|
623
|
+
|
|
624
|
+
const handleDelete = useCallback(
|
|
625
|
+
async (entry: CustomTextEntry) => {
|
|
626
|
+
if (
|
|
627
|
+
!window.confirm(
|
|
628
|
+
`Delete custom text for ${entry.prompt} (${entry.language})?`,
|
|
629
|
+
)
|
|
630
|
+
) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
setLoading(true);
|
|
635
|
+
try {
|
|
636
|
+
await dataProvider.delete("custom-text", {
|
|
637
|
+
id: `${entry.prompt}:${entry.language}`,
|
|
638
|
+
});
|
|
639
|
+
notify("Custom text deleted successfully", { type: "success" });
|
|
640
|
+
refresh();
|
|
641
|
+
} catch (error) {
|
|
642
|
+
notify("Error deleting custom text", { type: "error" });
|
|
643
|
+
} finally {
|
|
644
|
+
setLoading(false);
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
[dataProvider, notify, refresh],
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
const handleTextChange = useCallback((key: string, value: string) => {
|
|
651
|
+
setEditingTexts((prev) => {
|
|
652
|
+
const newTexts = { ...prev, [key]: value };
|
|
653
|
+
setJsonValue(JSON.stringify(newTexts, null, 2));
|
|
654
|
+
return newTexts;
|
|
655
|
+
});
|
|
656
|
+
}, []);
|
|
657
|
+
|
|
658
|
+
const handleJsonChange = useCallback((value: string) => {
|
|
659
|
+
setJsonValue(value);
|
|
660
|
+
try {
|
|
661
|
+
const parsed = JSON.parse(value);
|
|
662
|
+
if (
|
|
663
|
+
parsed === null ||
|
|
664
|
+
typeof parsed !== "object" ||
|
|
665
|
+
Array.isArray(parsed)
|
|
666
|
+
) {
|
|
667
|
+
setJsonError("JSON must be an object with string values");
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// Validate all values are strings or null
|
|
671
|
+
const hasInvalidValues = Object.values(parsed).some(
|
|
672
|
+
(v) => v !== null && typeof v !== "string",
|
|
673
|
+
);
|
|
674
|
+
if (hasInvalidValues) {
|
|
675
|
+
setJsonError("All values must be strings");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
// Filter to only string values for the form state
|
|
679
|
+
const stringOnly: Record<string, string> = {};
|
|
680
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
681
|
+
if (typeof v === "string") {
|
|
682
|
+
stringOnly[k] = v;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
setEditingTexts(stringOnly);
|
|
686
|
+
setJsonError(null);
|
|
687
|
+
} catch (e) {
|
|
688
|
+
setJsonError("Invalid JSON");
|
|
689
|
+
}
|
|
690
|
+
}, []);
|
|
691
|
+
|
|
692
|
+
const handleViewModeChange = useCallback(
|
|
693
|
+
(_event: React.MouseEvent<HTMLElement>, newMode: "form" | "json" | null) => {
|
|
694
|
+
if (newMode !== null) {
|
|
695
|
+
// Sync data between views
|
|
696
|
+
if (newMode === "json") {
|
|
697
|
+
setJsonValue(JSON.stringify(editingTexts, null, 2));
|
|
698
|
+
setJsonError(null);
|
|
699
|
+
} else if (newMode === "form" && !jsonError) {
|
|
700
|
+
try {
|
|
701
|
+
const parsed = JSON.parse(jsonValue);
|
|
702
|
+
// Validate before updating form state
|
|
703
|
+
if (
|
|
704
|
+
parsed !== null &&
|
|
705
|
+
typeof parsed === "object" &&
|
|
706
|
+
!Array.isArray(parsed)
|
|
707
|
+
) {
|
|
708
|
+
// Filter to only string values
|
|
709
|
+
const stringOnly: Record<string, string> = {};
|
|
710
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
711
|
+
if (typeof v === "string") {
|
|
712
|
+
stringOnly[k] = v;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
setEditingTexts(stringOnly);
|
|
716
|
+
}
|
|
717
|
+
} catch (e) {
|
|
718
|
+
// Keep existing form data if JSON is invalid
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
setViewMode(newMode);
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
[editingTexts, jsonValue, jsonError],
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
const handleAddTextKey = useCallback(() => {
|
|
728
|
+
const key = window.prompt("Enter new text key:");
|
|
729
|
+
if (key && !editingTexts[key]) {
|
|
730
|
+
setEditingTexts((prev) => ({ ...prev, [key]: "" }));
|
|
731
|
+
}
|
|
732
|
+
}, [editingTexts]);
|
|
733
|
+
|
|
734
|
+
const handleRemoveTextKey = useCallback((key: string) => {
|
|
735
|
+
setEditingTexts((prev) => {
|
|
736
|
+
const next = { ...prev };
|
|
737
|
+
delete next[key];
|
|
738
|
+
return next;
|
|
739
|
+
});
|
|
740
|
+
}, []);
|
|
741
|
+
|
|
742
|
+
const getScreenName = (prompt: string) => {
|
|
743
|
+
return PROMPT_SCREENS.find((s) => s.id === prompt)?.name || prompt;
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const getLanguageName = (lang: string) => {
|
|
747
|
+
return LANGUAGES.find((l) => l.id === lang)?.name || lang;
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
return (
|
|
751
|
+
<Box>
|
|
752
|
+
<Box
|
|
753
|
+
display="flex"
|
|
754
|
+
justifyContent="space-between"
|
|
755
|
+
alignItems="center"
|
|
756
|
+
mb={2}
|
|
757
|
+
>
|
|
758
|
+
<Typography variant="h6">Custom Text</Typography>
|
|
759
|
+
<Button
|
|
760
|
+
variant="contained"
|
|
761
|
+
startIcon={<AddIcon />}
|
|
762
|
+
onClick={handleAdd}
|
|
763
|
+
disabled={loading}
|
|
764
|
+
>
|
|
765
|
+
Add Custom Text
|
|
766
|
+
</Button>
|
|
767
|
+
</Box>
|
|
768
|
+
|
|
769
|
+
<Typography variant="body2" color="textSecondary" paragraph>
|
|
770
|
+
Customize button labels, messages, and screen texts in different
|
|
771
|
+
languages. Custom text applies only to Universal Login screens.
|
|
772
|
+
</Typography>
|
|
773
|
+
|
|
774
|
+
{customTextEntries.length === 0 ? (
|
|
775
|
+
<Typography color="textSecondary">
|
|
776
|
+
No custom text configured yet. Click "Add Custom Text" to create your
|
|
777
|
+
first customization.
|
|
778
|
+
</Typography>
|
|
779
|
+
) : (
|
|
780
|
+
<TableContainer component={Paper}>
|
|
781
|
+
<Table>
|
|
782
|
+
<TableHead>
|
|
783
|
+
<TableRow>
|
|
784
|
+
<TableCell>Screen</TableCell>
|
|
785
|
+
<TableCell>Language</TableCell>
|
|
786
|
+
<TableCell align="right">Actions</TableCell>
|
|
787
|
+
</TableRow>
|
|
788
|
+
</TableHead>
|
|
789
|
+
<TableBody>
|
|
790
|
+
{customTextEntries.map((entry) => (
|
|
791
|
+
<TableRow key={`${entry.prompt}:${entry.language}`}>
|
|
792
|
+
<TableCell>{getScreenName(entry.prompt)}</TableCell>
|
|
793
|
+
<TableCell>{getLanguageName(entry.language)}</TableCell>
|
|
794
|
+
<TableCell align="right">
|
|
795
|
+
<IconButton
|
|
796
|
+
onClick={() => handleEdit(entry)}
|
|
797
|
+
disabled={loading}
|
|
798
|
+
size="small"
|
|
799
|
+
>
|
|
800
|
+
<EditIcon />
|
|
801
|
+
</IconButton>
|
|
802
|
+
<IconButton
|
|
803
|
+
onClick={() => handleDelete(entry)}
|
|
804
|
+
disabled={loading}
|
|
805
|
+
size="small"
|
|
806
|
+
color="error"
|
|
807
|
+
>
|
|
808
|
+
<DeleteIcon />
|
|
809
|
+
</IconButton>
|
|
810
|
+
</TableCell>
|
|
811
|
+
</TableRow>
|
|
812
|
+
))}
|
|
813
|
+
</TableBody>
|
|
814
|
+
</Table>
|
|
815
|
+
</TableContainer>
|
|
816
|
+
)}
|
|
817
|
+
|
|
818
|
+
{/* Create Dialog */}
|
|
819
|
+
<Dialog
|
|
820
|
+
open={dialogOpen}
|
|
821
|
+
onClose={() => setDialogOpen(false)}
|
|
822
|
+
maxWidth="sm"
|
|
823
|
+
fullWidth
|
|
824
|
+
>
|
|
825
|
+
<DialogTitle>Add Custom Text</DialogTitle>
|
|
826
|
+
<DialogContent>
|
|
827
|
+
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
828
|
+
<TextField
|
|
829
|
+
select
|
|
830
|
+
label="Screen"
|
|
831
|
+
value={newPrompt}
|
|
832
|
+
onChange={(e) => setNewPrompt(e.target.value)}
|
|
833
|
+
fullWidth
|
|
834
|
+
>
|
|
835
|
+
{PROMPT_SCREENS.map((screen) => (
|
|
836
|
+
<MenuItem key={screen.id} value={screen.id}>
|
|
837
|
+
{screen.name}
|
|
838
|
+
</MenuItem>
|
|
839
|
+
))}
|
|
840
|
+
</TextField>
|
|
841
|
+
<TextField
|
|
842
|
+
select
|
|
843
|
+
label="Language"
|
|
844
|
+
value={newLanguage}
|
|
845
|
+
onChange={(e) => setNewLanguage(e.target.value)}
|
|
846
|
+
fullWidth
|
|
847
|
+
>
|
|
848
|
+
{LANGUAGES.map((lang) => (
|
|
849
|
+
<MenuItem key={lang.id} value={lang.id}>
|
|
850
|
+
{lang.name}
|
|
851
|
+
</MenuItem>
|
|
852
|
+
))}
|
|
853
|
+
</TextField>
|
|
854
|
+
</Stack>
|
|
855
|
+
</DialogContent>
|
|
856
|
+
<DialogActions>
|
|
857
|
+
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
|
858
|
+
<Button onClick={handleCreate} variant="contained" disabled={loading}>
|
|
859
|
+
Create
|
|
860
|
+
</Button>
|
|
861
|
+
</DialogActions>
|
|
862
|
+
</Dialog>
|
|
863
|
+
|
|
864
|
+
{/* Edit Dialog */}
|
|
865
|
+
<Dialog
|
|
866
|
+
open={editDialogOpen}
|
|
867
|
+
onClose={() => setEditDialogOpen(false)}
|
|
868
|
+
maxWidth="lg"
|
|
869
|
+
fullWidth
|
|
870
|
+
PaperProps={{ sx: { minHeight: "80vh" } }}
|
|
871
|
+
>
|
|
872
|
+
<DialogTitle>
|
|
873
|
+
<Box
|
|
874
|
+
display="flex"
|
|
875
|
+
justifyContent="space-between"
|
|
876
|
+
alignItems="center"
|
|
877
|
+
>
|
|
878
|
+
<span>
|
|
879
|
+
Edit Custom Text -{" "}
|
|
880
|
+
{selectedEntry && getScreenName(selectedEntry.prompt)} (
|
|
881
|
+
{selectedEntry && getLanguageName(selectedEntry.language)})
|
|
882
|
+
</span>
|
|
883
|
+
<ToggleButtonGroup
|
|
884
|
+
value={viewMode}
|
|
885
|
+
exclusive
|
|
886
|
+
onChange={handleViewModeChange}
|
|
887
|
+
size="small"
|
|
888
|
+
>
|
|
889
|
+
<ToggleButton value="form">Form</ToggleButton>
|
|
890
|
+
<ToggleButton value="json">JSON</ToggleButton>
|
|
891
|
+
</ToggleButtonGroup>
|
|
892
|
+
</Box>
|
|
893
|
+
</DialogTitle>
|
|
894
|
+
<DialogContent>
|
|
895
|
+
<Box sx={{ mt: 1 }}>
|
|
896
|
+
{viewMode === "form" ? (
|
|
897
|
+
<>
|
|
898
|
+
<Box
|
|
899
|
+
display="flex"
|
|
900
|
+
justifyContent="space-between"
|
|
901
|
+
alignItems="center"
|
|
902
|
+
mb={2}
|
|
903
|
+
>
|
|
904
|
+
<Typography variant="body2" color="textSecondary">
|
|
905
|
+
Fill in the values you want to customize. Empty fields will
|
|
906
|
+
use the default values. Variables like ${"{"}clientName{"}"}{" "}
|
|
907
|
+
will be replaced at runtime.
|
|
908
|
+
</Typography>
|
|
909
|
+
<Button
|
|
910
|
+
size="small"
|
|
911
|
+
startIcon={<AddIcon />}
|
|
912
|
+
onClick={handleAddTextKey}
|
|
913
|
+
>
|
|
914
|
+
Add Custom Key
|
|
915
|
+
</Button>
|
|
916
|
+
</Box>
|
|
917
|
+
|
|
918
|
+
{/* Group fields by category */}
|
|
919
|
+
{(() => {
|
|
920
|
+
const entries = Object.entries(editingTexts);
|
|
921
|
+
const defaults = selectedEntry
|
|
922
|
+
? DEFAULT_TEXT_KEYS[selectedEntry.prompt] || {}
|
|
923
|
+
: {};
|
|
924
|
+
|
|
925
|
+
// Categorize fields
|
|
926
|
+
const categories = {
|
|
927
|
+
"Page & Titles": [] as [string, string][],
|
|
928
|
+
"Buttons & Actions": [] as [string, string][],
|
|
929
|
+
"Input Fields": [] as [string, string][],
|
|
930
|
+
"Messages & Labels": [] as [string, string][],
|
|
931
|
+
"Error Messages": [] as [string, string][],
|
|
932
|
+
"Custom Fields": [] as [string, string][],
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
entries.forEach(([key, value]) => {
|
|
936
|
+
if (
|
|
937
|
+
key.includes("error") ||
|
|
938
|
+
key.includes("Error") ||
|
|
939
|
+
key.startsWith("wrong-") ||
|
|
940
|
+
key.startsWith("invalid-") ||
|
|
941
|
+
key.startsWith("no-") ||
|
|
942
|
+
key.includes("blocked") ||
|
|
943
|
+
key.includes("breached") ||
|
|
944
|
+
key.includes("failure") ||
|
|
945
|
+
key.includes("captcha")
|
|
946
|
+
) {
|
|
947
|
+
categories["Error Messages"].push([key, value]);
|
|
948
|
+
} else if (
|
|
949
|
+
key.includes("Title") ||
|
|
950
|
+
key.includes("title") ||
|
|
951
|
+
key.includes("pageTitle") ||
|
|
952
|
+
key.includes("description") ||
|
|
953
|
+
key.includes("Description")
|
|
954
|
+
) {
|
|
955
|
+
categories["Page & Titles"].push([key, value]);
|
|
956
|
+
} else if (
|
|
957
|
+
key.includes("button") ||
|
|
958
|
+
key.includes("Button") ||
|
|
959
|
+
key.includes("Action") ||
|
|
960
|
+
key.includes("Link") ||
|
|
961
|
+
(key.includes("Text") &&
|
|
962
|
+
(key.includes("footer") ||
|
|
963
|
+
key.includes("signup") ||
|
|
964
|
+
key.includes("login") ||
|
|
965
|
+
key.includes("forgot") ||
|
|
966
|
+
key.includes("back")))
|
|
967
|
+
) {
|
|
968
|
+
categories["Buttons & Actions"].push([key, value]);
|
|
969
|
+
} else if (
|
|
970
|
+
key.includes("Placeholder") ||
|
|
971
|
+
key.includes("placeholder") ||
|
|
972
|
+
key.includes("Label") ||
|
|
973
|
+
key.includes("select")
|
|
974
|
+
) {
|
|
975
|
+
categories["Input Fields"].push([key, value]);
|
|
976
|
+
} else if (!(key in defaults)) {
|
|
977
|
+
categories["Custom Fields"].push([key, value]);
|
|
978
|
+
} else {
|
|
979
|
+
categories["Messages & Labels"].push([key, value]);
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
return Object.entries(categories)
|
|
984
|
+
.filter(([_, items]) => items.length > 0)
|
|
985
|
+
.map(([category, items]) => (
|
|
986
|
+
<Accordion key={category} defaultExpanded>
|
|
987
|
+
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
988
|
+
<Typography variant="subtitle1">
|
|
989
|
+
{category}
|
|
990
|
+
<Chip
|
|
991
|
+
size="small"
|
|
992
|
+
label={items.length}
|
|
993
|
+
sx={{ ml: 1 }}
|
|
994
|
+
/>
|
|
995
|
+
</Typography>
|
|
996
|
+
</AccordionSummary>
|
|
997
|
+
<AccordionDetails>
|
|
998
|
+
<Stack spacing={2}>
|
|
999
|
+
{items.map(([key, value]) => {
|
|
1000
|
+
const defaultValue = defaults[key] || "";
|
|
1001
|
+
const isCustom = !(key in defaults);
|
|
1002
|
+
return (
|
|
1003
|
+
<Box
|
|
1004
|
+
key={key}
|
|
1005
|
+
display="flex"
|
|
1006
|
+
alignItems="flex-start"
|
|
1007
|
+
gap={1}
|
|
1008
|
+
>
|
|
1009
|
+
<TextField
|
|
1010
|
+
label={key}
|
|
1011
|
+
value={value}
|
|
1012
|
+
onChange={(e) =>
|
|
1013
|
+
handleTextChange(key, e.target.value)
|
|
1014
|
+
}
|
|
1015
|
+
fullWidth
|
|
1016
|
+
multiline
|
|
1017
|
+
minRows={1}
|
|
1018
|
+
maxRows={4}
|
|
1019
|
+
placeholder={defaultValue}
|
|
1020
|
+
helperText={
|
|
1021
|
+
defaultValue && !isCustom
|
|
1022
|
+
? `Default: ${defaultValue.length > 80 ? defaultValue.substring(0, 80) + "..." : defaultValue}`
|
|
1023
|
+
: isCustom
|
|
1024
|
+
? "Custom field"
|
|
1025
|
+
: undefined
|
|
1026
|
+
}
|
|
1027
|
+
InputLabelProps={{
|
|
1028
|
+
shrink: true,
|
|
1029
|
+
}}
|
|
1030
|
+
/>
|
|
1031
|
+
<IconButton
|
|
1032
|
+
onClick={() => handleRemoveTextKey(key)}
|
|
1033
|
+
size="small"
|
|
1034
|
+
color="error"
|
|
1035
|
+
title="Remove field"
|
|
1036
|
+
>
|
|
1037
|
+
<DeleteIcon />
|
|
1038
|
+
</IconButton>
|
|
1039
|
+
</Box>
|
|
1040
|
+
);
|
|
1041
|
+
})}
|
|
1042
|
+
</Stack>
|
|
1043
|
+
</AccordionDetails>
|
|
1044
|
+
</Accordion>
|
|
1045
|
+
));
|
|
1046
|
+
})()}
|
|
1047
|
+
|
|
1048
|
+
{Object.keys(editingTexts).length === 0 && (
|
|
1049
|
+
<Typography color="textSecondary">
|
|
1050
|
+
No text keys configured. Click "Add Custom Key" to add
|
|
1051
|
+
customizations.
|
|
1052
|
+
</Typography>
|
|
1053
|
+
)}
|
|
1054
|
+
</>
|
|
1055
|
+
) : (
|
|
1056
|
+
<>
|
|
1057
|
+
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
|
1058
|
+
Edit all text values as JSON. You can copy and paste the entire
|
|
1059
|
+
JSON object to quickly update all values.
|
|
1060
|
+
</Typography>
|
|
1061
|
+
{jsonError && (
|
|
1062
|
+
<Alert severity="error" sx={{ mb: 2 }}>
|
|
1063
|
+
{jsonError}
|
|
1064
|
+
</Alert>
|
|
1065
|
+
)}
|
|
1066
|
+
<TextField
|
|
1067
|
+
multiline
|
|
1068
|
+
fullWidth
|
|
1069
|
+
minRows={20}
|
|
1070
|
+
maxRows={30}
|
|
1071
|
+
value={jsonValue}
|
|
1072
|
+
onChange={(e) => handleJsonChange(e.target.value)}
|
|
1073
|
+
error={!!jsonError}
|
|
1074
|
+
sx={{
|
|
1075
|
+
fontFamily: "monospace",
|
|
1076
|
+
"& .MuiInputBase-input": {
|
|
1077
|
+
fontFamily: "monospace",
|
|
1078
|
+
fontSize: "0.875rem",
|
|
1079
|
+
},
|
|
1080
|
+
}}
|
|
1081
|
+
/>
|
|
1082
|
+
</>
|
|
1083
|
+
)}
|
|
1084
|
+
</Box>
|
|
1085
|
+
</DialogContent>
|
|
1086
|
+
<DialogActions>
|
|
1087
|
+
<Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
|
|
1088
|
+
<Button
|
|
1089
|
+
onClick={handleSave}
|
|
1090
|
+
variant="contained"
|
|
1091
|
+
disabled={loading || (viewMode === "json" && !!jsonError)}
|
|
1092
|
+
>
|
|
1093
|
+
Save
|
|
1094
|
+
</Button>
|
|
1095
|
+
</DialogActions>
|
|
1096
|
+
</Dialog>
|
|
1097
|
+
</Box>
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
export function PromptsEdit() {
|
|
1102
|
+
const transform = (data: Record<string, unknown>) => {
|
|
1103
|
+
return removeNullValues(data);
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
return (
|
|
1107
|
+
<Edit transform={transform}>
|
|
1108
|
+
<TabbedForm>
|
|
1109
|
+
<TabbedForm.Tab label="Settings">
|
|
1110
|
+
<Typography variant="h6" gutterBottom>
|
|
1111
|
+
Prompt Settings
|
|
1112
|
+
</Typography>
|
|
1113
|
+
<Typography variant="body2" color="textSecondary" paragraph>
|
|
1114
|
+
Configure how the login prompts behave for your users.
|
|
1115
|
+
</Typography>
|
|
1116
|
+
|
|
1117
|
+
<Stack spacing={2} sx={{ maxWidth: 600 }}>
|
|
1118
|
+
<SelectInput
|
|
1119
|
+
source="universal_login_experience"
|
|
1120
|
+
label="Universal Login Experience"
|
|
1121
|
+
choices={[
|
|
1122
|
+
{ id: "new", name: "New Universal Login" },
|
|
1123
|
+
{ id: "classic", name: "Classic Universal Login" },
|
|
1124
|
+
]}
|
|
1125
|
+
helperText="Choose between the new or classic Universal Login experience"
|
|
1126
|
+
fullWidth
|
|
1127
|
+
/>
|
|
1128
|
+
|
|
1129
|
+
<BooleanInput
|
|
1130
|
+
source="identifier_first"
|
|
1131
|
+
label="Identifier First"
|
|
1132
|
+
helperText="Show identifier (email/username) field first, then password on a separate screen"
|
|
1133
|
+
/>
|
|
1134
|
+
|
|
1135
|
+
<BooleanInput
|
|
1136
|
+
source="password_first"
|
|
1137
|
+
label="Password First"
|
|
1138
|
+
helperText="Show password field on the first screen along with the identifier"
|
|
1139
|
+
/>
|
|
1140
|
+
|
|
1141
|
+
<BooleanInput
|
|
1142
|
+
source="webauthn_platform_first_factor"
|
|
1143
|
+
label="WebAuthn Platform First Factor"
|
|
1144
|
+
helperText="Enable WebAuthn (passkeys, biometrics) as a first factor authentication option"
|
|
1145
|
+
/>
|
|
1146
|
+
</Stack>
|
|
1147
|
+
</TabbedForm.Tab>
|
|
1148
|
+
|
|
1149
|
+
<TabbedForm.Tab label="Custom Text">
|
|
1150
|
+
<CustomTextTab />
|
|
1151
|
+
</TabbedForm.Tab>
|
|
1152
|
+
</TabbedForm>
|
|
1153
|
+
</Edit>
|
|
1154
|
+
);
|
|
1155
|
+
}
|