SubscribeDialog.jsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import * as React from "react";
  2. import { useContext, useState } from "react";
  3. import {
  4. Button,
  5. TextField,
  6. Dialog,
  7. DialogContent,
  8. DialogContentText,
  9. DialogTitle,
  10. Autocomplete,
  11. FormControlLabel,
  12. FormGroup,
  13. useMediaQuery,
  14. Switch,
  15. useTheme,
  16. } from "@mui/material";
  17. import { useTranslation } from "react-i18next";
  18. import { useLiveQuery } from "dexie-react-hooks";
  19. import api from "../app/Api";
  20. import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
  21. import userManager from "../app/UserManager";
  22. import subscriptionManager from "../app/SubscriptionManager";
  23. import poller from "../app/Poller";
  24. import DialogFooter from "./DialogFooter";
  25. import session from "../app/Session";
  26. import routes from "./routes";
  27. import accountApi, { Permission, Role } from "../app/AccountApi";
  28. import ReserveTopicSelect from "./ReserveTopicSelect";
  29. import { AccountContext } from "./App";
  30. import { TopicReservedError, UnauthorizedError } from "../app/errors";
  31. import { ReserveLimitChip } from "./SubscriptionPopup";
  32. import prefs from "../app/Prefs";
  33. const publicBaseUrl = "https://ntfy.sh";
  34. export const subscribeTopic = async (baseUrl, topic, opts) => {
  35. const subscription = await subscriptionManager.add(baseUrl, topic, opts);
  36. if (session.exists()) {
  37. try {
  38. await accountApi.addSubscription(baseUrl, topic);
  39. } catch (e) {
  40. console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
  41. if (e instanceof UnauthorizedError) {
  42. await session.resetAndRedirect(routes.login);
  43. }
  44. }
  45. }
  46. return subscription;
  47. };
  48. const SubscribeDialog = (props) => {
  49. const theme = useTheme();
  50. const [baseUrl, setBaseUrl] = useState("");
  51. const [topic, setTopic] = useState("");
  52. const [showLoginPage, setShowLoginPage] = useState(false);
  53. const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
  54. const handleSuccess = async () => {
  55. console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
  56. const actualBaseUrl = baseUrl || config.base_url;
  57. const subscription = await subscribeTopic(actualBaseUrl, topic, {});
  58. poller.pollInBackground(subscription); // Dangle!
  59. props.onSuccess(subscription);
  60. };
  61. return (
  62. <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
  63. {!showLoginPage && (
  64. <SubscribePage
  65. baseUrl={baseUrl}
  66. setBaseUrl={setBaseUrl}
  67. topic={topic}
  68. setTopic={setTopic}
  69. subscriptions={props.subscriptions}
  70. onCancel={props.onCancel}
  71. onNeedsLogin={() => setShowLoginPage(true)}
  72. onSuccess={handleSuccess}
  73. />
  74. )}
  75. {showLoginPage && <LoginPage baseUrl={baseUrl} topic={topic} onBack={() => setShowLoginPage(false)} onSuccess={handleSuccess} />}
  76. </Dialog>
  77. );
  78. };
  79. const SubscribePage = (props) => {
  80. const { t } = useTranslation();
  81. const { account } = useContext(AccountContext);
  82. const [error, setError] = useState("");
  83. const [reserveTopicVisible, setReserveTopicVisible] = useState(false);
  84. const [anotherServerVisible, setAnotherServerVisible] = useState(false);
  85. const [everyone, setEveryone] = useState(Permission.DENY_ALL);
  86. const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url;
  87. const { topic } = props;
  88. const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.topic));
  89. const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter(
  90. (s) => s !== config.base_url
  91. );
  92. const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account);
  93. const reserveTopicEnabled =
  94. session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));
  95. const webPushEnabled = useLiveQuery(() => prefs.webPushEnabled());
  96. const handleSubscribe = async () => {
  97. const user = await userManager.get(baseUrl); // May be undefined
  98. const username = user ? user.username : t("subscribe_dialog_error_user_anonymous");
  99. // Check read access to topic
  100. const success = await api.topicAuth(baseUrl, topic, user);
  101. if (!success) {
  102. console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
  103. if (user) {
  104. setError(
  105. t("subscribe_dialog_error_user_not_authorized", {
  106. username,
  107. })
  108. );
  109. return;
  110. }
  111. props.onNeedsLogin();
  112. return;
  113. }
  114. // Reserve topic (if requested)
  115. if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) {
  116. console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`);
  117. try {
  118. await accountApi.upsertReservation(topic, everyone);
  119. } catch (e) {
  120. console.log(`[SubscribeDialog] Error reserving topic`, e);
  121. if (e instanceof UnauthorizedError) {
  122. await session.resetAndRedirect(routes.login);
  123. } else if (e instanceof TopicReservedError) {
  124. setError(t("subscribe_dialog_error_topic_already_reserved"));
  125. return;
  126. }
  127. }
  128. }
  129. console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
  130. props.onSuccess();
  131. };
  132. const handleUseAnotherChanged = (e) => {
  133. props.setBaseUrl("");
  134. setAnotherServerVisible(e.target.checked);
  135. };
  136. const subscribeButtonEnabled = (() => {
  137. if (anotherServerVisible) {
  138. const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
  139. return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
  140. }
  141. const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
  142. return validTopic(topic) && !isExistingTopicUrl;
  143. })();
  144. const updateBaseUrl = (ev, newVal) => {
  145. if (validUrl(newVal)) {
  146. props.setBaseUrl(newVal.replace(/\/$/, "")); // strip trailing slash after https?://
  147. } else {
  148. props.setBaseUrl(newVal);
  149. }
  150. };
  151. return (
  152. <>
  153. <DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
  154. <DialogContent>
  155. <DialogContentText>{t("subscribe_dialog_subscribe_description")}</DialogContentText>
  156. <div style={{ display: "flex", paddingBottom: "8px" }} role="row">
  157. <TextField
  158. autoFocus
  159. margin="dense"
  160. id="topic"
  161. placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
  162. value={props.topic}
  163. onChange={(ev) => props.setTopic(ev.target.value)}
  164. type="text"
  165. fullWidth
  166. variant="standard"
  167. inputProps={{
  168. maxLength: 64,
  169. "aria-label": t("subscribe_dialog_subscribe_topic_placeholder"),
  170. }}
  171. />
  172. <Button
  173. onClick={() => {
  174. props.setTopic(randomAlphanumericString(16));
  175. }}
  176. style={{ flexShrink: "0", marginTop: "0.5em" }}
  177. >
  178. {t("subscribe_dialog_subscribe_button_generate_topic_name")}
  179. </Button>
  180. </div>
  181. {showReserveTopicCheckbox && (
  182. <FormGroup>
  183. <FormControlLabel
  184. variant="standard"
  185. control={
  186. <Switch
  187. disabled={!reserveTopicEnabled}
  188. checked={reserveTopicVisible}
  189. onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
  190. inputProps={{
  191. "aria-label": t("reserve_dialog_checkbox_label"),
  192. }}
  193. />
  194. }
  195. label={
  196. <>
  197. {t("reserve_dialog_checkbox_label")}
  198. <ReserveLimitChip />
  199. </>
  200. }
  201. />
  202. {reserveTopicVisible && <ReserveTopicSelect value={everyone} onChange={setEveryone} />}
  203. </FormGroup>
  204. )}
  205. {!reserveTopicVisible && (
  206. <FormGroup>
  207. <FormControlLabel
  208. control={
  209. <Switch
  210. onChange={handleUseAnotherChanged}
  211. checked={anotherServerVisible}
  212. inputProps={{
  213. "aria-label": t("subscribe_dialog_subscribe_use_another_label"),
  214. }}
  215. />
  216. }
  217. label={t("subscribe_dialog_subscribe_use_another_label")}
  218. />
  219. {anotherServerVisible && (
  220. <Autocomplete
  221. freeSolo
  222. options={existingBaseUrls}
  223. inputValue={props.baseUrl}
  224. onInputChange={updateBaseUrl}
  225. renderInput={(params) => (
  226. <>
  227. <TextField
  228. {...params}
  229. placeholder={config.base_url}
  230. variant="standard"
  231. aria-label={t("subscribe_dialog_subscribe_base_url_label")}
  232. />
  233. {webPushEnabled && (
  234. <div style={{ width: "100%", color: "#aaa", fontSize: "0.75rem", marginTop: "0.5rem" }}>
  235. {t("subscribe_dialog_subscribe_use_another_background_info")}
  236. </div>
  237. )}
  238. </>
  239. )}
  240. />
  241. )}
  242. </FormGroup>
  243. )}
  244. </DialogContent>
  245. <DialogFooter status={error}>
  246. <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
  247. <Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>
  248. {t("subscribe_dialog_subscribe_button_subscribe")}
  249. </Button>
  250. </DialogFooter>
  251. </>
  252. );
  253. };
  254. const LoginPage = (props) => {
  255. const { t } = useTranslation();
  256. const [username, setUsername] = useState("");
  257. const [password, setPassword] = useState("");
  258. const [error, setError] = useState("");
  259. const baseUrl = props.baseUrl ? props.baseUrl : config.base_url;
  260. const { topic } = props;
  261. const handleLogin = async () => {
  262. const user = { baseUrl, username, password };
  263. const success = await api.topicAuth(baseUrl, topic, user);
  264. if (!success) {
  265. console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
  266. setError(t("subscribe_dialog_error_user_not_authorized", { username }));
  267. return;
  268. }
  269. console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
  270. await userManager.save(user);
  271. props.onSuccess();
  272. };
  273. return (
  274. <>
  275. <DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle>
  276. <DialogContent>
  277. <DialogContentText>{t("subscribe_dialog_login_description")}</DialogContentText>
  278. <TextField
  279. autoFocus
  280. margin="dense"
  281. id="username"
  282. label={t("subscribe_dialog_login_username_label")}
  283. value={username}
  284. onChange={(ev) => setUsername(ev.target.value)}
  285. type="text"
  286. fullWidth
  287. variant="standard"
  288. inputProps={{
  289. "aria-label": t("subscribe_dialog_login_username_label"),
  290. }}
  291. />
  292. <TextField
  293. margin="dense"
  294. id="password"
  295. label={t("subscribe_dialog_login_password_label")}
  296. type="password"
  297. value={password}
  298. onChange={(ev) => setPassword(ev.target.value)}
  299. fullWidth
  300. variant="standard"
  301. inputProps={{
  302. "aria-label": t("subscribe_dialog_login_password_label"),
  303. }}
  304. />
  305. </DialogContent>
  306. <DialogFooter status={error}>
  307. <Button onClick={props.onBack}>{t("common_back")}</Button>
  308. <Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
  309. </DialogFooter>
  310. </>
  311. );
  312. };
  313. export default SubscribeDialog;