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 && (
+
+
+
+ )}
+
+
+ }>
+ {t("admin_users_add_button")}
+
+
+
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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",