68d169266c
The clickable area now covers both the logo icon and the 'OxiCloud' text on profile.html and admin.html. The page indicator (· Profile, · Admin) remains outside the link as expected.
384 lines
17 KiB
HTML
384 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>OxiCloud — My Profile</title>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif}
|
|
body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-direction:column}
|
|
|
|
/* ── Scrollbar ── */
|
|
::-webkit-scrollbar{width:8px}
|
|
::-webkit-scrollbar-track{background:transparent}
|
|
::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px}
|
|
*{scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
|
|
|
/* ── Header ── */
|
|
.profile-header{
|
|
background:linear-gradient(135deg,#2a3042 0%,#232838 100%);
|
|
padding:0 32px;height:64px;display:flex;align-items:center;justify-content:space-between;
|
|
box-shadow:0 2px 12px rgba(0,0,0,.15);position:sticky;top:0;z-index:100;
|
|
}
|
|
.profile-header-left{display:flex;align-items:center;gap:14px}
|
|
.profile-logo{
|
|
width:38px;height:38px;background:linear-gradient(135deg,#ff5e3a,#ff2d55);border-radius:11px;
|
|
display:flex;align-items:center;justify-content:center;
|
|
box-shadow:0 3px 10px rgba(255,94,58,.35);
|
|
}
|
|
.profile-logo svg{width:20px;height:20px;fill:#fff}
|
|
.profile-title-text{font-size:17px;font-weight:700;color:#fff;letter-spacing:.3px}
|
|
.profile-title-separator{font-size:17px;color:rgba(255,255,255,.5);font-weight:400;margin-left:6px}
|
|
.profile-header-right a{
|
|
color:rgba(255,255,255,.65);text-decoration:none;font-size:13px;font-weight:500;
|
|
display:flex;align-items:center;gap:6px;transition:color .2s;
|
|
}
|
|
.profile-header-right a:hover{color:#fff}
|
|
|
|
/* ── Container ── */
|
|
.profile-container{max-width:720px;margin:0 auto;padding:32px 24px 60px;width:100%}
|
|
|
|
/* ── Card ── */
|
|
.profile-card{background:#fff;border-radius:16px;box-shadow:0 1px 4px rgba(0,0,0,.06),0 0 0 1px rgba(0,0,0,.03);padding:32px;margin-bottom:22px}
|
|
.profile-card h2{font-size:16px;font-weight:700;margin-bottom:20px;color:#1e293b;display:flex;align-items:center;gap:10px}
|
|
.profile-card h2 i{color:#ff5e3a;font-size:17px}
|
|
|
|
/* ── Avatar Section ── */
|
|
.avatar-section{display:flex;align-items:center;gap:24px;margin-bottom:8px}
|
|
.avatar-large{
|
|
width:88px;height:88px;border-radius:50%;
|
|
background:linear-gradient(135deg,#ff5e3a,#ff2d55);
|
|
display:flex;align-items:center;justify-content:center;
|
|
color:#fff;font-size:32px;font-weight:700;letter-spacing:1px;
|
|
box-shadow:0 6px 20px rgba(255,94,58,.3);flex-shrink:0;
|
|
}
|
|
.avatar-info h1{font-size:22px;font-weight:700;color:#1e293b;margin-bottom:4px}
|
|
.avatar-info .email{font-size:14px;color:#64748b;margin-bottom:8px}
|
|
.avatar-info .role-badge{
|
|
display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;
|
|
padding:4px 12px;border-radius:20px;
|
|
}
|
|
.role-badge-admin{background:#dbeafe;color:#1d4ed8}
|
|
.role-badge-user{background:#f1f5f9;color:#64748b}
|
|
|
|
/* ── Info Grid ── */
|
|
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
|
@media(max-width:560px){.info-grid{grid-template-columns:1fr}}
|
|
.info-item{padding:16px;background:#f8fafc;border-radius:12px;border:1px solid #e2e8f0}
|
|
.info-item .info-label{font-size:11.5px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;font-weight:700;margin-bottom:4px;display:flex;align-items:center;gap:6px}
|
|
.info-item .info-label i{font-size:12px;color:#cbd5e1}
|
|
.info-item .info-value{font-size:15px;font-weight:600;color:#1e293b}
|
|
|
|
/* ── Storage ── */
|
|
.storage-section{margin-top:4px}
|
|
.storage-stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:16px}
|
|
@media(max-width:560px){.storage-stats{grid-template-columns:1fr}}
|
|
.storage-stat{text-align:center;padding:16px;background:#f8fafc;border-radius:12px;border:1px solid #e2e8f0}
|
|
.storage-stat .stat-value{font-size:1.5rem;font-weight:800;color:#1e293b}
|
|
.storage-stat .stat-label{font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;font-weight:600;margin-top:2px}
|
|
.storage-bar-wrap{margin-top:4px}
|
|
.storage-bar{height:10px;background:#e2e8f0;border-radius:5px;overflow:hidden}
|
|
.storage-fill{height:100%;border-radius:5px;transition:width .6s ease}
|
|
.storage-fill.green{background:linear-gradient(90deg,#059669,#10b981)}
|
|
.storage-fill.orange{background:linear-gradient(90deg,#d97706,#f59e0b)}
|
|
.storage-fill.red{background:linear-gradient(90deg,#dc2626,#ef4444)}
|
|
.storage-text{font-size:12px;color:#94a3b8;text-align:right;margin-top:6px}
|
|
|
|
/* ── Change Password ── */
|
|
.form-group{margin-bottom:16px}
|
|
.form-group label{display:block;font-size:13px;font-weight:600;margin-bottom:4px;color:#334155}
|
|
.form-group input{
|
|
width:100%;padding:10px 14px;border:2px solid #e2e8f0;border-radius:10px;font-size:14px;
|
|
background:#f8fafc;transition:all .2s;font-family:inherit;color:#1e293b;
|
|
}
|
|
.form-group input:focus{outline:none;border-color:#ff5e3a;background:#fff;box-shadow:0 0 0 3px rgba(255,94,58,.1)}
|
|
.form-group small{color:#94a3b8;font-size:12px;display:block;margin-top:3px}
|
|
|
|
.btn{
|
|
padding:10px 22px;border:none;border-radius:10px;font-size:14px;font-weight:600;
|
|
cursor:pointer;transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:6px;
|
|
}
|
|
.btn-primary{background:linear-gradient(135deg,#ff5e3a,#ff2d55);color:#fff;box-shadow:0 2px 8px rgba(255,94,58,.25)}
|
|
.btn-primary:hover{box-shadow:0 4px 14px rgba(255,94,58,.35);transform:translateY(-1px)}
|
|
.btn-primary:disabled{opacity:.4;cursor:not-allowed;transform:none!important}
|
|
|
|
.alert{padding:12px 16px;border-radius:10px;font-size:13px;margin-top:14px;font-weight:500}
|
|
.alert-success{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0}
|
|
.alert-error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca}
|
|
|
|
/* ── 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 .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}
|
|
#auth-error p{color:#64748b;margin-bottom:20px;font-size:14px}
|
|
#auth-error a{display:inline-flex;align-items:center;gap:6px;padding:10px 24px;background:linear-gradient(135deg,#ff5e3a,#ff2d55);color:#fff;text-decoration:none;border-radius:10px;font-weight:600;font-size:14px;box-shadow:0 3px 12px rgba(255,94,58,.3);transition:all .2s}
|
|
#auth-error a:hover{transform:translateY(-1px);box-shadow:0 5px 18px rgba(255,94,58,.4)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Header -->
|
|
<div class="profile-header">
|
|
<div class="profile-header-left">
|
|
<a href="/" class="profile-logo-link" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:14px">
|
|
<div class="profile-logo">
|
|
<svg viewBox="0 0 500 500">
|
|
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z"/>
|
|
</svg>
|
|
</div>
|
|
<span class="profile-title-text">OxiCloud</span>
|
|
</a>
|
|
<span class="profile-title-separator">· Profile</span>
|
|
</div>
|
|
<div class="profile-header-right">
|
|
<a href="/"><i class="fas fa-arrow-left"></i> Back to OxiCloud</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="profile-container">
|
|
<div id="loading"><i class="fas fa-circle-notch"></i> Loading…</div>
|
|
<div id="auth-error">
|
|
<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" style="display:none">
|
|
<!-- Profile Header Card -->
|
|
<div class="profile-card">
|
|
<div class="avatar-section">
|
|
<div class="avatar-large" id="p-avatar">—</div>
|
|
<div class="avatar-info">
|
|
<h1 id="p-username">—</h1>
|
|
<div class="email" id="p-email">—</div>
|
|
<span class="role-badge" id="p-role-badge">—</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Account Details -->
|
|
<div class="profile-card">
|
|
<h2><i class="fas fa-id-card"></i> Account Details</h2>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<div class="info-label"><i class="fas fa-user"></i> Username</div>
|
|
<div class="info-value" id="p-detail-username">—</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-label"><i class="fas fa-envelope"></i> Email</div>
|
|
<div class="info-value" id="p-detail-email">—</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-label"><i class="fas fa-shield-alt"></i> Role</div>
|
|
<div class="info-value" id="p-detail-role">—</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-label"><i class="fas fa-clock"></i> Last Login</div>
|
|
<div class="info-value" id="p-detail-login">—</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Storage -->
|
|
<div class="profile-card">
|
|
<h2><i class="fas fa-hdd"></i> Storage</h2>
|
|
<div class="storage-stats">
|
|
<div class="storage-stat">
|
|
<div class="stat-value" id="p-storage-used">—</div>
|
|
<div class="stat-label">Used</div>
|
|
</div>
|
|
<div class="storage-stat">
|
|
<div class="stat-value" id="p-storage-quota">—</div>
|
|
<div class="stat-label">Quota</div>
|
|
</div>
|
|
<div class="storage-stat">
|
|
<div class="stat-value" id="p-storage-pct">—</div>
|
|
<div class="stat-label">Usage</div>
|
|
</div>
|
|
</div>
|
|
<div class="storage-bar-wrap">
|
|
<div class="storage-bar"><div class="storage-fill green" id="p-storage-bar" style="width:0%"></div></div>
|
|
<div class="storage-text" id="p-storage-text">—</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Change Password -->
|
|
<div class="profile-card" id="password-section">
|
|
<h2><i class="fas fa-key"></i> Change Password</h2>
|
|
<form id="password-form" onsubmit="return changePassword(event)">
|
|
<div class="form-group">
|
|
<label for="current-password">Current Password</label>
|
|
<input type="password" id="current-password" required autocomplete="current-password">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="new-password">New Password</label>
|
|
<input type="password" id="new-password" required minlength="8" autocomplete="new-password">
|
|
<small>At least 8 characters</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="confirm-password">Confirm New Password</label>
|
|
<input type="password" id="confirm-password" required minlength="8" autocomplete="new-password">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" id="pw-submit"><i class="fas fa-save"></i> Update Password</button>
|
|
<div id="pw-status"></div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API = '/api';
|
|
const token = localStorage.getItem('oxicloud_token') || localStorage.getItem('token') || localStorage.getItem('access_token');
|
|
|
|
function headers() {
|
|
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function timeAgo(dateStr) {
|
|
if (!dateStr) return 'Never';
|
|
const d = new Date(dateStr);
|
|
const now = new Date();
|
|
const secs = Math.floor((now - d) / 1000);
|
|
if (secs < 60) return 'Just now';
|
|
if (secs < 3600) return Math.floor(secs/60) + ' min ago';
|
|
if (secs < 86400) return Math.floor(secs/3600) + 'h ago';
|
|
if (secs < 2592000) return Math.floor(secs/86400) + ' days ago';
|
|
return d.toLocaleDateString();
|
|
}
|
|
|
|
async function init() {
|
|
if (!token) { showError(); return; }
|
|
try {
|
|
const resp = await fetch(API + '/auth/me', { headers: headers() });
|
|
if (!resp.ok) { showError(); return; }
|
|
const user = await resp.json();
|
|
|
|
// Avatar
|
|
const initials = (user.username || '?').substring(0, 2).toUpperCase();
|
|
document.getElementById('p-avatar').textContent = initials;
|
|
document.getElementById('p-username').textContent = user.username;
|
|
document.getElementById('p-email').textContent = user.email || '';
|
|
|
|
// Role badge
|
|
const badge = document.getElementById('p-role-badge');
|
|
if (user.role === 'admin') {
|
|
badge.className = 'role-badge role-badge-admin';
|
|
badge.innerHTML = '<i class="fas fa-shield-alt"></i> Administrator';
|
|
} else {
|
|
badge.className = 'role-badge role-badge-user';
|
|
badge.innerHTML = '<i class="fas fa-user"></i> User';
|
|
}
|
|
|
|
// Details
|
|
document.getElementById('p-detail-username').textContent = user.username;
|
|
document.getElementById('p-detail-email').textContent = user.email || '—';
|
|
document.getElementById('p-detail-role').textContent = user.role === 'admin' ? 'Administrator' : 'User';
|
|
document.getElementById('p-detail-login').textContent = timeAgo(user.last_login_at);
|
|
|
|
// Storage
|
|
const used = user.storage_used_bytes || 0;
|
|
const quota = user.storage_quota_bytes || 0;
|
|
const pct = quota > 0 ? Math.min(Math.round((used / quota) * 100), 100) : 0;
|
|
|
|
document.getElementById('p-storage-used').textContent = formatBytes(used);
|
|
document.getElementById('p-storage-quota').textContent = quota > 0 ? formatBytes(quota) : '∞';
|
|
document.getElementById('p-storage-pct').textContent = quota > 0 ? pct + '%' : '—';
|
|
|
|
const bar = document.getElementById('p-storage-bar');
|
|
bar.style.width = pct + '%';
|
|
bar.className = 'storage-fill ' + (pct > 90 ? 'red' : pct > 70 ? 'orange' : 'green');
|
|
document.getElementById('p-storage-text').textContent = formatBytes(used) + ' / ' + (quota > 0 ? formatBytes(quota) : 'Unlimited');
|
|
|
|
// Hide change password for OIDC-only users
|
|
if (user.auth_provider && user.auth_provider !== 'local') {
|
|
document.getElementById('password-section').style.display = 'none';
|
|
}
|
|
|
|
// Also check OIDC config: hide password form when password login is disabled
|
|
try {
|
|
const oidcResp = await fetch(API + '/auth/oidc/providers');
|
|
if (oidcResp.ok) {
|
|
const oidcInfo = await oidcResp.json();
|
|
if (!oidcInfo.password_login_enabled) {
|
|
document.getElementById('password-section').style.display = 'none';
|
|
}
|
|
}
|
|
} catch (oidcErr) {
|
|
// Ignore — if OIDC endpoint is unavailable, password login is implicitly enabled
|
|
}
|
|
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('main-content').style.display = 'block';
|
|
} catch (e) {
|
|
console.error(e);
|
|
showError();
|
|
}
|
|
}
|
|
|
|
function showError() {
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('auth-error').style.display = 'block';
|
|
}
|
|
|
|
async function changePassword(e) {
|
|
e.preventDefault();
|
|
const currentPw = document.getElementById('current-password').value;
|
|
const newPw = document.getElementById('new-password').value;
|
|
const confirmPw = document.getElementById('confirm-password').value;
|
|
const statusEl = document.getElementById('pw-status');
|
|
|
|
if (newPw !== confirmPw) {
|
|
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Passwords do not match</div>';
|
|
return false;
|
|
}
|
|
|
|
if (newPw.length < 8) {
|
|
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Password must be at least 8 characters</div>';
|
|
return false;
|
|
}
|
|
|
|
const btn = document.getElementById('pw-submit');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating…';
|
|
|
|
try {
|
|
const resp = await fetch(API + '/auth/change-password', {
|
|
method: 'PUT',
|
|
headers: headers(),
|
|
body: JSON.stringify({ current_password: currentPw, new_password: newPw })
|
|
});
|
|
|
|
if (resp.ok) {
|
|
statusEl.innerHTML = '<div class="alert alert-success"><i class="fas fa-check-circle"></i> Password updated successfully</div>';
|
|
document.getElementById('password-form').reset();
|
|
} else {
|
|
const err = await resp.json().catch(() => ({}));
|
|
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + (err.message || 'Failed to change password') + '</div>';
|
|
}
|
|
} catch (err) {
|
|
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Network error: ' + err.message + '</div>';
|
|
}
|
|
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-save"></i> Update Password';
|
|
return false;
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|