Basic user access endpoint

pull/584/head
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
Account usage not updated "in real time"
max token issue limit
user db startup queries -> foreign keys
Sync:
- "mute" setting
- figure out what settings are "web" or "phone"
@ -101,6 +102,7 @@ var (
accountPasswordPath = "/v1/account/password"
accountSettingsPath = "/v1/account/settings"
accountSubscriptionPath = "/v1/account/subscription"
accountAccessPath = "/v1/account/access"
accountSubscriptionSingleRegex = regexp.MustCompile(`^/v1/account/subscription/([-_A-Za-z0-9]{16})$`)
matrixPushPath = "/_matrix/push/v1/notify"
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)
} else if r.Method == http.MethodDelete && accountSubscriptionSingleRegex.MatchString(r.URL.Path) {
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 {
return s.handleMatrixDiscovery(w)
} 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
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"`
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_confirm_password": "Confirm password",
"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_disabled": "Signup is disabled",
"signup_error_username_taken": "Username {{username}} is already taken",
@ -224,6 +225,7 @@
"prefs_users_add_button": "Add user",
"prefs_users_edit_button": "Edit 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_base_url_header": "Service URL",
"prefs_users_dialog_title_add": "Add user",

View File

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

View File

@ -11,12 +11,17 @@ import {NavLink} from "react-router-dom";
import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next";
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 { t } = useTranslation();
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
const user = { username, password };
@ -66,11 +71,25 @@ const Login = () => {
fullWidth
name="password"
label={t("signup_form_password")}
type="password"
type={showPassword ? "text" : "password"}
id="password"
value={password}
onChange={ev => setPassword(ev.target.value.trim())}
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
type="submit"

View File

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

View File

@ -11,13 +11,16 @@ import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
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 { t } = useTranslation();
const [error, setError] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
const user = { username, password };
@ -70,29 +73,31 @@ const Signup = () => {
fullWidth
name="password"
label={t("signup_form_password")}
type="password"
type={showPassword ? "text" : "password"}
id="password"
autoComplete="current-password"
value={password}
onChange={ev => setPassword(ev.target.value.trim())}
/>
<TextField
margin="dense"
required
fullWidth
name="confirm-password"
label={t("signup_form_confirm_password")}
type="password"
id="confirm-password"
value={confirm}
onChange={ev => setConfirm(ev.target.value.trim())}
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
type="submit"
fullWidth
variant="contained"
disabled={username === "" || password === "" || password !== confirm}
disabled={username === "" || password === ""}
sx={{mt: 2, mb: 2}}
>
{t("signup_form_button_submit")}

View File

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