SubscribeDialog.js 14 KB

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