Ldap support #2
+23
-3
@@ -85,6 +85,15 @@ AUTHORIZATION_TYPE=local
|
|||||||
# Shared LDAP / Active Directory connection settings
|
# Shared LDAP / Active Directory connection settings
|
||||||
# LDAP_URL=ldap://10.0.0.22:389
|
# LDAP_URL=ldap://10.0.0.22:389
|
||||||
# LDAP_STARTTLS=false
|
# LDAP_STARTTLS=false
|
||||||
|
|
||||||
|
# Verify the LDAP server certificate for LDAPS/StartTLS.
|
||||||
|
# Set false only for trusted internal/test servers with a self-signed certificate.
|
||||||
|
# This allows encrypted TLS without mounting a custom CA certificate.
|
||||||
|
LDAP_TLS_VERIFY=true
|
||||||
|
|
||||||
|
# Connection and LDAP operation timeouts.
|
||||||
|
LDAP_CONNECT_TIMEOUT_SECONDS=5
|
||||||
|
LDAP_OPERATION_TIMEOUT_SECONDS=10
|
||||||
# For LDAPS: LDAP_URL=ldaps://ldap.example.org:636 and LDAP_STARTTLS=false
|
# For LDAPS: LDAP_URL=ldaps://ldap.example.org:636 and LDAP_STARTTLS=false
|
||||||
# For StartTLS: LDAP_URL=ldap://ldap.example.org:389 and LDAP_STARTTLS=true
|
# For StartTLS: LDAP_URL=ldap://ldap.example.org:389 and LDAP_STARTTLS=true
|
||||||
# LDAP_BIND_DN=cn=admin,dc=example,dc=org
|
# LDAP_BIND_DN=cn=admin,dc=example,dc=org
|
||||||
@@ -94,12 +103,23 @@ AUTHORIZATION_TYPE=local
|
|||||||
# LDAP_EMAIL_ATTRIBUTE=mail
|
# LDAP_EMAIL_ATTRIBUTE=mail
|
||||||
# LDAP_DISPLAY_NAME_ATTRIBUTE=displayName
|
# LDAP_DISPLAY_NAME_ATTRIBUTE=displayName
|
||||||
|
|
||||||
|
# Stable directory identifier. Defaults:
|
||||||
|
# ldap: entryUUID
|
||||||
|
# ad: objectGUID
|
||||||
|
# LDAP_EXTERNAL_ID_ATTRIBUTE=entryUUID
|
||||||
|
|
||||||
|
# Reject directory users without a valid mail attribute.
|
||||||
|
LDAP_EMAIL_REQUIRED=true
|
||||||
|
|
||||||
|
# Link an existing account with the same e-mail on first directory login.
|
||||||
|
# Keep false unless you intentionally migrate existing local/legacy LDAP accounts.
|
||||||
|
LDAP_LINK_EXISTING_BY_EMAIL=false
|
||||||
|
|
||||||
# Optional overrides. Defaults depend on AUTHORIZATION_TYPE:
|
# Optional overrides. Defaults depend on AUTHORIZATION_TYPE:
|
||||||
# ldap: LDAP_USER_FILTER=(uid={username}), LDAP_USERNAME_ATTRIBUTE=uid
|
# ldap: LDAP_USER_FILTER=(uid={username}), LDAP_USERNAME_ATTRIBUTE=uid
|
||||||
# ad: LDAP_USER_FILTER=(|(sAMAccountName={username})(userPrincipalName={username}))
|
# ad: LDAP_USER_FILTER=(|(sAMAccountName={username})(userPrincipalName={username}))
|
||||||
# LDAP_USERNAME_ATTRIBUTE=sAMAccountName
|
# LDAP_USERNAME_ATTRIBUTE=sAMAccountName
|
||||||
# LDAP_USER_FILTER=(uid={username})
|
# LDAP_USER_FILTER=(uid={username})
|
||||||
# LDAP_USERNAME_ATTRIBUTE=uid
|
# LDAP_USERNAME_ATTRIBUTE=uid
|
||||||
# email auth: LDAP_EMAIL_ATTRIBUTE=mail
|
# To allow login by either username or e-mail:
|
||||||
#for both
|
# LDAP_USER_FILTER=(|(uid={username})(mail={username}))
|
||||||
#LDAP_USER_FILTER=(|(uid={username})(mail={username}))
|
|
||||||
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.0.31"
|
version = "0.0.32"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.0.31"
|
version = "0.0.32"
|
||||||
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"
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ Supported values:
|
|||||||
|
|
||||||
For `ldap` and `ad`, RustPad searches the directory with the service account, validates the password by binding as the user, and automatically provisions a local account. Existing sessions, ownership, sharing, and other account functions continue to use the existing `users` table. Local registration and guest nickname access are disabled.
|
For `ldap` and `ad`, RustPad searches the directory with the service account, validates the password by binding as the user, and automatically provisions a local account. Existing sessions, ownership, sharing, and other account functions continue to use the existing `users` table. Local registration and guest nickname access are disabled.
|
||||||
|
|
||||||
The nickname is generated as `LDAP_ORGANIZATION/displayName`, for example `example/Mateusz Testowy`. The directory e-mail is the stable local account identifier.
|
The nickname is generated as `LDAP_ORGANIZATION/displayName`, for example `example/Mateusz Testowy`. Directory accounts are stored using a stable `entryUUID` (LDAP) or `objectGUID` (AD), while e-mail, nickname, and DN are synchronized after every successful login.
|
||||||
|
|
||||||
### OpenLDAP
|
### OpenLDAP
|
||||||
|
|
||||||
@@ -218,7 +218,20 @@ LDAP_EMAIL_ATTRIBUTE=mail
|
|||||||
LDAP_DISPLAY_NAME_ATTRIBUTE=displayName
|
LDAP_DISPLAY_NAME_ATTRIBUTE=displayName
|
||||||
```
|
```
|
||||||
|
|
||||||
All LDAP attributes and filters can still be overridden explicitly. The LDAP/LDAPS server certificate must be trusted by the RustPad container.
|
All LDAP attributes and filters can still be overridden explicitly. By default TLS certificates are verified. For a trusted internal or test server using a self-signed certificate, `LDAP_TLS_VERIFY=false` keeps LDAPS/StartTLS encryption enabled without requiring a custom CA file. Do not disable verification on untrusted networks.
|
||||||
|
|
||||||
|
Additional directory options:
|
||||||
|
|
||||||
|
```env
|
||||||
|
LDAP_EXTERNAL_ID_ATTRIBUTE=entryUUID
|
||||||
|
LDAP_EMAIL_REQUIRED=true
|
||||||
|
LDAP_LINK_EXISTING_BY_EMAIL=false
|
||||||
|
LDAP_TLS_VERIFY=true
|
||||||
|
LDAP_CONNECT_TIMEOUT_SECONDS=5
|
||||||
|
LDAP_OPERATION_TIMEOUT_SECONDS=10
|
||||||
|
```
|
||||||
|
|
||||||
|
For Active Directory, the default external identifier is `objectGUID`. Set `LDAP_LINK_EXISTING_BY_EMAIL=true` only during an intentional migration of existing local or legacy LDAP accounts; otherwise an e-mail collision is rejected.
|
||||||
|
|
||||||
### Test LDAP on 10.87.2.6
|
### Test LDAP on 10.87.2.6
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN auth_provider VARCHAR(16) NOT NULL DEFAULT 'local',
|
||||||
|
ADD COLUMN external_id VARCHAR(512) NULL,
|
||||||
|
ADD COLUMN external_dn TEXT NULL,
|
||||||
|
ADD UNIQUE INDEX idx_users_external_identity (auth_provider, external_id);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local';
|
||||||
|
ALTER TABLE users ADD COLUMN external_id TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN external_dn TEXT;
|
||||||
|
CREATE UNIQUE INDEX idx_users_external_identity ON users(auth_provider, external_id) WHERE external_id IS NOT NULL;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local';
|
||||||
|
ALTER TABLE users ADD COLUMN external_id TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN external_dn TEXT;
|
||||||
|
CREATE UNIQUE INDEX idx_users_external_identity ON users(auth_provider, external_id) WHERE external_id IS NOT NULL;
|
||||||
+4
-1
@@ -392,7 +392,10 @@ fn error_response(
|
|||||||
asset_version: &str,
|
asset_version: &str,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let html = include_str!("../static/error.html")
|
let html = include_str!("../static/error.html")
|
||||||
.replace("__APP_STYLESHEET__", &assets::stylesheet_tag(asset_version, "styles"))
|
.replace(
|
||||||
|
"__APP_STYLESHEET__",
|
||||||
|
&assets::stylesheet_tag(asset_version, "styles"),
|
||||||
|
)
|
||||||
.replace("__ERROR_CODE__", &escape_html(code))
|
.replace("__ERROR_CODE__", &escape_html(code))
|
||||||
.replace("__ERROR_TITLE__", &escape_html(title))
|
.replace("__ERROR_TITLE__", &escape_html(title))
|
||||||
.replace("__ERROR_MESSAGE__", &escape_html(message))
|
.replace("__ERROR_MESSAGE__", &escape_html(message))
|
||||||
|
|||||||
+5
-1
@@ -60,7 +60,11 @@ pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
|
|||||||
AssetUrls::new(asset_version).stylesheet(name)
|
AssetUrls::new(asset_version).stylesheet(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn frontend_config(frontend_log_level: &str, upload_max_size_bytes: usize, external_auth: bool) -> String {
|
fn frontend_config(
|
||||||
|
frontend_log_level: &str,
|
||||||
|
upload_max_size_bytes: usize,
|
||||||
|
external_auth: bool,
|
||||||
|
) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}",uploadMaxSizeBytes:{},externalAuth:{}}});</script>"#,
|
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}",uploadMaxSizeBytes:{},externalAuth:{}}});</script>"#,
|
||||||
escape_js_string(frontend_log_level),
|
escape_js_string(frontend_log_level),
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use crate::{queries, state::SharedState};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
AuthError, MAX_NICKNAME, SessionResponse, User, create_session, find_user_by_email,
|
||||||
|
find_user_by_external_id, find_user_by_nickname, hash_password, normalize, random_token,
|
||||||
|
validate_email,
|
||||||
|
};
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use ldap3::{LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LdapConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub starttls: bool,
|
||||||
|
pub bind_dn: String,
|
||||||
|
pub bind_password: String,
|
||||||
|
pub base_dn: String,
|
||||||
|
pub user_filter: String,
|
||||||
|
pub username_attribute: String,
|
||||||
|
pub email_attribute: String,
|
||||||
|
pub display_name_attribute: String,
|
||||||
|
pub external_id_attribute: String,
|
||||||
|
pub organization: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub email_required: bool,
|
||||||
|
pub link_existing_by_email: bool,
|
||||||
|
pub tls_verify: bool,
|
||||||
|
pub connect_timeout_seconds: u64,
|
||||||
|
pub operation_timeout_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LdapIdentity {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub nickname: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub external_id: String,
|
||||||
|
pub external_dn: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authenticate(
|
||||||
|
config: &LdapConfig,
|
||||||
|
login: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<Option<LdapIdentity>, String> {
|
||||||
|
if login.trim().is_empty() || password.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = LdapConnSettings::new()
|
||||||
|
.set_starttls(config.starttls)
|
||||||
|
.set_no_tls_verify(!config.tls_verify);
|
||||||
|
let connect_timeout = Duration::from_secs(config.connect_timeout_seconds);
|
||||||
|
let operation_timeout = Duration::from_secs(config.operation_timeout_seconds);
|
||||||
|
let (connection, mut ldap) = timeout(
|
||||||
|
connect_timeout,
|
||||||
|
LdapConnAsync::with_settings(settings, &config.url),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "LDAP connection timed out".to_owned())?
|
||||||
|
.map_err(|error| format!("LDAP connection failed: {error}"))?;
|
||||||
|
ldap3::drive!(connection);
|
||||||
|
|
||||||
|
if !config.bind_dn.trim().is_empty() {
|
||||||
|
let result = timeout(
|
||||||
|
operation_timeout,
|
||||||
|
ldap.simple_bind(&config.bind_dn, &config.bind_password),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "LDAP service bind timed out".to_owned())?
|
||||||
|
.map_err(|error| format!("LDAP service bind failed: {error}"))?;
|
||||||
|
result
|
||||||
|
.success()
|
||||||
|
.map_err(|error| format!("LDAP service bind rejected: {error}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let escaped = escape_filter(login.trim());
|
||||||
|
let filter = config.user_filter.replace("{username}", &escaped);
|
||||||
|
let attributes = vec![
|
||||||
|
config.username_attribute.as_str(),
|
||||||
|
config.email_attribute.as_str(),
|
||||||
|
config.display_name_attribute.as_str(),
|
||||||
|
config.external_id_attribute.as_str(),
|
||||||
|
];
|
||||||
|
let (mut entries, _) = timeout(
|
||||||
|
operation_timeout,
|
||||||
|
ldap.search(&config.base_dn, Scope::Subtree, &filter, attributes.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "LDAP search timed out".to_owned())?
|
||||||
|
.map_err(|error| format!("LDAP search failed: {error}"))?
|
||||||
|
.success()
|
||||||
|
.map_err(|error| format!("LDAP search rejected: {error}"))?;
|
||||||
|
|
||||||
|
if entries.is_empty() && login.trim().contains('@') {
|
||||||
|
let email_filter = format!("({}={})", config.email_attribute, escaped);
|
||||||
|
let (email_entries, _) = timeout(
|
||||||
|
operation_timeout,
|
||||||
|
ldap.search(&config.base_dn, Scope::Subtree, &email_filter, attributes),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "LDAP e-mail search timed out".to_owned())?
|
||||||
|
.map_err(|error| format!("LDAP e-mail search failed: {error}"))?
|
||||||
|
.success()
|
||||||
|
.map_err(|error| format!("LDAP e-mail search rejected: {error}"))?;
|
||||||
|
entries = email_entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries.len() != 1 {
|
||||||
|
let _ = ldap.unbind().await;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = SearchEntry::construct(entries.into_iter().next().unwrap());
|
||||||
|
let user_dn = entry.dn.clone();
|
||||||
|
let username =
|
||||||
|
first_attr(&entry, &config.username_attribute).unwrap_or_else(|| login.trim().to_owned());
|
||||||
|
let email = first_attr(&entry, &config.email_attribute)
|
||||||
|
.filter(|value| value.contains('@'))
|
||||||
|
.or_else(|| {
|
||||||
|
(!config.email_required && login.trim().contains('@')).then(|| login.trim().to_owned())
|
||||||
|
});
|
||||||
|
if email.is_none() {
|
||||||
|
let _ = ldap.unbind().await;
|
||||||
|
return Err(format!(
|
||||||
|
"LDAP user is missing required e-mail attribute {}",
|
||||||
|
config.email_attribute
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let email = email.unwrap_or_default();
|
||||||
|
let external_id = first_attr_or_binary(&entry, &config.external_id_attribute)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"LDAP user is missing required external identifier attribute {}",
|
||||||
|
config.external_id_attribute
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let display_name = first_attr(&entry, &config.display_name_attribute)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| username.clone());
|
||||||
|
|
||||||
|
let result = timeout(operation_timeout, ldap.simple_bind(&user_dn, password))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "LDAP user bind timed out".to_owned())?
|
||||||
|
.map_err(|error| format!("LDAP user bind failed: {error}"))?;
|
||||||
|
if result.success().is_err() {
|
||||||
|
let _ = ldap.unbind().await;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let _ = ldap.unbind().await;
|
||||||
|
|
||||||
|
let organization = config.organization.trim();
|
||||||
|
let nickname = if organization.is_empty() {
|
||||||
|
display_name
|
||||||
|
} else {
|
||||||
|
format!("{organization}/{display_name}")
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(LdapIdentity {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
nickname,
|
||||||
|
provider: config.provider.clone(),
|
||||||
|
external_id,
|
||||||
|
external_dn: user_dn,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_attr(entry: &SearchEntry, name: &str) -> Option<String> {
|
||||||
|
entry
|
||||||
|
.attrs
|
||||||
|
.get(name)
|
||||||
|
.and_then(|values| values.first())
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_attr_or_binary(entry: &SearchEntry, name: &str) -> Option<String> {
|
||||||
|
first_attr(entry, name).or_else(|| {
|
||||||
|
entry
|
||||||
|
.bin_attrs
|
||||||
|
.get(name)
|
||||||
|
.and_then(|values| values.first())
|
||||||
|
.map(|value| {
|
||||||
|
let mut output = String::with_capacity(value.len() * 2);
|
||||||
|
for byte in value {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(&mut output, "{byte:02x}");
|
||||||
|
}
|
||||||
|
output
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_filter(value: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(value.len());
|
||||||
|
for byte in value.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'*' => result.push_str("\\2a"),
|
||||||
|
b'(' => result.push_str("\\28"),
|
||||||
|
b')' => result.push_str("\\29"),
|
||||||
|
b'\\' => result.push_str("\\5c"),
|
||||||
|
0 => result.push_str("\\00"),
|
||||||
|
value if value < 0x20 || value >= 0x7f => result.push_str(&format!("\\{value:02x}")),
|
||||||
|
value => result.push(value as char),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn login(
|
||||||
|
state: &SharedState,
|
||||||
|
login: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<SessionResponse, AuthError> {
|
||||||
|
let config = state
|
||||||
|
.ldap
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AuthError::internal("LDAP authentication is not configured."))?;
|
||||||
|
debug!(login = %login.trim(), "LDAP login requested");
|
||||||
|
let identity = authenticate(config, login, password)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
tracing::error!(error = %error, "LDAP authentication error");
|
||||||
|
AuthError::service_unavailable("The organization directory is currently unavailable.")
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| AuthError::unauthorized("Invalid organization login or password."))?;
|
||||||
|
let user = provision_ldap_user(state, identity).await?;
|
||||||
|
let session = create_session(state, &user).await?;
|
||||||
|
info!(user_id = user.id, nickname = %user.nickname, "LDAP login successful");
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn provision_ldap_user(
|
||||||
|
state: &SharedState,
|
||||||
|
identity: LdapIdentity,
|
||||||
|
) -> Result<User, AuthError> {
|
||||||
|
let email = validate_email(&identity.email)?;
|
||||||
|
if let Some(user) =
|
||||||
|
find_user_by_external_id(state, &identity.provider, &identity.external_id).await?
|
||||||
|
{
|
||||||
|
return sync_directory_user(state, user, &identity, &email).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(existing) = find_user_by_email(state, &email).await? {
|
||||||
|
let link_by_email = state
|
||||||
|
.ldap
|
||||||
|
.as_ref()
|
||||||
|
.map(|config| config.link_existing_by_email)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !link_by_email {
|
||||||
|
return Err(AuthError::conflict(
|
||||||
|
"This e-mail address already belongs to another account. Ask an administrator to link it or enable LDAP_LINK_EXISTING_BY_EMAIL.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return sync_directory_user(state, existing, &identity, &email).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nickname = available_directory_nickname(state, &identity.nickname, None).await?;
|
||||||
|
let password_hash = hash_password(&random_token())?;
|
||||||
|
let confirmed_at = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::AUTH_INSERT_DIRECTORY_USER,
|
||||||
|
))
|
||||||
|
.bind(&nickname)
|
||||||
|
.bind(normalize(&nickname))
|
||||||
|
.bind(&email)
|
||||||
|
.bind(normalize(&email))
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(Some(confirmed_at))
|
||||||
|
.bind(&identity.provider)
|
||||||
|
.bind(&identity.external_id)
|
||||||
|
.bind(&identity.external_dn)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
|
info!(ldap_username = %identity.username, nickname = %nickname, provider = %identity.provider, "provisioned directory user");
|
||||||
|
find_user_by_external_id(state, &identity.provider, &identity.external_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AuthError::internal("Failed to provision the organization account."))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_directory_user(
|
||||||
|
state: &SharedState,
|
||||||
|
user: User,
|
||||||
|
identity: &LdapIdentity,
|
||||||
|
email: &str,
|
||||||
|
) -> Result<User, AuthError> {
|
||||||
|
let nickname = available_directory_nickname(state, &identity.nickname, Some(user.id)).await?;
|
||||||
|
sqlx::query(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::AUTH_UPDATE_DIRECTORY_USER,
|
||||||
|
))
|
||||||
|
.bind(&nickname)
|
||||||
|
.bind(normalize(&nickname))
|
||||||
|
.bind(email)
|
||||||
|
.bind(normalize(email))
|
||||||
|
.bind(&identity.provider)
|
||||||
|
.bind(&identity.external_id)
|
||||||
|
.bind(&identity.external_dn)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?;
|
||||||
|
find_user_by_external_id(state, &identity.provider, &identity.external_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AuthError::internal("Failed to synchronize the organization account."))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn available_directory_nickname(
|
||||||
|
state: &SharedState,
|
||||||
|
requested: &str,
|
||||||
|
current_user_id: Option<i64>,
|
||||||
|
) -> Result<String, AuthError> {
|
||||||
|
let base = truncate_nickname(requested);
|
||||||
|
let mut nickname = base.clone();
|
||||||
|
let mut suffix = 1usize;
|
||||||
|
loop {
|
||||||
|
match find_user_by_nickname(state, &nickname).await? {
|
||||||
|
None => return Ok(nickname),
|
||||||
|
Some(user) if Some(user.id) == current_user_id => return Ok(nickname),
|
||||||
|
Some(_) => {
|
||||||
|
suffix += 1;
|
||||||
|
let marker = format!("-{suffix}");
|
||||||
|
nickname = format!(
|
||||||
|
"{}{}",
|
||||||
|
truncate_to_chars(&base, MAX_NICKNAME - marker.chars().count()),
|
||||||
|
marker
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_nickname(value: &str) -> String {
|
||||||
|
let value = value.trim();
|
||||||
|
let value = if value.is_empty() { "ldap-user" } else { value };
|
||||||
|
truncate_to_chars(value, MAX_NICKNAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_to_chars(value: &str, max: usize) -> String {
|
||||||
|
value.chars().take(max).collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::state::SharedState;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
AuthError, SessionResponse, create_session, email_domain, find_user_by_email, validate_email,
|
||||||
|
verify_password,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) async fn login(
|
||||||
|
state: &SharedState,
|
||||||
|
login: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<SessionResponse, AuthError> {
|
||||||
|
let email = validate_email(login)?;
|
||||||
|
debug!(email_domain = %email_domain(&email), "login requested");
|
||||||
|
let user = find_user_by_email(state, &email)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
|
||||||
|
if !verify_password(&user.password_hash, password) {
|
||||||
|
warn!(user_id = user.id, "login rejected: invalid password");
|
||||||
|
return Err(AuthError::unauthorized(
|
||||||
|
"Invalid e-mail address or password.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if state.account_confirmation_required && user.confirmed_at.is_none() {
|
||||||
|
return Err(AuthError::forbidden(
|
||||||
|
"Confirm the account using the link sent by e-mail before logging in.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let session = create_session(state, &user).await?;
|
||||||
|
info!(user_id = user.id, nickname = %user.nickname, "login successful");
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
pub(crate) mod ldap;
|
||||||
|
mod local;
|
||||||
|
|
||||||
use argon2::{
|
use argon2::{
|
||||||
Argon2,
|
Argon2,
|
||||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
@@ -162,7 +165,9 @@ pub async fn identity(
|
|||||||
Json(req): Json<IdentityRequest>,
|
Json(req): Json<IdentityRequest>,
|
||||||
) -> Result<Json<IdentityResponse>, AuthError> {
|
) -> Result<Json<IdentityResponse>, AuthError> {
|
||||||
if state.ldap.is_some() && req.session_token.is_none() {
|
if state.ldap.is_some() && req.session_token.is_none() {
|
||||||
return Err(AuthError::unauthorized("Log in with your organization account."));
|
return Err(AuthError::unauthorized(
|
||||||
|
"Log in with your organization account.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let nickname = validate_nickname(&req.nickname)?;
|
let nickname = validate_nickname(&req.nickname)?;
|
||||||
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
|
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
|
||||||
@@ -200,7 +205,9 @@ pub async fn register(
|
|||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
) -> Result<(StatusCode, Json<RegisterResponse>), AuthError> {
|
) -> Result<(StatusCode, Json<RegisterResponse>), AuthError> {
|
||||||
if state.ldap.is_some() {
|
if state.ldap.is_some() {
|
||||||
return Err(AuthError::forbidden("Local registration is disabled while LDAP authentication is enabled."));
|
return Err(AuthError::forbidden(
|
||||||
|
"Local registration is disabled while LDAP authentication is enabled.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !state.registration_enabled {
|
if !state.registration_enabled {
|
||||||
warn!("registration attempt rejected because registration is disabled");
|
warn!("registration attempt rejected because registration is disabled");
|
||||||
@@ -330,83 +337,15 @@ pub async fn login(
|
|||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
Json(req): Json<LoginRequest>,
|
Json(req): Json<LoginRequest>,
|
||||||
) -> Result<Json<SessionResponse>, AuthError> {
|
) -> Result<Json<SessionResponse>, AuthError> {
|
||||||
if let Some(config) = state.ldap.as_ref() {
|
if state.ldap.is_some() {
|
||||||
debug!(login = %req.email.trim(), "LDAP login requested");
|
ldap::login(&state, &req.email, &req.password)
|
||||||
let identity = crate::ldap_auth::authenticate(config, &req.email, &req.password)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map(Json)
|
||||||
tracing::error!(error = %error, "LDAP authentication error");
|
} else {
|
||||||
AuthError::service_unavailable("The organization directory is currently unavailable.")
|
local::login(&state, &req.email, &req.password)
|
||||||
})?
|
.await
|
||||||
.ok_or_else(|| AuthError::unauthorized("Invalid organization login or password."))?;
|
.map(Json)
|
||||||
let user = provision_ldap_user(&state, identity).await?;
|
|
||||||
let session = create_session(&state, &user).await?;
|
|
||||||
info!(user_id = user.id, nickname = %user.nickname, "LDAP login successful");
|
|
||||||
return Ok(Json(session));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let email = validate_email(&req.email)?;
|
|
||||||
debug!(email_domain = %email_domain(&email), "login requested");
|
|
||||||
let user = find_user_by_email(&state, &email)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
|
|
||||||
if !verify_password(&user.password_hash, &req.password) {
|
|
||||||
warn!(user_id = user.id, "login rejected: invalid password");
|
|
||||||
return Err(AuthError::unauthorized("Invalid e-mail address or password."));
|
|
||||||
}
|
|
||||||
if state.account_confirmation_required && user.confirmed_at.is_none() {
|
|
||||||
return Err(AuthError::forbidden(
|
|
||||||
"Confirm the account using the link sent by e-mail before logging in.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let session = create_session(&state, &user).await?;
|
|
||||||
info!(user_id = user.id, nickname = %user.nickname, "login successful");
|
|
||||||
Ok(Json(session))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn provision_ldap_user(
|
|
||||||
state: &SharedState,
|
|
||||||
identity: crate::ldap_auth::LdapIdentity,
|
|
||||||
) -> Result<User, AuthError> {
|
|
||||||
let email = validate_email(&identity.email)?;
|
|
||||||
if let Some(user) = find_user_by_email(state, &email).await? {
|
|
||||||
return Ok(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
let base = truncate_nickname(&identity.nickname);
|
|
||||||
let mut nickname = base.clone();
|
|
||||||
let mut suffix = 1usize;
|
|
||||||
while find_user_by_nickname(state, &nickname).await?.is_some() {
|
|
||||||
suffix += 1;
|
|
||||||
let marker = format!("-{suffix}");
|
|
||||||
nickname = format!("{}{}", truncate_to_chars(&base, MAX_NICKNAME - marker.chars().count()), marker);
|
|
||||||
}
|
|
||||||
let password_hash = hash_password(&random_token())?;
|
|
||||||
let confirmed_at = Utc::now().to_rfc3339();
|
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_USER))
|
|
||||||
.bind(&nickname)
|
|
||||||
.bind(normalize(&nickname))
|
|
||||||
.bind(&email)
|
|
||||||
.bind(normalize(&email))
|
|
||||||
.bind(password_hash)
|
|
||||||
.bind(Some(confirmed_at))
|
|
||||||
.execute(state.db.pool())
|
|
||||||
.await
|
|
||||||
.map_err(AuthError::database)?;
|
|
||||||
info!(ldap_username = %identity.username, nickname = %nickname, "provisioned LDAP user");
|
|
||||||
find_user_by_email(state, &email)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AuthError::internal("Failed to provision the organization account."))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_nickname(value: &str) -> String {
|
|
||||||
let value = value.trim();
|
|
||||||
let value = if value.is_empty() { "ldap-user" } else { value };
|
|
||||||
truncate_to_chars(value, MAX_NICKNAME)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_to_chars(value: &str, max: usize) -> String {
|
|
||||||
value.chars().take(max).collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn confirm_account(
|
pub async fn confirm_account(
|
||||||
@@ -1329,6 +1268,22 @@ async fn find_user_by_nickname(
|
|||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)
|
.map_err(AuthError::database)
|
||||||
}
|
}
|
||||||
|
async fn find_user_by_external_id(
|
||||||
|
state: &SharedState,
|
||||||
|
provider: &str,
|
||||||
|
external_id: &str,
|
||||||
|
) -> Result<Option<User>, AuthError> {
|
||||||
|
sqlx::query_as::<_, User>(queries::get(
|
||||||
|
state.db.kind(),
|
||||||
|
queries::AUTH_USER_BY_EXTERNAL_ID,
|
||||||
|
))
|
||||||
|
.bind(provider)
|
||||||
|
.bind(external_id)
|
||||||
|
.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))
|
||||||
+31
-3
@@ -51,7 +51,7 @@ pub struct Config {
|
|||||||
pub anonymous_access_token_ttl_days: i64,
|
pub anonymous_access_token_ttl_days: i64,
|
||||||
pub user_session_ttl_days: i64,
|
pub user_session_ttl_days: i64,
|
||||||
pub authorization_type: AuthorizationType,
|
pub authorization_type: AuthorizationType,
|
||||||
pub ldap: Option<crate::ldap_auth::LdapConfig>,
|
pub ldap: Option<crate::auth::ldap::LdapConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -101,17 +101,37 @@ impl Config {
|
|||||||
),
|
),
|
||||||
AuthorizationType::Local => unreachable!(),
|
AuthorizationType::Local => unreachable!(),
|
||||||
};
|
};
|
||||||
Some(crate::ldap_auth::LdapConfig {
|
Some(crate::auth::ldap::LdapConfig {
|
||||||
url: required_nonempty_env("LDAP_URL", &context)?,
|
url: required_nonempty_env("LDAP_URL", &context)?,
|
||||||
starttls: env_bool("LDAP_STARTTLS", false)?,
|
starttls: env_bool("LDAP_STARTTLS", false)?,
|
||||||
bind_dn: env::var("LDAP_BIND_DN").unwrap_or_default(),
|
bind_dn: env::var("LDAP_BIND_DN").unwrap_or_default(),
|
||||||
bind_password: env::var("LDAP_BIND_PASSWORD").unwrap_or_default(),
|
bind_password: env::var("LDAP_BIND_PASSWORD").unwrap_or_default(),
|
||||||
base_dn: required_nonempty_env("LDAP_BASE_DN", &context)?,
|
base_dn: required_nonempty_env("LDAP_BASE_DN", &context)?,
|
||||||
user_filter: env_var("LDAP_USER_FILTER", default_filter),
|
user_filter: env_var("LDAP_USER_FILTER", default_filter),
|
||||||
username_attribute: env_var("LDAP_USERNAME_ATTRIBUTE", default_username_attribute),
|
username_attribute: env_var(
|
||||||
|
"LDAP_USERNAME_ATTRIBUTE",
|
||||||
|
default_username_attribute,
|
||||||
|
),
|
||||||
email_attribute: env_var("LDAP_EMAIL_ATTRIBUTE", "mail"),
|
email_attribute: env_var("LDAP_EMAIL_ATTRIBUTE", "mail"),
|
||||||
display_name_attribute: env_var("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
|
display_name_attribute: env_var("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
|
||||||
|
external_id_attribute: env_var(
|
||||||
|
"LDAP_EXTERNAL_ID_ATTRIBUTE",
|
||||||
|
match authorization_type {
|
||||||
|
AuthorizationType::Ldap => "entryUUID",
|
||||||
|
AuthorizationType::Ad => "objectGUID",
|
||||||
|
AuthorizationType::Local => unreachable!(),
|
||||||
|
},
|
||||||
|
),
|
||||||
organization: env_var("LDAP_ORGANIZATION", "organization"),
|
organization: env_var("LDAP_ORGANIZATION", "organization"),
|
||||||
|
provider: authorization_type.as_str().to_owned(),
|
||||||
|
email_required: env_bool("LDAP_EMAIL_REQUIRED", true)?,
|
||||||
|
link_existing_by_email: env_bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?,
|
||||||
|
tls_verify: env_bool("LDAP_TLS_VERIFY", true)?,
|
||||||
|
connect_timeout_seconds: env_positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?,
|
||||||
|
operation_timeout_seconds: env_positive_u64(
|
||||||
|
"LDAP_OPERATION_TIMEOUT_SECONDS",
|
||||||
|
10,
|
||||||
|
)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -192,6 +212,14 @@ fn env_positive_i64(name: &str, default: i64) -> Result<i64, Box<dyn std::error:
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn env_positive_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
||||||
|
let value: u64 = env_var(name, &default.to_string()).parse()?;
|
||||||
|
if value == 0 {
|
||||||
|
return Err(format!("{name} must be greater than 0").into());
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
fn env_nonnegative_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
fn env_nonnegative_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
||||||
Ok(env_var(name, &default.to_string()).parse()?)
|
Ok(env_var(name, &default.to_string()).parse()?)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ mod app;
|
|||||||
mod assets;
|
mod assets;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod ldap_auth;
|
|
||||||
mod database;
|
mod database;
|
||||||
mod db;
|
mod db;
|
||||||
mod queries;
|
mod queries;
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str =
|
|||||||
|
|
||||||
// Authentication queries.
|
// Authentication queries.
|
||||||
pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)";
|
pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)";
|
||||||
|
pub const AUTH_INSERT_DIRECTORY_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at, auth_provider, external_id, external_dn) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
pub const AUTH_UPDATE_DIRECTORY_USER: &str = "UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, auth_provider = ?, external_id = ?, external_dn = ?, updated_at = ? WHERE id = ?";
|
||||||
|
pub const AUTH_USER_BY_EXTERNAL_ID: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE auth_provider = ? AND external_id = ?";
|
||||||
pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?";
|
pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?";
|
||||||
pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?";
|
pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?";
|
||||||
pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?";
|
pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?";
|
||||||
|
|||||||
+2
-2
@@ -63,7 +63,7 @@ pub struct AppState {
|
|||||||
pub frontend_log_level: String,
|
pub frontend_log_level: String,
|
||||||
pub anonymous_access_token_ttl_days: i64,
|
pub anonymous_access_token_ttl_days: i64,
|
||||||
pub user_session_ttl_days: i64,
|
pub user_session_ttl_days: i64,
|
||||||
pub ldap: Option<crate::ldap_auth::LdapConfig>,
|
pub ldap: Option<crate::auth::ldap::LdapConfig>,
|
||||||
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
|
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
|
||||||
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
|
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
|
||||||
next_connection_id: AtomicU64,
|
next_connection_id: AtomicU64,
|
||||||
@@ -83,7 +83,7 @@ impl AppState {
|
|||||||
frontend_log_level: String,
|
frontend_log_level: String,
|
||||||
anonymous_access_token_ttl_days: i64,
|
anonymous_access_token_ttl_days: i64,
|
||||||
user_session_ttl_days: i64,
|
user_session_ttl_days: i64,
|
||||||
ldap: Option<crate::ldap_auth::LdapConfig>,
|
ldap: Option<crate::auth::ldap::LdapConfig>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
db,
|
db,
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ async function loadResources() {
|
|||||||
const sharedLabel = !item.owned ? `<span class="resource-shared-badge">Shared by ${escapeHtml(item.shared_by || "another user")}</span>` : "";
|
const sharedLabel = !item.owned ? `<span class="resource-shared-badge">Shared by ${escapeHtml(item.shared_by || "another user")}</span>` : "";
|
||||||
const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only";
|
const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only";
|
||||||
row.classList.toggle("resource-row--shared", !Boolean(item.owned));
|
row.classList.toggle("resource-row--shared", !Boolean(item.owned));
|
||||||
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${item.url}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button type="button" data-share>Share</button><button type="button" data-password>Change password</button><button type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
|
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${item.url}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button type="button" data-share>Share</button><button type="button" data-password>Change password</button><button class="danger-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
|
||||||
|
|
||||||
const inline = row.querySelector("[data-inline]");
|
const inline = row.querySelector("[data-inline]");
|
||||||
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
|
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
|
||||||
|
|||||||
Reference in New Issue
Block a user