@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.
@@ -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
+ }