@authhero/react-admin 0.31.0 → 0.33.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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # @authhero/react-admin
2
2
 
3
+ ## 0.33.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bf22ac7: Add support for inlang
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [bf22ac7]
12
+ - @authhero/widget@0.11.0
13
+
14
+ ## 0.32.0
15
+
16
+ ### Minor Changes
17
+
18
+ - 44b76d9: Update the custom text behaviour
19
+
20
+ ### Patch Changes
21
+
22
+ - Updated dependencies [44b76d9]
23
+ - @authhero/widget@0.10.0
24
+
3
25
  ## 0.31.0
4
26
 
5
27
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authhero/react-admin",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "packageManager": "pnpm@10.20.0",
5
5
  "private": false,
6
6
  "repository": {
@@ -38,6 +38,12 @@ function ConnectionTabbedFrom() {
38
38
  <TabbedForm.Tab label="details">
39
39
  <TextInput source="id" label="Client ID" style={{ width: "800px" }} />
40
40
  <TextInput disabled source="strategy" />
41
+ <TextInput
42
+ source="display_name"
43
+ label="Display Name"
44
+ helperText="Custom display name for the login button (optional)"
45
+ fullWidth
46
+ />
41
47
  <TextInput source="options.client_id" label="Client Id" />
42
48
  <TextInput
43
49
  source="options.client_secret"
@@ -87,11 +93,6 @@ function ConnectionTabbedFrom() {
87
93
 
88
94
  {["oauth2", "oidc"].includes(record?.strategy) && (
89
95
  <>
90
- <TextInput
91
- source="display_name"
92
- label="Display Name"
93
- fullWidth
94
- />
95
96
  <SelectInput
96
97
  source="response_type"
97
98
  label="Response Type"
@@ -27,7 +27,15 @@ import {
27
27
  DialogActions,
28
28
  TextField,
29
29
  MenuItem,
30
+ ToggleButton,
31
+ ToggleButtonGroup,
32
+ Alert,
33
+ Accordion,
34
+ AccordionSummary,
35
+ AccordionDetails,
36
+ Chip,
30
37
  } from "@mui/material";
38
+ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
31
39
  import EditIcon from "@mui/icons-material/Edit";
32
40
  import DeleteIcon from "@mui/icons-material/Delete";
33
41
  import AddIcon from "@mui/icons-material/Add";
@@ -81,69 +89,345 @@ const LANGUAGES = [
81
89
  { id: "cs", name: "Czech" },
82
90
  ];
83
91
 
84
- // Default text keys for each screen type
85
- const DEFAULT_TEXT_KEYS: Record<string, string[]> = {
86
- login: [
87
- "pageTitle",
88
- "title",
89
- "description",
90
- "buttonText",
91
- "signupActionLinkText",
92
- "signupActionText",
93
- "forgotPasswordText",
94
- "usernameLabel",
95
- "usernameOrEmailLabel",
96
- "emailLabel",
97
- "phoneLabel",
98
- "passwordLabel",
99
- "separatorText",
100
- "continueWithText",
101
- "invitationTitle",
102
- "invitationDescription",
103
- "alertListTitle",
104
- "wrongEmailOrPasswordErrorText",
105
- "invalidEmailErrorText",
106
- "passwordRequiredErrorText",
107
- "captchaErrorText",
108
- ],
109
- signup: [
110
- "pageTitle",
111
- "title",
112
- "description",
113
- "buttonText",
114
- "loginActionLinkText",
115
- "loginActionText",
116
- "usernameLabel",
117
- "emailLabel",
118
- "phoneLabel",
119
- "passwordLabel",
120
- "confirmPasswordLabel",
121
- "termsText",
122
- "privacyPolicyText",
123
- "separatorText",
124
- "continueWithText",
125
- ],
126
- "reset-password": [
127
- "pageTitle",
128
- "title",
129
- "description",
130
- "buttonText",
131
- "backToLoginText",
132
- "emailLabel",
133
- "successTitle",
134
- "successDescription",
135
- ],
136
- common: [
137
- "alertListTitle",
138
- "showPasswordText",
139
- "hidePasswordText",
140
- "continueText",
141
- "orText",
142
- "termsOfServiceText",
143
- "privacyPolicyText",
144
- "contactSupportText",
145
- "copyrightText",
146
- ],
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
+ },
147
431
  };
148
432
 
149
433
  // Remove null/undefined values from an object
@@ -186,9 +470,35 @@ function CustomTextTab() {
186
470
  const [newPrompt, setNewPrompt] = useState("");
187
471
  const [newLanguage, setNewLanguage] = useState("en");
188
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);
189
476
 
190
477
  const customTextEntries: CustomTextEntry[] = record?.customTextEntries || [];
191
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
+
192
502
  const handleAdd = useCallback(() => {
193
503
  setNewPrompt("");
194
504
  setNewLanguage("en");
@@ -203,10 +513,10 @@ function CustomTextTab() {
203
513
 
204
514
  setLoading(true);
205
515
  try {
206
- // Get default text keys for this screen
207
- const defaultKeys = DEFAULT_TEXT_KEYS[newPrompt] || [];
516
+ // Get default text keys for this screen (empty values)
517
+ const defaults = DEFAULT_TEXT_KEYS[newPrompt] || {};
208
518
  const initialTexts: Record<string, string> = {};
209
- defaultKeys.forEach((key) => {
519
+ Object.keys(defaults).forEach((key) => {
210
520
  initialTexts[key] = "";
211
521
  });
212
522
 
@@ -235,7 +545,14 @@ function CustomTextTab() {
235
545
  id: `${entry.prompt}:${entry.language}`,
236
546
  });
237
547
  setSelectedEntry(entry);
238
- setEditingTexts(result.data.texts || {});
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");
239
556
  setEditDialogOpen(true);
240
557
  } catch (error) {
241
558
  notify("Error loading custom text", { type: "error" });
@@ -243,17 +560,55 @@ function CustomTextTab() {
243
560
  setLoading(false);
244
561
  }
245
562
  },
246
- [dataProvider, notify],
563
+ [dataProvider, notify, getTextsWithDefaults],
247
564
  );
248
565
 
249
566
  const handleSave = useCallback(async () => {
250
567
  if (!selectedEntry) return;
251
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
+
252
607
  setLoading(true);
253
608
  try {
254
609
  await dataProvider.update("custom-text", {
255
610
  id: `${selectedEntry.prompt}:${selectedEntry.language}`,
256
- data: { texts: editingTexts },
611
+ data: { texts: filteredTexts },
257
612
  previousData: {},
258
613
  });
259
614
  notify("Custom text updated successfully", { type: "success" });
@@ -264,7 +619,7 @@ function CustomTextTab() {
264
619
  } finally {
265
620
  setLoading(false);
266
621
  }
267
- }, [dataProvider, selectedEntry, editingTexts, notify, refresh]);
622
+ }, [dataProvider, selectedEntry, editingTexts, jsonValue, viewMode, notify, refresh]);
268
623
 
269
624
  const handleDelete = useCallback(
270
625
  async (entry: CustomTextEntry) => {
@@ -293,9 +648,82 @@ function CustomTextTab() {
293
648
  );
294
649
 
295
650
  const handleTextChange = useCallback((key: string, value: string) => {
296
- setEditingTexts((prev) => ({ ...prev, [key]: value }));
651
+ setEditingTexts((prev) => {
652
+ const newTexts = { ...prev, [key]: value };
653
+ setJsonValue(JSON.stringify(newTexts, null, 2));
654
+ return newTexts;
655
+ });
297
656
  }, []);
298
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
+
299
727
  const handleAddTextKey = useCallback(() => {
300
728
  const key = window.prompt("Enter new text key:");
301
729
  if (key && !editingTexts[key]) {
@@ -437,58 +865,231 @@ function CustomTextTab() {
437
865
  <Dialog
438
866
  open={editDialogOpen}
439
867
  onClose={() => setEditDialogOpen(false)}
440
- maxWidth="md"
868
+ maxWidth="lg"
441
869
  fullWidth
870
+ PaperProps={{ sx: { minHeight: "80vh" } }}
442
871
  >
443
872
  <DialogTitle>
444
- Edit Custom Text -{" "}
445
- {selectedEntry && getScreenName(selectedEntry.prompt)} (
446
- {selectedEntry && getLanguageName(selectedEntry.language)})
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>
447
893
  </DialogTitle>
448
894
  <DialogContent>
449
895
  <Box sx={{ mt: 1 }}>
450
- <Box display="flex" justifyContent="flex-end" mb={2}>
451
- <Button
452
- size="small"
453
- startIcon={<AddIcon />}
454
- onClick={handleAddTextKey}
455
- >
456
- Add Text Key
457
- </Button>
458
- </Box>
459
- <Stack spacing={2}>
460
- {Object.entries(editingTexts).map(([key, value]) => (
461
- <Box key={key} display="flex" alignItems="flex-start" gap={1}>
462
- <TextField
463
- label={key}
464
- value={value}
465
- onChange={(e) => handleTextChange(key, e.target.value)}
466
- fullWidth
467
- multiline
468
- minRows={1}
469
- maxRows={4}
470
- />
471
- <IconButton
472
- onClick={() => handleRemoveTextKey(key)}
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
473
910
  size="small"
474
- color="error"
911
+ startIcon={<AddIcon />}
912
+ onClick={handleAddTextKey}
475
913
  >
476
- <DeleteIcon />
477
- </IconButton>
914
+ Add Custom Key
915
+ </Button>
478
916
  </Box>
479
- ))}
480
- {Object.keys(editingTexts).length === 0 && (
481
- <Typography color="textSecondary">
482
- No text keys configured. Click "Add Text Key" to add
483
- customizations.
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.
484
1060
  </Typography>
485
- )}
486
- </Stack>
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
+ )}
487
1084
  </Box>
488
1085
  </DialogContent>
489
1086
  <DialogActions>
490
1087
  <Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
491
- <Button onClick={handleSave} variant="contained" disabled={loading}>
1088
+ <Button
1089
+ onClick={handleSave}
1090
+ variant="contained"
1091
+ disabled={loading || (viewMode === "json" && !!jsonError)}
1092
+ >
492
1093
  Save
493
1094
  </Button>
494
1095
  </DialogActions>