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:
+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