Compare commits
2 Commits
update-ava
...
admin-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a940ad289 | ||
|
|
75b2ca7dec |
@@ -24,15 +24,17 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
userGrants := make([]*apiUserGrantResponse, len(grants[u.ID]))
|
userGrants := make([]*apiUserGrantResponse, len(grants[u.ID]))
|
||||||
for i, g := range grants[u.ID] {
|
for i, g := range grants[u.ID] {
|
||||||
userGrants[i] = &apiUserGrantResponse{
|
userGrants[i] = &apiUserGrantResponse{
|
||||||
Topic: g.TopicPattern,
|
Topic: g.TopicPattern,
|
||||||
Permission: g.Permission.String(),
|
Permission: g.Permission.String(),
|
||||||
|
Provisioned: g.Provisioned,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
usersResponse[i] = &apiUserResponse{
|
usersResponse[i] = &apiUserResponse{
|
||||||
Username: u.Name,
|
Username: u.Name,
|
||||||
Role: string(u.Role),
|
Role: string(u.Role),
|
||||||
Tier: tier,
|
Tier: tier,
|
||||||
Grants: userGrants,
|
Grants: userGrants,
|
||||||
|
Provisioned: u.Provisioned,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, usersResponse)
|
return s.writeJSON(w, usersResponse)
|
||||||
|
|||||||
@@ -308,15 +308,17 @@ type apiUserAddOrUpdateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type apiUserResponse struct {
|
type apiUserResponse struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Tier string `json:"tier,omitempty"`
|
Tier string `json:"tier,omitempty"`
|
||||||
Grants []*apiUserGrantResponse `json:"grants,omitempty"`
|
Grants []*apiUserGrantResponse `json:"grants,omitempty"`
|
||||||
|
Provisioned bool `json:"provisioned,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiUserGrantResponse struct {
|
type apiUserGrantResponse struct {
|
||||||
Topic string `json:"topic"` // This may be a pattern
|
Topic string `json:"topic"` // This may be a pattern
|
||||||
Permission string `json:"permission"`
|
Permission string `json:"permission"`
|
||||||
|
Provisioned bool `json:"provisioned,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiUserDeleteRequest struct {
|
type apiUserDeleteRequest struct {
|
||||||
|
|||||||
@@ -405,5 +405,48 @@
|
|||||||
"web_push_subscription_expiring_title": "Notifications will be paused",
|
"web_push_subscription_expiring_title": "Notifications will be paused",
|
||||||
"web_push_subscription_expiring_body": "Open ntfy to continue receiving notifications",
|
"web_push_subscription_expiring_body": "Open ntfy to continue receiving notifications",
|
||||||
"web_push_unknown_notification_title": "Unknown notification received from server",
|
"web_push_unknown_notification_title": "Unknown notification received from server",
|
||||||
"web_push_unknown_notification_body": "You may need to update ntfy by opening the web app"
|
"web_push_unknown_notification_body": "You may need to update ntfy by opening the web app",
|
||||||
|
"nav_button_admin": "Admin",
|
||||||
|
"admin_users_title": "Users",
|
||||||
|
"admin_users_description": "Manage users and their access permissions. Admin users cannot be modified via the web interface.",
|
||||||
|
"admin_users_table_username_header": "Username",
|
||||||
|
"admin_users_table_role_header": "Role",
|
||||||
|
"admin_users_table_tier_header": "Tier",
|
||||||
|
"admin_users_table_grants_header": "Access grants",
|
||||||
|
"admin_users_table_actions_header": "Actions",
|
||||||
|
"admin_users_table_grant_tooltip": "Permission: {{permission}}",
|
||||||
|
"admin_users_table_grant_provisioned_tooltip": "Permission: {{permission}} (provisioned, cannot be changed)",
|
||||||
|
"admin_users_table_add_access_tooltip": "Add access grant",
|
||||||
|
"admin_users_table_edit_tooltip": "Edit user",
|
||||||
|
"admin_users_table_delete_tooltip": "Delete user",
|
||||||
|
"admin_users_table_admin_no_actions": "Cannot modify admin users",
|
||||||
|
"admin_users_provisioned_tooltip": "Provisioned user (defined in server config)",
|
||||||
|
"admin_users_provisioned_cannot_edit": "Provisioned users cannot be edited or deleted",
|
||||||
|
"admin_users_role_admin": "Admin",
|
||||||
|
"admin_users_role_user": "User",
|
||||||
|
"admin_users_add_button": "Add user",
|
||||||
|
"admin_users_add_dialog_title": "Add user",
|
||||||
|
"admin_users_add_dialog_username_label": "Username",
|
||||||
|
"admin_users_add_dialog_password_label": "Password",
|
||||||
|
"admin_users_add_dialog_tier_label": "Tier",
|
||||||
|
"admin_users_add_dialog_tier_helper": "Optional. Leave empty for no tier.",
|
||||||
|
"admin_users_edit_dialog_title": "Edit user {{username}}",
|
||||||
|
"admin_users_edit_dialog_password_label": "New password",
|
||||||
|
"admin_users_edit_dialog_password_helper": "Leave empty to keep current password",
|
||||||
|
"admin_users_edit_dialog_tier_label": "Tier",
|
||||||
|
"admin_users_edit_dialog_tier_helper": "Leave empty to keep current tier",
|
||||||
|
"admin_users_delete_dialog_title": "Delete user",
|
||||||
|
"admin_users_delete_dialog_description": "Are you sure you want to delete user {{username}}? This action cannot be undone.",
|
||||||
|
"admin_users_delete_dialog_button": "Delete user",
|
||||||
|
"admin_access_add_dialog_title": "Add access for {{username}}",
|
||||||
|
"admin_access_add_dialog_topic_label": "Topic",
|
||||||
|
"admin_access_add_dialog_topic_helper": "Topic name or pattern (e.g. mytopic or alerts-*)",
|
||||||
|
"admin_access_add_dialog_permission_label": "Permission",
|
||||||
|
"admin_access_permission_read_write": "Read & Write",
|
||||||
|
"admin_access_permission_read_only": "Read only",
|
||||||
|
"admin_access_permission_write_only": "Write only",
|
||||||
|
"admin_access_permission_deny_all": "Deny all",
|
||||||
|
"admin_access_delete_dialog_title": "Remove access",
|
||||||
|
"admin_access_delete_dialog_description": "Are you sure you want to remove access to topic {{topic}} for user {{username}}?",
|
||||||
|
"admin_access_delete_dialog_button": "Remove access"
|
||||||
}
|
}
|
||||||
|
|||||||
82
web/src/app/AdminApi.js
Normal file
82
web/src/app/AdminApi.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { fetchOrThrow } from "./errors";
|
||||||
|
import { withBearerAuth } from "./utils";
|
||||||
|
import session from "./Session";
|
||||||
|
|
||||||
|
const usersUrl = (baseUrl) => `${baseUrl}/v1/users`;
|
||||||
|
const usersAccessUrl = (baseUrl) => `${baseUrl}/v1/users/access`;
|
||||||
|
|
||||||
|
class AdminApi {
|
||||||
|
async getUsers() {
|
||||||
|
const url = usersUrl(config.base_url);
|
||||||
|
console.log(`[AdminApi] Fetching users ${url}`);
|
||||||
|
const response = await fetchOrThrow(url, {
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addUser(username, password, tier) {
|
||||||
|
const url = usersUrl(config.base_url);
|
||||||
|
const body = { username, password };
|
||||||
|
if (tier) {
|
||||||
|
body.tier = tier;
|
||||||
|
}
|
||||||
|
console.log(`[AdminApi] Adding user ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(username, password, tier) {
|
||||||
|
const url = usersUrl(config.base_url);
|
||||||
|
const body = { username };
|
||||||
|
if (password) {
|
||||||
|
body.password = password;
|
||||||
|
}
|
||||||
|
if (tier) {
|
||||||
|
body.tier = tier;
|
||||||
|
}
|
||||||
|
console.log(`[AdminApi] Updating user ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(username) {
|
||||||
|
const url = usersUrl(config.base_url);
|
||||||
|
console.log(`[AdminApi] Deleting user ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async allowAccess(username, topic, permission) {
|
||||||
|
const url = usersAccessUrl(config.base_url);
|
||||||
|
console.log(`[AdminApi] Allowing access ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({ username, topic, permission }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetAccess(username, topic) {
|
||||||
|
const url = usersAccessUrl(config.base_url);
|
||||||
|
console.log(`[AdminApi] Resetting access ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({ username, topic }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminApi = new AdminApi();
|
||||||
|
export default adminApi;
|
||||||
|
|
||||||
593
web/src/components/Admin.jsx
Normal file
593
web/src/components/Admin.jsx
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useContext, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
FormControl,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
Container,
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
TextField,
|
||||||
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
|
DialogContentText,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
Stack,
|
||||||
|
CircularProgress,
|
||||||
|
Box,
|
||||||
|
} from "@mui/material";
|
||||||
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import LockIcon from "@mui/icons-material/Lock";
|
||||||
|
import routes from "./routes";
|
||||||
|
import { AccountContext } from "./App";
|
||||||
|
import DialogFooter from "./DialogFooter";
|
||||||
|
import { Paragraph } from "./styles";
|
||||||
|
import { UnauthorizedError } from "../app/errors";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import adminApi from "../app/AdminApi";
|
||||||
|
import { Role } from "../app/AccountApi";
|
||||||
|
|
||||||
|
const Admin = () => {
|
||||||
|
const { account } = useContext(AccountContext);
|
||||||
|
|
||||||
|
// Redirect non-admins away
|
||||||
|
if (!session.exists() || (account && account.role !== Role.ADMIN)) {
|
||||||
|
window.location.href = routes.app;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for account to load
|
||||||
|
if (!account) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "100vh" }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ marginTop: 3, marginBottom: 3 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Users />
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Users = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [users, setUsers] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [addDialogKey, setAddDialogKey] = useState(0);
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await adminApi.getUsers();
|
||||||
|
setUsers(data);
|
||||||
|
setError("");
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Admin] Error loading users`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
await session.resetAndRedirect(routes.login);
|
||||||
|
} else {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddClick = () => {
|
||||||
|
setAddDialogKey((prev) => prev + 1);
|
||||||
|
setAddDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setAddDialogOpen(false);
|
||||||
|
loadUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ padding: 1 }} aria-label={t("admin_users_title")}>
|
||||||
|
<CardContent sx={{ paddingBottom: 1 }}>
|
||||||
|
<Typography variant="h5" sx={{ marginBottom: 2 }}>
|
||||||
|
{t("admin_users_title")}
|
||||||
|
</Typography>
|
||||||
|
<Paragraph>{t("admin_users_description")}</Paragraph>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!loading && users && (
|
||||||
|
<div style={{ width: "100%", overflowX: "auto" }}>
|
||||||
|
<UsersTable users={users} onUserChanged={loadUsers} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardActions>
|
||||||
|
<Button onClick={handleAddClick} startIcon={<AddIcon />}>
|
||||||
|
{t("admin_users_add_button")}
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
<AddUserDialog key={`addUserDialog${addDialogKey}`} open={addDialogOpen} onClose={handleDialogClose} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UsersTable = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [editDialogKey, setEditDialogKey] = useState(0);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [accessDialogKey, setAccessDialogKey] = useState(0);
|
||||||
|
const [accessDialogOpen, setAccessDialogOpen] = useState(false);
|
||||||
|
const [deleteAccessDialogOpen, setDeleteAccessDialogOpen] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState(null);
|
||||||
|
const [selectedGrant, setSelectedGrant] = useState(null);
|
||||||
|
|
||||||
|
const { users } = props;
|
||||||
|
|
||||||
|
const handleEditClick = (user) => {
|
||||||
|
setEditDialogKey((prev) => prev + 1);
|
||||||
|
setSelectedUser(user);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (user) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAccessClick = (user) => {
|
||||||
|
setAccessDialogKey((prev) => prev + 1);
|
||||||
|
setSelectedUser(user);
|
||||||
|
setAccessDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccessClick = (user, grant) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setSelectedGrant(grant);
|
||||||
|
setDeleteAccessDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setAccessDialogOpen(false);
|
||||||
|
setDeleteAccessDialogOpen(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
setSelectedGrant(null);
|
||||||
|
props.onUserChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table size="small" aria-label={t("admin_users_title")}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ paddingLeft: 0 }}>{t("admin_users_table_username_header")}</TableCell>
|
||||||
|
<TableCell>{t("admin_users_table_role_header")}</TableCell>
|
||||||
|
<TableCell>{t("admin_users_table_tier_header")}</TableCell>
|
||||||
|
<TableCell>{t("admin_users_table_grants_header")}</TableCell>
|
||||||
|
<TableCell align="right">{t("admin_users_table_actions_header")}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.username} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||||
|
<TableCell component="th" scope="row" sx={{ paddingLeft: 0 }}>
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
|
<span>{user.username}</span>
|
||||||
|
{user.provisioned && (
|
||||||
|
<Tooltip title={t("admin_users_provisioned_tooltip")}>
|
||||||
|
<LockIcon fontSize="small" color="disabled" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<RoleChip role={user.role} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{user.tier || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.grants && user.grants.length > 0 ? (
|
||||||
|
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||||
|
{user.grants.map((grant, idx) => {
|
||||||
|
const canDelete = user.role !== "admin" && !grant.provisioned;
|
||||||
|
const tooltipText = grant.provisioned
|
||||||
|
? t("admin_users_table_grant_provisioned_tooltip", { permission: grant.permission })
|
||||||
|
: t("admin_users_table_grant_tooltip", { permission: grant.permission });
|
||||||
|
return (
|
||||||
|
<Tooltip key={idx} title={tooltipText}>
|
||||||
|
<Chip
|
||||||
|
label={grant.topic}
|
||||||
|
size="small"
|
||||||
|
variant={grant.provisioned ? "filled" : "outlined"}
|
||||||
|
color={grant.provisioned ? "default" : "default"}
|
||||||
|
icon={grant.provisioned ? <LockIcon fontSize="small" /> : undefined}
|
||||||
|
onDelete={canDelete ? () => handleDeleteAccessClick(user, grant) : undefined}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
|
||||||
|
{user.role !== "admin" && !user.provisioned ? (
|
||||||
|
<>
|
||||||
|
<Tooltip title={t("admin_users_table_add_access_tooltip")}>
|
||||||
|
<IconButton onClick={() => handleAddAccessClick(user)} size="small">
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t("admin_users_table_edit_tooltip")}>
|
||||||
|
<IconButton onClick={() => handleEditClick(user)} size="small">
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t("admin_users_table_delete_tooltip")}>
|
||||||
|
<IconButton onClick={() => handleDeleteClick(user)} size="small">
|
||||||
|
<DeleteOutlineIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
) : user.role !== "admin" && user.provisioned ? (
|
||||||
|
<>
|
||||||
|
<Tooltip title={t("admin_users_table_add_access_tooltip")}>
|
||||||
|
<IconButton onClick={() => handleAddAccessClick(user)} size="small">
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t("admin_users_provisioned_cannot_edit")}>
|
||||||
|
<span>
|
||||||
|
<IconButton disabled size="small">
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton disabled size="small">
|
||||||
|
<DeleteOutlineIcon />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Tooltip title={t("admin_users_table_admin_no_actions")}>
|
||||||
|
<span>
|
||||||
|
<IconButton disabled size="small">
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<EditUserDialog key={`editUserDialog${editDialogKey}`} open={editDialogOpen} user={selectedUser} onClose={handleDialogClose} />
|
||||||
|
<DeleteUserDialog open={deleteDialogOpen} user={selectedUser} onClose={handleDialogClose} />
|
||||||
|
<AddAccessDialog key={`addAccessDialog${accessDialogKey}`} open={accessDialogOpen} user={selectedUser} onClose={handleDialogClose} />
|
||||||
|
<DeleteAccessDialog open={deleteAccessDialogOpen} user={selectedUser} grant={selectedGrant} onClose={handleDialogClose} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RoleChip = ({ role }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (role === "admin") {
|
||||||
|
return <Chip label={t("admin_users_role_admin")} size="small" color="primary" />;
|
||||||
|
}
|
||||||
|
return <Chip label={t("admin_users_role_user")} size="small" variant="outlined" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddUserDialog = (props) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [tier, setTier] = useState("");
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await adminApi.addUser(username, password, tier || undefined);
|
||||||
|
props.onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Admin] Error adding user`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
await session.resetAndRedirect(routes.login);
|
||||||
|
} else {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
|
||||||
|
<DialogTitle>{t("admin_users_add_dialog_title")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="username"
|
||||||
|
label={t("admin_users_add_dialog_username_label")}
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(ev) => setUsername(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="password"
|
||||||
|
label={t("admin_users_add_dialog_password_label")}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(ev) => setPassword(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="tier"
|
||||||
|
label={t("admin_users_add_dialog_tier_label")}
|
||||||
|
type="text"
|
||||||
|
value={tier}
|
||||||
|
onChange={(ev) => setTier(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
helperText={t("admin_users_add_dialog_tier_helper")}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter status={error}>
|
||||||
|
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!username || !password}>
|
||||||
|
{t("common_add")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditUserDialog = (props) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [tier, setTier] = useState(props.user?.tier || "");
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await adminApi.updateUser(props.user.username, password || undefined, tier || undefined);
|
||||||
|
props.onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Admin] Error updating user`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
await session.resetAndRedirect(routes.login);
|
||||||
|
} else {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!props.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
|
||||||
|
<DialogTitle>{t("admin_users_edit_dialog_title", { username: props.user.username })}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="password"
|
||||||
|
label={t("admin_users_edit_dialog_password_label")}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(ev) => setPassword(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
helperText={t("admin_users_edit_dialog_password_helper")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="tier"
|
||||||
|
label={t("admin_users_edit_dialog_tier_label")}
|
||||||
|
type="text"
|
||||||
|
value={tier}
|
||||||
|
onChange={(ev) => setTier(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
helperText={t("admin_users_edit_dialog_tier_helper")}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter status={error}>
|
||||||
|
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!password && !tier}>
|
||||||
|
{t("common_save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteUserDialog = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteUser(props.user.username);
|
||||||
|
props.onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Admin] Error deleting user`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
await session.resetAndRedirect(routes.login);
|
||||||
|
} else {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!props.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onClose}>
|
||||||
|
<DialogTitle>{t("admin_users_delete_dialog_title")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{t("admin_users_delete_dialog_description", { username: props.user.username })}</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter status={error}>
|
||||||
|
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||||
|
<Button onClick={handleSubmit} color="error">
|
||||||
|
{t("admin_users_delete_dialog_button")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddAccessDialog = (props) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [topic, setTopic] = useState("");
|
||||||
|
const [permission, setPermission] = useState("read-write");
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await adminApi.allowAccess(props.user.username, topic, permission);
|
||||||
|
props.onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Admin] Error adding access`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
await session.resetAndRedirect(routes.login);
|
||||||
|
} else {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!props.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
|
||||||
|
<DialogTitle>{t("admin_access_add_dialog_title", { username: props.user.username })}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="topic"
|
||||||
|
label={t("admin_access_add_dialog_topic_label")}
|
||||||
|
type="text"
|
||||||
|
value={topic}
|
||||||
|
onChange={(ev) => setTopic(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
autoFocus
|
||||||
|
helperText={t("admin_access_add_dialog_topic_helper")}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth variant="standard" sx={{ mt: 2 }}>
|
||||||
|
<Select
|
||||||
|
value={permission}
|
||||||
|
onChange={(ev) => setPermission(ev.target.value)}
|
||||||
|
label={t("admin_access_add_dialog_permission_label")}
|
||||||
|
>
|
||||||
|
<MenuItem value="read-write">{t("admin_access_permission_read_write")}</MenuItem>
|
||||||
|
<MenuItem value="read-only">{t("admin_access_permission_read_only")}</MenuItem>
|
||||||
|
<MenuItem value="write-only">{t("admin_access_permission_write_only")}</MenuItem>
|
||||||
|
<MenuItem value="deny-all">{t("admin_access_permission_deny_all")}</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter status={error}>
|
||||||
|
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!topic}>
|
||||||
|
{t("common_add")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteAccessDialog = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await adminApi.resetAccess(props.user.username, props.grant.topic);
|
||||||
|
props.onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Admin] Error removing access`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
await session.resetAndRedirect(routes.login);
|
||||||
|
} else {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!props.user || !props.grant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onClose}>
|
||||||
|
<DialogTitle>{t("admin_access_delete_dialog_title")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
{t("admin_access_delete_dialog_description", { username: props.user.username, topic: props.grant.topic })}
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter status={error}>
|
||||||
|
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||||
|
<Button onClick={handleSubmit} color="error">
|
||||||
|
{t("admin_access_delete_dialog_button")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Admin;
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ import Messaging from "./Messaging";
|
|||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
import Signup from "./Signup";
|
import Signup from "./Signup";
|
||||||
import Account from "./Account";
|
import Account from "./Account";
|
||||||
|
import Admin from "./Admin";
|
||||||
import initI18n from "../app/i18n"; // Translations!
|
import initI18n from "../app/i18n"; // Translations!
|
||||||
import prefs, { THEME } from "../app/Prefs";
|
import prefs, { THEME } from "../app/Prefs";
|
||||||
import RTLCacheProvider from "./RTLCacheProvider";
|
import RTLCacheProvider from "./RTLCacheProvider";
|
||||||
@@ -80,6 +81,7 @@ const App = () => {
|
|||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path={routes.app} element={<AllSubscriptions />} />
|
<Route path={routes.app} element={<AllSubscriptions />} />
|
||||||
<Route path={routes.account} element={<Account />} />
|
<Route path={routes.account} element={<Account />} />
|
||||||
|
<Route path={routes.admin} element={<Admin />} />
|
||||||
<Route path={routes.settings} element={<Preferences />} />
|
<Route path={routes.settings} element={<Preferences />} />
|
||||||
<Route path={routes.subscription} element={<SingleSubscription />} />
|
<Route path={routes.subscription} element={<SingleSubscription />} />
|
||||||
<Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
|
<Route path={routes.subscriptionExternal} element={<SingleSubscription />} />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useContext, useState } from "react";
|
|||||||
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
||||||
import Person from "@mui/icons-material/Person";
|
import Person from "@mui/icons-material/Person";
|
||||||
import SettingsIcon from "@mui/icons-material/Settings";
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
|
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
|
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
|
||||||
@@ -164,6 +165,14 @@ const NavList = (props) => {
|
|||||||
<ListItemText primary={t("nav_button_account")} />
|
<ListItemText primary={t("nav_button_account")} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
)}
|
)}
|
||||||
|
{session.exists() && isAdmin && (
|
||||||
|
<ListItemButton onClick={() => navigate(routes.admin)} selected={location.pathname === routes.admin}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<AdminPanelSettingsIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={t("nav_button_admin")} />
|
||||||
|
</ListItemButton>
|
||||||
|
)}
|
||||||
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
|
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const routes = {
|
|||||||
signup: "/signup",
|
signup: "/signup",
|
||||||
app: config.app_root,
|
app: config.app_root,
|
||||||
account: "/account",
|
account: "/account",
|
||||||
|
admin: "/admin",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
subscription: "/:topic",
|
subscription: "/:topic",
|
||||||
subscriptionExternal: "/:baseUrl/:topic",
|
subscriptionExternal: "/:baseUrl/:topic",
|
||||||
|
|||||||
Reference in New Issue
Block a user