bananas-commerce-admin 0.21.2 → 0.22.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/dist/esm/Admin.js +35 -9
- package/dist/esm/Admin.js.map +1 -1
- package/dist/esm/extensions/pim/components/ProductRow.js +16 -0
- package/dist/esm/extensions/pim/components/ProductRow.js.map +1 -1
- package/dist/esm/extensions/pim/pages/product/list.js +1 -0
- package/dist/esm/extensions/pim/pages/product/list.js.map +1 -1
- package/dist/esm/extensions/user/components/InviteUserModal.js +106 -0
- package/dist/esm/extensions/user/components/InviteUserModal.js.map +1 -0
- package/dist/esm/extensions/user/index.js +30 -0
- package/dist/esm/extensions/user/index.js.map +1 -0
- package/dist/esm/extensions/user/pages/staff/detail.js +143 -0
- package/dist/esm/extensions/user/pages/staff/detail.js.map +1 -0
- package/dist/esm/extensions/user/pages/staff/list.js +61 -0
- package/dist/esm/extensions/user/pages/staff/list.js.map +1 -0
- package/dist/esm/extensions/user/types/staff.js +2 -0
- package/dist/esm/extensions/user/types/staff.js.map +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/pages/AcceptInvitePage.js +125 -0
- package/dist/esm/pages/AcceptInvitePage.js.map +1 -0
- package/dist/esm/pages/ResetPasswordPage.js +125 -0
- package/dist/esm/pages/ResetPasswordPage.js.map +1 -0
- package/dist/types/extensions/pim/types/product.d.ts +1 -0
- package/dist/types/extensions/user/components/InviteUserModal.d.ts +9 -0
- package/dist/types/extensions/user/index.d.ts +4 -0
- package/dist/types/extensions/user/pages/staff/detail.d.ts +4 -0
- package/dist/types/extensions/user/pages/staff/list.d.ts +4 -0
- package/dist/types/extensions/user/types/staff.d.ts +24 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/pages/AcceptInvitePage.d.ts +9 -0
- package/dist/types/pages/ResetPasswordPage.d.ts +9 -0
- package/package.json +1 -1
- package/src/Admin.tsx +53 -26
- package/src/extensions/pim/components/ProductRow.tsx +32 -0
- package/src/extensions/pim/pages/product/list.tsx +1 -0
- package/src/extensions/pim/types/product.ts +1 -0
- package/src/extensions/user/components/InviteUserModal.tsx +179 -0
- package/src/extensions/user/index.tsx +51 -0
- package/src/extensions/user/pages/staff/detail.tsx +262 -0
- package/src/extensions/user/pages/staff/list.tsx +100 -0
- package/src/extensions/user/types/staff.ts +27 -0
- package/src/index.ts +1 -0
- package/src/pages/AcceptInvitePage.tsx +232 -0
- package/src/pages/ResetPasswordPage.tsx +236 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Autocomplete,
|
|
5
|
+
Box,
|
|
6
|
+
Button,
|
|
7
|
+
Chip,
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogActions,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
TextField,
|
|
13
|
+
} from "@mui/material";
|
|
14
|
+
|
|
15
|
+
import { useSnackbar } from "notistack";
|
|
16
|
+
|
|
17
|
+
import { useApi } from "../../../contexts/ApiContext";
|
|
18
|
+
import { useI18n } from "../../../contexts/I18nContext";
|
|
19
|
+
import { PageProps } from "../../../types";
|
|
20
|
+
import { GroupOption, StaffOptions } from "../types/staff";
|
|
21
|
+
|
|
22
|
+
export interface InviteUserModalProps {
|
|
23
|
+
open: boolean;
|
|
24
|
+
setOpen: (open: boolean) => void;
|
|
25
|
+
refresh: PageProps["refresh"];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const InviteUserModal: React.FC<InviteUserModalProps> = ({ open, setOpen, refresh }) => {
|
|
29
|
+
const { t } = useI18n();
|
|
30
|
+
const api = useApi();
|
|
31
|
+
const { enqueueSnackbar } = useSnackbar();
|
|
32
|
+
|
|
33
|
+
const [email, setEmail] = useState("");
|
|
34
|
+
const [selectedGroups, setSelectedGroups] = useState<GroupOption[]>([]);
|
|
35
|
+
const [availableGroups, setAvailableGroups] = useState<GroupOption[]>([]);
|
|
36
|
+
const [loading, setLoading] = useState(false);
|
|
37
|
+
const [emailError, setEmailError] = useState("");
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const fetchOptions = async () => {
|
|
41
|
+
const response = await api.operations["user.staff:options"].call({});
|
|
42
|
+
if (response.ok) {
|
|
43
|
+
const options: StaffOptions = await response.json();
|
|
44
|
+
setAvailableGroups(options.groups);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
if (open) {
|
|
48
|
+
fetchOptions();
|
|
49
|
+
}
|
|
50
|
+
}, [api, open]);
|
|
51
|
+
|
|
52
|
+
const handleClose = () => {
|
|
53
|
+
setEmail("");
|
|
54
|
+
setSelectedGroups([]);
|
|
55
|
+
setEmailError("");
|
|
56
|
+
setOpen(false);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const validateEmail = (email: string): boolean => {
|
|
60
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
61
|
+
return emailRegex.test(email);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handleSubmit = async () => {
|
|
65
|
+
// Validate email
|
|
66
|
+
if (!email) {
|
|
67
|
+
setEmailError(t("Email is required"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!validateEmail(email)) {
|
|
72
|
+
setEmailError(t("Please enter a valid email address"));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (selectedGroups.length === 0) {
|
|
77
|
+
enqueueSnackbar(t("Please select at least one group"), { variant: "error" });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setLoading(true);
|
|
82
|
+
setEmailError("");
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const response = await api.operations["user.staff:invite"].call({
|
|
86
|
+
body: {
|
|
87
|
+
email,
|
|
88
|
+
groups: selectedGroups.map((g) => g.id),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (response.ok) {
|
|
93
|
+
const data = await response.json();
|
|
94
|
+
enqueueSnackbar(data.message || t("Invite sent successfully"), { variant: "success" });
|
|
95
|
+
handleClose();
|
|
96
|
+
refresh();
|
|
97
|
+
} else {
|
|
98
|
+
const errorData = await response.json();
|
|
99
|
+
if (errorData.detail && Array.isArray(errorData.detail)) {
|
|
100
|
+
const emailErrors = errorData.detail.filter((err: { loc?: string[]; msg?: string }) =>
|
|
101
|
+
err.loc?.includes("email"),
|
|
102
|
+
);
|
|
103
|
+
if (emailErrors.length > 0) {
|
|
104
|
+
setEmailError(emailErrors[0].msg);
|
|
105
|
+
} else {
|
|
106
|
+
enqueueSnackbar(errorData.detail[0]?.msg || t("Failed to send invite"), {
|
|
107
|
+
variant: "error",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
enqueueSnackbar(t("Failed to send invite"), { variant: "error" });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error("[INVITE_USER]", error);
|
|
116
|
+
enqueueSnackbar(t("Failed to send invite"), { variant: "error" });
|
|
117
|
+
} finally {
|
|
118
|
+
setLoading(false);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Dialog open={open} sx={{ "& .MuiPaper-rounded": { borderRadius: 3 } }} onClose={handleClose}>
|
|
124
|
+
<DialogTitle sx={{ p: 3 }}>{t("Invite Staff User")}</DialogTitle>
|
|
125
|
+
<DialogContent sx={{ p: 3, minWidth: 500 }}>
|
|
126
|
+
<Box display="flex" flexDirection="column" gap={3}>
|
|
127
|
+
<TextField
|
|
128
|
+
fullWidth
|
|
129
|
+
error={!!emailError}
|
|
130
|
+
helperText={emailError || t("Enter the email address of the user to invite")}
|
|
131
|
+
label={t("Email")}
|
|
132
|
+
type="email"
|
|
133
|
+
value={email}
|
|
134
|
+
onChange={(e) => {
|
|
135
|
+
setEmail(e.target.value);
|
|
136
|
+
setEmailError("");
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
<Autocomplete
|
|
141
|
+
fullWidth
|
|
142
|
+
multiple
|
|
143
|
+
getOptionLabel={(option) => option.name}
|
|
144
|
+
isOptionEqualToValue={(option, value) => option.id === value.id}
|
|
145
|
+
options={availableGroups}
|
|
146
|
+
renderInput={(params) => (
|
|
147
|
+
<TextField
|
|
148
|
+
{...params}
|
|
149
|
+
helperText={t("Select one or more groups for the user")}
|
|
150
|
+
label={t("Groups")}
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
renderTags={(value, getTagProps) =>
|
|
154
|
+
value.map((option, index) => (
|
|
155
|
+
<Chip
|
|
156
|
+
{...getTagProps({ index })}
|
|
157
|
+
key={option.id}
|
|
158
|
+
label={option.name}
|
|
159
|
+
size="small"
|
|
160
|
+
/>
|
|
161
|
+
))
|
|
162
|
+
}
|
|
163
|
+
size="small"
|
|
164
|
+
value={selectedGroups}
|
|
165
|
+
onChange={(_, newValue) => setSelectedGroups(newValue)}
|
|
166
|
+
/>
|
|
167
|
+
</Box>
|
|
168
|
+
</DialogContent>
|
|
169
|
+
<DialogActions sx={{ px: 3, py: 3 }}>
|
|
170
|
+
<Button onClick={handleClose}>{t("Cancel")}</Button>
|
|
171
|
+
<Button disabled={loading} variant="contained" onClick={handleSubmit}>
|
|
172
|
+
{loading ? t("Sending...") : t("Send Invite")}
|
|
173
|
+
</Button>
|
|
174
|
+
</DialogActions>
|
|
175
|
+
</Dialog>
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export default InviteUserModal;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import PeopleIcon from "@mui/icons-material/People";
|
|
2
|
+
|
|
3
|
+
import { OpenAPI } from "openapi-types";
|
|
4
|
+
|
|
5
|
+
import { RouterExtension } from "../../router/Router";
|
|
6
|
+
import { NavigationOverrides, PageComponent } from "../../types";
|
|
7
|
+
|
|
8
|
+
const routes: Record<
|
|
9
|
+
string,
|
|
10
|
+
Record<
|
|
11
|
+
string,
|
|
12
|
+
{
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
page: () => PageComponent<any> | Promise<PageComponent<any>>;
|
|
15
|
+
request?: OpenAPI.Request;
|
|
16
|
+
defaultRequest?: OpenAPI.Request;
|
|
17
|
+
offline?: boolean;
|
|
18
|
+
}
|
|
19
|
+
>
|
|
20
|
+
> = {
|
|
21
|
+
staff: {
|
|
22
|
+
list: { page: async () => (await import("./pages/staff/list")).default },
|
|
23
|
+
detail: {
|
|
24
|
+
page: async () => (await import("./pages/staff/detail")).default,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const router: RouterExtension = {
|
|
30
|
+
app: "user",
|
|
31
|
+
pages: (route) => {
|
|
32
|
+
const { page, ...hit } = routes[route.view]?.[route.action] ?? {};
|
|
33
|
+
|
|
34
|
+
if (page != null) {
|
|
35
|
+
return {
|
|
36
|
+
page: page(),
|
|
37
|
+
...hit,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return undefined;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const navigation: NavigationOverrides = {
|
|
46
|
+
"user.staff:list": {
|
|
47
|
+
icon: PeopleIcon,
|
|
48
|
+
title: "Staff",
|
|
49
|
+
permission: "user.view_user",
|
|
50
|
+
},
|
|
51
|
+
} as const;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { Autocomplete, Button, Chip, Stack, TextField } from "@mui/material";
|
|
4
|
+
|
|
5
|
+
import { DateTime } from "luxon";
|
|
6
|
+
import { useSnackbar } from "notistack";
|
|
7
|
+
|
|
8
|
+
import ActionBar from "../../../../components/ActionBar";
|
|
9
|
+
import Card from "../../../../components/Card";
|
|
10
|
+
import CardActions from "../../../../components/Card/CardActions";
|
|
11
|
+
import CardCancelButton from "../../../../components/Card/CardCancelButton";
|
|
12
|
+
import CardContent from "../../../../components/Card/CardContent";
|
|
13
|
+
import CardFieldSwitch from "../../../../components/Card/CardFieldSwitch";
|
|
14
|
+
import CardFieldText from "../../../../components/Card/CardFieldText";
|
|
15
|
+
import CardHeader from "../../../../components/Card/CardHeader";
|
|
16
|
+
import CardRow from "../../../../components/Card/CardRow";
|
|
17
|
+
import CardSaveButton from "../../../../components/Card/CardSaveButton";
|
|
18
|
+
import { Header } from "../../../../components/Header";
|
|
19
|
+
import { Page } from "../../../../components/Page";
|
|
20
|
+
import { TitleBar } from "../../../../components/TitleBar";
|
|
21
|
+
import Content, {
|
|
22
|
+
ContentWrapperWithActionBar,
|
|
23
|
+
LeftColumn,
|
|
24
|
+
RightColumn,
|
|
25
|
+
} from "../../../../containers/Content";
|
|
26
|
+
import { useApi } from "../../../../contexts/ApiContext";
|
|
27
|
+
import { useCardContext } from "../../../../contexts/CardContext";
|
|
28
|
+
import { useI18n } from "../../../../contexts/I18nContext";
|
|
29
|
+
import { useUser } from "../../../../contexts/UserContext";
|
|
30
|
+
import { PageComponent } from "../../../../types";
|
|
31
|
+
import { hasPermission } from "../../../../util/has_permission";
|
|
32
|
+
import { GroupOption, StaffDetail, StaffOptions } from "../../types/staff";
|
|
33
|
+
|
|
34
|
+
interface StaffUpdateValues {
|
|
35
|
+
is_active: string;
|
|
36
|
+
groups: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const StaffDetailPage: PageComponent<StaffDetail> = ({ data }) => {
|
|
40
|
+
const { t } = useI18n();
|
|
41
|
+
const api = useApi();
|
|
42
|
+
const { user: currentUser } = useUser();
|
|
43
|
+
const { enqueueSnackbar } = useSnackbar();
|
|
44
|
+
const [user, setUser] = useState<StaffDetail>(data);
|
|
45
|
+
const [availableGroups, setAvailableGroups] = useState<GroupOption[]>([]);
|
|
46
|
+
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
|
47
|
+
|
|
48
|
+
const formatDateTime = (dateString: string | null) => {
|
|
49
|
+
if (!dateString) return t("Never");
|
|
50
|
+
return DateTime.fromISO(dateString as string).toISODate() ?? undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const fetchOptions = async () => {
|
|
55
|
+
const response = await api.operations["user.staff:options"].call({});
|
|
56
|
+
if (response.ok) {
|
|
57
|
+
const options: StaffOptions = await response.json();
|
|
58
|
+
setAvailableGroups(options.groups);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
fetchOptions();
|
|
62
|
+
}, [api]);
|
|
63
|
+
|
|
64
|
+
const handleSubmit = async (values: StaffUpdateValues) => {
|
|
65
|
+
const response = await api.operations["user.staff:update"].call({
|
|
66
|
+
params: { id: user.id },
|
|
67
|
+
body: {
|
|
68
|
+
is_active: values.is_active === "on",
|
|
69
|
+
groups: values.groups
|
|
70
|
+
? values.groups
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((id) => parseInt(id.trim(), 10))
|
|
73
|
+
.filter((id) => !isNaN(id))
|
|
74
|
+
: [],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (response.ok) {
|
|
79
|
+
const updatedUser = await response.json();
|
|
80
|
+
setUser(updatedUser);
|
|
81
|
+
return t("User updated successfully");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw new Error("Failed to update user");
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handlePasswordReset = async () => {
|
|
88
|
+
setIsResettingPassword(true);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const response = await api.operations["user.staff:password-reset-request"].call({
|
|
92
|
+
body: {
|
|
93
|
+
email: user.email,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (response.ok) {
|
|
98
|
+
const data = await response.json();
|
|
99
|
+
enqueueSnackbar(data.message || t("Password reset link sent successfully"), {
|
|
100
|
+
variant: "success",
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
const errorData = await response.json();
|
|
104
|
+
if (errorData.detail && Array.isArray(errorData.detail)) {
|
|
105
|
+
enqueueSnackbar(errorData.detail[0]?.msg || t("Failed to send password reset link"), {
|
|
106
|
+
variant: "error",
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
enqueueSnackbar(t("Failed to send password reset link"), { variant: "error" });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error("[PASSWORD_RESET]", error);
|
|
114
|
+
enqueueSnackbar(t("Failed to send password reset link"), { variant: "error" });
|
|
115
|
+
} finally {
|
|
116
|
+
setIsResettingPassword(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const GroupsField = () => {
|
|
121
|
+
const { isEditing } = useCardContext();
|
|
122
|
+
const [selectedGroups, setSelectedGroups] = useState<GroupOption[]>(
|
|
123
|
+
availableGroups.filter((g) => user.groups.includes(g.name)),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (isEditing) {
|
|
127
|
+
return (
|
|
128
|
+
<>
|
|
129
|
+
<Autocomplete
|
|
130
|
+
fullWidth
|
|
131
|
+
multiple
|
|
132
|
+
getOptionLabel={(option) => option.name}
|
|
133
|
+
isOptionEqualToValue={(option, value) => option.id === value.id}
|
|
134
|
+
options={availableGroups}
|
|
135
|
+
renderInput={(params) => (
|
|
136
|
+
<TextField {...params} helperText={t("Select user groups")} label={t("Groups")} />
|
|
137
|
+
)}
|
|
138
|
+
renderTags={(value, getTagProps) =>
|
|
139
|
+
value.map((option, index) => (
|
|
140
|
+
<Chip
|
|
141
|
+
{...getTagProps({ index })}
|
|
142
|
+
key={option.id}
|
|
143
|
+
label={option.name}
|
|
144
|
+
size="small"
|
|
145
|
+
/>
|
|
146
|
+
))
|
|
147
|
+
}
|
|
148
|
+
size="small"
|
|
149
|
+
value={selectedGroups}
|
|
150
|
+
onChange={(_, newValue) => setSelectedGroups(newValue)}
|
|
151
|
+
/>
|
|
152
|
+
<input name="groups" type="hidden" value={selectedGroups.map((g) => g.id).join(", ")} />
|
|
153
|
+
</>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<Stack direction="column" spacing={1}>
|
|
159
|
+
<strong>{t("Groups")}</strong>
|
|
160
|
+
<Stack direction="row" flexWrap="wrap" gap={1}>
|
|
161
|
+
{user.groups.length > 0 ? (
|
|
162
|
+
user.groups.map((group) => <Chip key={group} label={group} size="small" />)
|
|
163
|
+
) : (
|
|
164
|
+
<span style={{ fontSize: "0.875rem", color: "#666" }}>{t("No groups")}</span>
|
|
165
|
+
)}
|
|
166
|
+
</Stack>
|
|
167
|
+
</Stack>
|
|
168
|
+
);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<Page>
|
|
173
|
+
<Header>
|
|
174
|
+
<TitleBar title={user.email} />
|
|
175
|
+
</Header>
|
|
176
|
+
|
|
177
|
+
<ContentWrapperWithActionBar>
|
|
178
|
+
<Content layout="fixedWidth">
|
|
179
|
+
<LeftColumn>
|
|
180
|
+
<Card isEditable={false}>
|
|
181
|
+
<CardHeader title={t("User Details")} />
|
|
182
|
+
|
|
183
|
+
<CardContent>
|
|
184
|
+
<CardRow>
|
|
185
|
+
<CardFieldText
|
|
186
|
+
formName="email"
|
|
187
|
+
isEditable={false}
|
|
188
|
+
label={t("Email")}
|
|
189
|
+
value={user.email}
|
|
190
|
+
/>
|
|
191
|
+
</CardRow>
|
|
192
|
+
<CardRow>
|
|
193
|
+
<CardFieldText
|
|
194
|
+
formName="last_login"
|
|
195
|
+
isEditable={false}
|
|
196
|
+
label={t("Last Login")}
|
|
197
|
+
value={formatDateTime(user.last_login)}
|
|
198
|
+
/>
|
|
199
|
+
</CardRow>
|
|
200
|
+
<CardRow>
|
|
201
|
+
<CardFieldText
|
|
202
|
+
formName="date_created"
|
|
203
|
+
isEditable={false}
|
|
204
|
+
label={t("Date Joined")}
|
|
205
|
+
value={formatDateTime(user.date_created)}
|
|
206
|
+
/>
|
|
207
|
+
</CardRow>
|
|
208
|
+
</CardContent>
|
|
209
|
+
</Card>
|
|
210
|
+
</LeftColumn>
|
|
211
|
+
|
|
212
|
+
<RightColumn>
|
|
213
|
+
<Card<StaffUpdateValues>
|
|
214
|
+
isCompact={true}
|
|
215
|
+
isEditable={hasPermission(currentUser, "user.change_user")}
|
|
216
|
+
onSubmit={handleSubmit}
|
|
217
|
+
>
|
|
218
|
+
<CardHeader title={t("Permissions")} />
|
|
219
|
+
|
|
220
|
+
<CardContent>
|
|
221
|
+
<CardRow>
|
|
222
|
+
<CardFieldSwitch
|
|
223
|
+
formName="is_active"
|
|
224
|
+
helperText={t(
|
|
225
|
+
"Indicates whether the user should be considered active. Uncheck this instead of deleting accounts.",
|
|
226
|
+
)}
|
|
227
|
+
label={t("Active")}
|
|
228
|
+
value={user.is_active}
|
|
229
|
+
/>
|
|
230
|
+
</CardRow>
|
|
231
|
+
|
|
232
|
+
<CardRow>
|
|
233
|
+
<GroupsField />
|
|
234
|
+
</CardRow>
|
|
235
|
+
</CardContent>
|
|
236
|
+
|
|
237
|
+
<CardActions>
|
|
238
|
+
<CardCancelButton />
|
|
239
|
+
<CardSaveButton />
|
|
240
|
+
</CardActions>
|
|
241
|
+
</Card>
|
|
242
|
+
</RightColumn>
|
|
243
|
+
</Content>
|
|
244
|
+
|
|
245
|
+
{hasPermission(currentUser, "user.change_user") && (
|
|
246
|
+
<ActionBar>
|
|
247
|
+
<Button
|
|
248
|
+
color="primary"
|
|
249
|
+
disabled={isResettingPassword}
|
|
250
|
+
variant="contained"
|
|
251
|
+
onClick={handlePasswordReset}
|
|
252
|
+
>
|
|
253
|
+
{isResettingPassword ? t("Sending...") : t("Reset Password")}
|
|
254
|
+
</Button>
|
|
255
|
+
</ActionBar>
|
|
256
|
+
)}
|
|
257
|
+
</ContentWrapperWithActionBar>
|
|
258
|
+
</Page>
|
|
259
|
+
);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export default StaffDetailPage;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
|
|
3
|
+
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
|
4
|
+
import { Button, TableBody } from "@mui/material";
|
|
5
|
+
|
|
6
|
+
import ActionBar from "../../../../components/ActionBar";
|
|
7
|
+
import { Header } from "../../../../components/Header";
|
|
8
|
+
import { Page } from "../../../../components/Page";
|
|
9
|
+
import SearchBar from "../../../../components/SearchBar";
|
|
10
|
+
import { Table } from "../../../../components/Table";
|
|
11
|
+
import { TableCell } from "../../../../components/Table/TableCell";
|
|
12
|
+
import TableHead from "../../../../components/Table/TableHead";
|
|
13
|
+
import TableHeading from "../../../../components/Table/TableHeading";
|
|
14
|
+
import { NavigatingTableRow } from "../../../../components/Table/TableRow";
|
|
15
|
+
import TableCard from "../../../../components/TableCard";
|
|
16
|
+
import { TitleBar } from "../../../../components/TitleBar";
|
|
17
|
+
import Content, { ContentWrapperWithActionBar } from "../../../../containers/Content";
|
|
18
|
+
import { useI18n } from "../../../../contexts/I18nContext";
|
|
19
|
+
import { useRouter } from "../../../../contexts/RouterContext";
|
|
20
|
+
import { useUser } from "../../../../contexts/UserContext";
|
|
21
|
+
import { LimitOffset, PageComponent } from "../../../../types";
|
|
22
|
+
import { hasPermission } from "../../../../util/has_permission";
|
|
23
|
+
import InviteUserModal from "../../components/InviteUserModal";
|
|
24
|
+
import { StaffList } from "../../types/staff";
|
|
25
|
+
|
|
26
|
+
const StaffListPage: PageComponent<LimitOffset<StaffList>> = ({ data, refresh }) => {
|
|
27
|
+
const { t } = useI18n();
|
|
28
|
+
const { navigate } = useRouter();
|
|
29
|
+
const { user } = useUser();
|
|
30
|
+
const [inviteModalOpen, setInviteModalOpen] = useState(false);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Page>
|
|
34
|
+
<Header>
|
|
35
|
+
<TitleBar title="Staff">
|
|
36
|
+
<SearchBar
|
|
37
|
+
defaultValue={new URLSearchParams(window.location.search).get("search") ?? ""}
|
|
38
|
+
placeholder={t("Search users…")}
|
|
39
|
+
onSubmit={(input) => {
|
|
40
|
+
if (input === "") {
|
|
41
|
+
navigate("user.staff:list", {
|
|
42
|
+
replace: true,
|
|
43
|
+
});
|
|
44
|
+
} else {
|
|
45
|
+
navigate("user.staff:list", {
|
|
46
|
+
query: {
|
|
47
|
+
search: input,
|
|
48
|
+
},
|
|
49
|
+
replace: true,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
</TitleBar>
|
|
55
|
+
</Header>
|
|
56
|
+
|
|
57
|
+
<ContentWrapperWithActionBar>
|
|
58
|
+
<Content layout="fullWidth">
|
|
59
|
+
<TableCard>
|
|
60
|
+
<Table pagination count={data?.count}>
|
|
61
|
+
<TableHead>
|
|
62
|
+
<TableHeading>{t("Email")}</TableHeading>
|
|
63
|
+
<TableHeading>{t("Permissions")}</TableHeading>
|
|
64
|
+
<TableHeading>{t("Active")}</TableHeading>
|
|
65
|
+
</TableHead>
|
|
66
|
+
|
|
67
|
+
<TableBody>
|
|
68
|
+
{data?.results.map((user) => (
|
|
69
|
+
<NavigatingTableRow
|
|
70
|
+
key={user.id}
|
|
71
|
+
route="user.staff:detail"
|
|
72
|
+
routeParams={{ id: user.id }}
|
|
73
|
+
>
|
|
74
|
+
<TableCell>{user.email}</TableCell>
|
|
75
|
+
<TableCell>{user.groups.join(", ")}</TableCell>
|
|
76
|
+
<TableCell>
|
|
77
|
+
{user.is_active ? <CheckCircleIcon color="success" fontSize="small" /> : null}
|
|
78
|
+
</TableCell>
|
|
79
|
+
</NavigatingTableRow>
|
|
80
|
+
))}
|
|
81
|
+
</TableBody>
|
|
82
|
+
</Table>
|
|
83
|
+
</TableCard>
|
|
84
|
+
</Content>
|
|
85
|
+
|
|
86
|
+
{hasPermission(user, "user.add_user") && (
|
|
87
|
+
<ActionBar>
|
|
88
|
+
<Button color="primary" variant="contained" onClick={() => setInviteModalOpen(true)}>
|
|
89
|
+
{t("Invite")}
|
|
90
|
+
</Button>
|
|
91
|
+
</ActionBar>
|
|
92
|
+
)}
|
|
93
|
+
</ContentWrapperWithActionBar>
|
|
94
|
+
|
|
95
|
+
<InviteUserModal open={inviteModalOpen} refresh={refresh} setOpen={setInviteModalOpen} />
|
|
96
|
+
</Page>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export default StaffListPage;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface StaffList {
|
|
2
|
+
id: number;
|
|
3
|
+
email: string;
|
|
4
|
+
is_active: boolean;
|
|
5
|
+
groups: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface StaffDetail {
|
|
9
|
+
id: number;
|
|
10
|
+
email: string;
|
|
11
|
+
is_staff: boolean;
|
|
12
|
+
is_active: boolean;
|
|
13
|
+
is_superuser: boolean;
|
|
14
|
+
groups: string[];
|
|
15
|
+
permissions: string[];
|
|
16
|
+
last_login: string | null;
|
|
17
|
+
date_created: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GroupOption {
|
|
21
|
+
id: number;
|
|
22
|
+
name: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface StaffOptions {
|
|
26
|
+
groups: GroupOption[];
|
|
27
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -70,6 +70,7 @@ export * as pos from "./extensions/pos";
|
|
|
70
70
|
export * as pricing from "./extensions/pricing";
|
|
71
71
|
export * as report from "./extensions/report";
|
|
72
72
|
export * as subscription from "./extensions/subscription";
|
|
73
|
+
export * as user from "./extensions/user";
|
|
73
74
|
export * as mui from "./mui";
|
|
74
75
|
export type { RouterExtension } from "./router/Router";
|
|
75
76
|
export * as themes from "./themes";
|