Compare commits
7
Commits
cc37fdde9a
...
0052dff119
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0052dff119 | ||
|
|
3bdca88144 | ||
|
|
4b18429945 | ||
|
|
d9b2297eca | ||
|
|
b4282c2b4b | ||
|
|
8554fbd6e5 | ||
|
|
550bb7f0eb |
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS webdav_tokens_user_tenant_idx;
|
||||||
|
DROP INDEX IF EXISTS webdav_tokens_token_prefix_key;
|
||||||
|
DROP TABLE IF EXISTS webdav_tokens;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE webdav_tokens (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix);
|
||||||
|
CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE webdav_tokens
|
||||||
|
ADD COLUMN scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE webdav_tokens
|
||||||
|
DROP COLUMN IF EXISTS scopes;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
|
pub mod webdav_tokens;
|
||||||
|
|
||||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||||
use axum_extra::headers::{authorization::Bearer, Authorization};
|
use axum_extra::headers::{authorization::Bearer, Authorization};
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
use argon2::{
|
||||||
|
password_hash::{PasswordHasher, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::AppError,
|
||||||
|
models::{NewWebdavToken, WebdavToken},
|
||||||
|
schema::webdav_tokens,
|
||||||
|
state::PgPooledConnection,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN_PREFIX_LENGTH: usize = 12;
|
||||||
|
const TOKEN_SECRET_LENGTH: usize = 32;
|
||||||
|
|
||||||
|
pub struct IssuedWebdavToken {
|
||||||
|
pub token: String,
|
||||||
|
pub record: WebdavToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_webdav_token(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
label: Option<String>,
|
||||||
|
expires_at: Option<NaiveDateTime>,
|
||||||
|
) -> Result<IssuedWebdavToken, AppError> {
|
||||||
|
let raw_secret = generate_secret()?;
|
||||||
|
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
||||||
|
let token_hash = hash_secret(&raw_secret)?;
|
||||||
|
let new_token = NewWebdavToken {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id,
|
||||||
|
tenant_id,
|
||||||
|
token_prefix,
|
||||||
|
token_hash,
|
||||||
|
label,
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
let record = diesel::insert_into(webdav_tokens::table)
|
||||||
|
.values(&new_token)
|
||||||
|
.get_result::<WebdavToken>(conn)?;
|
||||||
|
|
||||||
|
Ok(IssuedWebdavToken {
|
||||||
|
token: raw_secret,
|
||||||
|
record,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_webdav_tokens(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<WebdavToken>, AppError> {
|
||||||
|
let mut query = webdav_tokens::table
|
||||||
|
.filter(webdav_tokens::user_id.eq(user_id))
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
if let Some(tenant_id) = tenant_id {
|
||||||
|
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens = query
|
||||||
|
.order(webdav_tokens::created_at.asc())
|
||||||
|
.load::<WebdavToken>(conn)?;
|
||||||
|
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_active_token_by_secret(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Option<Uuid>,
|
||||||
|
secret: &str,
|
||||||
|
) -> Result<Option<WebdavToken>, AppError> {
|
||||||
|
if secret.len() < TOKEN_PREFIX_LENGTH {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
|
||||||
|
let mut query = webdav_tokens::table
|
||||||
|
.filter(webdav_tokens::user_id.eq(user_id))
|
||||||
|
.filter(webdav_tokens::token_prefix.eq(prefix))
|
||||||
|
.filter(webdav_tokens::revoked_at.is_null())
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
query = query.filter(
|
||||||
|
webdav_tokens::expires_at
|
||||||
|
.is_null()
|
||||||
|
.or(webdav_tokens::expires_at.gt(now)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(tenant_id) = tenant_id {
|
||||||
|
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = query.load::<WebdavToken>(conn)?;
|
||||||
|
|
||||||
|
for token in candidates {
|
||||||
|
if verify_token_secret(secret, &token.token_hash)? {
|
||||||
|
return Ok(Some(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke_webdav_token(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
token_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let affected = diesel::update(
|
||||||
|
webdav_tokens::table
|
||||||
|
.filter(webdav_tokens::id.eq(token_id))
|
||||||
|
.filter(webdav_tokens::user_id.eq(user_id)),
|
||||||
|
)
|
||||||
|
.set(webdav_tokens::revoked_at.eq(Utc::now().naive_utc()))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
return Err(AppError::not_found());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn touch_webdav_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> {
|
||||||
|
diesel::update(webdav_tokens::table.filter(webdav_tokens::id.eq(token_id)))
|
||||||
|
.set(webdav_tokens::last_used_at.eq(Utc::now().naive_utc()))
|
||||||
|
.execute(conn)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
|
||||||
|
crate::auth::password::verify_password(secret, token_hash)
|
||||||
|
.map_err(|err| AppError::internal(format!("failed to verify token: {err}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_secret() -> Result<String, AppError> {
|
||||||
|
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
||||||
|
OsRng
|
||||||
|
.try_fill_bytes(&mut buffer)
|
||||||
|
.map_err(|err| AppError::internal(format!("failed to generate token: {err}")))?;
|
||||||
|
Ok(hex::encode(buffer))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_secret(secret: &str) -> Result<String, AppError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(secret.as_bytes(), &salt)
|
||||||
|
.map_err(|err| AppError::internal(format!("failed to hash token: {err}")))?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _ensure_constants() {
|
||||||
|
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_secret_has_expected_length() {
|
||||||
|
let secret = generate_secret().unwrap();
|
||||||
|
assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_and_verify_secret_round_trip() {
|
||||||
|
let secret = generate_secret().unwrap();
|
||||||
|
let hash = hash_secret(&secret).unwrap();
|
||||||
|
assert!(verify_token_secret(&secret, &hash).unwrap());
|
||||||
|
assert!(!verify_token_secret("wrong", &hash).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,19 @@ pub fn to_asset_object_response(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn delete_asset(state: &AppState, tenant_id: Uuid, asset_id: Uuid) -> AppResult<()> {
|
||||||
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||||
|
|
||||||
|
diesel::delete(
|
||||||
|
document_assets::table
|
||||||
|
.filter(document_assets::id.eq(asset_id))
|
||||||
|
.filter(document_assets::tenant_id.eq(tenant_id)),
|
||||||
|
)
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_asset_responses(
|
pub async fn load_asset_responses(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
|
|||||||
@@ -58,6 +58,35 @@ pub struct NewUser {
|
|||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
|
#[diesel(table_name = webdav_tokens)]
|
||||||
|
#[diesel(belongs_to(User))]
|
||||||
|
#[diesel(belongs_to(Tenant))]
|
||||||
|
pub struct WebdavToken {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub token_prefix: String,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub last_used_at: Option<NaiveDateTime>,
|
||||||
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
|
pub revoked_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = webdav_tokens)]
|
||||||
|
pub struct NewWebdavToken {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub token_prefix: String,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
#[diesel(table_name = folders)]
|
#[diesel(table_name = folders)]
|
||||||
pub struct Folder {
|
pub struct Folder {
|
||||||
|
|||||||
+80
-9
@@ -11,6 +11,7 @@ use uuid::Uuid;
|
|||||||
doc::refresh,
|
doc::refresh,
|
||||||
doc::logout,
|
doc::logout,
|
||||||
doc::me,
|
doc::me,
|
||||||
|
doc::list_tenants,
|
||||||
doc::select_tenant,
|
doc::select_tenant,
|
||||||
doc::list_documents,
|
doc::list_documents,
|
||||||
doc::check_document,
|
doc::check_document,
|
||||||
@@ -45,14 +46,18 @@ use uuid::Uuid;
|
|||||||
doc::create_correspondent,
|
doc::create_correspondent,
|
||||||
doc::update_correspondent,
|
doc::update_correspondent,
|
||||||
doc::delete_correspondent,
|
doc::delete_correspondent,
|
||||||
|
doc::list_webdav_tokens,
|
||||||
|
doc::create_webdav_token,
|
||||||
|
doc::delete_webdav_token,
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
schemas::LoginRequest,
|
schemas::LoginRequest,
|
||||||
schemas::AccessTokenResponse,
|
schemas::AccessTokenResponse,
|
||||||
schemas::TenantSummary,
|
schemas::TenantSnippet,
|
||||||
schemas::TenantSelectionResponse,
|
schemas::TenantSelectionResponse,
|
||||||
schemas::TenantSelectionRequest,
|
schemas::TenantSelectionRequest,
|
||||||
|
schemas::TenantListResponse,
|
||||||
schemas::LoginResponseVariants,
|
schemas::LoginResponseVariants,
|
||||||
schemas::DocumentResponse,
|
schemas::DocumentResponse,
|
||||||
schemas::DocumentDetailResponse,
|
schemas::DocumentDetailResponse,
|
||||||
@@ -98,6 +103,9 @@ use uuid::Uuid;
|
|||||||
schemas::CorrespondentCatalogEntry,
|
schemas::CorrespondentCatalogEntry,
|
||||||
schemas::CreateCorrespondentRequest,
|
schemas::CreateCorrespondentRequest,
|
||||||
schemas::UpdateCorrespondentRequest,
|
schemas::UpdateCorrespondentRequest,
|
||||||
|
schemas::WebdavTokenResponse,
|
||||||
|
schemas::WebdavTokenCreatedResponse,
|
||||||
|
schemas::CreateWebdavTokenRequest,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
@@ -107,7 +115,8 @@ use uuid::Uuid;
|
|||||||
(name = "Assets", description = "Document assets"),
|
(name = "Assets", description = "Document assets"),
|
||||||
(name = "Folders", description = "Folder management"),
|
(name = "Folders", description = "Folder management"),
|
||||||
(name = "Tags", description = "Tag catalog"),
|
(name = "Tags", description = "Tag catalog"),
|
||||||
(name = "Correspondents", description = "Correspondent catalog")
|
(name = "Correspondents", description = "Correspondent catalog"),
|
||||||
|
(name = "Profile", description = "User profile and WebDAV tokens")
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub struct ApiDoc;
|
pub struct ApiDoc;
|
||||||
@@ -169,6 +178,14 @@ mod doc {
|
|||||||
)]
|
)]
|
||||||
pub(super) fn me() {}
|
pub(super) fn me() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/auth/tenants",
|
||||||
|
responses((status = 200, description = "Available tenants", body = TenantListResponse)),
|
||||||
|
tag = "Auth"
|
||||||
|
)]
|
||||||
|
pub(super) fn list_tenants() {}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/auth/select-tenant",
|
path = "/api/auth/select-tenant",
|
||||||
@@ -529,6 +546,32 @@ mod doc {
|
|||||||
tag = "Correspondents"
|
tag = "Correspondents"
|
||||||
)]
|
)]
|
||||||
pub(super) fn delete_correspondent() {}
|
pub(super) fn delete_correspondent() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/profile/webdav-tokens",
|
||||||
|
responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])),
|
||||||
|
tag = "Profile"
|
||||||
|
)]
|
||||||
|
pub(super) fn list_webdav_tokens() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/profile/webdav-tokens",
|
||||||
|
request_body = CreateWebdavTokenRequest,
|
||||||
|
responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)),
|
||||||
|
tag = "Profile"
|
||||||
|
)]
|
||||||
|
pub(super) fn create_webdav_token() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/profile/webdav-tokens/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
||||||
|
responses((status = 204, description = "WebDAV token revoked")),
|
||||||
|
tag = "Profile"
|
||||||
|
)]
|
||||||
|
pub(super) fn delete_webdav_token() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -562,12 +605,6 @@ pub mod schemas {
|
|||||||
pub tenant: TenantSnippet,
|
pub tenant: TenantSnippet,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
|
||||||
pub struct TenantSummary {
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub slug: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct TenantSnippet {
|
pub struct TenantSnippet {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -577,7 +614,7 @@ pub mod schemas {
|
|||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct TenantSelectionResponse {
|
pub struct TenantSelectionResponse {
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub tenants: Vec<TenantSummary>,
|
pub tenants: Vec<TenantSnippet>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -585,6 +622,11 @@ pub mod schemas {
|
|||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct TenantListResponse {
|
||||||
|
pub tenants: Vec<TenantSnippet>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum LoginResponseVariants {
|
pub enum LoginResponseVariants {
|
||||||
@@ -1001,4 +1043,33 @@ pub mod schemas {
|
|||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub metadata: Option<Value>,
|
pub metadata: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct WebdavTokenResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct WebdavTokenCreatedResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub token_info: WebdavTokenResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CreateWebdavTokenRequest {
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
#[schema(nullable, example = "2025-01-01T00:00:00Z")]
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,6 @@ pub struct LoginResponse {
|
|||||||
pub tenant: TenantSnippet,
|
pub tenant: TenantSnippet,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct TenantSummary {
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub slug: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct TenantSnippet {
|
pub struct TenantSnippet {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -61,7 +55,7 @@ pub struct TenantSnippet {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct TenantSelectionResponse {
|
pub struct TenantSelectionResponse {
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub tenants: Vec<TenantSummary>,
|
pub tenants: Vec<TenantSnippet>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -132,8 +126,8 @@ pub async fn login(
|
|||||||
|
|
||||||
let tenants = memberships
|
let tenants = memberships
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(_, tenant)| TenantSummary {
|
.map(|(_, tenant)| TenantSnippet {
|
||||||
tenant_id: tenant.id,
|
id: tenant.id,
|
||||||
slug: tenant.slug,
|
slug: tenant.slug,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod correspondents;
|
|||||||
pub mod documents;
|
pub mod documents;
|
||||||
pub mod folders;
|
pub mod folders;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod profile;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
pub mod webdav;
|
pub mod webdav;
|
||||||
|
|
||||||
@@ -130,6 +131,13 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.delete(correspondents::delete_correspondent),
|
.delete(correspondents::delete_correspondent),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let profile_routes = Router::new()
|
||||||
|
.route(
|
||||||
|
"/webdav-tokens",
|
||||||
|
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
|
||||||
|
)
|
||||||
|
.route("/webdav-tokens/:id", delete(profile::delete_webdav_token));
|
||||||
|
|
||||||
let protected_state = state.clone();
|
let protected_state = state.clone();
|
||||||
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
||||||
|
|
||||||
@@ -138,6 +146,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.nest("/api/folders", folders_routes)
|
.nest("/api/folders", folders_routes)
|
||||||
.nest("/api/tags", tags_routes)
|
.nest("/api/tags", tags_routes)
|
||||||
.nest("/api/correspondents", correspondents_routes)
|
.nest("/api/correspondents", correspondents_routes)
|
||||||
|
.nest("/api/profile", profile_routes)
|
||||||
.nest("/api/assets", assets_routes)
|
.nest("/api/assets", assets_routes)
|
||||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use axum::{extract::Path, http::StatusCode, Json};
|
||||||
|
use chrono::{DateTime, NaiveDateTime};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::{
|
||||||
|
webdav_tokens::{
|
||||||
|
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
||||||
|
revoke_webdav_token as revoke_token,
|
||||||
|
},
|
||||||
|
TenantScopedConn,
|
||||||
|
};
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::models::WebdavToken;
|
||||||
|
use crate::utils::{db::no_content, time::to_iso};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct WebdavTokenResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct WebdavTokenCreatedResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub token_info: WebdavTokenResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateWebdavTokenRequest {
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_webdav_tokens(
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
user_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
) -> AppResult<Json<Vec<WebdavTokenResponse>>> {
|
||||||
|
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
|
||||||
|
let responses = tokens.into_iter().map(webdav_token_to_response).collect();
|
||||||
|
Ok(Json(responses))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_webdav_token(
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
user_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
Json(payload): Json<CreateWebdavTokenRequest>,
|
||||||
|
) -> AppResult<(StatusCode, Json<WebdavTokenCreatedResponse>)> {
|
||||||
|
let expires_at = match payload.expires_at {
|
||||||
|
Some(ref value) => Some(parse_timestamp(value)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let issued = issue_token(
|
||||||
|
&mut conn,
|
||||||
|
user_id,
|
||||||
|
tenant_id,
|
||||||
|
payload.label.clone(),
|
||||||
|
expires_at,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let response = WebdavTokenCreatedResponse {
|
||||||
|
token: issued.token,
|
||||||
|
token_info: webdav_token_to_response(issued.record),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_webdav_token(
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn, user_id, ..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
Path(token_id): Path<Uuid>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
revoke_token(&mut conn, token_id, user_id)?;
|
||||||
|
no_content()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
|
||||||
|
WebdavTokenResponse {
|
||||||
|
id: token.id,
|
||||||
|
tenant_id: token.tenant_id,
|
||||||
|
label: token.label,
|
||||||
|
created_at: to_iso(token.created_at),
|
||||||
|
last_used_at: token.last_used_at.map(to_iso),
|
||||||
|
expires_at: token.expires_at.map(to_iso),
|
||||||
|
revoked_at: token.revoked_at.map(to_iso),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
|
||||||
|
let dt = DateTime::parse_from_rfc3339(value)
|
||||||
|
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
|
||||||
|
Ok(dt.naive_utc())
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use axum::Router;
|
|||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use diesel::OptionalExtension;
|
||||||
use diesel::PgConnection;
|
use diesel::PgConnection;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
||||||
@@ -15,7 +16,7 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
|||||||
use quick_xml::Writer;
|
use quick_xml::Writer;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::password;
|
use crate::auth::webdav_tokens::{find_active_token_by_secret, touch_webdav_token};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::{Document, DocumentVersion, Folder, User};
|
use crate::models::{Document, DocumentVersion, Folder, User};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
@@ -29,17 +30,11 @@ use crate::utils::{http::inline_content_disposition, time::to_http_date};
|
|||||||
const REALM: &str = "Papercrate WebDAV";
|
const REALM: &str = "Papercrate WebDAV";
|
||||||
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct TenantEntry {
|
|
||||||
tenant_id: Uuid,
|
|
||||||
slug: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct WebDavContext {
|
struct WebDavContext {
|
||||||
|
tenant_id: Uuid,
|
||||||
_user_id: Uuid,
|
_user_id: Uuid,
|
||||||
_username: String,
|
_username: String,
|
||||||
tenants: Vec<TenantEntry>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_router() -> Router<AppState> {
|
pub fn create_router() -> Router<AppState> {
|
||||||
@@ -89,35 +84,20 @@ async fn handle_propfind(
|
|||||||
|
|
||||||
let segments = parse_segments(path)?;
|
let segments = parse_segments(path)?;
|
||||||
|
|
||||||
let resources = if segments.is_empty() {
|
let tenant_id = context.tenant_id;
|
||||||
build_account_root_resources(&context.tenants, depth)
|
|
||||||
} else {
|
|
||||||
let (requested_slug, remainder) = segments.split_first().unwrap();
|
|
||||||
let tenant_entry = match context
|
|
||||||
.tenants
|
|
||||||
.iter()
|
|
||||||
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
|
|
||||||
{
|
|
||||||
Some(entry) => TenantEntry {
|
|
||||||
tenant_id: entry.tenant_id,
|
|
||||||
slug: entry.slug.clone(),
|
|
||||||
},
|
|
||||||
None => return Ok(not_found_response()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
|
let resources = if segments.is_empty() {
|
||||||
|
let contents = fetch_folder_contents(state, tenant_id, None)?;
|
||||||
|
build_resources_for_folder(None, &[], &contents, depth)
|
||||||
|
} else {
|
||||||
|
let resolution = match resolve_path(state, tenant_id, &segments)? {
|
||||||
Some(resolved) => resolved,
|
Some(resolved) => resolved,
|
||||||
None => return Ok(not_found_response()),
|
None => return Ok(not_found_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
match resolution {
|
match resolution {
|
||||||
ResolvedPath::TenantRoot { chain } => {
|
|
||||||
let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?;
|
|
||||||
build_resources_for_folder(None, &chain, &contents, depth)
|
|
||||||
}
|
|
||||||
ResolvedPath::Folder { folder, chain } => {
|
ResolvedPath::Folder { folder, chain } => {
|
||||||
let contents =
|
let contents = fetch_folder_contents(state, tenant_id, Some(folder.id))?;
|
||||||
fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?;
|
|
||||||
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
||||||
}
|
}
|
||||||
ResolvedPath::Document {
|
ResolvedPath::Document {
|
||||||
@@ -151,29 +131,13 @@ async fn handle_get_or_head(
|
|||||||
None => return Ok(unauthorized_response()),
|
None => return Ok(unauthorized_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let tenant_id = context.tenant_id;
|
||||||
let segments = parse_segments(path)?;
|
let segments = parse_segments(path)?;
|
||||||
let (requested_slug, remainder) = match segments.split_first() {
|
if segments.is_empty() {
|
||||||
Some(values) => values,
|
|
||||||
None => return Ok(method_not_allowed()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let tenant_entry = match context
|
|
||||||
.tenants
|
|
||||||
.iter()
|
|
||||||
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
|
|
||||||
{
|
|
||||||
Some(entry) => TenantEntry {
|
|
||||||
tenant_id: entry.tenant_id,
|
|
||||||
slug: entry.slug.clone(),
|
|
||||||
},
|
|
||||||
None => return Ok(not_found_response()),
|
|
||||||
};
|
|
||||||
|
|
||||||
if remainder.is_empty() {
|
|
||||||
return Ok(method_not_allowed());
|
return Ok(method_not_allowed());
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
|
let resolution = match resolve_path(state, tenant_id, &segments)? {
|
||||||
Some(resolved) => resolved,
|
Some(resolved) => resolved,
|
||||||
None => return Ok(not_found_response()),
|
None => return Ok(not_found_response()),
|
||||||
};
|
};
|
||||||
@@ -458,8 +422,8 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (username, password) = match credential_str.split_once(':') {
|
let (username, secret) = match credential_str.split_once(':') {
|
||||||
Some((username, password)) if !username.is_empty() => (username, password),
|
Some((username, secret)) if !username.is_empty() => (username, secret),
|
||||||
_ => return Ok(None),
|
_ => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -478,35 +442,46 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
Err(err) => return Err(AppError::from(err)),
|
Err(err) => return Err(AppError::from(err)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let valid = password::verify_password(password, &user.password_hash)
|
let token = match find_active_token_by_secret(&mut conn, user.id, None, secret)? {
|
||||||
.map_err(|_| AppError::internal("failed to verify password"))?;
|
Some(token) => token,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(%username, "webdav token invalid or expired");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if !valid {
|
let tenant_row = memberships_dsl::user_memberships
|
||||||
tracing::warn!(%username, "webdav password invalid");
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships
|
|
||||||
.inner_join(tenant_dsl::tenants)
|
.inner_join(tenant_dsl::tenants)
|
||||||
.filter(memberships_dsl::user_id.eq(user.id))
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
|
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||||
.select((tenant_dsl::id, tenant_dsl::slug))
|
.select((tenant_dsl::id, tenant_dsl::slug))
|
||||||
.load(&mut conn)?;
|
.first::<(Uuid, String)>(&mut conn)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
if tenant_rows.is_empty() {
|
let (tenant_id, _slug) = match tenant_row {
|
||||||
tracing::warn!(%username, "webdav user has no tenant memberships");
|
Some(row) => row,
|
||||||
return Ok(None);
|
None => {
|
||||||
}
|
tracing::warn!(
|
||||||
|
%username,
|
||||||
|
tenant_id = %token.tenant_id,
|
||||||
|
"webdav token tenant membership missing"
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let tenants: Vec<TenantEntry> = tenant_rows
|
touch_webdav_token(&mut conn, token.id)?;
|
||||||
.into_iter()
|
|
||||||
.map(|(tenant_id, slug)| TenantEntry { tenant_id, slug })
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success");
|
tracing::debug!(
|
||||||
|
%username,
|
||||||
|
tenant_id = %tenant_id,
|
||||||
|
token_id = %token.id,
|
||||||
|
"webdav token login success"
|
||||||
|
);
|
||||||
Ok(Some(WebDavContext {
|
Ok(Some(WebDavContext {
|
||||||
|
tenant_id,
|
||||||
_user_id: user.id,
|
_user_id: user.id,
|
||||||
_username: user.username,
|
_username: user.username,
|
||||||
tenants,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,37 +539,6 @@ fn build_resources_for_folder(
|
|||||||
resources
|
resources
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec<DavResource> {
|
|
||||||
let mut resources = Vec::new();
|
|
||||||
|
|
||||||
resources.push(DavResource {
|
|
||||||
href: "/".to_string(),
|
|
||||||
display_name: "/".to_string(),
|
|
||||||
is_collection: true,
|
|
||||||
content_length: None,
|
|
||||||
content_type: None,
|
|
||||||
last_modified: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
if depth == 0 {
|
|
||||||
return resources;
|
|
||||||
}
|
|
||||||
|
|
||||||
for tenant in tenants {
|
|
||||||
let href = build_href(&[tenant.slug.clone()], true);
|
|
||||||
resources.push(DavResource {
|
|
||||||
href,
|
|
||||||
display_name: tenant.slug.clone(),
|
|
||||||
is_collection: true,
|
|
||||||
content_length: None,
|
|
||||||
content_type: None,
|
|
||||||
last_modified: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resources
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_resources_for_document(
|
fn build_resources_for_document(
|
||||||
chain: &[String],
|
chain: &[String],
|
||||||
document: &Document,
|
document: &Document,
|
||||||
@@ -717,9 +661,6 @@ struct DavResource {
|
|||||||
last_modified: Option<String>,
|
last_modified: Option<String>,
|
||||||
}
|
}
|
||||||
enum ResolvedPath {
|
enum ResolvedPath {
|
||||||
TenantRoot {
|
|
||||||
chain: Vec<String>,
|
|
||||||
},
|
|
||||||
Folder {
|
Folder {
|
||||||
folder: Folder,
|
folder: Folder,
|
||||||
chain: Vec<String>,
|
chain: Vec<String>,
|
||||||
@@ -733,23 +674,18 @@ enum ResolvedPath {
|
|||||||
|
|
||||||
fn resolve_path(
|
fn resolve_path(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
tenant: &TenantEntry,
|
tenant_id: Uuid,
|
||||||
segments: &[String],
|
segments: &[String],
|
||||||
) -> AppResult<Option<ResolvedPath>> {
|
) -> AppResult<Option<ResolvedPath>> {
|
||||||
let mut conn = state.db_for_tenant(tenant.tenant_id)?;
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||||
let mut parent_id: Option<Uuid> = None;
|
let mut parent_id: Option<Uuid> = None;
|
||||||
let mut chain: Vec<String> = vec![tenant.slug.clone()];
|
let mut chain: Vec<String> = Vec::new();
|
||||||
let mut current_folder: Option<Folder> = None;
|
let mut current_folder: Option<Folder> = None;
|
||||||
|
|
||||||
if segments.is_empty() {
|
|
||||||
return Ok(Some(ResolvedPath::TenantRoot { chain }));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (index, segment) in segments.iter().enumerate() {
|
for (index, segment) in segments.iter().enumerate() {
|
||||||
let is_last = index == segments.len() - 1;
|
let is_last = index == segments.len() - 1;
|
||||||
|
|
||||||
if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)?
|
if let Some(folder) = find_folder_by_name(&mut conn, tenant_id, parent_id, segment)? {
|
||||||
{
|
|
||||||
chain.push(folder.name.clone());
|
chain.push(folder.name.clone());
|
||||||
if is_last {
|
if is_last {
|
||||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||||
@@ -761,7 +697,7 @@ fn resolve_path(
|
|||||||
|
|
||||||
if is_last {
|
if is_last {
|
||||||
if let Some((document, version)) =
|
if let Some((document, version)) =
|
||||||
find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)?
|
find_document_by_filename(&mut conn, tenant_id, parent_id, segment)?
|
||||||
{
|
{
|
||||||
chain.push(document.filename.clone());
|
chain.push(document.filename.clone());
|
||||||
return Ok(Some(ResolvedPath::Document {
|
return Ok(Some(ResolvedPath::Document {
|
||||||
@@ -773,7 +709,7 @@ fn resolve_path(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(uuid) = Uuid::parse_str(segment) {
|
if let Ok(uuid) = Uuid::parse_str(segment) {
|
||||||
if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? {
|
if let Some(folder) = find_folder_by_id(&mut conn, tenant_id, uuid)? {
|
||||||
if folder.parent_id != parent_id {
|
if folder.parent_id != parent_id {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -786,9 +722,7 @@ fn resolve_path(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((document, version)) =
|
if let Some((document, version)) = find_document_by_id(&mut conn, tenant_id, uuid)? {
|
||||||
find_document_by_id(&mut conn, tenant.tenant_id, uuid)?
|
|
||||||
{
|
|
||||||
if document.folder_id != parent_id {
|
if document.folder_id != parent_id {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,21 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
webdav_tokens (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
user_id -> Uuid,
|
||||||
|
tenant_id -> Uuid,
|
||||||
|
token_prefix -> Text,
|
||||||
|
token_hash -> Text,
|
||||||
|
label -> Nullable<Text>,
|
||||||
|
created_at -> Timestamptz,
|
||||||
|
last_used_at -> Nullable<Timestamptz>,
|
||||||
|
expires_at -> Nullable<Timestamptz>,
|
||||||
|
revoked_at -> Nullable<Timestamptz>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::joinable!(correspondents -> tenants (tenant_id));
|
diesel::joinable!(correspondents -> tenants (tenant_id));
|
||||||
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
|
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
|
||||||
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
|
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
|
||||||
@@ -206,6 +221,8 @@ diesel::joinable!(refresh_tokens -> users (user_id));
|
|||||||
diesel::joinable!(tags -> tenants (tenant_id));
|
diesel::joinable!(tags -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> users (user_id));
|
diesel::joinable!(user_memberships -> users (user_id));
|
||||||
|
diesel::joinable!(webdav_tokens -> tenants (tenant_id));
|
||||||
|
diesel::joinable!(webdav_tokens -> users (user_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
correspondents,
|
correspondents,
|
||||||
@@ -222,4 +239,5 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
tenants,
|
tenants,
|
||||||
user_memberships,
|
user_memberships,
|
||||||
users,
|
users,
|
||||||
|
webdav_tokens,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use tracing::{error, info, warn};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
documents::asset::delete_asset,
|
||||||
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
|
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
|
||||||
models::{
|
models::{
|
||||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||||
@@ -133,12 +134,29 @@ impl JobHandler for GenerateOcrTextJob {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if context.existing_asset.is_some() {
|
if let Some(existing_asset) = &context.existing_asset {
|
||||||
for object in &context.existing_objects {
|
for object in &context.existing_objects {
|
||||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||||
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
|
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tenant_id = context.document.tenant_id;
|
||||||
|
let asset_id = existing_asset.id;
|
||||||
|
let state_clone = state.clone();
|
||||||
|
match task::spawn_blocking(move || {
|
||||||
|
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
warn!(job_id = %job.id, error = ?err, asset_id = %asset_id, "failed to remove ocr asset metadata after deletion");
|
||||||
|
}
|
||||||
|
Err(join_err) => {
|
||||||
|
warn!(job_id = %job.id, error = %join_err, asset_id = %asset_id, "failed to remove ocr asset metadata: task panicked");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let asset_id = Uuid::new_v4();
|
let asset_id = Uuid::new_v4();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use tracing::{error, info, warn};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
documents::asset::delete_asset,
|
||||||
jobs::JOB_GENERATE_THUMBNAILS,
|
jobs::JOB_GENERATE_THUMBNAILS,
|
||||||
models::{
|
models::{
|
||||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||||
@@ -152,7 +153,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if initial.existing_preview.is_some() {
|
if let Some(existing_preview) = &initial.existing_preview {
|
||||||
for object in &initial.existing_preview_objects {
|
for object in &initial.existing_preview_objects {
|
||||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -163,9 +164,36 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tenant_id = initial.document.tenant_id;
|
||||||
|
let asset_id = existing_preview.id;
|
||||||
|
let state_clone = state.clone();
|
||||||
|
match task::spawn_blocking(move || {
|
||||||
|
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
error = ?err,
|
||||||
|
asset_id = %asset_id,
|
||||||
|
"failed to remove preview metadata after deletion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(join_err) => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
error = %join_err,
|
||||||
|
asset_id = %asset_id,
|
||||||
|
"failed to remove preview metadata: task panicked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if initial.existing_thumbnail.is_some() {
|
if let Some(existing_thumbnail) = &initial.existing_thumbnail {
|
||||||
for object in &initial.existing_thumbnail_objects {
|
for object in &initial.existing_thumbnail_objects {
|
||||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -176,6 +204,33 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tenant_id = initial.document.tenant_id;
|
||||||
|
let asset_id = existing_thumbnail.id;
|
||||||
|
let state_clone = state.clone();
|
||||||
|
match task::spawn_blocking(move || {
|
||||||
|
delete_asset(state_clone.as_ref(), tenant_id, asset_id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
error = ?err,
|
||||||
|
asset_id = %asset_id,
|
||||||
|
"failed to remove thumbnail metadata after deletion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(join_err) => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
error = %join_err,
|
||||||
|
asset_id = %asset_id,
|
||||||
|
"failed to remove thumbnail metadata: task panicked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let preview_asset_id = Uuid::new_v4();
|
let preview_asset_id = Uuid::new_v4();
|
||||||
|
|||||||
@@ -707,6 +707,7 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
|||||||
jobs, \
|
jobs, \
|
||||||
refresh_tokens, \
|
refresh_tokens, \
|
||||||
tags, \
|
tags, \
|
||||||
|
webdav_tokens, \
|
||||||
user_memberships, \
|
user_memberships, \
|
||||||
users, \
|
users, \
|
||||||
tenants \
|
tenants \
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{header, Method, Request, StatusCode};
|
||||||
|
use backend::models::WebdavToken;
|
||||||
|
use backend::routes::webdav;
|
||||||
|
use backend::schema::webdav_tokens;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenInfo {
|
||||||
|
id: Uuid,
|
||||||
|
label: Option<String>,
|
||||||
|
last_used_at: Option<String>,
|
||||||
|
revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateTokenResponse {
|
||||||
|
token: String,
|
||||||
|
#[serde(rename = "token_info")]
|
||||||
|
info: TokenInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn webdav_token_api_crud() -> Result<()> {
|
||||||
|
let _guard = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let username = "alice";
|
||||||
|
let password = "correct horse battery";
|
||||||
|
app.insert_user(username, password, "admin").await?;
|
||||||
|
let access_token = app.login_token(username, password).await?;
|
||||||
|
|
||||||
|
let create_response = app
|
||||||
|
.post_json(
|
||||||
|
"/api/profile/webdav-tokens",
|
||||||
|
&json!({ "label": "dav" }),
|
||||||
|
Some(&access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||||
|
let create_body = body_to_vec(create_response.into_body()).await?;
|
||||||
|
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
|
||||||
|
let token_id = created.info.id;
|
||||||
|
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
||||||
|
assert!(created.info.last_used_at.is_none());
|
||||||
|
|
||||||
|
let list_response = app
|
||||||
|
.get("/api/profile/webdav-tokens", Some(&access_token))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(list_response.status(), StatusCode::OK);
|
||||||
|
let list_body = body_to_vec(list_response.into_body()).await?;
|
||||||
|
let listed: Vec<TokenInfo> = serde_json::from_slice(&list_body)?;
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
assert_eq!(listed[0].id, token_id);
|
||||||
|
|
||||||
|
let delete_response = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/profile/webdav-tokens/{token_id}"),
|
||||||
|
Some(&access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let list_after = app
|
||||||
|
.get("/api/profile/webdav-tokens", Some(&access_token))
|
||||||
|
.await?;
|
||||||
|
let list_after_body = body_to_vec(list_after.into_body()).await?;
|
||||||
|
let listed_after: Vec<TokenInfo> = serde_json::from_slice(&list_after_body)?;
|
||||||
|
assert_eq!(listed_after.len(), 1);
|
||||||
|
assert_eq!(listed_after[0].id, token_id);
|
||||||
|
assert!(listed_after[0].revoked_at.is_some());
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn webdav_basic_auth_uses_tokens() -> Result<()> {
|
||||||
|
let _guard = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let username = "bruce";
|
||||||
|
let password = "wayne";
|
||||||
|
app.insert_user(username, password, "admin").await?;
|
||||||
|
let access_token = app.login_token(username, password).await?;
|
||||||
|
|
||||||
|
let create_response = app
|
||||||
|
.post_json(
|
||||||
|
"/api/profile/webdav-tokens",
|
||||||
|
&json!({ "label": "webdav" }),
|
||||||
|
Some(&access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let create_body = body_to_vec(create_response.into_body()).await?;
|
||||||
|
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
|
||||||
|
let token_id = created.info.id;
|
||||||
|
|
||||||
|
let router = webdav::create_router().with_state(app.state.clone());
|
||||||
|
let auth_header = format!(
|
||||||
|
"Basic {}",
|
||||||
|
BASE64.encode(format!("{}:{}", username, created.token))
|
||||||
|
);
|
||||||
|
|
||||||
|
let propfind = Method::from_bytes(b"PROPFIND")?;
|
||||||
|
let success_request = Request::builder()
|
||||||
|
.method(propfind.clone())
|
||||||
|
.uri("/")
|
||||||
|
.header(header::AUTHORIZATION, auth_header.clone())
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let response = router.clone().oneshot(success_request).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
||||||
|
|
||||||
|
let used = app
|
||||||
|
.with_conn(move |conn| {
|
||||||
|
let record = webdav_tokens::table
|
||||||
|
.find(token_id)
|
||||||
|
.first::<WebdavToken>(conn)?;
|
||||||
|
Ok::<_, anyhow::Error>(record.last_used_at)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert!(used.is_some());
|
||||||
|
|
||||||
|
let delete_response = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/profile/webdav-tokens/{token_id}"),
|
||||||
|
Some(&access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let failure_request = Request::builder()
|
||||||
|
.method(propfind)
|
||||||
|
.uri("/")
|
||||||
|
.header(header::AUTHORIZATION, auth_header)
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let response = router.oneshot(failure_request).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+1
-2
@@ -12,10 +12,9 @@ RUN npm run build
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
WORKDIR /usr/share/nginx/html
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY --from=build /app/dist ./
|
COPY --from=build /app/dist ./
|
||||||
|
|
||||||
ENV API_BASE_URL=""
|
ENV API_PROXY_PASS=""
|
||||||
|
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
RUN chmod +x /docker-entrypoint.sh
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
API_BASE_URL_TRIMMED="${API_BASE_URL:-}"
|
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}"
|
||||||
API_BASE_URL_TRIMMED="${API_BASE_URL_TRIMMED%%/}"
|
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS_TRIMMED%%/}"
|
||||||
|
|
||||||
cat <<CONFIG > /usr/share/nginx/html/config.js
|
cat <<'BASE' > /etc/nginx/conf.d/default.conf
|
||||||
window.__PAPERCRATE_API_BASE_URL = "${API_BASE_URL_TRIMMED}";
|
server {
|
||||||
CONFIG
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
BASE
|
||||||
|
|
||||||
|
if [ -n "$API_PROXY_PASS_TRIMMED" ]; then
|
||||||
|
cat <<PROXY >> /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass ${API_PROXY_PASS_TRIMMED};
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
PROXY
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<'ENDCFG' >> /etc/nginx/conf.d/default.conf
|
||||||
|
}
|
||||||
|
ENDCFG
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
window.__PAPERCRATE_API_BASE_URL = window.__PAPERCRATE_API_BASE_URL || '';
|
|
||||||
@@ -8,9 +8,7 @@
|
|||||||
|
|
||||||
.skeuo-item {
|
.skeuo-item {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: block;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
@@ -50,11 +48,9 @@
|
|||||||
|
|
||||||
|
|
||||||
.skeuo-item__body {
|
.skeuo-item__body {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item:focus-visible {
|
.skeuo-item:focus-visible {
|
||||||
@@ -104,7 +100,7 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
||||||
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.18);
|
box-shadow: 2px 2px 4px color-mix(in oklch, black 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-chip--draggable:active {
|
.tag-chip--draggable:active {
|
||||||
@@ -150,14 +146,14 @@
|
|||||||
padding: 0.25em;
|
padding: 0.25em;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: none;
|
border: none;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: oklch(0.22 0.06 260deg);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, opacity 0.15s ease;
|
transition: background 0.15s ease, opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-card__nav-button:hover:not([disabled]) {
|
.skeuo-card__nav-button:hover:not([disabled]) {
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: oklch(0.18 0.06 260deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-card__nav-button:disabled {
|
.skeuo-card__nav-button:disabled {
|
||||||
@@ -196,7 +192,7 @@ body.skeuo-cursor-remove * {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 12px 32px color-mix(in oklch, black 18%, transparent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,8 +205,8 @@ body.skeuo-cursor-remove * {
|
|||||||
|
|
||||||
.skeuo-item__card--empty {
|
.skeuo-item__card--empty {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 28% 24%, rgba(255, 255, 255, 0.32), transparent 60%),
|
radial-gradient(circle at 28% 24%, color-mix(in oklch, white 32%, transparent), transparent 60%),
|
||||||
radial-gradient(circle at 72% 78%, rgba(0, 0, 0, 0.08), transparent 65%),
|
radial-gradient(circle at 72% 78%, color-mix(in oklch, black 8%, transparent), transparent 65%),
|
||||||
linear-gradient(135deg, #e6e1d6 0%, #d2cdc2 100%);
|
linear-gradient(135deg, #e6e1d6 0%, #d2cdc2 100%);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<title>Papercrate</title>
|
<title>Papercrate</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="/config.js"></script>
|
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+233
-107
@@ -29,39 +29,35 @@ import TagsPanel from './tags/TagsPanel';
|
|||||||
import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
|
import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
|
||||||
import TagManager from './tag_manager';
|
import TagManager from './tag_manager';
|
||||||
import Sidebar from './sidebar/Sidebar';
|
import Sidebar from './sidebar/Sidebar';
|
||||||
|
import SettingsModal from './settings/SettingsModal';
|
||||||
import { ChevronsRightIcon } from './ui/icons';
|
import { ChevronsRightIcon } from './ui/icons';
|
||||||
import { createDocumentsSurface } from './documents/DocumentsTable';
|
import { createDocumentsSurface } from './documents/DocumentsTable';
|
||||||
import { createPreviewSurface } from './preview/PreviewWorkspace';
|
import { createPreviewSurface } from './preview/PreviewWorkspace';
|
||||||
import { createDesktopSurface } from './DesktopWorkspace';
|
import { createDesktopSurface } from './DesktopWorkspace';
|
||||||
import { AppShellContext, useAppShell } from './appShellContext';
|
import { AppShellContext, useAppShell } from './appShellContext';
|
||||||
|
|
||||||
const runtimeApiBase =
|
|
||||||
typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL
|
|
||||||
? window.__PAPERCRATE_API_BASE_URL
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
|
|
||||||
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
||||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
|
|
||||||
const API_ROOT = (runtimeApiBase || process.env.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, '');
|
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
|
baseURL: '/api',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const STORED_TOKEN = window.localStorage.getItem('papercrate_token') || '';
|
const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined;
|
||||||
|
const STORED_TOKEN = storage?.getItem('papercrate_token') || '';
|
||||||
let STORED_TENANT = null;
|
let STORED_TENANT = null;
|
||||||
try {
|
if (storage) {
|
||||||
const rawTenant = window.localStorage.getItem('papercrate_tenant');
|
try {
|
||||||
if (rawTenant) {
|
const rawTenant = storage.getItem('papercrate_tenant');
|
||||||
STORED_TENANT = JSON.parse(rawTenant);
|
if (rawTenant) {
|
||||||
}
|
STORED_TENANT = JSON.parse(rawTenant);
|
||||||
} catch (
|
}
|
||||||
// eslint-disable-next-line no-empty
|
} catch (
|
||||||
error
|
// eslint-disable-next-line no-empty
|
||||||
) {}
|
error
|
||||||
|
) {}
|
||||||
|
}
|
||||||
if (STORED_TOKEN) {
|
if (STORED_TOKEN) {
|
||||||
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
|
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
|
||||||
}
|
}
|
||||||
@@ -190,22 +186,22 @@ const AppStateProvider = ({ children }) => {
|
|||||||
const token = state.token || '';
|
const token = state.token || '';
|
||||||
if (token) {
|
if (token) {
|
||||||
api.defaults.headers.common.Authorization = `Bearer ${token}`;
|
api.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||||
window.localStorage.setItem('papercrate_token', token);
|
storage?.setItem('papercrate_token', token);
|
||||||
} else {
|
} else {
|
||||||
delete api.defaults.headers.common.Authorization;
|
delete api.defaults.headers.common.Authorization;
|
||||||
window.localStorage.removeItem('papercrate_token');
|
storage?.removeItem('papercrate_token');
|
||||||
}
|
}
|
||||||
}, [state.token]);
|
}, [state.token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.tenant) {
|
if (state.tenant) {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem('papercrate_tenant', JSON.stringify(state.tenant));
|
storage?.setItem('papercrate_tenant', JSON.stringify(state.tenant));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to persist tenant info', error);
|
console.warn('Failed to persist tenant info', error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem('papercrate_tenant');
|
storage?.removeItem('papercrate_tenant');
|
||||||
}
|
}
|
||||||
}, [state.tenant]);
|
}, [state.tenant]);
|
||||||
|
|
||||||
@@ -269,7 +265,7 @@ const ROW_KEY_SEPARATOR = ':';
|
|||||||
const DOCUMENT_ROW_PREFIX = 'document';
|
const DOCUMENT_ROW_PREFIX = 'document';
|
||||||
const FOLDER_ROW_PREFIX = 'folder';
|
const FOLDER_ROW_PREFIX = 'folder';
|
||||||
|
|
||||||
const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path);
|
const resolveApiPath = (path = '') => path;
|
||||||
|
|
||||||
const makeRowKey = (type, id) =>
|
const makeRowKey = (type, id) =>
|
||||||
id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`;
|
id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`;
|
||||||
@@ -338,12 +334,12 @@ const LoginView = ({
|
|||||||
<div className="login-card__tenant-list">
|
<div className="login-card__tenant-list">
|
||||||
{tenantSelection.tenants.map((tenant) => (
|
{tenantSelection.tenants.map((tenant) => (
|
||||||
<button
|
<button
|
||||||
key={tenant.tenant_id}
|
key={tenant.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectTenant?.(tenant)}
|
onClick={() => onSelectTenant?.(tenant)}
|
||||||
disabled={Boolean(selectingTenantId)}
|
disabled={Boolean(selectingTenantId)}
|
||||||
className={
|
className={
|
||||||
selectingTenantId === tenant.tenant_id
|
selectingTenantId === tenant.id
|
||||||
? 'login-card__tenant-button is-loading'
|
? 'login-card__tenant-button is-loading'
|
||||||
: 'login-card__tenant-button'
|
: 'login-card__tenant-button'
|
||||||
}
|
}
|
||||||
@@ -440,7 +436,7 @@ const AppLayout = () => {
|
|||||||
const previewDocumentId = routeDocumentId;
|
const previewDocumentId = routeDocumentId;
|
||||||
const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState;
|
const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState;
|
||||||
const tenantSlug = tenant?.slug || null;
|
const tenantSlug = tenant?.slug || null;
|
||||||
const currentTenantId = tenant?.tenant_id || null;
|
const currentTenantId = tenant?.id || null;
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||||
setStatus(message ? { message, variant } : null);
|
setStatus(message ? { message, variant } : null);
|
||||||
@@ -464,6 +460,7 @@ const AppLayout = () => {
|
|||||||
const [isCreateFolderModalOpen, setCreateFolderModalOpen] = useState(false);
|
const [isCreateFolderModalOpen, setCreateFolderModalOpen] = useState(false);
|
||||||
const [isTagsModalOpen, setTagsModalOpen] = useState(false);
|
const [isTagsModalOpen, setTagsModalOpen] = useState(false);
|
||||||
const [isCorrespondentsModalOpen, setCorrespondentsModalOpen] = useState(false);
|
const [isCorrespondentsModalOpen, setCorrespondentsModalOpen] = useState(false);
|
||||||
|
const [isSettingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
const [createFolderError, setCreateFolderError] = useState('');
|
const [createFolderError, setCreateFolderError] = useState('');
|
||||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||||
@@ -537,6 +534,11 @@ const AppLayout = () => {
|
|||||||
const [searchResults, setSearchResults] = useState(null);
|
const [searchResults, setSearchResults] = useState(null);
|
||||||
const [tags, setTags] = useState([]);
|
const [tags, setTags] = useState([]);
|
||||||
const [correspondents, setCorrespondents] = useState([]);
|
const [correspondents, setCorrespondents] = useState([]);
|
||||||
|
const [webdavTokens, setWebdavTokens] = useState([]);
|
||||||
|
const [webdavTokensLoading, setWebdavTokensLoading] = useState(false);
|
||||||
|
const [creatingWebdavToken, setCreatingWebdavToken] = useState(false);
|
||||||
|
const [deletingWebdavTokenId, setDeletingWebdavTokenId] = useState(null);
|
||||||
|
const [webdavTokenSecret, setWebdavTokenSecret] = useState(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
||||||
@@ -564,6 +566,16 @@ const AppLayout = () => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token && !initialRefreshAttemptedRef.current && appStatus === 'logged-out') {
|
||||||
|
initialRefreshAttemptedRef.current = true;
|
||||||
|
console.log('[Auth] Attempting refresh at startup');
|
||||||
|
refreshAccessToken().catch(() => {});
|
||||||
|
}
|
||||||
|
}, [token, appStatus, refreshAccessToken]);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setActiveTagFilters([]);
|
setActiveTagFilters([]);
|
||||||
@@ -741,6 +753,11 @@ const AppLayout = () => {
|
|||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
setTags([]);
|
setTags([]);
|
||||||
setCorrespondents([]);
|
setCorrespondents([]);
|
||||||
|
setWebdavTokens([]);
|
||||||
|
setWebdavTokensLoading(false);
|
||||||
|
setCreatingWebdavToken(false);
|
||||||
|
setDeletingWebdavTokenId(null);
|
||||||
|
setWebdavTokenSecret(null);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setActiveTagFilters([]);
|
setActiveTagFilters([]);
|
||||||
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
||||||
@@ -1732,6 +1749,97 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
}, [api, notifyApiError]);
|
}, [api, notifyApiError]);
|
||||||
|
|
||||||
|
const refreshWebdavTokens = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWebdavTokensLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/profile/webdav-tokens');
|
||||||
|
setWebdavTokens(Array.isArray(data) ? data : []);
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Failed to load WebDAV tokens.');
|
||||||
|
} finally {
|
||||||
|
setWebdavTokensLoading(false);
|
||||||
|
}
|
||||||
|
}, [api, notifyApiError, token]);
|
||||||
|
|
||||||
|
const createWebdavToken = useCallback(
|
||||||
|
async ({ label, expires_at } = {}) => {
|
||||||
|
if (creatingWebdavToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setCreatingWebdavToken(true);
|
||||||
|
try {
|
||||||
|
const payload = {};
|
||||||
|
if (label) {
|
||||||
|
payload.label = label;
|
||||||
|
}
|
||||||
|
if (expires_at) {
|
||||||
|
payload.expires_at = expires_at;
|
||||||
|
}
|
||||||
|
const { data } = await api.post('/profile/webdav-tokens', payload);
|
||||||
|
if (data?.token_info) {
|
||||||
|
setWebdavTokens((previous) => {
|
||||||
|
const filtered = previous.filter((entry) => entry.id !== data.token_info.id);
|
||||||
|
return [data.token_info, ...filtered];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await refreshWebdavTokens();
|
||||||
|
}
|
||||||
|
if (data?.token) {
|
||||||
|
setWebdavTokenSecret(data.token);
|
||||||
|
}
|
||||||
|
setStatusMessage('WebDAV token created.', 'success');
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Failed to create WebDAV token.');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setCreatingWebdavToken(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, creatingWebdavToken, notifyApiError, refreshWebdavTokens, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteWebdavToken = useCallback(
|
||||||
|
async (tokenId) => {
|
||||||
|
if (!tokenId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setDeletingWebdavTokenId(tokenId);
|
||||||
|
try {
|
||||||
|
await api.delete(`/profile/webdav-tokens/${tokenId}`);
|
||||||
|
await refreshWebdavTokens();
|
||||||
|
setStatusMessage('WebDAV token revoked.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Failed to revoke WebDAV token.');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingWebdavTokenId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, refreshWebdavTokens, notifyApiError, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismissCreatedWebdavToken = useCallback(() => {
|
||||||
|
setWebdavTokenSecret(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSettingsModalOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshWebdavTokens();
|
||||||
|
}, [isSettingsModalOpen, refreshWebdavTokens]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSettingsModalOpen) {
|
||||||
|
setWebdavTokenSecret(null);
|
||||||
|
}
|
||||||
|
}, [isSettingsModalOpen]);
|
||||||
|
|
||||||
const handleTagUpdate = useCallback(
|
const handleTagUpdate = useCallback(
|
||||||
async (tagId, changes) => {
|
async (tagId, changes) => {
|
||||||
if (!tagId) {
|
if (!tagId) {
|
||||||
@@ -2004,38 +2112,6 @@ const AppLayout = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadFolder = useCallback(
|
|
||||||
async (folderId, { showLoading = true, preserveSearch = false } = {}) => {
|
|
||||||
const targetId = folderId || 'root';
|
|
||||||
setSelectedFolder(targetId);
|
|
||||||
if (showLoading) setLoading(true);
|
|
||||||
try {
|
|
||||||
const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 });
|
|
||||||
if (targetId !== 'root') {
|
|
||||||
try {
|
|
||||||
await ensureFolderData('root', {
|
|
||||||
force: false,
|
|
||||||
includeDocuments: false,
|
|
||||||
prefetchDepth: 1,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to refresh root folder tree', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applySelectedFolder(targetId, contents);
|
|
||||||
if (!preserveSearch) {
|
|
||||||
setSearchResults(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
notifyApiError(error, 'Failed to load folder contents.');
|
|
||||||
} finally {
|
|
||||||
if (showLoading) setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[ensureFolderData, applySelectedFolder, notifyApiError],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
const expandFolderAncestors = useCallback(
|
const expandFolderAncestors = useCallback(
|
||||||
(targetId) => {
|
(targetId) => {
|
||||||
if (!targetId || targetId === 'root') {
|
if (!targetId || targetId === 'root') {
|
||||||
@@ -2097,51 +2173,73 @@ const AppLayout = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visited = new Set();
|
const fetchAncestor = async (folderId, guard = 0) => {
|
||||||
const stack = [];
|
if (!folderId || folderId === 'root' || guard > 32) {
|
||||||
|
return;
|
||||||
let currentId = targetId;
|
|
||||||
let guard = 0;
|
|
||||||
while (currentId && currentId !== 'root' && guard < 32) {
|
|
||||||
guard += 1;
|
|
||||||
if (visited.has(currentId)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
visited.add(currentId);
|
|
||||||
|
|
||||||
const node = folderNodes.get(currentId);
|
|
||||||
if (!node) {
|
|
||||||
stack.push(currentId);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentId = node.parentId ?? 'root';
|
const existing = folderNodes.get(folderId);
|
||||||
if (!parentId || parentId === 'root') {
|
if (existing?.loaded) {
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
stack.push(parentId);
|
|
||||||
currentId = parentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (stack.length) {
|
try {
|
||||||
const ancestorId = stack.pop();
|
const contents = await ensureFolderData(folderId, {
|
||||||
if (!ancestorId || ancestorId === 'root') {
|
includeDocuments: false,
|
||||||
continue;
|
force: false,
|
||||||
|
prefetchDepth: 0,
|
||||||
|
});
|
||||||
|
const parentId = contents?.folder?.parent_id ?? 'root';
|
||||||
|
if (parentId && parentId !== 'root') {
|
||||||
|
await fetchAncestor(parentId, guard + 1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to ensure ancestor folder for navigation', folderId, error);
|
||||||
}
|
}
|
||||||
if (!folderNodes.has(ancestorId)) {
|
};
|
||||||
|
|
||||||
|
await fetchAncestor(targetId, 0);
|
||||||
|
},
|
||||||
|
[folderNodes, ensureFolderData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadFolder = useCallback(
|
||||||
|
async (folderId, { showLoading = true, preserveSearch = false } = {}) => {
|
||||||
|
const targetId = folderId || 'root';
|
||||||
|
setSelectedFolder(targetId);
|
||||||
|
await ensureFolderAncestorsLoaded(targetId);
|
||||||
|
expandFolderAncestors(targetId);
|
||||||
|
if (showLoading) setLoading(true);
|
||||||
|
try {
|
||||||
|
const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 });
|
||||||
|
if (targetId !== 'root') {
|
||||||
try {
|
try {
|
||||||
await ensureFolderData(ancestorId, {
|
await ensureFolderData('root', {
|
||||||
includeDocuments: false,
|
|
||||||
force: false,
|
force: false,
|
||||||
|
includeDocuments: false,
|
||||||
prefetchDepth: 1,
|
prefetchDepth: 1,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to ensure ancestor folder for navigation', ancestorId, error);
|
console.warn('Failed to refresh root folder tree', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
applySelectedFolder(targetId, contents);
|
||||||
|
if (!preserveSearch) {
|
||||||
|
setSearchResults(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Failed to load folder contents.');
|
||||||
|
} finally {
|
||||||
|
if (showLoading) setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[folderNodes, ensureFolderData],
|
[
|
||||||
|
ensureFolderData,
|
||||||
|
applySelectedFolder,
|
||||||
|
notifyApiError,
|
||||||
|
ensureFolderAncestorsLoaded,
|
||||||
|
expandFolderAncestors,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectFolder = useCallback(
|
const selectFolder = useCallback(
|
||||||
@@ -2200,10 +2298,7 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
const hasData = folderContents.has(targetParam);
|
const hasData = folderContents.has(targetParam);
|
||||||
if (targetParam !== selectedFolder || !hasData) {
|
if (targetParam !== selectedFolder || !hasData) {
|
||||||
loadFolder(targetParam, {
|
selectFolder(targetParam, { immediate: true });
|
||||||
showLoading: !routeDocumentId,
|
|
||||||
preserveSearch: isFilterActive,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
token,
|
token,
|
||||||
@@ -2211,9 +2306,9 @@ const AppLayout = () => {
|
|||||||
routeFolderId,
|
routeFolderId,
|
||||||
routeDocumentId,
|
routeDocumentId,
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
loadFolder,
|
|
||||||
folderContents,
|
folderContents,
|
||||||
isFilterActive,
|
isFilterActive,
|
||||||
|
selectFolder,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const refreshCurrentFolder = useCallback(async () => {
|
const refreshCurrentFolder = useCallback(async () => {
|
||||||
@@ -4048,6 +4143,14 @@ const AppLayout = () => {
|
|||||||
setCorrespondentsModalOpen(false);
|
setCorrespondentsModalOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openSettingsModal = useCallback(() => {
|
||||||
|
setSettingsModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSettingsModal = useCallback(() => {
|
||||||
|
setSettingsModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCreateFolderSubmit = useCallback(
|
const handleCreateFolderSubmit = useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -4111,10 +4214,11 @@ const AppLayout = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTagsModalOpen(false);
|
setTagsModalOpen(false);
|
||||||
setCorrespondentsModalOpen(false);
|
setCorrespondentsModalOpen(false);
|
||||||
|
setSettingsModalOpen(false);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTagsModalOpen && !isCorrespondentsModalOpen) {
|
if (!isTagsModalOpen && !isCorrespondentsModalOpen && !isSettingsModalOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
@@ -4124,6 +4228,8 @@ const AppLayout = () => {
|
|||||||
closeTagsModal();
|
closeTagsModal();
|
||||||
} else if (isCorrespondentsModalOpen) {
|
} else if (isCorrespondentsModalOpen) {
|
||||||
closeCorrespondentsModal();
|
closeCorrespondentsModal();
|
||||||
|
} else if (isSettingsModalOpen) {
|
||||||
|
closeSettingsModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -4131,7 +4237,14 @@ const AppLayout = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [isTagsModalOpen, isCorrespondentsModalOpen, closeTagsModal, closeCorrespondentsModal]);
|
}, [
|
||||||
|
isTagsModalOpen,
|
||||||
|
isCorrespondentsModalOpen,
|
||||||
|
isSettingsModalOpen,
|
||||||
|
closeTagsModal,
|
||||||
|
closeCorrespondentsModal,
|
||||||
|
closeSettingsModal,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return undefined;
|
if (!token) return undefined;
|
||||||
@@ -4943,8 +5056,7 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
const handleTenantSelect = useCallback(
|
const handleTenantSelect = useCallback(
|
||||||
async (tenantOption, { refreshOnly = false } = {}) => {
|
async (tenantOption, { refreshOnly = false } = {}) => {
|
||||||
const requestedTenantId =
|
const requestedTenantId = tenantOption?.id ?? null;
|
||||||
tenantOption?.tenant_id || tenantOption?.tenantId || tenantOption?.id || null;
|
|
||||||
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
|
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4980,7 +5092,7 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
|
api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
|
||||||
tokenRef.current = data.access_token;
|
tokenRef.current = data.access_token;
|
||||||
tenantIdRef.current = data?.tenant?.tenant_id || null;
|
tenantIdRef.current = data?.tenant?.id ?? null;
|
||||||
|
|
||||||
if (Array.isArray(data?.tenants)) {
|
if (Array.isArray(data?.tenants)) {
|
||||||
appDispatch({ type: 'SET_TENANTS', tenants: data.tenants });
|
appDispatch({ type: 'SET_TENANTS', tenants: data.tenants });
|
||||||
@@ -4992,10 +5104,8 @@ const AppLayout = () => {
|
|||||||
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
||||||
await loadFolder('root', { showLoading: false, preserveSearch: false });
|
await loadFolder('root', { showLoading: false, preserveSearch: false });
|
||||||
|
|
||||||
setStatusMessage(
|
const tenantLabel = data?.tenant?.slug || data?.tenant?.id || 'tenant';
|
||||||
`Switched to ${data?.tenant?.slug || data?.tenant?.tenant_id || 'tenant'}.`,
|
setStatusMessage(`Switched to ${tenantLabel}.`, 'info');
|
||||||
'info',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyApiError(error, 'Failed to switch tenant.');
|
notifyApiError(error, 'Failed to switch tenant.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -5102,6 +5212,7 @@ const AppLayout = () => {
|
|||||||
tenants: tenantOptions,
|
tenants: tenantOptions,
|
||||||
activeTenantId: currentTenantId,
|
activeTenantId: currentTenantId,
|
||||||
onSelectTenant: handleTenantSelect,
|
onSelectTenant: handleTenantSelect,
|
||||||
|
onOpenSettings: openSettingsModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sidebarPropsWithActions = useMemo(
|
const sidebarPropsWithActions = useMemo(
|
||||||
@@ -5215,6 +5326,7 @@ const AppLayout = () => {
|
|||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
openTagsModal,
|
openTagsModal,
|
||||||
openCorrespondentsModal,
|
openCorrespondentsModal,
|
||||||
|
openSettingsModal,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
token,
|
token,
|
||||||
@@ -5255,6 +5367,7 @@ const AppLayout = () => {
|
|||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
openTagsModal,
|
openTagsModal,
|
||||||
openCorrespondentsModal,
|
openCorrespondentsModal,
|
||||||
|
openSettingsModal,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -5397,6 +5510,19 @@ const AppLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<SettingsModal
|
||||||
|
open={isSettingsModalOpen}
|
||||||
|
onClose={closeSettingsModal}
|
||||||
|
tokens={webdavTokens}
|
||||||
|
loading={webdavTokensLoading}
|
||||||
|
creating={creatingWebdavToken}
|
||||||
|
deletingId={deletingWebdavTokenId}
|
||||||
|
onRefresh={refreshWebdavTokens}
|
||||||
|
onCreate={createWebdavToken}
|
||||||
|
onDelete={deleteWebdavToken}
|
||||||
|
createdToken={webdavTokenSecret}
|
||||||
|
onDismissCreatedToken={dismissCreatedWebdavToken}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppShellContext.Provider>
|
</AppShellContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -5661,15 +5787,15 @@ const LoginRoute = () => {
|
|||||||
|
|
||||||
const handleTenantSelect = useCallback(
|
const handleTenantSelect = useCallback(
|
||||||
async (tenant) => {
|
async (tenant) => {
|
||||||
if (!tenantSelection?.selectionToken || !tenant?.tenant_id) {
|
if (!tenantSelection?.selectionToken || !tenant?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSelectingTenantId(tenant.tenant_id);
|
setSelectingTenantId(tenant.id);
|
||||||
const { data } = await api.post(
|
const { data } = await api.post(
|
||||||
'/auth/select-tenant',
|
'/auth/select-tenant',
|
||||||
{ tenant_id: tenant.tenant_id },
|
{ tenant_id: tenant.id },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tenantSelection.selectionToken}`,
|
Authorization: `Bearer ${tenantSelection.selectionToken}`,
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
id: 'webdav',
|
||||||
|
label: 'WebDAV',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SettingsModal = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
tokens = [],
|
||||||
|
loading = false,
|
||||||
|
creating = false,
|
||||||
|
deletingId = null,
|
||||||
|
onRefresh,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
createdToken = null,
|
||||||
|
onDismissCreatedToken,
|
||||||
|
}) => {
|
||||||
|
const defaultSection = SECTIONS[0]?.id || 'webdav';
|
||||||
|
const [activeSection, setActiveSection] = useState(defaultSection);
|
||||||
|
const [newTokenLabel, setNewTokenLabel] = useState('');
|
||||||
|
const [newTokenExpires, setNewTokenExpires] = useState('');
|
||||||
|
const [formError, setFormError] = useState(null);
|
||||||
|
|
||||||
|
const handleBackdropClick = useCallback(() => {
|
||||||
|
onClose?.();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleInnerClick = useCallback((event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setActiveSection(defaultSection);
|
||||||
|
setNewTokenLabel('');
|
||||||
|
setNewTokenExpires('');
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
}, [open, defaultSection]);
|
||||||
|
|
||||||
|
const formatDateTime = useCallback((value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const timestamp = new Date(value);
|
||||||
|
if (Number.isNaN(timestamp.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return timestamp.toLocaleString();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
onRefresh?.();
|
||||||
|
}, [onRefresh]);
|
||||||
|
|
||||||
|
const handleCopyToken = useCallback(() => {
|
||||||
|
if (!createdToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(createdToken).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [createdToken]);
|
||||||
|
|
||||||
|
const handleDismissSecret = useCallback(() => {
|
||||||
|
onDismissCreatedToken?.();
|
||||||
|
}, [onDismissCreatedToken]);
|
||||||
|
|
||||||
|
const handleCreateToken = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
let normalizedLabel = newTokenLabel.trim();
|
||||||
|
if (normalizedLabel.length === 0) {
|
||||||
|
normalizedLabel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedExpires;
|
||||||
|
if (newTokenExpires) {
|
||||||
|
const parsed = new Date(newTokenExpires);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
setFormError('Enter a valid expiration date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalizedExpires = parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await onCreate?.({
|
||||||
|
label: normalizedLabel,
|
||||||
|
expires_at: normalizedExpires,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result !== false) {
|
||||||
|
setNewTokenLabel('');
|
||||||
|
setNewTokenExpires('');
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[newTokenExpires, newTokenLabel, onCreate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderWebdavSection = useMemo(() => {
|
||||||
|
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createdToken ? (
|
||||||
|
<div className="settings-notice">
|
||||||
|
<p>
|
||||||
|
Copy this token now; you will not be able to view it again after closing this window.
|
||||||
|
</p>
|
||||||
|
<pre className="token-display">{createdToken}</pre>
|
||||||
|
<div className="settings-notice__actions">
|
||||||
|
<button type="button" className="secondary" onClick={handleCopyToken}>
|
||||||
|
Copy token
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleDismissSecret}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="settings-form" onSubmit={handleCreateToken}>
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="webdav-token-label">Label</label>
|
||||||
|
<input
|
||||||
|
id="webdav-token-label"
|
||||||
|
type="text"
|
||||||
|
value={newTokenLabel}
|
||||||
|
onChange={(event) => setNewTokenLabel(event.target.value)}
|
||||||
|
placeholder="Personal WebDAV token"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="webdav-token-expires">Expires at</label>
|
||||||
|
<input
|
||||||
|
id="webdav-token-expires"
|
||||||
|
type="datetime-local"
|
||||||
|
value={newTokenExpires}
|
||||||
|
onChange={(event) => setNewTokenExpires(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__actions">
|
||||||
|
<button type="submit" disabled={creating}>
|
||||||
|
{creating ? 'Creating…' : 'Create token'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{formError ? <p className="settings-form__error">{formError}</p> : null}
|
||||||
|
|
||||||
|
{loading && !hasTokens ? (
|
||||||
|
<p className="settings-empty">Loading tokens…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && !hasTokens ? (
|
||||||
|
<p className="settings-empty">No WebDAV tokens yet.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasTokens ? (
|
||||||
|
<table className="settings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Label</th>
|
||||||
|
<th scope="col">Created</th>
|
||||||
|
<th scope="col">Last used</th>
|
||||||
|
<th scope="col">Expires</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tokens.map((token) => {
|
||||||
|
const isRevoked = Boolean(token?.revoked_at);
|
||||||
|
return (
|
||||||
|
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
|
||||||
|
<td>{token.label || '—'}</td>
|
||||||
|
<td>{formatDateTime(token.created_at)}</td>
|
||||||
|
<td>{formatDateTime(token.last_used_at)}</td>
|
||||||
|
<td>{formatDateTime(token.expires_at)}</td>
|
||||||
|
<td className="settings-table__actions">
|
||||||
|
{isRevoked ? (
|
||||||
|
<span className="settings-status">Revoked</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => onDelete?.(token.id)}
|
||||||
|
disabled={deletingId === token.id}
|
||||||
|
>
|
||||||
|
{deletingId === token.id ? 'Revoking…' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
tokens,
|
||||||
|
loading,
|
||||||
|
createdToken,
|
||||||
|
creating,
|
||||||
|
deletingId,
|
||||||
|
newTokenLabel,
|
||||||
|
newTokenExpires,
|
||||||
|
formError,
|
||||||
|
formatDateTime,
|
||||||
|
handleCopyToken,
|
||||||
|
handleCreateToken,
|
||||||
|
handleRefresh,
|
||||||
|
onDelete,
|
||||||
|
handleDismissSecret,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" role="presentation" onClick={handleBackdropClick}>
|
||||||
|
<div
|
||||||
|
className="modal modal--panel settings-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="settings-modal-title"
|
||||||
|
onClick={handleInnerClick}
|
||||||
|
>
|
||||||
|
<div className="panel-modal__header">
|
||||||
|
<h3 id="settings-modal-title">Settings</h3>
|
||||||
|
<button type="button" className="secondary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="settings-modal__body">
|
||||||
|
<nav className="settings-modal__sidebar" aria-label="Settings sections">
|
||||||
|
<ul>
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<li key={section.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={section.id === activeSection ? 'active' : ''}
|
||||||
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="settings-modal__content">
|
||||||
|
{activeSection === 'webdav' ? renderWebdavSection : <p>Select a settings section.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsModal;
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
ChevronsLeftIcon,
|
ChevronsLeftIcon,
|
||||||
LogoutIcon,
|
LogoutIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
CheckIcon,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
|
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
@@ -29,12 +31,12 @@ const FolderNode = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const isRoot = node.id === 'root';
|
const isRoot = node.id === 'root';
|
||||||
const hasChildren = Boolean(node.hasChildren);
|
const hasChildren = Boolean(node.hasChildren);
|
||||||
const canToggle = !isRoot && (hasChildren || !node.loaded);
|
const canToggle = hasChildren || !node.loaded;
|
||||||
const showChevron = !isRoot && (hasChildren || !node.loaded);
|
const showChevron = canToggle;
|
||||||
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
|
const icon = showChevron ? <ChevronIcon className="toggle-icon" /> : null;
|
||||||
const canDrag = !isRoot;
|
const canDrag = !isRoot;
|
||||||
const isDragging = draggingFolderId === node.id;
|
const isDragging = draggingFolderId === node.id;
|
||||||
const isExpanded = isRoot ? true : Boolean(node.expanded);
|
const isExpanded = Boolean(node.expanded);
|
||||||
const rowClasses = ['folder-row'];
|
const rowClasses = ['folder-row'];
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
rowClasses.push('active');
|
rowClasses.push('active');
|
||||||
@@ -66,14 +68,12 @@ const FolderNode = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!isRoot && (
|
<span
|
||||||
<span
|
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
|
||||||
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
|
onClick={handleToggleClick}
|
||||||
onClick={handleToggleClick}
|
>
|
||||||
>
|
{icon}
|
||||||
{icon}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="name">
|
<span className="name">
|
||||||
<FolderIcon className="folder-icon-image" size={16} />
|
<FolderIcon className="folder-icon-image" size={16} />
|
||||||
{node.name}
|
{node.name}
|
||||||
@@ -161,6 +161,7 @@ const Sidebar = ({
|
|||||||
tenants = [],
|
tenants = [],
|
||||||
activeTenantId = null,
|
activeTenantId = null,
|
||||||
onSelectTenant,
|
onSelectTenant,
|
||||||
|
onOpenSettings,
|
||||||
}) => {
|
}) => {
|
||||||
const sortedCorrespondents = useMemo(
|
const sortedCorrespondents = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -234,7 +235,7 @@ const Sidebar = ({
|
|||||||
|
|
||||||
const handleTenantSelect = useCallback(
|
const handleTenantSelect = useCallback(
|
||||||
(tenant) => {
|
(tenant) => {
|
||||||
const targetId = tenant?.tenant_id || tenant?.tenantId || tenant?.id || null;
|
const targetId = tenant?.id || null;
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -249,6 +250,11 @@ const Sidebar = ({
|
|||||||
onLogout?.();
|
onLogout?.();
|
||||||
}, [onLogout]);
|
}, [onLogout]);
|
||||||
|
|
||||||
|
const handleSettingsFromMenu = useCallback(() => {
|
||||||
|
setTenantMenuOpen(false);
|
||||||
|
onOpenSettings?.();
|
||||||
|
}, [onOpenSettings]);
|
||||||
|
|
||||||
const renderNodes = useCallback(
|
const renderNodes = useCallback(
|
||||||
(ids, depth) =>
|
(ids, depth) =>
|
||||||
ids.map((id) => {
|
ids.map((id) => {
|
||||||
@@ -319,7 +325,7 @@ const Sidebar = ({
|
|||||||
<span className="menu__empty">No tenants available.</span>
|
<span className="menu__empty">No tenants available.</span>
|
||||||
) : (
|
) : (
|
||||||
tenants.map((tenant) => {
|
tenants.map((tenant) => {
|
||||||
const tenantId = tenant?.tenant_id || tenant?.tenantId || tenant?.id || null;
|
const tenantId = tenant?.id || null;
|
||||||
const isActive = tenantId === activeTenantId;
|
const isActive = tenantId === activeTenantId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -329,16 +335,26 @@ const Sidebar = ({
|
|||||||
onClick={() => handleTenantSelect(tenant)}
|
onClick={() => handleTenantSelect(tenant)}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
|
<span className="menu__check-slot">
|
||||||
|
{isActive ? <CheckIcon size={16} /> : null}
|
||||||
|
</span>
|
||||||
<span className="menu__label">
|
<span className="menu__label">
|
||||||
{tenant?.slug || tenant?.name || tenantId || 'Tenant'}
|
{tenant?.slug || tenant?.name || tenantId || 'Tenant'}
|
||||||
</span>
|
</span>
|
||||||
{isActive ? <span className="menu__active-indicator">Active</span> : null}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="menu__footer">
|
<div className="menu__footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="menu__settings"
|
||||||
|
onClick={handleSettingsFromMenu}
|
||||||
|
>
|
||||||
|
<SettingsIcon size={16} />
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="menu__logout"
|
className="menu__logout"
|
||||||
|
|||||||
+264
-46
@@ -36,6 +36,18 @@
|
|||||||
--shadow-faint: color-mix(in oklch, black 6%, transparent);
|
--shadow-faint: color-mix(in oklch, black 6%, transparent);
|
||||||
--shadow-soft: color-mix(in oklch, black 12%, transparent);
|
--shadow-soft: color-mix(in oklch, black 12%, transparent);
|
||||||
--shadow-medium: color-mix(in oklch, black 18%, transparent);
|
--shadow-medium: color-mix(in oklch, black 18%, transparent);
|
||||||
|
--shadow-strong: color-mix(in oklch, black 28%, transparent);
|
||||||
|
--shadow-deep: color-mix(in oklch, black 55%, transparent);
|
||||||
|
--shadow-pop: color-mix(in oklch, black 25%, transparent);
|
||||||
|
--outline-subtle: color-mix(in oklch, black 6%, transparent);
|
||||||
|
--overlay-dark: color-mix(in oklch, black 55%, transparent);
|
||||||
|
--overlay-dim: color-mix(in oklch, oklch(0.28 0.01 calc(265deg + var(--warmth))) 20%, transparent);
|
||||||
|
--overlay-darker: color-mix(in oklch, black 75%, transparent);
|
||||||
|
--surface-danger-subtle: color-mix(in oklch, var(--danger) 12%, transparent);
|
||||||
|
--overlay-accent-subtle: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||||
|
--border-strong: color-mix(in oklch, var(--fg) 24%, transparent);
|
||||||
|
--surface-hover: color-mix(in oklch, white 10%, transparent);
|
||||||
|
--overlay-accent-strong: color-mix(in oklch, var(--accent) 24%, transparent);
|
||||||
--overlay-backdrop: color-mix(in oklch, oklch(0.22 0.015 calc(265deg + var(--warmth))) 45%, transparent);
|
--overlay-backdrop: color-mix(in oklch, oklch(0.22 0.015 calc(265deg + var(--warmth))) 45%, transparent);
|
||||||
--overlay-shadow: color-mix(in oklch, oklch(0.22 0.015 calc(265deg + var(--warmth))) 18%, transparent);
|
--overlay-shadow: color-mix(in oklch, oklch(0.22 0.015 calc(265deg + var(--warmth))) 18%, transparent);
|
||||||
|
|
||||||
@@ -57,9 +69,17 @@
|
|||||||
--sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent);
|
--sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent);
|
||||||
--selection-ring: oklch(0.78 0.16 calc(260deg + var(--warmth)));
|
--selection-ring: oklch(0.78 0.16 calc(260deg + var(--warmth)));
|
||||||
--sidebar-active-pill-border: color-mix(in oklch, var(--accent) 64%, transparent);
|
--sidebar-active-pill-border: color-mix(in oklch, var(--accent) 64%, transparent);
|
||||||
|
--link: var(--accent);
|
||||||
|
--link-visited: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%);
|
||||||
|
--preview-nav-bg: oklch(0.18 0.05 calc(270deg + var(--warmth)));
|
||||||
|
--preview-nav-bg-hover: oklch(0.14 0.05 calc(270deg + var(--warmth)));
|
||||||
|
--preview-nav-fg: var(--on-accent);
|
||||||
|
--text-on-dark: color-mix(in oklch, white 90%, transparent);
|
||||||
|
--surface-ink-soft: color-mix(in oklch, var(--fg) 8%, transparent);
|
||||||
|
|
||||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
|
||||||
--detail-panel-width: 30em;
|
--detail-panel-width: 30em;
|
||||||
--documents-grid-title-size: 0.8rem;
|
--documents-grid-title-size: 0.8rem;
|
||||||
@@ -86,7 +106,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a:visited {
|
a:visited {
|
||||||
color: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%);
|
color: var(--link-visited);
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover,
|
a:hover,
|
||||||
@@ -244,7 +264,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
.preview-zoom-backdrop {
|
.preview-zoom-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(15, 23, 42, 0);
|
background: var(--overlay-backdrop);
|
||||||
transition: background 0.25s ease, opacity 0.25s ease;
|
transition: background 0.25s ease, opacity 0.25s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -283,7 +303,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
max-height: 95vh;
|
max-height: 95vh;
|
||||||
box-shadow: 0 32px 120px rgba(15, 23, 42, 0.55);
|
box-shadow: 0 32px 120px var(--shadow-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-zoom__scroll:focus {
|
.preview-zoom__scroll:focus {
|
||||||
@@ -322,23 +342,23 @@ button.danger:hover:not([disabled]) {
|
|||||||
height: 2.8rem;
|
height: 2.8rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(0, 0, 0, 0.62);
|
background: var(--preview-nav-bg);
|
||||||
color: #fff;
|
color: var(--preview-nav-fg);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
transition: background 0.15s ease, transform 0.15s ease;
|
transition: background 0.15s ease, transform 0.15s ease;
|
||||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.28);
|
box-shadow: 0 12px 28px var(--shadow-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-zoom__nav-button:hover {
|
.preview-zoom__nav-button:hover {
|
||||||
background: rgba(0, 0, 0, 0.82);
|
background: var(--preview-nav-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-zoom__nav-button:focus-visible {
|
.preview-zoom__nav-button:focus-visible {
|
||||||
outline: 2px solid var(--accent, #4f46e5);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,9 +624,9 @@ button.danger:hover:not([disabled]) {
|
|||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 6px 12px var(--shadow-pop);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: rgba(32, 34, 40, 0.2);
|
background-color: var(--overlay-dim);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -668,7 +688,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: var(--text-on-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-drag-preview__count {
|
.document-drag-preview__count {
|
||||||
@@ -676,12 +696,12 @@ button.danger:hover:not([disabled]) {
|
|||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
right: 4px;
|
right: 4px;
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 4px 8px var(--shadow-pop);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,7 +723,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
.preview-workspace__info {
|
.preview-workspace__info {
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
|
box-shadow: inset 0 0 0 1px var(--outline-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace__summary {
|
.preview-workspace__summary {
|
||||||
@@ -737,7 +757,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
.preview-workspace__metadata {
|
.preview-workspace__metadata {
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
|
box-shadow: inset 0 0 0 1px var(--outline-subtle);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: 40vh;
|
max-height: 40vh;
|
||||||
@@ -758,7 +778,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
|
box-shadow: inset 0 0 0 1px var(--outline-subtle);
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -955,8 +975,8 @@ button.danger:hover:not([disabled]) {
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
background: rgba(0, 0, 0, 0.65);
|
background: var(--overlay-dark);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -1095,9 +1115,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-children.folder-children--level1 {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-section {
|
.sidebar-section {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -1154,7 +1172,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__title-button:focus-visible {
|
.sidebar__title-button:focus-visible {
|
||||||
outline: 2px solid var(--accent, #2563eb);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,7 +1192,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border-muted, var(--border));
|
border: 1px solid var(--border-muted, var(--border));
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
|
box-shadow: 0 12px 28px var(--shadow-strong);
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1193,7 +1211,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
.menu .menu__item {
|
.menu .menu__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.55rem 1rem;
|
padding: 0.55rem 1rem;
|
||||||
@@ -1217,12 +1235,12 @@ button.danger:hover:not([disabled]) {
|
|||||||
.menu button.menu__item.active,
|
.menu button.menu__item.active,
|
||||||
.menu .menu__item.active {
|
.menu .menu__item.active {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent, #2563eb);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu button.menu__item:focus-visible,
|
.menu button.menu__item:focus-visible,
|
||||||
.menu .menu__item:focus-visible {
|
.menu .menu__item:focus-visible {
|
||||||
outline: 2px solid var(--accent, #2563eb);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1232,6 +1250,14 @@ button.danger:hover:not([disabled]) {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu__check-slot {
|
||||||
|
width: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.menu__active-indicator {
|
.menu__active-indicator {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -1247,6 +1273,28 @@ button.danger:hover:not([disabled]) {
|
|||||||
.menu__footer {
|
.menu__footer {
|
||||||
border-top: 1px solid var(--border-muted, var(--border));
|
border-top: 1px solid var(--border-muted, var(--border));
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__settings {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu__settings:hover,
|
||||||
|
.menu__settings:focus-visible {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu__logout {
|
.menu__logout {
|
||||||
@@ -1265,7 +1313,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
|
|
||||||
.menu__logout:hover,
|
.menu__logout:hover,
|
||||||
.menu__logout:focus-visible {
|
.menu__logout:focus-visible {
|
||||||
background: rgba(209, 67, 67, 0.12);
|
background: var(--surface-danger-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__search {
|
.sidebar__search {
|
||||||
@@ -1417,12 +1465,12 @@ button.danger:hover:not([disabled]) {
|
|||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease,
|
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease,
|
||||||
opacity 0.12s ease;
|
opacity 0.12s ease;
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
box-shadow: 0 1px 2px var(--shadow-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tag-pill:hover {
|
.sidebar-tag-pill:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
|
box-shadow: 0 2px 6px var(--shadow-pop);
|
||||||
border-color: var(--accent-soft);
|
border-color: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1463,7 +1511,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
|
|
||||||
.sidebar-correspondent-item:hover {
|
.sidebar-correspondent-item:hover {
|
||||||
background: var(--sidebar-hover-bg);
|
background: var(--sidebar-hover-bg);
|
||||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
box-shadow: 0 1px 3px var(--shadow-soft);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1473,8 +1521,8 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-correspondent-item.active {
|
.sidebar-correspondent-item.active {
|
||||||
background: var(--sidebar-active-bg, rgba(59, 130, 246, 0.18));
|
background: var(--sidebar-active-bg);
|
||||||
box-shadow: 0 0 0 1px var(--sidebar-active-bg, rgba(59, 130, 246, 0.24));
|
box-shadow: 0 0 0 1px var(--sidebar-active-pill-border);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1860,7 +1908,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--accent, #2563eb);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1911,7 +1959,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.documents-panel tbody tr.document.tag-drop-target {
|
.documents-panel tbody tr.document.tag-drop-target {
|
||||||
background: var(--accent-soft, rgba(63, 106, 216, 0.12));
|
background: var(--accent-soft);
|
||||||
box-shadow: inset 0 0 0 2px var(--accent);
|
box-shadow: inset 0 0 0 2px var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1967,7 +2015,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
|
|
||||||
.tag-chip--more {
|
.tag-chip--more {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: var(--border-strong, rgba(15, 23, 42, 0.24));
|
border-color: var(--border-strong);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2078,14 +2126,14 @@ button.danger:hover:not([disabled]) {
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.05);
|
box-shadow: inset 0 1px 2px var(--shadow-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-ocr__content pre {
|
.detail-ocr__content pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-family: var(--font-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
@@ -2096,7 +2144,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
height: 60vh;
|
height: 60vh;
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.05);
|
box-shadow: inset 0 1px 2px var(--shadow-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal__body {
|
.modal__body {
|
||||||
@@ -2309,15 +2357,15 @@ button.danger:hover:not([disabled]) {
|
|||||||
height: 2.2em;
|
height: 2.2em;
|
||||||
padding: 0.45em;
|
padding: 0.45em;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: var(--preview-nav-bg);
|
||||||
color: #fff;
|
color: var(--preview-nav-fg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, opacity 0.15s ease;
|
transition: background 0.15s ease, opacity 0.15s ease;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-pane__nav-button:hover:not([disabled]) {
|
.preview-pane__nav-button:hover:not([disabled]) {
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: var(--preview-nav-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-pane__nav-button:disabled {
|
.preview-pane__nav-button:disabled {
|
||||||
@@ -2326,7 +2374,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-pane__nav-button:focus-visible {
|
.preview-pane__nav-button:focus-visible {
|
||||||
outline: 2px solid var(--accent, #2684ff);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2398,7 +2446,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
border: 1px solid var(--border-muted, var(--border));
|
border: 1px solid var(--border-muted, var(--border));
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-family: var(--font-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
@@ -2503,7 +2551,8 @@ form.inline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal--panel {
|
.modal--panel {
|
||||||
width: min(80vw, 960px);
|
width: 80vw;
|
||||||
|
max-width: 960px;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -2511,6 +2560,175 @@ form.inline {
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-modal__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12rem 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
height: 90vh;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal__sidebar {
|
||||||
|
border-right: 1px solid var(--border-muted, var(--border));
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal__sidebar ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal__sidebar button {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal__sidebar button:hover,
|
||||||
|
.settings-modal__sidebar button:focus-visible {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal__sidebar button.active {
|
||||||
|
background: var(--surface-soft);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal__content {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions .secondary {
|
||||||
|
min-width: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form__field input[type='text'],
|
||||||
|
.settings-form__field input[type='datetime-local'] {
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form__error {
|
||||||
|
color: var(--danger, #d14343);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-empty {
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table th,
|
||||||
|
.settings-table td {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-muted, var(--border));
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table__actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-status {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table tr.is-revoked {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-notice {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-display {
|
||||||
|
background: var(--surface-ink-soft);
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-notice__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-notice__actions button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-modal__header {
|
.panel-modal__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2651,8 +2869,8 @@ form.inline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-card__tenant-button:hover:not([disabled]) {
|
.login-card__tenant-button:hover:not([disabled]) {
|
||||||
background: var(--surface-hover, rgba(255, 255, 255, 0.1));
|
background: var(--surface-hover);
|
||||||
border-color: var(--border-strong, var(--border));
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card__tenant-button:disabled {
|
.login-card__tenant-button:disabled {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
IconLogout,
|
IconLogout,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconX,
|
IconX,
|
||||||
|
IconSettings,
|
||||||
|
IconCheck,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FolderSvg from '../assets/folder.svg';
|
import FolderSvg from '../assets/folder.svg';
|
||||||
|
|
||||||
@@ -195,6 +197,24 @@ export const CloseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) =>
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const SettingsIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconSettings
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconCheck
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconAnalyze
|
<IconAnalyze
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
const webpack = require('webpack');
|
|
||||||
const dotenv = require('dotenv');
|
|
||||||
|
|
||||||
const env = dotenv.config({ path: path.resolve(__dirname, '.env.local') }).parsed || {};
|
|
||||||
|
|
||||||
const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
|
|
||||||
|
|
||||||
const API_BASE_URL = env.API_BASE_URL || process.env.API_BASE_URL || DEFAULT_DEV_API;
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: './src/index.jsx',
|
entry: './src/index.jsx',
|
||||||
output: {
|
output: {
|
||||||
@@ -46,9 +37,6 @@ module.exports = {
|
|||||||
template: path.resolve(__dirname, 'src/index.html'),
|
template: path.resolve(__dirname, 'src/index.html'),
|
||||||
favicon: false,
|
favicon: false,
|
||||||
}),
|
}),
|
||||||
new webpack.DefinePlugin({
|
|
||||||
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
devServer: {
|
devServer: {
|
||||||
static: {
|
static: {
|
||||||
@@ -58,6 +46,14 @@ module.exports = {
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
historyApiFallback: true,
|
historyApiFallback: true,
|
||||||
open: true,
|
open: true,
|
||||||
|
proxy: [
|
||||||
|
{
|
||||||
|
context: ['/api'],
|
||||||
|
target: 'http://127.0.0.1:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
devtool: 'source-map',
|
devtool: 'source-map',
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ spec:
|
|||||||
containerPort: {{ .Values.frontend.service.port }}
|
containerPort: {{ .Values.frontend.service.port }}
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
env:
|
env:
|
||||||
|
- name: API_PROXY_PASS
|
||||||
|
value: {{ printf "http://%s-backend:%d" (include "papercrate.fullname" . ) (int (.Values.backend.service.port)) | quote }}
|
||||||
{{- range .Values.frontend.env.extra }}
|
{{- range .Values.frontend.env.extra }}
|
||||||
- name: {{ .name }}
|
- name: {{ .name }}
|
||||||
value: {{ .value | quote }}
|
value: {{ .value | quote }}
|
||||||
|
|||||||
Reference in New Issue
Block a user