fixes
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.1.17"
|
version = "0.1.18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.1.17"
|
version = "0.1.18"
|
||||||
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"
|
||||||
|
|||||||
+33
-14
@@ -154,7 +154,8 @@ pub struct PrivacyRequest {
|
|||||||
pub struct ShareUsersRequest {
|
pub struct ShareUsersRequest {
|
||||||
kind: String,
|
kind: String,
|
||||||
slug: String,
|
slug: String,
|
||||||
emails: String,
|
#[serde(alias = "emails")]
|
||||||
|
recipients: String,
|
||||||
permission: String,
|
permission: String,
|
||||||
}
|
}
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -1188,26 +1189,29 @@ pub async fn share_resource_users(
|
|||||||
"Share confirmation requires SMTP configuration.",
|
"Share confirmation requires SMTP configuration.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let emails: Vec<String> = req
|
let recipients: Vec<String> = req
|
||||||
.emails
|
.recipients
|
||||||
.split(',')
|
.split([',', ';', '\n'])
|
||||||
.map(|v| normalize(v))
|
.map(str::trim)
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect::<std::collections::HashSet<_>>()
|
||||||
|
.into_iter()
|
||||||
.collect();
|
.collect();
|
||||||
if emails.is_empty() || emails.len() > 100 {
|
if recipients.is_empty() || recipients.len() > 100 {
|
||||||
return Err(AuthError::bad_request(
|
return Err(AuthError::bad_request(
|
||||||
"Enter between 1 and 100 registered e-mail addresses.",
|
"Enter between 1 and 100 e-mail addresses or user names.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut missing = Vec::new();
|
let mut missing = Vec::new();
|
||||||
for email in emails {
|
for recipient in recipients {
|
||||||
let user = find_user_by_email(&state, &email).await?;
|
let user = find_user_by_share_identifier(&state, &recipient).await?;
|
||||||
let Some(user) = user else {
|
let Some(user) = user else {
|
||||||
missing.push(email);
|
missing.push(recipient);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if user.confirmed_at.is_none() {
|
if user.confirmed_at.is_none() {
|
||||||
missing.push(format!("{} (account not activated)", email));
|
missing.push(format!("{} (account not activated)", recipient));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if user.id == owner.id {
|
if user.id == owner.id {
|
||||||
@@ -1289,7 +1293,7 @@ pub async fn share_resource_users(
|
|||||||
}
|
}
|
||||||
if !missing.is_empty() {
|
if !missing.is_empty() {
|
||||||
return Err(AuthError::bad_request(&format!(
|
return Err(AuthError::bad_request(&format!(
|
||||||
"No registered account for: {}",
|
"No active registered account for: {}",
|
||||||
missing.join(", ")
|
missing.join(", ")
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -1426,7 +1430,7 @@ pub async fn resource_sharing(
|
|||||||
.fetch_all(state.db.pool())
|
.fetch_all(state.db.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?;
|
||||||
let pending: Vec<(String, String, String, String)> = sqlx::query_as(queries::get(
|
let pending: Vec<(String, String, String, Option<String>)> = sqlx::query_as(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::RESOURCE_SHARING_PENDING,
|
queries::RESOURCE_SHARING_PENDING,
|
||||||
))
|
))
|
||||||
@@ -1870,6 +1874,21 @@ async fn find_user_by_external_id(
|
|||||||
.map_err(AuthError::database)
|
.map_err(AuthError::database)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn find_user_by_share_identifier(
|
||||||
|
state: &SharedState,
|
||||||
|
identifier: &str,
|
||||||
|
) -> Result<Option<User>, AuthError> {
|
||||||
|
sqlx::query_as::<_, User>(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::AUTH_USER_BY_SHARE_IDENTIFIER,
|
||||||
|
))
|
||||||
|
.bind(normalize(identifier))
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)
|
||||||
|
}
|
||||||
|
|
||||||
async fn find_user_by_email(state: &SharedState, email: &str) -> Result<Option<User>, AuthError> {
|
async fn find_user_by_email(state: &SharedState, email: &str) -> Result<Option<User>, AuthError> {
|
||||||
sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL))
|
sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL))
|
||||||
.bind(normalize(email))
|
.bind(normalize(email))
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub enum Query {
|
|||||||
AUTH_INSERT_SESSION,
|
AUTH_INSERT_SESSION,
|
||||||
AUTH_USER_BY_NICKNAME,
|
AUTH_USER_BY_NICKNAME,
|
||||||
AUTH_USER_BY_EMAIL,
|
AUTH_USER_BY_EMAIL,
|
||||||
|
AUTH_USER_BY_SHARE_IDENTIFIER,
|
||||||
USER_ATTACH_WORKSPACE,
|
USER_ATTACH_WORKSPACE,
|
||||||
USER_ATTACH_PAD,
|
USER_ATTACH_PAD,
|
||||||
USER_LIST_WORKSPACES,
|
USER_LIST_WORKSPACES,
|
||||||
@@ -188,6 +189,7 @@ pub const AUTH_USER_BY_SESSION: Query = Query::AUTH_USER_BY_SESSION;
|
|||||||
pub const AUTH_INSERT_SESSION: Query = Query::AUTH_INSERT_SESSION;
|
pub const AUTH_INSERT_SESSION: Query = Query::AUTH_INSERT_SESSION;
|
||||||
pub const AUTH_USER_BY_NICKNAME: Query = Query::AUTH_USER_BY_NICKNAME;
|
pub const AUTH_USER_BY_NICKNAME: Query = Query::AUTH_USER_BY_NICKNAME;
|
||||||
pub const AUTH_USER_BY_EMAIL: Query = Query::AUTH_USER_BY_EMAIL;
|
pub const AUTH_USER_BY_EMAIL: Query = Query::AUTH_USER_BY_EMAIL;
|
||||||
|
pub const AUTH_USER_BY_SHARE_IDENTIFIER: Query = Query::AUTH_USER_BY_SHARE_IDENTIFIER;
|
||||||
pub const USER_ATTACH_WORKSPACE: Query = Query::USER_ATTACH_WORKSPACE;
|
pub const USER_ATTACH_WORKSPACE: Query = Query::USER_ATTACH_WORKSPACE;
|
||||||
pub const USER_ATTACH_PAD: Query = Query::USER_ATTACH_PAD;
|
pub const USER_ATTACH_PAD: Query = Query::USER_ATTACH_PAD;
|
||||||
pub const USER_LIST_WORKSPACES: Query = Query::USER_LIST_WORKSPACES;
|
pub const USER_LIST_WORKSPACES: Query = Query::USER_LIST_WORKSPACES;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::AUTH_INSERT_SESSION => r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#,
|
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 = ?"#,
|
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 = ?"#,
|
||||||
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 = ?"#,
|
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 = ?"#,
|
||||||
|
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 nickname_key = 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 = ?"#,
|
Query::USER_ATTACH_WORKSPACE => r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#,
|
||||||
Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"#,
|
Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"#,
|
||||||
Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"#,
|
Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"#,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::AUTH_INSERT_SESSION => r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)"#,
|
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"#,
|
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"#,
|
||||||
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"#,
|
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"#,
|
||||||
|
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 nickname_key = $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"#,
|
Query::USER_ATTACH_WORKSPACE => r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT $1, id FROM workspaces WHERE slug = $2"#,
|
||||||
Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT $1, id FROM pads WHERE slug = $2"#,
|
Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT $1, id FROM pads WHERE slug = $2"#,
|
||||||
Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, w.title, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, w.updated_at, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(1 AS BIGINT) AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = $1 UNION SELECT w.slug, w.title, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT), w.updated_at, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT), CAST(0 AS BIGINT), rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = $2 ORDER BY updated_at DESC"#,
|
Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, w.title, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, w.updated_at, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(1 AS BIGINT) AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = $1 UNION SELECT w.slug, w.title, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT), w.updated_at, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT), CAST(0 AS BIGINT), rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = $2 ORDER BY updated_at DESC"#,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
Query::AUTH_INSERT_SESSION => r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#,
|
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 = ?"#,
|
Query::AUTH_USER_BY_NICKNAME => r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active 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 = ?"#,
|
Query::AUTH_USER_BY_EMAIL => r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active 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 nickname_key = ?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 = ?"#,
|
Query::USER_ATTACH_WORKSPACE => r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#,
|
||||||
Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"#,
|
Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"#,
|
||||||
Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"#,
|
Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"#,
|
||||||
|
|||||||
+8
-8
@@ -136,8 +136,8 @@ async function loadResources() {
|
|||||||
dialog.innerHTML = `<div class="share-panel">
|
dialog.innerHTML = `<div class="share-panel">
|
||||||
<div class="share-panel-head"><div><h3>Share access</h3><p>Manage registered users and direct access links for <strong>${escapeHtml(item.title)}</strong>.</p></div><button class="dialog-close" type="button" data-cancel aria-label="Close share dialog">×</button></div>
|
<div class="share-panel-head"><div><h3>Share access</h3><p>Manage registered users and direct access links for <strong>${escapeHtml(item.title)}</strong>.</p></div><button class="dialog-close" type="button" data-cancel aria-label="Close share dialog">×</button></div>
|
||||||
<div class="share-dialog-body">
|
<div class="share-dialog-body">
|
||||||
<section class="share-section"><div class="share-section-head"><div><h4>People with access</h4><p>Invite existing Rustpad users by e-mail.</p></div></div><form data-user-share-form class="share-form-grid">
|
<section class="share-section"><div class="share-section-head"><div><h4>People with access</h4><p>Invite existing RustPad users by e-mail, or AD/LDAP login.</p></div></div><form data-user-share-form class="share-form-grid">
|
||||||
<label class="share-emails"><span>E-mail addresses</span><input name="emails" type="text" placeholder="alice@example.com, bob@example.com" autocomplete="off"></label>
|
<label class="share-emails"><span>E-mail or user names</span><input name="recipients" type="text" placeholder="alice@example.com, jsmith, John Snow" autocomplete="off"></label>
|
||||||
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
|
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
|
||||||
<button class="primary-button" type="submit">Grant access</button>
|
<button class="primary-button" type="submit">Grant access</button>
|
||||||
</form><div data-user-list class="share-list"></div></section>
|
</form><div data-user-list class="share-list"></div></section>
|
||||||
@@ -183,7 +183,7 @@ async function loadResources() {
|
|||||||
linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken }) }); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken }) }); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
userForm.addEventListener("submit", async event => { event.preventDefault(); try { const result = await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, emails: userForm.emails.value, permission: userForm.permission.value }) }); userForm.emails.value = ""; setDialogMessage(result.confirmation_required ? "Invitation sent. Access will appear after the recipient accepts it." : "Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
userForm.addEventListener("submit", async event => { event.preventDefault(); try { const result = await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, recipients: userForm.recipients.value, permission: userForm.permission.value }) }); userForm.recipients.value = ""; setDialogMessage(result.confirmation_required ? "Invitation sent. Access will appear after the recipient accepts it." : "Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||||
linkForm.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked); const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, permission: linkForm.permission.value, expires_at }) }); const absolute = new URL(result.url, location.origin).href; await copyText(absolute); setDialogMessage("Link created and copied. It remains visible below.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
linkForm.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked); const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, permission: linkForm.permission.value, expires_at }) }); const absolute = new URL(result.url, location.origin).href; await copyText(absolute); setDialogMessage("Link created and copied. It remains visible below.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
|
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
|
||||||
@@ -286,15 +286,15 @@ if (identityDialog) {
|
|||||||
document.querySelector("#close-profile")?.addEventListener("click", () => profileDialog.close());
|
document.querySelector("#close-profile")?.addEventListener("click", () => profileDialog.close());
|
||||||
profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); });
|
profileDialog?.addEventListener("click", event => { if (event.target === profileDialog) profileDialog.close(); });
|
||||||
profileForm?.addEventListener("submit", async event => {
|
profileForm?.addEventListener("submit", async event => {
|
||||||
event.preventDefault(); const message=document.querySelector("#profile-message"); message.textContent=""; message.classList.remove("success", "error");
|
event.preventDefault(); const message = document.querySelector("#profile-message"); message.textContent = ""; message.classList.remove("success", "error");
|
||||||
try { const selectedColor=document.querySelector("#profile-color").value; const newEmail=currentSession?.directory_managed?null:(document.querySelector("#profile-email").value.trim()||null); const newPassword=currentSession?.directory_managed?null:(document.querySelector("#profile-new-password").value||null); const password=currentSession?.directory_managed?"":document.querySelector("#profile-password").value; if ((newEmail || newPassword) && !password) throw new Error("Enter the current password to change e-mail or password."); const result=await api("/api/auth/profile",{method:"POST",headers:authHeaders(),body:JSON.stringify({nickname:document.querySelector("#profile-nickname").value.trim(),editor_color:selectedColor,new_email:newEmail,new_password:newPassword,password})}); message.textContent=result.message; message.classList.add("success"); currentSession.nickname=result.nickname; currentSession.editor_color=result.editor_color; renderAccount(currentSession); } catch(e){ message.textContent=e.message; message.classList.add("error"); }
|
try { const selectedColor = document.querySelector("#profile-color").value; const newEmail = currentSession?.directory_managed ? null : (document.querySelector("#profile-email").value.trim() || null); const newPassword = currentSession?.directory_managed ? null : (document.querySelector("#profile-new-password").value || null); const password = currentSession?.directory_managed ? "" : document.querySelector("#profile-password").value; if ((newEmail || newPassword) && !password) throw new Error("Enter the current password to change e-mail or password."); const result = await api("/api/auth/profile", { method: "POST", headers: authHeaders(), body: JSON.stringify({ nickname: document.querySelector("#profile-nickname").value.trim(), editor_color: selectedColor, new_email: newEmail, new_password: newPassword, password }) }); message.textContent = result.message; message.classList.add("success"); currentSession.nickname = result.nickname; currentSession.editor_color = result.editor_color; renderAccount(currentSession); } catch (e) { message.textContent = e.message; message.classList.add("error"); }
|
||||||
});
|
});
|
||||||
document.querySelector("#profile-delete")?.addEventListener("click", async () => {
|
document.querySelector("#profile-delete")?.addEventListener("click", async () => {
|
||||||
const message=document.querySelector("#profile-message"); const password=document.querySelector("#profile-password").value;
|
const message = document.querySelector("#profile-message"); const password = document.querySelector("#profile-password").value;
|
||||||
message.classList.remove("success", "error");
|
message.classList.remove("success", "error");
|
||||||
if (!password) { message.textContent="Enter the current password first."; message.classList.add("error"); return; }
|
if (!password) { message.textContent = "Enter the current password first."; message.classList.add("error"); return; }
|
||||||
if (!confirm("Send an e-mail link to permanently delete this account?")) return;
|
if (!confirm("Send an e-mail link to permanently delete this account?")) return;
|
||||||
try { const result=await api("/api/auth/account/delete",{method:"POST",headers:authHeaders(),body:JSON.stringify({password})}); message.textContent=result.message; message.classList.add("success"); } catch(e){ message.textContent=e.message; message.classList.add("error"); }
|
try { const result = await api("/api/auth/account/delete", { method: "POST", headers: authHeaders(), body: JSON.stringify({ password }) }); message.textContent = result.message; message.classList.add("success"); } catch (e) { message.textContent = e.message; message.classList.add("error"); }
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#footer-resources")?.addEventListener("click", async () => { resourcesDialog.showModal(); await loadResources(); });
|
document.querySelector("#footer-resources")?.addEventListener("click", async () => { resourcesDialog.showModal(); await loadResources(); });
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function startNoteEditor(adapter) {
|
|||||||
let unreadChat = 0;
|
let unreadChat = 0;
|
||||||
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
|
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
|
||||||
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
|
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
|
||||||
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "";
|
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "";
|
||||||
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
|
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
|
||||||
let compactView = uiState.view === "preview" ? "preview" : "edit";
|
let compactView = uiState.view === "preview" ? "preview" : "edit";
|
||||||
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
|
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
|
||||||
@@ -223,7 +223,7 @@ export function startNoteEditor(adapter) {
|
|||||||
editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
|
editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
|
||||||
endpoints: adapter.fileEndpoints,
|
endpoints: adapter.fileEndpoints,
|
||||||
});
|
});
|
||||||
function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
|
function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
|
||||||
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
||||||
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
|
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
@@ -391,8 +391,14 @@ export function startNoteEditor(adapter) {
|
|||||||
toast("Global profile color restored");
|
toast("Global profile color restored");
|
||||||
});
|
});
|
||||||
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = adoptCurrentOwnerAliases(authorship, previousContent.length); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
|
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = adoptCurrentOwnerAliases(authorship, previousContent.length); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
|
||||||
|
passwordDialog.addEventListener("cancel", event => {
|
||||||
|
if (info?.protected && !resourceUnlocked) {
|
||||||
|
event.preventDefault();
|
||||||
|
document.querySelector("#open-password")?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); accessToken = result.access_token; setAccessToken(adapter.access.kind, adapter.access.key, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
|
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); accessToken = result.access_token; setAccessToken(adapter.access.kind, adapter.access.key, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
|
||||||
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
||||||
|
|
||||||
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } });
|
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } });
|
||||||
window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); });
|
window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); });
|
||||||
|
|||||||
Reference in New Issue
Block a user