Files
rustpad/static/js/auth-ui.js
T

440 lines
17 KiB
JavaScript

/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { api } from "@rustpad/api";
import * as sessionStore from "@rustpad/session";
import { askConfirm, askInput, showMessage } from "@rustpad/modal";
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
const clearAuthSession = sessionStore.clearAuthSession || (() => {
localStorage.removeItem("rustpad:auth-state");
localStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:auth-token");
localStorage.removeItem("rustpad:nickname");
sessionStorage.removeItem("rustpad:nickname");
});
const clearResourceAccessState = sessionStore.clearResourceAccessState || (() => {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key?.startsWith("rustpad:access:")) localStorage.removeItem(key);
}
});
function configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth }) {
const directoryLogin = loginMode && externalAuth;
emailLabel.textContent = directoryLogin ? "E-mail / LDAP or AD Username" : "E-mail";
email.type = directoryLogin ? "text" : "email";
email.placeholder = directoryLogin ? "you@example.com or username" : "you@example.com";
email.autocomplete = loginMode ? "username" : "off";
password.autocomplete = loginMode ? "current-password" : "off";
if (directoryLogin) password.removeAttribute("minlength");
else password.minLength = 8;
if (loginMode) {
email.removeAttribute("data-bwignore");
password.removeAttribute("data-bwignore");
} else {
email.dataset.bwignore = "true";
password.dataset.bwignore = "true";
}
}
export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }) {
if (dialog.querySelector("#auth-panel")) return bindLegacyIdentityDialog({ dialog, onIdentity });
const form = dialog.querySelector("#identity-form");
const nickname = dialog.querySelector("#nickname");
const message = dialog.querySelector("#identity-error");
const title = dialog.querySelector("#identity-title");
const copy = dialog.querySelector("#identity-copy");
const emailField = dialog.querySelector("#auth-email-field");
const emailLabel = dialog.querySelector("#auth-email-label");
const email = dialog.querySelector("#auth-email");
const password = dialog.querySelector("#auth-password");
const submit = dialog.querySelector("#auth-submit");
const switchMode = dialog.querySelector("#auth-switch-mode");
const resetButton = dialog.querySelector("#show-reset");
const backButton = dialog.querySelector("#reset-back");
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
const externalAuth = window.__RUSTPAD_CONFIG__?.externalAuth === true;
let mode = initialMode;
const setMode = (nextMode) => {
mode = nextMode === "register" && !registrationEnabled ? "login" : nextMode;
const resetting = mode === "reset";
const registering = mode === "register";
title.textContent = resetting ? "Reset password" : registering ? "Register" : "Log in";
copy.textContent = resetting
? "Enter the e-mail address assigned to your local account."
: registering
? "Register your account with an e-mail address and password."
: externalAuth
? "Use your e-mail address or LDAP/AD username and password."
: "Use your e-mail address and password.";
nickname.closest("label").hidden = !registering;
nickname.disabled = !registering;
emailField.hidden = false;
email.disabled = false;
password.closest("label").hidden = resetting;
password.disabled = resetting;
submit.textContent = resetting ? "Send reset link" : registering ? "Create account" : "Log in";
switchMode.hidden = resetting || !registrationEnabled;
switchMode.textContent = registering ? "Already registered? Log in" : "Create an account";
resetButton.hidden = resetting || registering || externalAuth;
backButton.hidden = !resetting;
const loginMode = mode === "login";
configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth });
form.autocomplete = loginMode ? "on" : "off";
nickname.autocomplete = "off";
nickname.dataset.bwignore = "true";
message.textContent = "";
queueMicrotask(() => {
(registering ? nickname : email).focus();
});
};
switchMode?.addEventListener("click", () => setMode(mode === "register" ? "login" : "register"));
resetButton?.addEventListener("click", () => setMode("reset"));
backButton?.addEventListener("click", () => setMode("login"));
dialog.querySelector("#close-identity")?.addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => {
if (event.target === dialog) dialog.close();
});
dialog.addEventListener("close", () => {
form.reset();
message.textContent = "";
setMode(initialMode);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
message.textContent = "";
submit.disabled = true;
try {
if (mode === "reset") {
const result = await api("/api/auth/password-reset", {
method: "POST",
body: JSON.stringify({ email: email.value.trim() }),
});
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
return;
}
const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login";
const payload = { email: email.value.trim(), password: password.value };
if (mode === "register") payload.nickname = nickname.value.trim();
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
if (mode === "register" && session.confirmation_required) {
message.classList.remove("error");
message.classList.add("success");
message.textContent = session.message;
password.value = "";
return;
}
setAuthSession(session);
await onIdentity(session.nickname, session);
dialog.close();
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
if (/confirm the account/i.test(error.message) && email.value.trim()) {
const resend = document.createElement("button");
resend.type = "button";
resend.className = "text-button resend-confirmation";
resend.textContent = "Resend confirmation e-mail";
resend.addEventListener("click", async () => {
resend.disabled = true;
try {
const result = await api("/api/auth/resend-confirmation", { method: "POST", body: JSON.stringify({ email: email.value.trim() }) });
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
} catch (resendError) {
message.textContent = resendError.message;
resend.disabled = false;
}
});
message.append(document.createElement("br"), resend);
}
} finally {
submit.disabled = false;
}
});
setMode(initialMode);
return { setMode };
}
function bindLegacyIdentityDialog({ dialog, onIdentity }) {
const form = dialog.querySelector("#identity-form");
const nickname = dialog.querySelector("#nickname");
const authPanel = dialog.querySelector("#auth-panel");
const message = dialog.querySelector("#identity-error");
const modeTitle = dialog.querySelector("#auth-mode-title");
const emailField = dialog.querySelector("#auth-email-field");
const emailLabel = dialog.querySelector("#auth-email-label");
const email = dialog.querySelector("#auth-email");
const password = dialog.querySelector("#auth-password");
const authSubmit = dialog.querySelector("#auth-submit");
const guestSubmit = dialog.querySelector("#guest-continue");
const registerButton = dialog.querySelector("#show-register");
const loginButton = dialog.querySelector("#show-login");
const backButton = dialog.querySelector("#auth-back");
const logoutButton = dialog.querySelector("#logout-account");
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
const externalAuth = window.__RUSTPAD_CONFIG__?.externalAuth === true;
let mode = "login";
const updateActions = () => {
if (logoutButton) logoutButton.hidden = !getAuthToken();
if (registerButton) registerButton.hidden = !registrationEnabled;
};
const collapse = () => {
authPanel.hidden = true;
nickname.disabled = false;
nickname.required = true;
password.value = "";
message.textContent = "";
guestSubmit?.focus();
};
const showAuth = (nextMode) => {
mode = nextMode === "register" && !registrationEnabled ? "login" : nextMode;
authPanel.hidden = false;
// Keep the guest nickname form visible so the user can still continue anonymously.
nickname.disabled = mode === "login";
nickname.required = mode === "register";
emailField.hidden = false;
email.disabled = false;
modeTitle.textContent = mode === "register" ? "Register" : "Log in";
authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue";
const loginMode = mode === "login";
configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth });
form.autocomplete = loginMode ? "on" : "off";
nickname.autocomplete = "off";
nickname.dataset.bwignore = "true";
message.textContent = "";
queueMicrotask(() => (mode === "register" ? nickname : email).focus());
};
registerButton?.addEventListener("click", () => showAuth("register"));
loginButton?.addEventListener("click", () => showAuth("login"));
backButton?.addEventListener("click", collapse);
dialog.querySelector("#close-identity")?.addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); });
dialog.addEventListener("close", collapse);
dialog.addEventListener("cancel", collapse);
const legacyResetButton = dialog.querySelector("#show-reset");
if (legacyResetButton) legacyResetButton.hidden = externalAuth;
legacyResetButton?.addEventListener("click", async () => {
const value = email.value.trim() || await askInput({
title: "Reset password",
message: "Enter the e-mail address assigned to your local account.",
label: "E-mail",
type: "email",
autocomplete: "off",
confirmText: "Send reset link",
bitwardenIgnore: true,
}) || "";
if (!value) return;
try {
const result = await api("/api/auth/password-reset", { method: "POST", body: JSON.stringify({ email: value }) });
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
}
});
logoutButton?.addEventListener("click", async () => {
await logoutCurrentSession();
updateActions();
collapse();
nickname.value = "";
message.textContent = "Logged out.";
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
message.textContent = "";
const name = nickname.value.trim();
const authRequested = event.submitter === authSubmit;
try {
if (authRequested) {
const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login";
const payload = { email: email.value.trim(), password: password.value };
if (mode === "register") payload.nickname = name;
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
if (mode === "register" && session.confirmation_required) {
message.classList.remove("error");
message.classList.add("success");
message.textContent = session.message;
password.value = "";
return;
}
setAuthSession(session);
await onIdentity(session.nickname, session);
dialog.close();
return;
}
nickname.disabled = false;
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name }) });
setNickname(result.nickname);
await onIdentity(result.nickname, null);
dialog.close();
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
if (/registered|session|account/i.test(error.message)) showAuth("login");
}
});
updateActions();
collapse();
return { setMode: showAuth };
}
export async function validateCurrentSession() {
const expectedSession = Boolean(getAuthToken());
try {
const session = await api("/api/auth/me");
setAuthSession(session);
return session;
} catch {
if (expectedSession) clearAuthSession();
return null;
}
}
export async function logoutCurrentSession() {
try {
await api("/api/auth/logout", { method: "POST" });
} catch { }
clearResourceAccessState();
clearAuthSession();
}
function mailActionToken(pathPrefix, legacyQueryName) {
const url = new URL(location.href);
const prefix = `${pathPrefix}/`;
let token = null;
if (url.pathname.startsWith(prefix)) {
const encodedToken = url.pathname.slice(prefix.length).split("/", 1)[0];
try { token = decodeURIComponent(encodedToken); } catch { token = null; }
}
if (!token) token = url.searchParams.get(legacyQueryName);
return token?.trim() || null;
}
function clearMailActionUrl() {
const url = new URL(location.href);
url.searchParams.delete("confirm_token");
url.searchParams.delete("reset_token");
url.searchParams.delete("account_action_token");
history.replaceState({}, "", `/${url.search}${url.hash}`);
}
export async function handleAccountConfirmationToken() {
const token = mailActionToken("/auth/confirm", "confirm_token");
if (!token) return false;
clearMailActionUrl();
const confirmed = await askConfirm(
"Confirm activation of this RustPad account.",
{ title: "Confirm account", confirmText: "Activate account" },
);
if (!confirmed) return true;
try {
const result = await api("/api/auth/confirm-account", { method: "POST", body: JSON.stringify({ token }) });
await showMessage(result.message, { title: "Account confirmed" });
} catch (error) {
await showMessage(error.message, { title: "Account confirmation failed" });
}
return true;
}
export async function handleResetToken() {
const token = mailActionToken("/auth/reset-password", "reset_token");
if (!token) return false;
clearMailActionUrl();
const password = await askInput({
title: "Set a new password",
message: "The password must contain at least 8 characters.",
label: "New password",
type: "password",
autocomplete: "off",
minLength: 8,
confirmText: "Change password",
bitwardenIgnore: true,
});
if (!password) return true;
try {
await api("/api/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) });
await showMessage("Password changed. The reset link has been used and cannot be opened again.", { title: "Password changed" });
} catch (error) {
await showMessage(error.message, { title: "Password reset failed" });
}
return true;
}
export async function handleShareInvitationToken() {
const token = mailActionToken("/share-invitations", "share_invitation_token");
if (!token || !location.pathname.endsWith("/accept")) return false;
clearMailActionUrl();
const confirmed = await askConfirm(
"Accept this sharing invitation?",
{ title: "Sharing invitation", confirmText: "Accept invitation" },
);
if (!confirmed) return true;
try {
const result = await api(`/share-invitations/${encodeURIComponent(token)}/accept`, { method: "POST" });
window.location.assign(result.url);
} catch (error) {
await showMessage(error.message, { title: "Invitation failed" });
}
return true;
}
export async function handleAccountActionToken() {
const token = mailActionToken("/auth/account-action", "account_action_token");
if (!token) return false;
clearMailActionUrl();
const confirmed = await askConfirm(
"Confirm the requested account action. If you did not request it, cancel and ignore the e-mail.",
{ title: "Confirm account action", confirmText: "Confirm action", danger: true },
);
if (!confirmed) return true;
try {
const result = await api("/api/auth/account-action/confirm", { method: "POST", body: JSON.stringify({ token }) });
await showMessage(result.message, { title: "Account action confirmed" });
} catch (error) {
await showMessage(error.message, { title: "Account action failed" });
}
return true;
}