Allow logging in via email

This commit is contained in:
binwiederhier
2026-07-16 21:42:42 +02:00
parent b55e78a918
commit 24bc50b585
17 changed files with 452 additions and 199 deletions
+6
View File
@@ -2007,6 +2007,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.27.0 (UNRELEASED)
**Features:**
* Allow logging in with your verified primary email address (in addition to your username), so a password reset no longer leaves you unable to sign in when you only remember the email you signed up with
### ntfy Android v1.25.2 (UNRELEASED)
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
+3
View File
@@ -110,6 +110,7 @@ var (
apiUsersPath = "/v1/users"
apiUsersAccessPath = "/v1/users/access"
apiAccountPath = "/v1/account"
apiAccountLoginPath = "/v1/account/login"
apiAccountTokenPath = "/v1/account/token"
apiAccountPasswordPath = "/v1/account/password"
apiAccountSettingsPath = "/v1/account/settings"
@@ -579,6 +580,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath {
return s.ensureUser(s.handleAccountPasswordChange)(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountLoginPath {
return s.ensureUser(s.withAccountSync(s.handleAccountLogin))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath {
return s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v)
} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath {
+18
View File
@@ -268,6 +268,24 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
return s.writeJSON(w, newSuccessResponse())
}
// handleAccountLogin authenticates a username-or-email + password (via the ensureUser wrapper's
// Basic Auth), mints a session token, and returns it together with the canonical username. Unlike
// the token endpoint (which exists to mint arbitrary API tokens), this endpoint's job is to log a
// user in, so it also reports who they are (the identifier they typed may be a primary email).
func (s *Server) handleAccountLogin(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
logvr(v, r).Tag(tagAccount).Info("Logging in user %s", u.Name)
token, err := s.userManager.CreateToken(u.ID, "", time.Now().Add(tokenExpiryDuration), v.IP(), false)
if err != nil {
return err
}
response := &apiAccountLoginResponse{
Token: token.Value,
Username: u.Name,
}
return s.writeJSON(w, response)
}
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
if err != nil {
+16
View File
@@ -224,6 +224,22 @@ func canLogin(t *testing.T, s *Server, username, password string) bool {
return rr.Code == 200
}
func TestAccount_LoginByPrimaryEmail(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
// Basic Auth works with either the username or the verified primary email
require.True(t, canLogin(t, s, "ben", "ben"))
require.True(t, canLogin(t, s, "ben@example.com", "ben"))
// ...but not with the wrong password or an unknown email
require.False(t, canLogin(t, s, "ben@example.com", "wrong"))
require.False(t, canLogin(t, s, "nobody@example.com", "ben"))
})
}
func TestAccount_PasswordReset_ByUsername(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
+52
View File
@@ -55,6 +55,58 @@ func TestAccount_Signup_Success(t *testing.T) {
})
}
func TestAccount_Login_Success(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "phil@example.com"))
// Login by username returns a token and the canonical username
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
resp, _ := util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
require.Equal(t, "phil", resp.Username)
// The returned token actually authenticates
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BearerAuth(resp.Token),
})
require.Equal(t, 200, rr.Code)
// Login by primary email returns the canonical username, not the email that was typed
rr = request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil@example.com", "mypass"),
})
require.Equal(t, 200, rr.Code)
resp, _ = util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
require.Equal(t, "phil", resp.Username)
})
}
func TestAccount_Login_InvalidCredentials(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil", "wrongpass"),
})
require.Equal(t, 401, rr.Code)
})
}
func TestAccount_Signup_UserExists(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
+8
View File
@@ -217,6 +217,14 @@ type apiAccountTokenResponse struct {
Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
}
// apiAccountLoginResponse is the body of POST /v1/account/login: it authenticates a
// username-or-email + password, mints a session token, and returns the token together with the
// canonical username (which may differ from the identifier the user typed, e.g. a primary email).
type apiAccountLoginResponse struct {
Token string `json:"token"`
Username string `json:"username"`
}
type apiAccountPhoneNumberVerifyRequest struct {
Number string `json:"number"`
Channel string `json:"channel"`
+26 -9
View File
@@ -153,24 +153,26 @@ func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) {
}
}
// Authenticate checks username and password and returns a User if correct, and the user has not been
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
// the password is correct or incorrect.
func (a *Manager) Authenticate(username, password string) (*User, error) {
if username == Everyone {
// Authenticate checks a login identifier (a username or a verified primary email) and password, and
// returns a User if correct and not marked as deleted. The identifier is resolved in a single query
// via userByNameOrEmail, so a user can log in with either their username or their primary
// email. The method returns in constant-ish time (one query, one bcrypt compare), regardless of
// whether the identifier exists or the password is correct or incorrect.
func (a *Manager) Authenticate(identifier, password string) (*User, error) {
if identifier == Everyone {
return nil, ErrUnauthenticated
}
user, err := a.User(username)
user, err := a.userByNameOrEmail(identifier)
if err != nil {
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (1)")
log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (1)")
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated
} else if user.Deleted {
log.Tag(tag).Field("user_name", username).Trace("Authentication of user failed (2): user marked deleted")
log.Tag(tag).Field("user_name", identifier).Trace("Authentication of user failed (2): user marked deleted")
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated
} else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (3)")
log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (3)")
return nil, ErrUnauthenticated
}
return user, nil
@@ -532,6 +534,21 @@ func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) {
return a.User(identifier)
}
// userByNameOrEmail resolves a login identifier to a single user in one query, matching it
// against the username first and a verified primary email address second. This is the INVERSE
// precedence of UserByEmailOrUsername (used by password reset): at login a freely-chosen username
// must win over a look-alike primary email, so a user whose username happens to equal another
// account's email is not locked out of their own account. Because Authenticate still gates the match
// on a password check, returning the username owner here never grants access to the email owner's
// account. Returns ErrUserNotFound if neither matches.
func (a *Manager) userByNameOrEmail(identifier string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByNameOrPrimaryEmail, identifier, identifier, identifier)
if err != nil {
return nil, err
}
return a.readUser(rows)
}
// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise
func (a *Manager) userByToken(token string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix())
+10
View File
@@ -33,6 +33,15 @@ const (
LEFT JOIN tier t on t.id = u.tier_id
WHERE user_name = $1
`
postgresSelectUserByNameOrPrimaryEmailQuery = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.user_name = $1
OR u.id = (SELECT user_id FROM user_email WHERE email = $2 AND is_primary)
ORDER BY CASE WHEN u.user_name = $3 THEN 0 ELSE 1 END
LIMIT 1
`
postgresSelectUserByTokenQuery = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u
@@ -262,6 +271,7 @@ func postgresSelectAccessCacheUsersQuery(n int) string {
var postgresQueries = queries{
selectUserByID: postgresSelectUserByIDQuery,
selectUserByName: postgresSelectUserByNameQuery,
selectUserByNameOrPrimaryEmail: postgresSelectUserByNameOrPrimaryEmailQuery,
selectUserByToken: postgresSelectUserByTokenQuery,
selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery,
selectUsernames: postgresSelectUsernamesQuery,
+10
View File
@@ -37,6 +37,15 @@ const (
LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ?
`
sqliteSelectUserByNameOrPrimaryEmailQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.user = ?
OR u.id = (SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1)
ORDER BY CASE WHEN u.user = ? THEN 0 ELSE 1 END
LIMIT 1
`
sqliteSelectUserByTokenQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
@@ -258,6 +267,7 @@ func sqliteSelectAccessCacheUsersQuery(n int) string {
var sqliteQueries = queries{
selectUserByID: sqliteSelectUserByIDQuery,
selectUserByName: sqliteSelectUserByNameQuery,
selectUserByNameOrPrimaryEmail: sqliteSelectUserByNameOrPrimaryEmailQuery,
selectUserByToken: sqliteSelectUserByTokenQuery,
selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery,
selectUsernames: sqliteSelectUsernamesQuery,
+102
View File
@@ -2947,6 +2947,108 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
})
}
func TestManager_Authenticate_ByPrimaryEmail(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
// phil verifies phil@example.com -> becomes his primary (recovery) email
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour))
require.Nil(t, err)
// Login by username still works
u, err := a.Authenticate("phil", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// Login by primary email works and resolves to the same account
u, err = a.Authenticate("phil@example.com", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// Login by primary email with the wrong password fails
u, err = a.Authenticate("phil@example.com", "wrong")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
// An unknown email fails
u, err = a.Authenticate("nobody@example.com", "phil")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
})
}
func TestManager_Authenticate_BySecondaryEmailDenied(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
ben, err := a.User("ben")
require.Nil(t, err)
// phil verifies shared@ first -> his primary; ben verifies it too -> only secondary for ben
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour))
require.Nil(t, err)
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour))
require.Nil(t, err)
// Login by the shared address resolves to the primary owner (phil), never the secondary (ben)
u, err := a.Authenticate("shared@example.com", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// ben's password must not authenticate via the shared address (it is not his primary)
u, err = a.Authenticate("shared@example.com", "ben")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
})
}
func TestManager_Authenticate_UsernameLookalikeEmailPrecedence(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
// The collision: a squatter whose USERNAME is literally "phil@example.com" (usernames may
// contain '@' and '.'), and a different account (ben) that owns "phil@example.com" as its
// verified primary email. Both are reachable; nothing links usernames to email addresses.
require.Nil(t, a.AddUser("phil@example.com", "squatterpass", RoleUser, false))
require.Nil(t, a.AddUser("ben", "benpass", RoleUser, false))
ben, err := a.User("ben")
require.Nil(t, err)
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "phil@example.com", 24*time.Hour))
require.Nil(t, err)
// Login resolves the ambiguous identifier username-FIRST (the ORDER BY CASE in the query):
// the squatter owns the login, and returns deterministically even though both rows match.
squatter, err := a.Authenticate("phil@example.com", "squatterpass")
require.Nil(t, err)
require.Equal(t, "phil@example.com", squatter.Name)
// Consequently the email owner's password does NOT authenticate via the colliding identifier
// at login, but the owner is not locked out: their real username still works.
u, err := a.Authenticate("phil@example.com", "benpass")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
u, err = a.Authenticate("ben", "benpass")
require.Nil(t, err)
require.Equal(t, "ben", u.Name)
// The inverse: password reset (UserByEmailOrUsername) resolves the SAME identifier email-FIRST,
// so the reset link goes to the verified email owner (ben), never the look-alike username. The
// two flows deliberately use opposite precedence.
loginUser, err := a.userByNameOrEmail("phil@example.com")
require.Nil(t, err)
require.Equal(t, "phil@example.com", loginUser.Name) // username owner (squatter)
resetUser, err := a.UserByEmailOrUsername("phil@example.com")
require.Nil(t, err)
require.Equal(t, "ben", resetUser.Name) // email owner
})
}
func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
+5 -4
View File
@@ -47,10 +47,10 @@ func (u *User) IsUser() bool {
// Auther is an interface for authentication and authorization
type Auther interface {
// Authenticate checks username and password and returns a user if correct. The method
// returns in constant-ish time, regardless of whether the user exists or the password is
// correct or incorrect.
Authenticate(username, password string) (*User, error)
// Authenticate checks a login identifier (username or verified primary email) and password
// and returns a user if correct. The method returns in constant-ish time, regardless of
// whether the identifier exists or the password is correct or incorrect.
Authenticate(identifier, password string) (*User, error)
// Authorize returns nil if the given user has access to the given topic using the desired
// permission. The user param may be nil to signal an anonymous user.
@@ -340,6 +340,7 @@ type queries struct {
// User queries
selectUserByID string
selectUserByName string
selectUserByNameOrPrimaryEmail string
selectUserByToken string
selectUserByStripeCustomerID string
selectUsernames string
+1
View File
@@ -26,6 +26,7 @@
"signup_error_username_taken": "Username {{username}} is already taken",
"signup_error_creation_limit_reached": "Account creation limit reached",
"login_title": "Sign in to your ntfy account",
"login_form_username_label": "Username or email",
"login_form_button_submit": "Sign in",
"login_link_signup": "Sign up",
"login_link_forgot_password": "Forgot password",
+7 -3
View File
@@ -6,6 +6,7 @@ import {
accountEmailVerifyUrl,
accountEmailPrimaryUrl,
accountEmailResendUrl,
accountLoginUrl,
accountPasswordResetRequestUrl,
accountPasswordResetUrl,
accountPasswordUrl,
@@ -47,8 +48,8 @@ class AccountApi {
}
async login(user) {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Checking auth for ${url}`);
const url = accountLoginUrl(config.base_url);
console.log(`[AccountApi] Logging in at ${url}`);
const response = await fetchOrThrow(url, {
method: "POST",
headers: withBasicAuth({}, user.username, user.password),
@@ -57,7 +58,10 @@ class AccountApi {
if (!json.token) {
throw new Error(`Unexpected server response: Cannot find token`);
}
return json.token;
// The identifier the user typed may be a primary email; login returns the canonical username
// so callers can store it and show the real username rather than whatever was typed. Fall back
// to the typed identifier if an older server omits the username, so the session still stores one.
return { token: json.token, username: json.username || user.username };
}
async logout() {
+10 -6
View File
@@ -42,15 +42,19 @@ afterEach(() => {
});
describe("AccountApi.login", () => {
it("POSTs basic auth to the token URL and returns the token", async () => {
fetchMock.mockResolvedValue(ok({ token: "tk_returned" }));
const token = await accountApi.login({ username: "phil", password: "secret" });
it("POSTs basic auth to the login URL and returns the token and canonical username", async () => {
// The typed identifier is an email; the login endpoint returns the canonical username in one
// request, so login() surfaces it (callers store it) rather than echoing what was typed.
fetchMock.mockResolvedValue(ok({ token: "tk_returned", username: "phil" }));
const result = await accountApi.login({ username: "phil@example.com", password: "secret" });
expect(result).toEqual({ token: "tk_returned", username: "phil" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(token).toBe("tk_returned");
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/token");
expect(url).toBe("https://ntfy.sh/v1/account/login");
expect(options.method).toBe("POST");
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil@example.com:secret")}`);
});
it("throws when the server response has no token", async () => {
+1
View File
@@ -23,6 +23,7 @@ export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/aut
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const webPushUrl = (baseUrl) => `${baseUrl}/v1/webpush`;
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
export const accountLoginUrl = (baseUrl) => `${baseUrl}/v1/account/login`;
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
+5 -5
View File
@@ -24,14 +24,14 @@ const Login = () => {
event.preventDefault();
const user = { username, password };
try {
const token = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
await session.store(user.username, token);
const { token, username: canonicalUsername } = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, logged in as ${canonicalUsername}`);
await session.store(canonicalUsername, token);
fadeReload(routes.app);
} catch (e) {
console.log(`[Login] User auth for user ${user.username} failed`, e);
if (e instanceof UnauthorizedError) {
setError(t("Login failed: Invalid username or password"));
setError(t("Login failed: Invalid username/email or password"));
} else {
setError(e.message);
}
@@ -53,7 +53,7 @@ const Login = () => {
required
fullWidth
id="username"
label={t("signup_form_username")}
label={t("login_form_username_label")}
name="username"
value={username}
onChange={(ev) => setUsername(ev.target.value.trim())}
+3 -3
View File
@@ -28,9 +28,9 @@ const Signup = () => {
const user = { username, password };
try {
await accountApi.create(user.username, user.password, email);
const token = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
await session.store(user.username, token);
const { token, username: canonicalUsername } = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, logged in as ${canonicalUsername}`);
await session.store(canonicalUsername, token);
fadeReload(routes.app);
} catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e);