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
+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);