guest password protect
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.31"
|
version = "0.2.34"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.31"
|
version = "0.2.34"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
license = "MIT"
|
license = "Source-Available Code / Dual-Licensed"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE workspaces ADD COLUMN created_by_guest_id VARCHAR(64) NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE workspaces ADD COLUMN created_by_guest_id TEXT;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE workspaces ADD COLUMN created_by_guest_id TEXT;
|
||||||
+93
-7
@@ -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)
|
.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(
|
async fn has_write_permission(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
@@ -258,6 +285,11 @@ pub struct CreateNoteRequest {
|
|||||||
created_by: Option<String>,
|
created_by: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetWorkspacePasswordRequest {
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RestoreRequest {
|
pub struct RestoreRequest {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -273,6 +305,7 @@ pub struct WorkspaceInfo {
|
|||||||
title: String,
|
title: String,
|
||||||
protected: bool,
|
protected: bool,
|
||||||
access_level: String,
|
access_level: String,
|
||||||
|
can_set_password: bool,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -360,6 +393,7 @@ pub struct NoteInfo {
|
|||||||
personal_editor_settings: bool,
|
personal_editor_settings: bool,
|
||||||
can_save_editor_settings: bool,
|
can_save_editor_settings: bool,
|
||||||
can_manage_authorship: bool,
|
can_manage_authorship: bool,
|
||||||
|
can_set_password: bool,
|
||||||
files: Vec<MarkdownFileReference>,
|
files: Vec<MarkdownFileReference>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,11 +570,23 @@ pub async fn create_workspace(
|
|||||||
let password = validate_password(payload.password.as_deref())?;
|
let password = validate_password(payload.password.as_deref())?;
|
||||||
let slug = unique_workspace_slug(&state, title).await?;
|
let slug = unique_workspace_slug(&state, title).await?;
|
||||||
|
|
||||||
let workspace = db::create_workspace(&state.db, &slug, title, password).await?;
|
let account_user = crate::auth::optional_user(&state, &headers)
|
||||||
if let Some(user) = crate::auth::optional_user(&state, &headers)
|
|
||||||
.await
|
.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(
|
sqlx::query(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::USER_ATTACH_WORKSPACE,
|
queries::USER_ATTACH_WORKSPACE,
|
||||||
@@ -585,7 +631,37 @@ pub async fn workspace_info(
|
|||||||
workspace.password_hash.is_some(),
|
workspace.password_hash.is_some(),
|
||||||
)
|
)
|
||||||
.await?;
|
.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<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(workspace_slug): Path<String>,
|
||||||
|
Json(payload): Json<SetWorkspacePasswordRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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(
|
pub async fn open_workspace(
|
||||||
@@ -668,8 +744,9 @@ pub async fn open_workspace(
|
|||||||
if db::verify_workspace_password(&workspace, payload.password.as_deref()) {
|
if db::verify_workspace_password(&workspace, payload.password.as_deref()) {
|
||||||
access_level = AccessLevel::Write;
|
access_level = AccessLevel::Write;
|
||||||
}
|
}
|
||||||
|
let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await;
|
||||||
Ok(Json(WorkspaceOpenResponse {
|
Ok(Json(WorkspaceOpenResponse {
|
||||||
workspace: workspace_info_from(&workspace, access_level),
|
workspace: workspace_info_from(&workspace, access_level, can_set_password),
|
||||||
notes,
|
notes,
|
||||||
pagination: ListPaginationMeta {
|
pagination: ListPaginationMeta {
|
||||||
page,
|
page,
|
||||||
@@ -913,6 +990,7 @@ pub async fn note_info(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
.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 note_owner = note_creator_is_requester(&state, &headers, ¬e).await?;
|
||||||
let password_write_access =
|
let password_write_access =
|
||||||
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
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?;
|
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
|
||||||
|
|
||||||
if workspace.is_private == 0
|
if workspace.is_private == 0
|
||||||
|
&& workspace.password_hash.is_some()
|
||||||
&& !db::note_public_page_disabled(&state.db, note.id).await?
|
&& !db::note_public_page_disabled(&state.db, note.id).await?
|
||||||
&& !db::note_public_page_enabled(&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,
|
personal_editor_settings,
|
||||||
can_save_editor_settings,
|
can_save_editor_settings,
|
||||||
can_manage_authorship,
|
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?,
|
files: markdown_file_references(&state, None, Some(note.id), None).await?,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -1408,12 +1489,17 @@ async fn authorized_note(
|
|||||||
Ok((workspace, 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 {
|
WorkspaceInfo {
|
||||||
slug: workspace.slug.clone(),
|
slug: workspace.slug.clone(),
|
||||||
title: workspace.title.clone(),
|
title: workspace.title.clone(),
|
||||||
protected: workspace.password_hash.is_some(),
|
protected: workspace.password_hash.is_some(),
|
||||||
access_level: access_level_name(access_level).into(),
|
access_level: access_level_name(access_level).into(),
|
||||||
|
can_set_password,
|
||||||
created_at: db::normalize_timestamp(&workspace.created_at),
|
created_at: db::normalize_timestamp(&workspace.created_at),
|
||||||
updated_at: db::normalize_timestamp(&workspace.updated_at),
|
updated_at: db::normalize_timestamp(&workspace.updated_at),
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-8
@@ -18,6 +18,11 @@ pub struct CreatePadRequest {
|
|||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetPadPasswordRequest {
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct CreatePadResponse {
|
pub struct CreatePadResponse {
|
||||||
slug: String,
|
slug: String,
|
||||||
@@ -51,6 +56,7 @@ pub struct PadInfo {
|
|||||||
personal_editor_settings: bool,
|
personal_editor_settings: bool,
|
||||||
can_save_editor_settings: bool,
|
can_save_editor_settings: bool,
|
||||||
can_manage_authorship: bool,
|
can_manage_authorship: bool,
|
||||||
|
can_set_password: bool,
|
||||||
files: Vec<MarkdownFileReference>,
|
files: Vec<MarkdownFileReference>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +140,7 @@ pub async fn pad_info(
|
|||||||
let can_save_editor_settings = (personal_editor_settings || can_manage_authorship)
|
let can_save_editor_settings = (personal_editor_settings || can_manage_authorship)
|
||||||
&& has_write_permission(&state, &headers, "pad", &slug).await?;
|
&& has_write_permission(&state, &headers, "pad", &slug).await?;
|
||||||
if pad.is_private == 0
|
if pad.is_private == 0
|
||||||
|
&& pad.password_hash.is_some()
|
||||||
&& !db::pad_public_page_disabled(&state.db, pad.id).await?
|
&& !db::pad_public_page_disabled(&state.db, pad.id).await?
|
||||||
&& !db::pad_public_page_enabled(&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,
|
personal_editor_settings,
|
||||||
can_save_editor_settings,
|
can_save_editor_settings,
|
||||||
can_manage_authorship,
|
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?,
|
files: markdown_file_references(&state, Some(pad.id), None, None).await?,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_pad_password(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(payload): Json<SetPadPasswordRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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(
|
pub async fn set_pad_editor_settings(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -279,6 +312,11 @@ pub async fn publish_pad_page(
|
|||||||
};
|
};
|
||||||
require_write(level)?;
|
require_write(level)?;
|
||||||
let enabled = payload.enabled.unwrap_or(true);
|
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 {
|
if !enabled {
|
||||||
db::unpublish_pad(&state.db, pad.id).await?;
|
db::unpublish_pad(&state.db, pad.id).await?;
|
||||||
db::set_pad_public_page_disabled(&state.db, pad.id, true).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)?;
|
require_write(level)?;
|
||||||
let enabled = payload.enabled.unwrap_or(true);
|
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 {
|
if !enabled {
|
||||||
db::unpublish_note(&state.db, note.id).await?;
|
db::unpublish_note(&state.db, note.id).await?;
|
||||||
db::set_note_public_page_disabled(&state.db, note.id, true).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> {
|
) -> Result<(), ApiError> {
|
||||||
let password = page_password(headers);
|
let password = page_password(headers);
|
||||||
if let Some(pad_id) = page.pad_id {
|
if let Some(pad_id) = page.pad_id {
|
||||||
if db::pad_public_page_unprotected(&state.db, pad_id).await? {
|
let page_unprotected = db::pad_public_page_unprotected(&state.db, pad_id).await?;
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let sql = match state.db.kind() {
|
let sql = match state.db.kind() {
|
||||||
crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1",
|
crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1",
|
||||||
_ => "SELECT slug FROM pads WHERE id = ?",
|
_ => "SELECT slug FROM pads WHERE id = ?",
|
||||||
@@ -390,7 +431,12 @@ async fn ensure_public_page_access(
|
|||||||
let pad = db::find_pad(&state.db, &slug)
|
let pad = db::find_pad(&state.db, &slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let password_ok = db::verify_pad_password(&pad, password);
|
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 let Some(note_id) = page.note_id {
|
||||||
if db::note_public_page_unprotected(&state.db, note_id).await? {
|
let page_unprotected = db::note_public_page_unprotected(&state.db, note_id).await?;
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let sql = match state.db.kind() {
|
let sql = match state.db.kind() {
|
||||||
crate::database::DatabaseKind::Postgres => {
|
crate::database::DatabaseKind::Postgres => {
|
||||||
"SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1"
|
"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)
|
let workspace = db::find_workspace(&state.db, &slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_workspace)?;
|
.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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let password_ok = db::verify_workspace_password(&workspace, password);
|
let password_ok = db::verify_workspace_password(&workspace, password);
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ pub fn router(
|
|||||||
post(api::set_pad_editor_settings),
|
post(api::set_pad_editor_settings),
|
||||||
)
|
)
|
||||||
.route("/api/pads/{slug}/publish", post(api::publish_pad_page))
|
.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}/restore", post(api::pad_restore))
|
||||||
.route(
|
.route(
|
||||||
"/api/pads/{slug}/files",
|
"/api/pads/{slug}/files",
|
||||||
@@ -169,6 +170,10 @@ pub fn router(
|
|||||||
)
|
)
|
||||||
.route("/api/workspaces", post(api::create_workspace))
|
.route("/api/workspaces", post(api::create_workspace))
|
||||||
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
|
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
|
||||||
|
.route(
|
||||||
|
"/api/workspaces/{workspace_slug}/password",
|
||||||
|
post(api::set_workspace_password),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/workspaces/{workspace_slug}/open",
|
"/api/workspaces/{workspace_slug}/open",
|
||||||
post(api::open_workspace),
|
post(api::open_workspace),
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pub struct Workspace {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub is_private: i64,
|
pub is_private: i64,
|
||||||
|
pub created_by_guest_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -142,6 +143,7 @@ pub async fn create_workspace(
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
|
created_by_guest_id: Option<&str>,
|
||||||
) -> Result<Workspace, sqlx::Error> {
|
) -> Result<Workspace, sqlx::Error> {
|
||||||
let password_hash = password
|
let password_hash = password
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
@@ -150,6 +152,7 @@ pub async fn create_workspace(
|
|||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
|
.bind(created_by_guest_id)
|
||||||
.execute(pool.pool())
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -159,6 +162,23 @@ pub async fn create_workspace(
|
|||||||
.await
|
.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 {
|
pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool {
|
||||||
match (
|
match (
|
||||||
&workspace.password_hash,
|
&workspace.password_hash,
|
||||||
@@ -496,6 +516,20 @@ pub async fn create_pad(
|
|||||||
.await
|
.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 {
|
pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool {
|
||||||
match (
|
match (
|
||||||
&pad.password_hash,
|
&pad.password_hash,
|
||||||
@@ -631,6 +665,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace {
|
|||||||
created_at: crate::row_decode::text(row, "created_at")?,
|
created_at: crate::row_decode::text(row, "created_at")?,
|
||||||
updated_at: crate::row_decode::text(row, "updated_at")?,
|
updated_at: crate::row_decode::text(row, "updated_at")?,
|
||||||
is_private: row.try_get("is_private")?,
|
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(),
|
created_at: String::new(),
|
||||||
updated_at: String::new(),
|
updated_at: String::new(),
|
||||||
is_private: 1,
|
is_private: 1,
|
||||||
|
created_by_guest_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ?"#
|
r#"SELECT CAST(CASE WHEN public_page_disabled THEN 1 ELSE 0 END AS SIGNED) FROM notes WHERE id = ?"#
|
||||||
}
|
}
|
||||||
Query::Q001 => {
|
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 => {
|
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"#
|
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"#
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,9 +274,9 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT public_page_disabled FROM notes WHERE id = $1"#
|
r#"SELECT public_page_disabled FROM notes WHERE id = $1"#
|
||||||
}
|
}
|
||||||
Query::Q001 => {
|
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 => {
|
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"#
|
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"#
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = ?"#
|
r#"SELECT CASE WHEN public_page_disabled THEN 1 ELSE 0 END FROM notes WHERE id = ?"#
|
||||||
}
|
}
|
||||||
Query::Q001 => {
|
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 => {
|
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"#
|
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"#
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-2
@@ -2509,6 +2509,66 @@ dialog::backdrop {
|
|||||||
gap: 10px;
|
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 {
|
.notes-view-switch {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
@@ -2639,6 +2699,14 @@ dialog::backdrop {
|
|||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-password-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-password-card__controls {
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
.notes-view-switch button {
|
.notes-view-switch button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -6577,6 +6645,7 @@ dialog::backdrop {
|
|||||||
font-size: .68rem;
|
font-size: .68rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search, pagination and theme-aware scrollbars. */
|
/* Search, pagination and theme-aware scrollbars. */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -6810,7 +6879,7 @@ dialog::backdrop {
|
|||||||
grid-template-columns: 12px minmax(0, 1fr);
|
grid-template-columns: 12px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.revision > div,
|
.revision>div,
|
||||||
.revision__meta,
|
.revision__meta,
|
||||||
.revision__meta strong,
|
.revision__meta strong,
|
||||||
.revision time,
|
.revision time,
|
||||||
@@ -6842,7 +6911,7 @@ dialog::backdrop {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-header > div,
|
.history-header>div,
|
||||||
.history-header h2,
|
.history-header h2,
|
||||||
.history-header p,
|
.history-header p,
|
||||||
.history-help,
|
.history-help,
|
||||||
@@ -6853,3 +6922,104 @@ dialog::backdrop {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
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;
|
||||||
|
}
|
||||||
+29
-15
@@ -54,6 +54,19 @@
|
|||||||
class="public-task-toggle"
|
class="public-task-toggle"
|
||||||
title="Allow the published page to open without the resource password or private access"><input
|
title="Allow the published page to open without the resource password or private access"><input
|
||||||
id="unprotect-public-page" type="checkbox"> Unprotect Page</label>
|
id="unprotect-public-page" type="checkbox"> Unprotect Page</label>
|
||||||
|
<p id="page-password-requirement" class="page-password-requirement" hidden>Access to page
|
||||||
|
options requires a password-protected note.</p>
|
||||||
|
<form id="set-page-password-form" class="page-password-inline" hidden>
|
||||||
|
<div class="page-password-inline__heading">
|
||||||
|
<label id="set-page-password-label" for="set-page-password">Set password</label>
|
||||||
|
<small id="set-page-password-help">Minimum 8 characters.</small>
|
||||||
|
</div>
|
||||||
|
<div class="page-password-inline__controls"><input id="set-page-password" type="password"
|
||||||
|
minlength="8" maxlength="128" autocomplete="new-password" placeholder="Min. 8 chars"
|
||||||
|
aria-describedby="set-page-password-help" required><button type="submit"
|
||||||
|
class="page-password-inline__save">Set</button></div>
|
||||||
|
<small id="set-page-password-error" class="error"></small>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</details><button id="files-button" class="secondary-button">Files</button><button id="delete-note"
|
</details><button id="files-button" class="secondary-button">Files</button><button id="delete-note"
|
||||||
class="secondary-button danger-button" hidden>Delete</button><button id="history-button"
|
class="secondary-button danger-button" hidden>Delete</button><button id="history-button"
|
||||||
@@ -123,15 +136,16 @@
|
|||||||
Compact</label><label class="line-toggle"><input id="line-links-toggle" type="checkbox">
|
Compact</label><label class="line-toggle"><input id="line-links-toggle" type="checkbox">
|
||||||
Line links</label>
|
Line links</label>
|
||||||
<div class="toolbar-fill"></div><button id="mode-toggle" class="toolbar-action active"
|
<div class="toolbar-fill"></div><button id="mode-toggle" class="toolbar-action active"
|
||||||
aria-pressed="true" aria-label="Markdown" title="Markdown"><span class="control-label-full">Markdown</span><span
|
aria-pressed="true" aria-label="Markdown" title="Markdown"><span
|
||||||
class="control-label-short" aria-hidden="true">M</span></button>
|
class="control-label-full">Markdown</span><span class="control-label-short"
|
||||||
<div class="view-switch" aria-label="Editor view"><button data-view="edit" aria-label="Edit" title="Edit"><span
|
aria-hidden="true">M</span></button>
|
||||||
class="control-label-full">Edit</span><span class="control-label-short"
|
<div class="view-switch" aria-label="Editor view"><button data-view="edit" aria-label="Edit"
|
||||||
aria-hidden="true">E</span></button><button data-view="split" class="active" aria-label="Split"
|
title="Edit"><span class="control-label-full">Edit</span><span class="control-label-short"
|
||||||
title="Split"><span class="control-label-full">Split</span><span class="control-label-short"
|
aria-hidden="true">E</span></button><button data-view="split" class="active"
|
||||||
aria-hidden="true">S</span></button><button data-view="preview" aria-label="Preview" title="Preview"><span
|
aria-label="Split" title="Split"><span class="control-label-full">Split</span><span
|
||||||
class="control-label-full">Preview</span><span class="control-label-short"
|
class="control-label-short" aria-hidden="true">S</span></button><button data-view="preview"
|
||||||
aria-hidden="true">P</span></button></div>
|
aria-label="Preview" title="Preview"><span class="control-label-full">Preview</span><span
|
||||||
|
class="control-label-short" aria-hidden="true">P</span></button></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="connection-notice" class="connection-notice" role="status" aria-live="polite" hidden>
|
<div id="connection-notice" class="connection-notice" role="status" aria-live="polite" hidden>
|
||||||
<span class="connection-notice__signal"
|
<span class="connection-notice__signal"
|
||||||
@@ -283,12 +297,12 @@
|
|||||||
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
|
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
|
||||||
id="show-login" class="text-button" type="button">Log in</button></div>
|
id="show-login" class="text-button" type="button">Log in</button></div>
|
||||||
<section id="auth-panel" class="auth-panel" hidden>
|
<section id="auth-panel" class="auth-panel" hidden>
|
||||||
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field"><span id="auth-email-label">E-mail</span><input
|
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field"><span
|
||||||
id="auth-email" name="username" type="email" maxlength="320" autocomplete="username"
|
id="auth-email-label">E-mail</span><input id="auth-email" name="username" type="email"
|
||||||
placeholder="you@example.com"></label><label>Password<input
|
maxlength="320" autocomplete="username"
|
||||||
id="auth-password" name="password" type="password" minlength="8" maxlength="128"
|
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
|
||||||
autocomplete="current-password"></label><button id="auth-submit" class="primary-button"
|
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button
|
||||||
type="submit">Log in and continue</button>
|
id="auth-submit" class="primary-button" type="submit">Log in and continue</button>
|
||||||
<div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot
|
<div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot
|
||||||
password?</button><button id="auth-back" class="text-button" type="button">Back to
|
password?</button><button id="auth-back" class="text-button" type="button">Back to
|
||||||
nickname</button><button id="logout-account" class="text-button" type="button">Log out saved
|
nickname</button><button id="logout-account" class="text-button" type="button">Log out saved
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function createPadAdapter() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
access: { kind: "pad", key: slug },
|
access: { kind: "pad", key: slug },
|
||||||
|
passwordScope: "note",
|
||||||
addressSelector: "#document-url",
|
addressSelector: "#document-url",
|
||||||
title: info => `${info.title} · RustPad`,
|
title: info => `${info.title} · RustPad`,
|
||||||
loadInfo: headers => api(base, { headers }),
|
loadInfo: headers => api(base, { headers }),
|
||||||
@@ -37,6 +38,7 @@ export function createPadAdapter() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ kind: "pad", slug, password }),
|
body: JSON.stringify({ kind: "pad", slug, password }),
|
||||||
}),
|
}),
|
||||||
|
setPassword: password => api(`${base}/password`, { method: "POST", body: JSON.stringify({ password }) }),
|
||||||
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
|
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }),
|
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }),
|
||||||
@@ -62,6 +64,7 @@ export function createWorkspaceNoteAdapter() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
access: { kind: "workspace", key: workspaceSlug },
|
access: { kind: "workspace", key: workspaceSlug },
|
||||||
|
passwordScope: "workspace",
|
||||||
addressSelector: "#document-url",
|
addressSelector: "#document-url",
|
||||||
title: info => `${info.title} · ${info.workspace_title}`,
|
title: info => `${info.title} · ${info.workspace_title}`,
|
||||||
loadInfo: headers => api(base, { headers }),
|
loadInfo: headers => api(base, { headers }),
|
||||||
@@ -78,6 +81,10 @@ export function createWorkspaceNoteAdapter() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
|
body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
|
||||||
}),
|
}),
|
||||||
|
setPassword: password => api(`/api/workspaces/${encode(workspaceSlug)}/password`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
}),
|
||||||
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
|
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }),
|
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }),
|
||||||
|
|||||||
@@ -1851,13 +1851,50 @@ export function startNoteEditor(adapter) {
|
|||||||
});
|
});
|
||||||
const publishPageButton = document.querySelector("#publish-page");
|
const publishPageButton = document.querySelector("#publish-page");
|
||||||
const pageSettings = document.querySelector(".page-settings");
|
const pageSettings = document.querySelector(".page-settings");
|
||||||
|
const setPagePasswordForm = document.querySelector("#set-page-password-form");
|
||||||
|
const setPagePasswordInput = document.querySelector("#set-page-password");
|
||||||
|
const setPagePasswordError = document.querySelector("#set-page-password-error");
|
||||||
|
const setPagePasswordLabel = document.querySelector("#set-page-password-label");
|
||||||
|
const setPagePasswordHelp = document.querySelector("#set-page-password-help");
|
||||||
|
const pagePasswordRequirement = document.querySelector("#page-password-requirement");
|
||||||
function updatePageControls() {
|
function updatePageControls() {
|
||||||
const enabled = publicPageEnabled.checked;
|
const passwordProtected = Boolean(info?.protected);
|
||||||
|
const workspacePassword = adapter.passwordScope === "workspace";
|
||||||
|
if (!passwordProtected) {
|
||||||
|
publicPageEnabled.checked = false;
|
||||||
|
unprotectPublicPage.checked = false;
|
||||||
|
}
|
||||||
|
const requirementText = workspacePassword
|
||||||
|
? "Access to page options requires a password-protected workspace."
|
||||||
|
: "Access to page options requires a password-protected note.";
|
||||||
|
if (pagePasswordRequirement) {
|
||||||
|
pagePasswordRequirement.textContent = requirementText;
|
||||||
|
pagePasswordRequirement.hidden = passwordProtected;
|
||||||
|
}
|
||||||
|
if (setPagePasswordLabel) setPagePasswordLabel.textContent = workspacePassword ? "Set workspace password" : "Set password";
|
||||||
|
if (setPagePasswordHelp) setPagePasswordHelp.textContent = workspacePassword ? "Protects the entire workspace. Minimum 8 characters." : "Minimum 8 characters.";
|
||||||
|
const canSetPassword = !passwordProtected && Boolean(adapter.setPassword) && Boolean(info?.can_set_password);
|
||||||
|
setPagePasswordForm.hidden = !canSetPassword;
|
||||||
|
const enabled = passwordProtected && publicPageEnabled.checked;
|
||||||
|
publicPageEnabled.disabled = !passwordProtected;
|
||||||
publishPageButton.disabled = !enabled;
|
publishPageButton.disabled = !enabled;
|
||||||
publicTaskUpdates.disabled = !enabled;
|
publicTaskUpdates.disabled = !enabled;
|
||||||
unprotectPublicPage.disabled = !enabled;
|
unprotectPublicPage.disabled = !enabled;
|
||||||
|
pageSettings?.querySelector("summary")?.setAttribute(
|
||||||
|
"aria-disabled",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
pageSettings?.classList.toggle("is-enabled", enabled);
|
pageSettings?.classList.toggle("is-enabled", enabled);
|
||||||
pageSettings?.querySelector("summary")?.setAttribute("title", enabled ? "Published page enabled" : "Published page disabled");
|
pageSettings?.querySelector("summary")?.setAttribute(
|
||||||
|
"title",
|
||||||
|
!passwordProtected
|
||||||
|
? canSetPassword
|
||||||
|
? "Set a password before enabling the published page"
|
||||||
|
: requirementText
|
||||||
|
: enabled
|
||||||
|
? "Published page enabled"
|
||||||
|
: "Published page disabled",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
document.addEventListener("pointerdown", event => {
|
document.addEventListener("pointerdown", event => {
|
||||||
const target = event.target instanceof Element ? event.target : null;
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
@@ -1868,6 +1905,37 @@ export function startNoteEditor(adapter) {
|
|||||||
if (event.key === "Escape" && pageSettings?.open) pageSettings.open = false;
|
if (event.key === "Escape" && pageSettings?.open) pageSettings.open = false;
|
||||||
if (event.key === "Escape" && mobileConnectionDetails?.open) mobileConnectionDetails.open = false;
|
if (event.key === "Escape" && mobileConnectionDetails?.open) mobileConnectionDetails.open = false;
|
||||||
});
|
});
|
||||||
|
setPagePasswordForm?.addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!adapter.setPassword) return;
|
||||||
|
const passwordValue = setPagePasswordInput.value;
|
||||||
|
setPagePasswordError.textContent = "";
|
||||||
|
if (passwordValue.length < 8) {
|
||||||
|
setPagePasswordError.textContent = "Password must contain at least 8 characters.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const submit = setPagePasswordForm.querySelector('button[type="submit"]');
|
||||||
|
submit.disabled = true;
|
||||||
|
try {
|
||||||
|
await adapter.setPassword(passwordValue);
|
||||||
|
const access = await adapter.requestAccess(passwordValue);
|
||||||
|
setAccessToken(adapter.access.kind, adapter.access.key, access.granted);
|
||||||
|
accessToken = getAccessToken(adapter.access.kind, adapter.access.key) || shareToken;
|
||||||
|
password = "";
|
||||||
|
setPagePasswordInput.value = "";
|
||||||
|
await loadNoteInfo();
|
||||||
|
resourceUnlocked = false;
|
||||||
|
socket?.stop();
|
||||||
|
loadFiles();
|
||||||
|
connect();
|
||||||
|
toast("Password set. Page options are now available.");
|
||||||
|
} catch (error) {
|
||||||
|
setPagePasswordError.textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
updatePageControls();
|
||||||
|
}
|
||||||
|
});
|
||||||
const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked);
|
const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked);
|
||||||
publicPageEnabled.addEventListener("change", async () => {
|
publicPageEnabled.addEventListener("change", async () => {
|
||||||
const previous = !publicPageEnabled.checked;
|
const previous = !publicPageEnabled.checked;
|
||||||
|
|||||||
@@ -32,12 +32,19 @@ const notesList = document.querySelector("#notes-list");
|
|||||||
const notesSearch = document.querySelector("#notes-search");
|
const notesSearch = document.querySelector("#notes-search");
|
||||||
const notesPerPage = document.querySelector("#notes-per-page");
|
const notesPerPage = document.querySelector("#notes-per-page");
|
||||||
const notesPagination = document.querySelector("#notes-pagination");
|
const notesPagination = document.querySelector("#notes-pagination");
|
||||||
|
const workspacePasswordForm = document.querySelector("#workspace-password-form");
|
||||||
|
const workspacePasswordInput = document.querySelector("#workspace-set-password");
|
||||||
|
const workspacePasswordError = document.querySelector("#workspace-password-error");
|
||||||
let notesPage = 1;
|
let notesPage = 1;
|
||||||
let notesSearchTimer;
|
let notesSearchTimer;
|
||||||
const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
|
const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
|
||||||
let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid";
|
let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid";
|
||||||
let notesCache = [];
|
let notesCache = [];
|
||||||
|
|
||||||
|
function updateWorkspacePasswordControl() {
|
||||||
|
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
|
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
|
||||||
function formatBytes(value) {
|
function formatBytes(value) {
|
||||||
const bytes = Math.max(0, Number(value) || 0);
|
const bytes = Math.max(0, Number(value) || 0);
|
||||||
@@ -135,6 +142,7 @@ async function openWorkspace() {
|
|||||||
notesCache = data.notes;
|
notesCache = data.notes;
|
||||||
renderNotes();
|
renderNotes();
|
||||||
renderNotesPagination(data.pagination);
|
renderNotesPagination(data.pagination);
|
||||||
|
updateWorkspacePasswordControl();
|
||||||
if (dialog.open) dialog.close();
|
if (dialog.open) dialog.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (info?.protected || e.message.toLowerCase().includes("password")) {
|
if (info?.protected || e.message.toLowerCase().includes("password")) {
|
||||||
@@ -151,6 +159,7 @@ async function init() {
|
|||||||
info = await api(`/api/workspaces/${encodeURIComponent(slug)}`, { headers });
|
info = await api(`/api/workspaces/${encodeURIComponent(slug)}`, { headers });
|
||||||
document.querySelector("#workspace-title").textContent = info.title;
|
document.querySelector("#workspace-title").textContent = info.title;
|
||||||
document.querySelector("#workspace-url").textContent = location.pathname;
|
document.querySelector("#workspace-url").textContent = location.pathname;
|
||||||
|
updateWorkspacePasswordControl();
|
||||||
if (info.protected && info.access_level === "none") dialog.showModal(); else openWorkspace();
|
if (info.protected && info.access_level === "none") dialog.showModal(); else openWorkspace();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 403 || e.status === 404) await showSystemNotFound();
|
if (e.status === 403 || e.status === 404) await showSystemNotFound();
|
||||||
@@ -169,6 +178,36 @@ document.querySelector("#password-form").addEventListener("submit", async e => {
|
|||||||
openWorkspace();
|
openWorkspace();
|
||||||
} catch (error) { document.querySelector("#password-error").textContent = error.message; }
|
} catch (error) { document.querySelector("#password-error").textContent = error.message; }
|
||||||
});
|
});
|
||||||
|
workspacePasswordForm.addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const password = workspacePasswordInput.value;
|
||||||
|
workspacePasswordError.textContent = "";
|
||||||
|
if (password.length < 8) {
|
||||||
|
workspacePasswordError.textContent = "Password must contain at least 8 characters.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const submit = workspacePasswordForm.querySelector('button[type="submit"]');
|
||||||
|
submit.disabled = true;
|
||||||
|
try {
|
||||||
|
await api(`/api/workspaces/${encodeURIComponent(slug)}/password`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
const result = await api("/api/access-token", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ kind: "workspace", slug, password }),
|
||||||
|
});
|
||||||
|
setAccessToken("workspace", slug, result.granted);
|
||||||
|
accessToken = getAccessToken("workspace", slug);
|
||||||
|
workspacePasswordInput.value = "";
|
||||||
|
toast("Workspace password set");
|
||||||
|
await openWorkspace();
|
||||||
|
} catch (error) {
|
||||||
|
workspacePasswordError.textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal());
|
document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal());
|
||||||
document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
|
document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
|
||||||
document.querySelector("#note-form").addEventListener("submit", async e => {
|
document.querySelector("#note-form").addEventListener("submit", async e => {
|
||||||
|
|||||||
@@ -34,6 +34,11 @@
|
|||||||
<p>Select a note or create a new one.</p>
|
<p>Select a note or create a new one.</p>
|
||||||
</div><button id="new-note-button" class="primary-button inline-button">New note</button>
|
</div><button id="new-note-button" class="primary-button inline-button">New note</button>
|
||||||
</section>
|
</section>
|
||||||
|
<form id="workspace-password-form" class="workspace-password-card" hidden>
|
||||||
|
<div class="workspace-password-card__copy"><strong>Protect workspace</strong><small>One password secures the workspace and all its notes.</small></div>
|
||||||
|
<div class="workspace-password-card__controls"><input id="workspace-set-password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="Min. 8 chars" required><button class="secondary-button" type="submit">Set</button></div>
|
||||||
|
<small id="workspace-password-error" class="form-message error" role="alert"></small>
|
||||||
|
</form>
|
||||||
<div class="notes-toolbar">
|
<div class="notes-toolbar">
|
||||||
<label class="list-search"><span class="sr-only">Search notes</span><input id="notes-search" type="search" placeholder="Search notes…" autocomplete="off"></label>
|
<label class="list-search"><span class="sr-only">Search notes</span><input id="notes-search" type="search" placeholder="Search notes…" autocomplete="off"></label>
|
||||||
<label class="page-size-label">Per page<select id="notes-per-page"><option value="25">25</option><option value="50">50</option><option value="100">100</option></select></label>
|
<label class="page-size-label">Per page<select id="notes-per-page"><option value="25">25</option><option value="50">50</option><option value="100">100</option></select></label>
|
||||||
|
|||||||
Reference in New Issue
Block a user