multi mode

This commit is contained in:
Mateusz Gruszczyński
2026-07-31 10:10:19 +02:00
parent e39a1154e6
commit f7afee886b
25 changed files with 910 additions and 212 deletions
+1
View File
@@ -282,6 +282,7 @@ pub(super) fn error_response(
asset_version: &str,
) -> Response {
let html = include_str!("../../static/error.html")
.replace("__APP_THEME_BOOTSTRAP__", assets::theme_bootstrap())
.replace(
"__APP_STYLESHEET__",
&assets::stylesheet_tag(asset_version, "styles"),
+6
View File
@@ -31,6 +31,7 @@ const MODULES: &[&str] = &[
"session",
"socket",
"toast",
"theme",
"url-state",
"security",
];
@@ -47,6 +48,7 @@ pub fn render_html(
let urls = AssetUrls::new(asset_version);
let frontend_config = frontend_config(frontend_log_level, upload_max_size_bytes, external_auth);
let html = template
.replace("__APP_THEME_BOOTSTRAP__", theme_bootstrap())
.replace("__APP_STYLESHEET__", &urls.stylesheet("styles"))
.replace("__APP_IMPORT_MAP__", &urls.import_map())
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
@@ -68,6 +70,10 @@ pub fn render_html(
response
}
pub fn theme_bootstrap() -> &'static str {
r#"<script>(()=>{const key="rustpad:theme";let theme="dark";try{const saved=localStorage.getItem(key);if(saved==="light"||saved==="dark")theme=saved}catch{}const root=document.documentElement;root.dataset.theme=theme;root.style.colorScheme=theme;const meta=document.querySelector('meta[name="color-scheme"]');if(meta)meta.content=theme})();</script>"#
}
pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
AssetUrls::new(asset_version).stylesheet(name)
}
+31
View File
@@ -49,6 +49,7 @@ pub struct User {
pub password_hash: String,
pub confirmed_at: Option<String>,
pub is_active: i64,
pub theme: String,
}
#[derive(Deserialize)]
@@ -95,6 +96,8 @@ pub struct ProfileUpdateRequest {
password: String,
#[serde(default)]
editor_color: Option<String>,
#[serde(default)]
theme: Option<String>,
}
#[derive(Deserialize)]
pub struct DeleteAccountRequest {
@@ -190,6 +193,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for User {
password_hash: crate::row_decode::text(row, "password_hash")?,
confirmed_at: crate::row_decode::optional_text(row, "confirmed_at")?,
is_active: row.try_get("is_active")?,
theme: crate::row_decode::text(row, "theme")?,
})
}
}
@@ -268,6 +272,7 @@ pub struct SessionResponse {
directory_organization: Option<String>,
suggested_nickname: Option<String>,
editor_color: Option<String>,
theme: String,
}
#[derive(Serialize)]
pub struct IdentityResponse {
@@ -282,6 +287,7 @@ pub struct RegisterResponse {
email: String,
expires_at: Option<String>,
confirmation_required: bool,
theme: String,
message: String,
}
@@ -433,6 +439,7 @@ pub async fn register(
email: user.email,
expires_at: None,
confirmation_required: true,
theme: user.theme,
message:
"Account created. Check your e-mail and confirm the account before logging in."
.into(),
@@ -451,6 +458,7 @@ pub async fn register(
email: session.email,
expires_at: Some(session.expires_at),
confirmation_required: false,
theme: session.theme,
message: "Account created.".into(),
}),
).into_response();
@@ -740,6 +748,7 @@ pub async fn me(
directory_organization,
suggested_nickname,
editor_color,
theme: user.theme,
},
state.user_session_ttl_days,
))
@@ -855,10 +864,23 @@ pub async fn update_profile(
.map_err(AuthError::database)?;
}
let mut theme = user.theme.clone();
if let Some(value) = req.theme.as_deref() {
theme = validate_theme(value)?.to_owned();
sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_THEME))
.bind(&theme)
.bind(Utc::now().to_rfc3339())
.bind(user.id)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
}
Ok(Json(serde_json::json!({
"ok": true,
"nickname": nickname,
"editor_color": req.editor_color.as_deref(),
"theme": theme,
"email_pending": email_pending,
"message": if email_pending {
"Profile updated. Confirm the new e-mail address using the link sent to it."
@@ -2052,6 +2074,7 @@ async fn create_session(state: &SharedState, user: &User) -> Result<SessionRespo
directory_organization,
suggested_nickname,
editor_color,
theme: user.theme.clone(),
})
}
async fn find_user_by_nickname(
@@ -2104,6 +2127,14 @@ async fn find_user_by_email(state: &SharedState, email: &str) -> Result<Option<U
.await
.map_err(AuthError::database)
}
fn validate_theme(value: &str) -> Result<&str, AuthError> {
match value.trim() {
"dark" => Ok("dark"),
"light" => Ok("light"),
_ => Err(AuthError::bad_request("Unknown interface theme.")),
}
}
fn validate_editor_color(value: &str) -> Result<String, AuthError> {
let value = value.trim();
if value.len() == 7
+2
View File
@@ -26,6 +26,7 @@ pub enum Query {
AUTH_LATEST_CONFIRMATION_CREATED_AT,
AUTH_UPDATE_NICKNAME,
AUTH_UPDATE_EDITOR_COLOR,
AUTH_UPDATE_THEME,
AUTH_EDITOR_COLOR_BY_USER,
RESOURCE_COLOR_BY_USER,
RESOURCE_COLOR_DELETE,
@@ -176,6 +177,7 @@ pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: Query = Query::POSTGRES_PAD_REVI
pub const AUTH_LATEST_CONFIRMATION_CREATED_AT: Query = Query::AUTH_LATEST_CONFIRMATION_CREATED_AT;
pub const AUTH_UPDATE_NICKNAME: Query = Query::AUTH_UPDATE_NICKNAME;
pub const AUTH_UPDATE_EDITOR_COLOR: Query = Query::AUTH_UPDATE_EDITOR_COLOR;
pub const AUTH_UPDATE_THEME: Query = Query::AUTH_UPDATE_THEME;
pub const AUTH_EDITOR_COLOR_BY_USER: Query = Query::AUTH_EDITOR_COLOR_BY_USER;
pub const RESOURCE_COLOR_BY_USER: Query = Query::RESOURCE_COLOR_BY_USER;
pub const RESOURCE_COLOR_DELETE: Query = Query::RESOURCE_COLOR_DELETE;
+8 -5
View File
@@ -31,6 +31,9 @@ pub fn get(query: Query) -> &'static str {
Query::AUTH_UPDATE_EDITOR_COLOR => {
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_UPDATE_THEME => {
r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
@@ -103,7 +106,7 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#
}
Query::AUTH_USER_BY_EXTERNAL_ID => {
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE auth_provider = ? AND external_id = ?"#
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE auth_provider = ? AND external_id = ?"#
}
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_USER => {
@@ -150,19 +153,19 @@ pub fn get(query: Query) -> &'static str {
}
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#,
Query::AUTH_USER_BY_SESSION => {
r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(u.theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
}
Query::AUTH_INSERT_SESSION => {
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
}
Query::AUTH_USER_BY_NICKNAME => {
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE nickname_key = ?"#
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE nickname_key = ?"#
}
Query::AUTH_USER_BY_EMAIL => {
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE email_key = ?"#
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users WHERE email_key = ?"#
}
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"#
r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active, CAST(theme AS CHAR CHARACTER SET utf8mb4) AS theme FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"#
}
Query::USER_ATTACH_WORKSPACE => {
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#
+8 -5
View File
@@ -31,6 +31,9 @@ pub fn get(query: Query) -> &'static str {
Query::AUTH_UPDATE_EDITOR_COLOR => {
r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"#
}
Query::AUTH_UPDATE_THEME => {
r#"UPDATE users SET theme = $1, updated_at = $2 WHERE id = $3"#
}
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#,
Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#
@@ -103,7 +106,7 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = $1"#
}
Query::AUTH_USER_BY_EXTERNAL_ID => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE auth_provider = $1 AND external_id = $2"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE auth_provider = $1 AND external_id = $2"#
}
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = $1"#,
Query::AUTH_ANONYMIZE_USER => {
@@ -152,19 +155,19 @@ pub fn get(query: Query) -> &'static str {
}
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = $1"#,
Query::AUTH_USER_BY_SESSION => {
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > $2 AND u.is_active = TRUE"#
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, u.theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > $2 AND u.is_active = TRUE"#
}
Query::AUTH_INSERT_SESSION => {
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)"#
}
Query::AUTH_USER_BY_NICKNAME => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE nickname_key = $1"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE nickname_key = $1"#
}
Query::AUTH_USER_BY_EMAIL => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE email_key = $1"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE email_key = $1"#
}
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 1"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active, theme FROM users WHERE is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 1"#
}
Query::USER_ATTACH_WORKSPACE => {
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT $1, id FROM workspaces WHERE slug = $2"#
+8 -5
View File
@@ -31,6 +31,9 @@ pub fn get(query: Query) -> &'static str {
Query::AUTH_UPDATE_EDITOR_COLOR => {
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_UPDATE_THEME => {
r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
@@ -103,7 +106,7 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#
}
Query::AUTH_USER_BY_EXTERNAL_ID => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE auth_provider = ? AND external_id = ?"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE auth_provider = ? AND external_id = ?"#
}
Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#,
Query::AUTH_ANONYMIZE_USER => {
@@ -150,19 +153,19 @@ pub fn get(query: Query) -> &'static str {
}
Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#,
Query::AUTH_USER_BY_SESSION => {
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, u.is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, u.is_active, u.theme FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#
}
Query::AUTH_INSERT_SESSION => {
r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#
}
Query::AUTH_USER_BY_NICKNAME => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE nickname_key = ?"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE nickname_key = ?"#
}
Query::AUTH_USER_BY_EMAIL => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE email_key = ?"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active, theme FROM users WHERE email_key = ?"#
}
Query::AUTH_USER_BY_SHARE_IDENTIFIER => {
r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"#
r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active, theme FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"#
}
Query::USER_ATTACH_WORKSPACE => {
r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#