diff --git a/server/config.go b/server/config.go index c3f93560..633e1fed 100644 --- a/server/config.go +++ b/server/config.go @@ -108,6 +108,7 @@ type Config struct { EnableLogin bool EnableEmailConfirm bool EnableResetPassword bool + EnableAccountUpgrades bool Version string // injected by App } diff --git a/server/server.go b/server/server.go index b4a79f0e..d45bd282 100644 --- a/server/server.go +++ b/server/server.go @@ -40,12 +40,12 @@ import ( message cache duration Keep 10000 messages or keep X days? Attachment expiration based on plan - reserve topics purge accounts that were not logged into in X reset daily limits for users - Account usage not updated "in real time" max token issue limit user db startup queries -> foreign keys + UI + - Feature flag for "reserve topic" feature Sync: - "mute" setting - 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 { 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") - _, err := io.WriteString(w, fmt.Sprintf(`// Generated server configuration -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)) + _, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b))) return err } diff --git a/server/types.go b/server/types.go index de5c452e..2a2db420 100644 --- a/server/types.go +++ b/server/types.go @@ -280,3 +280,12 @@ type apiAccountAccessRequest struct { Topic string `json:"topic"` 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"` +} diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 5b3e7235..4ab4e018 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -83,6 +83,7 @@ "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_display_name_placeholder": "Display name", + "subscription_settings_dialog_reserve_topic_label": "Reserve topic and configure access", "subscription_settings_button_cancel": "Cancel", "subscription_settings_button_save": "Save", "notifications_loading": "Loading notifications …", @@ -159,6 +160,7 @@ "subscribe_dialog_login_button_back": "Back", "subscribe_dialog_login_button_login": "Login", "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", "account_basics_title": "Account", "account_basics_username_title": "Username", @@ -253,6 +255,7 @@ "prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe", "prefs_reservations_dialog_title_add": "Reserve 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_access_label": "Access", "priority_min": "min", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 94f638d3..338681de 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -231,6 +231,8 @@ class AccountApi { }); if (response.status === 401 || response.status === 403) { throw new UnauthorizedError(); + } else if (response.status === 409) { + throw new TopicReservedError(); } else if (response.status !== 200) { 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 { constructor() { super("Account creation limit reached"); diff --git a/web/src/app/config.js b/web/src/app/config.js index 71a9ece3..0cb0bb1b 100644 --- a/web/src/app/config.js +++ b/web/src/app/config.js @@ -1,2 +1,7 @@ const config = window.config; + +if (config.base_url === "") { + config.base_url = window.location.origin; +} + export default config; diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 7ef175b1..1357f866 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -177,7 +177,7 @@ const Stats = () => {
- {account?.role === "admin" + {account.role === "admin" ? <>{t("account_usage_unlimited")} 👑 : t(`account_usage_plan_code_${planCode}`)}
@@ -187,28 +187,44 @@ const Stats = () => { {account.stats.topics} {account.limits.topics > 0 ? t("account_usage_of_limit", { limit: account.limits.topics }) : t("account_usage_unlimited")} - 0 ? normalize(account.stats.topics, account.limits.topics) : 100} /> + 0 ? normalize(account.stats.topics, account.limits.topics) : 100} + color={account?.role !== "admin" && account.stats.topics_remaining === 0 ? 'error' : 'primary'} + />
{account.stats.messages} {account.limits.messages > 0 ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}
- 0 ? normalize(account.stats.messages, account.limits.messages) : 100} /> + 0 ? normalize(account.stats.messages, account.limits.messages) : 100} + color={account?.role !== "admin" && account.stats.messages_remaining === 0 ? 'error' : 'primary'} + />
{account.stats.emails} {account.limits.emails > 0 ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}
- 0 ? normalize(account.stats.emails, account.limits.emails) : 100} /> + 0 ? normalize(account.stats.emails, account.limits.emails) : 100} + color={account?.role !== "admin" && account.stats.emails_remaining === 0 ? 'error' : 'primary'} + />
- +
{formatBytes(account.stats.attachment_total_size)} {account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}
- 0 ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100} /> + 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'} + />
{account.limits.basis === "ip" && diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 6b7162d1..90e0eb21 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -1,6 +1,7 @@ import * as React from 'react'; import {useEffect, useState} from 'react'; import { + Alert, CardActions, CardContent, FormControl, @@ -44,6 +45,8 @@ import LockIcon from "@mui/icons-material/Lock"; import {Public, PublicOff} from "@mui/icons-material"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; +import DialogContentText from "@mui/material/DialogContentText"; +import ReserveTopicSelect from "./ReserveTopicSelect"; const Preferences = () => { return ( @@ -482,10 +485,11 @@ const Reservations = () => { const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); - if (!session.exists() || !account) { + if (!session.exists() || !account || account.role === "admin") { return <>; } const reservations = account.reservations || []; + const limitReached = account.role === "user" && account.stats.topics_remaining === 0; const handleAddClick = () => { setDialogKey(prev => prev+1); @@ -505,7 +509,7 @@ const Reservations = () => { } catch (e) { console.log(`[Preferences] Error topic reservation.`, e); } - // FIXME handle 401/403 + // FIXME handle 401/403/409 }; return ( @@ -518,9 +522,15 @@ const Reservations = () => { {t("prefs_reservations_description")} {reservations.length > 0 && } + {limitReached && + + You reached your reserved topics limit. + + } - + + { } catch (e) { console.log(`[Preferences] Error topic reservation.`, e); } - // FIXME handle 401/403 + // FIXME handle 401/403/409 }; const handleDeleteClick = async (reservation) => { @@ -670,6 +680,9 @@ const ReservationsDialog = (props) => { {editMode ? t("prefs_reservations_dialog_title_edit") : t("prefs_reservations_dialog_title_add")} + + {t("prefs_reservations_dialog_description")} + {!editMode && { fullWidth variant="standard" />} - - - + diff --git a/web/src/components/ReserveTopicSelect.js b/web/src/components/ReserveTopicSelect.js new file mode 100644 index 00000000..e9ca91d0 --- /dev/null +++ b/web/src/components/ReserveTopicSelect.js @@ -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 ( + + + + ); +}; + +export default ReserveTopicSelect; diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index f5add414..b5061ea1 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -6,7 +6,7 @@ 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 {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material"; +import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material"; import theme from "./theme"; import api from "../app/Api"; import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils"; @@ -17,14 +17,14 @@ import DialogFooter from "./DialogFooter"; 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 accountApi, {TopicReservedError, UnauthorizedError} from "../app/AccountApi"; import PublicIcon from '@mui/icons-material/Public'; import LockIcon from '@mui/icons-material/Lock'; import PublicOffIcon from '@mui/icons-material/PublicOff'; import MenuItem from "@mui/material/MenuItem"; import PopupMenu from "./PopupMenu"; import ListItemIcon from "@mui/material/ListItemIcon"; +import ReserveTopicSelect from "./ReserveTopicSelect"; const publicBaseUrl = "https://ntfy.sh"; @@ -33,6 +33,7 @@ const SubscribeDialog = (props) => { const [topic, setTopic] = useState(""); const [showLoginPage, setShowLoginPage] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const handleSuccess = async () => { console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); const actualBaseUrl = (baseUrl) ? baseUrl : config.baseUrl; @@ -44,6 +45,7 @@ const SubscribeDialog = (props) => { topic: topic }); await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); + await accountApi.sync(); } catch (e) { console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); if ((e instanceof UnauthorizedError)) { @@ -54,6 +56,7 @@ const SubscribeDialog = (props) => { poller.pollInBackground(subscription); // Dangle! props.onSuccess(subscription); } + return ( {!showLoginPage && { const SubscribePage = (props) => { const { t } = useTranslation(); + const [reserveTopicVisible, setReserveTopicVisible] = useState(false); const [anotherServerVisible, setAnotherServerVisible] = useState(false); const [errorText, setErrorText] = useState(""); const [accessAnchorEl, setAccessAnchorEl] = useState(null); - const [access, setAccess] = useState("public"); + const [everyone, setEveryone] = useState("deny-all"); const baseUrl = (anotherServerVisible) ? props.baseUrl : config.baseUrl; const topic = props.topic; const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic)); @@ -92,6 +96,8 @@ const SubscribePage = (props) => { const handleSubscribe = async () => { const user = await userManager.get(baseUrl); // May be undefined const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous"); + + // Check read access to topic const success = await api.topicAuth(baseUrl, topic, user); if (!success) { console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); @@ -103,6 +109,24 @@ const SubscribePage = (props) => { 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}`); props.onSuccess(); }; @@ -137,14 +161,7 @@ const SubscribePage = (props) => { {t("subscribe_dialog_subscribe_description")} -
- {session.exists() && - setAccessAnchorEl(ev.currentTarget)} color="inherit" size="large" edge="start" sx={{height: "45px", marginTop: "5px", color: "grey"}}> - {access === "public" && } - {access === "public-read" && } - {access === "private" && } - - } +
{ open={!!accessAnchorEl} onClose={() => setAccessAnchorEl(null)} > - setAccess("private")} selected={access === "private"}> + setEveryone("private")} selected={everyone === "private"}> Only I can publish and subscribe - setAccess("public-read")} selected={access === "public-read"}> + setEveryone("public-read")} selected={everyone === "public-read"}> I can publish, everyone can subscribe - setAccess("public")} selected={access === "public"}> + setEveryone("public")} selected={everyone === "public"}> @@ -188,32 +205,58 @@ const SubscribePage = (props) => {
- - } - label={t("subscribe_dialog_subscribe_use_another_label")} /> - {anotherServerVisible && - + setReserveTopicVisible(ev.target.checked)} + inputProps={{ + "aria-label": t("subscription_settings_dialog_reserve_topic_label") + }} + /> + } + label={t("subscription_settings_dialog_reserve_topic_label")} /> - } - />} + {reserveTopicVisible && + + } + + } + {!reserveTopicVisible && + + + } + label={t("subscribe_dialog_subscribe_use_another_label")}/> + {anotherServerVisible && + + } + />} + + } diff --git a/web/src/components/SubscriptionSettingsDialog.js b/web/src/components/SubscriptionSettingsDialog.js index f6f5125a..7592262f 100644 --- a/web/src/components/SubscriptionSettingsDialog.js +++ b/web/src/components/SubscriptionSettingsDialog.js @@ -6,7 +6,7 @@ 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 {Checkbox, FormControlLabel, useMediaQuery} from "@mui/material"; import theme from "./theme"; import subscriptionManager from "../app/SubscriptionManager"; import DialogFooter from "./DialogFooter"; @@ -14,11 +14,7 @@ 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"; +import ReserveTopicSelect from "./ReserveTopicSelect"; const SubscriptionSettingsDialog = (props) => { const { t } = useTranslation(); @@ -53,6 +49,8 @@ const SubscriptionSettingsDialog = (props) => { if ((e instanceof UnauthorizedError)) { session.resetAndRedirect(routes.login); } + + // FIXME handle 409 } } props.onClose(); @@ -80,7 +78,6 @@ const SubscriptionSettingsDialog = (props) => { "aria-label": t("subscription_settings_dialog_display_name_placeholder") }} /> - { checked={reserveTopicVisible} onChange={(ev) => setReserveTopicVisible(ev.target.checked)} 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 && - - - + }