From 75b2ca7deca2e5cc88d037bb20a8152c44b7af9e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 30 Dec 2025 11:10:41 -0500 Subject: [PATCH] Admin web app --- web/public/static/langs/en.json | 42 ++- web/src/app/AdminApi.js | 82 +++++ web/src/components/Admin.jsx | 559 ++++++++++++++++++++++++++++++ web/src/components/App.jsx | 2 + web/src/components/Navigation.jsx | 9 + web/src/components/routes.js | 1 + 6 files changed, 694 insertions(+), 1 deletion(-) create mode 100644 web/src/app/AdminApi.js create mode 100644 web/src/components/Admin.jsx diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index b0d3c545..5bebf2fa 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -405,5 +405,45 @@ "web_push_subscription_expiring_title": "Notifications will be paused", "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_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_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_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" } diff --git a/web/src/app/AdminApi.js b/web/src/app/AdminApi.js new file mode 100644 index 00000000..ab2f3593 --- /dev/null +++ b/web/src/app/AdminApi.js @@ -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; + diff --git a/web/src/components/Admin.jsx b/web/src/components/Admin.jsx new file mode 100644 index 00000000..cf900dc2 --- /dev/null +++ b/web/src/components/Admin.jsx @@ -0,0 +1,559 @@ +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 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 ( + + + + ); + } + + return ( + + + + + + ); +}; + +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 ( + + + + {t("admin_users_title")} + + {t("admin_users_description")} + {error && ( + + {error} + + )} + {loading && ( + + + + )} + {!loading && users && ( +
+ +
+ )} +
+ + + + +
+ ); +}; + +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 ( + <> + + + + {t("admin_users_table_username_header")} + {t("admin_users_table_role_header")} + {t("admin_users_table_tier_header")} + {t("admin_users_table_grants_header")} + {t("admin_users_table_actions_header")} + + + + {users.map((user) => ( + + + {user.username} + + + + + {user.tier || "-"} + + {user.grants && user.grants.length > 0 ? ( + + {user.grants.map((grant, idx) => ( + + handleDeleteAccessClick(user, grant) : undefined} + /> + + ))} + + ) : ( + "-" + )} + + + {user.role !== "admin" ? ( + <> + + handleAddAccessClick(user)} size="small"> + + + + + handleEditClick(user)} size="small"> + + + + + handleDeleteClick(user)} size="small"> + + + + + ) : ( + + + + + + + + )} + + + ))} + +
+ + + + + + ); +}; + +const RoleChip = ({ role }) => { + const { t } = useTranslation(); + if (role === "admin") { + return ; + } + return ; +}; + +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 ( + + {t("admin_users_add_dialog_title")} + + setUsername(ev.target.value)} + fullWidth + variant="standard" + autoFocus + /> + setPassword(ev.target.value)} + fullWidth + variant="standard" + /> + setTier(ev.target.value)} + fullWidth + variant="standard" + helperText={t("admin_users_add_dialog_tier_helper")} + /> + + + + + + + ); +}; + +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 ( + + {t("admin_users_edit_dialog_title", { username: props.user.username })} + + setPassword(ev.target.value)} + fullWidth + variant="standard" + helperText={t("admin_users_edit_dialog_password_helper")} + /> + setTier(ev.target.value)} + fullWidth + variant="standard" + helperText={t("admin_users_edit_dialog_tier_helper")} + /> + + + + + + + ); +}; + +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 ( + + {t("admin_users_delete_dialog_title")} + + {t("admin_users_delete_dialog_description", { username: props.user.username })} + + + + + + + ); +}; + +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 ( + + {t("admin_access_add_dialog_title", { username: props.user.username })} + + setTopic(ev.target.value)} + fullWidth + variant="standard" + autoFocus + helperText={t("admin_access_add_dialog_topic_helper")} + /> + + + + + + + + + + ); +}; + +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 ( + + {t("admin_access_delete_dialog_title")} + + + {t("admin_access_delete_dialog_description", { username: props.user.username, topic: props.grant.topic })} + + + + + + + + ); +}; + +export default Admin; + diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 9a2c3e66..0a21d1ea 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -20,6 +20,7 @@ import Messaging from "./Messaging"; import Login from "./Login"; import Signup from "./Signup"; import Account from "./Account"; +import Admin from "./Admin"; import initI18n from "../app/i18n"; // Translations! import prefs, { THEME } from "../app/Prefs"; import RTLCacheProvider from "./RTLCacheProvider"; @@ -80,6 +81,7 @@ const App = () => { }> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx index 7e30931a..b2709f68 100644 --- a/web/src/components/Navigation.jsx +++ b/web/src/components/Navigation.jsx @@ -25,6 +25,7 @@ import { useContext, useState } from "react"; import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; import Person from "@mui/icons-material/Person"; import SettingsIcon from "@mui/icons-material/Settings"; +import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings"; import AddIcon from "@mui/icons-material/Add"; import { useLocation, useNavigate } from "react-router-dom"; import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material"; @@ -164,6 +165,14 @@ const NavList = (props) => { )} + {session.exists() && isAdmin && ( + navigate(routes.admin)} selected={location.pathname === routes.admin}> + + + + + + )} navigate(routes.settings)} selected={location.pathname === routes.settings}> diff --git a/web/src/components/routes.js b/web/src/components/routes.js index 17e0eac6..f35ad453 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -6,6 +6,7 @@ const routes = { signup: "/signup", app: config.app_root, account: "/account", + admin: "/admin", settings: "/settings", subscription: "/:topic", subscriptionExternal: "/:baseUrl/:topic",