1
0
Fork 0
mirror of https://github.com/binwiederhier/ntfy.git synced 2024-09-28 19:31:59 +02:00

Basic user access endpoint

This commit is contained in:
binwiederhier 2022-12-30 14:20:48 -05:00
parent b131d676c4
commit bd86e3d951
9 changed files with 95 additions and 23 deletions

View file

@ -45,6 +45,7 @@ import (
reset daily limits for users reset daily limits for users
Account usage not updated "in real time" Account usage not updated "in real time"
max token issue limit max token issue limit
user db startup queries -> foreign keys
Sync: Sync:
- "mute" setting - "mute" setting
- figure out what settings are "web" or "phone" - figure out what settings are "web" or "phone"
@ -101,6 +102,7 @@ var (
accountPasswordPath = "/v1/account/password" accountPasswordPath = "/v1/account/password"
accountSettingsPath = "/v1/account/settings" accountSettingsPath = "/v1/account/settings"
accountSubscriptionPath = "/v1/account/subscription" accountSubscriptionPath = "/v1/account/subscription"
accountAccessPath = "/v1/account/access"
accountSubscriptionSingleRegex = regexp.MustCompile(`^/v1/account/subscription/([-_A-Za-z0-9]{16})$`) accountSubscriptionSingleRegex = regexp.MustCompile(`^/v1/account/subscription/([-_A-Za-z0-9]{16})$`)
matrixPushPath = "/_matrix/push/v1/notify" matrixPushPath = "/_matrix/push/v1/notify"
staticRegex = regexp.MustCompile(`^/static/.+`) staticRegex = regexp.MustCompile(`^/static/.+`)
@ -357,6 +359,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.handleAccountSubscriptionChange)(w, r, v) return s.ensureUser(s.handleAccountSubscriptionChange)(w, r, v)
} else if r.Method == http.MethodDelete && accountSubscriptionSingleRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodDelete && accountSubscriptionSingleRegex.MatchString(r.URL.Path) {
return s.ensureUser(s.handleAccountSubscriptionDelete)(w, r, v) return s.ensureUser(s.handleAccountSubscriptionDelete)(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == accountAccessPath {
return s.ensureUser(s.handleAccountAccessAdd)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
return s.handleMatrixDiscovery(w) return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {

View file

@ -307,3 +307,22 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
return nil return nil
} }
func (s *Server) handleAccountAccessAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccountAccessRequest](r.Body, jsonBodyBytesLimit)
if err != nil {
return err
}
if !topicRegex.MatchString(req.Topic) {
return errHTTPBadRequestTopicInvalid
}
if err := s.userManager.AllowAccess(v.user.Name, req.Topic, true, true); err != nil {
return err
}
if err := s.userManager.AllowAccess(user.Everyone, req.Topic, false, false); err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
return nil
}

View file

@ -266,3 +266,8 @@ type apiAccountResponse struct {
Limits *apiAccountLimits `json:"limits,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"`
} }
type apiAccountAccessRequest struct {
Topic string `json:"topic"`
Access string `json:"access"`
}

View file

@ -4,6 +4,7 @@
"signup_form_password": "Password", "signup_form_password": "Password",
"signup_form_confirm_password": "Confirm password", "signup_form_confirm_password": "Confirm password",
"signup_form_button_submit": "Sign up", "signup_form_button_submit": "Sign up",
"signup_form_toggle_password_visibility": "Toggle password visibility",
"signup_already_have_account": "Already have an account? Sign in!", "signup_already_have_account": "Already have an account? Sign in!",
"signup_disabled": "Signup is disabled", "signup_disabled": "Signup is disabled",
"signup_error_username_taken": "Username {{username}} is already taken", "signup_error_username_taken": "Username {{username}} is already taken",
@ -224,6 +225,7 @@
"prefs_users_add_button": "Add user", "prefs_users_add_button": "Add user",
"prefs_users_edit_button": "Edit user", "prefs_users_edit_button": "Edit user",
"prefs_users_delete_button": "Delete user", "prefs_users_delete_button": "Delete user",
"prefs_users_table_cannot_delete_or_edit": "Cannot delete or edit logged in user",
"prefs_users_table_user_header": "User", "prefs_users_table_user_header": "User",
"prefs_users_table_base_url_header": "Service URL", "prefs_users_table_base_url_header": "Service URL",
"prefs_users_dialog_title_add": "Add user", "prefs_users_dialog_title_add": "Add user",

View file

@ -18,7 +18,7 @@ class UserManager {
} }
async save(user) { async save(user) {
if (user.baseUrl === config.baseUrl) { if (session.exists() && user.baseUrl === config.baseUrl) {
return; return;
} }
await db.users.put(user); await db.users.put(user);

View file

@ -11,12 +11,17 @@ import {NavLink} from "react-router-dom";
import AvatarBox from "./AvatarBox"; import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import accountApi, {UnauthorizedError} from "../app/AccountApi"; import accountApi, {UnauthorizedError} from "../app/AccountApi";
import IconButton from "@mui/material/IconButton";
import {InputAdornment} from "@mui/material";
import {Visibility, VisibilityOff} from "@mui/icons-material";
const Login = () => { const Login = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault(); event.preventDefault();
const user = { username, password }; const user = { username, password };
@ -66,11 +71,25 @@ const Login = () => {
fullWidth fullWidth
name="password" name="password"
label={t("signup_form_password")} label={t("signup_form_password")}
type="password" type={showPassword ? "text" : "password"}
id="password" id="password"
value={password} value={password}
onChange={ev => setPassword(ev.target.value.trim())} onChange={ev => setPassword(ev.target.value.trim())}
autoComplete="current-password" autoComplete="current-password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/> />
<Button <Button
type="submit" type="submit"

View file

@ -10,7 +10,7 @@ import {
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
TableRow, TableRow, Tooltip,
useMediaQuery useMediaQuery
} from "@mui/material"; } from "@mui/material";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
@ -38,6 +38,8 @@ import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, {UnauthorizedError} from "../app/AccountApi"; import accountApi, {UnauthorizedError} from "../app/AccountApi";
import {Pref, PrefGroup} from "./Pref"; import {Pref, PrefGroup} from "./Pref";
import InfoIcon from '@mui/icons-material/Info';
import {useNavigate} from "react-router-dom";
const Preferences = () => { const Preferences = () => {
return ( return (
@ -245,14 +247,17 @@ const UserTable = (props) => {
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [dialogUser, setDialogUser] = useState(null); const [dialogUser, setDialogUser] = useState(null);
const handleEditClick = (user) => { const handleEditClick = (user) => {
setDialogKey(prev => prev+1); setDialogKey(prev => prev+1);
setDialogUser(user); setDialogUser(user);
setDialogOpen(true); setDialogOpen(true);
}; };
const handleDialogCancel = () => { const handleDialogCancel = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const handleDialogSubmit = async (user) => { const handleDialogSubmit = async (user) => {
setDialogOpen(false); setDialogOpen(false);
try { try {
@ -262,6 +267,7 @@ const UserTable = (props) => {
console.log(`[Preferences] Error updating user.`, e); console.log(`[Preferences] Error updating user.`, e);
} }
}; };
const handleDeleteClick = async (user) => { const handleDeleteClick = async (user) => {
try { try {
await userManager.delete(user.baseUrl); await userManager.delete(user.baseUrl);
@ -270,6 +276,7 @@ const UserTable = (props) => {
console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
} }
}; };
return ( return (
<Table size="small" aria-label={t("prefs_users_table")}> <Table size="small" aria-label={t("prefs_users_table")}>
<TableHead> <TableHead>
@ -289,18 +296,24 @@ const UserTable = (props) => {
aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell> aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell> <TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
<TableCell align="right"> <TableCell align="right">
{user.baseUrl !== config.baseUrl && {(!session.exists() || user.baseUrl !== config.baseUrl) &&
<> <>
<IconButton onClick={() => handleEditClick(user)} <IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
aria-label={t("prefs_users_edit_button")}>
<EditIcon/> <EditIcon/>
</IconButton> </IconButton>
<IconButton onClick={() => handleDeleteClick(user)} <IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
aria-label={t("prefs_users_delete_button")}>
<CloseIcon/> <CloseIcon/>
</IconButton> </IconButton>
</> </>
} }
{session.exists() && user.baseUrl === config.baseUrl &&
<Tooltip title={t("prefs_users_table_cannot_delete_or_edit")}>
<span>
<IconButton disabled><EditIcon/></IconButton>
<IconButton disabled><CloseIcon/></IconButton>
</span>
</Tooltip>
}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View file

@ -11,13 +11,16 @@ import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import accountApi, {AccountCreateLimitReachedError, UsernameTakenError} from "../app/AccountApi"; import accountApi, {AccountCreateLimitReachedError, UsernameTakenError} from "../app/AccountApi";
import {InputAdornment} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import {Visibility, VisibilityOff} from "@mui/icons-material";
const Signup = () => { const Signup = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState(""); const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault(); event.preventDefault();
const user = { username, password }; const user = { username, password };
@ -70,29 +73,31 @@ const Signup = () => {
fullWidth fullWidth
name="password" name="password"
label={t("signup_form_password")} label={t("signup_form_password")}
type="password" type={showPassword ? "text" : "password"}
id="password" id="password"
autoComplete="current-password" autoComplete="current-password"
value={password} value={password}
onChange={ev => setPassword(ev.target.value.trim())} onChange={ev => setPassword(ev.target.value.trim())}
/> InputProps={{
<TextField endAdornment: (
margin="dense" <InputAdornment position="end">
required <IconButton
fullWidth aria-label={t("signup_form_toggle_password_visibility")}
name="confirm-password" onClick={() => setShowPassword(!showPassword)}
label={t("signup_form_confirm_password")} onMouseDown={(ev) => ev.preventDefault()}
type="password" edge="end"
id="confirm-password" >
value={confirm} {showPassword ? <VisibilityOff /> : <Visibility />}
onChange={ev => setConfirm(ev.target.value.trim())} </IconButton>
</InputAdornment>
)
}}
/> />
<Button <Button
type="submit" type="submit"
fullWidth fullWidth
variant="contained" variant="contained"
disabled={username === "" || password === "" || password !== confirm} disabled={username === "" || password === ""}
sx={{mt: 2, mb: 2}} sx={{mt: 2, mb: 2}}
> >
{t("signup_form_button_submit")} {t("signup_form_button_submit")}

View file

@ -18,6 +18,8 @@ import {useTranslation} from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, {UnauthorizedError} from "../app/AccountApi"; import accountApi, {UnauthorizedError} from "../app/AccountApi";
import IconButton from "@mui/material/IconButton";
import PublicIcon from '@mui/icons-material/Public';
const publicBaseUrl = "https://ntfy.sh"; const publicBaseUrl = "https://ntfy.sh";
@ -123,6 +125,9 @@ const SubscribePage = (props) => {
{t("subscribe_dialog_subscribe_description")} {t("subscribe_dialog_subscribe_description")}
</DialogContentText> </DialogContentText>
<div style={{display: 'flex'}} role="row"> <div style={{display: 'flex'}} role="row">
<IconButton color="inherit" size="large" edge="start" sx={{height: "45px", marginTop: "5px", color: "grey"}}>
<PublicIcon/>
</IconButton>
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"