Reserved topic stuff

This commit is contained in:
binwiederhier 2023-01-04 20:34:22 -05:00
parent 6c0429351a
commit a91da7cf2c
11 changed files with 240 additions and 133 deletions

View file

@ -108,6 +108,7 @@ type Config struct {
EnableLogin bool EnableLogin bool
EnableEmailConfirm bool EnableEmailConfirm bool
EnableResetPassword bool EnableResetPassword bool
EnableAccountUpgrades bool
Version string // injected by App Version string // injected by App
} }

View file

@ -40,12 +40,12 @@ import (
message cache duration message cache duration
Keep 10000 messages or keep X days? Keep 10000 messages or keep X days?
Attachment expiration based on plan Attachment expiration based on plan
reserve topics
purge accounts that were not logged into in X purge accounts that were not logged into in X
reset daily limits for users reset daily limits for users
Account usage not updated "in real time"
max token issue limit max token issue limit
user db startup queries -> foreign keys user db startup queries -> foreign keys
UI
- Feature flag for "reserve topic" feature
Sync: Sync:
- "mute" setting - "mute" setting
- figure out what settings are "web" or "phone" - figure out what settings are "web" or "phone"
@ -447,17 +447,20 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
if !s.config.WebRootIsApp { if !s.config.WebRootIsApp {
appRoot = "/app" appRoot = "/app"
} }
disallowedTopicsStr := `"` + strings.Join(disallowedTopics, `", "`) + `"` response := &apiConfigResponse{
BaseURL: "", // Will translate to window.location.origin
AppRoot: appRoot,
EnableLogin: s.config.EnableLogin,
EnableSignup: s.config.EnableSignup,
EnableResetPassword: s.config.EnableResetPassword,
DisallowedTopics: disallowedTopics,
}
b, err := json.Marshal(response)
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/javascript") w.Header().Set("Content-Type", "text/javascript")
_, err := io.WriteString(w, fmt.Sprintf(`// Generated server configuration _, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
var config = {
baseUrl: window.location.origin,
appRoot: "%s",
enableLogin: %t,
enableSignup: %t,
enableResetPassword: %t,
disallowedTopics: [%s],
};`, appRoot, s.config.EnableLogin, s.config.EnableSignup, s.config.EnableResetPassword, disallowedTopicsStr))
return err return err
} }

View file

@ -280,3 +280,12 @@ type apiAccountAccessRequest struct {
Topic string `json:"topic"` Topic string `json:"topic"`
Everyone string `json:"everyone"` Everyone string `json:"everyone"`
} }
type apiConfigResponse struct {
BaseURL string `json:"base_url"`
AppRoot string `json:"app_root"`
EnableLogin bool `json:"enable_login"`
EnableSignup bool `json:"enable_signup"`
EnableResetPassword bool `json:"enable_reset_password"`
DisallowedTopics []string `json:"disallowed_topics"`
}

View file

@ -83,6 +83,7 @@
"subscription_settings_dialog_title": "Subscription settings", "subscription_settings_dialog_title": "Subscription settings",
"subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.", "subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.",
"subscription_settings_dialog_display_name_placeholder": "Display name", "subscription_settings_dialog_display_name_placeholder": "Display name",
"subscription_settings_dialog_reserve_topic_label": "Reserve topic and configure access",
"subscription_settings_button_cancel": "Cancel", "subscription_settings_button_cancel": "Cancel",
"subscription_settings_button_save": "Save", "subscription_settings_button_save": "Save",
"notifications_loading": "Loading notifications …", "notifications_loading": "Loading notifications …",
@ -159,6 +160,7 @@
"subscribe_dialog_login_button_back": "Back", "subscribe_dialog_login_button_back": "Back",
"subscribe_dialog_login_button_login": "Login", "subscribe_dialog_login_button_login": "Login",
"subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized", "subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized",
"subscribe_dialog_error_topic_already_reserved": "Topic already reserved",
"subscribe_dialog_error_user_anonymous": "anonymous", "subscribe_dialog_error_user_anonymous": "anonymous",
"account_basics_title": "Account", "account_basics_title": "Account",
"account_basics_username_title": "Username", "account_basics_username_title": "Username",
@ -253,6 +255,7 @@
"prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe", "prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe",
"prefs_reservations_dialog_title_add": "Reserve topic", "prefs_reservations_dialog_title_add": "Reserve topic",
"prefs_reservations_dialog_title_edit": "Edit reserved topic", "prefs_reservations_dialog_title_edit": "Edit reserved topic",
"prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
"prefs_reservations_dialog_topic_label": "Topic", "prefs_reservations_dialog_topic_label": "Topic",
"prefs_reservations_dialog_access_label": "Access", "prefs_reservations_dialog_access_label": "Access",
"priority_min": "min", "priority_min": "min",

View file

@ -231,6 +231,8 @@ class AccountApi {
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError(); throw new UnauthorizedError();
} else if (response.status === 409) {
throw new TopicReservedError();
} else if (response.status !== 200) { } else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`); throw new Error(`Unexpected server response ${response.status}`);
} }
@ -312,6 +314,13 @@ export class UsernameTakenError extends Error {
} }
} }
export class TopicReservedError extends Error {
constructor(topic) {
super("Topic already reserved");
this.topic = topic;
}
}
export class AccountCreateLimitReachedError extends Error { export class AccountCreateLimitReachedError extends Error {
constructor() { constructor() {
super("Account creation limit reached"); super("Account creation limit reached");

View file

@ -1,2 +1,7 @@
const config = window.config; const config = window.config;
if (config.base_url === "") {
config.base_url = window.location.origin;
}
export default config; export default config;

View file

@ -177,7 +177,7 @@ const Stats = () => {
<PrefGroup> <PrefGroup>
<Pref title={t("account_usage_plan_title")}> <Pref title={t("account_usage_plan_title")}>
<div> <div>
{account?.role === "admin" {account.role === "admin"
? <>{t("account_usage_unlimited")} <Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></> ? <>{t("account_usage_unlimited")} <Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></>
: t(`account_usage_plan_code_${planCode}`)} : t(`account_usage_plan_code_${planCode}`)}
</div> </div>
@ -187,28 +187,44 @@ const Stats = () => {
<Typography variant="body2" sx={{float: "left"}}>{account.stats.topics}</Typography> <Typography variant="body2" sx={{float: "left"}}>{account.stats.topics}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.limits.topics > 0 ? t("account_usage_of_limit", { limit: account.limits.topics }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.limits.topics > 0 ? t("account_usage_of_limit", { limit: account.limits.topics }) : t("account_usage_unlimited")}</Typography>
</div> </div>
<LinearProgress variant="determinate" value={account.limits.topics > 0 ? normalize(account.stats.topics, account.limits.topics) : 100} /> <LinearProgress
variant="determinate"
value={account.limits.topics > 0 ? normalize(account.stats.topics, account.limits.topics) : 100}
color={account?.role !== "admin" && account.stats.topics_remaining === 0 ? 'error' : 'primary'}
/>
</Pref> </Pref>
<Pref title={t("account_usage_messages_title")}> <Pref title={t("account_usage_messages_title")}>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography> <Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.limits.messages > 0 ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.limits.messages > 0 ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography>
</div> </div>
<LinearProgress variant="determinate" value={account.limits.messages > 0 ? normalize(account.stats.messages, account.limits.messages) : 100} /> <LinearProgress
variant="determinate"
value={account.limits.messages > 0 ? normalize(account.stats.messages, account.limits.messages) : 100}
color={account?.role !== "admin" && account.stats.messages_remaining === 0 ? 'error' : 'primary'}
/>
</Pref> </Pref>
<Pref title={t("account_usage_emails_title")}> <Pref title={t("account_usage_emails_title")}>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography> <Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.limits.emails > 0 ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.limits.emails > 0 ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography>
</div> </div>
<LinearProgress variant="determinate" value={account.limits.emails > 0 ? normalize(account.stats.emails, account.limits.emails) : 100} /> <LinearProgress
variant="determinate"
value={account.limits.emails > 0 ? normalize(account.stats.emails, account.limits.emails) : 100}
color={account?.role !== "admin" && account.stats.emails_remaining === 0 ? 'error' : 'primary'}
/>
</Pref> </Pref>
<Pref title={t("account_usage_attachment_storage_title")} subtitle={t("account_usage_attachment_storage_subtitle", { filesize: formatBytes(account.limits.attachment_file_size) })}> <Pref title={t("account_usage_attachment_storage_title")} subtitle={account.role !== "admin" ? t("account_usage_attachment_storage_subtitle", { filesize: formatBytes(account.limits.attachment_file_size) }) : null}>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography> <Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography>
</div> </div>
<LinearProgress variant="determinate" value={account.limits.attachment_total_size > 0 ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100} /> <LinearProgress
variant="determinate"
value={account.limits.attachment_total_size > 0 ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
color={account.role !== "admin" && account.stats.attachment_total_size_remaining === 0 ? 'error' : 'primary'}
/>
</Pref> </Pref>
</PrefGroup> </PrefGroup>
{account.limits.basis === "ip" && {account.limits.basis === "ip" &&

View file

@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import { import {
Alert,
CardActions, CardActions,
CardContent, CardContent,
FormControl, FormControl,
@ -44,6 +45,8 @@ import LockIcon from "@mui/icons-material/Lock";
import {Public, PublicOff} from "@mui/icons-material"; import {Public, PublicOff} from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText"; import ListItemText from "@mui/material/ListItemText";
import DialogContentText from "@mui/material/DialogContentText";
import ReserveTopicSelect from "./ReserveTopicSelect";
const Preferences = () => { const Preferences = () => {
return ( return (
@ -482,10 +485,11 @@ const Reservations = () => {
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
if (!session.exists() || !account) { if (!session.exists() || !account || account.role === "admin") {
return <></>; return <></>;
} }
const reservations = account.reservations || []; const reservations = account.reservations || [];
const limitReached = account.role === "user" && account.stats.topics_remaining === 0;
const handleAddClick = () => { const handleAddClick = () => {
setDialogKey(prev => prev+1); setDialogKey(prev => prev+1);
@ -505,7 +509,7 @@ const Reservations = () => {
} catch (e) { } catch (e) {
console.log(`[Preferences] Error topic reservation.`, e); console.log(`[Preferences] Error topic reservation.`, e);
} }
// FIXME handle 401/403 // FIXME handle 401/403/409
}; };
return ( return (
@ -518,9 +522,15 @@ const Reservations = () => {
{t("prefs_reservations_description")} {t("prefs_reservations_description")}
</Paragraph> </Paragraph>
{reservations.length > 0 && <ReservationsTable reservations={reservations}/>} {reservations.length > 0 && <ReservationsTable reservations={reservations}/>}
{limitReached &&
<Alert severity="info">
You reached your reserved topics limit.
</Alert>
}
</CardContent> </CardContent>
<CardActions> <CardActions>
<Button onClick={handleAddClick}>{t("prefs_reservations_add_button")}</Button> <Button onClick={handleAddClick} disabled={limitReached}>{t("prefs_reservations_add_button")}</Button>
<ReservationsDialog <ReservationsDialog
key={`reservationAddDialog${dialogKey}`} key={`reservationAddDialog${dialogKey}`}
open={dialogOpen} open={dialogOpen}
@ -559,7 +569,7 @@ const ReservationsTable = (props) => {
} catch (e) { } catch (e) {
console.log(`[Preferences] Error topic reservation.`, e); console.log(`[Preferences] Error topic reservation.`, e);
} }
// FIXME handle 401/403 // FIXME handle 401/403/409
}; };
const handleDeleteClick = async (reservation) => { const handleDeleteClick = async (reservation) => {
@ -670,6 +680,9 @@ const ReservationsDialog = (props) => {
<Dialog open={props.open} onClose={props.onCancel} maxWidth="sm" fullWidth fullScreen={fullScreen}> <Dialog open={props.open} onClose={props.onCancel} maxWidth="sm" fullWidth fullScreen={fullScreen}>
<DialogTitle>{editMode ? t("prefs_reservations_dialog_title_edit") : t("prefs_reservations_dialog_title_add")}</DialogTitle> <DialogTitle>{editMode ? t("prefs_reservations_dialog_title_edit") : t("prefs_reservations_dialog_title_add")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText>
{t("prefs_reservations_dialog_description")}
</DialogContentText>
{!editMode && <TextField {!editMode && <TextField
autoFocus autoFocus
margin="dense" margin="dense"
@ -682,37 +695,11 @@ const ReservationsDialog = (props) => {
fullWidth fullWidth
variant="standard" variant="standard"
/>} />}
<FormControl fullWidth variant="standard"> <ReserveTopicSelect
<Select
value={everyone} value={everyone}
onChange={(ev) => setEveryone(ev.target.value)} onChange={setEveryone}
aria-label={t("prefs_reservations_dialog_access_label")} sx={{mt: 1}}
sx={{ />
marginTop: 1,
"& .MuiSelect-select": {
display: 'flex',
alignItems: 'center'
}
}}
>
<MenuItem value="deny-all">
<ListItemIcon><LockIcon /></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_deny_all")} />
</MenuItem>
<MenuItem value="read-only">
<ListItemIcon><PublicOff /></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_only")} />
</MenuItem>
<MenuItem value="write-only">
<ListItemIcon><PublicOff /></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_write_only")} />
</MenuItem>
<MenuItem value="read-write">
<ListItemIcon><Public /></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_write")} />
</MenuItem>
</Select>
</FormControl>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button> <Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button>

View file

@ -0,0 +1,62 @@
import * as React from 'react';
import {useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Checkbox, FormControl, FormControlLabel, Select, useMediaQuery} from "@mui/material";
import theme from "./theme";
import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
import accountApi, {UnauthorizedError} from "../app/AccountApi";
import session from "../app/Session";
import routes from "./routes";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import LockIcon from "@mui/icons-material/Lock";
import ListItemText from "@mui/material/ListItemText";
import {Public, PublicOff} from "@mui/icons-material";
const ReserveTopicSelect = (props) => {
const { t } = useTranslation();
const sx = props.sx || {};
return (
<FormControl fullWidth variant="standard" sx={sx}>
<Select
value={props.value}
onChange={(ev) => props.onChange(ev.target.value)}
aria-label={t("prefs_reservations_dialog_access_label")}
sx={{
"& .MuiSelect-select": {
display: 'flex',
alignItems: 'center',
paddingTop: "4px",
paddingBottom: "4px",
}
}}
>
<MenuItem value="deny-all">
<ListItemIcon><LockIcon/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_deny_all")}/>
</MenuItem>
<MenuItem value="read-only">
<ListItemIcon><PublicOff/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_only")}/>
</MenuItem>
<MenuItem value="write-only">
<ListItemIcon><PublicOff/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_write_only")}/>
</MenuItem>
<MenuItem value="read-write">
<ListItemIcon><Public/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_write")}/>
</MenuItem>
</Select>
</FormControl>
);
};
export default ReserveTopicSelect;

View file

@ -6,7 +6,7 @@ import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText'; import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material"; import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material";
import theme from "./theme"; import theme from "./theme";
import api from "../app/Api"; import api from "../app/Api";
import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils"; import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils";
@ -17,14 +17,14 @@ import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next"; 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, {TopicReservedError, UnauthorizedError} from "../app/AccountApi";
import IconButton from "@mui/material/IconButton";
import PublicIcon from '@mui/icons-material/Public'; import PublicIcon from '@mui/icons-material/Public';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import PublicOffIcon from '@mui/icons-material/PublicOff'; import PublicOffIcon from '@mui/icons-material/PublicOff';
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import PopupMenu from "./PopupMenu"; import PopupMenu from "./PopupMenu";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import ReserveTopicSelect from "./ReserveTopicSelect";
const publicBaseUrl = "https://ntfy.sh"; const publicBaseUrl = "https://ntfy.sh";
@ -33,6 +33,7 @@ const SubscribeDialog = (props) => {
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
const [showLoginPage, setShowLoginPage] = useState(false); const [showLoginPage, setShowLoginPage] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleSuccess = async () => { const handleSuccess = async () => {
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
const actualBaseUrl = (baseUrl) ? baseUrl : config.baseUrl; const actualBaseUrl = (baseUrl) ? baseUrl : config.baseUrl;
@ -44,6 +45,7 @@ const SubscribeDialog = (props) => {
topic: topic topic: topic
}); });
await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id);
await accountApi.sync();
} catch (e) { } catch (e) {
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
if ((e instanceof UnauthorizedError)) { if ((e instanceof UnauthorizedError)) {
@ -54,6 +56,7 @@ const SubscribeDialog = (props) => {
poller.pollInBackground(subscription); // Dangle! poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription); props.onSuccess(subscription);
} }
return ( return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
{!showLoginPage && <SubscribePage {!showLoginPage && <SubscribePage
@ -78,10 +81,11 @@ const SubscribeDialog = (props) => {
const SubscribePage = (props) => { const SubscribePage = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [reserveTopicVisible, setReserveTopicVisible] = useState(false);
const [anotherServerVisible, setAnotherServerVisible] = useState(false); const [anotherServerVisible, setAnotherServerVisible] = useState(false);
const [errorText, setErrorText] = useState(""); const [errorText, setErrorText] = useState("");
const [accessAnchorEl, setAccessAnchorEl] = useState(null); const [accessAnchorEl, setAccessAnchorEl] = useState(null);
const [access, setAccess] = useState("public"); const [everyone, setEveryone] = useState("deny-all");
const baseUrl = (anotherServerVisible) ? props.baseUrl : config.baseUrl; const baseUrl = (anotherServerVisible) ? props.baseUrl : config.baseUrl;
const topic = props.topic; const topic = props.topic;
const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic)); const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic));
@ -92,6 +96,8 @@ const SubscribePage = (props) => {
const handleSubscribe = async () => { const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined const user = await userManager.get(baseUrl); // May be undefined
const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous"); const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous");
// Check read access to topic
const success = await api.topicAuth(baseUrl, topic, user); const success = await api.topicAuth(baseUrl, topic, user);
if (!success) { if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
@ -103,6 +109,24 @@ const SubscribePage = (props) => {
return; return;
} }
} }
// Reserve topic (if requested)
if (session.exists() && baseUrl === config.baseUrl && reserveTopicVisible) {
console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`);
try {
await accountApi.upsertAccess(topic, everyone);
// Account sync later after it was added
} catch (e) {
console.log(`[SubscribeDialog] Error reserving topic`, e);
if ((e instanceof UnauthorizedError)) {
session.resetAndRedirect(routes.login);
} else if ((e instanceof TopicReservedError)) {
setErrorText(t("subscribe_dialog_error_topic_already_reserved"));
return;
}
}
}
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
props.onSuccess(); props.onSuccess();
}; };
@ -137,14 +161,7 @@ const SubscribePage = (props) => {
<DialogContentText> <DialogContentText>
{t("subscribe_dialog_subscribe_description")} {t("subscribe_dialog_subscribe_description")}
</DialogContentText> </DialogContentText>
<div style={{display: 'flex'}} role="row"> <div style={{display: 'flex', paddingBottom: "8px"}} role="row">
{session.exists() &&
<IconButton onClick={(ev) => setAccessAnchorEl(ev.currentTarget)} color="inherit" size="large" edge="start" sx={{height: "45px", marginTop: "5px", color: "grey"}}>
{access === "public" && <PublicIcon/>}
{access === "public-read" && <PublicOffIcon/>}
{access === "private" && <LockIcon/>}
</IconButton>
}
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
@ -168,19 +185,19 @@ const SubscribePage = (props) => {
open={!!accessAnchorEl} open={!!accessAnchorEl}
onClose={() => setAccessAnchorEl(null)} onClose={() => setAccessAnchorEl(null)}
> >
<MenuItem onClick={() => setAccess("private")} selected={access === "private"}> <MenuItem onClick={() => setEveryone("private")} selected={everyone === "private"}>
<ListItemIcon> <ListItemIcon>
<LockIcon fontSize="small" /> <LockIcon fontSize="small" />
</ListItemIcon> </ListItemIcon>
Only I can publish and subscribe Only I can publish and subscribe
</MenuItem> </MenuItem>
<MenuItem onClick={() => setAccess("public-read")} selected={access === "public-read"}> <MenuItem onClick={() => setEveryone("public-read")} selected={everyone === "public-read"}>
<ListItemIcon> <ListItemIcon>
<PublicOffIcon fontSize="small" /> <PublicOffIcon fontSize="small" />
</ListItemIcon> </ListItemIcon>
I can publish, everyone can subscribe I can publish, everyone can subscribe
</MenuItem> </MenuItem>
<MenuItem onClick={() => setAccess("public")} selected={access === "public"}> <MenuItem onClick={() => setEveryone("public")} selected={everyone === "public"}>
<ListItemIcon> <ListItemIcon>
<PublicIcon fontSize="small" /> <PublicIcon fontSize="small" />
</ListItemIcon> </ListItemIcon>
@ -188,8 +205,33 @@ const SubscribePage = (props) => {
</MenuItem> </MenuItem>
</PopupMenu> </PopupMenu>
</div> </div>
{session.exists() && !anotherServerVisible &&
<FormGroup>
<FormControlLabel
variant="standard"
control={
<Checkbox
fullWidth
checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{
"aria-label": t("subscription_settings_dialog_reserve_topic_label")
}}
/>
}
label={t("subscription_settings_dialog_reserve_topic_label")}
/>
{reserveTopicVisible &&
<ReserveTopicSelect
value={everyone}
onChange={setEveryone}
/>
}
</FormGroup>
}
{!reserveTopicVisible &&
<FormGroup>
<FormControlLabel <FormControlLabel
sx={{pt: 1}}
control={ control={
<Checkbox <Checkbox
onChange={handleUseAnotherChanged} onChange={handleUseAnotherChanged}
@ -198,14 +240,13 @@ const SubscribePage = (props) => {
}} }}
/> />
} }
label={t("subscribe_dialog_subscribe_use_another_label")} /> label={t("subscribe_dialog_subscribe_use_another_label")}/>
{anotherServerVisible && <Autocomplete {anotherServerVisible && <Autocomplete
freeSolo freeSolo
options={existingBaseUrls} options={existingBaseUrls}
sx={{ maxWidth: 400 }}
inputValue={props.baseUrl} inputValue={props.baseUrl}
onInputChange={updateBaseUrl} onInputChange={updateBaseUrl}
renderInput={ (params) => renderInput={(params) =>
<TextField <TextField
{...params} {...params}
placeholder={config.baseUrl} placeholder={config.baseUrl}
@ -214,6 +255,8 @@ const SubscribePage = (props) => {
/> />
} }
/>} />}
</FormGroup>
}
</DialogContent> </DialogContent>
<DialogFooter status={errorText}> <DialogFooter status={errorText}>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button> <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>

View file

@ -6,7 +6,7 @@ import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText'; import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import {Checkbox, FormControl, FormControlLabel, Select, useMediaQuery} from "@mui/material"; import {Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
import theme from "./theme"; import theme from "./theme";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
@ -14,11 +14,7 @@ import {useTranslation} from "react-i18next";
import accountApi, {UnauthorizedError} from "../app/AccountApi"; import accountApi, {UnauthorizedError} from "../app/AccountApi";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import MenuItem from "@mui/material/MenuItem"; import ReserveTopicSelect from "./ReserveTopicSelect";
import ListItemIcon from "@mui/material/ListItemIcon";
import LockIcon from "@mui/icons-material/Lock";
import ListItemText from "@mui/material/ListItemText";
import {Public, PublicOff} from "@mui/icons-material";
const SubscriptionSettingsDialog = (props) => { const SubscriptionSettingsDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -53,6 +49,8 @@ const SubscriptionSettingsDialog = (props) => {
if ((e instanceof UnauthorizedError)) { if ((e instanceof UnauthorizedError)) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} }
// FIXME handle 409
} }
} }
props.onClose(); props.onClose();
@ -80,7 +78,6 @@ const SubscriptionSettingsDialog = (props) => {
"aria-label": t("subscription_settings_dialog_display_name_placeholder") "aria-label": t("subscription_settings_dialog_display_name_placeholder")
}} }}
/> />
<FormControlLabel <FormControlLabel
fullWidth fullWidth
variant="standard" variant="standard"
@ -90,45 +87,17 @@ const SubscriptionSettingsDialog = (props) => {
checked={reserveTopicVisible} checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)} onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{ inputProps={{
"aria-label": t("xxxxxxxxxxxxxxxxxx") "aria-label": t("subscription_settings_dialog_reserve_topic_label")
}} }}
/> />
} }
label={t("Reserve topic and configure custom access:")} label={t("subscription_settings_dialog_reserve_topic_label")}
/> />
{reserveTopicVisible && {reserveTopicVisible &&
<FormControl variant="standard"> <ReserveTopicSelect
<Select
value={everyone} value={everyone}
onChange={(ev) => setEveryone(ev.target.value)} onChange={setEveryone}
aria-label={t("prefs_reservations_dialog_access_label")} />
sx={{
"& .MuiSelect-select": {
display: 'flex',
alignItems: 'center',
paddingTop: "4px",
paddingBottom: "4px",
}
}}
>
<MenuItem value="deny-all">
<ListItemIcon><LockIcon/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_deny_all")}/>
</MenuItem>
<MenuItem value="read-only">
<ListItemIcon><PublicOff/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_only")}/>
</MenuItem>
<MenuItem value="write-only">
<ListItemIcon><PublicOff/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_write_only")}/>
</MenuItem>
<MenuItem value="read-write">
<ListItemIcon><Public/></ListItemIcon>
<ListItemText primary={t("prefs_reservations_table_everyone_read_write")}/>
</MenuItem>
</Select>
</FormControl>
} }
</DialogContent> </DialogContent>
<DialogFooter> <DialogFooter>