No address step before signing in when there's only one mail server

The server's own page asks for the username, so with a single server the
sign-in page keeps only the own-device choice. With several servers the
address still comes first, since its domain picks the server. One new string,
in all nine catalogues.
This commit is contained in:
2026-09-18 15:51:54 -07:00
parent 8857bdac30
commit cdd8fabff9
14 changed files with 53 additions and 6 deletions
+5 -1
View File
@@ -15,7 +15,11 @@ keep up. Nothing here is pushed there.
handles a password to sign someone in. Two-factor codes are asked for on handles a password to sign someone in. Two-factor codes are asked for on
that page. Sessions hold sealed tokens and renew them before they expire. A that page. Sessions hold sealed tokens and renew them before they expire. A
password change revokes the tokens, so it signs the person out everywhere, password change revokes the tokens, so it signs the person out everywhere,
this session included. this session included. With one mail server (no `STALWART_SERVERS_FILE`,
or one whose domains all map to `STALWART_URL`), the sign-in page asks for
no address: only whether this is the person's own device, then the server's
page takes it from there. With several servers, the address comes first,
since its domain picks the server.
- **Tenants are offered on every server**, whatever edition it reports. - **Tenants are offered on every server**, whatever edition it reports.
`SHOW_ENTERPRISE_NOTICES` still adds the notice for an upstream Stalwart. `SHOW_ENTERPRISE_NOTICES` still adds the notice for an upstream Stalwart.
+4 -1
View File
@@ -41,7 +41,7 @@ import {
revokeAppPassword, revokeAppPassword,
} from "./account.js"; } from "./account.js";
import { imageProxyHandler } from "./imageproxy.js"; import { imageProxyHandler } from "./imageproxy.js";
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, start as startSignIn, type TokenSet } from "./oauth.js"; import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js";
import { icsProxyHandler } from "./icsproxy.js"; import { icsProxyHandler } from "./icsproxy.js";
import { staticHandler } from "./static.js"; import { staticHandler } from "./static.js";
@@ -384,6 +384,9 @@ export function createApp(basePath = config.basePath): Hono<Env> {
settingsPolicy: config.settingsPolicy, settingsPolicy: config.settingsPolicy,
/* "oauth": sign in on the mail server's own page (see oauth.ts). */ /* "oauth": sign in on the mail server's own page (see oauth.ts). */
signIn: oauthEnabled() ? "oauth" : "password", signIn: oauthEnabled() ? "oauth" : "password",
/* With "oauth": true when the server's page can take it from here, so
the sign-in form doesn't ask for an address first. */
signInDirect: oauthEnabled() && singleServer(),
}), }),
); );
+12
View File
@@ -84,6 +84,18 @@ after(() => {
test("the configuration tells the web app to use the server's page", async () => { test("the configuration tells the web app to use the server's page", async () => {
const body = await jsonOf(await call("/api/config")); const body = await jsonOf(await call("/api/config"));
assert.equal(body.signIn, "oauth"); assert.equal(body.signIn, "oauth");
assert.equal(body.signInDirect, true, "one mail server: its page asks for the username, not ihasmail");
});
test("with one mail server, sign-in starts without an address", async () => {
const res = await call("/api/auth/oauth/start");
assert.equal(res.status, 302);
const to = new URL(res.headers.get("location")!);
assert.equal(to.searchParams.has("login_hint"), false);
const approved = await fetch(to, { redirect: "manual" });
const back = new URL(approved.headers.get("location")!);
assert.equal((await call(`/api/auth/callback${back.search}`)).headers.get("location"), "/");
assert.equal((await call("/api/auth/session")).status, 200);
}); });
test("the password form is refused: ihasmail never sees a password", async () => { test("the password form is refused: ihasmail never sees a password", async () => {
+10
View File
@@ -52,6 +52,16 @@ export function oauthEnabled(): boolean {
return Boolean(config.oauthClientSecret); return Boolean(config.oauthClientSecret);
} }
/**
* Whether every account is on the same server. Then sign-in needs no address
* first: the server's page asks for the username itself. With several servers
* (STALWART_SERVERS_FILE), the domain picks the server, so the address comes
* first.
*/
export function singleServer(): boolean {
return Object.values(config.stalwartServers).every((url) => url === config.stalwartUrl);
}
/** The one redirect URI registered for this client on the server. */ /** The one redirect URI registered for this client on the server. */
export function redirectUri(): string { export function redirectUri(): string {
return `${config.publicUrl}${config.basePath}/api/auth/callback`; return `${config.publicUrl}${config.basePath}/api/auth/callback`;
+1
View File
@@ -1243,6 +1243,7 @@ export const catalog: Catalog = {
"Could not send the receipt: {error}": "Die Lesebestätigung konnte nicht gesendet werden: {error}", "Could not send the receipt: {error}": "Die Lesebestätigung konnte nicht gesendet werden: {error}",
"Could not sign in.": "Anmeldung fehlgeschlagen.", "Could not sign in.": "Anmeldung fehlgeschlagen.",
"You'll enter your password on your mail server's sign-in page.": "Ihr Passwort geben Sie auf der Anmeldeseite Ihres Mailservers ein.", "You'll enter your password on your mail server's sign-in page.": "Ihr Passwort geben Sie auf der Anmeldeseite Ihres Mailservers ein.",
"You'll sign in on your mail server's own page.": "Sie melden sich auf der eigenen Seite Ihres Mailservers an.",
"This sign-in didn't start in this browser. Try again.": "Diese Anmeldung wurde nicht in diesem Browser begonnen. Bitte versuchen Sie es erneut.", "This sign-in didn't start in this browser. Try again.": "Diese Anmeldung wurde nicht in diesem Browser begonnen. Bitte versuchen Sie es erneut.",
"The sign-in took too long. Try again.": "Die Anmeldung hat zu lange gedauert. Bitte versuchen Sie es erneut.", "The sign-in took too long. Try again.": "Die Anmeldung hat zu lange gedauert. Bitte versuchen Sie es erneut.",
"Sign-in was cancelled.": "Die Anmeldung wurde abgebrochen.", "Sign-in was cancelled.": "Die Anmeldung wurde abgebrochen.",
+1
View File
@@ -1216,6 +1216,7 @@ export const catalog: Catalog = {
"Could not send the receipt: {error}": "No se pudo enviar la confirmación de lectura: {error}", "Could not send the receipt: {error}": "No se pudo enviar la confirmación de lectura: {error}",
"Could not sign in.": "No se pudo iniciar sesión.", "Could not sign in.": "No se pudo iniciar sesión.",
"You'll enter your password on your mail server's sign-in page.": "Introducirá su contraseña en la página de inicio de sesión de su servidor de correo.", "You'll enter your password on your mail server's sign-in page.": "Introducirá su contraseña en la página de inicio de sesión de su servidor de correo.",
"You'll sign in on your mail server's own page.": "Iniciará sesión en la propia página de su servidor de correo.",
"This sign-in didn't start in this browser. Try again.": "Este inicio de sesión no empezó en este navegador. Inténtelo de nuevo.", "This sign-in didn't start in this browser. Try again.": "Este inicio de sesión no empezó en este navegador. Inténtelo de nuevo.",
"The sign-in took too long. Try again.": "El inicio de sesión tardó demasiado. Inténtelo de nuevo.", "The sign-in took too long. Try again.": "El inicio de sesión tardó demasiado. Inténtelo de nuevo.",
"Sign-in was cancelled.": "Se canceló el inicio de sesión.", "Sign-in was cancelled.": "Se canceló el inicio de sesión.",
+1
View File
@@ -1221,6 +1221,7 @@ export const catalog: Catalog = {
"Could not send the receipt: {error}": "Impossible denvoyer laccusé de lecture : {error}", "Could not send the receipt: {error}": "Impossible denvoyer laccusé de lecture : {error}",
"Could not sign in.": "Connexion impossible.", "Could not sign in.": "Connexion impossible.",
"You'll enter your password on your mail server's sign-in page.": "Vous saisirez votre mot de passe sur la page de connexion de votre serveur de messagerie.", "You'll enter your password on your mail server's sign-in page.": "Vous saisirez votre mot de passe sur la page de connexion de votre serveur de messagerie.",
"You'll sign in on your mail server's own page.": "Vous vous connecterez sur la page de votre serveur de messagerie.",
"This sign-in didn't start in this browser. Try again.": "Cette connexion na pas commencé dans ce navigateur. Réessayez.", "This sign-in didn't start in this browser. Try again.": "Cette connexion na pas commencé dans ce navigateur. Réessayez.",
"The sign-in took too long. Try again.": "La connexion a pris trop de temps. Réessayez.", "The sign-in took too long. Try again.": "La connexion a pris trop de temps. Réessayez.",
"Sign-in was cancelled.": "La connexion a été annulée.", "Sign-in was cancelled.": "La connexion a été annulée.",
+1
View File
@@ -1224,6 +1224,7 @@ export const catalog: Catalog = {
"Could not send the receipt: {error}": "開封確認を送信できませんでした: {error}", "Could not send the receipt: {error}": "開封確認を送信できませんでした: {error}",
"Could not sign in.": "サインインできませんでした。", "Could not sign in.": "サインインできませんでした。",
"You'll enter your password on your mail server's sign-in page.": "パスワードはメールサーバーのサインインページで入力します。", "You'll enter your password on your mail server's sign-in page.": "パスワードはメールサーバーのサインインページで入力します。",
"You'll sign in on your mail server's own page.": "メールサーバー自身のページでサインインします。",
"This sign-in didn't start in this browser. Try again.": "このサインインはこのブラウザーで開始されたものではありません。もう一度お試しください。", "This sign-in didn't start in this browser. Try again.": "このサインインはこのブラウザーで開始されたものではありません。もう一度お試しください。",
"The sign-in took too long. Try again.": "サインインに時間がかかりすぎました。もう一度お試しください。", "The sign-in took too long. Try again.": "サインインに時間がかかりすぎました。もう一度お試しください。",
"Sign-in was cancelled.": "サインインはキャンセルされました。", "Sign-in was cancelled.": "サインインはキャンセルされました。",
+1
View File
@@ -1214,6 +1214,7 @@ export const catalog: Catalog = {
"Could not send the receipt: {error}": "De leesbevestiging kon niet worden verzonden: {error}", "Could not send the receipt: {error}": "De leesbevestiging kon niet worden verzonden: {error}",
"Could not sign in.": "Aanmelden mislukt.", "Could not sign in.": "Aanmelden mislukt.",
"You'll enter your password on your mail server's sign-in page.": "U voert uw wachtwoord in op de aanmeldpagina van uw mailserver.", "You'll enter your password on your mail server's sign-in page.": "U voert uw wachtwoord in op de aanmeldpagina van uw mailserver.",
"You'll sign in on your mail server's own page.": "U meldt zich aan op de eigen pagina van uw mailserver.",
"This sign-in didn't start in this browser. Try again.": "Deze aanmelding is niet in deze browser begonnen. Probeer het opnieuw.", "This sign-in didn't start in this browser. Try again.": "Deze aanmelding is niet in deze browser begonnen. Probeer het opnieuw.",
"The sign-in took too long. Try again.": "Het aanmelden duurde te lang. Probeer het opnieuw.", "The sign-in took too long. Try again.": "Het aanmelden duurde te lang. Probeer het opnieuw.",
"Sign-in was cancelled.": "Het aanmelden is geannuleerd.", "Sign-in was cancelled.": "Het aanmelden is geannuleerd.",
+1
View File
@@ -1219,6 +1219,7 @@ export const catalog: Catalog = {
"Could not send the receipt: {error}": "Não foi possível enviar a confirmação de leitura: {error}", "Could not send the receipt: {error}": "Não foi possível enviar a confirmação de leitura: {error}",
"Could not sign in.": "Não foi possível entrar.", "Could not sign in.": "Não foi possível entrar.",
"You'll enter your password on your mail server's sign-in page.": "Você vai digitar sua senha na página de entrada do seu servidor de e-mail.", "You'll enter your password on your mail server's sign-in page.": "Você vai digitar sua senha na página de entrada do seu servidor de e-mail.",
"You'll sign in on your mail server's own page.": "Você vai entrar na própria página do seu servidor de e-mail.",
"This sign-in didn't start in this browser. Try again.": "Esta entrada não começou neste navegador. Tente de novo.", "This sign-in didn't start in this browser. Try again.": "Esta entrada não começou neste navegador. Tente de novo.",
"The sign-in took too long. Try again.": "A entrada demorou demais. Tente de novo.", "The sign-in took too long. Try again.": "A entrada demorou demais. Tente de novo.",
"Sign-in was cancelled.": "A entrada foi cancelada.", "Sign-in was cancelled.": "A entrada foi cancelada.",
+1
View File
@@ -1218,6 +1218,7 @@ export const catalog: Catalog = {
"Could not send the receipt: {error}": "Не удалось отправить уведомление о прочтении: {error}", "Could not send the receipt: {error}": "Не удалось отправить уведомление о прочтении: {error}",
"Could not sign in.": "Не удалось войти.", "Could not sign in.": "Не удалось войти.",
"You'll enter your password on your mail server's sign-in page.": "Пароль вводится на странице входа вашего почтового сервера.", "You'll enter your password on your mail server's sign-in page.": "Пароль вводится на странице входа вашего почтового сервера.",
"You'll sign in on your mail server's own page.": "Вход выполняется на странице вашего почтового сервера.",
"This sign-in didn't start in this browser. Try again.": "Этот вход начат не в этом браузере. Попробуйте ещё раз.", "This sign-in didn't start in this browser. Try again.": "Этот вход начат не в этом браузере. Попробуйте ещё раз.",
"The sign-in took too long. Try again.": "Вход занял слишком много времени. Попробуйте ещё раз.", "The sign-in took too long. Try again.": "Вход занял слишком много времени. Попробуйте ещё раз.",
"Sign-in was cancelled.": "Вход отменён.", "Sign-in was cancelled.": "Вход отменён.",
+1
View File
@@ -1212,6 +1212,7 @@ export const catalog: Catalog = {
"Could not send the receipt: {error}": "Не вдалося надіслати сповіщення про прочитання: {error}", "Could not send the receipt: {error}": "Не вдалося надіслати сповіщення про прочитання: {error}",
"Could not sign in.": "Не вдалося увійти.", "Could not sign in.": "Не вдалося увійти.",
"You'll enter your password on your mail server's sign-in page.": "Пароль вводиться на сторінці входу вашого поштового сервера.", "You'll enter your password on your mail server's sign-in page.": "Пароль вводиться на сторінці входу вашого поштового сервера.",
"You'll sign in on your mail server's own page.": "Вхід виконується на сторінці вашого поштового сервера.",
"This sign-in didn't start in this browser. Try again.": "Цей вхід розпочато не в цьому браузері. Спробуйте ще раз.", "This sign-in didn't start in this browser. Try again.": "Цей вхід розпочато не в цьому браузері. Спробуйте ще раз.",
"The sign-in took too long. Try again.": "Вхід тривав занадто довго. Спробуйте ще раз.", "The sign-in took too long. Try again.": "Вхід тривав занадто довго. Спробуйте ще раз.",
"Sign-in was cancelled.": "Вхід скасовано.", "Sign-in was cancelled.": "Вхід скасовано.",
+1
View File
@@ -1223,6 +1223,7 @@ export const catalog: Catalog = {
"Could not send the receipt: {error}": "无法发送已读回执:{error}", "Could not send the receipt: {error}": "无法发送已读回执:{error}",
"Could not sign in.": "无法登录。", "Could not sign in.": "无法登录。",
"You'll enter your password on your mail server's sign-in page.": "您将在邮件服务器的登录页面上输入密码。", "You'll enter your password on your mail server's sign-in page.": "您将在邮件服务器的登录页面上输入密码。",
"You'll sign in on your mail server's own page.": "您将在邮件服务器自己的页面上登录。",
"This sign-in didn't start in this browser. Try again.": "此次登录不是在这个浏览器中发起的。请重试。", "This sign-in didn't start in this browser. Try again.": "此次登录不是在这个浏览器中发起的。请重试。",
"The sign-in took too long. Try again.": "登录耗时过长。请重试。", "The sign-in took too long. Try again.": "登录耗时过长。请重试。",
"Sign-in was cancelled.": "登录已取消。", "Sign-in was cancelled.": "登录已取消。",
+13 -4
View File
@@ -33,6 +33,8 @@ export function LoginPage() {
* and the password form if it never does. * and the password form if it never does.
*/ */
const [signIn, setSignIn] = useState<"oauth" | "password" | null>(null); const [signIn, setSignIn] = useState<"oauth" | "password" | null>(null);
/* With "oauth" and one mail server, its page asks for the username: no address here. */
const [direct, setDirect] = useState(false);
useEffect(() => { useEffect(() => {
let live = true; let live = true;
fetch(withBase("/api/config")) fetch(withBase("/api/config"))
@@ -42,6 +44,7 @@ export function LoginPage() {
if (c.sourceUrl) setSourceUrl(c.sourceUrl as string); if (c.sourceUrl) setSourceUrl(c.sourceUrl as string);
if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim()); if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim());
setSignIn(c.signIn === "oauth" ? "oauth" : "password"); setSignIn(c.signIn === "oauth" ? "oauth" : "password");
setDirect(c.signIn === "oauth" && c.signInDirect === true);
}) })
.catch(() => { /* the default stands */ }) .catch(() => { /* the default stands */ })
.finally(() => { if (live) setSignIn((m) => m ?? "password"); }); .finally(() => { if (live) setSignIn((m) => m ?? "password"); });
@@ -58,10 +61,10 @@ export function LoginPage() {
e.preventDefault(); e.preventDefault();
if (signIn === "oauth") { if (signIn === "oauth") {
// Off to the mail server's page, which asks for the password there. // Off to the mail server's page, which asks for the password there.
if (!username.trim()) return; if (!direct && !username.trim()) return;
setBusy(true); setBusy(true);
if (trustDevice) localStorage.setItem("ihasmail:lastUser", username.trim()); if (trustDevice && !direct) localStorage.setItem("ihasmail:lastUser", username.trim());
const params = new URLSearchParams({ username: username.trim(), ...(trustDevice ? { remember: "1" } : {}) }); const params = new URLSearchParams({ ...(direct ? {} : { username: username.trim() }), ...(trustDevice ? { remember: "1" } : {}) });
window.location.assign(withBase(`/api/auth/oauth/start?${params}`)); window.location.assign(withBase(`/api/auth/oauth/start?${params}`));
return; return;
} }
@@ -100,12 +103,18 @@ export function LoginPage() {
{error} {error}
</div> </div>
)} )}
{!direct && (
<div className="field"> <div className="field">
<label htmlFor="u">{t("Email or username")}</label> <label htmlFor="u">{t("Email or username")}</label>
<input id="u" className="input" type="text" autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus={!username} required /> <input id="u" className="input" type="text" autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus={!username} required />
</div> </div>
)}
{signIn === "oauth" ? ( {signIn === "oauth" ? (
<p className="hint" style={{ marginBottom: 12 }}>{t("You'll enter your password on your mail server's sign-in page.")}</p> <p className="hint" style={{ marginBottom: 12 }}>
{direct
? t("You'll sign in on your mail server's own page.")
: t("You'll enter your password on your mail server's sign-in page.")}
</p>
) : ( ) : (
<div className="field"> <div className="field">
<label htmlFor="p">{t("Password")}</label> <label htmlFor="p">{t("Password")}</label>