222 lines
8.9 KiB
HTML
222 lines
8.9 KiB
HTML
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>Profile — rokojori</title>
|
||
|
|
<style>
|
||
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
|
body { font-family: system-ui, sans-serif; background: #111; color: #eee; padding: 2rem; }
|
||
|
|
.card { background: #1c1c1c; border: 1px solid #2a2a2a; border-radius: 8px; padding: 2rem; width: 100%; max-width: 600px; margin: 0 auto 1.5rem; }
|
||
|
|
h1 { font-size: 1.4rem; margin-bottom: 1.5rem; }
|
||
|
|
h2 { font-size: 1rem; margin-bottom: 1rem; color: #bbb; }
|
||
|
|
.field { margin-bottom: 0.75rem; }
|
||
|
|
.field-label { font-size: 0.8rem; color: #888; }
|
||
|
|
.field-value { font-size: 0.95rem; margin-top: 0.15rem; }
|
||
|
|
label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 0.25rem; margin-top: 0.75rem; }
|
||
|
|
input[type="password"] { display: block; width: 100%; padding: 0.6rem 0.75rem; background: #111; border: 1px solid #333; border-radius: 4px; color: #eee; font-size: 0.95rem; margin-bottom: 0.25rem; }
|
||
|
|
input[type="password"]:focus { outline: none; border-color: #555; }
|
||
|
|
button { padding: 0.5rem 1.1rem; border: none; border-radius: 4px; color: #fff; font-size: 0.85rem; cursor: pointer; }
|
||
|
|
button.primary { background: #2563eb; }
|
||
|
|
button.primary:hover { background: #1d4ed8; }
|
||
|
|
button.secondary { background: #333; }
|
||
|
|
button.secondary:hover { background: #444; }
|
||
|
|
button.danger { background: #7f1d1d; }
|
||
|
|
button.danger:hover { background: #991b1b; }
|
||
|
|
button.grant { background: #14532d; }
|
||
|
|
button.grant:hover { background: #166534; }
|
||
|
|
.success { color: #4ade80; font-size: 0.85rem; margin-top: 0.5rem; }
|
||
|
|
.error { color: #f87171; font-size: 0.85rem; margin-top: 0.5rem; }
|
||
|
|
nav { max-width: 600px; margin: 0 auto 1.5rem; display: flex; justify-content: flex-end; }
|
||
|
|
/* User table */
|
||
|
|
.user-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||
|
|
.user-table th { text-align: left; color: #888; font-weight: normal; padding: 0.4rem 0.5rem; border-bottom: 1px solid #2a2a2a; }
|
||
|
|
.user-table td { padding: 0.5rem 0.5rem; border-bottom: 1px solid #1e1e1e; vertical-align: middle; }
|
||
|
|
.role-tag { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.75rem; margin-right: 0.25rem; }
|
||
|
|
.role-tag.superadmin { background: #451a03; color: #fb923c; }
|
||
|
|
.role-tag.admin { background: #1e1b4b; color: #a5b4fc; }
|
||
|
|
.role-tag.user { background: #1c1c1c; color: #888; border: 1px solid #333; }
|
||
|
|
.actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<nav>
|
||
|
|
<button class="secondary" id="logout-btn">Log out</button>
|
||
|
|
</nav>
|
||
|
|
|
||
|
|
<!-- Profile info -->
|
||
|
|
<div class="card">
|
||
|
|
<h1>Profile</h1>
|
||
|
|
<div class="field">
|
||
|
|
<div class="field-label">Email</div>
|
||
|
|
<div class="field-value" id="email-display">—</div>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<div class="field-label">Roles</div>
|
||
|
|
<div class="field-value" id="roles-display">—</div>
|
||
|
|
</div>
|
||
|
|
<div class="field" id="products-row" hidden>
|
||
|
|
<div class="field-label">Products</div>
|
||
|
|
<div class="field-value" id="products-display">—</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Change password -->
|
||
|
|
<div class="card">
|
||
|
|
<h2>Change password</h2>
|
||
|
|
<form id="pw-form" autocomplete="on">
|
||
|
|
<input type="email" id="email-field" name="email" autocomplete="username" hidden readonly>
|
||
|
|
<label for="current-pw">Current password</label>
|
||
|
|
<input type="password" id="current-pw" name="currentPassword" required autocomplete="current-password">
|
||
|
|
<label for="new-pw">New password</label>
|
||
|
|
<input type="password" id="new-pw" name="newPassword" required minlength="8" autocomplete="new-password">
|
||
|
|
<button type="submit" class="primary" style="margin-top:0.75rem">Update password</button>
|
||
|
|
<p class="success" id="pw-success" hidden>Password updated.</p>
|
||
|
|
<p class="error" id="pw-error" hidden></p>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Admin: user list (admin + superadmin) -->
|
||
|
|
<div class="card" id="admin-card" hidden>
|
||
|
|
<h2>Users</h2>
|
||
|
|
<table class="user-table">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Email</th>
|
||
|
|
<th>Roles</th>
|
||
|
|
<th>Joined</th>
|
||
|
|
<th id="actions-header" hidden>Actions</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody id="user-list"></tbody>
|
||
|
|
</table>
|
||
|
|
<p class="error" id="admin-error" hidden></p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
let currentUser = null;
|
||
|
|
|
||
|
|
function roleTag(role) {
|
||
|
|
return `<span class="role-tag ${role}">${role}</span>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderRoles(roles) {
|
||
|
|
return roles.map(roleTag).join('');
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadAdminUsers() {
|
||
|
|
const res = await fetch('/api/admin/users');
|
||
|
|
if (!res.ok) {
|
||
|
|
document.getElementById('admin-error').textContent = 'Could not load users.';
|
||
|
|
document.getElementById('admin-error').hidden = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const userList = await res.json();
|
||
|
|
const isSuperAdmin = currentUser.roles.includes('superadmin');
|
||
|
|
if (isSuperAdmin) document.getElementById('actions-header').hidden = false;
|
||
|
|
|
||
|
|
const tbody = document.getElementById('user-list');
|
||
|
|
tbody.innerHTML = '';
|
||
|
|
|
||
|
|
for (const u of userList) {
|
||
|
|
const isSelf = u.id === currentUser.id;
|
||
|
|
const targetIsSuperAdmin = u.roles.includes('superadmin');
|
||
|
|
const targetIsAdmin = u.roles.includes('admin');
|
||
|
|
const joined = new Date(u.createdAt).toLocaleDateString();
|
||
|
|
|
||
|
|
let actions = '';
|
||
|
|
if (isSuperAdmin && !isSelf) {
|
||
|
|
if (targetIsSuperAdmin) {
|
||
|
|
actions = '<span style="color:#666;font-size:0.8rem">superadmin</span>';
|
||
|
|
} else if (targetIsAdmin) {
|
||
|
|
actions = `<div class="actions">
|
||
|
|
<button class="danger" onclick="setRoles('${u.id}', ['user'])">Revoke admin</button>
|
||
|
|
</div>`;
|
||
|
|
} else {
|
||
|
|
actions = `<div class="actions">
|
||
|
|
<button class="grant" onclick="setRoles('${u.id}', ['admin', 'user'])">Grant admin</button>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
tbody.insertAdjacentHTML('beforeend', `
|
||
|
|
<tr>
|
||
|
|
<td>${u.email}${isSelf ? ' <span style="color:#888;font-size:0.75rem">(you)</span>' : ''}</td>
|
||
|
|
<td>${renderRoles(u.roles)}</td>
|
||
|
|
<td style="color:#666">${joined}</td>
|
||
|
|
${isSuperAdmin ? `<td>${actions}</td>` : ''}
|
||
|
|
</tr>
|
||
|
|
`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function setRoles(userId, roles) {
|
||
|
|
const res = await fetch(`/api/admin/users/${userId}/roles`, {
|
||
|
|
method: 'PATCH',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ roles })
|
||
|
|
});
|
||
|
|
if (res.ok) {
|
||
|
|
loadAdminUsers();
|
||
|
|
} else {
|
||
|
|
const err = await res.json();
|
||
|
|
document.getElementById('admin-error').textContent = err.error;
|
||
|
|
document.getElementById('admin-error').hidden = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadProfile() {
|
||
|
|
const res = await fetch('/api/auth/me');
|
||
|
|
if (!res.ok) { location.href = '/login.html'; return; }
|
||
|
|
currentUser = await res.json();
|
||
|
|
|
||
|
|
document.getElementById('email-display').textContent = currentUser.email;
|
||
|
|
document.getElementById('roles-display').innerHTML = renderRoles(currentUser.roles);
|
||
|
|
document.getElementById('email-field').value = currentUser.email;
|
||
|
|
|
||
|
|
if (currentUser.products?.length) {
|
||
|
|
document.getElementById('products-display').textContent = currentUser.products.map(p => p.id).join(', ');
|
||
|
|
document.getElementById('products-row').hidden = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const isAdmin = currentUser.roles.includes('admin') || currentUser.roles.includes('superadmin');
|
||
|
|
if (isAdmin) {
|
||
|
|
document.getElementById('admin-card').hidden = false;
|
||
|
|
loadAdminUsers();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById('logout-btn').addEventListener('click', async () => {
|
||
|
|
await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||
|
|
location.href = '/login.html';
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById('pw-form').addEventListener('submit', async e => {
|
||
|
|
e.preventDefault();
|
||
|
|
document.getElementById('pw-success').hidden = true;
|
||
|
|
document.getElementById('pw-error').hidden = true;
|
||
|
|
const res = await fetch('/api/auth/me/password', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
currentPassword: document.getElementById('current-pw').value,
|
||
|
|
newPassword: document.getElementById('new-pw').value
|
||
|
|
})
|
||
|
|
});
|
||
|
|
if (res.ok) {
|
||
|
|
document.getElementById('pw-success').hidden = false;
|
||
|
|
document.getElementById('pw-form').reset();
|
||
|
|
document.getElementById('email-field').value = currentUser.email;
|
||
|
|
} else {
|
||
|
|
const err = await res.json();
|
||
|
|
const p = document.getElementById('pw-error');
|
||
|
|
p.textContent = err.error;
|
||
|
|
p.hidden = false;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
loadProfile();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|