webdav token regeneration
This commit is contained in:
@@ -74,6 +74,51 @@ pub fn list_webdav_tokens(
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
pub fn regenerate_webdav_token(
|
||||
conn: &mut PgPooledConnection,
|
||||
token_id: Uuid,
|
||||
user_id: Uuid,
|
||||
tenant_id: Option<Uuid>,
|
||||
) -> Result<IssuedWebdavToken, AppError> {
|
||||
let mut query = webdav_tokens::table
|
||||
.filter(webdav_tokens::id.eq(token_id))
|
||||
.filter(webdav_tokens::user_id.eq(user_id))
|
||||
.into_boxed();
|
||||
|
||||
if let Some(tenant) = tenant_id {
|
||||
query = query.filter(webdav_tokens::tenant_id.eq(tenant));
|
||||
}
|
||||
|
||||
let record = query
|
||||
.first::<WebdavToken>(conn)
|
||||
.optional()
|
||||
.map_err(AppError::from)?
|
||||
.ok_or_else(AppError::not_found)?;
|
||||
|
||||
if record.revoked_at.is_some() {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot regenerate a revoked WebDAV token",
|
||||
));
|
||||
}
|
||||
|
||||
let raw_secret = generate_secret()?;
|
||||
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
||||
let token_hash = hash_secret(&raw_secret)?;
|
||||
|
||||
let updated = diesel::update(webdav_tokens::table.find(record.id))
|
||||
.set((
|
||||
webdav_tokens::token_prefix.eq(&token_prefix),
|
||||
webdav_tokens::token_hash.eq(&token_hash),
|
||||
webdav_tokens::last_used_at.eq::<Option<NaiveDateTime>>(None),
|
||||
))
|
||||
.get_result::<WebdavToken>(conn)?;
|
||||
|
||||
Ok(IssuedWebdavToken {
|
||||
token: raw_secret,
|
||||
record: updated,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_active_token_by_secret(
|
||||
conn: &mut PgPooledConnection,
|
||||
user_id: Uuid,
|
||||
|
||||
@@ -533,7 +533,10 @@ pub async fn list_tenants(
|
||||
.select(tenant_dsl::name)
|
||||
.first(&mut tenant_conn)
|
||||
.map_err(AppError::from)?;
|
||||
tenants.push(TenantSnippet { id: tenant_id, name });
|
||||
tenants.push(TenantSnippet {
|
||||
id: tenant_id,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(TenantListResponse { tenants }))
|
||||
@@ -699,7 +702,10 @@ fn complete_login(
|
||||
.select(tenant_dsl::name)
|
||||
.first(&mut tenant_conn)
|
||||
.map_err(AppError::from)?;
|
||||
tenants.push(TenantSnippet { id: tenant_id, name });
|
||||
tenants.push(TenantSnippet {
|
||||
id: tenant_id,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(TenantSelectionResponse {
|
||||
|
||||
@@ -148,6 +148,10 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
"/webdav-tokens",
|
||||
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
|
||||
)
|
||||
.route(
|
||||
"/webdav-tokens/:id/regenerate",
|
||||
post(profile::regenerate_webdav_token),
|
||||
)
|
||||
.route("/webdav-tokens/:id", delete(profile::delete_webdav_token))
|
||||
.route("/passkeys", get(profile::list_passkeys))
|
||||
.route("/passkeys/:id", delete(profile::delete_passkey));
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::auth::{
|
||||
passkeys::PasskeySummary,
|
||||
webdav_tokens::{
|
||||
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
||||
revoke_webdav_token as revoke_token,
|
||||
regenerate_webdav_token as rotate_token, revoke_webdav_token as revoke_token,
|
||||
},
|
||||
TenantScopedConn,
|
||||
};
|
||||
@@ -134,6 +134,31 @@ pub async fn create_webdav_token(
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/profile/webdav-tokens/{id}/regenerate",
|
||||
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
||||
responses((status = 200, description = "WebDAV token regenerated", body = WebdavTokenCreatedResponse)),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn regenerate_webdav_token(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Path(token_id): Path<Uuid>,
|
||||
) -> AppResult<Json<WebdavTokenCreatedResponse>> {
|
||||
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?;
|
||||
let response = WebdavTokenCreatedResponse {
|
||||
token: issued.token,
|
||||
token_info: webdav_token_to_response(issued.record),
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/profile/webdav-tokens/{id}",
|
||||
@@ -208,6 +233,7 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
|
||||
paths(
|
||||
crate::routes::profile::list_webdav_tokens,
|
||||
crate::routes::profile::create_webdav_token,
|
||||
crate::routes::profile::regenerate_webdav_token,
|
||||
crate::routes::profile::delete_webdav_token,
|
||||
crate::routes::profile::list_passkeys,
|
||||
crate::routes::profile::delete_passkey
|
||||
|
||||
@@ -21,8 +21,7 @@ use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, DocumentVersion, Folder, User};
|
||||
use crate::schema::{
|
||||
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
||||
folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl,
|
||||
users::dsl as users_dsl,
|
||||
folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc};
|
||||
|
||||
@@ -149,7 +149,7 @@ pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> {
|
||||
set_config('papercrate.tenant_id', '', false), \
|
||||
set_config('papercrate.user_id', '', false), \
|
||||
set_config('papercrate.refresh_token_hash', '', false), \
|
||||
set_config('papercrate.webdav_token_prefix', '', false)"
|
||||
set_config('papercrate.webdav_token_prefix', '', false)",
|
||||
)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
|
||||
@@ -90,23 +90,25 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
|
||||
let state_clone = state.clone();
|
||||
let tenant_id = job.tenant_id;
|
||||
let context = match task::spawn_blocking(move || load_context(state_clone, tenant_id, payload)).await {
|
||||
Ok(Ok(ctx)) => ctx,
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "index job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "index task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
let context =
|
||||
match task::spawn_blocking(move || load_context(state_clone, tenant_id, payload)).await
|
||||
{
|
||||
Ok(Ok(ctx)) => ctx,
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "index job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "index task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if context.text_s3_key.is_none() {
|
||||
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
|
||||
|
||||
+21
-17
@@ -83,22 +83,22 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(ctx)) => ctx,
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "ocr job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "ocr task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
Ok(Ok(ctx)) => ctx,
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "ocr job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "ocr task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if context.skip {
|
||||
info!(job_id = %job.id, "ocr already present; skipping");
|
||||
@@ -489,7 +489,11 @@ fn persist_ocr_metadata(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enqueue_index_job(state: &AppState, tenant_id: Uuid, payload: &OcrPayload) -> Result<(), String> {
|
||||
fn enqueue_index_job(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
payload: &OcrPayload,
|
||||
) -> Result<(), String> {
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
@@ -82,22 +82,22 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(ctx)) => ctx,
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "thumbnail job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "thumbnail task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
Ok(Ok(ctx)) => ctx,
|
||||
Ok(Err(err)) => {
|
||||
warn!(job_id = %job.id, error = %err, "thumbnail job will retry");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
Err(join_err) => {
|
||||
error!(job_id = %job.id, error = %join_err, "thumbnail task panicked");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(60),
|
||||
error: format!("worker panicked: {join_err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if initial.skip {
|
||||
info!(job_id = %job.id, "thumbnails already exist; skipping");
|
||||
@@ -129,7 +129,13 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
let version_id = initial.version.id;
|
||||
let tenant_id = initial.tenant_id;
|
||||
match task::spawn_blocking(move || {
|
||||
persist_document_page_count(state_clone, tenant_id, document_id, version_id, page_count)
|
||||
persist_document_page_count(
|
||||
state_clone,
|
||||
tenant_id,
|
||||
document_id,
|
||||
version_id,
|
||||
page_count,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -37,8 +37,7 @@ use tower::util::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||
const RESET_DATABASE_SQL: &str =
|
||||
"DROP SCHEMA IF EXISTS tenant CASCADE;\n\
|
||||
const RESET_DATABASE_SQL: &str = "DROP SCHEMA IF EXISTS tenant CASCADE;\n\
|
||||
DROP SCHEMA IF EXISTS shared CASCADE;\n\
|
||||
DROP SCHEMA IF EXISTS public CASCADE;\n\
|
||||
CREATE SCHEMA public;\n\
|
||||
|
||||
@@ -54,6 +54,20 @@ async fn webdav_token_api_crud() -> Result<()> {
|
||||
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
||||
assert!(created.info.last_used_at.is_none());
|
||||
|
||||
let regenerate_response = app
|
||||
.post_json(
|
||||
&format!("/api/profile/webdav-tokens/{token_id}/regenerate"),
|
||||
&json!({}),
|
||||
Some(&access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(regenerate_response.status(), StatusCode::OK);
|
||||
let regenerate_body = body_to_vec(regenerate_response.into_body()).await?;
|
||||
let regenerated: CreateTokenResponse = serde_json::from_slice(®enerate_body)?;
|
||||
assert_eq!(regenerated.info.id, token_id);
|
||||
assert_ne!(regenerated.token, created.token);
|
||||
assert!(regenerated.info.last_used_at.is_none());
|
||||
|
||||
let list_response = app
|
||||
.get("/api/profile/webdav-tokens", Some(&access_token))
|
||||
.await?;
|
||||
@@ -106,9 +120,10 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> {
|
||||
let token_id = created.info.id;
|
||||
|
||||
let router = webdav::create_router().with_state(app.state.clone());
|
||||
let original_secret = created.token.clone();
|
||||
let auth_header = format!(
|
||||
"Basic {}",
|
||||
BASE64.encode(format!("{}:{}", username, created.token))
|
||||
BASE64.encode(format!("{}:{}", username, original_secret))
|
||||
);
|
||||
|
||||
let propfind = Method::from_bytes(b"PROPFIND")?;
|
||||
@@ -131,6 +146,57 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> {
|
||||
.await?;
|
||||
assert!(used.is_some());
|
||||
|
||||
let regenerate_response = app
|
||||
.post_json(
|
||||
&format!("/api/profile/webdav-tokens/{token_id}/regenerate"),
|
||||
&json!({}),
|
||||
Some(&access_token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(regenerate_response.status(), StatusCode::OK);
|
||||
let regenerate_body = body_to_vec(regenerate_response.into_body()).await?;
|
||||
let regenerated: CreateTokenResponse = serde_json::from_slice(®enerate_body)?;
|
||||
assert_ne!(regenerated.token, original_secret);
|
||||
|
||||
let unused_after_regen = 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!(unused_after_regen.is_none());
|
||||
|
||||
let old_secret_request = Request::builder()
|
||||
.method(propfind.clone())
|
||||
.uri("/")
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!(
|
||||
"Basic {}",
|
||||
BASE64.encode(format!("{}:{}", username, original_secret))
|
||||
),
|
||||
)
|
||||
.header("depth", "0")
|
||||
.body(Body::empty())?;
|
||||
let old_secret_response = router.clone().oneshot(old_secret_request).await?;
|
||||
assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let new_secret_header = format!(
|
||||
"Basic {}",
|
||||
BASE64.encode(format!("{}:{}", username, regenerated.token))
|
||||
);
|
||||
|
||||
let success_request = Request::builder()
|
||||
.method(propfind.clone())
|
||||
.uri("/")
|
||||
.header(header::AUTHORIZATION, new_secret_header.clone())
|
||||
.header("depth", "0")
|
||||
.body(Body::empty())?;
|
||||
let response = router.clone().oneshot(success_request).await?;
|
||||
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
||||
|
||||
let delete_response = app
|
||||
.delete(
|
||||
&format!("/api/profile/webdav-tokens/{token_id}"),
|
||||
@@ -142,7 +208,7 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> {
|
||||
let failure_request = Request::builder()
|
||||
.method(propfind)
|
||||
.uri("/")
|
||||
.header(header::AUTHORIZATION, auth_header)
|
||||
.header(header::AUTHORIZATION, new_secret_header)
|
||||
.header("depth", "0")
|
||||
.body(Body::empty())?;
|
||||
let response = router.oneshot(failure_request).await?;
|
||||
|
||||
@@ -322,6 +322,7 @@ const AppLayout = () => {
|
||||
const [webdavTokensLoading, setWebdavTokensLoading] = useState(false);
|
||||
const [creatingWebdavToken, setCreatingWebdavToken] = useState(false);
|
||||
const [deletingWebdavTokenId, setDeletingWebdavTokenId] = useState(null);
|
||||
const [regeneratingWebdavTokenId, setRegeneratingWebdavTokenId] = useState(null);
|
||||
const [webdavTokenSecret, setWebdavTokenSecret] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||
@@ -1597,6 +1598,47 @@ const AppLayout = () => {
|
||||
[refreshWebdavTokens, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const regenerateWebdavToken = useCallback(
|
||||
async (tokenId) => {
|
||||
if (!tokenId) {
|
||||
return false;
|
||||
}
|
||||
setRegeneratingWebdavTokenId(tokenId);
|
||||
try {
|
||||
const { data } = await api.post(`/profile/webdav-tokens/${tokenId}/regenerate`);
|
||||
if (data?.token_info) {
|
||||
setWebdavTokens((previous) => {
|
||||
let found = false;
|
||||
const next = previous.map((entry) => {
|
||||
if (entry.id === data.token_info.id) {
|
||||
found = true;
|
||||
return data.token_info;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
if (!found) {
|
||||
return [data.token_info, ...previous];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
await refreshWebdavTokens();
|
||||
}
|
||||
if (data?.token) {
|
||||
setWebdavTokenSecret(data.token);
|
||||
}
|
||||
setStatusMessage('WebDAV token regenerated.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to regenerate WebDAV token.');
|
||||
return false;
|
||||
} finally {
|
||||
setRegeneratingWebdavTokenId(null);
|
||||
}
|
||||
},
|
||||
[notifyApiError, refreshWebdavTokens, setStatusMessage],
|
||||
);
|
||||
|
||||
const dismissCreatedWebdavToken = useCallback(() => {
|
||||
setWebdavTokenSecret(null);
|
||||
}, []);
|
||||
@@ -5040,9 +5082,11 @@ const AppLayout = () => {
|
||||
webdavTokensLoading,
|
||||
creatingWebdavToken,
|
||||
deletingWebdavTokenId,
|
||||
regeneratingWebdavTokenId,
|
||||
refreshWebdavTokens,
|
||||
createWebdavToken,
|
||||
deleteWebdavToken,
|
||||
regenerateWebdavToken,
|
||||
webdavTokenSecret,
|
||||
dismissCreatedWebdavToken,
|
||||
passkeys,
|
||||
@@ -5098,9 +5142,11 @@ const AppLayout = () => {
|
||||
webdavTokensLoading,
|
||||
creatingWebdavToken,
|
||||
deletingWebdavTokenId,
|
||||
regeneratingWebdavTokenId,
|
||||
refreshWebdavTokens,
|
||||
createWebdavToken,
|
||||
deleteWebdavToken,
|
||||
regenerateWebdavToken,
|
||||
webdavTokenSecret,
|
||||
dismissCreatedWebdavToken,
|
||||
passkeys,
|
||||
|
||||
@@ -10,9 +10,11 @@ const SettingsRoute = () => {
|
||||
webdavTokensLoading,
|
||||
creatingWebdavToken,
|
||||
deletingWebdavTokenId,
|
||||
regeneratingWebdavTokenId,
|
||||
refreshWebdavTokens,
|
||||
createWebdavToken,
|
||||
deleteWebdavToken,
|
||||
regenerateWebdavToken,
|
||||
webdavTokenSecret,
|
||||
dismissCreatedWebdavToken,
|
||||
passkeys,
|
||||
@@ -58,9 +60,11 @@ const SettingsRoute = () => {
|
||||
loading={webdavTokensLoading}
|
||||
creating={creatingWebdavToken}
|
||||
deletingId={deletingWebdavTokenId}
|
||||
regeneratingId={regeneratingWebdavTokenId}
|
||||
onRefresh={refreshWebdavTokens}
|
||||
onCreate={createWebdavToken}
|
||||
onDelete={deleteWebdavToken}
|
||||
onRegenerate={regenerateWebdavToken}
|
||||
createdToken={webdavTokenSecret}
|
||||
onDismissCreatedToken={dismissCreatedWebdavToken}
|
||||
passkeys={passkeys}
|
||||
|
||||
@@ -19,9 +19,11 @@ const SettingsModal = ({
|
||||
loading = false,
|
||||
creating = false,
|
||||
deletingId = null,
|
||||
regeneratingId = null,
|
||||
onRefresh,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
createdToken = null,
|
||||
onDismissCreatedToken,
|
||||
passkeys = [],
|
||||
@@ -147,6 +149,16 @@ const SettingsModal = ({
|
||||
[newTokenExpires, newTokenLabel, onCreate],
|
||||
);
|
||||
|
||||
const handleRegenerateToken = useCallback(
|
||||
async (token) => {
|
||||
if (!token?.id) {
|
||||
return;
|
||||
}
|
||||
await onRegenerate?.(token.id);
|
||||
},
|
||||
[onRegenerate],
|
||||
);
|
||||
|
||||
const renderWebdavSection = useMemo(() => {
|
||||
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
||||
|
||||
@@ -240,14 +252,28 @@ const SettingsModal = ({
|
||||
{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>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => handleRegenerateToken(token)}
|
||||
disabled={
|
||||
regeneratingId === token.id || deletingId === token.id
|
||||
}
|
||||
>
|
||||
{regeneratingId === token.id ? 'Regenerating…' : 'Regenerate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => onDelete?.(token.id)}
|
||||
disabled={
|
||||
deletingId === token.id || regeneratingId === token.id
|
||||
}
|
||||
>
|
||||
{deletingId === token.id ? 'Revoking…' : 'Revoke'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -264,12 +290,14 @@ const SettingsModal = ({
|
||||
createdToken,
|
||||
creating,
|
||||
deletingId,
|
||||
regeneratingId,
|
||||
newTokenLabel,
|
||||
newTokenExpires,
|
||||
formError,
|
||||
formatDateTime,
|
||||
handleCopyToken,
|
||||
handleCreateToken,
|
||||
handleRegenerateToken,
|
||||
handleRefresh,
|
||||
onDelete,
|
||||
handleDismissSecret,
|
||||
|
||||
Reference in New Issue
Block a user