diff --git a/TODO-LIST.md b/TODO-LIST.md index 6e7cc813..8f1b8cf3 100755 --- a/TODO-LIST.md +++ b/TODO-LIST.md @@ -323,6 +323,7 @@ This document contains the task list for the development of OxiCloud, a minimali - [ ] Add scene and object detection in photos (beach, mountain, animals, etc.) - [ ] Implement similar or duplicate photo detection - [ ] Add non-destructive basic editing features (crop, filters, adjustments) + - [ ] Video support ### Enterprise Collaboration - [ ] Create shared workspaces @@ -390,4 +391,4 @@ This document contains the task list for the development of OxiCloud, a minimali - [ ] Implement legal hold - [ ] Develop case-based retention - [ ] Add evidence preservation - - [ ] Implement chain of custody \ No newline at end of file + - [ ] Implement chain of custody diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index fae2f4f5..2f262393 100755 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -1,18 +1,16 @@ use axum::{ Router, - extract::{Json, Path, Query, State}, - http::{HeaderMap, StatusCode, header}, + extract::{Json, Query, State}, + http::{HeaderMap, StatusCode}, response::{IntoResponse, Redirect, Response}, - routing::{delete, get, post, put}, + routing::{get, post, put}, }; use std::sync::Arc; use crate::application::dtos::user_dto::{ - AppPasswordCreatedDto, AppPasswordDto, ChangePasswordDto, CreateAppPasswordDto, LoginDto, - OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto, RefreshTokenDto, RegisterDto, - SetupAdminDto, + ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto, + RefreshTokenDto, RegisterDto, SetupAdminDto, }; -use crate::application::ports::auth_ports::TokenServicePort; use crate::application::services::auth_application_service::OidcCallbackResult; use crate::common::di::AppState; use crate::interfaces::api::cookie_auth; @@ -37,11 +35,6 @@ pub fn auth_protected_routes() -> Router> { .route("/me", get(get_current_user)) .route("/change-password", put(change_password)) .route("/logout", post(logout)) - .route( - "/app-passwords", - get(list_app_passwords).post(create_app_password), - ) - .route("/app-passwords/{id}", delete(delete_app_password)) } /// Rate-limited auth routes — split out so main.rs can apply per-endpoint @@ -500,139 +493,6 @@ async fn get_system_status( } // ============================================================================ -// App Password Handlers -// ============================================================================ - -async fn create_app_password( - State(state): State>, - headers: HeaderMap, - Json(dto): Json, -) -> Result { - let auth_service = state - .auth_service - .as_ref() - .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - - let token = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - - let claims = auth_service - .token_service - .validate_token(token) - .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - - let nextcloud = state - .nextcloud - .as_ref() - .ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?; - - let label = dto.label.trim(); - if label.is_empty() || label.len() > 128 { - return Err(AppError::new( - StatusCode::BAD_REQUEST, - "Label must be between 1 and 128 characters", - "InvalidInput", - )); - } - - let (id, password) = nextcloud - .app_passwords - .create_nc(&claims.sub, label) - .await - .map_err(AppError::from)?; - - Ok(( - StatusCode::CREATED, - Json(AppPasswordCreatedDto { - id, - label: label.to_string(), - password, - }), - )) -} - -async fn list_app_passwords( - State(state): State>, - headers: HeaderMap, -) -> Result { - let auth_service = state - .auth_service - .as_ref() - .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - - let token = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - - let claims = auth_service - .token_service - .validate_token(token) - .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - - let nextcloud = state - .nextcloud - .as_ref() - .ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?; - - let records = nextcloud - .app_passwords - .list_nc(&claims.sub) - .await - .map_err(AppError::from)?; - - let passwords: Vec = records - .into_iter() - .map(|r| AppPasswordDto { - id: r.id, - label: r.label, - created_at: r.created_at, - last_used_at: r.last_used_at, - }) - .collect(); - - Ok((StatusCode::OK, Json(passwords))) -} - -async fn delete_app_password( - State(state): State>, - headers: HeaderMap, - Path(id): Path, -) -> Result { - let auth_service = state - .auth_service - .as_ref() - .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - - let token = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - - let claims = auth_service - .token_service - .validate_token(token) - .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - - let nextcloud = state - .nextcloud - .as_ref() - .ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?; - - nextcloud - .app_passwords - .delete_by_user(&id, &claims.sub) - .await - .map_err(AppError::from)?; - - Ok(StatusCode::NO_CONTENT) -} - // ============================================================================ // OIDC Handlers // ============================================================================ diff --git a/src/main.rs b/src/main.rs index f0c166a2..e23c5ce9 100755 --- a/src/main.rs +++ b/src/main.rs @@ -187,6 +187,7 @@ async fn main() -> Result<(), Box> { auth_protected_routes, auth_public_routes, login_route, refresh_route, register_route, setup_route, }; + use oxicloud::interfaces::api::handlers::app_password_handler; use oxicloud::interfaces::api::handlers::device_auth_handler; use oxicloud::interfaces::middleware::auth::auth_middleware; use oxicloud::interfaces::middleware::csrf::csrf_middleware; @@ -250,6 +251,14 @@ async fn main() -> Result<(), Box> { auth_middleware, )) .with_state(app_state.clone()); + // App password management routes — require auth + CSRF + let app_pw_protected = app_password_handler::app_password_routes() + .layer(axum::middleware::from_fn(csrf_middleware)) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + auth_middleware, + )) + .with_state(app_state.clone()); // One-time setup route — public, rate-limited like register let setup_router = setup_route() .layer(axum::middleware::from_fn_with_state( @@ -302,6 +311,8 @@ async fn main() -> Result<(), Box> { .nest("/api/auth", auth_public) // Protected auth endpoints (/me, /change-password, /logout) .nest("/api/auth", auth_protected) + // App password management (create, list, revoke) + .nest("/api/auth", app_pw_protected) // One-time setup endpoint — public, rate-limited .nest("/api", setup_router) // Device Auth Grant public endpoints (authorize + token polling) diff --git a/static/css/views/profile.css b/static/css/views/profile.css index f456713b..de9d4fed 100755 --- a/static/css/views/profile.css +++ b/static/css/views/profile.css @@ -4,7 +4,7 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;height:auto;display:flex; .link-reset-flex{text-decoration:none;color:inherit;display:flex;align-items:center;gap:14px} .width-zero{width:0%} -#main-content{display:none} +/* #main-content and #auth-error start hidden via .hidden class */ /* ── Scrollbar ── */ ::-webkit-scrollbar{width:8px} @@ -104,11 +104,60 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;height:auto;display:flex; .alert-success{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0} .alert-error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca} +/* ── App Passwords ── */ +.section-desc{font-size:13px;color:#64748b;margin-bottom:16px;line-height:1.5} +.scope-checkboxes{display:flex;gap:16px;flex-wrap:wrap} +.checkbox-label{display:flex;align-items:center;gap:5px;font-size:13px;font-weight:500;color:#334155;cursor:pointer} +.checkbox-label input[type="checkbox"]{accent-color:#ff5e3a;width:16px;height:16px} +.form-select{ + width:100%;padding:10px 14px;border:2px solid #e2e8f0;border-radius:10px;font-size:14px; + background:#f8fafc;color:#1e293b;font-family:inherit;transition:all .2s;appearance:auto; +} +.form-select:focus{outline:none;border-color:#ff5e3a;background:#fff;box-shadow:0 0 0 3px rgba(255,94,58,.1)} +.btn-secondary{background:#f1f5f9;color:#334155;border:2px solid #e2e8f0} +.btn-secondary:hover{background:#e2e8f0} +.btn-danger{background:linear-gradient(135deg,#dc2626,#ef4444);color:#fff;box-shadow:0 2px 8px rgba(220,38,38,.2)} +.btn-danger:hover{box-shadow:0 4px 14px rgba(220,38,38,.3);transform:translateY(-1px)} +.btn-sm{padding:6px 12px;font-size:12px;border-radius:8px} + +.app-pw-table{width:100%;border-collapse:collapse;font-size:13px} +.app-pw-table th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;font-weight:700;padding:8px 10px;border-bottom:2px solid #e2e8f0} +.app-pw-table td{padding:10px;border-bottom:1px solid #f1f5f9;vertical-align:middle} +.app-pw-table tr:last-child td{border-bottom:none} +.app-pw-label-cell{font-weight:600;color:#1e293b} +.app-pw-prefix code{font-size:12px;background:#f1f5f9;padding:2px 8px;border-radius:6px;color:#64748b} + +.badge{display:inline-block;font-size:11px;font-weight:600;padding:2px 10px;border-radius:12px} +.badge-active{background:#ecfdf5;color:#065f46} +.badge-expired{background:#fef2f2;color:#991b1b} + +@media(max-width:640px){ + .app-pw-table{font-size:12px} + .app-pw-table th:nth-child(3),.app-pw-table td:nth-child(3), + .app-pw-table th:nth-child(5),.app-pw-table td:nth-child(5){display:none} +} +.app-pw-empty{text-align:center;padding:24px;color:#94a3b8;font-size:13px} +.app-pw-empty i{margin-right:6px} + +.hidden{display:none} +.app-pw-warn{margin-bottom:8px} +.app-pw-dismiss{margin-top:10px} +.app-pw-actions{display:flex;gap:8px} +.app-pw-new-btn{margin-top:14px} +.app-pw-created-box{position:relative} +.app-pw-token-row{display:flex;gap:8px;align-items:center;margin-bottom:8px} +.app-pw-token-input{ + flex:1;padding:10px 14px;border:2px solid #a7f3d0;border-radius:10px;font-size:14px; + font-family:'Courier New',Courier,monospace;background:#f0fdf4;color:#065f46; +} +.app-pw-instr-list{font-size:12px;line-height:1.8;margin-top:8px} +.app-pw-instr-list code{background:#f0fdf4;padding:1px 6px;border-radius:4px;font-size:12px} + /* ── Loading / Error ── */ #loading{text-align:center;padding:80px;color:#94a3b8;font-size:15px} #loading i{font-size:32px;color:#ff5e3a;display:block;margin-bottom:12px;animation:spin 1s linear infinite} @keyframes spin{to{transform:rotate(360deg)}} -#auth-error{display:none;text-align:center;padding:80px 20px} +#auth-error{text-align:center;padding:80px 20px} #auth-error .err-icon{width:80px;height:80px;background:#fef2f2;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 20px} #auth-error .err-icon i{font-size:32px;color:#ef4444} #auth-error h2{color:#991b1b;margin-bottom:8px;font-size:20px} @@ -190,6 +239,21 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;height:auto;display:flex; [data-theme="dark"] #auth-error .err-icon{background:#3b1111} [data-theme="dark"] #auth-error h2{color:#fca5a5} [data-theme="dark"] #auth-error p{color:#94a3b8} +[data-theme="dark"] .section-desc{color:#64748b} +[data-theme="dark"] .checkbox-label{color:#94a3b8} +[data-theme="dark"] .form-select{background:#0f172a;border-color:#334155;color:#e2e8f0} +[data-theme="dark"] .form-select:focus{border-color:#ff5e3a;background:#0f172a;box-shadow:0 0 0 3px rgba(255,94,58,.15)} +[data-theme="dark"] .btn-secondary{background:#334155;color:#e2e8f0;border-color:#475569} +[data-theme="dark"] .btn-secondary:hover{background:#475569} +[data-theme="dark"] .app-pw-table th{color:#64748b;border-bottom-color:#334155} +[data-theme="dark"] .app-pw-table td{border-bottom-color:#1e293b} +[data-theme="dark"] .app-pw-label-cell{color:#f1f5f9} +[data-theme="dark"] .app-pw-prefix code{background:#334155;color:#94a3b8} +[data-theme="dark"] .badge-active{background:#052e16;color:#86efac} +[data-theme="dark"] .badge-expired{background:#3b1111;color:#fca5a5} +[data-theme="dark"] .app-pw-empty{color:#64748b} +[data-theme="dark"] .app-pw-token-input{background:#052e16;border-color:#065f46;color:#86efac} +[data-theme="dark"] .app-pw-instr-list code{background:#052e16;color:#86efac} [data-theme="dark"] #loading{color:#64748b} [data-theme="dark"] .app-pw-desc{color:#94a3b8} [data-theme="dark"] .app-pw-create input{background:#0f172a;border-color:#334155;color:#e2e8f0} diff --git a/static/js/views/profile/profile.js b/static/js/views/profile/profile.js index 06ff026b..a4f2faec 100755 --- a/static/js/views/profile/profile.js +++ b/static/js/views/profile/profile.js @@ -152,25 +152,38 @@ function renderPwRow(pw) { created.textContent = new Date(pw.created_at).toLocaleDateString(); const lastUsed = document.createElement('td'); lastUsed.textContent = pw.last_used_at ? timeAgo(pw.last_used_at) : 'Never'; + const status = document.createElement('td'); + const badge = document.createElement('span'); + if (pw.active !== false) { + badge.className = 'badge badge-active'; + badge.textContent = 'Active'; + } else { + badge.className = 'badge badge-expired'; + badge.textContent = 'Revoked'; + } + status.appendChild(badge); const actions = document.createElement('td'); - const btn = document.createElement('button'); - btn.className = 'btn btn-danger-sm'; - btn.innerHTML = ''; - btn.title = 'Revoke'; - btn.addEventListener('click', function () { revokeAppPassword(pw.id, pw.label); }); - actions.appendChild(btn); - tr.append(label, created, lastUsed, actions); + if (pw.active !== false) { + const btn = document.createElement('button'); + btn.className = 'btn btn-danger-sm'; + btn.innerHTML = ''; + btn.title = 'Revoke'; + btn.addEventListener('click', function () { revokeAppPassword(pw.id, pw.label); }); + actions.appendChild(btn); + } + tr.append(label, created, lastUsed, status, actions); return tr; } async function loadAppPasswords() { try { - const resp = await fetch(API + '/auth/app-passwords', { headers: headers() }); + const resp = await fetch(API + '/auth/app-passwords', { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) { document.getElementById('app-passwords-section').classList.add('hidden'); return; } - const passwords = await resp.json(); + const data = await resp.json(); + const passwords = data.app_passwords || data; const userPws = passwords.filter(function (pw) { return !isAutoPassword(pw); }); const autoPws = passwords.filter(isAutoPassword); @@ -231,6 +244,7 @@ async function createAppPassword() { const resp = await fetch(API + '/auth/app-passwords', { method: 'POST', headers: headers(), + credentials: 'same-origin', body: JSON.stringify({ label: label }) }); if (!resp.ok) { @@ -266,7 +280,8 @@ async function revokeAppPassword(id, label) { try { const resp = await fetch(API + '/auth/app-passwords/' + encodeURIComponent(id), { method: 'DELETE', - headers: headers() + headers: headers(), + credentials: 'same-origin' }); if (resp.ok || resp.status === 204) { document.getElementById('app-pw-created').classList.add('hidden'); diff --git a/static/profile.html b/static/profile.html index 30f58e33..022b8b9c 100755 --- a/static/profile.html +++ b/static/profile.html @@ -31,14 +31,14 @@
Loading…
-
+ -
+