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

330 lines
13 KiB
JavaScript

import { api } from "@rustpad/api";
import * as sessionStore from "@rustpad/session";
import { askInput, showMessage } from "./modal.js";
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
const clearAuthSession = sessionStore.clearAuthSession || (() => {
localStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:nickname");
});
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 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";
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 nickname" : "Log in";
copy.textContent = resetting
? "Enter the e-mail address assigned to your account."
: registering
? "Reserve your nickname with an e-mail address and password."
: "Use the e-mail address and password assigned to your account.";
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;
backButton.hidden = !resetting;
const loginMode = mode === "login";
form.autocomplete = loginMode ? "on" : "off";
nickname.autocomplete = "off";
nickname.dataset.bwignore = "true";
email.autocomplete = loginMode ? "username" : "off";
password.autocomplete = loginMode ? "current-password" : "off";
if (loginMode) {
email.removeAttribute("data-bwignore");
password.removeAttribute("data-bwignore");
} else {
email.dataset.bwignore = "true";
password.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;
} 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 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";
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 nickname" : "Log in";
authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue";
const loginMode = mode === "login";
form.autocomplete = loginMode ? "on" : "off";
nickname.autocomplete = "off";
nickname.dataset.bwignore = "true";
email.autocomplete = loginMode ? "username" : "off";
password.autocomplete = loginMode ? "current-password" : "off";
if (loginMode) {
email.removeAttribute("data-bwignore");
password.removeAttribute("data-bwignore");
} else {
email.dataset.bwignore = "true";
password.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);
dialog.querySelector("#show-reset")?.addEventListener("click", async () => {
const value = email.value.trim() || await askInput({
title: "Reset password",
message: "Enter the e-mail address assigned to your 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);
return;
}
nickname.disabled = false;
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name, session_token: getAuthToken() || null }) });
setNickname(result.nickname);
await onIdentity(result.nickname, null);
} 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 token = getAuthToken();
if (!token) return null;
try {
return await api("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } });
} catch {
clearAuthSession();
return null;
}
}
export async function logoutCurrentSession() {
const token = getAuthToken();
if (token) {
try {
await api("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${token}` } });
} catch {}
}
clearAuthSession();
}
export async function handleAccountConfirmationToken() {
const url = new URL(location.href);
const token = url.searchParams.get("confirm_token");
if (!token) return;
url.searchParams.delete("confirm_token");
history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
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" });
}
}
export async function handleResetToken() {
const url = new URL(location.href);
const token = url.searchParams.get("reset_token");
if (!token) return;
// Remove the token immediately. Refreshing or navigating back must not reopen the reset dialog.
url.searchParams.delete("reset_token");
history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
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;
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" });
}
}