@authhero/react-admin 0.10.0 → 0.12.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.
@@ -1,6 +1,8 @@
1
- import { AppBar, TitlePortal, useDataProvider } from "react-admin";
2
- import { useEffect, useState } from "react";
1
+ import { AppBar, TitlePortal } from "react-admin";
2
+ import { useEffect, useState, useMemo } from "react";
3
3
  import { Link, Box } from "@mui/material";
4
+ import { getDataprovider } from "../dataProvider";
5
+ import { getDomainFromStorage } from "../utils/domainUtils";
4
6
 
5
7
  type TenantResponse = {
6
8
  audience: string;
@@ -26,12 +28,25 @@ export function TenantAppBar(props: TenantAppBarProps) {
26
28
  const pathSegments = location.pathname.split("/").filter(Boolean);
27
29
  const tenantId = pathSegments[0];
28
30
  const [tenant, setTenant] = useState<TenantResponse>();
29
- const dataProvider = useDataProvider();
31
+
32
+ // Get the selected domain from storage or environment
33
+ const selectedDomain = useMemo(() => {
34
+ const domains = getDomainFromStorage();
35
+ const selected = domains.find((d) => d.isSelected);
36
+ return selected?.url || import.meta.env.VITE_AUTH0_DOMAIN || "";
37
+ }, []);
38
+
39
+ // Use the non-org data provider for fetching tenants list
40
+ // This is necessary because tenants list requires a non-org token
41
+ const tenantsDataProvider = useMemo(
42
+ () => getDataprovider(selectedDomain),
43
+ [selectedDomain],
44
+ );
30
45
 
31
46
  useEffect(() => {
32
- // Use the dataProvider to fetch tenants list and find the matching one
33
- // This ensures we use the correct API URL configured in the app
34
- dataProvider
47
+ // Use the non-org dataProvider to fetch tenants list
48
+ // The tenants endpoint requires non-org scoped tokens
49
+ tenantsDataProvider
35
50
  .getList("tenants", {
36
51
  pagination: { page: 1, perPage: 100 },
37
52
  sort: { field: "id", order: "ASC" },
@@ -59,7 +74,7 @@ export function TenantAppBar(props: TenantAppBarProps) {
59
74
  name: tenantId,
60
75
  } as TenantResponse);
61
76
  });
62
- }, [tenantId, dataProvider]);
77
+ }, [tenantId, tenantsDataProvider]);
63
78
 
64
79
  const isDefaultSettings = tenantId === "DEFAULT_SETTINGS";
65
80
 
@@ -60,6 +60,7 @@ import {
60
60
  getDomainFromStorage,
61
61
  getSelectedDomainFromStorage,
62
62
  buildUrlWithProtocol,
63
+ formatDomain,
63
64
  } from "../../utils/domainUtils";
64
65
 
65
66
  const AddClientGrantButton = () => {
@@ -746,7 +747,20 @@ const ClientMetadataInput = ({ source }: { source: string }) => {
746
747
  };
747
748
 
748
749
  const updateFormData = (array: Array<{ key: string; value: string }>) => {
750
+ // Fields managed by other inputs (BooleanInput, SelectInput, etc.)
751
+ const preservedFields = ["disable_sign_ups", "email_validation"];
752
+
753
+ // Start with preserved fields from current value
749
754
  const newObject: Record<string, any> = {};
755
+ if (value && typeof value === "object") {
756
+ preservedFields.forEach((field) => {
757
+ if (field in value) {
758
+ newObject[field] = value[field];
759
+ }
760
+ });
761
+ }
762
+
763
+ // Add the metadata array values
750
764
  array.forEach((item) => {
751
765
  if (item.key && item.key.trim()) {
752
766
  newObject[item.key.trim()] = item.value;
@@ -808,7 +822,8 @@ interface Connection {
808
822
  const getApiBaseUrl = (): string => {
809
823
  const selectedDomain = getSelectedDomainFromStorage();
810
824
  const domains = getDomainFromStorage();
811
- const domainConfig = domains.find((d) => d.url === selectedDomain);
825
+ const formattedDomain = formatDomain(selectedDomain);
826
+ const domainConfig = domains.find((d) => formatDomain(d.url) === formattedDomain);
812
827
 
813
828
  if (domainConfig?.restApiUrl) {
814
829
  return domainConfig.restApiUrl.replace(/\/$/, "");
@@ -1157,8 +1172,34 @@ const ConnectionsTab = () => {
1157
1172
  };
1158
1173
 
1159
1174
  export function ClientEdit() {
1175
+ // Transform data before submission to ensure client_metadata values are strings
1176
+ const transformClientData = (data: Record<string, unknown>) => {
1177
+ const transformed = { ...data };
1178
+
1179
+ // Ensure client_metadata values are strings (Auth0 requirement)
1180
+ if (
1181
+ transformed.client_metadata &&
1182
+ typeof transformed.client_metadata === "object"
1183
+ ) {
1184
+ const metadata = transformed.client_metadata as Record<string, unknown>;
1185
+ const stringifiedMetadata: Record<string, string> = {};
1186
+
1187
+ for (const [key, value] of Object.entries(metadata)) {
1188
+ if (typeof value === "boolean") {
1189
+ stringifiedMetadata[key] = value ? "true" : "false";
1190
+ } else if (value !== null && value !== undefined) {
1191
+ stringifiedMetadata[key] = String(value);
1192
+ }
1193
+ }
1194
+
1195
+ transformed.client_metadata = stringifiedMetadata;
1196
+ }
1197
+
1198
+ return transformed;
1199
+ };
1200
+
1160
1201
  return (
1161
- <Edit>
1202
+ <Edit transform={transformClientData}>
1162
1203
  <SimpleShowLayout>
1163
1204
  <TextField source="name" />
1164
1205
  <TextField source="id" />
@@ -14,7 +14,7 @@ import {
14
14
  useRecordContext,
15
15
  useRedirect,
16
16
  } from "react-admin";
17
- import { useState } from "react";
17
+ import { useState, useEffect } from "react";
18
18
  import {
19
19
  Box,
20
20
  Button,
@@ -34,9 +34,15 @@ import {
34
34
  Checkbox,
35
35
  Chip,
36
36
  Stack,
37
+ FormControl,
38
+ InputLabel,
39
+ Select,
40
+ MenuItem,
41
+ OutlinedInput,
37
42
  } from "@mui/material";
38
43
  import AddIcon from "@mui/icons-material/Add";
39
44
  import DeleteIcon from "@mui/icons-material/Delete";
45
+ import EditIcon from "@mui/icons-material/Edit";
40
46
  import { useParams } from "react-router-dom";
41
47
 
42
48
  const AddOrganizationMemberButton = () => {
@@ -401,6 +407,227 @@ const RemoveMemberButton = ({ record }: { record: any }) => {
401
407
  );
402
408
  };
403
409
 
410
+ const ManageMemberRolesButton = ({ record }: { record: any }) => {
411
+ const [open, setOpen] = useState(false);
412
+ const [roles, setRoles] = useState<any[]>([]);
413
+ const [memberRoles, setMemberRoles] = useState<string[]>([]);
414
+ const [loading, setLoading] = useState(false);
415
+ const dataProvider = useDataProvider();
416
+ const notify = useNotify();
417
+ const refresh = useRefresh();
418
+ const { id: organizationId } = useParams();
419
+
420
+ const handleOpen = async () => {
421
+ setOpen(true);
422
+ setLoading(true);
423
+ try {
424
+ // Fetch all available roles
425
+ const { data: allRoles } = await dataProvider.getList("roles", {
426
+ pagination: { page: 1, perPage: 100 },
427
+ sort: { field: "name", order: "ASC" },
428
+ filter: {},
429
+ });
430
+ setRoles(allRoles);
431
+
432
+ // Fetch member's current roles in this organization
433
+ if (organizationId && record?.user_id) {
434
+ const response = await dataProvider.getList(
435
+ `organizations/${organizationId}/members/${record.user_id}/roles`,
436
+ {
437
+ pagination: { page: 1, perPage: 100 },
438
+ sort: { field: "name", order: "ASC" },
439
+ filter: {},
440
+ },
441
+ );
442
+ setMemberRoles(response.data.map((r: any) => r.id));
443
+ }
444
+ } catch (error) {
445
+ notify("Error loading roles", { type: "error" });
446
+ } finally {
447
+ setLoading(false);
448
+ }
449
+ };
450
+
451
+ const handleClose = () => {
452
+ setOpen(false);
453
+ setMemberRoles([]);
454
+ setRoles([]);
455
+ };
456
+
457
+ const handleSaveRoles = async () => {
458
+ if (!organizationId || !record?.user_id) return;
459
+
460
+ setLoading(true);
461
+ try {
462
+ // Get current roles to determine what to add/remove
463
+ const currentResponse = await dataProvider.getList(
464
+ `organizations/${organizationId}/members/${record.user_id}/roles`,
465
+ {
466
+ pagination: { page: 1, perPage: 100 },
467
+ sort: { field: "name", order: "ASC" },
468
+ filter: {},
469
+ },
470
+ );
471
+ const currentRoleIds = currentResponse.data.map((r: any) => r.id);
472
+
473
+ const rolesToAdd = memberRoles.filter((r) => !currentRoleIds.includes(r));
474
+ const rolesToRemove = currentRoleIds.filter(
475
+ (r: string) => !memberRoles.includes(r),
476
+ );
477
+
478
+ // Add new roles
479
+ if (rolesToAdd.length > 0) {
480
+ await dataProvider.create(
481
+ `organizations/${organizationId}/members/${record.user_id}/roles`,
482
+ {
483
+ data: { roles: rolesToAdd },
484
+ },
485
+ );
486
+ }
487
+
488
+ // Remove old roles
489
+ if (rolesToRemove.length > 0) {
490
+ await dataProvider.delete(
491
+ `organizations/${organizationId}/members/${record.user_id}/roles`,
492
+ {
493
+ id: "",
494
+ previousData: { roles: rolesToRemove },
495
+ },
496
+ );
497
+ }
498
+
499
+ notify("Member roles updated successfully", { type: "success" });
500
+ refresh();
501
+ handleClose();
502
+ } catch (error) {
503
+ notify("Error updating member roles", { type: "error" });
504
+ } finally {
505
+ setLoading(false);
506
+ }
507
+ };
508
+
509
+ return (
510
+ <>
511
+ <IconButton
512
+ onClick={handleOpen}
513
+ color="primary"
514
+ size="small"
515
+ title="Manage roles"
516
+ >
517
+ <EditIcon />
518
+ </IconButton>
519
+
520
+ <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
521
+ <DialogTitle>
522
+ Manage Roles for {record?.email || record?.user_id}
523
+ </DialogTitle>
524
+ <DialogContent>
525
+ {loading ? (
526
+ <Box display="flex" justifyContent="center" p={3}>
527
+ <CircularProgress />
528
+ </Box>
529
+ ) : (
530
+ <>
531
+ <DialogContentText sx={{ mb: 2 }}>
532
+ Select roles to assign to this member in this organization.
533
+ </DialogContentText>
534
+
535
+ <FormControl fullWidth>
536
+ <InputLabel id="member-roles-label">Roles</InputLabel>
537
+ <Select
538
+ labelId="member-roles-label"
539
+ multiple
540
+ value={memberRoles}
541
+ onChange={(e) => setMemberRoles(e.target.value as string[])}
542
+ input={<OutlinedInput label="Roles" />}
543
+ renderValue={(selected) => (
544
+ <Stack direction="row" spacing={0.5} flexWrap="wrap">
545
+ {selected.map((roleId) => {
546
+ const role = roles.find((r) => r.id === roleId);
547
+ return (
548
+ <Chip
549
+ key={roleId}
550
+ label={role?.name || roleId}
551
+ size="small"
552
+ />
553
+ );
554
+ })}
555
+ </Stack>
556
+ )}
557
+ >
558
+ {roles.map((role) => (
559
+ <MenuItem key={role.id} value={role.id}>
560
+ <Checkbox checked={memberRoles.includes(role.id)} />
561
+ <ListItemText
562
+ primary={role.name}
563
+ secondary={role.description}
564
+ />
565
+ </MenuItem>
566
+ ))}
567
+ </Select>
568
+ </FormControl>
569
+ </>
570
+ )}
571
+ </DialogContent>
572
+ <DialogActions>
573
+ <Button onClick={handleClose}>Cancel</Button>
574
+ <Button onClick={handleSaveRoles} variant="contained" disabled={loading}>
575
+ Save Roles
576
+ </Button>
577
+ </DialogActions>
578
+ </Dialog>
579
+ </>
580
+ );
581
+ };
582
+
583
+ const MemberRolesDisplay = ({ record }: { record: any }) => {
584
+ const [roles, setRoles] = useState<any[]>([]);
585
+ const [loading, setLoading] = useState(true);
586
+ const dataProvider = useDataProvider();
587
+ const { id: organizationId } = useParams();
588
+
589
+ useEffect(() => {
590
+ const fetchRoles = async () => {
591
+ if (!organizationId || !record?.user_id) {
592
+ setLoading(false);
593
+ return;
594
+ }
595
+ try {
596
+ const response = await dataProvider.getList(
597
+ `organizations/${organizationId}/members/${record.user_id}/roles`,
598
+ {
599
+ pagination: { page: 1, perPage: 100 },
600
+ sort: { field: "name", order: "ASC" },
601
+ filter: {},
602
+ },
603
+ );
604
+ setRoles(response.data);
605
+ } catch (error) {
606
+ console.error("Error fetching member roles:", error);
607
+ } finally {
608
+ setLoading(false);
609
+ }
610
+ };
611
+ fetchRoles();
612
+ }, [dataProvider, organizationId, record?.user_id]);
613
+
614
+ if (loading) {
615
+ return <CircularProgress size={16} />;
616
+ }
617
+
618
+ if (roles.length === 0) {
619
+ return <Typography color="text.secondary">No roles</Typography>;
620
+ }
621
+
622
+ return (
623
+ <Stack direction="row" spacing={0.5} flexWrap="wrap">
624
+ {roles.map((role) => (
625
+ <Chip key={role.id} label={role.name} size="small" variant="outlined" />
626
+ ))}
627
+ </Stack>
628
+ );
629
+ };
630
+
404
631
  const OrganizationGeneralTab = () => (
405
632
  <Box>
406
633
  <TextInput source="name" validate={[required()]} fullWidth />
@@ -456,9 +683,18 @@ const OrganizationMembersTab = () => {
456
683
  )}
457
684
  />
458
685
  <TextField source="email" label="Email" />
686
+ <FunctionField
687
+ label="Roles"
688
+ render={(record) => <MemberRolesDisplay record={record} />}
689
+ />
459
690
  <FunctionField
460
691
  label="Actions"
461
- render={(record) => <RemoveMemberButton record={record} />}
692
+ render={(record) => (
693
+ <Box sx={{ display: "flex", gap: 0.5 }}>
694
+ <ManageMemberRolesButton record={record} />
695
+ <RemoveMemberButton record={record} />
696
+ </Box>
697
+ )}
462
698
  />
463
699
  </Datagrid>
464
700
  </ReferenceManyField>
@@ -9,113 +9,146 @@ import {
9
9
  required,
10
10
  NumberInput,
11
11
  FormDataConsumer,
12
+ useRecordContext,
12
13
  } from "react-admin";
13
- import { Stack } from "@mui/material";
14
+ import { Stack, Alert } from "@mui/material";
15
+
16
+ function SystemEntityAlert() {
17
+ const record = useRecordContext();
18
+ if (!record?.is_system) return null;
14
19
 
15
- export function ResourceServerEdit() {
16
20
  return (
17
- <Edit>
18
- <TabbedForm>
19
- <TabbedForm.Tab label="Details">
20
- <Stack spacing={2}>
21
- <TextInput source="name" validate={[required()]} />
22
- <TextInput
23
- source="identifier"
24
- validate={[required()]}
25
- helperText="Unique identifier for this resource server"
26
- />
27
- </Stack>
21
+ <Alert severity="info" sx={{ mb: 2 }}>
22
+ This Resource Server represents a system entity and cannot be modified or
23
+ deleted. You can still authorize applications to consume this resource
24
+ server.
25
+ </Alert>
26
+ );
27
+ }
28
+
29
+ function ResourceServerForm() {
30
+ const record = useRecordContext();
31
+ const isSystem = record?.is_system;
32
+
33
+ return (
34
+ <TabbedForm>
35
+ <TabbedForm.Tab label="Details">
36
+ <SystemEntityAlert />
37
+ <Stack spacing={2}>
38
+ <TextInput source="name" validate={[required()]} disabled={isSystem} />
39
+ <TextInput
40
+ source="identifier"
41
+ validate={[required()]}
42
+ helperText="Unique identifier for this resource server"
43
+ disabled={isSystem}
44
+ />
45
+ </Stack>
46
+
47
+ <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
48
+ <BooleanInput
49
+ source="signing_alg_values_supported"
50
+ defaultValue={true}
51
+ disabled={isSystem}
52
+ />
53
+ <BooleanInput
54
+ source="skip_consent_for_verifiable_first_party_clients"
55
+ defaultValue={true}
56
+ disabled={isSystem}
57
+ />
58
+ <BooleanInput source="allow_offline_access" defaultValue={true} disabled={isSystem} />
59
+ </Stack>
28
60
 
29
- <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
30
- <BooleanInput
31
- source="signing_alg_values_supported"
32
- defaultValue={true}
33
- />
34
- <BooleanInput
35
- source="skip_consent_for_verifiable_first_party_clients"
36
- defaultValue={true}
37
- />
38
- <BooleanInput source="allow_offline_access" defaultValue={true} />
39
- </Stack>
61
+ <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
62
+ <TextInput
63
+ source="signing_alg"
64
+ defaultValue="RS256"
65
+ helperText="Signing algorithm for tokens"
66
+ disabled={isSystem}
67
+ />
68
+ </Stack>
40
69
 
41
- <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
42
- <TextInput
43
- source="signing_alg"
44
- defaultValue="RS256"
45
- helperText="Signing algorithm for tokens"
46
- />
47
- </Stack>
70
+ <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
71
+ <NumberInput
72
+ source="token_lifetime"
73
+ defaultValue={1209600}
74
+ helperText="Token lifetime in seconds (default: 14 days)"
75
+ disabled={isSystem}
76
+ />
77
+ <NumberInput
78
+ source="token_lifetime_for_web"
79
+ defaultValue={7200}
80
+ helperText="Web token lifetime in seconds (default: 2 hours)"
81
+ disabled={isSystem}
82
+ />
83
+ </Stack>
48
84
 
49
- <Stack spacing={2} direction="row" sx={{ mt: 2 }}>
50
- <NumberInput
51
- source="token_lifetime"
52
- defaultValue={1209600}
53
- helperText="Token lifetime in seconds (default: 14 days)"
54
- />
55
- <NumberInput
56
- source="token_lifetime_for_web"
57
- defaultValue={7200}
58
- helperText="Web token lifetime in seconds (default: 2 hours)"
59
- />
60
- </Stack>
85
+ <Stack spacing={2} direction="row" sx={{ mt: 4 }}>
86
+ <TextField source="created_at" />
87
+ <TextField source="updated_at" />
88
+ </Stack>
89
+ </TabbedForm.Tab>
61
90
 
62
- <Stack spacing={2} direction="row" sx={{ mt: 4 }}>
63
- <TextField source="created_at" />
64
- <TextField source="updated_at" />
65
- </Stack>
66
- </TabbedForm.Tab>
91
+ <TabbedForm.Tab label="RBAC">
92
+ <Stack spacing={3}>
93
+ <BooleanInput
94
+ source="options.enforce_policies"
95
+ label="Enable RBAC"
96
+ helperText="Enable Role-Based Access Control for this resource server"
97
+ disabled={isSystem}
98
+ />
67
99
 
68
- <TabbedForm.Tab label="RBAC">
69
- <Stack spacing={3}>
70
- <BooleanInput
71
- source="options.enforce_policies"
72
- label="Enable RBAC"
73
- helperText="Enable Role-Based Access Control for this resource server"
74
- />
100
+ <FormDataConsumer>
101
+ {({ formData }) => (
102
+ <BooleanInput
103
+ source="options.token_dialect"
104
+ label="Add permissions in token"
105
+ helperText="Include permissions directly in the access token"
106
+ disabled={isSystem || !formData?.options?.enforce_policies}
107
+ format={(value) => value === "access_token_authz"}
108
+ parse={(checked) =>
109
+ checked ? "access_token_authz" : "access_token"
110
+ }
111
+ />
112
+ )}
113
+ </FormDataConsumer>
114
+ </Stack>
115
+ </TabbedForm.Tab>
75
116
 
76
- <FormDataConsumer>
77
- {({ formData }) => (
78
- <BooleanInput
79
- source="options.token_dialect"
80
- label="Add permissions in token"
81
- helperText="Include permissions directly in the access token"
82
- disabled={!formData?.options?.enforce_policies}
83
- format={(value) => value === "access_token_authz"}
84
- parse={(checked) =>
85
- checked ? "access_token_authz" : "access_token"
86
- }
87
- />
88
- )}
89
- </FormDataConsumer>
90
- </Stack>
91
- </TabbedForm.Tab>
117
+ <TabbedForm.Tab label="Scopes">
118
+ <ArrayInput source="scopes" label="" disabled={isSystem}>
119
+ <SimpleFormIterator disableAdd={isSystem} disableRemove={isSystem} disableReordering={isSystem}>
120
+ <Stack
121
+ spacing={2}
122
+ direction="row"
123
+ sx={{ width: "100%", alignItems: "flex-start" }}
124
+ >
125
+ <TextInput
126
+ source="value"
127
+ validate={[required()]}
128
+ label="Scope Name"
129
+ helperText="e.g., read:users, write:posts"
130
+ sx={{ flex: 1 }}
131
+ disabled={isSystem}
132
+ />
133
+ <TextInput
134
+ source="description"
135
+ label="Description"
136
+ helperText="What this scope allows"
137
+ sx={{ flex: 2 }}
138
+ disabled={isSystem}
139
+ />
140
+ </Stack>
141
+ </SimpleFormIterator>
142
+ </ArrayInput>
143
+ </TabbedForm.Tab>
144
+ </TabbedForm>
145
+ );
146
+ }
92
147
 
93
- <TabbedForm.Tab label="Scopes">
94
- <ArrayInput source="scopes" label="">
95
- <SimpleFormIterator>
96
- <Stack
97
- spacing={2}
98
- direction="row"
99
- sx={{ width: "100%", alignItems: "flex-start" }}
100
- >
101
- <TextInput
102
- source="value"
103
- validate={[required()]}
104
- label="Scope Name"
105
- helperText="e.g., read:users, write:posts"
106
- sx={{ flex: 1 }}
107
- />
108
- <TextInput
109
- source="description"
110
- label="Description"
111
- helperText="What this scope allows"
112
- sx={{ flex: 2 }}
113
- />
114
- </Stack>
115
- </SimpleFormIterator>
116
- </ArrayInput>
117
- </TabbedForm.Tab>
118
- </TabbedForm>
148
+ export function ResourceServerEdit() {
149
+ return (
150
+ <Edit>
151
+ <ResourceServerForm />
119
152
  </Edit>
120
153
  );
121
154
  }