preview stack, refresh token, design
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE refresh_tokens;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE refresh_tokens (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
issued_at TIMESTAMPTZ NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||||
|
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||||
@@ -11,6 +11,10 @@ pub struct AppConfig {
|
|||||||
pub jwt_issuer: String,
|
pub jwt_issuer: String,
|
||||||
pub jwt_audience: String,
|
pub jwt_audience: String,
|
||||||
pub jwt_expiry_minutes: i64,
|
pub jwt_expiry_minutes: i64,
|
||||||
|
pub refresh_token_expiry_days: i64,
|
||||||
|
pub refresh_cookie_secure: bool,
|
||||||
|
pub refresh_cookie_domain: Option<String>,
|
||||||
|
pub cors_allowed_origin: Option<String>,
|
||||||
pub aws_endpoint_url: Option<String>,
|
pub aws_endpoint_url: Option<String>,
|
||||||
pub aws_access_key_id: Option<String>,
|
pub aws_access_key_id: Option<String>,
|
||||||
pub aws_secret_access_key: Option<String>,
|
pub aws_secret_access_key: Option<String>,
|
||||||
@@ -34,6 +38,15 @@ impl AppConfig {
|
|||||||
.unwrap_or_else(|_| "60".to_string())
|
.unwrap_or_else(|_| "60".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.context("JWT_EXPIRY_MINUTES must be an integer")?;
|
.context("JWT_EXPIRY_MINUTES must be an integer")?;
|
||||||
|
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
|
||||||
|
.unwrap_or_else(|_| "30".to_string())
|
||||||
|
.parse()
|
||||||
|
.context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?;
|
||||||
|
let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE")
|
||||||
|
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok();
|
||||||
|
let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok();
|
||||||
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
|
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
|
||||||
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
|
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
|
||||||
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
|
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
|
||||||
@@ -48,6 +61,10 @@ impl AppConfig {
|
|||||||
jwt_issuer,
|
jwt_issuer,
|
||||||
jwt_audience,
|
jwt_audience,
|
||||||
jwt_expiry_minutes,
|
jwt_expiry_minutes,
|
||||||
|
refresh_token_expiry_days,
|
||||||
|
refresh_cookie_secure,
|
||||||
|
refresh_cookie_domain,
|
||||||
|
cors_allowed_origin,
|
||||||
aws_endpoint_url,
|
aws_endpoint_url,
|
||||||
aws_access_key_id,
|
aws_access_key_id,
|
||||||
aws_secret_access_key,
|
aws_secret_access_key,
|
||||||
|
|||||||
@@ -191,3 +191,27 @@ pub struct NewDocumentTag {
|
|||||||
pub tag_id: Uuid,
|
pub tag_id: Uuid,
|
||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
|
#[diesel(table_name = refresh_tokens)]
|
||||||
|
#[diesel(belongs_to(User))]
|
||||||
|
pub struct RefreshToken {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub issued_at: NaiveDateTime,
|
||||||
|
pub expires_at: NaiveDateTime,
|
||||||
|
pub revoked_at: Option<NaiveDateTime>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = refresh_tokens)]
|
||||||
|
pub struct NewRefreshToken {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub issued_at: NaiveDateTime,
|
||||||
|
pub expires_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|||||||
+202
-12
@@ -1,15 +1,28 @@
|
|||||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use axum_extra::{headers::Cookie, typed_header::TypedHeader};
|
||||||
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{password, AuthenticatedUser},
|
auth::{password, AuthenticatedUser},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::User,
|
models::{NewRefreshToken, RefreshToken, User},
|
||||||
schema::users::dsl,
|
schema::{refresh_tokens, users::dsl},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::schema::refresh_tokens::dsl as refresh_dsl;
|
||||||
|
|
||||||
|
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -26,7 +39,7 @@ pub struct LoginResponse {
|
|||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<LoginRequest>,
|
Json(payload): Json<LoginRequest>,
|
||||||
) -> AppResult<Json<LoginResponse>> {
|
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db()?;
|
||||||
|
|
||||||
let user: User = dsl::users
|
let user: User = dsl::users
|
||||||
@@ -40,22 +53,199 @@ pub async fn login(
|
|||||||
return Err(AppError::unauthorized());
|
return Err(AppError::unauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = state
|
let access_token = state
|
||||||
.jwt
|
.jwt
|
||||||
.generate_token(user.id, &user.username, &user.role)
|
.generate_token(user.id, &user.username, &user.role)
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
let now = Utc::now();
|
||||||
access_token: token,
|
let refresh_value = generate_refresh_token();
|
||||||
token_type: "Bearer".to_string(),
|
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
}))
|
|
||||||
|
let new_refresh = NewRefreshToken {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: user.id,
|
||||||
|
token_hash: refresh_hash,
|
||||||
|
issued_at: now.naive_utc(),
|
||||||
|
expires_at: refresh_expires_at.naive_utc(),
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(refresh_tokens::table)
|
||||||
|
.values(&new_refresh)
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
SET_COOKIE,
|
||||||
|
build_refresh_cookie(&state, &refresh_value, refresh_expires_at),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
headers,
|
||||||
|
Json(LoginResponse {
|
||||||
|
access_token,
|
||||||
|
token_type: "Bearer".to_string(),
|
||||||
|
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||||
|
}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(_user: AuthenticatedUser) -> impl IntoResponse {
|
pub async fn refresh(
|
||||||
StatusCode::NO_CONTENT
|
State(state): State<AppState>,
|
||||||
|
jar: Option<TypedHeader<Cookie>>,
|
||||||
|
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
|
||||||
|
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
||||||
|
let refresh_value = cookies
|
||||||
|
.get(REFRESH_COOKIE_NAME)
|
||||||
|
.ok_or_else(AppError::unauthorized)?;
|
||||||
|
|
||||||
|
let hashed = hash_refresh_token(refresh_value);
|
||||||
|
let mut conn = state.db()?;
|
||||||
|
let now = Utc::now();
|
||||||
|
let now_naive = now.naive_utc();
|
||||||
|
|
||||||
|
let token = match refresh_dsl::refresh_tokens
|
||||||
|
.filter(refresh_dsl::token_hash.eq(&hashed))
|
||||||
|
.filter(refresh_dsl::revoked_at.is_null())
|
||||||
|
.filter(refresh_dsl::expires_at.gt(now_naive))
|
||||||
|
.first::<RefreshToken>(&mut conn)
|
||||||
|
{
|
||||||
|
Ok(token) => token,
|
||||||
|
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
|
||||||
|
Err(err) => return Err(AppError::from(err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id)))
|
||||||
|
.set((
|
||||||
|
refresh_dsl::revoked_at.eq(now_naive),
|
||||||
|
refresh_dsl::updated_at.eq(now_naive),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
let user: User = dsl::users
|
||||||
|
.find(token.user_id)
|
||||||
|
.first(&mut conn)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let access_token = state
|
||||||
|
.jwt
|
||||||
|
.generate_token(user.id, &user.username, &user.role)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let new_refresh_value = generate_refresh_token();
|
||||||
|
let new_refresh_hash = hash_refresh_token(&new_refresh_value);
|
||||||
|
let new_refresh_expires = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
|
|
||||||
|
let new_refresh = NewRefreshToken {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: user.id,
|
||||||
|
token_hash: new_refresh_hash,
|
||||||
|
issued_at: now_naive,
|
||||||
|
expires_at: new_refresh_expires.naive_utc(),
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(refresh_tokens::table)
|
||||||
|
.values(&new_refresh)
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
SET_COOKIE,
|
||||||
|
build_refresh_cookie(&state, &new_refresh_value, new_refresh_expires),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
headers,
|
||||||
|
Json(LoginResponse {
|
||||||
|
access_token,
|
||||||
|
token_type: "Bearer".to_string(),
|
||||||
|
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
jar: Option<TypedHeader<Cookie>>,
|
||||||
|
) -> AppResult<(HeaderMap, StatusCode)> {
|
||||||
|
let mut conn = state.db()?;
|
||||||
|
if let Some(cookies) = jar {
|
||||||
|
if let Some(value) = cookies.get(REFRESH_COOKIE_NAME) {
|
||||||
|
let hashed = hash_refresh_token(value);
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let _ = diesel::update(
|
||||||
|
refresh_dsl::refresh_tokens
|
||||||
|
.filter(refresh_dsl::token_hash.eq(hashed))
|
||||||
|
.filter(refresh_dsl::user_id.eq(user.user_id))
|
||||||
|
.filter(refresh_dsl::revoked_at.is_null()),
|
||||||
|
)
|
||||||
|
.set((
|
||||||
|
refresh_dsl::revoked_at.eq(now),
|
||||||
|
refresh_dsl::updated_at.eq(now),
|
||||||
|
))
|
||||||
|
.execute(&mut conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(SET_COOKIE, build_clear_refresh_cookie(&state));
|
||||||
|
Ok((headers, StatusCode::NO_CONTENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
||||||
Json(user)
|
Json(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hash_refresh_token(token: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_refresh_token() -> String {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_refresh_cookie(
|
||||||
|
state: &AppState,
|
||||||
|
token: &str,
|
||||||
|
expires_at: chrono::DateTime<Utc>,
|
||||||
|
) -> HeaderValue {
|
||||||
|
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
|
||||||
|
|
||||||
|
let mut parts = vec![format!("{}={}", REFRESH_COOKIE_NAME, token)];
|
||||||
|
parts.push("Path=/".into());
|
||||||
|
parts.push("HttpOnly".into());
|
||||||
|
parts.push("SameSite=Strict".into());
|
||||||
|
parts.push(format!("Max-Age={}", max_age));
|
||||||
|
parts.push(format!("Expires={}", expires_at.to_rfc2822()));
|
||||||
|
if state.config.refresh_cookie_secure {
|
||||||
|
parts.push("Secure".into());
|
||||||
|
}
|
||||||
|
if let Some(domain) = &state.config.refresh_cookie_domain {
|
||||||
|
parts.push(format!("Domain={}", domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue {
|
||||||
|
let mut parts = vec![format!("{}=", REFRESH_COOKIE_NAME)];
|
||||||
|
parts.push("Path=/".into());
|
||||||
|
parts.push("HttpOnly".into());
|
||||||
|
parts.push("SameSite=Strict".into());
|
||||||
|
parts.push("Max-Age=0".into());
|
||||||
|
parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into());
|
||||||
|
if state.config.refresh_cookie_secure {
|
||||||
|
parts.push("Secure".into());
|
||||||
|
}
|
||||||
|
if let Some(domain) = &state.config.refresh_cookie_domain {
|
||||||
|
parts.push(format!("Domain={}", domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
use axum::http::HeaderValue;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::DefaultBodyLimit,
|
extract::DefaultBodyLimit,
|
||||||
middleware,
|
middleware,
|
||||||
routing::{delete, get, patch, post},
|
routing::{delete, get, patch, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||||
|
|
||||||
use crate::{auth::AuthenticatedUser, state::AppState};
|
use crate::{auth::AuthenticatedUser, state::AppState};
|
||||||
|
|
||||||
@@ -15,13 +16,37 @@ pub mod health;
|
|||||||
pub mod tags;
|
pub mod tags;
|
||||||
|
|
||||||
pub fn create_router(state: AppState) -> Router<()> {
|
pub fn create_router(state: AppState) -> Router<()> {
|
||||||
let cors = CorsLayer::new()
|
let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() {
|
||||||
.allow_origin(Any)
|
let headers: Vec<HeaderValue> = origins
|
||||||
.allow_methods(Any)
|
.split(',')
|
||||||
.allow_headers(Any);
|
.filter_map(|value| {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
(!trimmed.is_empty()).then(|| {
|
||||||
|
trimmed
|
||||||
|
.parse::<HeaderValue>()
|
||||||
|
.expect("invalid CORS allowed origin")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let allow_origin = AllowOrigin::list(headers);
|
||||||
|
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(allow_origin)
|
||||||
|
.allow_methods(tower_http::cors::AllowMethods::mirror_request())
|
||||||
|
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
|
||||||
|
.allow_credentials(true)
|
||||||
|
} else {
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(AllowOrigin::mirror_request())
|
||||||
|
.allow_methods(tower_http::cors::AllowMethods::mirror_request())
|
||||||
|
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
|
||||||
|
.allow_credentials(true)
|
||||||
|
};
|
||||||
|
|
||||||
let auth_routes = Router::new()
|
let auth_routes = Router::new()
|
||||||
.route("/login", post(auth::login))
|
.route("/login", post(auth::login))
|
||||||
|
.route("/refresh", post(auth::refresh))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
.route("/me", get(auth::me));
|
.route("/me", get(auth::me));
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,19 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
refresh_tokens (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
user_id -> Uuid,
|
||||||
|
token_hash -> Text,
|
||||||
|
issued_at -> Timestamptz,
|
||||||
|
expires_at -> Timestamptz,
|
||||||
|
revoked_at -> Nullable<Timestamptz>,
|
||||||
|
created_at -> Timestamptz,
|
||||||
|
updated_at -> Timestamptz,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
tags (id) {
|
tags (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
@@ -117,6 +130,7 @@ diesel::joinable!(document_tags -> tags (tag_id));
|
|||||||
diesel::joinable!(document_tags -> users (assigned_by));
|
diesel::joinable!(document_tags -> users (assigned_by));
|
||||||
diesel::joinable!(document_versions -> documents (document_id));
|
diesel::joinable!(document_versions -> documents (document_id));
|
||||||
diesel::joinable!(documents -> folders (folder_id));
|
diesel::joinable!(documents -> folders (folder_id));
|
||||||
|
diesel::joinable!(refresh_tokens -> users (user_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
document_assets,
|
document_assets,
|
||||||
@@ -125,6 +139,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
documents,
|
documents,
|
||||||
folders,
|
folders,
|
||||||
jobs,
|
jobs,
|
||||||
|
refresh_tokens,
|
||||||
tags,
|
tags,
|
||||||
users,
|
users,
|
||||||
);
|
);
|
||||||
|
|||||||
+17
-61
@@ -21,6 +21,7 @@ const API_ROOT = (process.env.API_BASE_URL || '').replace(/\/$/, '');
|
|||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
|
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
|
||||||
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_FOLDER_NAME = 'All Documents';
|
const DEFAULT_FOLDER_NAME = 'All Documents';
|
||||||
@@ -562,7 +563,7 @@ const computeStackAngle = (docId, index) => {
|
|||||||
return magnitude * sign;
|
return magnitude * sign;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_PREVIEW_STACK_ITEMS = 4;
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||||
|
|
||||||
const PreviewStack = ({
|
const PreviewStack = ({
|
||||||
items = [],
|
items = [],
|
||||||
@@ -572,75 +573,27 @@ const PreviewStack = ({
|
|||||||
onOpenPreview,
|
onOpenPreview,
|
||||||
activeItemId = null,
|
activeItemId = null,
|
||||||
}) => {
|
}) => {
|
||||||
const angleMapRef = useRef(new Map());
|
|
||||||
const previousOrderRef = useRef([]);
|
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return <span className="meta">{emptyMessage}</span>;
|
return <span className="meta">{emptyMessage}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const limited = items.slice(0, maxItems);
|
const limited = items.slice(0, maxItems);
|
||||||
const hasMultiple = limited.length > 1;
|
const hasMultiple = limited.length > 1;
|
||||||
|
const preparedItems = useMemo(
|
||||||
const preparedItems = useMemo(() => {
|
() =>
|
||||||
const angleMap = angleMapRef.current;
|
limited.map((entry, index) => ({
|
||||||
const previousOrder = previousOrderRef.current;
|
|
||||||
const prevFrontId = previousOrder.length ? previousOrder[0] : null;
|
|
||||||
const currentFrontId = limited.length ? limited[0].id : null;
|
|
||||||
const currentIds = new Set();
|
|
||||||
|
|
||||||
limited.forEach((entry, index) => {
|
|
||||||
const id = entry.id;
|
|
||||||
currentIds.add(id);
|
|
||||||
if (!angleMap.has(id)) {
|
|
||||||
const initialAngle = index === 0 ? 0 : computeStackAngle(id, index);
|
|
||||||
angleMap.set(id, initialAngle);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
onItemActivate &&
|
|
||||||
activeItemId &&
|
|
||||||
currentFrontId === activeItemId &&
|
|
||||||
prevFrontId &&
|
|
||||||
prevFrontId !== activeItemId
|
|
||||||
) {
|
|
||||||
const frontAngle = angleMap.get(prevFrontId);
|
|
||||||
const activeAngle = angleMap.get(activeItemId);
|
|
||||||
angleMap.set(
|
|
||||||
prevFrontId,
|
|
||||||
typeof activeAngle === 'number' ? activeAngle : computeStackAngle(prevFrontId, 1),
|
|
||||||
);
|
|
||||||
angleMap.set(activeItemId, typeof frontAngle === 'number' ? frontAngle : 0);
|
|
||||||
} else if (currentFrontId && (!angleMap.has(currentFrontId) || angleMap.get(currentFrontId) !== 0)) {
|
|
||||||
angleMap.set(currentFrontId, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
angleMap.forEach((_, id) => {
|
|
||||||
if (!currentIds.has(id)) {
|
|
||||||
angleMap.delete(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = limited.map((entry, index) => {
|
|
||||||
const angle = angleMap.get(entry.id);
|
|
||||||
return {
|
|
||||||
entry,
|
entry,
|
||||||
angle: typeof angle === 'number' ? angle : computeStackAngle(entry.id, index),
|
angle: index === 0 ? 0 : computeStackAngle(entry.id, index),
|
||||||
offset: hasMultiple ? index * 8 : 0,
|
offset: 0,
|
||||||
};
|
})),
|
||||||
});
|
[limited],
|
||||||
|
);
|
||||||
previousOrderRef.current = limited.map((entry) => entry.id);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [limited, hasMultiple, activeItemId, onItemActivate]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="preview-stack preview-stack--stacked">
|
<div className="preview-stack preview-stack--stacked">
|
||||||
{preparedItems.map(({ entry, angle, offset }, index) => {
|
{preparedItems.map(({ entry, angle, offset }, index) => {
|
||||||
const transform = hasMultiple
|
const transform = hasMultiple
|
||||||
? `translate(-50%, -50%) rotate(${angle}deg) translateY(${offset}px)`
|
? `translate(-50%, -50%) rotate(${angle}deg)`
|
||||||
: 'translate(-50%, -50%)';
|
: 'translate(-50%, -50%)';
|
||||||
const isFront = index === 0;
|
const isFront = index === 0;
|
||||||
return (
|
return (
|
||||||
@@ -659,8 +612,6 @@ const PreviewStack = ({
|
|||||||
src={entry.url}
|
src={entry.url}
|
||||||
alt={entry.alt || ''}
|
alt={entry.alt || ''}
|
||||||
className="preview-stack__image"
|
className="preview-stack__image"
|
||||||
role={onItemActivate || onOpenPreview ? 'button' : undefined}
|
|
||||||
tabIndex={onItemActivate || onOpenPreview ? 0 : -1}
|
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (isFront && onOpenPreview) {
|
if (isFront && onOpenPreview) {
|
||||||
@@ -1195,7 +1146,7 @@ const Sidebar = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) {
|
function App({ routeFolderId = null, routeDocumentId = null, navigate }) {
|
||||||
const [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || '');
|
const [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || '');
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -1292,6 +1243,7 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) {
|
|||||||
if (!selectedDocumentIds.includes(activePreviewId)) {
|
if (!selectedDocumentIds.includes(activePreviewId)) {
|
||||||
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
|
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
|
||||||
}
|
}
|
||||||
|
initializedRef.current = true;
|
||||||
}, [selectedDocumentIds, activePreviewId]);
|
}, [selectedDocumentIds, activePreviewId]);
|
||||||
|
|
||||||
const currentFolderName = useMemo(() => {
|
const currentFolderName = useMemo(() => {
|
||||||
@@ -1318,6 +1270,10 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) {
|
|||||||
let nextSelection = [];
|
let nextSelection = [];
|
||||||
|
|
||||||
setSelectedDocumentIds((previous) => {
|
setSelectedDocumentIds((previous) => {
|
||||||
|
if (initializedRef.current) {
|
||||||
|
nextSelection = previous.filter((id) => availableIds.has(id));
|
||||||
|
return nextSelection;
|
||||||
|
}
|
||||||
const filtered = previous.filter((id) => availableIds.has(id));
|
const filtered = previous.filter((id) => availableIds.has(id));
|
||||||
if (filtered.length) {
|
if (filtered.length) {
|
||||||
nextSelection = filtered;
|
nextSelection = filtered;
|
||||||
|
|||||||
@@ -198,7 +198,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.75rem 1.5rem 1.25rem;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +214,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace__meta {
|
.preview-workspace__meta {
|
||||||
@@ -242,9 +242,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
|
|
||||||
.preview-workspace__body {
|
.preview-workspace__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--surface-subtle);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 2px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user