@authhero/react-admin 0.30.0 → 0.31.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,18 @@
1
1
  # @authhero/react-admin
2
2
 
3
+ ## 0.31.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ac8af37: Add custom text support
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [88a03cd]
12
+ - Updated dependencies [ac8af37]
13
+ - @authhero/widget@0.9.0
14
+ - @authhero/adapter-interfaces@0.130.0
15
+
3
16
  ## 0.30.0
4
17
 
5
18
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authhero/react-admin",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "packageManager": "pnpm@10.20.0",
5
5
  "private": false,
6
6
  "repository": {
package/src/App.tsx CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  DomainList,
26
26
  } from "./components/custom-domains";
27
27
  import { BrandingList, BrandingEdit } from "./components/branding";
28
+ import { PromptsList, PromptsEdit } from "./components/prompts";
28
29
  import { LogsList, LogShow } from "./components/logs";
29
30
  import { HookEdit, HookList, HooksCreate } from "./components/hooks";
30
31
  import { SessionEdit } from "./components/sessions";
@@ -41,6 +42,7 @@ import {
41
42
  import WebhookIcon from "@mui/icons-material/Webhook";
42
43
  import DnsIcon from "@mui/icons-material/Dns";
43
44
  import PaletteIcon from "@mui/icons-material/Palette";
45
+ import TextFieldsIcon from "@mui/icons-material/TextFields";
44
46
  import StorageIcon from "@mui/icons-material/Storage";
45
47
  import AccountTreeIcon from "@mui/icons-material/AccountTree";
46
48
  import { useMemo, useState, useEffect } from "react";
@@ -251,6 +253,14 @@ export function App(props: AppProps) {
251
253
  edit={BrandingEdit}
252
254
  show={ShowGuesser}
253
255
  />
256
+ <Resource
257
+ icon={TextFieldsIcon}
258
+ name="prompts"
259
+ options={{ hasSingle: true }}
260
+ list={PromptsList}
261
+ edit={PromptsEdit}
262
+ show={ShowGuesser}
263
+ />
254
264
  <Resource
255
265
  icon={StorageIcon}
256
266
  name="resource-servers"
@@ -346,6 +346,61 @@ export default (
346
346
  );
347
347
  }
348
348
 
349
+ // Handle prompts singleton resource
350
+ if (resource === "prompts") {
351
+ const headers = createHeaders(tenantId);
352
+ try {
353
+ const res = await httpClient(`${apiUrl}/api/v2/prompts`, { headers });
354
+ // Also fetch custom text entries list
355
+ let customTextEntries: Array<{ prompt: string; language: string }> =
356
+ [];
357
+ try {
358
+ const customTextRes = await httpClient(
359
+ `${apiUrl}/api/v2/prompts/custom-text`,
360
+ { headers },
361
+ );
362
+ customTextEntries = customTextRes.json || [];
363
+ } catch {
364
+ // Custom text list might not exist yet
365
+ }
366
+ return {
367
+ data: [{ ...res.json, customTextEntries, id: resource }],
368
+ total: 1,
369
+ };
370
+ } catch (error) {
371
+ console.error("Error fetching prompts:", error);
372
+ return {
373
+ data: [{ id: resource, customTextEntries: [] }],
374
+ total: 1,
375
+ };
376
+ }
377
+ }
378
+
379
+ // Handle custom-text resource (for individual custom text entries)
380
+ if (resource === "custom-text") {
381
+ const headers = createHeaders(tenantId);
382
+ try {
383
+ const res = await httpClient(
384
+ `${apiUrl}/api/v2/prompts/custom-text`,
385
+ { headers },
386
+ );
387
+ const entries = res.json || [];
388
+ return {
389
+ data: entries.map(
390
+ (e: { prompt: string; language: string }, idx: number) => ({
391
+ id: `${e.prompt}:${e.language}`,
392
+ prompt: e.prompt,
393
+ language: e.language,
394
+ }),
395
+ ),
396
+ total: entries.length,
397
+ };
398
+ } catch (error) {
399
+ console.error("Error fetching custom-text list:", error);
400
+ return { data: [], total: 0 };
401
+ }
402
+ }
403
+
349
404
  // Handle organizations with client-side paging and search (fetch 500, filter locally)
350
405
  if (resource === "organizations" && !resourcePath.includes("/")) {
351
406
  const result = await managementClient.organizations.list({
@@ -587,6 +642,62 @@ export default (
587
642
  };
588
643
  }
589
644
 
645
+ // Handle prompts singleton resource
646
+ if (resource === "prompts") {
647
+ const headers = createHeaders(tenantId);
648
+ try {
649
+ const res = await httpClient(`${apiUrl}/api/v2/prompts`, { headers });
650
+ // Also fetch custom text entries list
651
+ let customTextEntries: Array<{ prompt: string; language: string }> =
652
+ [];
653
+ try {
654
+ const customTextRes = await httpClient(
655
+ `${apiUrl}/api/v2/prompts/custom-text`,
656
+ { headers },
657
+ );
658
+ customTextEntries = customTextRes.json || [];
659
+ } catch {
660
+ // Custom text list might not exist yet
661
+ }
662
+ return {
663
+ data: { ...res.json, customTextEntries, id: resource },
664
+ };
665
+ } catch (error) {
666
+ console.error("Error fetching prompts:", error);
667
+ return {
668
+ data: { id: resource, customTextEntries: [] },
669
+ };
670
+ }
671
+ }
672
+
673
+ // Handle custom-text resource (individual entries)
674
+ if (resource === "custom-text") {
675
+ // ID format is "prompt:language"
676
+ const [prompt, language] = params.id.split(":");
677
+ if (!prompt || !language) {
678
+ throw new Error("Invalid custom-text ID format");
679
+ }
680
+ try {
681
+ const result = await managementClient.prompts.customText.get(
682
+ prompt as any,
683
+ language as any,
684
+ );
685
+ return {
686
+ data: {
687
+ id: params.id,
688
+ prompt,
689
+ language,
690
+ texts: result || {},
691
+ },
692
+ };
693
+ } catch (error) {
694
+ console.error("Error fetching custom-text:", error);
695
+ return {
696
+ data: { id: params.id, prompt, language, texts: {} },
697
+ };
698
+ }
699
+ }
700
+
590
701
  // Handle stats/active-users endpoint
591
702
  if (resource === "stats/active-users") {
592
703
  const headers = createHeaders(tenantId);
@@ -948,6 +1059,44 @@ export default (
948
1059
  };
949
1060
  }
950
1061
 
1062
+ // Handle prompts singleton resource
1063
+ if (resource === "prompts") {
1064
+ const headers = createHeaders(tenantId);
1065
+ headers.set("Content-Type", "application/json");
1066
+ // Don't send customTextEntries to the settings endpoint
1067
+ const { customTextEntries, ...promptsData } = cleanParams.data;
1068
+ const res = await httpClient(`${apiUrl}/api/v2/prompts`, {
1069
+ method: "PATCH",
1070
+ headers,
1071
+ body: JSON.stringify(promptsData),
1072
+ });
1073
+ return {
1074
+ data: { ...res.json, customTextEntries, id: resource },
1075
+ };
1076
+ }
1077
+
1078
+ // Handle custom-text resource
1079
+ if (resource === "custom-text") {
1080
+ // ID format is "prompt:language"
1081
+ const [prompt, language] = params.id.split(":");
1082
+ if (!prompt || !language) {
1083
+ throw new Error("Invalid custom-text ID format");
1084
+ }
1085
+ await managementClient.prompts.customText.set(
1086
+ prompt as any,
1087
+ language as any,
1088
+ cleanParams.data.texts || {},
1089
+ );
1090
+ return {
1091
+ data: {
1092
+ id: params.id,
1093
+ prompt,
1094
+ language,
1095
+ texts: cleanParams.data.texts || {},
1096
+ },
1097
+ };
1098
+ }
1099
+
951
1100
  // Special handling for branding to update theme data separately
952
1101
  if (resource === "branding") {
953
1102
  // Extract themes from the payload - it's updated via a separate endpoint
@@ -1123,6 +1272,27 @@ export default (
1123
1272
  if (tenantId) headers.set("tenant-id", tenantId);
1124
1273
  const managementClient = await getManagementClient();
1125
1274
 
1275
+ // Handle custom-text resource
1276
+ if (resource === "custom-text") {
1277
+ const { prompt, language, texts } = params.data;
1278
+ if (!prompt || !language) {
1279
+ throw new Error("prompt and language are required");
1280
+ }
1281
+ await managementClient.prompts.customText.set(
1282
+ prompt as any,
1283
+ language as any,
1284
+ texts || {},
1285
+ );
1286
+ return {
1287
+ data: {
1288
+ id: `${prompt}:${language}`,
1289
+ prompt,
1290
+ language,
1291
+ texts: texts || {},
1292
+ },
1293
+ };
1294
+ }
1295
+
1126
1296
  // Helper for POST requests
1127
1297
  const post = async (endpoint: string, body: any) =>
1128
1298
  httpClient(`${apiUrl}/api/v2/${endpoint}`, {
@@ -1276,6 +1446,23 @@ export default (
1276
1446
  const headers = new Headers({ "content-type": "application/json" });
1277
1447
  if (tenantId) headers.set("tenant-id", tenantId);
1278
1448
 
1449
+ // Handle custom-text resource
1450
+ if (resource === "custom-text") {
1451
+ // ID format is "prompt:language"
1452
+ const [prompt, language] = String(params.id).split(":");
1453
+ if (!prompt || !language) {
1454
+ throw new Error("Invalid custom-text ID format");
1455
+ }
1456
+ // Auth0 SDK doesn't have delete, so we set to empty object
1457
+ // Our backend also supports DELETE, but using set({}) is compatible with Auth0
1458
+ await managementClient.prompts.customText.set(
1459
+ prompt as any,
1460
+ language as any,
1461
+ {},
1462
+ );
1463
+ return { data: { id: params.id } };
1464
+ }
1465
+
1279
1466
  // Helper for DELETE requests
1280
1467
  const del = async (endpoint: string, body?: any) =>
1281
1468
  httpClient(`${apiUrl}/api/v2/${endpoint}`, {
@@ -0,0 +1,554 @@
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
+ } from "@mui/material";
31
+ import EditIcon from "@mui/icons-material/Edit";
32
+ import DeleteIcon from "@mui/icons-material/Delete";
33
+ import AddIcon from "@mui/icons-material/Add";
34
+ import { useState, useCallback } from "react";
35
+
36
+ // Available prompt screens
37
+ const PROMPT_SCREENS = [
38
+ { id: "login", name: "Login" },
39
+ { id: "login-id", name: "Login - Identifier" },
40
+ { id: "login-password", name: "Login - Password" },
41
+ { id: "signup", name: "Sign Up" },
42
+ { id: "signup-id", name: "Sign Up - Identifier" },
43
+ { id: "signup-password", name: "Sign Up - Password" },
44
+ { id: "reset-password", name: "Reset Password" },
45
+ { id: "consent", name: "Consent" },
46
+ { id: "mfa", name: "MFA" },
47
+ { id: "mfa-push", name: "MFA - Push" },
48
+ { id: "mfa-otp", name: "MFA - OTP" },
49
+ { id: "mfa-voice", name: "MFA - Voice" },
50
+ { id: "mfa-phone", name: "MFA - Phone" },
51
+ { id: "mfa-webauthn", name: "MFA - WebAuthn" },
52
+ { id: "mfa-sms", name: "MFA - SMS" },
53
+ { id: "mfa-email", name: "MFA - Email" },
54
+ { id: "mfa-recovery-code", name: "MFA - Recovery Code" },
55
+ { id: "status", name: "Status" },
56
+ { id: "device-flow", name: "Device Flow" },
57
+ { id: "email-verification", name: "Email Verification" },
58
+ { id: "email-otp-challenge", name: "Email OTP Challenge" },
59
+ { id: "organizations", name: "Organizations" },
60
+ { id: "invitation", name: "Invitation" },
61
+ { id: "common", name: "Common" },
62
+ ];
63
+
64
+ // Common languages
65
+ const LANGUAGES = [
66
+ { id: "en", name: "English" },
67
+ { id: "es", name: "Spanish" },
68
+ { id: "fr", name: "French" },
69
+ { id: "de", name: "German" },
70
+ { id: "it", name: "Italian" },
71
+ { id: "pt", name: "Portuguese" },
72
+ { id: "nl", name: "Dutch" },
73
+ { id: "ja", name: "Japanese" },
74
+ { id: "ko", name: "Korean" },
75
+ { id: "zh", name: "Chinese" },
76
+ { id: "sv", name: "Swedish" },
77
+ { id: "nb", name: "Norwegian" },
78
+ { id: "fi", name: "Finnish" },
79
+ { id: "da", name: "Danish" },
80
+ { id: "pl", name: "Polish" },
81
+ { id: "cs", name: "Czech" },
82
+ ];
83
+
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
+ ],
147
+ };
148
+
149
+ // Remove null/undefined values from an object
150
+ function removeNullValues(
151
+ obj: Record<string, unknown>,
152
+ ): Record<string, unknown> {
153
+ const result: Record<string, unknown> = {};
154
+ for (const [key, value] of Object.entries(obj)) {
155
+ if (value === null || value === undefined) {
156
+ continue;
157
+ }
158
+ if (typeof value === "object" && !Array.isArray(value)) {
159
+ const cleaned = removeNullValues(value as Record<string, unknown>);
160
+ if (Object.keys(cleaned).length > 0) {
161
+ result[key] = cleaned;
162
+ }
163
+ } else {
164
+ result[key] = value;
165
+ }
166
+ }
167
+ return result;
168
+ }
169
+
170
+ interface CustomTextEntry {
171
+ prompt: string;
172
+ language: string;
173
+ }
174
+
175
+ function CustomTextTab() {
176
+ const record = useRecordContext();
177
+ const dataProvider = useDataProvider();
178
+ const notify = useNotify();
179
+ const refresh = useRefresh();
180
+ const [dialogOpen, setDialogOpen] = useState(false);
181
+ const [editDialogOpen, setEditDialogOpen] = useState(false);
182
+ const [selectedEntry, setSelectedEntry] = useState<CustomTextEntry | null>(
183
+ null,
184
+ );
185
+ const [editingTexts, setEditingTexts] = useState<Record<string, string>>({});
186
+ const [newPrompt, setNewPrompt] = useState("");
187
+ const [newLanguage, setNewLanguage] = useState("en");
188
+ const [loading, setLoading] = useState(false);
189
+
190
+ const customTextEntries: CustomTextEntry[] = record?.customTextEntries || [];
191
+
192
+ const handleAdd = useCallback(() => {
193
+ setNewPrompt("");
194
+ setNewLanguage("en");
195
+ setDialogOpen(true);
196
+ }, []);
197
+
198
+ const handleCreate = useCallback(async () => {
199
+ if (!newPrompt || !newLanguage) {
200
+ notify("Please select a screen and language", { type: "warning" });
201
+ return;
202
+ }
203
+
204
+ setLoading(true);
205
+ try {
206
+ // Get default text keys for this screen
207
+ const defaultKeys = DEFAULT_TEXT_KEYS[newPrompt] || [];
208
+ const initialTexts: Record<string, string> = {};
209
+ defaultKeys.forEach((key) => {
210
+ initialTexts[key] = "";
211
+ });
212
+
213
+ await dataProvider.create("custom-text", {
214
+ data: {
215
+ prompt: newPrompt,
216
+ language: newLanguage,
217
+ texts: initialTexts,
218
+ },
219
+ });
220
+ notify("Custom text created successfully", { type: "success" });
221
+ setDialogOpen(false);
222
+ refresh();
223
+ } catch (error) {
224
+ notify("Error creating custom text", { type: "error" });
225
+ } finally {
226
+ setLoading(false);
227
+ }
228
+ }, [dataProvider, newPrompt, newLanguage, notify, refresh]);
229
+
230
+ const handleEdit = useCallback(
231
+ async (entry: CustomTextEntry) => {
232
+ setLoading(true);
233
+ try {
234
+ const result = await dataProvider.getOne("custom-text", {
235
+ id: `${entry.prompt}:${entry.language}`,
236
+ });
237
+ setSelectedEntry(entry);
238
+ setEditingTexts(result.data.texts || {});
239
+ setEditDialogOpen(true);
240
+ } catch (error) {
241
+ notify("Error loading custom text", { type: "error" });
242
+ } finally {
243
+ setLoading(false);
244
+ }
245
+ },
246
+ [dataProvider, notify],
247
+ );
248
+
249
+ const handleSave = useCallback(async () => {
250
+ if (!selectedEntry) return;
251
+
252
+ setLoading(true);
253
+ try {
254
+ await dataProvider.update("custom-text", {
255
+ id: `${selectedEntry.prompt}:${selectedEntry.language}`,
256
+ data: { texts: editingTexts },
257
+ previousData: {},
258
+ });
259
+ notify("Custom text updated successfully", { type: "success" });
260
+ setEditDialogOpen(false);
261
+ refresh();
262
+ } catch (error) {
263
+ notify("Error updating custom text", { type: "error" });
264
+ } finally {
265
+ setLoading(false);
266
+ }
267
+ }, [dataProvider, selectedEntry, editingTexts, notify, refresh]);
268
+
269
+ const handleDelete = useCallback(
270
+ async (entry: CustomTextEntry) => {
271
+ if (
272
+ !window.confirm(
273
+ `Delete custom text for ${entry.prompt} (${entry.language})?`,
274
+ )
275
+ ) {
276
+ return;
277
+ }
278
+
279
+ setLoading(true);
280
+ try {
281
+ await dataProvider.delete("custom-text", {
282
+ id: `${entry.prompt}:${entry.language}`,
283
+ });
284
+ notify("Custom text deleted successfully", { type: "success" });
285
+ refresh();
286
+ } catch (error) {
287
+ notify("Error deleting custom text", { type: "error" });
288
+ } finally {
289
+ setLoading(false);
290
+ }
291
+ },
292
+ [dataProvider, notify, refresh],
293
+ );
294
+
295
+ const handleTextChange = useCallback((key: string, value: string) => {
296
+ setEditingTexts((prev) => ({ ...prev, [key]: value }));
297
+ }, []);
298
+
299
+ const handleAddTextKey = useCallback(() => {
300
+ const key = window.prompt("Enter new text key:");
301
+ if (key && !editingTexts[key]) {
302
+ setEditingTexts((prev) => ({ ...prev, [key]: "" }));
303
+ }
304
+ }, [editingTexts]);
305
+
306
+ const handleRemoveTextKey = useCallback((key: string) => {
307
+ setEditingTexts((prev) => {
308
+ const next = { ...prev };
309
+ delete next[key];
310
+ return next;
311
+ });
312
+ }, []);
313
+
314
+ const getScreenName = (prompt: string) => {
315
+ return PROMPT_SCREENS.find((s) => s.id === prompt)?.name || prompt;
316
+ };
317
+
318
+ const getLanguageName = (lang: string) => {
319
+ return LANGUAGES.find((l) => l.id === lang)?.name || lang;
320
+ };
321
+
322
+ return (
323
+ <Box>
324
+ <Box
325
+ display="flex"
326
+ justifyContent="space-between"
327
+ alignItems="center"
328
+ mb={2}
329
+ >
330
+ <Typography variant="h6">Custom Text</Typography>
331
+ <Button
332
+ variant="contained"
333
+ startIcon={<AddIcon />}
334
+ onClick={handleAdd}
335
+ disabled={loading}
336
+ >
337
+ Add Custom Text
338
+ </Button>
339
+ </Box>
340
+
341
+ <Typography variant="body2" color="textSecondary" paragraph>
342
+ Customize button labels, messages, and screen texts in different
343
+ languages. Custom text applies only to Universal Login screens.
344
+ </Typography>
345
+
346
+ {customTextEntries.length === 0 ? (
347
+ <Typography color="textSecondary">
348
+ No custom text configured yet. Click "Add Custom Text" to create your
349
+ first customization.
350
+ </Typography>
351
+ ) : (
352
+ <TableContainer component={Paper}>
353
+ <Table>
354
+ <TableHead>
355
+ <TableRow>
356
+ <TableCell>Screen</TableCell>
357
+ <TableCell>Language</TableCell>
358
+ <TableCell align="right">Actions</TableCell>
359
+ </TableRow>
360
+ </TableHead>
361
+ <TableBody>
362
+ {customTextEntries.map((entry) => (
363
+ <TableRow key={`${entry.prompt}:${entry.language}`}>
364
+ <TableCell>{getScreenName(entry.prompt)}</TableCell>
365
+ <TableCell>{getLanguageName(entry.language)}</TableCell>
366
+ <TableCell align="right">
367
+ <IconButton
368
+ onClick={() => handleEdit(entry)}
369
+ disabled={loading}
370
+ size="small"
371
+ >
372
+ <EditIcon />
373
+ </IconButton>
374
+ <IconButton
375
+ onClick={() => handleDelete(entry)}
376
+ disabled={loading}
377
+ size="small"
378
+ color="error"
379
+ >
380
+ <DeleteIcon />
381
+ </IconButton>
382
+ </TableCell>
383
+ </TableRow>
384
+ ))}
385
+ </TableBody>
386
+ </Table>
387
+ </TableContainer>
388
+ )}
389
+
390
+ {/* Create Dialog */}
391
+ <Dialog
392
+ open={dialogOpen}
393
+ onClose={() => setDialogOpen(false)}
394
+ maxWidth="sm"
395
+ fullWidth
396
+ >
397
+ <DialogTitle>Add Custom Text</DialogTitle>
398
+ <DialogContent>
399
+ <Stack spacing={2} sx={{ mt: 1 }}>
400
+ <TextField
401
+ select
402
+ label="Screen"
403
+ value={newPrompt}
404
+ onChange={(e) => setNewPrompt(e.target.value)}
405
+ fullWidth
406
+ >
407
+ {PROMPT_SCREENS.map((screen) => (
408
+ <MenuItem key={screen.id} value={screen.id}>
409
+ {screen.name}
410
+ </MenuItem>
411
+ ))}
412
+ </TextField>
413
+ <TextField
414
+ select
415
+ label="Language"
416
+ value={newLanguage}
417
+ onChange={(e) => setNewLanguage(e.target.value)}
418
+ fullWidth
419
+ >
420
+ {LANGUAGES.map((lang) => (
421
+ <MenuItem key={lang.id} value={lang.id}>
422
+ {lang.name}
423
+ </MenuItem>
424
+ ))}
425
+ </TextField>
426
+ </Stack>
427
+ </DialogContent>
428
+ <DialogActions>
429
+ <Button onClick={() => setDialogOpen(false)}>Cancel</Button>
430
+ <Button onClick={handleCreate} variant="contained" disabled={loading}>
431
+ Create
432
+ </Button>
433
+ </DialogActions>
434
+ </Dialog>
435
+
436
+ {/* Edit Dialog */}
437
+ <Dialog
438
+ open={editDialogOpen}
439
+ onClose={() => setEditDialogOpen(false)}
440
+ maxWidth="md"
441
+ fullWidth
442
+ >
443
+ <DialogTitle>
444
+ Edit Custom Text -{" "}
445
+ {selectedEntry && getScreenName(selectedEntry.prompt)} (
446
+ {selectedEntry && getLanguageName(selectedEntry.language)})
447
+ </DialogTitle>
448
+ <DialogContent>
449
+ <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)}
473
+ size="small"
474
+ color="error"
475
+ >
476
+ <DeleteIcon />
477
+ </IconButton>
478
+ </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.
484
+ </Typography>
485
+ )}
486
+ </Stack>
487
+ </Box>
488
+ </DialogContent>
489
+ <DialogActions>
490
+ <Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
491
+ <Button onClick={handleSave} variant="contained" disabled={loading}>
492
+ Save
493
+ </Button>
494
+ </DialogActions>
495
+ </Dialog>
496
+ </Box>
497
+ );
498
+ }
499
+
500
+ export function PromptsEdit() {
501
+ const transform = (data: Record<string, unknown>) => {
502
+ return removeNullValues(data);
503
+ };
504
+
505
+ return (
506
+ <Edit transform={transform}>
507
+ <TabbedForm>
508
+ <TabbedForm.Tab label="Settings">
509
+ <Typography variant="h6" gutterBottom>
510
+ Prompt Settings
511
+ </Typography>
512
+ <Typography variant="body2" color="textSecondary" paragraph>
513
+ Configure how the login prompts behave for your users.
514
+ </Typography>
515
+
516
+ <Stack spacing={2} sx={{ maxWidth: 600 }}>
517
+ <SelectInput
518
+ source="universal_login_experience"
519
+ label="Universal Login Experience"
520
+ choices={[
521
+ { id: "new", name: "New Universal Login" },
522
+ { id: "classic", name: "Classic Universal Login" },
523
+ ]}
524
+ helperText="Choose between the new or classic Universal Login experience"
525
+ fullWidth
526
+ />
527
+
528
+ <BooleanInput
529
+ source="identifier_first"
530
+ label="Identifier First"
531
+ helperText="Show identifier (email/username) field first, then password on a separate screen"
532
+ />
533
+
534
+ <BooleanInput
535
+ source="password_first"
536
+ label="Password First"
537
+ helperText="Show password field on the first screen along with the identifier"
538
+ />
539
+
540
+ <BooleanInput
541
+ source="webauthn_platform_first_factor"
542
+ label="WebAuthn Platform First Factor"
543
+ helperText="Enable WebAuthn (passkeys, biometrics) as a first factor authentication option"
544
+ />
545
+ </Stack>
546
+ </TabbedForm.Tab>
547
+
548
+ <TabbedForm.Tab label="Custom Text">
549
+ <CustomTextTab />
550
+ </TabbedForm.Tab>
551
+ </TabbedForm>
552
+ </Edit>
553
+ );
554
+ }
@@ -0,0 +1,2 @@
1
+ export { PromptsList } from "./list";
2
+ export { PromptsEdit } from "./edit";
@@ -0,0 +1,14 @@
1
+ import { useEffect } from "react";
2
+ import { useRedirect, useBasename } from "react-admin";
3
+
4
+ export function PromptsList() {
5
+ const redirect = useRedirect();
6
+ const basename = useBasename();
7
+
8
+ useEffect(() => {
9
+ // For singleton resources, redirect to edit with "prompts" as the ID
10
+ redirect(`${basename}/prompts/prompts`);
11
+ }, [redirect, basename]);
12
+
13
+ return null;
14
+ }