diff --git a/.env.example b/.env.example index 673d674..9e2d4dc 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,8 @@ MYSQL_ROOT_PASSWORD=rustpad_root # Optional account password reset via SMTP REGISTRATION_ENABLED=false +ACCOUNT_CONFIRMATION_REQUIRED=false + PUBLIC_URL=https://pad.example.com # SMTP_HOST=smtp.example.com SMTP_PORT=587 diff --git a/README.md b/README.md index e12ad83..8331ba0 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,8 @@ RUST_LOG=rustpad=debug,tower_http=info Important lifecycle, database, authentication, password-reset and WebSocket events are logged. Passwords, session tokens, reset tokens, SMTP credentials and authorization headers are never logged. Browser diagnostics are configured separately from backend logs with `FRONTEND_LOG_LEVEL`. Supported values are `off`, `error`, `warn`, `info`, and `debug`; the default is `warn`. URL parameters cannot enable diagnostics. Use `debug` only in trusted development environments. Production should normally use `warn` or `error`. + + +### Rejestracja i SMTP + +`REGISTRATION_ENABLED=true` włącza rejestrację. Po utworzeniu konta aplikacja wysyła przez SMTP wiadomość z nickiem i adresem `PUBLIC_URL`. `ACCOUNT_CONFIRMATION_REQUIRED=true` wymaga dodatkowo kliknięcia linku potwierdzającego przed logowaniem; domyślnie opcja jest wyłączona i wymaga skonfigurowanego SMTP. diff --git a/migrations/mysql/0008_account_confirmation.sql b/migrations/mysql/0008_account_confirmation.sql new file mode 100644 index 0000000..4bd61b3 --- /dev/null +++ b/migrations/mysql/0008_account_confirmation.sql @@ -0,0 +1,11 @@ +ALTER TABLE users ADD COLUMN confirmed_at VARCHAR(64) NULL; +UPDATE users SET confirmed_at = CURRENT_TIMESTAMP WHERE confirmed_at IS NULL; +CREATE TABLE account_confirmation_tokens ( + token VARCHAR(128) PRIMARY KEY, + user_id BIGINT NOT NULL, + expires_at VARCHAR(64) NOT NULL, + used_at VARCHAR(64), + created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT fk_account_confirmation_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_account_confirmation_user (user_id) +) ENGINE=InnoDB; diff --git a/migrations/postgres/0008_account_confirmation.sql b/migrations/postgres/0008_account_confirmation.sql new file mode 100644 index 0000000..0987cfa --- /dev/null +++ b/migrations/postgres/0008_account_confirmation.sql @@ -0,0 +1,10 @@ +ALTER TABLE users ADD COLUMN confirmed_at TEXT; +UPDATE users SET confirmed_at = CURRENT_TIMESTAMP::text WHERE confirmed_at IS NULL; +CREATE TABLE account_confirmation_tokens ( + token TEXT PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) +); +CREATE INDEX idx_account_confirmation_user ON account_confirmation_tokens(user_id); diff --git a/migrations/sqlite/0008_account_confirmation.sql b/migrations/sqlite/0008_account_confirmation.sql new file mode 100644 index 0000000..73262b3 --- /dev/null +++ b/migrations/sqlite/0008_account_confirmation.sql @@ -0,0 +1,10 @@ +ALTER TABLE users ADD COLUMN confirmed_at TEXT; +UPDATE users SET confirmed_at = CURRENT_TIMESTAMP WHERE confirmed_at IS NULL; +CREATE TABLE account_confirmation_tokens ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_account_confirmation_user ON account_confirmation_tokens(user_id); diff --git a/src/app.rs b/src/app.rs index d60cb50..c12a1f0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -40,6 +40,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .route("/api/auth/identity", post(auth::identity)) .route("/api/auth/register", post(auth::register)) .route("/api/auth/login", post(auth::login)) + .route("/api/auth/confirm-account", post(auth::confirm_account)) .route("/api/auth/me", get(auth::me)) .route("/api/auth/logout", post(auth::logout)) .route("/api/auth/resources", get(auth::resources).put(auth::update_resource).delete(auth::delete_resource)) diff --git a/src/auth.rs b/src/auth.rs index 4694c0d..7a3954e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -15,11 +15,12 @@ const MAX_PASSWORD: usize = 128; const MAX_NICKNAME: usize = 40; #[derive(Debug, Clone, FromRow)] -pub struct User { pub id: i64, pub nickname: String, pub email: String, pub password_hash: String } +pub struct User { pub id: i64, pub nickname: String, pub email: String, pub password_hash: String, pub confirmed_at: Option } #[derive(Deserialize)] pub struct IdentityRequest { nickname: String, #[serde(default)] session_token: Option } #[derive(Deserialize)] pub struct RegisterRequest { nickname: String, email: String, password: String } #[derive(Deserialize)] pub struct LoginRequest { email: String, password: String } +#[derive(Deserialize)] pub struct ConfirmAccountRequest { token: String } #[derive(Deserialize)] pub struct ResetRequest { email: String } #[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String } #[derive(Deserialize)] pub struct ResourceActionRequest { kind: String, slug: String, #[serde(default)] password: Option } @@ -27,6 +28,7 @@ pub struct User { pub id: i64, pub nickname: String, pub email: String, pub pass #[derive(Serialize)] pub struct ResourceList { workspaces: Vec, pads: Vec } #[derive(Serialize)] pub struct SessionResponse { token: String, nickname: String, email: String, expires_at: String } #[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool } +#[derive(Serialize)] pub struct RegisterResponse { token: Option, nickname: String, email: String, expires_at: Option, confirmation_required: bool, message: String } pub async fn identity(State(state): State, Json(req): Json) -> Result, AuthError> { let nickname = validate_nickname(&req.nickname)?; @@ -43,8 +45,9 @@ pub async fn identity(State(state): State, Json(req): Json, Json(req): Json) -> Result<(StatusCode, Json), AuthError> { +pub async fn register(State(state): State, Json(req): Json) -> Result<(StatusCode, Json), AuthError> { if !state.registration_enabled { warn!("registration attempt rejected because registration is disabled"); return Err(AuthError::forbidden("Registration is disabled.")); } + if state.account_confirmation_required && state.smtp.is_none() { return Err(AuthError::service_unavailable("Account confirmation requires SMTP configuration.")); } let nickname = validate_nickname(&req.nickname)?; let email = validate_email(&req.email)?; info!(nickname = %nickname, email_domain = %email_domain(&email), "registration requested"); @@ -54,13 +57,49 @@ pub async fn register(State(state): State, Json(req): Json, Json(req): Json) -> Result, AuthError> { @@ -68,11 +107,29 @@ pub async fn login(State(state): State, Json(req): Json, Json(req): Json) -> Result, AuthError> { + let now_time = Utc::now(); + let now = now_time.to_rfc3339(); + let token_hash = hash_token(req.token.trim()); + let row: Option<(i64, String, Option)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_FIND_CONFIRMATION_TOKEN)) + .bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; + let (user_id, expires_at, used_at) = row.ok_or_else(|| AuthError::bad_request("The confirmation link is invalid or has expired."))?; + let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at).map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))?.with_timezone(&Utc); + if used_at.is_some() || expires_at <= now_time { return Err(AuthError::bad_request("The confirmation link is invalid or has expired.")); } + let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER)).bind(&now).bind(&now).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), queries::AUTH_MARK_CONFIRMATION_TOKEN_USED)).bind(&now).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?; + tx.commit().await.map_err(AuthError::database)?; + info!(user_id, "account confirmed"); + Ok(Json(serde_json::json!({"ok": true, "message": "Account confirmed. You can now log in."}))) +} + pub async fn me(State(state): State, headers: HeaderMap) -> Result, AuthError> { let token = bearer(&headers).ok_or_else(|| AuthError::unauthorized("Not logged in."))?; let user = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired."))?; @@ -222,6 +279,24 @@ fn verify_password(hash:&str,v:&str)->bool{PasswordHash::new(hash).ok().and_then fn random_token()->String{let mut bytes=[0u8;32];let mut rng=OsRng;rng.fill_bytes(&mut bytes);bytes.iter().map(|b|format!("{b:02x}")).collect()} fn hash_token(v:&str)->String{format!("{:x}",Sha256::digest(v.as_bytes()))} fn bearer(headers:&HeaderMap)->Option<&str>{headers.get("authorization")?.to_str().ok()?.strip_prefix("Bearer ")} +async fn send_registration_email(smtp: &SmtpConfig, user: &User, token: Option<&str>) -> Result<(), AuthError> { + let site = smtp.public_url.trim_end_matches('/'); + let (subject, body) = match token { + Some(token) => ("Confirm your RustPad account", format!("Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n\nConfirm the account within 24 hours:\n{}/?confirm_token={}\n", user.nickname, user.nickname, site, site, token)), + None => ("Your RustPad account has been created", format!("Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n", user.nickname, user.nickname, site)), + }; + let message = Message::builder().from(smtp.from.parse::().map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::().map_err(|_| AuthError::internal("Invalid recipient address."))?).subject(subject).body(body).map_err(|_| AuthError::internal("Failed to build registration e-mail."))?; + send_message(smtp, message, "registration e-mail").await +} + +async fn send_message(smtp: &SmtpConfig, message: Message, label: &str) -> Result<(), AuthError> { + let mut builder = if smtp.port == 465 { AsyncSmtpTransport::::relay(&smtp.host) } else { AsyncSmtpTransport::::starttls_relay(&smtp.host) } + .map_err(|error| { tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "invalid SMTP configuration"); AuthError::internal("Invalid SMTP configuration.") })?.port(smtp.port); + if !smtp.username.is_empty() { builder = builder.credentials(Credentials::new(smtp.username.clone(), smtp.password.clone())); } + builder.build().send(message).await.map_err(|error| { tracing::error!(error=%error, host=%smtp.host, port=smtp.port, message_type=label, "SMTP delivery failed"); AuthError::service_unavailable("The e-mail could not be sent. Check the SMTP configuration.") })?; + Ok(()) +} + async fn send_reset(smtp:&SmtpConfig,user:&User,token:&str)->Result<(),AuthError>{ let url=format!("{}/?reset_token={}",smtp.public_url.trim_end_matches('/'),token); let message=Message::builder().from(smtp.from.parse::().map_err(|_|AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::().map_err(|_|AuthError::internal("Invalid recipient address."))?).subject("RustPad password reset").body(format!("Hello {},\n\nUse this link within 30 minutes to set a new password:\n{}\n\nIf you did not request this, ignore this message.",user.nickname,url)).map_err(|_|AuthError::internal("Failed to build reset e-mail."))?; diff --git a/src/config.rs b/src/config.rs index e072bcb..2dba3d8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,7 @@ pub struct Config { pub asset_version: String, pub smtp: Option, pub registration_enabled: bool, + pub account_confirmation_required: bool, pub frontend_log_level: String, } @@ -57,6 +58,7 @@ impl Config { asset_version: env!("CARGO_PKG_VERSION").to_owned(), smtp, registration_enabled: env_bool("REGISTRATION_ENABLED", false)?, + account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?, }) } diff --git a/src/main.rs b/src/main.rs index f48c734..6282d31 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,7 @@ async fn main() -> Result<(), Box> { files_dir = %config.files_dir, upload_max_size_bytes = config.upload_max_size_bytes, registration_enabled = config.registration_enabled, + account_confirmation_required = config.account_confirmation_required, frontend_log_level = %config.frontend_log_level, smtp_configured = config.smtp.is_some(), asset_version = %config.asset_version, @@ -55,6 +56,7 @@ async fn main() -> Result<(), Box> { config.upload_max_size_bytes, config.smtp.clone(), config.registration_enabled, + config.account_confirmation_required, config.frontend_log_level.clone(), )); let app = app::router( diff --git a/src/queries.rs b/src/queries.rs index 570e9dd..2f6b599 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -12,19 +12,25 @@ pub const POSTGRES_NOTE_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_s pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_serial_sequence('revisions', 'id'))"; // Authentication queries. -pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)"; +pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)"; +pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?"; pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?"; pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?"; +pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: &str = "DELETE FROM account_confirmation_tokens WHERE user_id = ?"; +pub const AUTH_INSERT_CONFIRMATION_TOKEN: &str = "INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"; +pub const AUTH_FIND_CONFIRMATION_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?"; +pub const AUTH_CONFIRM_USER: &str = "UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?"; +pub const AUTH_MARK_CONFIRMATION_TOKEN_USED: &str = "UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ?"; pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str = "DELETE FROM password_reset_tokens WHERE user_id = ?"; pub const AUTH_INSERT_RESET_TOKEN: &str = "INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"; pub const AUTH_FIND_RESET_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?"; pub const AUTH_UPDATE_PASSWORD: &str = "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?"; pub const AUTH_MARK_RESET_TOKEN_USED: &str = "UPDATE password_reset_tokens SET used_at = ? WHERE token = ?"; pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?"; -pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?"; +pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?"; pub const AUTH_INSERT_SESSION: &str = "INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"; -pub const AUTH_USER_BY_NICKNAME: &str = "SELECT id, nickname, email, password_hash FROM users WHERE nickname_key = ?"; -pub const AUTH_USER_BY_EMAIL: &str = "SELECT id, nickname, email, password_hash FROM users WHERE email_key = ?"; +pub const AUTH_USER_BY_NICKNAME: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE nickname_key = ?"; +pub const AUTH_USER_BY_EMAIL: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE email_key = ?"; pub const USER_ATTACH_WORKSPACE: &str = "INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"; pub const USER_ATTACH_PAD: &str = "INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"; pub const USER_LIST_WORKSPACES: &str = "SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? ORDER BY w.updated_at DESC"; diff --git a/src/state.rs b/src/state.rs index be487b3..61bcb19 100644 --- a/src/state.rs +++ b/src/state.rs @@ -24,13 +24,14 @@ pub struct AppState { pub upload_max_size_bytes: usize, pub smtp: Option, pub registration_enabled: bool, + pub account_confirmation_required: bool, pub frontend_log_level: String, channels: RwLock>>, } impl AppState { - pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option, registration_enabled: bool, frontend_log_level: String) -> Self { - Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, frontend_log_level, channels: RwLock::new(HashMap::new()) } + pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String) -> Self { + Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, account_confirmation_required, frontend_log_level, channels: RwLock::new(HashMap::new()) } } async fn channel_for_key(&self, key: String) -> broadcast::Sender { if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); } diff --git a/static/home.html b/static/home.html index dee477b..c9330ac 100644 --- a/static/home.html +++ b/static/home.html @@ -82,7 +82,7 @@

- +
diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js index 8a09a00..bd9ffd9 100644 --- a/static/js/auth-ui.js +++ b/static/js/auth-ui.js @@ -49,10 +49,19 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" } switchMode.textContent = registering ? "Already registered? Log in" : "Create an account"; resetButton.hidden = resetting || registering; backButton.hidden = !resetting; - form.autocomplete = resetting ? "off" : "on"; - nickname.autocomplete = "nickname"; - email.autocomplete = "username"; - password.autocomplete = registering ? "new-password" : "current-password"; + 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(() => { @@ -94,6 +103,13 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "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(); @@ -153,7 +169,19 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) { email.disabled = false; modeTitle.textContent = mode === "register" ? "Register nickname" : "Log in"; authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue"; - password.autocomplete = mode === "register" ? "new-password" : "current-password"; + 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()); }; @@ -208,6 +236,13 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) { 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; @@ -250,6 +285,21 @@ export async function logoutCurrentSession() { 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"); diff --git a/static/js/home.js b/static/js/home.js index 55f561a..a9f7dc3 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -1,7 +1,7 @@ import { installGlobalDiagnostics, logInfo } from "./logger.js"; installGlobalDiagnostics(); -import { bindIdentityDialog, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js"; +import { bindIdentityDialog, handleAccountConfirmationToken, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js"; import { getAuthToken } from "@rustpad/session"; import { api } from "@rustpad/api"; @@ -80,6 +80,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even } }); +handleAccountConfirmationToken(); handleResetToken(); const identityDialog = document.querySelector("#identity-dialog");