Sign in on the mail server's own page (OAuth with PKCE), sessions hold tokens; tenants on every edition
Contract C-8 and C-10: with OAUTH_CLIENT_SECRET set, sign-in goes through the server's page and the session keeps sealed tokens, renewed before they expire, instead of a password. Push keeps a credential that renews itself. A password change signs the session out, since the server revokes its tokens. The mock answers OAuth for tests and development. Eleven new strings, in all nine catalogues.
This commit is contained in:
@@ -31,6 +31,8 @@ export interface JmapSession {
|
||||
maxUploadBytes: number;
|
||||
sessionId: string;
|
||||
loginName: string;
|
||||
/** "oauth" when signed in on the mail server's page (ihasmail-inbuxa); absent from older servers. */
|
||||
signIn?: "oauth" | "password";
|
||||
remember: boolean;
|
||||
/** Locale configured for the account in Stalwart, if the server exposes it. */
|
||||
userLocale?: string | null;
|
||||
|
||||
@@ -1242,6 +1242,17 @@ export const catalog: Catalog = {
|
||||
"Could not save filters: {error}": "Filter konnten nicht gespeichert werden: {error}",
|
||||
"Could not send the receipt: {error}": "Die Lesebestätigung konnte nicht gesendet werden: {error}",
|
||||
"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.",
|
||||
"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.",
|
||||
"Sign-in was cancelled.": "Die Anmeldung wurde abgebrochen.",
|
||||
"The mail server didn't accept the sign-in. Try again.": "Der Mailserver hat die Anmeldung nicht angenommen. Bitte versuchen Sie es erneut.",
|
||||
"That account is on a different mail server than the address you entered. Sign in with that address.": "Dieses Konto liegt auf einem anderen Mailserver als die eingegebene Adresse. Melden Sie sich mit dieser Adresse an.",
|
||||
"This mail server isn't supported.": "Dieser Mailserver wird nicht unterstützt.",
|
||||
"Couldn't reach the mail server. Try again in a moment.": "Der Mailserver ist nicht erreichbar. Bitte versuchen Sie es gleich noch einmal.",
|
||||
"Your password was changed. Sign in with the new one.": "Ihr Passwort wurde geändert. Melden Sie sich mit dem neuen an.",
|
||||
"Your sign-in ended. Sign in again.": "Ihre Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Wenn Sie Ihr Passwort ändern, werden Sie überall abgemeldet, auch hier, und melden sich mit dem neuen wieder an. App-Passwörter funktionieren weiterhin.",
|
||||
"Could not store image: {error}": "Bild konnte nicht gespeichert werden: {error}",
|
||||
"Could not update labels: {error}": "Labels konnten nicht aktualisiert werden: {error}",
|
||||
"Could not update the Scheduled folder: {error}": "Der Ordner „Geplant“ konnte nicht aktualisiert werden: {error}",
|
||||
|
||||
@@ -1215,6 +1215,17 @@ export const catalog: Catalog = {
|
||||
"Could not save filters: {error}": "No se pudieron guardar los filtros: {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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"Sign-in was cancelled.": "Se canceló el inicio de sesión.",
|
||||
"The mail server didn't accept the sign-in. Try again.": "El servidor de correo no aceptó el inicio de sesión. Inténtelo de nuevo.",
|
||||
"That account is on a different mail server than the address you entered. Sign in with that address.": "Esa cuenta está en un servidor de correo distinto del de la dirección que introdujo. Inicie sesión con esa dirección.",
|
||||
"This mail server isn't supported.": "Este servidor de correo no es compatible.",
|
||||
"Couldn't reach the mail server. Try again in a moment.": "No se pudo conectar con el servidor de correo. Inténtelo de nuevo en un momento.",
|
||||
"Your password was changed. Sign in with the new one.": "Se cambió su contraseña. Inicie sesión con la nueva.",
|
||||
"Your sign-in ended. Sign in again.": "Su sesión terminó. Inicie sesión de nuevo.",
|
||||
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Cambiar la contraseña cierra todas sus sesiones, también esta, y vuelve a iniciar sesión con la nueva. Las contraseñas de aplicación siguen funcionando.",
|
||||
"Could not store image: {error}": "No se pudo guardar la imagen: {error}",
|
||||
"Could not update labels: {error}": "No se pudieron actualizar las etiquetas: {error}",
|
||||
"Could not update the Scheduled folder: {error}": "No se pudo actualizar la carpeta Programados: {error}",
|
||||
|
||||
@@ -1220,6 +1220,17 @@ export const catalog: Catalog = {
|
||||
"Could not save filters: {error}": "Impossible d’enregistrer les filtres : {error}",
|
||||
"Could not send the receipt: {error}": "Impossible d’envoyer l’accusé de lecture : {error}",
|
||||
"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.",
|
||||
"This sign-in didn't start in this browser. Try again.": "Cette connexion n’a pas commencé dans ce navigateur. 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.",
|
||||
"The mail server didn't accept the sign-in. Try again.": "Le serveur de messagerie n’a pas accepté la connexion. Réessayez.",
|
||||
"That account is on a different mail server than the address you entered. Sign in with that address.": "Ce compte se trouve sur un autre serveur de messagerie que l’adresse saisie. Connectez-vous avec cette adresse.",
|
||||
"This mail server isn't supported.": "Ce serveur de messagerie n’est pas pris en charge.",
|
||||
"Couldn't reach the mail server. Try again in a moment.": "Impossible de joindre le serveur de messagerie. Réessayez dans un instant.",
|
||||
"Your password was changed. Sign in with the new one.": "Votre mot de passe a été changé. Connectez-vous avec le nouveau.",
|
||||
"Your sign-in ended. Sign in again.": "Votre connexion a pris fin. Connectez-vous à nouveau.",
|
||||
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Changer votre mot de passe vous déconnecte partout, ici compris, et vous vous reconnectez avec le nouveau. Les mots de passe d'application continuent de fonctionner.",
|
||||
"Could not store image: {error}": "Impossible d’enregistrer l’image : {error}",
|
||||
"Could not update labels: {error}": "Impossible de mettre à jour les libellés : {error}",
|
||||
"Could not update the Scheduled folder: {error}": "Impossible de mettre à jour le dossier Programmés : {error}",
|
||||
|
||||
@@ -1223,6 +1223,17 @@ export const catalog: Catalog = {
|
||||
"Could not save filters: {error}": "フィルターを保存できませんでした: {error}",
|
||||
"Could not send the receipt: {error}": "開封確認を送信できませんでした: {error}",
|
||||
"Could not sign in.": "サインインできませんでした。",
|
||||
"You'll enter your password on your mail server's sign-in page.": "パスワードはメールサーバーのサインインページで入力します。",
|
||||
"This sign-in didn't start in this browser. Try again.": "このサインインはこのブラウザーで開始されたものではありません。もう一度お試しください。",
|
||||
"The sign-in took too long. Try again.": "サインインに時間がかかりすぎました。もう一度お試しください。",
|
||||
"Sign-in was cancelled.": "サインインはキャンセルされました。",
|
||||
"The mail server didn't accept the sign-in. Try again.": "メールサーバーがサインインを受け付けませんでした。もう一度お試しください。",
|
||||
"That account is on a different mail server than the address you entered. Sign in with that address.": "そのアカウントは、入力したアドレスとは別のメールサーバーにあります。そのアドレスでサインインしてください。",
|
||||
"This mail server isn't supported.": "このメールサーバーには対応していません。",
|
||||
"Couldn't reach the mail server. Try again in a moment.": "メールサーバーに接続できませんでした。しばらくしてからもう一度お試しください。",
|
||||
"Your password was changed. Sign in with the new one.": "パスワードが変更されました。新しいパスワードでサインインしてください。",
|
||||
"Your sign-in ended. Sign in again.": "サインインが終了しました。もう一度サインインしてください。",
|
||||
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "パスワードを変更すると、この画面を含むすべての場所からサインアウトされ、新しいパスワードで再度サインインします。アプリパスワードはそのまま使えます。",
|
||||
"Could not store image: {error}": "画像を保存できませんでした: {error}",
|
||||
"Could not update labels: {error}": "ラベルを更新できませんでした: {error}",
|
||||
"Could not update the Scheduled folder: {error}": "「送信予約」フォルダーを更新できませんでした: {error}",
|
||||
|
||||
@@ -1213,6 +1213,17 @@ export const catalog: Catalog = {
|
||||
"Could not save filters: {error}": "Filters opslaan mislukt: {error}",
|
||||
"Could not send the receipt: {error}": "De leesbevestiging kon niet worden verzonden: {error}",
|
||||
"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.",
|
||||
"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.",
|
||||
"Sign-in was cancelled.": "Het aanmelden is geannuleerd.",
|
||||
"The mail server didn't accept the sign-in. Try again.": "De mailserver heeft de aanmelding niet geaccepteerd. Probeer het opnieuw.",
|
||||
"That account is on a different mail server than the address you entered. Sign in with that address.": "Dat account staat op een andere mailserver dan het adres dat u invoerde. Meld u aan met dat adres.",
|
||||
"This mail server isn't supported.": "Deze mailserver wordt niet ondersteund.",
|
||||
"Couldn't reach the mail server. Try again in a moment.": "Kan de mailserver niet bereiken. Probeer het zo meteen opnieuw.",
|
||||
"Your password was changed. Sign in with the new one.": "Uw wachtwoord is gewijzigd. Meld u aan met het nieuwe.",
|
||||
"Your sign-in ended. Sign in again.": "Uw aanmelding is beëindigd. Meld u opnieuw aan.",
|
||||
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Als u uw wachtwoord wijzigt, wordt u overal afgemeld, ook hier, en meldt u zich opnieuw aan met het nieuwe. App-wachtwoorden blijven werken.",
|
||||
"Could not store image: {error}": "De afbeelding kon niet worden opgeslagen: {error}",
|
||||
"Could not update labels: {error}": "Labels bijwerken mislukt: {error}",
|
||||
"Could not update the Scheduled folder: {error}": "De map Gepland kon niet worden bijgewerkt: {error}",
|
||||
|
||||
@@ -1218,6 +1218,17 @@ export const catalog: Catalog = {
|
||||
"Could not save filters: {error}": "Não foi possível salvar os filtros: {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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"Sign-in was cancelled.": "A entrada foi cancelada.",
|
||||
"The mail server didn't accept the sign-in. Try again.": "O servidor de e-mail não aceitou a entrada. Tente de novo.",
|
||||
"That account is on a different mail server than the address you entered. Sign in with that address.": "Essa conta está em um servidor de e-mail diferente do endereço que você digitou. Entre com esse endereço.",
|
||||
"This mail server isn't supported.": "Este servidor de e-mail não é compatível.",
|
||||
"Couldn't reach the mail server. Try again in a moment.": "Não foi possível acessar o servidor de e-mail. Tente de novo em instantes.",
|
||||
"Your password was changed. Sign in with the new one.": "Sua senha foi alterada. Entre com a nova.",
|
||||
"Your sign-in ended. Sign in again.": "Sua sessão terminou. Entre novamente.",
|
||||
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Mudar sua senha encerra todas as suas sessões, inclusive esta, e você entra de novo com a nova senha. As senhas de aplicativo continuam funcionando.",
|
||||
"Could not store image: {error}": "Não foi possível armazenar a imagem: {error}",
|
||||
"Could not update labels: {error}": "Não foi possível atualizar os marcadores: {error}",
|
||||
"Could not update the Scheduled folder: {error}": "Não foi possível atualizar a pasta Agendados: {error}",
|
||||
|
||||
@@ -1217,6 +1217,17 @@ export const catalog: Catalog = {
|
||||
"Could not save filters: {error}": "Не удалось сохранить фильтры: {error}",
|
||||
"Could not send the receipt: {error}": "Не удалось отправить уведомление о прочтении: {error}",
|
||||
"Could not sign in.": "Не удалось войти.",
|
||||
"You'll enter your password on your mail server's sign-in page.": "Пароль вводится на странице входа вашего почтового сервера.",
|
||||
"This sign-in didn't start in this browser. Try again.": "Этот вход начат не в этом браузере. Попробуйте ещё раз.",
|
||||
"The sign-in took too long. Try again.": "Вход занял слишком много времени. Попробуйте ещё раз.",
|
||||
"Sign-in was cancelled.": "Вход отменён.",
|
||||
"The mail server didn't accept the sign-in. Try again.": "Почтовый сервер не принял вход. Попробуйте ещё раз.",
|
||||
"That account is on a different mail server than the address you entered. Sign in with that address.": "Эта учётная запись находится на другом почтовом сервере, чем введённый адрес. Войдите с этим адресом.",
|
||||
"This mail server isn't supported.": "Этот почтовый сервер не поддерживается.",
|
||||
"Couldn't reach the mail server. Try again in a moment.": "Не удалось связаться с почтовым сервером. Попробуйте ещё раз чуть позже.",
|
||||
"Your password was changed. Sign in with the new one.": "Пароль изменён. Войдите с новым паролем.",
|
||||
"Your sign-in ended. Sign in again.": "Сеанс завершён. Войдите снова.",
|
||||
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Смена пароля завершает все сеансы, включая этот, после чего нужно войти с новым паролем. Пароли приложений продолжают работать.",
|
||||
"Could not store image: {error}": "Не удалось сохранить изображение: {error}",
|
||||
"Could not update labels: {error}": "Не удалось обновить метки: {error}",
|
||||
"Could not update the Scheduled folder: {error}": "Не удалось обновить папку «Отложенные»: {error}",
|
||||
|
||||
@@ -1211,6 +1211,17 @@ export const catalog: Catalog = {
|
||||
"Could not save filters: {error}": "Не вдалося зберегти фільтри: {error}",
|
||||
"Could not send the receipt: {error}": "Не вдалося надіслати сповіщення про прочитання: {error}",
|
||||
"Could not sign in.": "Не вдалося увійти.",
|
||||
"You'll enter your password on your mail server's sign-in page.": "Пароль вводиться на сторінці входу вашого поштового сервера.",
|
||||
"This sign-in didn't start in this browser. Try again.": "Цей вхід розпочато не в цьому браузері. Спробуйте ще раз.",
|
||||
"The sign-in took too long. Try again.": "Вхід тривав занадто довго. Спробуйте ще раз.",
|
||||
"Sign-in was cancelled.": "Вхід скасовано.",
|
||||
"The mail server didn't accept the sign-in. Try again.": "Поштовий сервер не прийняв вхід. Спробуйте ще раз.",
|
||||
"That account is on a different mail server than the address you entered. Sign in with that address.": "Цей обліковий запис на іншому поштовому сервері, ніж введена адреса. Увійдіть із цією адресою.",
|
||||
"This mail server isn't supported.": "Цей поштовий сервер не підтримується.",
|
||||
"Couldn't reach the mail server. Try again in a moment.": "Не вдалося зв’язатися з поштовим сервером. Спробуйте ще раз трохи згодом.",
|
||||
"Your password was changed. Sign in with the new one.": "Пароль змінено. Увійдіть із новим паролем.",
|
||||
"Your sign-in ended. Sign in again.": "Сеанс завершено. Увійдіть знову.",
|
||||
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "Зміна пароля завершує всі сеанси, зокрема цей, після чого треба увійти з новим паролем. Паролі програм продовжують працювати.",
|
||||
"Could not store image: {error}": "Не вдалося зберегти зображення: {error}",
|
||||
"Could not update labels: {error}": "Не вдалося оновити мітки: {error}",
|
||||
"Could not update the Scheduled folder: {error}": "Не вдалося оновити теку «Заплановані»: {error}",
|
||||
|
||||
@@ -1222,6 +1222,17 @@ export const catalog: Catalog = {
|
||||
"Could not save filters: {error}": "无法保存过滤器:{error}",
|
||||
"Could not send the receipt: {error}": "无法发送已读回执:{error}",
|
||||
"Could not sign in.": "无法登录。",
|
||||
"You'll enter your password on your mail server's sign-in page.": "您将在邮件服务器的登录页面上输入密码。",
|
||||
"This sign-in didn't start in this browser. Try again.": "此次登录不是在这个浏览器中发起的。请重试。",
|
||||
"The sign-in took too long. Try again.": "登录耗时过长。请重试。",
|
||||
"Sign-in was cancelled.": "登录已取消。",
|
||||
"The mail server didn't accept the sign-in. Try again.": "邮件服务器未接受此次登录。请重试。",
|
||||
"That account is on a different mail server than the address you entered. Sign in with that address.": "该账户所在的邮件服务器与您输入的地址不同。请使用该地址登录。",
|
||||
"This mail server isn't supported.": "不支持此邮件服务器。",
|
||||
"Couldn't reach the mail server. Try again in a moment.": "无法连接邮件服务器。请稍后重试。",
|
||||
"Your password was changed. Sign in with the new one.": "您的密码已更改。请使用新密码登录。",
|
||||
"Your sign-in ended. Sign in again.": "您的登录已结束。请重新登录。",
|
||||
"Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.": "更改密码会让您在所有地方退出登录(包括这里),然后需用新密码重新登录。已有的应用专用密码仍可继续使用。",
|
||||
"Could not store image: {error}": "无法保存图片:{error}",
|
||||
"Could not update labels: {error}": "无法更新标签:{error}",
|
||||
"Could not update the Scheduled folder: {error}": "无法更新「定时发送」文件夹:{error}",
|
||||
|
||||
+62
-3
@@ -27,6 +27,12 @@ export function LoginPage() {
|
||||
* sign-in form with no name on it would be worse than a wrong one.
|
||||
*/
|
||||
const [appName, setAppName] = useState(DEFAULT_APP_NAME);
|
||||
/*
|
||||
* How this installation signs people in: on the mail server's own page
|
||||
* ("oauth"), or with the password form. Unknown until the config arrives,
|
||||
* and the password form if it never does.
|
||||
*/
|
||||
const [signIn, setSignIn] = useState<"oauth" | "password" | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
fetch(withBase("/api/config"))
|
||||
@@ -35,8 +41,10 @@ export function LoginPage() {
|
||||
if (!live || !c) return;
|
||||
if (c.sourceUrl) setSourceUrl(c.sourceUrl as string);
|
||||
if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim());
|
||||
setSignIn(c.signIn === "oauth" ? "oauth" : "password");
|
||||
})
|
||||
.catch(() => { /* the default stands */ });
|
||||
.catch(() => { /* the default stands */ })
|
||||
.finally(() => { if (live) setSignIn((m) => m ?? "password"); });
|
||||
return () => { live = false; };
|
||||
}, []);
|
||||
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
|
||||
@@ -44,10 +52,19 @@ export function LoginPage() {
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [trustDevice, setTrustDevice] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(() => takeSignInNotice());
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (signIn === "oauth") {
|
||||
// Off to the mail server's page, which asks for the password there.
|
||||
if (!username.trim()) return;
|
||||
setBusy(true);
|
||||
if (trustDevice) localStorage.setItem("ihasmail:lastUser", username.trim());
|
||||
const params = new URLSearchParams({ username: username.trim(), ...(trustDevice ? { remember: "1" } : {}) });
|
||||
window.location.assign(withBase(`/api/auth/oauth/start?${params}`));
|
||||
return;
|
||||
}
|
||||
if (!username || !password) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -87,6 +104,9 @@ export function LoginPage() {
|
||||
<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 />
|
||||
</div>
|
||||
{signIn === "oauth" ? (
|
||||
<p className="hint" style={{ marginBottom: 12 }}>{t("You'll enter your password on your mail server's sign-in page.")}</p>
|
||||
) : (
|
||||
<div className="field">
|
||||
<label htmlFor="p">{t("Password")}</label>
|
||||
<div className="pw-wrap">
|
||||
@@ -96,6 +116,7 @@ export function LoginPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label className="check" style={{ marginBottom: 4 }}>
|
||||
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
||||
<span>{t("This is my own device")}</span>
|
||||
@@ -105,7 +126,7 @@ export function LoginPage() {
|
||||
? "Stay signed in, and keep settings and recent addresses on this computer."
|
||||
: "Signed out after 5 minutes of inactivity, and nothing is kept on this computer. Leave this unticked on a shared or public one."}
|
||||
</p>
|
||||
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy}>
|
||||
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy || signIn === null}>
|
||||
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
@@ -130,3 +151,41 @@ export function LoginPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Why the sign-in page is showing, when something sent it here: an error the
|
||||
* mail server's page came back with (`?signin_error=`), or a notice left by a
|
||||
* change that signed this session out. Read once, then removed, so a reload
|
||||
* doesn't repeat it.
|
||||
*/
|
||||
const SIGNIN_ERROR_LABELS: Record<string, string> = {
|
||||
state_mismatch: "This sign-in didn't start in this browser. Try again.",
|
||||
expired: "The sign-in took too long. Try again.",
|
||||
cancelled: "Sign-in was cancelled.",
|
||||
exchange_failed: "The mail server didn't accept the sign-in. Try again.",
|
||||
wrong_account: "That account is on a different mail server than the address you entered. Sign in with that address.",
|
||||
unsupported_server: "This mail server isn't supported.",
|
||||
unavailable: "Couldn't reach the mail server. Try again in a moment.",
|
||||
rate_limited: "Too many attempts. Please wait a few minutes and try again.",
|
||||
password_changed: "Your password was changed. Sign in with the new one.",
|
||||
signed_out: "Your sign-in ended. Sign in again.",
|
||||
};
|
||||
|
||||
export const SIGNIN_NOTICE_KEY = "ihasmail:signinNotice";
|
||||
|
||||
function takeSignInNotice(): string | null {
|
||||
let code: string | null = null;
|
||||
try {
|
||||
code = sessionStorage.getItem(SIGNIN_NOTICE_KEY);
|
||||
sessionStorage.removeItem(SIGNIN_NOTICE_KEY);
|
||||
} catch { /* storage may be unavailable */ }
|
||||
const url = new URL(window.location.href);
|
||||
const fromUrl = url.searchParams.get("signin_error");
|
||||
if (fromUrl) {
|
||||
code = fromUrl;
|
||||
url.searchParams.delete("signin_error");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
if (!code) return null;
|
||||
return t(SIGNIN_ERROR_LABELS[code] ?? "Could not sign in.");
|
||||
}
|
||||
|
||||
@@ -18,30 +18,14 @@ const PAGE_SIZE = 50;
|
||||
* Tenants: separate organizations on one server, each with its own people,
|
||||
* domains and limits.
|
||||
*
|
||||
* The section is offered to whoever may read tenants. On a server that does not
|
||||
* report Enterprise -- or reports no edition -- the page is only a notice that
|
||||
* tenants are an Enterprise feature: tenants there hold nobody to anything
|
||||
* beyond an ordinary user's permissions, so there is nothing worth creating or
|
||||
* listing. On Enterprise the notice is left out, unless the installation asks
|
||||
* for it (SHOW_ENTERPRISE_NOTICES), as the public demo does so as not to
|
||||
* suggest tenants come without the license.
|
||||
* The section is offered to whoever may read tenants. INBUXA ships tenants to
|
||||
* everybody, whatever edition the server reports, so there is no edition
|
||||
* check here (public ihasmail shows only a notice unless the server reports
|
||||
* Enterprise). SHOW_ENTERPRISE_NOTICES still adds the notice, for talking to
|
||||
* upstream Stalwart.
|
||||
*/
|
||||
export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
|
||||
const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null);
|
||||
const notices = useSession((s) => s.session?.ihasmail?.server?.enterpriseNotices === true);
|
||||
if (edition !== "enterprise") {
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-head">
|
||||
<div className="grow">
|
||||
<h1>{t("Tenants")}</h1>
|
||||
<p className="lead">{t("Separate organizations on one server, each with its own people, domains and limits.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<EnterpriseNotice warn />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <EnterpriseTenants selectedId={selectedId} notice={notices} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const PERMS = ["sysTenantGet", "sysTenantQuery", "sysTenantCreate"];
|
||||
const signIn = (edition: string | null, enterpriseNotices = false) =>
|
||||
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions: PERMS, server: { edition, enterpriseNotices } } } as unknown as JmapSession });
|
||||
|
||||
/** Tenants are managed on Enterprise only; anywhere else the page is the notice and nothing more. */
|
||||
/** INBUXA: tenants are managed on every server, whatever edition it reports. */
|
||||
describe("the Tenants page", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -43,25 +43,17 @@ describe("the Tenants page", () => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
for (const edition of ["community", "oss", null]) {
|
||||
it(`shows only the notice on ${edition ?? "a server that reports no edition"}`, async () => {
|
||||
for (const edition of ["community", "oss", null, "enterprise"]) {
|
||||
it(`lists and offers tenants on ${edition ?? "a server that reports no edition"}, with no Enterprise notice`, async () => {
|
||||
signIn(edition);
|
||||
await render();
|
||||
expect(host.querySelector(".admin-notice.warn")?.textContent).toContain("Tenants are a Stalwart Enterprise feature");
|
||||
expect(host.textContent).not.toContain("New tenant");
|
||||
expect(host.querySelector('input[type="search"]')).toBeNull();
|
||||
expect(host.querySelector(".admin-table")).toBeNull();
|
||||
expect(api.queryTenants).not.toHaveBeenCalled();
|
||||
expect(host.querySelector(".admin-notice")).toBeNull();
|
||||
expect(host.textContent).toContain("New tenant");
|
||||
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
|
||||
expect(api.queryTenants).toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
it("lists and offers tenants on Enterprise, and does not say they are Enterprise", async () => {
|
||||
signIn("enterprise");
|
||||
await render();
|
||||
expect(host.querySelector(".admin-notice")).toBeNull();
|
||||
expect(host.textContent).toContain("New tenant");
|
||||
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Tenants page where the installation asks for Enterprise notices", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatFullDate } from "@/lib/format";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { confirmDialog, Dialog } from "@/ui/dialog";
|
||||
import { plural, t, tNode } from "@/lib/i18n";
|
||||
import { SIGNIN_NOTICE_KEY } from "@/views/Login";
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
@@ -109,7 +110,18 @@ export function SecuritySettings() {
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* A change that ended this session on the server (with sign-in on the mail
|
||||
* server's page, a new password revokes every token): sign out here too, and
|
||||
* leave the sign-in page a line saying why.
|
||||
*/
|
||||
async function signedOutBy(notice: string) {
|
||||
try { sessionStorage.setItem(SIGNIN_NOTICE_KEY, notice); } catch { /* storage may be unavailable */ }
|
||||
await useSession.getState().logout();
|
||||
}
|
||||
|
||||
function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChanged: () => void }) {
|
||||
const tokenSession = useSession((s) => s.session?.ihasmail?.signIn === "oauth");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
@@ -124,11 +136,15 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<{ revokedSessions: number }>("/api/account/password", {
|
||||
const res = await apiFetch<{ revokedSessions: number; signedOut?: boolean }>("/api/account/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ current, next, otpCode: code || undefined }),
|
||||
});
|
||||
setCurrent(""); setNext(""); setConfirm(""); setCode("");
|
||||
if (res.signedOut) {
|
||||
await signedOutBy("password_changed");
|
||||
return;
|
||||
}
|
||||
toast.success(res.revokedSessions ? `Password changed. ${res.revokedSessions} other session(s) signed out.` : "Password changed");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
@@ -140,7 +156,11 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<p className="hint" style={{ marginBottom: 12 }}>{t("Changing your password signs out your other webmail sessions. Any app passwords keep working.")}</p>
|
||||
<p className="hint" style={{ marginBottom: 12 }}>
|
||||
{tokenSession
|
||||
? t("Changing your password signs you out everywhere, here included, and you sign in again with the new one. Any app passwords keep working.")
|
||||
: t("Changing your password signs out your other webmail sessions. Any app passwords keep working.")}
|
||||
</p>
|
||||
<div className="field" style={{ maxWidth: 380 }}>
|
||||
<label htmlFor="pw-current">{t("Current password")}</label>
|
||||
<input id="pw-current" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
|
||||
@@ -185,7 +205,11 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
|
||||
const disable = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch("/api/account/2fa/disable", { method: "POST", body: JSON.stringify({ current: password, code }) });
|
||||
const res = await apiFetch<{ signedOut?: boolean }>("/api/account/2fa/disable", { method: "POST", body: JSON.stringify({ current: password, code }) });
|
||||
if (res?.signedOut) {
|
||||
await signedOutBy("signed_out");
|
||||
return;
|
||||
}
|
||||
setDisabling(false);
|
||||
await reload();
|
||||
toast.success(t("Two-factor authentication is off"));
|
||||
|
||||
Reference in New Issue
Block a user