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", ""}
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"}

View File

@ -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
*/

View File

@ -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 {

View File

@ -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) {

View File

@ -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()

View File

@ -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

View File

@ -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",

View File

@ -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;

View File

@ -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 /<topic>/auth doesn't exist
} else if (response.status === 401 || response.status === 403) { // See server/server.go
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 {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"
/>
</DialogContent>
<DialogFooter status={errorText}>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("account_basics_password_dialog_button_cancel")}</Button>
<Button
onClick={handleDialogSubmit}
@ -219,6 +215,7 @@ const AccountType = () => {
const { account } = useContext(AccountContext);
const [upgradeDialogKey, setUpgradeDialogKey] = useState(0);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [showPortalError, setShowPortalError] = useState(false);
if (!account) {
return <></>;
@ -234,11 +231,12 @@ const AccountType = () => {
const response = await accountApi.createBillingPortalSession();
window.open(response.redirect_url, "billing_portal");
} catch (e) {
console.log(`[Account] Error changing password`, e);
if ((e instanceof UnauthorizedError)) {
console.log(`[Account] Error opening billing portal`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setShowPortalError(true);
}
// TODO show error
}
};
@ -302,6 +300,14 @@ const AccountType = () => {
{account.billing?.cancel_at > 0 &&
<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>
)
};
@ -324,27 +330,23 @@ const Stats = () => {
{t("account_usage_title")}
</Typography>
<PrefGroup>
{account.role === Role.USER &&
<Pref title={t("account_usage_reservations_title")}>
{account.limits.reservations > 0 &&
<>
<div>
<Typography variant="body2"
sx={{float: "left"}}>{account.stats.reservations}</Typography>
<Typography variant="body2"
sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography>
</div>
<LinearProgress
variant="determinate"
value={account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
/>
</>
}
{account.limits.reservations === 0 &&
<em>No reserved topics for this account</em>
}
</Pref>
}
<Pref title={t("account_usage_reservations_title")}>
{(account.role === Role.ADMIN || account.limits.reservations > 0) &&
<>
<div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography>
</div>
<LinearProgress
variant="determinate"
value={account.role === Role.USER && 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>
}
</Pref>
<Pref title={
<>
{t("account_usage_messages_title")}
@ -596,9 +598,9 @@ const TokensTable = (props) => {
const TokenDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [label, setLabel] = useState(props.token?.label || "");
const [expires, setExpires] = useState(props.token ? -1 : 0);
const [errorText, setErrorText] = useState("");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const editMode = !!props.token;
@ -612,10 +614,11 @@ const TokenDialog = (props) => {
props.onClose();
} catch (e) {
console.log(`[Account] Error creating token`, e);
if ((e instanceof UnauthorizedError)) {
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
} else {
setError(e.message);
}
// TODO show error
}
};
@ -648,7 +651,7 @@ const TokenDialog = (props) => {
</Select>
</FormControl>
</DialogContent>
<DialogFooter status={errorText}>
<DialogFooter status={error}>
<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>
</DialogFooter>
@ -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) => {
<Trans i18nKey="account_tokens_delete_dialog_description"/>
</DialogContentText>
</DialogContent>
<DialogActions>
<DialogFooter status>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} color="error">{t("account_tokens_delete_dialog_submit_button")}</Button>
</DialogActions>
</DialogFooter>
</Dialog>
);
}
@ -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) => {
<Alert severity="warning" sx={{mt: 1}}>{t("account_delete_dialog_billing_warning")}</Alert>
}
</DialogContent>
<DialogFooter status={errorText}>
<DialogFooter status={error}>
<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>
</DialogFooter>

View File

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

View File

@ -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 (
<Table size="small" aria-label={t("prefs_reservations_table")}>
<TableHead>
@ -589,7 +596,9 @@ const ReservationsTable = (props) => {
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{!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")}>
<EditIcon/>
@ -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);
}
}

View File

@ -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)

View File

@ -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}}
/>
</DialogContent>
<DialogFooter status={errorText}>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("prefs_users_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitButtonEnabled}>{t("prefs_users_dialog_button_add")}</Button>
</DialogFooter>
@ -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}}
/>
</DialogContent>
<DialogActions>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit}>{t("common_save")}</Button>
</DialogActions>
</DialogFooter>
</Dialog>
);
};
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) => {
</Alert>
}
</DialogContent>
<DialogActions>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} color="error">{t("reservation_delete_dialog_submit_button")}</Button>
</DialogActions>
</DialogFooter>
</Dialog>
);
};

View File

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

View File

@ -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) => {
</FormGroup>
}
</DialogContent>
<DialogFooter status={errorText}>
<DialogFooter status={error}>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button>
</DialogFooter>
@ -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 (
<>
<DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle>
@ -293,7 +281,7 @@ const LoginPage = (props) => {
}}
/>
</DialogContent>
<DialogFooter status={errorText}>
<DialogFooter status={error}>
<Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button>
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
</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;

View File

@ -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) => {
}}
/>
</DialogContent>
<DialogFooter>
<DialogFooter status={error}>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSave}>{t("common_save")}</Button>
</DialogFooter>

View File

@ -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) => {
</Alert>
}
</DialogContent>
<DialogFooter status={errorText}>
<DialogFooter status={error}>
<Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!submitAction}>{submitButtonLabel}</Button>
</DialogFooter>

View File

@ -8,7 +8,8 @@ import connectionManager from "../app/ConnectionManager";
import poller from "../app/Poller";
import pruner from "../app/Pruner";
import session from "../app/Session";
import 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);
}
}