JS error handling

This commit is contained in:
binwiederhier 2023-02-02 15:19:37 -05:00
parent 180a7df1e7
commit 0885951a67
20 changed files with 369 additions and 366 deletions

View file

@ -58,11 +58,10 @@ var (
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""} errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""}
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""} errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""}
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""} 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", ""} errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""} errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""} 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", ""} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}

View file

@ -35,11 +35,14 @@ import (
/* /*
- HIGH Rate limiting: Sensitive endpoints (account/login/change-password/...) - HIGH Rate limiting: Sensitive endpoints (account/login/change-password/...)
- HIGH Account limit creation triggers when account is taken!
- HIGH Docs - 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 for expiring messages after reservation removal
- MEDIUM: Test new token endpoints & never-expiring token - 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 - LOW: UI: Flickering upgrade banner when logging in
*/ */

View file

@ -20,8 +20,7 @@ const (
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User() u := v.User()
admin := u != nil && u.Role == user.RoleAdmin if !u.Admin() { // u may be nil, but that's fine
if !admin {
if !s.config.EnableSignup { if !s.config.EnableSignup {
return errHTTPBadRequestSignupNotEnabled return errHTTPBadRequestSignupNotEnabled
} else if u != nil { } else if u != nil {
@ -380,11 +379,11 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.
return s.writeJSON(w, newSuccessResponse()) 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 { func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User() u := v.User()
if u != nil && u.Role == user.RoleAdmin {
return errHTTPBadRequestMakesNoSenseForAdmin
}
req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false) req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil { if err != nil {
return err return err
@ -396,23 +395,23 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
if err != nil { if err != nil {
return errHTTPBadRequestPermissionInvalid return errHTTPBadRequestPermissionInvalid
} }
if u.Tier == nil { // Check if we are allowed to reserve this topic
if u.User() && u.Tier == nil {
return errHTTPUnauthorized return errHTTPUnauthorized
} } else if err := s.userManager.CheckAllowAccess(u.Name, req.Topic); err != nil {
// CHeck if we are allowed to reserve this topic
if err := s.userManager.CheckAllowAccess(u.Name, req.Topic); err != nil {
return errHTTPConflictTopicReserved return errHTTPConflictTopicReserved
} } else if u.User() {
hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic) hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic)
if err != nil {
return err
}
if !hasReservation {
reservations, err := s.userManager.ReservationsCount(u.Name)
if err != nil { if err != nil {
return err 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 // Actually add the reservation
@ -428,6 +427,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
return s.writeJSON(w, newSuccessResponse()) 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 { func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
matches := apiAccountReservationSingleRegex.FindStringSubmatch(r.URL.Path) matches := apiAccountReservationSingleRegex.FindStringSubmatch(r.URL.Path)
if len(matches) != 2 { if len(matches) != 2 {

View file

@ -435,13 +435,52 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
conf := newTestConfigWithAuthFile(t) conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true conf.EnableSignup = true
s := newTestServer(t, conf) 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)) 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"), "Authorization": util.BasicAuth("phil", "adminpass"),
}) })
require.Equal(t, 400, rr.Code) require.Equal(t, 200, rr.Code)
require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).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) { func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {

View file

@ -254,6 +254,12 @@ func (v *visitor) User() *user.User {
return v.user // May be nil 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 // IP returns the visitor IP address
func (v *visitor) IP() netip.Addr { func (v *visitor) IP() netip.Addr {
v.mu.Lock() v.mu.Lock()

View file

@ -33,6 +33,16 @@ func (u *User) TierID() string {
return u.Tier.ID 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 // Auther is an interface for authentication and authorization
type Auther interface { type Auther interface {
// Authenticate checks username and password and returns a user if correct. The method // Authenticate checks username and password and returns a user if correct. The method

View file

@ -11,7 +11,6 @@
"signup_disabled": "Signup is disabled", "signup_disabled": "Signup is disabled",
"signup_error_username_taken": "Username {{username}} is already taken", "signup_error_username_taken": "Username {{username}} is already taken",
"signup_error_creation_limit_reached": "Account creation limit reached", "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_title": "Sign in to your ntfy account",
"login_form_button_submit": "Sign in", "login_form_button_submit": "Sign in",
"login_link_signup": "Sign up", "login_link_signup": "Sign up",
@ -197,9 +196,11 @@
"account_usage_messages_title": "Published messages", "account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent", "account_usage_emails_title": "Emails sent",
"account_usage_reservations_title": "Reserved topics", "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_title": "Attachment storage",
"account_usage_attachment_storage_description": "{{filesize}} per file, deleted after {{expiry}}", "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_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_title": "Delete account",
"account_delete_description": "Permanently delete your 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.", "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_write_only": "I can publish and subscribe, everyone can publish",
"prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe", "prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe",
"prefs_reservations_table_not_subscribed": "Not subscribed", "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_add": "Reserve topic",
"prefs_reservations_dialog_title_edit": "Edit reserved topic", "prefs_reservations_dialog_title_edit": "Edit reserved topic",
"prefs_reservations_dialog_title_delete": "Delete topic reservation", "prefs_reservations_dialog_title_delete": "Delete topic reservation",

View file

@ -18,6 +18,7 @@ import subscriptionManager from "./SubscriptionManager";
import i18n from "i18next"; import i18n from "i18next";
import prefs from "./Prefs"; import prefs from "./Prefs";
import routes from "../components/routes"; import routes from "../components/routes";
import {fetchOrThrow, throwAppError, UnauthorizedError} from "./errors";
const delayMillis = 45000; // 45 seconds const delayMillis = 45000; // 45 seconds
const intervalMillis = 900000; // 15 minutes const intervalMillis = 900000; // 15 minutes
@ -39,16 +40,11 @@ class AccountApi {
async login(user) { async login(user) {
const url = accountTokenUrl(config.base_url); const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Checking auth for ${url}`); console.log(`[AccountApi] Checking auth for ${url}`);
const response = await fetch(url, { const response = await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBasicAuth({}, user.username, user.password) headers: withBasicAuth({}, user.username, user.password)
}); });
if (response.status === 401 || response.status === 403) { const json = await response.json(); // May throw SyntaxError
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const json = await response.json();
if (!json.token) { if (!json.token) {
throw new Error(`Unexpected server response: Cannot find token`); throw new Error(`Unexpected server response: Cannot find token`);
} }
@ -58,15 +54,10 @@ class AccountApi {
async logout() { async logout() {
const url = accountTokenUrl(config.base_url); const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`); console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({}, session.token()) 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) { async create(username, password) {
@ -76,31 +67,19 @@ class AccountApi {
password: password password: password
}); });
console.log(`[AccountApi] Creating user account ${url}`); console.log(`[AccountApi] Creating user account ${url}`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "POST", method: "POST",
body: body 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() { async get() {
const url = accountUrl(config.base_url); const url = accountUrl(config.base_url);
console.log(`[AccountApi] Fetching user account ${url}`); console.log(`[AccountApi] Fetching user account ${url}`);
const response = await fetch(url, { const response = await fetchOrThrow(url, {
headers: withBearerAuth({}, session.token()) headers: withBearerAuth({}, session.token())
}); });
if (response.status === 401 || response.status === 403) { const account = await response.json(); // May throw SyntaxError
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const account = await response.json();
console.log(`[AccountApi] Account`, account); console.log(`[AccountApi] Account`, account);
if (this.listener) { if (this.listener) {
this.listener(account); this.listener(account);
@ -111,26 +90,19 @@ class AccountApi {
async delete(password) { async delete(password) {
const url = accountUrl(config.base_url); const url = accountUrl(config.base_url);
console.log(`[AccountApi] Deleting user account ${url}`); console.log(`[AccountApi] Deleting user account ${url}`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
password: password 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) { async changePassword(currentPassword, newPassword) {
const url = accountPasswordUrl(config.base_url); const url = accountPasswordUrl(config.base_url);
console.log(`[AccountApi] Changing account password ${url}`); console.log(`[AccountApi] Changing account password ${url}`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
@ -138,13 +110,6 @@ class AccountApi {
new_password: newPassword 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) { async createToken(label, expires) {
@ -154,16 +119,11 @@ class AccountApi {
expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0 expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0
}; };
console.log(`[AccountApi] Creating user access token ${url}`); console.log(`[AccountApi] Creating user access token ${url}`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body) 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) { async updateToken(token, label, expires) {
@ -176,22 +136,17 @@ class AccountApi {
body.expires = Math.floor(Date.now() / 1000) + expires; body.expires = Math.floor(Date.now() / 1000) + expires;
} }
console.log(`[AccountApi] Creating user access token ${url}`); console.log(`[AccountApi] Creating user access token ${url}`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "PATCH", method: "PATCH",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify(body) 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() { async extendToken() {
const url = accountTokenUrl(config.base_url); const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Extending user access token ${url}`); console.log(`[AccountApi] Extending user access token ${url}`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "PATCH", method: "PATCH",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
@ -199,58 +154,38 @@ class AccountApi {
expires: Math.floor(Date.now() / 1000) + 6220800 // FIXME 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) { async deleteToken(token) {
const url = accountTokenUrl(config.base_url); const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Deleting user access token ${url}`); console.log(`[AccountApi] Deleting user access token ${url}`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({"X-Token": token}, session.token()) 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) { async updateSettings(payload) {
const url = accountSettingsUrl(config.base_url); const url = accountSettingsUrl(config.base_url);
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
console.log(`[AccountApi] Updating user account ${url}: ${body}`); console.log(`[AccountApi] Updating user account ${url}: ${body}`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "PATCH", method: "PATCH",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body 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) { async addSubscription(payload) {
const url = accountSubscriptionUrl(config.base_url); const url = accountSubscriptionUrl(config.base_url);
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
const response = await fetch(url, { const response = await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body body: body
}); });
if (response.status === 401 || response.status === 403) { const subscription = await response.json(); // May throw SyntaxError
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const subscription = await response.json();
console.log(`[AccountApi] Subscription`, subscription); console.log(`[AccountApi] Subscription`, subscription);
return subscription; return subscription;
} }
@ -259,17 +194,12 @@ class AccountApi {
const url = accountSubscriptionSingleUrl(config.base_url, remoteId); const url = accountSubscriptionSingleUrl(config.base_url, remoteId);
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
const response = await fetch(url, { const response = await fetchOrThrow(url, {
method: "PATCH", method: "PATCH",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: body body: body
}); });
if (response.status === 401 || response.status === 403) { const subscription = await response.json(); // May throw SyntaxError
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const subscription = await response.json();
console.log(`[AccountApi] Subscription`, subscription); console.log(`[AccountApi] Subscription`, subscription);
return subscription; return subscription;
} }
@ -277,21 +207,16 @@ class AccountApi {
async deleteSubscription(remoteId) { async deleteSubscription(remoteId) {
const url = accountSubscriptionSingleUrl(config.base_url, remoteId); const url = accountSubscriptionSingleUrl(config.base_url, remoteId);
console.log(`[AccountApi] Removing user subscription ${url}`); console.log(`[AccountApi] Removing user subscription ${url}`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({}, session.token()) 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) { async upsertReservation(topic, everyone) {
const url = accountReservationUrl(config.base_url); const url = accountReservationUrl(config.base_url);
console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`); console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
@ -299,13 +224,6 @@ class AccountApi {
everyone: everyone 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) { async deleteReservation(topic, deleteMessages) {
@ -314,25 +232,17 @@ class AccountApi {
const headers = { const headers = {
"X-Delete-Messages": deleteMessages ? "true" : "false" "X-Delete-Messages": deleteMessages ? "true" : "false"
} }
const response = await fetch(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth(headers, session.token()) 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() { async billingTiers() {
const url = tiersUrl(config.base_url); const url = tiersUrl(config.base_url);
console.log(`[AccountApi] Fetching billing tiers`); console.log(`[AccountApi] Fetching billing tiers`);
const response = await fetch(url); // No auth needed! const response = await fetchOrThrow(url); // No auth needed!
if (response.status !== 200) { return await response.json(); // May throw SyntaxError
throw new Error(`Unexpected server response ${response.status}`);
}
return await response.json();
} }
async createBillingSubscription(tier) { async createBillingSubscription(tier) {
@ -347,48 +257,33 @@ class AccountApi {
async upsertBillingSubscription(method, tier) { async upsertBillingSubscription(method, tier) {
const url = accountBillingSubscriptionUrl(config.base_url); const url = accountBillingSubscriptionUrl(config.base_url);
const response = await fetch(url, { const response = await fetchOrThrow(url, {
method: method, method: method,
headers: withBearerAuth({}, session.token()), headers: withBearerAuth({}, session.token()),
body: JSON.stringify({ body: JSON.stringify({
tier: tier tier: tier
}) })
}); });
if (response.status === 401 || response.status === 403) { return await response.json(); // May throw SyntaxError
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
return await response.json();
} }
async deleteBillingSubscription() { async deleteBillingSubscription() {
const url = accountBillingSubscriptionUrl(config.base_url); const url = accountBillingSubscriptionUrl(config.base_url);
console.log(`[AccountApi] Cancelling billing subscription`); console.log(`[AccountApi] Cancelling billing subscription`);
const response = await fetch(url, { await fetchOrThrow(url, {
method: "DELETE", method: "DELETE",
headers: withBearerAuth({}, session.token()) 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() { async createBillingPortalSession() {
const url = accountBillingPortalUrl(config.base_url); const url = accountBillingPortalUrl(config.base_url);
console.log(`[AccountApi] Creating billing portal session`); console.log(`[AccountApi] Creating billing portal session`);
const response = await fetch(url, { const response = await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBearerAuth({}, session.token()) headers: withBearerAuth({}, session.token())
}); });
if (response.status === 401 || response.status === 403) { return await response.json(); // May throw SyntaxError
throw new UnauthorizedError();
} else if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
return await response.json();
} }
async sync() { async sync() {
@ -418,7 +313,7 @@ class AccountApi {
return account; return account;
} catch (e) { } catch (e) {
console.log(`[AccountApi] Error fetching account`, e); console.log(`[AccountApi] Error fetching account`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} }
} }
@ -472,37 +367,5 @@ export const Permission = {
DENY_ALL: "deny-all" 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(); const accountApi = new AccountApi();
export default accountApi; export default accountApi;

View file

@ -8,6 +8,7 @@ import {
topicUrlJsonPollWithSince topicUrlJsonPollWithSince
} from "./utils"; } from "./utils";
import userManager from "./UserManager"; import userManager from "./UserManager";
import {fetchOrThrow} from "./errors";
class Api { class Api {
async poll(baseUrl, topic, since) { async poll(baseUrl, topic, since) {
@ -35,15 +36,11 @@ class Api {
message: message, message: message,
...options ...options
}; };
const response = await fetch(baseUrl, { await fetchOrThrow(baseUrl, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body), body: JSON.stringify(body),
headers: maybeWithAuth(headers, user) 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) { if (response.status >= 200 && response.status <= 299) {
return true; return true;
} else if (!user && response.status === 404) {
return true; // Special case: Anonymous login to old servers return 404 since /<topic>/auth doesn't exist
} else if (response.status === 401 || response.status === 403) { // See server/server.go } else if (response.status === 401 || response.status === 403) { // See server/server.go
return false; return false;
} }

66
web/src/app/errors.js Normal file
View file

@ -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"); }
}

View file

@ -1,12 +1,19 @@
import * as React from 'react'; import * as React from 'react';
import {useContext, useEffect, useState} from 'react'; import {useContext, useState} from 'react';
import { import {
Alert, Alert,
CardActions, CardActions,
CardContent, FormControl, CardContent,
LinearProgress, Link, Portal, Select, Snackbar, FormControl,
LinearProgress,
Link,
Portal,
Select,
Snackbar,
Stack, Stack,
Table, TableBody, TableCell, Table,
TableBody,
TableCell,
TableHead, TableHead,
TableRow, TableRow,
useMediaQuery useMediaQuery
@ -27,14 +34,8 @@ import DialogContent from "@mui/material/DialogContent";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import routes from "./routes"; import routes from "./routes";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import {formatBytes, formatShortDate, formatShortDateTime, openUrl, truncateString, validUrl} from "../app/utils"; import {formatBytes, formatShortDate, formatShortDateTime, openUrl} from "../app/utils";
import accountApi, { import accountApi, {LimitBasis, Role, SubscriptionStatus} from "../app/AccountApi";
IncorrectPasswordError,
LimitBasis,
Role,
SubscriptionStatus,
UnauthorizedError
} from "../app/AccountApi";
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import {Pref, PrefGroup} from "./Pref"; import {Pref, PrefGroup} from "./Pref";
import db from "../app/db"; import db from "../app/db";
@ -44,17 +45,12 @@ import UpgradeDialog from "./UpgradeDialog";
import CelebrationIcon from "@mui/icons-material/Celebration"; import CelebrationIcon from "@mui/icons-material/Celebration";
import {AccountContext} from "./App"; import {AccountContext} from "./App";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import {useLiveQuery} from "dexie-react-hooks";
import userManager from "../app/UserManager";
import {Paragraph} from "./styles"; import {Paragraph} from "./styles";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import DialogActions from "@mui/material/DialogActions";
import {ContentCopy, Public} from "@mui/icons-material"; import {ContentCopy, Public} from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem"; 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 DialogContentText from "@mui/material/DialogContentText";
import {IncorrectPasswordError, UnauthorizedError} from "../app/errors";
const Account = () => { const Account = () => {
if (!session.exists()) { if (!session.exists()) {
@ -140,11 +136,10 @@ const ChangePassword = () => {
const ChangePasswordDialog = (props) => { const ChangePasswordDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState("");
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [errorText, setErrorText] = useState("");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleDialogSubmit = async () => { const handleDialogSubmit = async () => {
@ -154,12 +149,13 @@ const ChangePasswordDialog = (props) => {
props.onClose(); props.onClose();
} catch (e) { } catch (e) {
console.log(`[Account] Error changing password`, e); console.log(`[Account] Error changing password`, e);
if ((e instanceof IncorrectPasswordError)) { if (e instanceof IncorrectPasswordError) {
setErrorText(t("account_basics_password_dialog_current_password_incorrect")); setError(t("account_basics_password_dialog_current_password_incorrect"));
} else if ((e instanceof UnauthorizedError)) { } else if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else {
setError(e.message);
} }
// TODO show error
} }
}; };
@ -201,7 +197,7 @@ const ChangePasswordDialog = (props) => {
variant="standard" variant="standard"
/> />
</DialogContent> </DialogContent>
<DialogFooter status={errorText}> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("account_basics_password_dialog_button_cancel")}</Button> <Button onClick={props.onClose}>{t("account_basics_password_dialog_button_cancel")}</Button>
<Button <Button
onClick={handleDialogSubmit} onClick={handleDialogSubmit}
@ -219,6 +215,7 @@ const AccountType = () => {
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
const [upgradeDialogKey, setUpgradeDialogKey] = useState(0); const [upgradeDialogKey, setUpgradeDialogKey] = useState(0);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [showPortalError, setShowPortalError] = useState(false);
if (!account) { if (!account) {
return <></>; return <></>;
@ -234,11 +231,12 @@ const AccountType = () => {
const response = await accountApi.createBillingPortalSession(); const response = await accountApi.createBillingPortalSession();
window.open(response.redirect_url, "billing_portal"); window.open(response.redirect_url, "billing_portal");
} catch (e) { } catch (e) {
console.log(`[Account] Error changing password`, e); console.log(`[Account] Error opening billing portal`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else {
setShowPortalError(true);
} }
// TODO show error
} }
}; };
@ -302,6 +300,14 @@ const AccountType = () => {
{account.billing?.cancel_at > 0 && {account.billing?.cancel_at > 0 &&
<Alert severity="warning" sx={{mt: 1}}>{t("account_usage_tier_canceled_subscription", { date: formatShortDate(account.billing.cancel_at) })}</Alert> <Alert severity="warning" sx={{mt: 1}}>{t("account_usage_tier_canceled_subscription", { date: formatShortDate(account.billing.cancel_at) })}</Alert>
} }
<Portal>
<Snackbar
open={showPortalError}
autoHideDuration={3000}
onClose={() => setShowPortalError(false)}
message={t("account_usage_cannot_create_portal_session")}
/>
</Portal>
</Pref> </Pref>
) )
}; };
@ -324,27 +330,23 @@ const Stats = () => {
{t("account_usage_title")} {t("account_usage_title")}
</Typography> </Typography>
<PrefGroup> <PrefGroup>
{account.role === Role.USER && <Pref title={t("account_usage_reservations_title")}>
<Pref title={t("account_usage_reservations_title")}> {(account.role === Role.ADMIN || account.limits.reservations > 0) &&
{account.limits.reservations > 0 && <>
<> <div>
<div> <Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography>
<Typography variant="body2" <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography>
sx={{float: "left"}}>{account.stats.reservations}</Typography> </div>
<Typography variant="body2" <LinearProgress
sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography> variant="determinate"
</div> value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
<LinearProgress />
variant="determinate" </>
value={account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} }
/> {account.role === Role.USER && account.limits.reservations === 0 &&
</> <em>{t("account_usage_reservations_none")}</em>
} }
{account.limits.reservations === 0 && </Pref>
<em>No reserved topics for this account</em>
}
</Pref>
}
<Pref title={ <Pref title={
<> <>
{t("account_usage_messages_title")} {t("account_usage_messages_title")}
@ -596,9 +598,9 @@ const TokensTable = (props) => {
const TokenDialog = (props) => { const TokenDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState("");
const [label, setLabel] = useState(props.token?.label || ""); const [label, setLabel] = useState(props.token?.label || "");
const [expires, setExpires] = useState(props.token ? -1 : 0); const [expires, setExpires] = useState(props.token ? -1 : 0);
const [errorText, setErrorText] = useState("");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const editMode = !!props.token; const editMode = !!props.token;
@ -612,10 +614,11 @@ const TokenDialog = (props) => {
props.onClose(); props.onClose();
} catch (e) { } catch (e) {
console.log(`[Account] Error creating token`, e); console.log(`[Account] Error creating token`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else {
setError(e.message);
} }
// TODO show error
} }
}; };
@ -648,7 +651,7 @@ const TokenDialog = (props) => {
</Select> </Select>
</FormControl> </FormControl>
</DialogContent> </DialogContent>
<DialogFooter status={errorText}> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("account_tokens_dialog_button_cancel")}</Button> <Button onClick={props.onClose}>{t("account_tokens_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit}>{editMode ? t("account_tokens_dialog_button_update") : t("account_tokens_dialog_button_create")}</Button> <Button onClick={handleSubmit}>{editMode ? t("account_tokens_dialog_button_update") : t("account_tokens_dialog_button_create")}</Button>
</DialogFooter> </DialogFooter>
@ -658,6 +661,7 @@ const TokenDialog = (props) => {
const TokenDeleteDialog = (props) => { const TokenDeleteDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState("");
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
@ -665,10 +669,11 @@ const TokenDeleteDialog = (props) => {
props.onClose(); props.onClose();
} catch (e) { } catch (e) {
console.log(`[Account] Error deleting token`, e); console.log(`[Account] Error deleting token`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else {
setError(e.message);
} }
// TODO show error
} }
}; };
@ -680,10 +685,10 @@ const TokenDeleteDialog = (props) => {
<Trans i18nKey="account_tokens_delete_dialog_description"/> <Trans i18nKey="account_tokens_delete_dialog_description"/>
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogFooter status>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> <Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} color="error">{t("account_tokens_delete_dialog_submit_button")}</Button> <Button onClick={handleSubmit} color="error">{t("account_tokens_delete_dialog_submit_button")}</Button>
</DialogActions> </DialogFooter>
</Dialog> </Dialog>
); );
} }
@ -736,8 +741,8 @@ const DeleteAccount = () => {
const DeleteAccountDialog = (props) => { const DeleteAccountDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
const [error, setError] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [errorText, setErrorText] = useState("");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleSubmit = async () => { const handleSubmit = async () => {
@ -748,12 +753,13 @@ const DeleteAccountDialog = (props) => {
session.resetAndRedirect(routes.app); session.resetAndRedirect(routes.app);
} catch (e) { } catch (e) {
console.log(`[Account] Error deleting account`, e); console.log(`[Account] Error deleting account`, e);
if ((e instanceof IncorrectPasswordError)) { if (e instanceof IncorrectPasswordError) {
setErrorText(t("account_basics_password_dialog_current_password_incorrect")); setError(t("account_basics_password_dialog_current_password_incorrect"));
} else if ((e instanceof UnauthorizedError)) { } else if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else {
setError(e.message);
} }
// TODO show error
} }
}; };
@ -779,7 +785,7 @@ const DeleteAccountDialog = (props) => {
<Alert severity="warning" sx={{mt: 1}}>{t("account_delete_dialog_billing_warning")}</Alert> <Alert severity="warning" sx={{mt: 1}}>{t("account_delete_dialog_billing_warning")}</Alert>
} }
</DialogContent> </DialogContent>
<DialogFooter status={errorText}> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("account_delete_dialog_button_cancel")}</Button> <Button onClick={props.onClose}>{t("account_delete_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} color="error" disabled={password.length === 0}>{t("account_delete_dialog_button_submit")}</Button> <Button onClick={handleSubmit} color="error" disabled={password.length === 0}>{t("account_delete_dialog_button_submit")}</Button>
</DialogFooter> </DialogFooter>

View file

@ -10,10 +10,11 @@ import session from "../app/Session";
import {NavLink} from "react-router-dom"; import {NavLink} from "react-router-dom";
import AvatarBox from "./AvatarBox"; import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import accountApi, {UnauthorizedError} from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import {InputAdornment} from "@mui/material"; import {InputAdornment} from "@mui/material";
import {Visibility, VisibilityOff} from "@mui/icons-material"; import {Visibility, VisibilityOff} from "@mui/icons-material";
import {UnauthorizedError} from "../app/errors";
const Login = () => { const Login = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -32,12 +33,10 @@ const Login = () => {
window.location.href = routes.app; window.location.href = routes.app;
} catch (e) { } catch (e) {
console.log(`[Login] User auth for user ${user.username} failed`, 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")); setError(t("Login failed: Invalid username or password"));
} else if (e.message) {
setError(e.message);
} else { } else {
setError(t("Unknown error. Check logs for details.")) setError(e.message);
} }
} }
}; };

View file

@ -39,13 +39,16 @@ import {playSound, shuffle, sounds, validUrl} from "../app/utils";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, {Permission, Role, UnauthorizedError} from "../app/AccountApi"; import accountApi, {Permission, Role} from "../app/AccountApi";
import {Pref, PrefGroup} from "./Pref"; import {Pref, PrefGroup} from "./Pref";
import {Info} from "@mui/icons-material"; import {Info} from "@mui/icons-material";
import {AccountContext} from "./App"; import {AccountContext} from "./App";
import {useOutletContext} from "react-router-dom"; import {useOutletContext} from "react-router-dom";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs"; import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs";
import {UnauthorizedError} from "../app/errors";
import subscriptionManager from "../app/SubscriptionManager";
import {subscribeTopic} from "./SubscribeDialog";
const Preferences = () => { const Preferences = () => {
return ( return (
@ -484,7 +487,7 @@ const Reservations = () => {
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
if (!config.enable_reservations || !session.exists() || !account || account.role === Role.ADMIN) { if (!config.enable_reservations || !session.exists() || !account) {
return <></>; return <></>;
} }
const reservations = account.reservations || []; const reservations = account.reservations || [];
@ -543,6 +546,10 @@ const ReservationsTable = (props) => {
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
}; };
const handleSubscribeClick = async (reservation) => {
await subscribeTopic(config.base_url, reservation.topic);
};
return ( return (
<Table size="small" aria-label={t("prefs_reservations_table")}> <Table size="small" aria-label={t("prefs_reservations_table")}>
<TableHead> <TableHead>
@ -589,7 +596,9 @@ const ReservationsTable = (props) => {
</TableCell> </TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}> <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{!localSubscriptions[reservation.topic] && {!localSubscriptions[reservation.topic] &&
<Chip icon={<Info/>} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/> <Tooltip title={t("prefs_reservations_table_click_to_subscribe")}>
<Chip icon={<Info/>} onClick={() => handleSubscribeClick(reservation)} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/>
</Tooltip>
} }
<IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> <IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
<EditIcon/> <EditIcon/>
@ -626,7 +635,7 @@ const maybeUpdateAccountSettings = async (payload) => {
await accountApi.updateSettings(payload); await accountApi.updateSettings(payload);
} catch (e) { } catch (e) {
console.log(`[Preferences] Error updating account settings`, e); console.log(`[Preferences] Error updating account settings`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} }
} }

View file

@ -27,7 +27,8 @@ import EmojiPicker from "./EmojiPicker";
import {Trans, useTranslation} from "react-i18next"; import {Trans, useTranslation} from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, {UnauthorizedError} from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import {UnauthorizedError} from "../app/errors";
const PublishDialog = (props) => { const PublishDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -179,7 +180,7 @@ const PublishDialog = (props) => {
setAttachFileError(""); setAttachFileError("");
} catch (e) { } catch (e) {
console.log(`[PublishDialog] Retrieving attachment limits failed`, e); console.log(`[PublishDialog] Retrieving attachment limits failed`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else { } else {
setAttachFileError(""); // Reset error (rely on server-side checking) setAttachFileError(""); // Reset error (rely on server-side checking)

View file

@ -1,46 +1,31 @@
import * as React from 'react'; import * as React from 'react';
import {useContext, useEffect, useState} from 'react'; import {useState} from 'react';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText'; import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import { import {Alert, FormControl, Select, useMediaQuery} from "@mui/material";
Alert,
Autocomplete,
Checkbox,
FormControl,
FormControlLabel,
FormGroup,
Select,
useMediaQuery
} from "@mui/material";
import theme from "./theme"; import theme from "./theme";
import api from "../app/Api"; import {validTopic} from "../app/utils";
import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils";
import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, {Permission, Role, TopicReservedError, UnauthorizedError} from "../app/AccountApi"; import accountApi, {Permission} from "../app/AccountApi";
import ReserveTopicSelect from "./ReserveTopicSelect"; import ReserveTopicSelect from "./ReserveTopicSelect";
import {AccountContext} from "./App";
import DialogActions from "@mui/material/DialogActions";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
import ListItemText from "@mui/material/ListItemText"; import ListItemText from "@mui/material/ListItemText";
import {Check, DeleteForever} from "@mui/icons-material"; import {Check, DeleteForever} from "@mui/icons-material";
import {TopicReservedError, UnauthorizedError} from "../app/errors";
export const ReserveAddDialog = (props) => { export const ReserveAddDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState("");
const [topic, setTopic] = useState(props.topic || ""); const [topic, setTopic] = useState(props.topic || "");
const [everyone, setEveryone] = useState(Permission.DENY_ALL); const [everyone, setEveryone] = useState(Permission.DENY_ALL);
const [errorText, setErrorText] = useState("");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const allowTopicEdit = !props.topic; const allowTopicEdit = !props.topic;
const alreadyReserved = props.reservations.filter(r => r.topic === topic).length > 0; 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}`); console.debug(`[ReserveAddDialog] Added reservation for topic ${t}: ${everyone}`);
} catch (e) { } catch (e) {
console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); console.log(`[ReserveAddDialog] Error adding topic reservation.`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else if ((e instanceof TopicReservedError)) { } else if (e instanceof TopicReservedError) {
setErrorText(t("subscribe_dialog_error_topic_already_reserved")); setError(t("subscribe_dialog_error_topic_already_reserved"));
return;
} else {
setError(e.message);
return; return;
} }
} }
props.onClose(); props.onClose();
// FIXME handle 401/403/409
}; };
return ( return (
@ -88,7 +75,7 @@ export const ReserveAddDialog = (props) => {
sx={{mt: 1}} sx={{mt: 1}}
/> />
</DialogContent> </DialogContent>
<DialogFooter status={errorText}> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("prefs_users_dialog_button_cancel")}</Button> <Button onClick={props.onClose}>{t("prefs_users_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitButtonEnabled}>{t("prefs_users_dialog_button_add")}</Button> <Button onClick={handleSubmit} disabled={!submitButtonEnabled}>{t("prefs_users_dialog_button_add")}</Button>
</DialogFooter> </DialogFooter>
@ -98,6 +85,7 @@ export const ReserveAddDialog = (props) => {
export const ReserveEditDialog = (props) => { export const ReserveEditDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState("");
const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL); const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
@ -107,12 +95,14 @@ export const ReserveEditDialog = (props) => {
console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`); console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`);
} catch (e) { } catch (e) {
console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); console.log(`[ReserveEditDialog] Error updating topic reservation.`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
} }
} }
props.onClose(); props.onClose();
// FIXME handle 401/403/409
}; };
return ( return (
@ -128,31 +118,34 @@ export const ReserveEditDialog = (props) => {
sx={{mt: 1}} sx={{mt: 1}}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> <Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit}>{t("common_save")}</Button> <Button onClick={handleSubmit}>{t("common_save")}</Button>
</DialogActions> </DialogFooter>
</Dialog> </Dialog>
); );
}; };
export const ReserveDeleteDialog = (props) => { export const ReserveDeleteDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [error, setError] = useState("");
const [deleteMessages, setDeleteMessages] = useState(false); const [deleteMessages, setDeleteMessages] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
await accountApi.deleteReservation(props.topic, deleteMessages); 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) { } catch (e) {
console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
} }
} }
props.onClose(); props.onClose();
// FIXME handle 401/403/409
}; };
return ( return (
@ -196,10 +189,10 @@ export const ReserveDeleteDialog = (props) => {
</Alert> </Alert>
} }
</DialogContent> </DialogContent>
<DialogActions> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> <Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} color="error">{t("reservation_delete_dialog_submit_button")}</Button> <Button onClick={handleSubmit} color="error">{t("reservation_delete_dialog_submit_button")}</Button>
</DialogActions> </DialogFooter>
</Dialog> </Dialog>
); );
}; };

View file

@ -10,10 +10,11 @@ import {NavLink} from "react-router-dom";
import AvatarBox from "./AvatarBox"; import AvatarBox from "./AvatarBox";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; 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 {InputAdornment} from "@mui/material";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import {Visibility, VisibilityOff} from "@mui/icons-material"; import {Visibility, VisibilityOff} from "@mui/icons-material";
import {AccountCreateLimitReachedError, UserExistsError} from "../app/errors";
const Signup = () => { const Signup = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -35,14 +36,12 @@ const Signup = () => {
window.location.href = routes.app; window.location.href = routes.app;
} catch (e) { } catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, 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 })); setError(t("signup_error_username_taken", { username: e.username }));
} else if ((e instanceof AccountCreateLimitReachedError)) { } else if ((e instanceof AccountCreateLimitReachedError)) {
setError(t("signup_error_creation_limit_reached")); setError(t("signup_error_creation_limit_reached"));
} else if (e.message) {
setError(e.message);
} else { } else {
setError(t("signup_error_unknown")) setError(e.message);
} }
} }
}; };

View file

@ -17,9 +17,10 @@ import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import accountApi, {Role, TopicReservedError, UnauthorizedError} from "../app/AccountApi"; import accountApi, {Role} from "../app/AccountApi";
import ReserveTopicSelect from "./ReserveTopicSelect"; import ReserveTopicSelect from "./ReserveTopicSelect";
import {AccountContext} from "./App"; import {AccountContext} from "./App";
import {TopicReservedError, UnauthorizedError} from "../app/errors";
const publicBaseUrl = "https://ntfy.sh"; const publicBaseUrl = "https://ntfy.sh";
@ -32,22 +33,7 @@ const SubscribeDialog = (props) => {
const handleSuccess = async () => { const handleSuccess = async () => {
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
const actualBaseUrl = (baseUrl) ? baseUrl : config.base_url; const actualBaseUrl = (baseUrl) ? baseUrl : config.base_url;
const subscription = await subscriptionManager.add(actualBaseUrl, topic); const subscription = subscribeTopic(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);
}
}
}
poller.pollInBackground(subscription); // Dangle! poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription); props.onSuccess(subscription);
} }
@ -77,9 +63,9 @@ const SubscribeDialog = (props) => {
const SubscribePage = (props) => { const SubscribePage = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
const [error, setError] = useState("");
const [reserveTopicVisible, setReserveTopicVisible] = useState(false); const [reserveTopicVisible, setReserveTopicVisible] = useState(false);
const [anotherServerVisible, setAnotherServerVisible] = useState(false); const [anotherServerVisible, setAnotherServerVisible] = useState(false);
const [errorText, setErrorText] = useState("");
const [everyone, setEveryone] = useState("deny-all"); const [everyone, setEveryone] = useState("deny-all");
const baseUrl = (anotherServerVisible) ? props.baseUrl : config.base_url; const baseUrl = (anotherServerVisible) ? props.baseUrl : config.base_url;
const topic = props.topic; const topic = props.topic;
@ -98,7 +84,7 @@ const SubscribePage = (props) => {
if (!success) { if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
if (user) { if (user) {
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username })); setError(t("subscribe_dialog_error_user_not_authorized", { username: username }));
return; return;
} else { } else {
props.onNeedsLogin(); props.onNeedsLogin();
@ -114,10 +100,10 @@ const SubscribePage = (props) => {
// Account sync later after it was added // Account sync later after it was added
} catch (e) { } catch (e) {
console.log(`[SubscribeDialog] Error reserving topic`, e); console.log(`[SubscribeDialog] Error reserving topic`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else if ((e instanceof TopicReservedError)) { } else if (e instanceof TopicReservedError) {
setErrorText(t("subscribe_dialog_error_topic_already_reserved")); setError(t("subscribe_dialog_error_topic_already_reserved"));
return; return;
} }
} }
@ -231,7 +217,7 @@ const SubscribePage = (props) => {
</FormGroup> </FormGroup>
} }
</DialogContent> </DialogContent>
<DialogFooter status={errorText}> <DialogFooter status={error}>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button> <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button> <Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button>
</DialogFooter> </DialogFooter>
@ -243,21 +229,23 @@ const LoginPage = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [errorText, setErrorText] = useState(""); const [error, setError] = useState("");
const baseUrl = (props.baseUrl) ? props.baseUrl : config.base_url; const baseUrl = (props.baseUrl) ? props.baseUrl : config.base_url;
const topic = props.topic; const topic = props.topic;
const handleLogin = async () => { const handleLogin = async () => {
const user = {baseUrl, username, password}; const user = {baseUrl, username, password};
const success = await api.topicAuth(baseUrl, topic, user); const success = await api.topicAuth(baseUrl, topic, user);
if (!success) { if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); 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; return;
} }
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
await userManager.save(user); await userManager.save(user);
props.onSuccess(); props.onSuccess();
}; };
return ( return (
<> <>
<DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle> <DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle>
@ -293,7 +281,7 @@ const LoginPage = (props) => {
}} }}
/> />
</DialogContent> </DialogContent>
<DialogFooter status={errorText}> <DialogFooter status={error}>
<Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button> <Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button>
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button> <Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
</DialogFooter> </DialogFooter>
@ -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; export default SubscribeDialog;

View file

@ -11,10 +11,9 @@ import theme from "./theme";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import accountApi, {Permission, UnauthorizedError} from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import ReserveTopicSelect from "./ReserveTopicSelect";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import PopupMenu from "./PopupMenu"; import PopupMenu from "./PopupMenu";
import {formatShortDateTime, shuffle} from "../app/utils"; import {formatShortDateTime, shuffle} from "../app/utils";
@ -23,7 +22,8 @@ import {useNavigate} from "react-router-dom";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import {Clear} from "@mui/icons-material"; import {Clear} from "@mui/icons-material";
import {AccountContext} from "./App"; 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 SubscriptionPopup = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -96,25 +96,25 @@ const SubscriptionPopup = (props) => {
tags: tags tags: tags
}); });
} catch (e) { } catch (e) {
console.log(`[ActionBar] Error publishing message`, e); console.log(`[SubscriptionPopup] Error publishing message`, e);
setShowPublishError(true); setShowPublishError(true);
} }
} }
const handleClearAll = async () => { 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); await subscriptionManager.deleteNotifications(props.subscription.id);
}; };
const handleUnsubscribe = async (event) => { const handleUnsubscribe = async () => {
console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`, props.subscription); console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription);
await subscriptionManager.remove(props.subscription.id); await subscriptionManager.remove(props.subscription.id);
if (session.exists() && props.subscription.remoteId) { if (session.exists() && props.subscription.remoteId) {
try { try {
await accountApi.deleteSubscription(props.subscription.remoteId); await accountApi.deleteSubscription(props.subscription.remoteId);
} catch (e) { } catch (e) {
console.log(`[ActionBar] Error unsubscribing`, e); console.log(`[SubscriptionPopup] Error unsubscribing`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} }
} }
@ -187,25 +187,24 @@ const SubscriptionPopup = (props) => {
const DisplayNameDialog = (props) => { const DisplayNameDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const subscription = props.subscription; const subscription = props.subscription;
const [error, setError] = useState("");
const [displayName, setDisplayName] = useState(subscription.displayName ?? ""); const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleSave = async () => { const handleSave = async () => {
// Apply locally
await subscriptionManager.setDisplayName(subscription.id, displayName); await subscriptionManager.setDisplayName(subscription.id, displayName);
// Apply remotely
if (session.exists() && subscription.remoteId) { if (session.exists() && subscription.remoteId) {
try { try {
console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`); console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`);
await accountApi.updateSubscription(subscription.remoteId, { display_name: displayName }); await accountApi.updateSubscription(subscription.remoteId, { display_name: displayName });
} catch (e) { } catch (e) {
console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else {
setError(e.message);
return;
} }
// FIXME handle 409
} }
} }
props.onClose(); props.onClose();
@ -241,7 +240,7 @@ const DisplayNameDialog = (props) => {
}} }}
/> />
</DialogContent> </DialogContent>
<DialogFooter> <DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button> <Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSave}>{t("common_save")}</Button> <Button onClick={handleSave}>{t("common_save")}</Button>
</DialogFooter> </DialogFooter>

View file

@ -7,7 +7,7 @@ import {Alert, CardActionArea, CardContent, ListItem, useMediaQuery} from "@mui/
import theme from "./theme"; import theme from "./theme";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import accountApi, {UnauthorizedError} from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
@ -21,19 +21,24 @@ import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText"; import ListItemText from "@mui/material/ListItemText";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import {NavLink} from "react-router-dom"; import {NavLink} from "react-router-dom";
import {UnauthorizedError} from "../app/errors";
const UpgradeDialog = (props) => { const UpgradeDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { account } = useContext(AccountContext); // May be undefined! const { account } = useContext(AccountContext); // May be undefined!
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const [error, setError] = useState("");
const [tiers, setTiers] = useState(null); const [tiers, setTiers] = useState(null);
const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errorText, setErrorText] = useState(""); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
useEffect(() => { useEffect(() => {
(async () => { (async () => {
setTiers(await accountApi.billingTiers()); try {
setTiers(await accountApi.billingTiers());
} catch (e) {
setError(e.message);
}
})(); })();
}, []); }, []);
@ -96,10 +101,11 @@ const UpgradeDialog = (props) => {
props.onCancel(); props.onCancel();
} catch (e) { } catch (e) {
console.log(`[UpgradeDialog] Error changing billing subscription`, e); console.log(`[UpgradeDialog] Error changing billing subscription`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} else {
setError(e.message);
} }
// FIXME show error
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -155,7 +161,7 @@ const UpgradeDialog = (props) => {
</Alert> </Alert>
} }
</DialogContent> </DialogContent>
<DialogFooter status={errorText}> <DialogFooter status={error}>
<Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button> <Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitAction}>{submitButtonLabel}</Button> <Button onClick={handleSubmit} disabled={!submitAction}>{submitButtonLabel}</Button>
</DialogFooter> </DialogFooter>

View file

@ -8,7 +8,8 @@ import connectionManager from "../app/ConnectionManager";
import poller from "../app/Poller"; import poller from "../app/Poller";
import pruner from "../app/Pruner"; import pruner from "../app/Pruner";
import session from "../app/Session"; 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 * 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); const eligible = params.topic && !selected && !disallowedTopic(params.topic);
if (eligible) { if (eligible) {
const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : config.base_url; 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 () => { (async () => {
const subscription = await subscriptionManager.add(baseUrl, params.topic); const subscription = await subscriptionManager.add(baseUrl, params.topic);
if (session.exists()) { if (session.exists()) {
@ -105,8 +106,8 @@ export const useAutoSubscribe = (subscriptions, selected) => {
}); });
await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id);
} catch (e) { } catch (e) {
console.log(`[App] Auto-subscribing failed`, e); console.log(`[Hooks] Auto-subscribing failed`, e);
if ((e instanceof UnauthorizedError)) { if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login); session.resetAndRedirect(routes.login);
} }
} }