From 0885951a6783cf15855a0e4d78cb900c18649970 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 2 Feb 2023 15:19:37 -0500 Subject: [PATCH] JS error handling --- server/errors.go | 3 +- server/server.go | 7 +- server/server_account.go | 36 ++--- server/server_account_test.go | 45 +++++- server/visitor.go | 6 + user/types.go | 10 ++ web/public/static/langs/en.json | 4 +- web/src/app/AccountApi.js | 195 ++++-------------------- web/src/app/Api.js | 9 +- web/src/app/errors.js | 66 ++++++++ web/src/components/Account.js | 132 ++++++++-------- web/src/components/Login.js | 9 +- web/src/components/Preferences.js | 17 ++- web/src/components/PublishDialog.js | 5 +- web/src/components/ReserveDialogs.js | 63 ++++---- web/src/components/Signup.js | 9 +- web/src/components/SubscribeDialog.js | 59 +++---- web/src/components/SubscriptionPopup.js | 31 ++-- web/src/components/UpgradeDialog.js | 20 ++- web/src/components/hooks.js | 9 +- 20 files changed, 369 insertions(+), 366 deletions(-) create mode 100644 web/src/app/errors.js diff --git a/server/errors.go b/server/errors.go index 81fb9eae..1a0b28e5 100644 --- a/server/errors.go +++ b/server/errors.go @@ -58,11 +58,10 @@ var ( errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""} errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""} errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""} - errHTTPBadRequestMakesNoSenseForAdmin = &errHTTP{40026, http.StatusBadRequest, "invalid request: this makes no sense for admins", ""} + errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", ""} errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""} errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""} errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""} - errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40030, http.StatusBadRequest, "invalid request: password confirmation is not correct", ""} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} diff --git a/server/server.go b/server/server.go index 311a9ec3..64232abd 100644 --- a/server/server.go +++ b/server/server.go @@ -35,11 +35,14 @@ import ( /* - HIGH Rate limiting: Sensitive endpoints (account/login/change-password/...) +- HIGH Account limit creation triggers when account is taken! - HIGH Docs -- HIGH CLI +- HIGH CLI "ntfy tier [add|list|delete]" +- HIGH CLI "ntfy user" should show tier +- HIGH Self-review +- HIGH Stripe webhook failures cannot be diagnosed because of missing logs - MEDIUM: Test for expiring messages after reservation removal - MEDIUM: Test new token endpoints & never-expiring token -- MEDIUM: Make sure account endpoints make sense for admins - LOW: UI: Flickering upgrade banner when logging in */ diff --git a/server/server_account.go b/server/server_account.go index 432b6998..6ffd1622 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -20,8 +20,7 @@ const ( func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - admin := u != nil && u.Role == user.RoleAdmin - if !admin { + if !u.Admin() { // u may be nil, but that's fine if !s.config.EnableSignup { return errHTTPBadRequestSignupNotEnabled } else if u != nil { @@ -380,11 +379,11 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http. return s.writeJSON(w, newSuccessResponse()) } +// handleAccountReservationAdd adds a topic reservation for the logged-in user, but only if the user has a tier +// with enough remaining reservations left, or if the user is an admin. Admins can always reserve a topic, unless +// it is already reserved by someone else. func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - if u != nil && u.Role == user.RoleAdmin { - return errHTTPBadRequestMakesNoSenseForAdmin - } req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -396,23 +395,23 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ if err != nil { return errHTTPBadRequestPermissionInvalid } - if u.Tier == nil { + // Check if we are allowed to reserve this topic + if u.User() && u.Tier == nil { return errHTTPUnauthorized - } - // CHeck if we are allowed to reserve this topic - if err := s.userManager.CheckAllowAccess(u.Name, req.Topic); err != nil { + } else if err := s.userManager.CheckAllowAccess(u.Name, req.Topic); err != nil { return errHTTPConflictTopicReserved - } - hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic) - if err != nil { - return err - } - if !hasReservation { - reservations, err := s.userManager.ReservationsCount(u.Name) + } else if u.User() { + hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic) if err != nil { return err - } else if reservations >= u.Tier.ReservationLimit { - return errHTTPTooManyRequestsLimitReservations + } + if !hasReservation { + reservations, err := s.userManager.ReservationsCount(u.Name) + if err != nil { + return err + } else if reservations >= u.Tier.ReservationLimit { + return errHTTPTooManyRequestsLimitReservations + } } } // Actually add the reservation @@ -428,6 +427,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ return s.writeJSON(w, newSuccessResponse()) } +// handleAccountReservationDelete deletes a topic reservation if it is owned by the current user func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { matches := apiAccountReservationSingleRegex.FindStringSubmatch(r.URL.Path) if len(matches) != 2 { diff --git a/server/server_account_test.go b/server/server_account_test.go index 62df104b..a26bfc91 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -435,13 +435,52 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) { conf := newTestConfigWithAuthFile(t) conf.EnableSignup = true s := newTestServer(t, conf) + + // A user, an admin, and a reservation walk into a bar + require.Nil(t, s.userManager.CreateTier(&user.Tier{ + Code: "pro", + ReservationLimit: 2, + })) + require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro")) + require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll)) + + require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro")) + require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin)) - rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{ + // Admin can reserve topic + rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"sometopic","everyone":"deny-all"}`, map[string]string{ "Authorization": util.BasicAuth("phil", "adminpass"), }) - require.Equal(t, 400, rr.Code) - require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code) + require.Equal(t, 200, rr.Code) + + // User cannot reserve already reserved topic + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("noadmin2", "pass"), + }) + require.Equal(t, 409, rr.Code) + + // Admin cannot reserve already reserved topic + rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{ + "Authorization": util.BasicAuth("phil", "adminpass"), + }) + require.Equal(t, 409, rr.Code) + + reservations, err := s.userManager.Reservations("phil") + require.Nil(t, err) + require.Equal(t, 1, len(reservations)) + require.Equal(t, "sometopic", reservations[0].Topic) + + reservations, err = s.userManager.Reservations("noadmin1") + require.Nil(t, err) + require.Equal(t, 1, len(reservations)) + require.Equal(t, "mytopic", reservations[0].Topic) + + reservations, err = s.userManager.Reservations("noadmin2") + require.Nil(t, err) + require.Equal(t, 0, len(reservations)) } func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { diff --git a/server/visitor.go b/server/visitor.go index e2966dc1..88ec91bb 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -254,6 +254,12 @@ func (v *visitor) User() *user.User { return v.user // May be nil } +// Admin returns true if the visitor is a user, and an admin +func (v *visitor) Admin() bool { + u := v.User() + return u != nil && u.Role == user.RoleAdmin +} + // IP returns the visitor IP address func (v *visitor) IP() netip.Addr { v.mu.Lock() diff --git a/user/types.go b/user/types.go index bed42074..4b125ed8 100644 --- a/user/types.go +++ b/user/types.go @@ -33,6 +33,16 @@ func (u *User) TierID() string { return u.Tier.ID } +// Admin returns true if the user is an admin +func (u *User) Admin() bool { + return u != nil && u.Role == RoleAdmin +} + +// User returns true if the user is a regular user, not an admin +func (u *User) User() bool { + return !u.Admin() +} + // Auther is an interface for authentication and authorization type Auther interface { // Authenticate checks username and password and returns a user if correct. The method diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index b8976096..4321a77e 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -11,7 +11,6 @@ "signup_disabled": "Signup is disabled", "signup_error_username_taken": "Username {{username}} is already taken", "signup_error_creation_limit_reached": "Account creation limit reached", - "signup_error_unknown": "Unknown error. Check logs for details.", "login_title": "Sign in to your ntfy account", "login_form_button_submit": "Sign in", "login_link_signup": "Sign up", @@ -197,9 +196,11 @@ "account_usage_messages_title": "Published messages", "account_usage_emails_title": "Emails sent", "account_usage_reservations_title": "Reserved topics", + "account_usage_reservations_none": "No reserved topics for this account", "account_usage_attachment_storage_title": "Attachment storage", "account_usage_attachment_storage_description": "{{filesize}} per file, deleted after {{expiry}}", "account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.", + "account_usage_cannot_create_portal_session": "Unable to open billing portal", "account_delete_title": "Delete account", "account_delete_description": "Permanently delete your account", "account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. If you really want to proceed, please confirm with your password in the box below.", @@ -312,6 +313,7 @@ "prefs_reservations_table_everyone_write_only": "I can publish and subscribe, everyone can publish", "prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe", "prefs_reservations_table_not_subscribed": "Not subscribed", + "prefs_reservations_table_click_to_subscribe": "Click to subscribe", "prefs_reservations_dialog_title_add": "Reserve topic", "prefs_reservations_dialog_title_edit": "Edit reserved topic", "prefs_reservations_dialog_title_delete": "Delete topic reservation", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 512fd1be..91c2195e 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -18,6 +18,7 @@ import subscriptionManager from "./SubscriptionManager"; import i18n from "i18next"; import prefs from "./Prefs"; import routes from "../components/routes"; +import {fetchOrThrow, throwAppError, UnauthorizedError} from "./errors"; const delayMillis = 45000; // 45 seconds const intervalMillis = 900000; // 15 minutes @@ -39,16 +40,11 @@ class AccountApi { async login(user) { const url = accountTokenUrl(config.base_url); console.log(`[AccountApi] Checking auth for ${url}`); - const response = await fetch(url, { + const response = await fetchOrThrow(url, { method: "POST", headers: withBasicAuth({}, user.username, user.password) }); - 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(); + const json = await response.json(); // May throw SyntaxError if (!json.token) { throw new Error(`Unexpected server response: Cannot find token`); } @@ -58,15 +54,10 @@ class AccountApi { async logout() { const url = accountTokenUrl(config.base_url); console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`); - const response = await fetch(url, { + await fetchOrThrow(url, { method: "DELETE", headers: withBearerAuth({}, 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 create(username, password) { @@ -76,31 +67,19 @@ class AccountApi { password: password }); console.log(`[AccountApi] Creating user account ${url}`); - const response = await fetch(url, { + await fetchOrThrow(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.base_url); console.log(`[AccountApi] Fetching user account ${url}`); - const response = await fetch(url, { + const response = await fetchOrThrow(url, { headers: withBearerAuth({}, 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(); + const account = await response.json(); // May throw SyntaxError console.log(`[AccountApi] Account`, account); if (this.listener) { this.listener(account); @@ -111,26 +90,19 @@ class AccountApi { async delete(password) { const url = accountUrl(config.base_url); console.log(`[AccountApi] Deleting user account ${url}`); - const response = await fetch(url, { + await fetchOrThrow(url, { method: "DELETE", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ password: password }) }); - if (response.status === 400) { - throw new IncorrectPasswordError(); - } else 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(currentPassword, newPassword) { const url = accountPasswordUrl(config.base_url); console.log(`[AccountApi] Changing account password ${url}`); - const response = await fetch(url, { + await fetchOrThrow(url, { method: "POST", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ @@ -138,13 +110,6 @@ class AccountApi { new_password: newPassword }) }); - if (response.status === 400) { - throw new IncorrectPasswordError(); - } else if (response.status === 401 || response.status === 403) { - throw new UnauthorizedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } } async createToken(label, expires) { @@ -154,16 +119,11 @@ class AccountApi { expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0 }; console.log(`[AccountApi] Creating user access token ${url}`); - const response = await fetch(url, { + await fetchOrThrow(url, { method: "POST", headers: withBearerAuth({}, session.token()), body: JSON.stringify(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 updateToken(token, label, expires) { @@ -176,22 +136,17 @@ class AccountApi { body.expires = Math.floor(Date.now() / 1000) + expires; } console.log(`[AccountApi] Creating user access token ${url}`); - const response = await fetch(url, { + await fetchOrThrow(url, { method: "PATCH", headers: withBearerAuth({}, session.token()), body: JSON.stringify(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 extendToken() { const url = accountTokenUrl(config.base_url); console.log(`[AccountApi] Extending user access token ${url}`); - const response = await fetch(url, { + await fetchOrThrow(url, { method: "PATCH", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ @@ -199,58 +154,38 @@ class AccountApi { expires: Math.floor(Date.now() / 1000) + 6220800 // FIXME }) }); - if (response.status === 401 || response.status === 403) { - throw new UnauthorizedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } } async deleteToken(token) { const url = accountTokenUrl(config.base_url); console.log(`[AccountApi] Deleting user access token ${url}`); - const response = await fetch(url, { + await fetchOrThrow(url, { method: "DELETE", headers: withBearerAuth({"X-Token": token}, 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.base_url); const body = JSON.stringify(payload); console.log(`[AccountApi] Updating user account ${url}: ${body}`); - const response = await fetch(url, { + await fetchOrThrow(url, { method: "PATCH", headers: withBearerAuth({}, 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.base_url); const body = JSON.stringify(payload); console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); - const response = await fetch(url, { + const response = await fetchOrThrow(url, { method: "POST", headers: withBearerAuth({}, 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(); + const subscription = await response.json(); // May throw SyntaxError console.log(`[AccountApi] Subscription`, subscription); return subscription; } @@ -259,17 +194,12 @@ class AccountApi { 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, { + const response = await fetchOrThrow(url, { method: "PATCH", headers: withBearerAuth({}, 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(); + const subscription = await response.json(); // May throw SyntaxError console.log(`[AccountApi] Subscription`, subscription); return subscription; } @@ -277,21 +207,16 @@ class AccountApi { async deleteSubscription(remoteId) { const url = accountSubscriptionSingleUrl(config.base_url, remoteId); console.log(`[AccountApi] Removing user subscription ${url}`); - const response = await fetch(url, { + await fetchOrThrow(url, { method: "DELETE", headers: withBearerAuth({}, 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 upsertReservation(topic, everyone) { const url = accountReservationUrl(config.base_url); console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`); - const response = await fetch(url, { + await fetchOrThrow(url, { method: "POST", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ @@ -299,13 +224,6 @@ class AccountApi { everyone: everyone }) }); - if (response.status === 401 || response.status === 403) { - throw new UnauthorizedError(); - } else if (response.status === 409) { - throw new TopicReservedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } } async deleteReservation(topic, deleteMessages) { @@ -314,25 +232,17 @@ class AccountApi { const headers = { "X-Delete-Messages": deleteMessages ? "true" : "false" } - const response = await fetch(url, { + await fetchOrThrow(url, { method: "DELETE", headers: withBearerAuth(headers, 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 billingTiers() { const url = tiersUrl(config.base_url); console.log(`[AccountApi] Fetching billing tiers`); - const response = await fetch(url); // No auth needed! - if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - return await response.json(); + const response = await fetchOrThrow(url); // No auth needed! + return await response.json(); // May throw SyntaxError } async createBillingSubscription(tier) { @@ -347,48 +257,33 @@ class AccountApi { async upsertBillingSubscription(method, tier) { const url = accountBillingSubscriptionUrl(config.base_url); - const response = await fetch(url, { + const response = await fetchOrThrow(url, { method: method, headers: withBearerAuth({}, session.token()), body: JSON.stringify({ tier: tier }) }); - if (response.status === 401 || response.status === 403) { - throw new UnauthorizedError(); - } else if (response.status !== 200) { - throw new Error(`Unexpected server response ${response.status}`); - } - return await response.json(); + return await response.json(); // May throw SyntaxError } async deleteBillingSubscription() { const url = accountBillingSubscriptionUrl(config.base_url); console.log(`[AccountApi] Cancelling billing subscription`); - const response = await fetch(url, { + await fetchOrThrow(url, { method: "DELETE", headers: withBearerAuth({}, 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 createBillingPortalSession() { const url = accountBillingPortalUrl(config.base_url); console.log(`[AccountApi] Creating billing portal session`); - const response = await fetch(url, { + const response = await fetchOrThrow(url, { method: "POST", headers: withBearerAuth({}, 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}`); - } - return await response.json(); + return await response.json(); // May throw SyntaxError } async sync() { @@ -418,7 +313,7 @@ class AccountApi { return account; } catch (e) { console.log(`[AccountApi] Error fetching account`, e); - if ((e instanceof UnauthorizedError)) { + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); } } @@ -472,37 +367,5 @@ export const Permission = { DENY_ALL: "deny-all" }; -export class UsernameTakenError extends Error { - constructor(username) { - super("Username taken"); - this.username = username; - } -} - -export class TopicReservedError extends Error { - constructor(topic) { - super("Topic already reserved"); - this.topic = topic; - } -} - -export class AccountCreateLimitReachedError extends Error { - constructor() { - super("Account creation limit reached"); - } -} - -export class IncorrectPasswordError extends Error { - constructor() { - super("Password incorrect"); - } -} - -export class UnauthorizedError extends Error { - constructor() { - super("Unauthorized"); - } -} - const accountApi = new AccountApi(); export default accountApi; diff --git a/web/src/app/Api.js b/web/src/app/Api.js index d94f021f..3d20d922 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -8,6 +8,7 @@ import { topicUrlJsonPollWithSince } from "./utils"; import userManager from "./UserManager"; +import {fetchOrThrow} from "./errors"; class Api { async poll(baseUrl, topic, since) { @@ -35,15 +36,11 @@ class Api { message: message, ...options }; - const response = await fetch(baseUrl, { + await fetchOrThrow(baseUrl, { method: 'PUT', body: JSON.stringify(body), headers: maybeWithAuth(headers, user) }); - if (response.status < 200 || response.status > 299) { - throw new Error(`Unexpected response: ${response.status}`); - } - return response; } /** @@ -108,8 +105,6 @@ class Api { }); if (response.status >= 200 && response.status <= 299) { return true; - } else if (!user && response.status === 404) { - return true; // Special case: Anonymous login to old servers return 404 since //auth doesn't exist } else if (response.status === 401 || response.status === 403) { // See server/server.go return false; } diff --git a/web/src/app/errors.js b/web/src/app/errors.js new file mode 100644 index 00000000..38165a24 --- /dev/null +++ b/web/src/app/errors.js @@ -0,0 +1,66 @@ +// This is a subset of, and the counterpart to errors.go + +export const fetchOrThrow = async (url, options) => { + const response = await fetch(url, options); + if (response.status !== 200) { + await throwAppError(response); + } + return response; // Promise! +}; + +export const throwAppError = async (response) => { + if (response.status === 401 || response.status === 403) { + console.log(`[Error] HTTP ${response.status}`, response); + throw new UnauthorizedError(); + } + const error = await maybeToJson(response); + if (error?.code) { + console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response); + if (error.code === UserExistsError.CODE) { + throw new UserExistsError(); + } else if (error.code === TopicReservedError.CODE) { + throw new TopicReservedError(); + } else if (error.code === AccountCreateLimitReachedError.CODE) { + throw new AccountCreateLimitReachedError(); + } else if (error.code === IncorrectPasswordError.CODE) { + throw new IncorrectPasswordError(); + } else if (error?.error) { + throw new Error(`Error ${error.code}: ${error.error}`); + } + } + console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response); + throw new Error(`Unexpected response ${response.status}`); +}; + +const maybeToJson = async (response) => { + try { + return await response.json(); + } catch (e) { + return null; + } +} + +export class UnauthorizedError extends Error { + constructor() { super("Unauthorized"); } +} + +export class UserExistsError extends Error { + static CODE = 40901; // errHTTPConflictUserExists + constructor() { super("Username already exists"); } +} + +export class TopicReservedError extends Error { + static CODE = 40902; // errHTTPConflictTopicReserved + constructor() { super("Topic already reserved"); } +} + +export class AccountCreateLimitReachedError extends Error { + static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation + constructor() { super("Account creation limit reached"); } +} + +export class IncorrectPasswordError extends Error { + static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation + constructor() { super("Password incorrect"); } +} + diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 7cb6d8db..d0308470 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -1,12 +1,19 @@ import * as React from 'react'; -import {useContext, useEffect, useState} from 'react'; +import {useContext, useState} from 'react'; import { Alert, CardActions, - CardContent, FormControl, - LinearProgress, Link, Portal, Select, Snackbar, + CardContent, + FormControl, + LinearProgress, + Link, + Portal, + Select, + Snackbar, Stack, - Table, TableBody, TableCell, + Table, + TableBody, + TableCell, TableHead, TableRow, useMediaQuery @@ -27,14 +34,8 @@ import DialogContent from "@mui/material/DialogContent"; import TextField from "@mui/material/TextField"; import routes from "./routes"; import IconButton from "@mui/material/IconButton"; -import {formatBytes, formatShortDate, formatShortDateTime, openUrl, truncateString, validUrl} from "../app/utils"; -import accountApi, { - IncorrectPasswordError, - LimitBasis, - Role, - SubscriptionStatus, - UnauthorizedError -} from "../app/AccountApi"; +import {formatBytes, formatShortDate, formatShortDateTime, openUrl} from "../app/utils"; +import accountApi, {LimitBasis, Role, SubscriptionStatus} from "../app/AccountApi"; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import {Pref, PrefGroup} from "./Pref"; import db from "../app/db"; @@ -44,17 +45,12 @@ import UpgradeDialog from "./UpgradeDialog"; import CelebrationIcon from "@mui/icons-material/Celebration"; import {AccountContext} from "./App"; import DialogFooter from "./DialogFooter"; -import {useLiveQuery} from "dexie-react-hooks"; -import userManager from "../app/UserManager"; import {Paragraph} from "./styles"; import CloseIcon from "@mui/icons-material/Close"; -import DialogActions from "@mui/material/DialogActions"; import {ContentCopy, Public} from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; -import ListItemText from "@mui/material/ListItemText"; import DialogContentText from "@mui/material/DialogContentText"; +import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; const Account = () => { if (!session.exists()) { @@ -140,11 +136,10 @@ const ChangePassword = () => { const ChangePasswordDialog = (props) => { const { t } = useTranslation(); + const [error, setError] = useState(""); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [errorText, setErrorText] = useState(""); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const handleDialogSubmit = async () => { @@ -154,12 +149,13 @@ const ChangePasswordDialog = (props) => { props.onClose(); } catch (e) { console.log(`[Account] Error changing password`, e); - if ((e instanceof IncorrectPasswordError)) { - setErrorText(t("account_basics_password_dialog_current_password_incorrect")); - } else if ((e instanceof UnauthorizedError)) { + if (e instanceof IncorrectPasswordError) { + setError(t("account_basics_password_dialog_current_password_incorrect")); + } else if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); + } else { + setError(e.message); } - // TODO show error } }; @@ -201,7 +197,7 @@ const ChangePasswordDialog = (props) => { variant="standard" /> - + @@ -658,6 +661,7 @@ const TokenDialog = (props) => { const TokenDeleteDialog = (props) => { const { t } = useTranslation(); + const [error, setError] = useState(""); const handleSubmit = async () => { try { @@ -665,10 +669,11 @@ const TokenDeleteDialog = (props) => { props.onClose(); } catch (e) { console.log(`[Account] Error deleting token`, e); - if ((e instanceof UnauthorizedError)) { + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); + } else { + setError(e.message); } - // TODO show error } }; @@ -680,10 +685,10 @@ const TokenDeleteDialog = (props) => { - + - + ); } @@ -736,8 +741,8 @@ const DeleteAccount = () => { const DeleteAccountDialog = (props) => { const { t } = useTranslation(); const { account } = useContext(AccountContext); + const [error, setError] = useState(""); const [password, setPassword] = useState(""); - const [errorText, setErrorText] = useState(""); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const handleSubmit = async () => { @@ -748,12 +753,13 @@ const DeleteAccountDialog = (props) => { session.resetAndRedirect(routes.app); } catch (e) { console.log(`[Account] Error deleting account`, e); - if ((e instanceof IncorrectPasswordError)) { - setErrorText(t("account_basics_password_dialog_current_password_incorrect")); - } else if ((e instanceof UnauthorizedError)) { + if (e instanceof IncorrectPasswordError) { + setError(t("account_basics_password_dialog_current_password_incorrect")); + } else if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); + } else { + setError(e.message); } - // TODO show error } }; @@ -779,7 +785,7 @@ const DeleteAccountDialog = (props) => { {t("account_delete_dialog_billing_warning")} } - + diff --git a/web/src/components/Login.js b/web/src/components/Login.js index 2b7d38f8..2e2389cc 100644 --- a/web/src/components/Login.js +++ b/web/src/components/Login.js @@ -10,10 +10,11 @@ import session from "../app/Session"; import {NavLink} from "react-router-dom"; import AvatarBox from "./AvatarBox"; import {useTranslation} from "react-i18next"; -import accountApi, {UnauthorizedError} from "../app/AccountApi"; +import accountApi from "../app/AccountApi"; import IconButton from "@mui/material/IconButton"; import {InputAdornment} from "@mui/material"; import {Visibility, VisibilityOff} from "@mui/icons-material"; +import {UnauthorizedError} from "../app/errors"; const Login = () => { const { t } = useTranslation(); @@ -32,12 +33,10 @@ const Login = () => { window.location.href = routes.app; } catch (e) { console.log(`[Login] User auth for user ${user.username} failed`, e); - if ((e instanceof UnauthorizedError)) { + if (e instanceof UnauthorizedError) { setError(t("Login failed: Invalid username or password")); - } else if (e.message) { - setError(e.message); } else { - setError(t("Unknown error. Check logs for details.")) + setError(e.message); } } }; diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 1a949188..4240f112 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -39,13 +39,16 @@ import {playSound, shuffle, sounds, validUrl} from "../app/utils"; import {useTranslation} from "react-i18next"; import session from "../app/Session"; import routes from "./routes"; -import accountApi, {Permission, Role, UnauthorizedError} from "../app/AccountApi"; +import accountApi, {Permission, Role} from "../app/AccountApi"; import {Pref, PrefGroup} from "./Pref"; import {Info} from "@mui/icons-material"; import {AccountContext} from "./App"; import {useOutletContext} from "react-router-dom"; import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs"; +import {UnauthorizedError} from "../app/errors"; +import subscriptionManager from "../app/SubscriptionManager"; +import {subscribeTopic} from "./SubscribeDialog"; const Preferences = () => { return ( @@ -484,7 +487,7 @@ const Reservations = () => { const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); - if (!config.enable_reservations || !session.exists() || !account || account.role === Role.ADMIN) { + if (!config.enable_reservations || !session.exists() || !account) { return <>; } const reservations = account.reservations || []; @@ -543,6 +546,10 @@ const ReservationsTable = (props) => { setDeleteDialogOpen(true); }; + const handleSubscribeClick = async (reservation) => { + await subscribeTopic(config.base_url, reservation.topic); + }; + return ( @@ -589,7 +596,9 @@ const ReservationsTable = (props) => { {!localSubscriptions[reservation.topic] && - } label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/> + + } onClick={() => handleSubscribeClick(reservation)} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/> + } handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> @@ -626,7 +635,7 @@ const maybeUpdateAccountSettings = async (payload) => { await accountApi.updateSettings(payload); } catch (e) { console.log(`[Preferences] Error updating account settings`, e); - if ((e instanceof UnauthorizedError)) { + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); } } diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index 08646fc0..bdf6fb62 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -27,7 +27,8 @@ 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"; +import accountApi from "../app/AccountApi"; +import {UnauthorizedError} from "../app/errors"; const PublishDialog = (props) => { const { t } = useTranslation(); @@ -179,7 +180,7 @@ const PublishDialog = (props) => { setAttachFileError(""); } catch (e) { console.log(`[PublishDialog] Retrieving attachment limits failed`, e); - if ((e instanceof UnauthorizedError)) { + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); } else { setAttachFileError(""); // Reset error (rely on server-side checking) diff --git a/web/src/components/ReserveDialogs.js b/web/src/components/ReserveDialogs.js index 65a2c16b..33f2db35 100644 --- a/web/src/components/ReserveDialogs.js +++ b/web/src/components/ReserveDialogs.js @@ -1,46 +1,31 @@ import * as React from 'react'; -import {useContext, useEffect, useState} from 'react'; +import {useState} from 'react'; import Button from '@mui/material/Button'; import TextField from '@mui/material/TextField'; import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; -import { - Alert, - Autocomplete, - Checkbox, - FormControl, - FormControlLabel, - FormGroup, - Select, - useMediaQuery -} from "@mui/material"; +import {Alert, FormControl, Select, useMediaQuery} from "@mui/material"; import theme from "./theme"; -import api from "../app/Api"; -import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils"; -import userManager from "../app/UserManager"; -import subscriptionManager from "../app/SubscriptionManager"; -import poller from "../app/Poller"; +import {validTopic} from "../app/utils"; import DialogFooter from "./DialogFooter"; import {useTranslation} from "react-i18next"; import session from "../app/Session"; import routes from "./routes"; -import accountApi, {Permission, Role, TopicReservedError, UnauthorizedError} from "../app/AccountApi"; +import accountApi, {Permission} from "../app/AccountApi"; import ReserveTopicSelect from "./ReserveTopicSelect"; -import {AccountContext} from "./App"; -import DialogActions from "@mui/material/DialogActions"; import MenuItem from "@mui/material/MenuItem"; import ListItemIcon from "@mui/material/ListItemIcon"; -import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; import ListItemText from "@mui/material/ListItemText"; import {Check, DeleteForever} from "@mui/icons-material"; +import {TopicReservedError, UnauthorizedError} from "../app/errors"; export const ReserveAddDialog = (props) => { const { t } = useTranslation(); + const [error, setError] = useState(""); const [topic, setTopic] = useState(props.topic || ""); const [everyone, setEveryone] = useState(Permission.DENY_ALL); - const [errorText, setErrorText] = useState(""); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const allowTopicEdit = !props.topic; const alreadyReserved = props.reservations.filter(r => r.topic === topic).length > 0; @@ -52,15 +37,17 @@ export const ReserveAddDialog = (props) => { console.debug(`[ReserveAddDialog] Added reservation for topic ${t}: ${everyone}`); } catch (e) { console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); - if ((e instanceof UnauthorizedError)) { + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); - } else if ((e instanceof TopicReservedError)) { - setErrorText(t("subscribe_dialog_error_topic_already_reserved")); + } else if (e instanceof TopicReservedError) { + setError(t("subscribe_dialog_error_topic_already_reserved")); + return; + } else { + setError(e.message); return; } } props.onClose(); - // FIXME handle 401/403/409 }; return ( @@ -88,7 +75,7 @@ export const ReserveAddDialog = (props) => { sx={{mt: 1}} /> - + @@ -98,6 +85,7 @@ export const ReserveAddDialog = (props) => { export const ReserveEditDialog = (props) => { const { t } = useTranslation(); + const [error, setError] = useState(""); const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); @@ -107,12 +95,14 @@ export const ReserveEditDialog = (props) => { console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`); } catch (e) { console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); - if ((e instanceof UnauthorizedError)) { + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; } } props.onClose(); - // FIXME handle 401/403/409 }; return ( @@ -128,31 +118,34 @@ export const ReserveEditDialog = (props) => { sx={{mt: 1}} /> - + - + ); }; export const ReserveDeleteDialog = (props) => { const { t } = useTranslation(); + const [error, setError] = useState(""); const [deleteMessages, setDeleteMessages] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const handleSubmit = async () => { try { await accountApi.deleteReservation(props.topic, deleteMessages); - console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${t}`); + console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`); } catch (e) { console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); - if ((e instanceof UnauthorizedError)) { + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; } } props.onClose(); - // FIXME handle 401/403/409 }; return ( @@ -196,10 +189,10 @@ export const ReserveDeleteDialog = (props) => { } - + - + ); }; diff --git a/web/src/components/Signup.js b/web/src/components/Signup.js index c5cbaf04..856ce8f1 100644 --- a/web/src/components/Signup.js +++ b/web/src/components/Signup.js @@ -10,10 +10,11 @@ import {NavLink} from "react-router-dom"; import AvatarBox from "./AvatarBox"; import {useTranslation} from "react-i18next"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; -import accountApi, {AccountCreateLimitReachedError, UsernameTakenError} from "../app/AccountApi"; +import accountApi from "../app/AccountApi"; import {InputAdornment} from "@mui/material"; import IconButton from "@mui/material/IconButton"; import {Visibility, VisibilityOff} from "@mui/icons-material"; +import {AccountCreateLimitReachedError, UserExistsError} from "../app/errors"; const Signup = () => { const { t } = useTranslation(); @@ -35,14 +36,12 @@ const Signup = () => { window.location.href = routes.app; } catch (e) { console.log(`[Signup] Signup for user ${user.username} failed`, e); - if ((e instanceof UsernameTakenError)) { + if (e instanceof UserExistsError) { setError(t("signup_error_username_taken", { username: e.username })); } else if ((e instanceof AccountCreateLimitReachedError)) { setError(t("signup_error_creation_limit_reached")); - } else if (e.message) { - setError(e.message); } else { - setError(t("signup_error_unknown")) + setError(e.message); } } }; diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 9460f523..2e1043c7 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -17,9 +17,10 @@ import DialogFooter from "./DialogFooter"; import {useTranslation} from "react-i18next"; import session from "../app/Session"; import routes from "./routes"; -import accountApi, {Role, TopicReservedError, UnauthorizedError} from "../app/AccountApi"; +import accountApi, {Role} from "../app/AccountApi"; import ReserveTopicSelect from "./ReserveTopicSelect"; import {AccountContext} from "./App"; +import {TopicReservedError, UnauthorizedError} from "../app/errors"; const publicBaseUrl = "https://ntfy.sh"; @@ -32,22 +33,7 @@ const SubscribeDialog = (props) => { const handleSuccess = async () => { console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); const actualBaseUrl = (baseUrl) ? baseUrl : config.base_url; - const subscription = await subscriptionManager.add(actualBaseUrl, topic); - if (session.exists()) { - try { - const remoteSubscription = await accountApi.addSubscription({ - base_url: actualBaseUrl, - topic: topic - }); - await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); - await accountApi.sync(); - } catch (e) { - console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); - if ((e instanceof UnauthorizedError)) { - session.resetAndRedirect(routes.login); - } - } - } + const subscription = subscribeTopic(actualBaseUrl, topic); poller.pollInBackground(subscription); // Dangle! props.onSuccess(subscription); } @@ -77,9 +63,9 @@ const SubscribeDialog = (props) => { const SubscribePage = (props) => { const { t } = useTranslation(); const { account } = useContext(AccountContext); + const [error, setError] = useState(""); const [reserveTopicVisible, setReserveTopicVisible] = useState(false); const [anotherServerVisible, setAnotherServerVisible] = useState(false); - const [errorText, setErrorText] = useState(""); const [everyone, setEveryone] = useState("deny-all"); const baseUrl = (anotherServerVisible) ? props.baseUrl : config.base_url; const topic = props.topic; @@ -98,7 +84,7 @@ const SubscribePage = (props) => { if (!success) { console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); if (user) { - setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username })); + setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); return; } else { props.onNeedsLogin(); @@ -114,10 +100,10 @@ const SubscribePage = (props) => { // Account sync later after it was added } catch (e) { console.log(`[SubscribeDialog] Error reserving topic`, e); - if ((e instanceof UnauthorizedError)) { + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); - } else if ((e instanceof TopicReservedError)) { - setErrorText(t("subscribe_dialog_error_topic_already_reserved")); + } else if (e instanceof TopicReservedError) { + setError(t("subscribe_dialog_error_topic_already_reserved")); return; } } @@ -231,7 +217,7 @@ const SubscribePage = (props) => { } - + @@ -243,21 +229,23 @@ const LoginPage = (props) => { const { t } = useTranslation(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [errorText, setErrorText] = useState(""); + const [error, setError] = useState(""); const baseUrl = (props.baseUrl) ? props.baseUrl : config.base_url; const topic = props.topic; + const handleLogin = async () => { const user = {baseUrl, username, password}; const success = await api.topicAuth(baseUrl, topic, user); if (!success) { console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); - setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username })); + setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); return; } console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); await userManager.save(user); props.onSuccess(); }; + return ( <> {t("subscribe_dialog_login_title")} @@ -293,7 +281,7 @@ const LoginPage = (props) => { }} /> - + @@ -301,4 +289,23 @@ const LoginPage = (props) => { ); }; +export const subscribeTopic = async (baseUrl, topic) => { + const subscription = await subscriptionManager.add(baseUrl, topic); + if (session.exists()) { + try { + const remoteSubscription = await accountApi.addSubscription({ + base_url: baseUrl, + topic: topic + }); + await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); + } catch (e) { + console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } + } + } + return subscription; +}; + export default SubscribeDialog; diff --git a/web/src/components/SubscriptionPopup.js b/web/src/components/SubscriptionPopup.js index caa3b575..46b9bd2b 100644 --- a/web/src/components/SubscriptionPopup.js +++ b/web/src/components/SubscriptionPopup.js @@ -11,10 +11,9 @@ import theme from "./theme"; import subscriptionManager from "../app/SubscriptionManager"; import DialogFooter from "./DialogFooter"; import {useTranslation} from "react-i18next"; -import accountApi, {Permission, UnauthorizedError} from "../app/AccountApi"; +import accountApi from "../app/AccountApi"; import session from "../app/Session"; import routes from "./routes"; -import ReserveTopicSelect from "./ReserveTopicSelect"; import MenuItem from "@mui/material/MenuItem"; import PopupMenu from "./PopupMenu"; import {formatShortDateTime, shuffle} from "../app/utils"; @@ -23,7 +22,8 @@ import {useNavigate} from "react-router-dom"; import IconButton from "@mui/material/IconButton"; import {Clear} from "@mui/icons-material"; import {AccountContext} from "./App"; -import {ReserveEditDialog, ReserveAddDialog, ReserveDeleteDialog} from "./ReserveDialogs"; +import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs"; +import {UnauthorizedError} from "../app/errors"; const SubscriptionPopup = (props) => { const { t } = useTranslation(); @@ -96,25 +96,25 @@ const SubscriptionPopup = (props) => { tags: tags }); } catch (e) { - console.log(`[ActionBar] Error publishing message`, e); + console.log(`[SubscriptionPopup] Error publishing message`, e); setShowPublishError(true); } } const handleClearAll = async () => { - console.log(`[ActionBar] Deleting all notifications from ${props.subscription.id}`); + console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`); await subscriptionManager.deleteNotifications(props.subscription.id); }; - const handleUnsubscribe = async (event) => { - console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`, props.subscription); + const handleUnsubscribe = async () => { + console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription); await subscriptionManager.remove(props.subscription.id); if (session.exists() && props.subscription.remoteId) { try { await accountApi.deleteSubscription(props.subscription.remoteId); } catch (e) { - console.log(`[ActionBar] Error unsubscribing`, e); - if ((e instanceof UnauthorizedError)) { + console.log(`[SubscriptionPopup] Error unsubscribing`, e); + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); } } @@ -187,25 +187,24 @@ const SubscriptionPopup = (props) => { const DisplayNameDialog = (props) => { const { t } = useTranslation(); const subscription = props.subscription; + const [error, setError] = useState(""); const [displayName, setDisplayName] = useState(subscription.displayName ?? ""); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const handleSave = async () => { - // Apply locally await subscriptionManager.setDisplayName(subscription.id, displayName); - - // Apply remotely if (session.exists() && subscription.remoteId) { try { console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`); await accountApi.updateSubscription(subscription.remoteId, { display_name: displayName }); } catch (e) { console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); - if ((e instanceof UnauthorizedError)) { + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; } - - // FIXME handle 409 } } props.onClose(); @@ -241,7 +240,7 @@ const DisplayNameDialog = (props) => { }} /> - + diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index 4bd21012..e87fe5ca 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -7,7 +7,7 @@ import {Alert, CardActionArea, CardContent, ListItem, useMediaQuery} from "@mui/ import theme from "./theme"; import DialogFooter from "./DialogFooter"; import Button from "@mui/material/Button"; -import accountApi, {UnauthorizedError} from "../app/AccountApi"; +import accountApi from "../app/AccountApi"; import session from "../app/Session"; import routes from "./routes"; import Card from "@mui/material/Card"; @@ -21,19 +21,24 @@ import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; import Box from "@mui/material/Box"; import {NavLink} from "react-router-dom"; +import {UnauthorizedError} from "../app/errors"; const UpgradeDialog = (props) => { const { t } = useTranslation(); const { account } = useContext(AccountContext); // May be undefined! - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const [error, setError] = useState(""); const [tiers, setTiers] = useState(null); const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined const [loading, setLoading] = useState(false); - const [errorText, setErrorText] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); useEffect(() => { (async () => { - setTiers(await accountApi.billingTiers()); + try { + setTiers(await accountApi.billingTiers()); + } catch (e) { + setError(e.message); + } })(); }, []); @@ -96,10 +101,11 @@ const UpgradeDialog = (props) => { props.onCancel(); } catch (e) { console.log(`[UpgradeDialog] Error changing billing subscription`, e); - if ((e instanceof UnauthorizedError)) { + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); + } else { + setError(e.message); } - // FIXME show error } finally { setLoading(false); } @@ -155,7 +161,7 @@ const UpgradeDialog = (props) => { } - + diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 43f4ddf8..c6f85df8 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -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 accountApi, {UnauthorizedError} from "../app/AccountApi"; +import accountApi from "../app/AccountApi"; +import {UnauthorizedError} from "../app/errors"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -94,7 +95,7 @@ export const useAutoSubscribe = (subscriptions, selected) => { const eligible = params.topic && !selected && !disallowedTopic(params.topic); if (eligible) { const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : config.base_url; - console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); + console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); (async () => { const subscription = await subscriptionManager.add(baseUrl, params.topic); if (session.exists()) { @@ -105,8 +106,8 @@ export const useAutoSubscribe = (subscriptions, selected) => { }); await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); } catch (e) { - console.log(`[App] Auto-subscribing failed`, e); - if ((e instanceof UnauthorizedError)) { + console.log(`[Hooks] Auto-subscribing failed`, e); + if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); } }