Merge pull request #167 from jaredwolff/feat/app-passwords-ui
feat(profile): add App Passwords UI to profile page
This commit is contained in:
+2
-1
@@ -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
|
||||
- [ ] Implement chain of custody
|
||||
|
||||
@@ -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<Arc<AppState>> {
|
||||
.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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<CreateAppPasswordDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<AppPasswordDto> = 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
+11
@@ -187,6 +187,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
.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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = '<i class="fas fa-trash"></i>';
|
||||
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 = '<i class="fas fa-trash"></i>';
|
||||
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');
|
||||
|
||||
+3
-3
@@ -31,14 +31,14 @@
|
||||
|
||||
<div class="profile-container">
|
||||
<div id="loading"><i class="fas fa-circle-notch"></i> Loading…</div>
|
||||
<div id="auth-error">
|
||||
<div id="auth-error" class="hidden">
|
||||
<div class="err-icon"><i class="fas fa-lock"></i></div>
|
||||
<h2>Not Authenticated</h2>
|
||||
<p>Please sign in to view your profile.</p>
|
||||
<a href="/login"><i class="fas fa-sign-in-alt"></i> Sign in</a>
|
||||
</div>
|
||||
|
||||
<div id="main-content">
|
||||
<div id="main-content" class="hidden">
|
||||
<div class="profile-card">
|
||||
<div class="avatar-section">
|
||||
<div class="avatar-large" id="p-avatar">—</div>
|
||||
@@ -116,7 +116,7 @@
|
||||
|
||||
<table class="app-pw-table" id="app-pw-table">
|
||||
<thead>
|
||||
<tr><th>Label</th><th>Created</th><th>Last Used</th><th></th></tr>
|
||||
<tr><th>Label</th><th>Created</th><th>Last Used</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="app-pw-tbody"></tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user