account confirmation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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))
|
||||
|
||||
+79
-4
@@ -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<String> }
|
||||
|
||||
#[derive(Deserialize)] pub struct IdentityRequest { nickname: String, #[serde(default)] session_token: Option<String> }
|
||||
#[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<String> }
|
||||
@@ -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<ResourceItem>, pads: Vec<ResourceItem> }
|
||||
#[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<String>, nickname: String, email: String, expires_at: Option<String>, confirmation_required: bool, message: String }
|
||||
|
||||
pub async fn identity(State(state): State<SharedState>, Json(req): Json<IdentityRequest>) -> Result<Json<IdentityResponse>, AuthError> {
|
||||
let nickname = validate_nickname(&req.nickname)?;
|
||||
@@ -43,8 +45,9 @@ pub async fn identity(State(state): State<SharedState>, Json(req): Json<Identity
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register(State(state): State<SharedState>, Json(req): Json<RegisterRequest>) -> Result<(StatusCode, Json<SessionResponse>), AuthError> {
|
||||
pub async fn register(State(state): State<SharedState>, Json(req): Json<RegisterRequest>) -> Result<(StatusCode, Json<RegisterResponse>), 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<SharedState>, Json(req): Json<Register
|
||||
if find_user_by_nickname(&state, &nickname).await?.is_some() { return Err(AuthError::conflict("This nickname is already registered.")); }
|
||||
if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict("This e-mail address is already registered.")); }
|
||||
let hash = hash_password(&req.password)?;
|
||||
let confirmed_at = (!state.account_confirmation_required).then(|| Utc::now().to_rfc3339());
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_USER))
|
||||
.bind(&nickname).bind(nickname_key).bind(&email).bind(email_key).bind(hash).execute(state.db.pool()).await
|
||||
.bind(&nickname).bind(nickname_key).bind(&email).bind(email_key).bind(hash).bind(confirmed_at).execute(state.db.pool()).await
|
||||
.map_err(AuthError::database)?;
|
||||
let user = find_user_by_nickname(&state, &nickname).await?.ok_or_else(|| AuthError::internal("Failed to create the account."))?;
|
||||
|
||||
let mut confirmation_token = None;
|
||||
if state.smtp.is_some() {
|
||||
let token = random_token();
|
||||
if state.account_confirmation_required {
|
||||
let expires = (Utc::now() + Duration::hours(24)).to_rfc3339();
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN))
|
||||
.bind(hash_token(&token)).bind(user.id).bind(expires).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
confirmation_token = Some(token.as_str());
|
||||
}
|
||||
if let Err(error) = send_registration_email(state.smtp.as_ref().unwrap(), &user, confirmation_token).await {
|
||||
if state.account_confirmation_required {
|
||||
if let Err(delete_error) = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)).bind(user.id).execute(state.db.pool()).await {
|
||||
tracing::error!(error=%delete_error, user_id=user.id, "failed to roll back account after confirmation e-mail error");
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
warn!(user_id = user.id, "account created, but registration e-mail could not be sent");
|
||||
}
|
||||
} else {
|
||||
warn!(user_id = user.id, "account created without registration e-mail because SMTP is not configured");
|
||||
}
|
||||
|
||||
if state.account_confirmation_required {
|
||||
info!(user_id = user.id, nickname = %user.nickname, "account registered; confirmation required");
|
||||
return Ok((StatusCode::CREATED, Json(RegisterResponse {
|
||||
token: None, nickname: user.nickname, email: user.email, expires_at: None,
|
||||
confirmation_required: true,
|
||||
message: "Account created. Check your e-mail and confirm the account before logging in.".into(),
|
||||
})));
|
||||
}
|
||||
|
||||
let session = create_session(&state, &user).await?;
|
||||
info!(user_id = user.id, nickname = %user.nickname, "account registered and session created");
|
||||
Ok((StatusCode::CREATED, Json(session)))
|
||||
Ok((StatusCode::CREATED, Json(RegisterResponse {
|
||||
token: Some(session.token), nickname: session.nickname, email: session.email, expires_at: Some(session.expires_at),
|
||||
confirmation_required: false, message: "Account created.".into(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn login(State(state): State<SharedState>, Json(req): Json<LoginRequest>) -> Result<Json<SessionResponse>, AuthError> {
|
||||
@@ -68,11 +107,29 @@ pub async fn login(State(state): State<SharedState>, Json(req): Json<LoginReques
|
||||
debug!(email_domain = %email_domain(&email), "login requested");
|
||||
let user = find_user_by_email(&state, &email).await?.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
|
||||
if !verify_password(&user.password_hash, &req.password) { warn!(user_id = user.id, "login rejected: invalid password"); return Err(AuthError::unauthorized("Invalid e-mail address or password.")); }
|
||||
if state.account_confirmation_required && user.confirmed_at.is_none() { return Err(AuthError::forbidden("Confirm the account using the link sent by e-mail before logging in.")); }
|
||||
let session = create_session(&state, &user).await?;
|
||||
info!(user_id = user.id, nickname = %user.nickname, "login successful");
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
pub async fn confirm_account(State(state): State<SharedState>, Json(req): Json<ConfirmAccountRequest>) -> Result<Json<serde_json::Value>, 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<String>)> = 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<SharedState>, headers: HeaderMap) -> Result<Json<SessionResponse>, 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::<Mailbox>().map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::<Mailbox>().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::<Tokio1Executor>::relay(&smtp.host) } else { AsyncSmtpTransport::<Tokio1Executor>::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::<Mailbox>().map_err(|_|AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::<Mailbox>().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."))?;
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct Config {
|
||||
pub asset_version: String,
|
||||
pub smtp: Option<crate::state::SmtpConfig>,
|
||||
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")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
config.upload_max_size_bytes,
|
||||
config.smtp.clone(),
|
||||
config.registration_enabled,
|
||||
config.account_confirmation_required,
|
||||
config.frontend_log_level.clone(),
|
||||
));
|
||||
let app = app::router(
|
||||
|
||||
+10
-4
@@ -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";
|
||||
|
||||
+3
-2
@@ -24,13 +24,14 @@ pub struct AppState {
|
||||
pub upload_max_size_bytes: usize,
|
||||
pub smtp: Option<SmtpConfig>,
|
||||
pub registration_enabled: bool,
|
||||
pub account_confirmation_required: bool,
|
||||
pub frontend_log_level: String,
|
||||
channels: RwLock<HashMap<String, broadcast::Sender<NoteUpdate>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option<SmtpConfig>, 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<SmtpConfig>, 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<NoteUpdate> {
|
||||
if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); }
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@
|
||||
<p id="identity-copy" class="dialog-copy"></p>
|
||||
</header>
|
||||
<div class="identity-fields">
|
||||
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="nickname" placeholder="Your nickname"></label>
|
||||
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true" placeholder="Your nickname"></label>
|
||||
<label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" required placeholder="you@example.com"></label>
|
||||
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required placeholder="At least 8 characters"></label>
|
||||
</div>
|
||||
|
||||
+55
-5
@@ -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");
|
||||
|
||||
+2
-1
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user