@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 +22 -0
- package/package.json +1 -1
- package/src/components/connections/edit.tsx +6 -5
- package/src/components/prompts/edit.tsx +710 -109
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
|
@@ -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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
"
|
|
103
|
-
|
|
104
|
-
"
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
-
"
|
|
120
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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) =>
|
|
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="
|
|
868
|
+
maxWidth="lg"
|
|
441
869
|
fullWidth
|
|
870
|
+
PaperProps={{ sx: { minHeight: "80vh" } }}
|
|
442
871
|
>
|
|
443
872
|
<DialogTitle>
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
911
|
+
startIcon={<AddIcon />}
|
|
912
|
+
onClick={handleAddTextKey}
|
|
475
913
|
>
|
|
476
|
-
|
|
477
|
-
</
|
|
914
|
+
Add Custom Key
|
|
915
|
+
</Button>
|
|
478
916
|
</Box>
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
|
1088
|
+
<Button
|
|
1089
|
+
onClick={handleSave}
|
|
1090
|
+
variant="contained"
|
|
1091
|
+
disabled={loading || (viewMode === "json" && !!jsonError)}
|
|
1092
|
+
>
|
|
492
1093
|
Save
|
|
493
1094
|
</Button>
|
|
494
1095
|
</DialogActions>
|