From 6fc408ddf7ead1513d1c7140b8514957c2db0c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 4 Aug 2026 16:41:45 +0200 Subject: [PATCH] guest password protect --- Cargo.lock | 2 +- Cargo.toml | 4 +- .../mysql/0029_workspace_guest_owner.sql | 1 + .../postgres/0029_workspace_guest_owner.sql | 1 + .../sqlite/0029_workspace_guest_owner.sql | 1 + src/api/mod.rs | 100 +++++++++- src/api/pads_public.rs | 67 ++++++- src/app/mod.rs | 5 + src/db/mod.rs | 36 ++++ src/queries/mysql.rs | 4 +- src/queries/postgres.rs | 4 +- src/queries/sqlite.rs | 4 +- static/css/styles.css | 174 +++++++++++++++++- static/editor.html | 44 +++-- static/js/note-api.js | 7 + static/js/note-editor.js | 72 +++++++- static/js/workspace.js | 39 ++++ static/workspace.html | 5 + 18 files changed, 527 insertions(+), 43 deletions(-) create mode 100644 migrations/mysql/0029_workspace_guest_owner.sql create mode 100644 migrations/postgres/0029_workspace_guest_owner.sql create mode 100644 migrations/sqlite/0029_workspace_guest_owner.sql diff --git a/Cargo.lock b/Cargo.lock index 2c2e758..085bb4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.31" +version = "0.2.34" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 3377814..b95bea2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "rustpad" -version = "0.2.31" +version = "0.2.34" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" -license = "MIT" +license = "Source-Available Code / Dual-Licensed" [dependencies] argon2 = "0.5" diff --git a/migrations/mysql/0029_workspace_guest_owner.sql b/migrations/mysql/0029_workspace_guest_owner.sql new file mode 100644 index 0000000..2e57f6f --- /dev/null +++ b/migrations/mysql/0029_workspace_guest_owner.sql @@ -0,0 +1 @@ +ALTER TABLE workspaces ADD COLUMN created_by_guest_id VARCHAR(64) NULL; diff --git a/migrations/postgres/0029_workspace_guest_owner.sql b/migrations/postgres/0029_workspace_guest_owner.sql new file mode 100644 index 0000000..aebe11c --- /dev/null +++ b/migrations/postgres/0029_workspace_guest_owner.sql @@ -0,0 +1 @@ +ALTER TABLE workspaces ADD COLUMN created_by_guest_id TEXT; diff --git a/migrations/sqlite/0029_workspace_guest_owner.sql b/migrations/sqlite/0029_workspace_guest_owner.sql new file mode 100644 index 0000000..aebe11c --- /dev/null +++ b/migrations/sqlite/0029_workspace_guest_owner.sql @@ -0,0 +1 @@ +ALTER TABLE workspaces ADD COLUMN created_by_guest_id TEXT; diff --git a/src/api/mod.rs b/src/api/mod.rs index 996b5c8..aa3aff5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -114,6 +114,33 @@ fn pad_creator_is_requester(headers: &HeaderMap, pad: &db::Pad) -> bool { .is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id) } +fn workspace_creator_is_requester(headers: &HeaderMap, workspace: &db::Workspace) -> bool { + workspace + .created_by_guest_id + .as_deref() + .zip(requester_guest_id(headers)) + .is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id) +} + +async fn can_set_workspace_password( + state: &SharedState, + headers: &HeaderMap, + workspace: &db::Workspace, +) -> bool { + if workspace.password_hash.is_some() { + return false; + } + let account_owner = crate::auth::is_resource_owner( + state, + "workspace", + &workspace.slug, + user_session_token(headers), + ) + .await + .unwrap_or(false); + account_owner || workspace_creator_is_requester(headers, workspace) +} + async fn has_write_permission( state: &SharedState, headers: &HeaderMap, @@ -258,6 +285,11 @@ pub struct CreateNoteRequest { created_by: Option, } +#[derive(Debug, Deserialize)] +pub struct SetWorkspacePasswordRequest { + password: String, +} + #[derive(Debug, Deserialize)] pub struct RestoreRequest { #[serde(default)] @@ -273,6 +305,7 @@ pub struct WorkspaceInfo { title: String, protected: bool, access_level: String, + can_set_password: bool, created_at: String, updated_at: String, } @@ -360,6 +393,7 @@ pub struct NoteInfo { personal_editor_settings: bool, can_save_editor_settings: bool, can_manage_authorship: bool, + can_set_password: bool, files: Vec, } @@ -536,11 +570,23 @@ pub async fn create_workspace( let password = validate_password(payload.password.as_deref())?; let slug = unique_workspace_slug(&state, title).await?; - let workspace = db::create_workspace(&state.db, &slug, title, password).await?; - if let Some(user) = crate::auth::optional_user(&state, &headers) + let account_user = crate::auth::optional_user(&state, &headers) .await - .map_err(|e| ApiError::forbidden(&e.message))? - { + .map_err(|e| ApiError::forbidden(&e.message))?; + let created_by_guest_id = if account_user.is_none() { + requester_guest_id(&headers) + } else { + None + }; + let workspace = db::create_workspace( + &state.db, + &slug, + title, + password, + created_by_guest_id, + ) + .await?; + if let Some(user) = account_user { sqlx::query(queries::get( state.db.kind(), queries::USER_ATTACH_WORKSPACE, @@ -585,7 +631,37 @@ pub async fn workspace_info( workspace.password_hash.is_some(), ) .await?; - Ok(Json(workspace_info_from(&workspace, access_level))) + let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await; + Ok(Json(workspace_info_from( + &workspace, + access_level, + can_set_password, + ))) +} + +pub async fn set_workspace_password( + State(state): State, + headers: HeaderMap, + Path(workspace_slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + let workspace = db::find_workspace(&state.db, &workspace_slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + if workspace.password_hash.is_some() { + return Err(ApiError::bad_request( + "This workspace already has a password.", + )); + } + if !can_set_workspace_password(&state, &headers, &workspace).await { + return Err(ApiError::forbidden( + "Only the workspace owner can set its password.", + )); + } + let password = validate_password(Some(payload.password.as_str()))? + .ok_or_else(|| ApiError::bad_request("Password is required."))?; + db::set_workspace_password(&state.db, &workspace_slug, password).await?; + Ok(Json(serde_json::json!({"ok": true, "protected": true}))) } pub async fn open_workspace( @@ -668,8 +744,9 @@ pub async fn open_workspace( if db::verify_workspace_password(&workspace, payload.password.as_deref()) { access_level = AccessLevel::Write; } + let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await; Ok(Json(WorkspaceOpenResponse { - workspace: workspace_info_from(&workspace, access_level), + workspace: workspace_info_from(&workspace, access_level, can_set_password), notes, pagination: ListPaginationMeta { page, @@ -913,6 +990,7 @@ pub async fn note_info( ) .await .unwrap_or(false); + let workspace_guest_owner = workspace_creator_is_requester(&headers, &workspace); let note_owner = note_creator_is_requester(&state, &headers, ¬e).await?; let password_write_access = has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?; @@ -924,6 +1002,7 @@ pub async fn note_info( && has_write_permission(&state, &headers, "workspace", &workspace_slug).await?; if workspace.is_private == 0 + && workspace.password_hash.is_some() && !db::note_public_page_disabled(&state.db, note.id).await? && !db::note_public_page_enabled(&state.db, note.id).await? { @@ -958,6 +1037,8 @@ pub async fn note_info( personal_editor_settings, can_save_editor_settings, can_manage_authorship, + can_set_password: workspace.password_hash.is_none() + && (workspace_owner || workspace_guest_owner), files: markdown_file_references(&state, None, Some(note.id), None).await?, })) } @@ -1408,12 +1489,17 @@ async fn authorized_note( Ok((workspace, note)) } -fn workspace_info_from(workspace: &db::Workspace, access_level: AccessLevel) -> WorkspaceInfo { +fn workspace_info_from( + workspace: &db::Workspace, + access_level: AccessLevel, + can_set_password: bool, +) -> WorkspaceInfo { WorkspaceInfo { slug: workspace.slug.clone(), title: workspace.title.clone(), protected: workspace.password_hash.is_some(), access_level: access_level_name(access_level).into(), + can_set_password, created_at: db::normalize_timestamp(&workspace.created_at), updated_at: db::normalize_timestamp(&workspace.updated_at), } diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index 9218fb1..5d7f3ec 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -18,6 +18,11 @@ pub struct CreatePadRequest { content: Option, } +#[derive(Debug, Deserialize)] +pub struct SetPadPasswordRequest { + password: String, +} + #[derive(Debug, Serialize)] pub struct CreatePadResponse { slug: String, @@ -51,6 +56,7 @@ pub struct PadInfo { personal_editor_settings: bool, can_save_editor_settings: bool, can_manage_authorship: bool, + can_set_password: bool, files: Vec, } @@ -134,6 +140,7 @@ pub async fn pad_info( let can_save_editor_settings = (personal_editor_settings || can_manage_authorship) && has_write_permission(&state, &headers, "pad", &slug).await?; if pad.is_private == 0 + && pad.password_hash.is_some() && !db::pad_public_page_disabled(&state.db, pad.id).await? && !db::pad_public_page_enabled(&state.db, pad.id).await? { @@ -165,10 +172,36 @@ pub async fn pad_info( personal_editor_settings, can_save_editor_settings, can_manage_authorship, + can_set_password: pad.password_hash.is_none() && (account_owner || guest_owner), files: markdown_file_references(&state, Some(pad.id), None, None).await?, })) } +pub async fn set_pad_password( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + let pad = db::find_pad(&state.db, &slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + if pad.password_hash.is_some() { + return Err(ApiError::bad_request("This note already has a password.")); + } + let account_owner = crate::auth::is_resource_owner( + &state, "pad", &slug, user_session_token(&headers), + ).await.unwrap_or(false); + let guest_owner = pad_creator_is_requester(&headers, &pad); + if !account_owner && !guest_owner { + return Err(ApiError::forbidden("Only the note owner can set its password.")); + } + let password = validate_password(Some(payload.password.as_str()))? + .ok_or_else(|| ApiError::bad_request("Password is required."))?; + db::set_pad_password(&state.db, &slug, password).await?; + Ok(Json(serde_json::json!({"ok": true, "protected": true}))) +} + pub async fn set_pad_editor_settings( State(state): State, headers: HeaderMap, @@ -279,6 +312,11 @@ pub async fn publish_pad_page( }; require_write(level)?; let enabled = payload.enabled.unwrap_or(true); + if enabled && pad.password_hash.is_none() { + return Err(ApiError::bad_request( + "Set a resource password before enabling the published page.", + )); + } if !enabled { db::unpublish_pad(&state.db, pad.id).await?; db::set_pad_public_page_disabled(&state.db, pad.id, true).await?; @@ -340,6 +378,11 @@ pub async fn publish_note_page( }; require_write(level)?; let enabled = payload.enabled.unwrap_or(true); + if enabled && workspace.password_hash.is_none() { + return Err(ApiError::bad_request( + "Set a workspace password before enabling the published page.", + )); + } if !enabled { db::unpublish_note(&state.db, note.id).await?; db::set_note_public_page_disabled(&state.db, note.id, true).await?; @@ -373,9 +416,7 @@ async fn ensure_public_page_access( ) -> Result<(), ApiError> { let password = page_password(headers); if let Some(pad_id) = page.pad_id { - if db::pad_public_page_unprotected(&state.db, pad_id).await? { - return Ok(()); - } + let page_unprotected = db::pad_public_page_unprotected(&state.db, pad_id).await?; let sql = match state.db.kind() { crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1", _ => "SELECT slug FROM pads WHERE id = ?", @@ -390,7 +431,12 @@ async fn ensure_public_page_access( let pad = db::find_pad(&state.db, &slug) .await? .ok_or_else(ApiError::not_found_note)?; - if has_header_resource_access(state, headers, "pad", &slug).await? { + if pad.password_hash.is_none() { + return Err(ApiError::forbidden( + "This published page is unavailable until a resource password is set.", + )); + } + if page_unprotected || has_header_resource_access(state, headers, "pad", &slug).await? { return Ok(()); } let password_ok = db::verify_pad_password(&pad, password); @@ -406,9 +452,7 @@ async fn ensure_public_page_access( }; } if let Some(note_id) = page.note_id { - if db::note_public_page_unprotected(&state.db, note_id).await? { - return Ok(()); - } + let page_unprotected = db::note_public_page_unprotected(&state.db, note_id).await?; let sql = match state.db.kind() { crate::database::DatabaseKind::Postgres => { "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1" @@ -427,7 +471,14 @@ async fn ensure_public_page_access( let workspace = db::find_workspace(&state.db, &slug) .await? .ok_or_else(ApiError::not_found_workspace)?; - if has_header_resource_access(state, headers, "workspace", &slug).await? { + if workspace.password_hash.is_none() { + return Err(ApiError::forbidden( + "This published page is unavailable until a workspace password is set.", + )); + } + if page_unprotected + || has_header_resource_access(state, headers, "workspace", &slug).await? + { return Ok(()); } let password_ok = db::verify_workspace_password(&workspace, password); diff --git a/src/app/mod.rs b/src/app/mod.rs index 92570ba..eb73b48 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -158,6 +158,7 @@ pub fn router( post(api::set_pad_editor_settings), ) .route("/api/pads/{slug}/publish", post(api::publish_pad_page)) + .route("/api/pads/{slug}/password", post(api::set_pad_password)) .route("/api/pads/{slug}/restore", post(api::pad_restore)) .route( "/api/pads/{slug}/files", @@ -169,6 +170,10 @@ pub fn router( ) .route("/api/workspaces", post(api::create_workspace)) .route("/api/workspaces/{workspace_slug}", get(api::workspace_info)) + .route( + "/api/workspaces/{workspace_slug}/password", + post(api::set_workspace_password), + ) .route( "/api/workspaces/{workspace_slug}/open", post(api::open_workspace), diff --git a/src/db/mod.rs b/src/db/mod.rs index 90d1499..5e20015 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -52,6 +52,7 @@ pub struct Workspace { pub created_at: String, pub updated_at: String, pub is_private: i64, + pub created_by_guest_id: Option, } #[derive(Debug, Clone, Serialize)] @@ -142,6 +143,7 @@ pub async fn create_workspace( slug: &str, title: &str, password: Option<&str>, + created_by_guest_id: Option<&str>, ) -> Result { let password_hash = password .filter(|value| !value.is_empty()) @@ -150,6 +152,7 @@ pub async fn create_workspace( .bind(slug) .bind(title) .bind(password_hash) + .bind(created_by_guest_id) .execute(pool.pool()) .await?; @@ -159,6 +162,23 @@ pub async fn create_workspace( .await } +pub async fn set_workspace_password( + pool: &Database, + slug: &str, + password: &str, +) -> Result<(), sqlx::Error> { + let password_hash = hash_password(password); + sqlx::query(queries::get( + pool.kind(), + queries::USER_SET_WORKSPACE_PASSWORD, + )) + .bind(password_hash) + .bind(slug) + .execute(pool.pool()) + .await?; + Ok(()) +} + pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool { match ( &workspace.password_hash, @@ -496,6 +516,20 @@ pub async fn create_pad( .await } +pub async fn set_pad_password( + pool: &Database, + slug: &str, + password: &str, +) -> Result<(), sqlx::Error> { + let password_hash = hash_password(password); + sqlx::query(queries::get(pool.kind(), queries::USER_SET_PAD_PASSWORD)) + .bind(password_hash) + .bind(slug) + .execute(pool.pool()) + .await?; + Ok(()) +} + pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool { match ( &pad.password_hash, @@ -631,6 +665,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace { created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, is_private: row.try_get("is_private")?, + created_by_guest_id: crate::row_decode::optional_text(row, "created_by_guest_id")?, }) } } @@ -692,6 +727,7 @@ mod password_verification_tests { created_at: String::new(), updated_at: String::new(), is_private: 1, + created_by_guest_id: None, } } diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index de95815..7593153 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -274,9 +274,9 @@ pub fn get(query: Query) -> &'static str { r#"SELECT CAST(CASE WHEN public_page_disabled THEN 1 ELSE 0 END AS SIGNED) FROM notes WHERE id = ?"# } Query::Q001 => { - r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM workspaces WHERE slug = ?"# + r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM workspaces WHERE slug = ?"# } - Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#, + Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#, Query::Q003 => { r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"# } diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index d880244..74d07ac 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -274,9 +274,9 @@ pub fn get(query: Query) -> &'static str { r#"SELECT public_page_disabled FROM notes WHERE id = $1"# } Query::Q001 => { - r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = $1"# + r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private, created_by_guest_id FROM workspaces WHERE slug = $1"# } - Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES ($1, $2, $3)"#, + Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#, Query::Q003 => { r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 ORDER BY updated_at DESC, id DESC"# } diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index 1d739b0..4c07b5d 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -274,9 +274,9 @@ pub fn get(query: Query) -> &'static str { r#"SELECT CASE WHEN public_page_disabled THEN 1 ELSE 0 END FROM notes WHERE id = ?"# } Query::Q001 => { - r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM workspaces WHERE slug = ?"# + r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private, created_by_guest_id FROM workspaces WHERE slug = ?"# } - Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#, + Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#, Query::Q003 => { r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"# } diff --git a/static/css/styles.css b/static/css/styles.css index 2f55b0f..1d80605 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -2509,6 +2509,66 @@ dialog::backdrop { gap: 10px; } +.workspace-password-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px 14px; + margin-top: 14px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-card); +} + +.workspace-password-card[hidden] { + display: none; +} + +.workspace-password-card__copy { + display: grid; + gap: 2px; +} + +.workspace-password-card__copy strong { + font-size: .82rem; +} + +.workspace-password-card__copy small { + color: var(--muted); + font-size: .72rem; +} + +.workspace-password-card__controls { + display: grid; + grid-template-columns: 106px auto; + align-items: center; + gap: 6px; +} + +.workspace-password-card__controls input { + width: 7vh; + height: 5vh; + min-width: 11vh; + padding: 0 1vh; +} + +.workspace-password-card__controls button { + min-width: 7vh; + min-height: 4vh; + padding: 0 1vh; +} + +.workspace-password-card>.form-message { + grid-column: 1 / -1; + margin: 0; + font-size: .72rem; +} + +.workspace-password-card>.form-message:empty { + display: none; +} + .notes-view-switch { display: inline-flex; padding: 3px; @@ -2639,6 +2699,14 @@ dialog::backdrop { flex-direction: column-reverse; } + .workspace-password-card { + grid-template-columns: 1fr; + } + + .workspace-password-card__controls { + justify-content: start; + } + .notes-view-switch button { flex: 1; } @@ -6577,6 +6645,7 @@ dialog::backdrop { font-size: .68rem; } } + /* Search, pagination and theme-aware scrollbars. */ * { scrollbar-width: thin; @@ -6810,7 +6879,7 @@ dialog::backdrop { grid-template-columns: 12px minmax(0, 1fr); } -.revision > div, +.revision>div, .revision__meta, .revision__meta strong, .revision time, @@ -6842,7 +6911,7 @@ dialog::backdrop { white-space: pre-wrap; } -.history-header > div, +.history-header>div, .history-header h2, .history-header p, .history-help, @@ -6853,3 +6922,104 @@ dialog::backdrop { overflow-wrap: anywhere; word-break: break-word; } + +.page-password-inline { + display: grid; + gap: 7px; + width: 100%; + max-width: 100%; + margin: 5px 0 0; + padding: 8px; + border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); + border-radius: 7px; + background: color-mix(in srgb, var(--surface-card) 76%, var(--surface-inset)); +} + +.page-password-inline[hidden] { + display: none; +} + +.page-password-requirement { + margin: 6px 0 0; + + color: var(--muted); + font-size: .69rem; + line-height: 1.35; +} + +.page-password-requirement[hidden] { + display: none; +} + +.page-password-inline__heading { + display: grid; + gap: 1px; +} + +.page-password-inline__heading label { + color: var(--text-tertiary); + font-size: .76rem; + font-weight: 650; +} + +.page-password-inline__heading small { + color: var(--muted); + font-size: .68rem; + line-height: 1.35; +} + +.page-password-inline__controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px; +} + +.page-password-inline__controls input { + width: 100%; + min-width: 0; + height: 50%; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-inset); + color: var(--text); + font-size: .74rem; +} + +.page-password-inline__controls input::placeholder { + color: var(--muted-2); +} + +.page-password-inline__controls input:focus { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + outline: 2px solid color-mix(in srgb, var(--accent) 12%, transparent); + outline-offset: 0; +} + +.page-password-inline__save { + min-width: 1vh; + height: 3vh; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-card); + color: var(--text-tertiary); + font-size: .72rem; + font-weight: 650; + cursor: pointer; +} + +.page-password-inline__save:hover { + background: var(--surface-hover); + color: var(--text); +} + +.page-password-inline .error:empty { + display: none; +} + +.page-password-inline .error { + margin: 0; + font-size: .68rem; +} \ No newline at end of file diff --git a/static/editor.html b/static/editor.html index f543ae7..18ab7e2 100644 --- a/static/editor.html +++ b/static/editor.html @@ -54,6 +54,19 @@ class="public-task-toggle" title="Allow the published page to open without the resource password or private access"> Unprotect Page + + -
+ aria-pressed="true" aria-label="Markdown" title="Markdown">Markdown +
+