diff --git a/server/config.go b/server/config.go index 633e1fed..85d47f8d 100644 --- a/server/config.go +++ b/server/config.go @@ -107,8 +107,8 @@ type Config struct { EnableSignup bool EnableLogin bool EnableEmailConfirm bool - EnableResetPassword bool - EnableAccountUpgrades bool + EnablePasswordReset bool + EnablePayments bool Version string // injected by App } diff --git a/server/server.go b/server/server.go index d45bd282..52ac1e5e 100644 --- a/server/server.go +++ b/server/server.go @@ -452,7 +452,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi AppRoot: appRoot, EnableLogin: s.config.EnableLogin, EnableSignup: s.config.EnableSignup, - EnableResetPassword: s.config.EnableResetPassword, + EnablePasswordReset: s.config.EnablePasswordReset, DisallowedTopics: disallowedTopics, } b, err := json.Marshal(response) diff --git a/server/server_account.go b/server/server_account.go index ce8a2921..cdd1c62b 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -80,18 +80,18 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis } if v.user.Plan != nil { response.Plan = &apiAccountPlan{ - Code: v.user.Plan.Code, - Upgradable: v.user.Plan.Upgradable, + Code: v.user.Plan.Code, + Upgradeable: v.user.Plan.Upgradeable, } } else if v.user.Role == user.RoleAdmin { response.Plan = &apiAccountPlan{ - Code: string(user.PlanUnlimited), - Upgradable: false, + Code: string(user.PlanUnlimited), + Upgradeable: false, } } else { response.Plan = &apiAccountPlan{ - Code: string(user.PlanDefault), - Upgradable: true, + Code: string(user.PlanDefault), + Upgradeable: true, } } reservations, err := s.userManager.Reservations(v.user.Name) @@ -111,8 +111,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis response.Username = user.Everyone response.Role = string(user.RoleAnonymous) response.Plan = &apiAccountPlan{ - Code: string(user.PlanNone), - Upgradable: true, + Code: string(user.PlanNone), + Upgradeable: true, } } w.Header().Set("Content-Type", "application/json") diff --git a/server/types.go b/server/types.go index 2a2db420..6fd1971e 100644 --- a/server/types.go +++ b/server/types.go @@ -235,8 +235,8 @@ type apiAccountTokenResponse struct { } type apiAccountPlan struct { - Code string `json:"code"` - Upgradable bool `json:"upgradable"` + Code string `json:"code"` + Upgradeable bool `json:"upgradeable"` } type apiAccountLimits struct { @@ -286,6 +286,7 @@ type apiConfigResponse struct { AppRoot string `json:"app_root"` EnableLogin bool `json:"enable_login"` EnableSignup bool `json:"enable_signup"` - EnableResetPassword bool `json:"enable_reset_password"` + EnablePasswordReset bool `json:"enable_password_reset"` + EnablePayments bool `json:"enable_payments"` DisallowedTopics []string `json:"disallowed_topics"` } diff --git a/user/manager.go b/user/manager.go index 3dd77a92..877f622c 100644 --- a/user/manager.go +++ b/user/manager.go @@ -503,7 +503,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { if planCode.Valid { user.Plan = &Plan{ Code: planCode.String, - Upgradable: true, // FIXME + Upgradeable: false, MessagesLimit: messagesLimit.Int64, EmailsLimit: emailsLimit.Int64, TopicsLimit: topicsLimit.Int64, diff --git a/user/types.go b/user/types.go index 9f8641db..f8c4e41f 100644 --- a/user/types.go +++ b/user/types.go @@ -56,7 +56,7 @@ const ( // Plan represents a user's account type, including its account limits type Plan struct { Code string `json:"name"` - Upgradable bool `json:"upgradable"` + Upgradeable bool `json:"upgradeable"` MessagesLimit int64 `json:"messages_limit"` EmailsLimit int64 `json:"emails_limit"` TopicsLimit int64 `json:"topics_limit"` diff --git a/web/public/config.js b/web/public/config.js index c25cddc2..e895b955 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -6,10 +6,11 @@ // During web development, you may change values here for rapid testing. var config = { - baseUrl: "http://localhost:2586", // window.location.origin FIXME update before merging - appRoot: "/app", - enableLogin: true, - enableSignup: true, - enableResetPassword: false, - disallowedTopics: ["docs", "static", "file", "app", "account", "settings", "pricing", "signup", "login", "reset-password"] + base_url: "http://localhost:2586", // window.location.origin FIXME update before merging + app_root: "/app", + enable_login: true, + enable_signup: true, + enable_password_reset: false, + enable_payments: true, + disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "pricing", "signup", "login", "reset-password"] }; diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 4ab4e018..b6de50b5 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -16,6 +16,7 @@ "action_bar_show_menu": "Show menu", "action_bar_logo_alt": "ntfy logo", "action_bar_settings": "Settings", + "action_bar_account": "Account", "action_bar_subscription_settings": "Subscription settings", "action_bar_send_test_notification": "Send test notification", "action_bar_clear_notifications": "Clear all notifications", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 338681de..5a50b893 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -34,7 +34,7 @@ class AccountApi { } async login(user) { - const url = accountTokenUrl(config.baseUrl); + const url = accountTokenUrl(config.base_url); console.log(`[AccountApi] Checking auth for ${url}`); const response = await fetch(url, { method: "POST", @@ -53,7 +53,7 @@ class AccountApi { } async logout() { - const url = accountTokenUrl(config.baseUrl); + const url = accountTokenUrl(config.base_url); console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`); const response = await fetch(url, { method: "DELETE", @@ -67,7 +67,7 @@ class AccountApi { } async create(username, password) { - const url = accountUrl(config.baseUrl); + const url = accountUrl(config.base_url); const body = JSON.stringify({ username: username, password: password @@ -87,7 +87,7 @@ class AccountApi { } async get() { - const url = accountUrl(config.baseUrl); + const url = accountUrl(config.base_url); console.log(`[AccountApi] Fetching user account ${url}`); const response = await fetch(url, { headers: withBearerAuth({}, session.token()) @@ -106,7 +106,7 @@ class AccountApi { } async delete() { - const url = accountUrl(config.baseUrl); + const url = accountUrl(config.base_url); console.log(`[AccountApi] Deleting user account ${url}`); const response = await fetch(url, { method: "DELETE", @@ -120,7 +120,7 @@ class AccountApi { } async changePassword(newPassword) { - const url = accountPasswordUrl(config.baseUrl); + const url = accountPasswordUrl(config.base_url); console.log(`[AccountApi] Changing account password ${url}`); const response = await fetch(url, { method: "POST", @@ -137,7 +137,7 @@ class AccountApi { } async extendToken() { - const url = accountTokenUrl(config.baseUrl); + const url = accountTokenUrl(config.base_url); console.log(`[AccountApi] Extending user access token ${url}`); const response = await fetch(url, { method: "PATCH", @@ -151,7 +151,7 @@ class AccountApi { } async updateSettings(payload) { - const url = accountSettingsUrl(config.baseUrl); + const url = accountSettingsUrl(config.base_url); const body = JSON.stringify(payload); console.log(`[AccountApi] Updating user account ${url}: ${body}`); const response = await fetch(url, { @@ -167,7 +167,7 @@ class AccountApi { } async addSubscription(payload) { - const url = accountSubscriptionUrl(config.baseUrl); + const url = accountSubscriptionUrl(config.base_url); const body = JSON.stringify(payload); console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); const response = await fetch(url, { @@ -186,7 +186,7 @@ class AccountApi { } async updateSubscription(remoteId, payload) { - const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId); + const url = accountSubscriptionSingleUrl(config.base_url, remoteId); const body = JSON.stringify(payload); console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); const response = await fetch(url, { @@ -205,7 +205,7 @@ class AccountApi { } async deleteSubscription(remoteId) { - const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId); + const url = accountSubscriptionSingleUrl(config.base_url, remoteId); console.log(`[AccountApi] Removing user subscription ${url}`); const response = await fetch(url, { method: "DELETE", @@ -219,7 +219,7 @@ class AccountApi { } async upsertAccess(topic, everyone) { - const url = accountAccessUrl(config.baseUrl); + const url = accountAccessUrl(config.base_url); console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`); const response = await fetch(url, { method: "POST", @@ -239,7 +239,7 @@ class AccountApi { } async deleteAccess(topic) { - const url = accountAccessSingleUrl(config.baseUrl, topic); + const url = accountAccessSingleUrl(config.base_url, topic); console.log(`[AccountApi] Removing topic reservation ${url}`); const response = await fetch(url, { method: "DELETE", diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 0733088b..2f283ffc 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -43,7 +43,7 @@ class SubscriptionManager { for (let i = 0; i < remoteSubscriptions.length; i++) { const remote = remoteSubscriptions[i]; const local = await this.add(remote.base_url, remote.topic); - const reservation = remoteReservations?.find(r => remote.base_url === config.baseUrl && remote.topic === r.topic) || null; + const reservation = remoteReservations?.find(r => remote.base_url === config.base_url && remote.topic === r.topic) || null; await this.setRemoteId(local.id, remote.id); await this.setDisplayName(local.id, remote.display_name); await this.setReservation(local.id, reservation); // May be null! diff --git a/web/src/app/UserManager.js b/web/src/app/UserManager.js index f22d3d6c..1e54eb0a 100644 --- a/web/src/app/UserManager.js +++ b/web/src/app/UserManager.js @@ -11,21 +11,21 @@ class UserManager { } async get(baseUrl) { - if (session.exists() && baseUrl === config.baseUrl) { + if (session.exists() && baseUrl === config.base_url) { return this.localUser(); } return db.users.get(baseUrl); } async save(user) { - if (session.exists() && user.baseUrl === config.baseUrl) { + if (session.exists() && user.baseUrl === config.base_url) { return; } await db.users.put(user); } async delete(baseUrl) { - if (session.exists() && baseUrl === config.baseUrl) { + if (session.exists() && baseUrl === config.base_url) { return; } await db.users.delete(baseUrl); @@ -36,7 +36,7 @@ class UserManager { return null; } return { - baseUrl: config.baseUrl, + baseUrl: config.base_url, username: session.username(), token: session.token() // Not "password"! }; diff --git a/web/src/app/utils.js b/web/src/app/utils.js index fdddab05..bdfbf0ce 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -42,13 +42,13 @@ export const validTopic = (topic) => { } export const disallowedTopic = (topic) => { - return config.disallowedTopics.includes(topic); + return config.disallowed_topics.includes(topic); } export const topicDisplayName = (subscription) => { if (subscription.displayName) { return subscription.displayName; - } else if (subscription.baseUrl === config.baseUrl) { + } else if (subscription.baseUrl === config.base_url) { return subscription.topic; } return topicShortUrl(subscription.baseUrl, subscription.topic); diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index a52a3be7..4ad40680 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -5,17 +5,12 @@ import IconButton from "@mui/material/IconButton"; import MenuIcon from "@mui/icons-material/Menu"; import Typography from "@mui/material/Typography"; import * as React from "react"; -import {useEffect, useRef, useState} from "react"; +import {useState} from "react"; import Box from "@mui/material/Box"; import {formatShortDateTime, shuffle, topicDisplayName} from "../app/utils"; import db from "../app/db"; import {useLocation, useNavigate} from "react-router-dom"; -import ClickAwayListener from '@mui/material/ClickAwayListener'; -import Grow from '@mui/material/Grow'; -import Paper from '@mui/material/Paper'; -import Popper from '@mui/material/Popper'; import MenuItem from '@mui/material/MenuItem'; -import MenuList from '@mui/material/MenuList'; import MoreVertIcon from "@mui/icons-material/MoreVert"; import NotificationsIcon from '@mui/icons-material/Notifications'; import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; @@ -24,7 +19,7 @@ import routes from "./routes"; import subscriptionManager from "../app/SubscriptionManager"; import logo from "../img/ntfy.svg"; import {useTranslation} from "react-i18next"; -import {Menu, Portal, Snackbar} from "@mui/material"; +import {Portal, Snackbar} from "@mui/material"; import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog"; import session from "../app/Session"; import AccountCircleIcon from '@mui/icons-material/AccountCircle'; @@ -41,8 +36,10 @@ const ActionBar = (props) => { let title = "ntfy"; if (props.selected) { title = topicDisplayName(props.selected); - } else if (location.pathname === "/settings") { + } else if (location.pathname === routes.settings) { title = t("action_bar_settings"); + } else if (location.pathname === routes.account) { + title = t("action_bar_account"); } return ( { } - {!session.exists() && config.enableLogin && + {!session.exists() && config.enable_login && } - {!session.exists() && config.enableSignup && + {!session.exists() && config.enable_signup && diff --git a/web/src/components/App.js b/web/src/components/App.js index f9b9dd27..9c7bfd0b 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -79,7 +79,7 @@ const Layout = () => { const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0; const [selected] = (subscriptions || []).filter(s => { return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) - || (config.baseUrl === s.baseUrl && params.topic === s.topic) + || (config.base_url === s.baseUrl && params.topic === s.topic) }); useConnectionListeners(subscriptions, users); @@ -95,6 +95,7 @@ const Layout = () => { onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} /> { } } }; - if (!config.enableLogin) { + if (!config.enable_login) { return ( {t("Login is disabled")} @@ -112,8 +112,8 @@ const Login = () => { } - {config.enableResetPassword &&
{t("Reset password")}
} - {config.enableSignup &&
{t("login_link_signup")}
} + {config.enable_password_reset &&
{t("Reset password")}
} + {config.enable_signup &&
{t("login_link_signup")}
}
diff --git a/web/src/components/Messaging.js b/web/src/components/Messaging.js index d4d89aa5..b1f11a96 100644 --- a/web/src/components/Messaging.js +++ b/web/src/components/Messaging.js @@ -38,7 +38,7 @@ const Messaging = (props) => { { navigate(routes.account); }; + const showUpgradeBanner = config.enable_payments && (!props.account || props.account.plan.upgradeable); const showSubscriptionsList = props.subscriptions?.length > 0; const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser @@ -123,14 +114,14 @@ const NavList = (props) => { {showNotificationContextNotSupportedBox && } {showNotificationGrantBox && } {!showSubscriptionsList && - navigate(routes.app)} selected={location.pathname === config.appRoot}> + navigate(routes.app)} selected={location.pathname === config.app_root}> } {showSubscriptionsList && <> {t("nav_topics_title")} - navigate(routes.app)} selected={location.pathname === config.appRoot}> + navigate(routes.app)} selected={location.pathname === config.app_root}> @@ -162,6 +153,34 @@ const NavList = (props) => { + {showUpgradeBanner && + + + setSubscribeDialogOpen(true)}> + + + + + + } { aria-label={t("prefs_users_table_user_header")}>{user.username} {user.baseUrl} - {(!session.exists() || user.baseUrl !== config.baseUrl) && + {(!session.exists() || user.baseUrl !== config.base_url) && <> handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> @@ -314,7 +314,7 @@ const UserTable = (props) => { } - {session.exists() && user.baseUrl === config.baseUrl && + {session.exists() && user.baseUrl === config.base_url && @@ -525,6 +525,9 @@ const Reservations = () => { {limitReached && You reached your reserved topics limit. + {config.enable_payments && + <>{" "}Upgrade + } } diff --git a/web/src/components/Signup.js b/web/src/components/Signup.js index 929cb2df..9665bd46 100644 --- a/web/src/components/Signup.js +++ b/web/src/components/Signup.js @@ -43,7 +43,7 @@ const Signup = () => { } } }; - if (!config.enableSignup) { + if (!config.enable_signup) { return ( {t("signup_disabled")} @@ -114,7 +114,7 @@ const Signup = () => { } - {config.enableLogin && + {config.enable_login && {t("signup_already_have_account")} diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index b5061ea1..ebd645e5 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -18,13 +18,8 @@ import {useTranslation} from "react-i18next"; import session from "../app/Session"; import routes from "./routes"; 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"; +import {useOutletContext} from "react-router-dom"; const publicBaseUrl = "https://ntfy.sh"; @@ -36,7 +31,7 @@ const SubscribeDialog = (props) => { const handleSuccess = async () => { console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); - const actualBaseUrl = (baseUrl) ? baseUrl : config.baseUrl; + const actualBaseUrl = (baseUrl) ? baseUrl : config.base_url; const subscription = await subscriptionManager.add(actualBaseUrl, topic); if (session.exists()) { try { @@ -81,17 +76,18 @@ const SubscribeDialog = (props) => { const SubscribePage = (props) => { const { t } = useTranslation(); + const { account } = useOutletContext(); const [reserveTopicVisible, setReserveTopicVisible] = useState(false); const [anotherServerVisible, setAnotherServerVisible] = useState(false); const [errorText, setErrorText] = useState(""); - const [accessAnchorEl, setAccessAnchorEl] = useState(null); const [everyone, setEveryone] = useState("deny-all"); - const baseUrl = (anotherServerVisible) ? props.baseUrl : config.baseUrl; + const baseUrl = (anotherServerVisible) ? props.baseUrl : config.base_url; const topic = props.topic; const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic)); const existingBaseUrls = Array .from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)])) - .filter(s => s !== config.baseUrl); + .filter(s => s !== config.base_url); + const reserveTopicEnabled = session.exists() && (account?.stats.topics_remaining || 0) > 0; const handleSubscribe = async () => { const user = await userManager.get(baseUrl); // May be undefined @@ -111,7 +107,7 @@ const SubscribePage = (props) => { } // Reserve topic (if requested) - if (session.exists() && baseUrl === config.baseUrl && reserveTopicVisible) { + if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) { console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`); try { await accountApi.upsertAccess(topic, everyone); @@ -141,7 +137,7 @@ const SubscribePage = (props) => { const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic)); return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; } else { - const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.baseUrl, topic)); + const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic)); return validTopic(topic) && !isExistingTopicUrl; } })(); @@ -180,30 +176,6 @@ const SubscribePage = (props) => { - setAccessAnchorEl(null)} - > - setEveryone("private")} selected={everyone === "private"}> - - - - Only I can publish and subscribe - - setEveryone("public-read")} selected={everyone === "public-read"}> - - - - I can publish, everyone can subscribe - - setEveryone("public")} selected={everyone === "public"}> - - - - Everyone can publish and subscribe - - {session.exists() && !anotherServerVisible && @@ -212,6 +184,7 @@ const SubscribePage = (props) => { control={ setReserveTopicVisible(ev.target.checked)} inputProps={{ @@ -249,7 +222,7 @@ const SubscribePage = (props) => { renderInput={(params) => @@ -271,7 +244,7 @@ const LoginPage = (props) => { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [errorText, setErrorText] = useState(""); - const baseUrl = (props.baseUrl) ? props.baseUrl : config.baseUrl; + const baseUrl = (props.baseUrl) ? props.baseUrl : config.base_url; const topic = props.topic; const handleLogin = async () => { const user = {baseUrl, username, password}; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index ce80c954..a9a7e2de 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -60,7 +60,7 @@ export const useAutoSubscribe = (subscriptions, selected) => { setHasRun(true); const eligible = params.topic && !selected && !disallowedTopic(params.topic); if (eligible) { - const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : config.baseUrl; + const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : config.base_url; console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); (async () => { const subscription = await subscriptionManager.add(baseUrl, params.topic); diff --git a/web/src/components/routes.js b/web/src/components/routes.js index 3e07f0fb..7f6589a2 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -9,13 +9,13 @@ const routes = { login: "/login", signup: "/signup", resetPassword: "/reset-password", - app: config.appRoot, + app: config.app_root, account: "/account", settings: "/settings", subscription: "/:topic", subscriptionExternal: "/:baseUrl/:topic", forSubscription: (subscription) => { - if (subscription.baseUrl !== config.baseUrl) { + if (subscription.baseUrl !== config.base_url) { return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; } return `/${subscription.topic}`;