7 Commits
Author SHA1 Message Date
nils 0052dff119 serve api on same origin at /api 2025-10-29 01:52:48 +01:00
nils 3bdca88144 stuff 2025-10-29 00:53:59 +01:00
nils 4b18429945 work 2025-10-29 00:40:13 +01:00
nils d9b2297eca backend: webdav tokens 2025-10-29 00:12:58 +01:00
nils b4282c2b4b frontend cleanup 2025-10-29 00:02:44 +01:00
nils 8554fbd6e5 frontend 2025-10-28 23:32:46 +01:00
nils 550bb7f0eb fix 2025-10-28 13:53:29 +01:00
31 changed files with 1630 additions and 353 deletions
@@ -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
View File
@@ -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};
+184
View File
@@ -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());
}
}
+13
View File
@@ -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,
+29
View File
@@ -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
View File
@@ -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>,
}
} }
+3 -9
View File
@@ -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();
+9
View File
@@ -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));
+109
View File
@@ -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())
}
+48 -114
View File
@@ -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 => {
if !valid { tracing::warn!(%username, "webdav token invalid or expired");
tracing::warn!(%username, "webdav password invalid");
return Ok(None); return Ok(None);
} }
};
let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships let tenant_row = 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,
None => {
tracing::warn!(
%username,
tenant_id = %token.tenant_id,
"webdav token tenant membership missing"
);
return Ok(None); 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);
} }
+18
View File
@@ -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,
); );
+19 -1
View File
@@ -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();
+57 -2
View File
@@ -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();
+1
View File
@@ -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 \
+153
View File
@@ -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
View File
@@ -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
+31 -5
View File
@@ -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 "$@"
-11
View File
@@ -1,11 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}
-1
View File
@@ -1 +0,0 @@
window.__PAPERCRATE_API_BASE_URL = window.__PAPERCRATE_API_BASE_URL || '';
+10 -14
View File
@@ -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%;
-1
View File
@@ -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>
+228 -102
View File
@@ -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 {
const rawTenant = storage.getItem('papercrate_tenant');
if (rawTenant) { if (rawTenant) {
STORED_TENANT = JSON.parse(rawTenant); STORED_TENANT = JSON.parse(rawTenant);
} }
} catch ( } catch (
// eslint-disable-next-line no-empty // eslint-disable-next-line no-empty
error 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) {
const ancestorId = stack.pop();
if (!ancestorId || ancestorId === 'root') {
continue;
}
if (!folderNodes.has(ancestorId)) {
try { try {
await ensureFolderData(ancestorId, { const contents = await ensureFolderData(folderId, {
includeDocuments: false, includeDocuments: false,
force: false, 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);
}
};
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 {
await ensureFolderData('root', {
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}`,
+279
View File
@@ -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;
+24 -8
View File
@@ -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
View File
@@ -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 {
+20
View File
@@ -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)}
+8 -12
View File
@@ -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 }}