Upgrade banner

This commit is contained in:
binwiederhier 2023-01-04 22:47:12 -05:00
parent a91da7cf2c
commit 3280c2c440
22 changed files with 114 additions and 118 deletions

View File

@ -107,8 +107,8 @@ type Config struct {
EnableSignup bool EnableSignup bool
EnableLogin bool EnableLogin bool
EnableEmailConfirm bool EnableEmailConfirm bool
EnableResetPassword bool EnablePasswordReset bool
EnableAccountUpgrades bool EnablePayments bool
Version string // injected by App Version string // injected by App
} }

View File

@ -452,7 +452,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
AppRoot: appRoot, AppRoot: appRoot,
EnableLogin: s.config.EnableLogin, EnableLogin: s.config.EnableLogin,
EnableSignup: s.config.EnableSignup, EnableSignup: s.config.EnableSignup,
EnableResetPassword: s.config.EnableResetPassword, EnablePasswordReset: s.config.EnablePasswordReset,
DisallowedTopics: disallowedTopics, DisallowedTopics: disallowedTopics,
} }
b, err := json.Marshal(response) b, err := json.Marshal(response)

View File

@ -80,18 +80,18 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
} }
if v.user.Plan != nil { if v.user.Plan != nil {
response.Plan = &apiAccountPlan{ response.Plan = &apiAccountPlan{
Code: v.user.Plan.Code, Code: v.user.Plan.Code,
Upgradable: v.user.Plan.Upgradable, Upgradeable: v.user.Plan.Upgradeable,
} }
} else if v.user.Role == user.RoleAdmin { } else if v.user.Role == user.RoleAdmin {
response.Plan = &apiAccountPlan{ response.Plan = &apiAccountPlan{
Code: string(user.PlanUnlimited), Code: string(user.PlanUnlimited),
Upgradable: false, Upgradeable: false,
} }
} else { } else {
response.Plan = &apiAccountPlan{ response.Plan = &apiAccountPlan{
Code: string(user.PlanDefault), Code: string(user.PlanDefault),
Upgradable: true, Upgradeable: true,
} }
} }
reservations, err := s.userManager.Reservations(v.user.Name) 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.Username = user.Everyone
response.Role = string(user.RoleAnonymous) response.Role = string(user.RoleAnonymous)
response.Plan = &apiAccountPlan{ response.Plan = &apiAccountPlan{
Code: string(user.PlanNone), Code: string(user.PlanNone),
Upgradable: true, Upgradeable: true,
} }
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View File

@ -235,8 +235,8 @@ type apiAccountTokenResponse struct {
} }
type apiAccountPlan struct { type apiAccountPlan struct {
Code string `json:"code"` Code string `json:"code"`
Upgradable bool `json:"upgradable"` Upgradeable bool `json:"upgradeable"`
} }
type apiAccountLimits struct { type apiAccountLimits struct {
@ -286,6 +286,7 @@ type apiConfigResponse struct {
AppRoot string `json:"app_root"` AppRoot string `json:"app_root"`
EnableLogin bool `json:"enable_login"` EnableLogin bool `json:"enable_login"`
EnableSignup bool `json:"enable_signup"` 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"` DisallowedTopics []string `json:"disallowed_topics"`
} }

View File

@ -503,7 +503,7 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
if planCode.Valid { if planCode.Valid {
user.Plan = &Plan{ user.Plan = &Plan{
Code: planCode.String, Code: planCode.String,
Upgradable: true, // FIXME Upgradeable: false,
MessagesLimit: messagesLimit.Int64, MessagesLimit: messagesLimit.Int64,
EmailsLimit: emailsLimit.Int64, EmailsLimit: emailsLimit.Int64,
TopicsLimit: topicsLimit.Int64, TopicsLimit: topicsLimit.Int64,

View File

@ -56,7 +56,7 @@ const (
// Plan represents a user's account type, including its account limits // Plan represents a user's account type, including its account limits
type Plan struct { type Plan struct {
Code string `json:"name"` Code string `json:"name"`
Upgradable bool `json:"upgradable"` Upgradeable bool `json:"upgradeable"`
MessagesLimit int64 `json:"messages_limit"` MessagesLimit int64 `json:"messages_limit"`
EmailsLimit int64 `json:"emails_limit"` EmailsLimit int64 `json:"emails_limit"`
TopicsLimit int64 `json:"topics_limit"` TopicsLimit int64 `json:"topics_limit"`

View File

@ -6,10 +6,11 @@
// During web development, you may change values here for rapid testing. // During web development, you may change values here for rapid testing.
var config = { var config = {
baseUrl: "http://localhost:2586", // window.location.origin FIXME update before merging base_url: "http://localhost:2586", // window.location.origin FIXME update before merging
appRoot: "/app", app_root: "/app",
enableLogin: true, enable_login: true,
enableSignup: true, enable_signup: true,
enableResetPassword: false, enable_password_reset: false,
disallowedTopics: ["docs", "static", "file", "app", "account", "settings", "pricing", "signup", "login", "reset-password"] enable_payments: true,
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "pricing", "signup", "login", "reset-password"]
}; };

View File

@ -16,6 +16,7 @@
"action_bar_show_menu": "Show menu", "action_bar_show_menu": "Show menu",
"action_bar_logo_alt": "ntfy logo", "action_bar_logo_alt": "ntfy logo",
"action_bar_settings": "Settings", "action_bar_settings": "Settings",
"action_bar_account": "Account",
"action_bar_subscription_settings": "Subscription settings", "action_bar_subscription_settings": "Subscription settings",
"action_bar_send_test_notification": "Send test notification", "action_bar_send_test_notification": "Send test notification",
"action_bar_clear_notifications": "Clear all notifications", "action_bar_clear_notifications": "Clear all notifications",

View File

@ -34,7 +34,7 @@ class AccountApi {
} }
async login(user) { async login(user) {
const url = accountTokenUrl(config.baseUrl); const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Checking auth for ${url}`); console.log(`[AccountApi] Checking auth for ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
@ -53,7 +53,7 @@ class AccountApi {
} }
async logout() { async logout() {
const url = accountTokenUrl(config.baseUrl); const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`); console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",
@ -67,7 +67,7 @@ class AccountApi {
} }
async create(username, password) { async create(username, password) {
const url = accountUrl(config.baseUrl); const url = accountUrl(config.base_url);
const body = JSON.stringify({ const body = JSON.stringify({
username: username, username: username,
password: password password: password
@ -87,7 +87,7 @@ class AccountApi {
} }
async get() { async get() {
const url = accountUrl(config.baseUrl); const url = accountUrl(config.base_url);
console.log(`[AccountApi] Fetching user account ${url}`); console.log(`[AccountApi] Fetching user account ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
headers: withBearerAuth({}, session.token()) headers: withBearerAuth({}, session.token())
@ -106,7 +106,7 @@ class AccountApi {
} }
async delete() { async delete() {
const url = accountUrl(config.baseUrl); const url = accountUrl(config.base_url);
console.log(`[AccountApi] Deleting user account ${url}`); console.log(`[AccountApi] Deleting user account ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",
@ -120,7 +120,7 @@ class AccountApi {
} }
async changePassword(newPassword) { async changePassword(newPassword) {
const url = accountPasswordUrl(config.baseUrl); const url = accountPasswordUrl(config.base_url);
console.log(`[AccountApi] Changing account password ${url}`); console.log(`[AccountApi] Changing account password ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
@ -137,7 +137,7 @@ class AccountApi {
} }
async extendToken() { async extendToken() {
const url = accountTokenUrl(config.baseUrl); const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Extending user access token ${url}`); console.log(`[AccountApi] Extending user access token ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "PATCH", method: "PATCH",
@ -151,7 +151,7 @@ class AccountApi {
} }
async updateSettings(payload) { async updateSettings(payload) {
const url = accountSettingsUrl(config.baseUrl); const url = accountSettingsUrl(config.base_url);
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
console.log(`[AccountApi] Updating user account ${url}: ${body}`); console.log(`[AccountApi] Updating user account ${url}: ${body}`);
const response = await fetch(url, { const response = await fetch(url, {
@ -167,7 +167,7 @@ class AccountApi {
} }
async addSubscription(payload) { async addSubscription(payload) {
const url = accountSubscriptionUrl(config.baseUrl); const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
const response = await fetch(url, { const response = await fetch(url, {
@ -186,7 +186,7 @@ class AccountApi {
} }
async updateSubscription(remoteId, payload) { async updateSubscription(remoteId, payload) {
const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId); const url = accountSubscriptionSingleUrl(config.base_url, remoteId);
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
const response = await fetch(url, { const response = await fetch(url, {
@ -205,7 +205,7 @@ class AccountApi {
} }
async deleteSubscription(remoteId) { async deleteSubscription(remoteId) {
const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId); const url = accountSubscriptionSingleUrl(config.base_url, remoteId);
console.log(`[AccountApi] Removing user subscription ${url}`); console.log(`[AccountApi] Removing user subscription ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",
@ -219,7 +219,7 @@ class AccountApi {
} }
async upsertAccess(topic, everyone) { 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}`); console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
@ -239,7 +239,7 @@ class AccountApi {
} }
async deleteAccess(topic) { async deleteAccess(topic) {
const url = accountAccessSingleUrl(config.baseUrl, topic); const url = accountAccessSingleUrl(config.base_url, topic);
console.log(`[AccountApi] Removing topic reservation ${url}`); console.log(`[AccountApi] Removing topic reservation ${url}`);
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",

View File

@ -43,7 +43,7 @@ class SubscriptionManager {
for (let i = 0; i < remoteSubscriptions.length; i++) { for (let i = 0; i < remoteSubscriptions.length; i++) {
const remote = remoteSubscriptions[i]; const remote = remoteSubscriptions[i];
const local = await this.add(remote.base_url, remote.topic); 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.setRemoteId(local.id, remote.id);
await this.setDisplayName(local.id, remote.display_name); await this.setDisplayName(local.id, remote.display_name);
await this.setReservation(local.id, reservation); // May be null! await this.setReservation(local.id, reservation); // May be null!

View File

@ -11,21 +11,21 @@ class UserManager {
} }
async get(baseUrl) { async get(baseUrl) {
if (session.exists() && baseUrl === config.baseUrl) { if (session.exists() && baseUrl === config.base_url) {
return this.localUser(); return this.localUser();
} }
return db.users.get(baseUrl); return db.users.get(baseUrl);
} }
async save(user) { async save(user) {
if (session.exists() && user.baseUrl === config.baseUrl) { if (session.exists() && user.baseUrl === config.base_url) {
return; return;
} }
await db.users.put(user); await db.users.put(user);
} }
async delete(baseUrl) { async delete(baseUrl) {
if (session.exists() && baseUrl === config.baseUrl) { if (session.exists() && baseUrl === config.base_url) {
return; return;
} }
await db.users.delete(baseUrl); await db.users.delete(baseUrl);
@ -36,7 +36,7 @@ class UserManager {
return null; return null;
} }
return { return {
baseUrl: config.baseUrl, baseUrl: config.base_url,
username: session.username(), username: session.username(),
token: session.token() // Not "password"! token: session.token() // Not "password"!
}; };

View File

@ -42,13 +42,13 @@ export const validTopic = (topic) => {
} }
export const disallowedTopic = (topic) => { export const disallowedTopic = (topic) => {
return config.disallowedTopics.includes(topic); return config.disallowed_topics.includes(topic);
} }
export const topicDisplayName = (subscription) => { export const topicDisplayName = (subscription) => {
if (subscription.displayName) { if (subscription.displayName) {
return subscription.displayName; return subscription.displayName;
} else if (subscription.baseUrl === config.baseUrl) { } else if (subscription.baseUrl === config.base_url) {
return subscription.topic; return subscription.topic;
} }
return topicShortUrl(subscription.baseUrl, subscription.topic); return topicShortUrl(subscription.baseUrl, subscription.topic);

View File

@ -5,17 +5,12 @@ import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu"; import MenuIcon from "@mui/icons-material/Menu";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import * as React from "react"; import * as React from "react";
import {useEffect, useRef, useState} from "react"; import {useState} from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import {formatShortDateTime, shuffle, topicDisplayName} from "../app/utils"; import {formatShortDateTime, shuffle, topicDisplayName} from "../app/utils";
import db from "../app/db"; import db from "../app/db";
import {useLocation, useNavigate} from "react-router-dom"; 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 MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from '@mui/icons-material/Notifications'; import NotificationsIcon from '@mui/icons-material/Notifications';
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
@ -24,7 +19,7 @@ import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg"; import logo from "../img/ntfy.svg";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {Menu, Portal, Snackbar} from "@mui/material"; import {Portal, Snackbar} from "@mui/material";
import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog"; import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog";
import session from "../app/Session"; import session from "../app/Session";
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AccountCircleIcon from '@mui/icons-material/AccountCircle';
@ -41,8 +36,10 @@ const ActionBar = (props) => {
let title = "ntfy"; let title = "ntfy";
if (props.selected) { if (props.selected) {
title = topicDisplayName(props.selected); title = topicDisplayName(props.selected);
} else if (location.pathname === "/settings") { } else if (location.pathname === routes.settings) {
title = t("action_bar_settings"); title = t("action_bar_settings");
} else if (location.pathname === routes.account) {
title = t("action_bar_account");
} }
return ( return (
<AppBar position="fixed" sx={{ <AppBar position="fixed" sx={{
@ -250,12 +247,12 @@ const ProfileIcon = () => {
<AccountCircleIcon/> <AccountCircleIcon/>
</IconButton> </IconButton>
} }
{!session.exists() && config.enableLogin && {!session.exists() && config.enable_login &&
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{m: 1}} aria-label={t("action_bar_sign_in")}> <Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{m: 1}} aria-label={t("action_bar_sign_in")}>
{t("action_bar_sign_in")} {t("action_bar_sign_in")}
</Button> </Button>
} }
{!session.exists() && config.enableSignup && {!session.exists() && config.enable_signup &&
<Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}> <Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}>
{t("action_bar_sign_up")} {t("action_bar_sign_up")}
</Button> </Button>

View File

@ -79,7 +79,7 @@ const Layout = () => {
const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0; const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptions || []).filter(s => { const [selected] = (subscriptions || []).filter(s => {
return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) 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); useConnectionListeners(subscriptions, users);
@ -95,6 +95,7 @@ const Layout = () => {
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
/> />
<Navigation <Navigation
account={account}
subscriptions={subscriptions} subscriptions={subscriptions}
selectedSubscription={selected} selectedSubscription={selected}
notificationsGranted={notificationsGranted} notificationsGranted={notificationsGranted}

View File

@ -41,7 +41,7 @@ const Login = () => {
} }
} }
}; };
if (!config.enableLogin) { if (!config.enable_login) {
return ( return (
<AvatarBox> <AvatarBox>
<Typography sx={{ typography: 'h6' }}>{t("Login is disabled")}</Typography> <Typography sx={{ typography: 'h6' }}>{t("Login is disabled")}</Typography>
@ -112,8 +112,8 @@ const Login = () => {
</Box> </Box>
} }
<Box sx={{width: "100%"}}> <Box sx={{width: "100%"}}>
{config.enableResetPassword && <div style={{float: "left"}}><NavLink to={routes.resetPassword} variant="body1">{t("Reset password")}</NavLink></div>} {config.enable_password_reset && <div style={{float: "left"}}><NavLink to={routes.resetPassword} variant="body1">{t("Reset password")}</NavLink></div>}
{config.enableSignup && <div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">{t("login_link_signup")}</NavLink></div>} {config.enable_signup && <div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">{t("login_link_signup")}</NavLink></div>}
</Box> </Box>
</Box> </Box>
</AvatarBox> </AvatarBox>

View File

@ -38,7 +38,7 @@ const Messaging = (props) => {
<PublishDialog <PublishDialog
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode} openMode={dialogOpenMode}
baseUrl={subscription?.baseUrl ?? config.baseUrl} baseUrl={subscription?.baseUrl ?? config.base_url}
topic={subscription?.topic ?? ""} topic={subscription?.topic ?? ""}
message={message} message={message}
onClose={handleDialogClose} onClose={handleDialogClose}

View File

@ -12,24 +12,15 @@ import List from "@mui/material/List";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import SubscribeDialog from "./SubscribeDialog"; import SubscribeDialog from "./SubscribeDialog";
import { import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Tooltip} from "@mui/material";
Alert,
AlertTitle,
Badge,
CircularProgress,
Link,
ListItem,
ListItemSecondaryAction,
ListSubheader, Tooltip
} from "@mui/material";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import {openUrl, topicDisplayName, topicUrl} from "../app/utils"; import {openUrl, topicDisplayName, topicUrl} from "../app/utils";
import routes from "./routes"; import routes from "./routes";
import {ConnectionState} from "../app/Connection"; import {ConnectionState} from "../app/Connection";
import {useLocation, useNavigate, useOutletContext} from "react-router-dom"; import {useLocation, useNavigate} from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import {ChatBubble, Lock, MoreVert, NotificationsOffOutlined, Public, PublicOff, Send} from "@mui/icons-material"; import {ChatBubble, Lock, NotificationsOffOutlined, Public, PublicOff, Send} from "@mui/icons-material";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import notifier from "../app/Notifier"; import notifier from "../app/Notifier";
import config from "../app/config"; import config from "../app/config";
@ -37,8 +28,7 @@ import ArticleIcon from '@mui/icons-material/Article';
import {Trans, useTranslation} from "react-i18next"; import {Trans, useTranslation} from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import IconButton from "@mui/material/IconButton"; import CelebrationIcon from '@mui/icons-material/Celebration';
import CloseIcon from "@mui/icons-material/Close";
const navWidth = 280; const navWidth = 280;
@ -109,6 +99,7 @@ const NavList = (props) => {
navigate(routes.account); navigate(routes.account);
}; };
const showUpgradeBanner = config.enable_payments && (!props.account || props.account.plan.upgradeable);
const showSubscriptionsList = props.subscriptions?.length > 0; const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
@ -123,14 +114,14 @@ const NavList = (props) => {
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>} {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>} {showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
{!showSubscriptionsList && {!showSubscriptionsList &&
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.appRoot}> <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
<ListItemIcon><ChatBubble/></ListItemIcon> <ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")}/> <ListItemText primary={t("nav_button_all_notifications")}/>
</ListItemButton>} </ListItemButton>}
{showSubscriptionsList && {showSubscriptionsList &&
<> <>
<ListSubheader>{t("nav_topics_title")}</ListSubheader> <ListSubheader>{t("nav_topics_title")}</ListSubheader>
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.appRoot}> <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
<ListItemIcon><ChatBubble/></ListItemIcon> <ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemText primary={t("nav_button_all_notifications")}/> <ListItemText primary={t("nav_button_all_notifications")}/>
</ListItemButton> </ListItemButton>
@ -162,6 +153,34 @@ const NavList = (props) => {
<ListItemIcon><AddIcon/></ListItemIcon> <ListItemIcon><AddIcon/></ListItemIcon>
<ListItemText primary={t("nav_button_subscribe")}/> <ListItemText primary={t("nav_button_subscribe")}/>
</ListItemButton> </ListItemButton>
{showUpgradeBanner &&
<Box sx={{
position: "fixed",
width: `${Navigation.width - 1}px`,
bottom: 0,
mt: 'auto',
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
}}>
<Divider/>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon><CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large"/></ListItemIcon>
<ListItemText
sx={{ ml: 1 }}
primary={"Upgrade to ntfy Pro"}
secondary={"Reserve topics, more messages & emails, bigger attachments"}
primaryTypographyProps={{
style: {
fontWeight: 500,
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent"
}
}}
/>
</ListItemButton>
</Box>
}
</List> </List>
<SubscribeDialog <SubscribeDialog
key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed

View File

@ -304,7 +304,7 @@ 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">
{(!session.exists() || user.baseUrl !== config.baseUrl) && {(!session.exists() || user.baseUrl !== config.base_url) &&
<> <>
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> <IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
<EditIcon/> <EditIcon/>
@ -314,7 +314,7 @@ const UserTable = (props) => {
</IconButton> </IconButton>
</> </>
} }
{session.exists() && user.baseUrl === config.baseUrl && {session.exists() && user.baseUrl === config.base_url &&
<Tooltip title={t("prefs_users_table_cannot_delete_or_edit")}> <Tooltip title={t("prefs_users_table_cannot_delete_or_edit")}>
<span> <span>
<IconButton disabled><EditIcon/></IconButton> <IconButton disabled><EditIcon/></IconButton>
@ -525,6 +525,9 @@ const Reservations = () => {
{limitReached && {limitReached &&
<Alert severity="info"> <Alert severity="info">
You reached your reserved topics limit. You reached your reserved topics limit.
{config.enable_payments &&
<>{" "}<b>Upgrade</b></>
}
</Alert> </Alert>
} }
</CardContent> </CardContent>

View File

@ -43,7 +43,7 @@ const Signup = () => {
} }
} }
}; };
if (!config.enableSignup) { if (!config.enable_signup) {
return ( return (
<AvatarBox> <AvatarBox>
<Typography sx={{ typography: 'h6' }}>{t("signup_disabled")}</Typography> <Typography sx={{ typography: 'h6' }}>{t("signup_disabled")}</Typography>
@ -114,7 +114,7 @@ const Signup = () => {
</Box> </Box>
} }
</Box> </Box>
{config.enableLogin && {config.enable_login &&
<Typography sx={{mb: 4}}> <Typography sx={{mb: 4}}>
<NavLink to={routes.login} variant="body1"> <NavLink to={routes.login} variant="body1">
{t("signup_already_have_account")} {t("signup_already_have_account")}

View File

@ -18,13 +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, {TopicReservedError, UnauthorizedError} from "../app/AccountApi"; 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 ReserveTopicSelect from "./ReserveTopicSelect";
import {useOutletContext} from "react-router-dom";
const publicBaseUrl = "https://ntfy.sh"; const publicBaseUrl = "https://ntfy.sh";
@ -36,7 +31,7 @@ const SubscribeDialog = (props) => {
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.base_url;
const subscription = await subscriptionManager.add(actualBaseUrl, topic); const subscription = await subscriptionManager.add(actualBaseUrl, topic);
if (session.exists()) { if (session.exists()) {
try { try {
@ -81,17 +76,18 @@ const SubscribeDialog = (props) => {
const SubscribePage = (props) => { const SubscribePage = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { account } = useOutletContext();
const [reserveTopicVisible, setReserveTopicVisible] = useState(false); 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 [everyone, setEveryone] = useState("deny-all"); 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 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));
const existingBaseUrls = Array const existingBaseUrls = Array
.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)])) .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 handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined const user = await userManager.get(baseUrl); // May be undefined
@ -111,7 +107,7 @@ const SubscribePage = (props) => {
} }
// Reserve topic (if requested) // 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}`); console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`);
try { try {
await accountApi.upsertAccess(topic, everyone); await accountApi.upsertAccess(topic, everyone);
@ -141,7 +137,7 @@ const SubscribePage = (props) => {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic)); const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
} else { } else {
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.baseUrl, topic)); const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
return validTopic(topic) && !isExistingTopicUrl; return validTopic(topic) && !isExistingTopicUrl;
} }
})(); })();
@ -180,30 +176,6 @@ const SubscribePage = (props) => {
<Button onClick={() => {props.setTopic(randomAlphanumericString(16))}} style={{flexShrink: "0", marginTop: "0.5em"}}> <Button onClick={() => {props.setTopic(randomAlphanumericString(16))}} style={{flexShrink: "0", marginTop: "0.5em"}}>
{t("subscribe_dialog_subscribe_button_generate_topic_name")} {t("subscribe_dialog_subscribe_button_generate_topic_name")}
</Button> </Button>
<PopupMenu
anchorEl={accessAnchorEl}
open={!!accessAnchorEl}
onClose={() => setAccessAnchorEl(null)}
>
<MenuItem onClick={() => setEveryone("private")} selected={everyone === "private"}>
<ListItemIcon>
<LockIcon fontSize="small" />
</ListItemIcon>
Only I can publish and subscribe
</MenuItem>
<MenuItem onClick={() => setEveryone("public-read")} selected={everyone === "public-read"}>
<ListItemIcon>
<PublicOffIcon fontSize="small" />
</ListItemIcon>
I can publish, everyone can subscribe
</MenuItem>
<MenuItem onClick={() => setEveryone("public")} selected={everyone === "public"}>
<ListItemIcon>
<PublicIcon fontSize="small" />
</ListItemIcon>
Everyone can publish and subscribe
</MenuItem>
</PopupMenu>
</div> </div>
{session.exists() && !anotherServerVisible && {session.exists() && !anotherServerVisible &&
<FormGroup> <FormGroup>
@ -212,6 +184,7 @@ const SubscribePage = (props) => {
control={ control={
<Checkbox <Checkbox
fullWidth fullWidth
disabled={account.stats.topics_remaining}
checked={reserveTopicVisible} checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)} onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{ inputProps={{
@ -249,7 +222,7 @@ const SubscribePage = (props) => {
renderInput={(params) => renderInput={(params) =>
<TextField <TextField
{...params} {...params}
placeholder={config.baseUrl} placeholder={config.base_url}
variant="standard" variant="standard"
aria-label={t("subscribe_dialog_subscribe_base_url_label")} aria-label={t("subscribe_dialog_subscribe_base_url_label")}
/> />
@ -271,7 +244,7 @@ const LoginPage = (props) => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [errorText, setErrorText] = 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 topic = props.topic;
const handleLogin = async () => { const handleLogin = async () => {
const user = {baseUrl, username, password}; const user = {baseUrl, username, password};

View File

@ -60,7 +60,7 @@ export const useAutoSubscribe = (subscriptions, selected) => {
setHasRun(true); setHasRun(true);
const eligible = params.topic && !selected && !disallowedTopic(params.topic); const eligible = params.topic && !selected && !disallowedTopic(params.topic);
if (eligible) { 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)}`); console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
(async () => { (async () => {
const subscription = await subscriptionManager.add(baseUrl, params.topic); const subscription = await subscriptionManager.add(baseUrl, params.topic);

View File

@ -9,13 +9,13 @@ const routes = {
login: "/login", login: "/login",
signup: "/signup", signup: "/signup",
resetPassword: "/reset-password", resetPassword: "/reset-password",
app: config.appRoot, app: config.app_root,
account: "/account", account: "/account",
settings: "/settings", settings: "/settings",
subscription: "/:topic", subscription: "/:topic",
subscriptionExternal: "/:baseUrl/:topic", subscriptionExternal: "/:baseUrl/:topic",
forSubscription: (subscription) => { forSubscription: (subscription) => {
if (subscription.baseUrl !== config.baseUrl) { if (subscription.baseUrl !== config.base_url) {
return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`;
} }
return `/${subscription.topic}`; return `/${subscription.topic}`;