rokojori-auth/source/pages/profile.html

222 lines
8.9 KiB
HTML
Raw Normal View History

2026-07-13 03:48:45 +00:00
<!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>