Split out AccountApi

This commit is contained in:
binwiederhier 2022-12-25 11:59:44 -05:00
parent d4c7ad4beb
commit 276301dc87
11 changed files with 251 additions and 222 deletions

205
web/src/app/AccountApi.js Normal file
View file

@ -0,0 +1,205 @@
import {
accountPasswordUrl,
accountSettingsUrl,
accountSubscriptionSingleUrl,
accountSubscriptionUrl,
accountTokenUrl,
accountUrl,
fetchLinesIterator,
maybeWithBasicAuth,
maybeWithBearerAuth,
topicShortUrl,
topicUrl,
topicUrlAuth,
topicUrlJsonPoll,
topicUrlJsonPollWithSince
} from "./utils";
import userManager from "./UserManager";
import session from "./Session";
class AccountApi {
async login(user) {
const url = accountTokenUrl(config.baseUrl);
console.log(`[Api] Checking auth for ${url}`);
const response = await fetch(url, {
method: "POST",
headers: maybeWithBasicAuth({}, user)
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const json = await response.json();
if (!json.token) {
throw new Error(`Unexpected server response: Cannot find token`);
}
return json.token;
}
async logout(token) {
const url = accountTokenUrl(config.baseUrl);
console.log(`[Api] Logging out from ${url} using token ${token}`);
const response = await fetch(url, {
method: "DELETE",
headers: maybeWithBearerAuth({}, token)
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async create(username, password) {
const url = accountUrl(config.baseUrl);
const body = JSON.stringify({
username: username,
password: password
});
console.log(`[Api] Creating user account ${url}`);
const response = await fetch(url, {
method: "POST",
body: body
});
if (response.status === 409) {
throw new UsernameTakenError(username);
} else if (response.status === 429) {
throw new AccountCreateLimitReachedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async get() {
const url = accountUrl(config.baseUrl);
console.log(`[Api] Fetching user account ${url}`);
const response = await fetch(url, {
headers: maybeWithBearerAuth({}, session.token())
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const account = await response.json();
console.log(`[Api] Account`, account);
return account;
}
async delete() {
const url = accountUrl(config.baseUrl);
console.log(`[Api] Deleting user account ${url}`);
const response = await fetch(url, {
method: "DELETE",
headers: maybeWithBearerAuth({}, session.token())
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async changePassword(newPassword) {
const url = accountPasswordUrl(config.baseUrl);
console.log(`[Api] Changing account password ${url}`);
const response = await fetch(url, {
method: "POST",
headers: maybeWithBearerAuth({}, session.token()),
body: JSON.stringify({
password: newPassword
})
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async extendToken() {
const url = accountTokenUrl(config.baseUrl);
console.log(`[Api] Extending user access token ${url}`);
const response = await fetch(url, {
method: "PATCH",
headers: maybeWithBearerAuth({}, session.token())
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async updateSettings(payload) {
const url = accountSettingsUrl(config.baseUrl);
const body = JSON.stringify(payload);
console.log(`[Api] Updating user account ${url}: ${body}`);
const response = await fetch(url, {
method: "PATCH",
headers: maybeWithBearerAuth({}, session.token()),
body: body
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async addSubscription(payload) {
const url = accountSubscriptionUrl(config.baseUrl);
const body = JSON.stringify(payload);
console.log(`[Api] Adding user subscription ${url}: ${body}`);
const response = await fetch(url, {
method: "POST",
headers: maybeWithBearerAuth({}, session.token()),
body: body
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const subscription = await response.json();
console.log(`[Api] Subscription`, subscription);
return subscription;
}
async deleteSubscription(remoteId) {
const url = accountSubscriptionSingleUrl(config.baseUrl, remoteId);
console.log(`[Api] Removing user subscription ${url}`);
const response = await fetch(url, {
method: "DELETE",
headers: maybeWithBearerAuth({}, session.token())
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
}
export class UsernameTakenError extends Error {
constructor(username) {
super("Username taken");
this.username = username;
}
}
export class AccountCreateLimitReachedError extends Error {
constructor() {
super("Account creation limit reached");
}
}
export class UnauthorizedError extends Error {
constructor() {
super("Unauthorized");
}
}
const accountApi = new AccountApi();
export default accountApi;

View file

@ -122,188 +122,6 @@ class Api {
}
throw new Error(`Unexpected server response ${response.status}`);
}
async login(baseUrl, user) {
const url = accountTokenUrl(baseUrl);
console.log(`[Api] Checking auth for ${url}`);
const response = await fetch(url, {
method: "POST",
headers: maybeWithBasicAuth({}, user)
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const json = await response.json();
if (!json.token) {
throw new Error(`Unexpected server response: Cannot find token`);
}
return json.token;
}
async logout(baseUrl, token) {
const url = accountTokenUrl(baseUrl);
console.log(`[Api] Logging out from ${url} using token ${token}`);
const response = await fetch(url, {
method: "DELETE",
headers: maybeWithBearerAuth({}, token)
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async createAccount(baseUrl, username, password) {
const url = accountUrl(baseUrl);
const body = JSON.stringify({
username: username,
password: password
});
console.log(`[Api] Creating user account ${url}`);
const response = await fetch(url, {
method: "POST",
body: body
});
if (response.status === 409) {
throw new UsernameTakenError(username);
} else if (response.status === 429) {
throw new AccountCreateLimitReachedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async getAccount(baseUrl, token) {
const url = accountUrl(baseUrl);
console.log(`[Api] Fetching user account ${url}`);
const response = await fetch(url, {
headers: maybeWithBearerAuth({}, token)
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const account = await response.json();
console.log(`[Api] Account`, account);
return account;
}
async deleteAccount(baseUrl, token) {
const url = accountUrl(baseUrl);
console.log(`[Api] Deleting user account ${url}`);
const response = await fetch(url, {
method: "DELETE",
headers: maybeWithBearerAuth({}, token)
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async changePassword(baseUrl, token, password) {
const url = accountPasswordUrl(baseUrl);
console.log(`[Api] Changing account password ${url}`);
const response = await fetch(url, {
method: "POST",
headers: maybeWithBearerAuth({}, token),
body: JSON.stringify({
password: password
})
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async extendToken(baseUrl, token) {
const url = accountTokenUrl(baseUrl);
console.log(`[Api] Extending user access token ${url}`);
const response = await fetch(url, {
method: "PATCH",
headers: maybeWithBearerAuth({}, token)
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async updateAccountSettings(baseUrl, token, payload) {
const url = accountSettingsUrl(baseUrl);
const body = JSON.stringify(payload);
console.log(`[Api] Updating user account ${url}: ${body}`);
const response = await fetch(url, {
method: "PATCH",
headers: maybeWithBearerAuth({}, token),
body: body
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
async addAccountSubscription(baseUrl, token, payload) {
const url = accountSubscriptionUrl(baseUrl);
const body = JSON.stringify(payload);
console.log(`[Api] Adding user subscription ${url}: ${body}`);
const response = await fetch(url, {
method: "POST",
headers: maybeWithBearerAuth({}, token),
body: body
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const subscription = await response.json();
console.log(`[Api] Subscription`, subscription);
return subscription;
}
async deleteAccountSubscription(baseUrl, token, remoteId) {
const url = accountSubscriptionSingleUrl(baseUrl, remoteId);
console.log(`[Api] Removing user subscription ${url}`);
const response = await fetch(url, {
method: "DELETE",
headers: maybeWithBearerAuth({}, token)
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
}
}
export class UsernameTakenError extends Error {
constructor(username) {
super("Username taken");
this.username = username;
}
}
export class AccountCreateLimitReachedError extends Error {
constructor() {
super("Account creation limit reached");
}
}
export class UnauthorizedError extends Error {
constructor() {
super("Unauthorized");
}
}
const api = new Api();

View file

@ -16,11 +16,11 @@ import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import TextField from "@mui/material/TextField";
import DialogActions from "@mui/material/DialogActions";
import api, {UnauthorizedError} from "../app/Api";
import routes from "./routes";
import IconButton from "@mui/material/IconButton";
import {useNavigate, useOutletContext} from "react-router-dom";
import {useOutletContext} from "react-router-dom";
import {formatBytes} from "../app/utils";
import accountApi, {UnauthorizedError} from "../app/AccountApi";
const Account = () => {
if (!session.exists()) {
@ -147,7 +147,7 @@ const ChangePassword = () => {
};
const handleDialogSubmit = async (newPassword) => {
try {
await api.changePassword(config.baseUrl, session.token(), newPassword);
await accountApi.changePassword(newPassword);
setDialogOpen(false);
console.debug(`[Account] Password changed`);
} catch (e) {
@ -234,7 +234,7 @@ const DeleteAccount = () => {
};
const handleDialogSubmit = async (newPassword) => {
try {
await api.deleteAccount(config.baseUrl, session.token());
await accountApi.delete();
setDialogOpen(false);
console.debug(`[Account] Account deleted`);
// TODO delete local storage

View file

@ -18,7 +18,7 @@ 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';
import api, {UnauthorizedError} from "../app/Api";
import api from "../app/Api";
import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg";
@ -31,6 +31,7 @@ import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import {Logout, Person, Settings} from "@mui/icons-material";
import ListItemIcon from "@mui/material/ListItemIcon";
import accountApi, {UnauthorizedError} from "../app/AccountApi";
const ActionBar = (props) => {
const { t } = useTranslation();
@ -119,7 +120,7 @@ const SettingsIcons = (props) => {
await subscriptionManager.remove(props.subscription.id);
if (session.exists() && props.subscription.remoteId) {
try {
await api.deleteAccountSubscription(config.baseUrl, session.token(), props.subscription.remoteId);
await accountApi.deleteSubscription(props.subscription.remoteId);
} catch (e) {
console.log(`[ActionBar] Error unsubscribing`, e);
if ((e instanceof UnauthorizedError)) {
@ -268,9 +269,12 @@ const ProfileIcon = (props) => {
setAnchorEl(null);
};
const handleLogout = async () => {
await api.logout(config.baseUrl, session.token());
session.reset();
window.location.href = routes.app;
try {
await accountApi.logout();
} finally {
session.reset();
window.location.href = routes.app;
}
};
return (
<>

View file

@ -26,13 +26,13 @@ import {Backdrop, CircularProgress} from "@mui/material";
import Home from "./Home";
import Login from "./Login";
import i18n from "i18next";
import api, {UnauthorizedError} from "../app/Api";
import prefs from "../app/Prefs";
import session from "../app/Session";
import Pricing from "./Pricing";
import Signup from "./Signup";
import Account from "./Account";
import ResetPassword from "./ResetPassword";
import accountApi, {UnauthorizedError} from "../app/AccountApi";
// TODO races when two tabs are open
// TODO investigate service workers
@ -101,24 +101,24 @@ const Layout = () => {
if (!session.token()) {
return;
}
const acc = await api.getAccount(config.baseUrl, session.token());
setAccount(acc);
if (acc.language) {
await i18n.changeLanguage(acc.language);
const remoteAccount = await accountApi.get();
setAccount(remoteAccount);
if (remoteAccount.language) {
await i18n.changeLanguage(remoteAccount.language);
}
if (acc.notification) {
if (acc.notification.sound) {
await prefs.setSound(acc.notification.sound);
if (remoteAccount.notification) {
if (remoteAccount.notification.sound) {
await prefs.setSound(remoteAccount.notification.sound);
}
if (acc.notification.delete_after) {
await prefs.setDeleteAfter(acc.notification.delete_after);
if (remoteAccount.notification.delete_after) {
await prefs.setDeleteAfter(remoteAccount.notification.delete_after);
}
if (acc.notification.min_priority) {
await prefs.setMinPriority(acc.notification.min_priority);
if (remoteAccount.notification.min_priority) {
await prefs.setMinPriority(remoteAccount.notification.min_priority);
}
}
if (acc.subscriptions) {
await subscriptionManager.syncFromRemote(acc.subscriptions);
if (remoteAccount.subscriptions) {
await subscriptionManager.syncFromRemote(remoteAccount.subscriptions);
}
} catch (e) {
console.log(`[App] Error fetching account`, e);

View file

@ -1,16 +1,16 @@
import * as React from 'react';
import {useState} from 'react';
import Typography from "@mui/material/Typography";
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import api, {UnauthorizedError} from "../app/Api";
import routes from "./routes";
import session from "../app/Session";
import {NavLink} from "react-router-dom";
import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next";
import {useState} from "react";
import accountApi, {UnauthorizedError} from "../app/AccountApi";
const Login = () => {
const { t } = useTranslation();
@ -21,7 +21,7 @@ const Login = () => {
event.preventDefault();
const user = { username, password };
try {
const token = await api.login(config.baseUrl, user);
const token = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
session.store(user.username, token);
window.location.href = routes.app;

View file

@ -34,9 +34,9 @@ import DialogActions from "@mui/material/DialogActions";
import userManager from "../app/UserManager";
import {playSound, shuffle, sounds, validTopic, validUrl} from "../app/utils";
import {useTranslation} from "react-i18next";
import api, {UnauthorizedError} from "../app/Api";
import session from "../app/Session";
import routes from "./routes";
import accountApi, {UnauthorizedError} from "../app/AccountApi";
const Preferences = () => {
return (
@ -668,7 +668,7 @@ const maybeUpdateAccountSettings = async (payload) => {
return;
}
try {
await api.updateAccountSettings(config.baseUrl, session.token(), payload);
await accountApi.updateSettings(payload);
} catch (e) {
console.log(`[Preferences] Error updating account settings`, e);
if ((e instanceof UnauthorizedError)) {

View file

@ -1,6 +1,5 @@
import * as React from 'react';
import {useEffect, useRef, useState} from 'react';
import {NotificationItem} from "./Notifications";
import theme from "./theme";
import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material";
import TextField from "@mui/material/TextField";
@ -18,16 +17,17 @@ import IconButton from "@mui/material/IconButton";
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
import {Close} from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem";
import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils";
import {formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils";
import Box from "@mui/material/Box";
import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter";
import api, {UnauthorizedError} from "../app/Api";
import api from "../app/Api";
import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker";
import {Trans, useTranslation} from "react-i18next";
import session from "../app/Session";
import routes from "./routes";
import accountApi, {UnauthorizedError} from "../app/AccountApi";
const PublishDialog = (props) => {
const { t } = useTranslation();
@ -161,7 +161,7 @@ const PublishDialog = (props) => {
const checkAttachmentLimits = async (file) => {
try {
const account = await api.getAccount(baseUrl, session.token());
const account = await accountApi.get();
const fileSizeLimit = account.limits.attachment_file_size ?? 0;
const remainingBytes = account.stats.attachment_total_size_remaining;
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;

View file

@ -1,16 +1,16 @@
import * as React from 'react';
import {useState} from 'react';
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import api, {AccountCreateLimitReachedError, UnauthorizedError, UsernameTakenError} from "../app/Api";
import routes from "./routes";
import session from "../app/Session";
import Typography from "@mui/material/Typography";
import {NavLink} from "react-router-dom";
import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next";
import {useState} from "react";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import accountApi, {AccountCreateLimitReachedError, UsernameTakenError} from "../app/AccountApi";
const Signup = () => {
const { t } = useTranslation();
@ -22,8 +22,8 @@ const Signup = () => {
event.preventDefault();
const user = { username, password };
try {
await api.createAccount(config.baseUrl, user.username, user.password);
const token = await api.login(config.baseUrl, user);
await accountApi.create(user.username, user.password);
const token = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
session.store(user.username, token);
window.location.href = routes.app;

View file

@ -8,7 +8,7 @@ import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
import theme from "./theme";
import api, {UnauthorizedError} from "../app/Api";
import api from "../app/Api";
import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils";
import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager";
@ -17,6 +17,7 @@ import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
import session from "../app/Session";
import routes from "./routes";
import accountApi, {UnauthorizedError} from "../app/AccountApi";
const publicBaseUrl = "https://ntfy.sh";
@ -31,7 +32,7 @@ const SubscribeDialog = (props) => {
const subscription = await subscriptionManager.add(actualBaseUrl, topic);
if (session.exists()) {
try {
const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), {
const remoteSubscription = await accountApi.addSubscription({
base_url: actualBaseUrl,
topic: topic
});

View file

@ -8,7 +8,8 @@ import connectionManager from "../app/ConnectionManager";
import poller from "../app/Poller";
import pruner from "../app/Pruner";
import session from "../app/Session";
import api, {UnauthorizedError} from "../app/Api";
import {UnauthorizedError} from "../app/AccountApi";
import accountApi from "../app/AccountApi";
/**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@ -65,7 +66,7 @@ export const useAutoSubscribe = (subscriptions, selected) => {
const subscription = await subscriptionManager.add(baseUrl, params.topic);
if (session.exists()) {
try {
const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), {
const remoteSubscription = await accountApi.addSubscription({
base_url: baseUrl,
topic: params.topic
});