/* * 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"); }); 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"; 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 nickname" : "Log in"; copy.textContent = resetting ? "Enter the e-mail address assigned to your local account." : registering ? "Reserve your nickname with an e-mail address and password." : "Use your e-mail address or organization login 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"; email.type = externalAuth && loginMode ? "text" : "email"; if (externalAuth && loginMode) password.removeAttribute("minlength"); else password.minLength = 8; 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; 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 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 nickname" : "Log in"; authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue"; const loginMode = mode === "login"; email.type = externalAuth && loginMode ? "text" : "email"; if (externalAuth && loginMode) password.removeAttribute("minlength"); else password.minLength = 8; 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); 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 { } 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; }