···
+
background: rgba(0, 0, 0, 0.04);
+
background: rgba(0, 0, 0, 0.5);
+
justify-content: center;
+
background: var(--background);
+
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.3);
+
border-bottom: 2px solid var(--secondary);
+
justify-content: space-between;
+
background: transparent;
+
justify-content: center;
+
transition: background 0.2s;
+
background: var(--secondary);
+
.detail-section:last-child {
+
.detail-section-title {
+
padding-bottom: 0.5rem;
+
border-bottom: 2px solid var(--secondary);
+
justify-content: space-between;
+
border-bottom: 1px solid var(--secondary);
+
.detail-row:last-child {
+
padding: 0.5rem 0.75rem;
+
border: 2px solid var(--secondary);
+
background: var(--background);
+
border-color: var(--primary);
+
background: var(--primary);
+
background: var(--gunmetal);
+
.btn-primary:disabled {
+
justify-content: space-between;
+
border: 2px solid var(--secondary);
+
.passkey-item:last-child {
+
margin-bottom: 0.25rem;
+
padding: 0.25rem 0.75rem;
+
background: rgba(0, 0, 0, 0.02);
+
padding: 0.25rem 0.75rem;
+
.password-status.has-password {
+
.password-status.no-password {
+
padding: 0.5rem 0.75rem;
+
border: 2px solid var(--secondary);
+
background: var(--background);
+
border-color: var(--primary);
+
background: var(--gunmetal);
+
th.sortable.asc::after {
+
th.sortable.desc::after {
+
justify-content: space-between;
+
border: 2px solid var(--secondary);
+
.session-item:last-child {
+
margin-bottom: 0.25rem;
+
background: rgba(0, 0, 0, 0.02);
+
justify-content: space-between;
···
<div id="users-tab" class="tab-content">
<h2 class="section-title">All Users</h2>
+
<input type="text" id="user-search" class="search" placeholder="Search by name or email..." />
<div id="users-table"></div>
+
<div id="user-modal" class="modal">
+
<div class="modal-content">
+
<div class="modal-header">
+
<h2 class="modal-title">User Details</h2>
+
<button class="modal-close" aria-label="Close">×</button>
+
<div class="modal-body">
+
<div class="detail-section">
+
<h3 class="detail-section-title">User Information</h3>
+
<div class="detail-row">
+
<span class="detail-label">Email</span>
+
<span class="detail-value" id="modal-email">-</span>
+
<div class="detail-row">
+
<span class="detail-label">Name</span>
+
<span class="detail-value" id="modal-name">-</span>
+
<div class="detail-row">
+
<span class="detail-label">Role</span>
+
<span class="detail-value" id="modal-role">-</span>
+
<div class="detail-row">
+
<span class="detail-label">Joined</span>
+
<span class="detail-value" id="modal-joined">-</span>
+
<div class="detail-row">
+
<span class="detail-label">Last Login</span>
+
<span class="detail-value" id="modal-last-login">-</span>
+
<div class="detail-row">
+
<span class="detail-label">Transcriptions</span>
+
<span class="detail-value" id="modal-transcription-count">-</span>
+
<div class="detail-row">
+
<span class="detail-label">Password Status</span>
+
<span id="modal-password-status">-</span>
+
<div class="detail-section">
+
<h3 class="detail-section-title">Change Name</h3>
+
<form id="change-name-form">
+
<div class="form-group">
+
<label class="form-label" for="new-name">New Name</label>
+
<input type="text" id="new-name" class="form-input" placeholder="Enter new name">
+
<button type="submit" class="btn btn-primary">Update Name</button>
+
<div class="detail-section">
+
<h3 class="detail-section-title">Change Email</h3>
+
<form id="change-email-form">
+
<div class="form-group">
+
<label class="form-label" for="new-email">New Email</label>
+
<input type="email" id="new-email" class="form-input" placeholder="Enter new email">
+
<button type="submit" class="btn btn-primary">Update Email</button>
+
<div class="detail-section">
+
<h3 class="detail-section-title">Change Password</h3>
+
<form id="change-password-form">
+
<div class="form-group">
+
<label class="form-label" for="new-password">New Password</label>
+
<input type="password" id="new-password" class="form-input" placeholder="Enter new password (min 8 characters)">
+
<button type="submit" class="btn btn-primary">Update Password</button>
+
<div class="detail-section">
+
<h3 class="detail-section-title">Active Sessions</h3>
+
<div class="section-actions">
+
<span class="detail-label" id="session-count">0 active sessions</span>
+
<button id="logout-all-btn" class="btn btn-danger btn-small">Logout All Devices</button>
+
<div id="sessions-container">
+
<div class="loading">Loading sessions...</div>
+
<div class="detail-section">
+
<h3 class="detail-section-title">Passkeys</h3>
+
<div id="passkeys-container">
+
<div class="loading">Loading passkeys...</div>
<script type="module" src="../components/auth.ts"></script>
const errorMessage = document.getElementById('error-message');
···
const content = document.getElementById('content');
const transcriptionsTable = document.getElementById('transcriptions-table');
const usersTable = document.getElementById('users-table');
+
const userModal = document.getElementById('user-modal');
+
const modalClose = userModal.querySelector('.modal-close');
let currentUserEmail = null;
+
let currentModalUserId = null;
+
let userSortKey = 'created_at';
+
let userSortDirection = 'desc';
+
let userSearchTerm = '';
async function getCurrentUser() {
···
return date.toLocaleString();
+
function parseUserAgent(userAgent) {
+
if (!userAgent) return '🖥️ Unknown Device';
+
if (userAgent.includes('iPhone')) return '📱 iPhone';
+
if (userAgent.includes('iPad')) return '📱 iPad';
+
if (userAgent.includes('Android')) return '📱 Android';
+
if (userAgent.includes('Mac')) return '💻 Mac';
+
if (userAgent.includes('Windows')) return '💻 Windows';
+
if (userAgent.includes('Linux')) return '💻 Linux';
+
return '🖥️ Unknown Device';
+
function openUserModal(userId) {
+
currentModalUserId = userId;
+
userModal.classList.add('active');
+
loadUserDetails(userId);
+
function closeUserModal() {
+
userModal.classList.remove('active');
+
currentModalUserId = null;
+
async function loadUserDetails(userId) {
+
const res = await fetch(`/api/admin/users/${userId}/details`);
+
throw new Error('Failed to load user details');
+
const user = await res.json();
+
document.getElementById('modal-email').textContent = user.email;
+
document.getElementById('modal-name').textContent = user.name || 'Not set';
+
document.getElementById('modal-role').textContent = user.role;
+
document.getElementById('modal-joined').textContent = formatTimestamp(user.created_at);
+
document.getElementById('modal-last-login').textContent = user.last_login ? formatTimestamp(user.last_login) : 'Never';
+
document.getElementById('modal-transcription-count').textContent = user.transcriptionCount;
+
const passwordStatus = document.getElementById('modal-password-status');
+
if (user.hasPassword) {
+
passwordStatus.innerHTML = '<span class="password-status has-password">Has password</span>';
+
passwordStatus.innerHTML = '<span class="password-status no-password">No password (passkey only)</span>';
+
document.getElementById('new-name').value = user.name || '';
+
document.getElementById('new-email').value = user.email;
+
renderSessions(user.sessions, userId);
+
renderPasskeys(user.passkeys, userId);
+
alert('Failed to load user details');
+
function renderSessions(sessions, userId) {
+
const container = document.getElementById('sessions-container');
+
const sessionCount = document.getElementById('session-count');
+
const logoutAllBtn = document.getElementById('logout-all-btn');
+
sessionCount.textContent = `${sessions.length} active session${sessions.length !== 1 ? 's' : ''}`;
+
if (sessions.length === 0) {
+
container.innerHTML = '<div class="empty-sessions">No active sessions</div>';
+
logoutAllBtn.disabled = true;
+
logoutAllBtn.disabled = false;
+
const list = document.createElement('ul');
+
list.className = 'session-list';
+
list.innerHTML = sessions.map(s => `
+
<li class="session-item">
+
<div class="session-info">
+
<div class="session-device">${parseUserAgent(s.user_agent)}</div>
+
<div class="session-meta">
+
IP: ${s.ip_address || 'Unknown'} •
+
Created: ${formatTimestamp(s.created_at)} •
+
Expires: ${formatTimestamp(s.expires_at)}
+
<div class="session-actions">
+
<button class="btn btn-danger btn-small revoke-session-btn" data-session-id="${s.id}" data-user-id="${userId}">
+
container.innerHTML = '';
+
container.appendChild(list);
+
// Add revoke event listeners
+
list.querySelectorAll('.revoke-session-btn').forEach(btn => {
+
btn.addEventListener('click', async (e) => {
+
const button = e.target;
+
const sessionId = button.dataset.sessionId;
+
const userId = button.dataset.userId;
+
if (!confirm('Revoke this session? The user will be logged out of this device.')) {
+
button.disabled = true;
+
button.textContent = 'Revoking...';
+
const res = await fetch(`/api/admin/users/${userId}/sessions/${sessionId}`, {
+
throw new Error('Failed to revoke session');
+
await loadUserDetails(userId);
+
alert('Failed to revoke session');
+
button.disabled = false;
+
button.textContent = 'Revoke';
+
function renderPasskeys(passkeys, userId) {
+
const container = document.getElementById('passkeys-container');
+
if (passkeys.length === 0) {
+
container.innerHTML = '<div class="empty-passkeys">No passkeys registered</div>';
+
const list = document.createElement('ul');
+
list.className = 'passkey-list';
+
list.innerHTML = passkeys.map(pk => `
+
<li class="passkey-item">
+
<div class="passkey-info">
+
<div class="passkey-name">${pk.name || 'Unnamed Passkey'}</div>
+
<div class="passkey-meta">
+
Created: ${formatTimestamp(pk.created_at)}
+
${pk.last_used_at ? ` • Last used: ${formatTimestamp(pk.last_used_at)}` : ''}
+
<div class="passkey-actions">
+
<button class="btn btn-danger btn-small revoke-passkey-btn" data-passkey-id="${pk.id}" data-user-id="${userId}">
+
container.innerHTML = '';
+
container.appendChild(list);
+
list.querySelectorAll('.revoke-passkey-btn').forEach(btn => {
+
btn.addEventListener('click', async (e) => {
+
const button = e.target;
+
const passkeyId = button.dataset.passkeyId;
+
const userId = button.dataset.userId;
+
if (!confirm('Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.')) {
+
button.disabled = true;
+
button.textContent = 'Revoking...';
+
const res = await fetch(`/api/admin/users/${userId}/passkeys/${passkeyId}`, {
+
throw new Error('Failed to revoke passkey');
+
await loadUserDetails(userId);
+
alert('Failed to revoke passkey');
+
button.disabled = false;
+
button.textContent = 'Revoke';
+
modalClose.addEventListener('click', closeUserModal);
+
userModal.addEventListener('click', (e) => {
+
if (e.target === userModal) {
+
document.getElementById('change-name-form').addEventListener('submit', async (e) => {
+
const name = document.getElementById('new-name').value.trim();
+
alert('Please enter a name');
+
const submitBtn = e.target.querySelector('button[type="submit"]');
+
submitBtn.disabled = true;
+
submitBtn.textContent = 'Updating...';
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/name`, {
+
headers: {'Content-Type': 'application/json'},
+
body: JSON.stringify({name})
+
throw new Error('Failed to update name');
+
alert('Name updated successfully');
+
await loadUserDetails(currentModalUserId);
+
alert('Failed to update name');
+
submitBtn.disabled = false;
+
submitBtn.textContent = 'Update Name';
+
document.getElementById('change-email-form').addEventListener('submit', async (e) => {
+
const email = document.getElementById('new-email').value.trim();
+
if (!email || !email.includes('@')) {
+
alert('Please enter a valid email');
+
const submitBtn = e.target.querySelector('button[type="submit"]');
+
submitBtn.disabled = true;
+
submitBtn.textContent = 'Updating...';
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/email`, {
+
headers: {'Content-Type': 'application/json'},
+
body: JSON.stringify({email})
+
const data = await res.json();
+
throw new Error(data.error || 'Failed to update email');
+
alert('Email updated successfully');
+
await loadUserDetails(currentModalUserId);
+
alert(error.message || 'Failed to update email');
+
submitBtn.disabled = false;
+
submitBtn.textContent = 'Update Email';
+
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
+
const password = document.getElementById('new-password').value;
+
if (password.length < 8) {
+
alert('Password must be at least 8 characters');
+
if (!confirm('Are you sure you want to change this user\'s password? This will log them out of all devices.')) {
+
const submitBtn = e.target.querySelector('button[type="submit"]');
+
submitBtn.disabled = true;
+
submitBtn.textContent = 'Updating...';
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/password`, {
+
headers: {'Content-Type': 'application/json'},
+
body: JSON.stringify({password})
+
throw new Error('Failed to update password');
+
alert('Password updated successfully. User has been logged out of all devices.');
+
document.getElementById('new-password').value = '';
+
await loadUserDetails(currentModalUserId);
+
alert('Failed to update password');
+
submitBtn.disabled = false;
+
submitBtn.textContent = 'Update Password';
+
document.getElementById('logout-all-btn').addEventListener('click', async (e) => {
+
if (!confirm('Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.')) {
+
const button = e.target;
+
button.disabled = true;
+
button.textContent = 'Logging out...';
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/sessions`, {
+
throw new Error('Failed to logout all devices');
+
alert('User logged out from all devices');
+
await loadUserDetails(currentModalUserId);
+
alert('Failed to logout all devices');
+
button.disabled = false;
+
button.textContent = 'Logout All Devices';
function renderTranscriptions(transcriptions) {
if (transcriptions.length === 0) {
transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions yet</div>';
···
function renderUsers(users) {
+
// Filter users based on search term
+
let filteredUsers = users.filter(u => {
+
if (!userSearchTerm) return true;
+
const term = userSearchTerm.toLowerCase();
+
const name = (u.name || '').toLowerCase();
+
const email = u.email.toLowerCase();
+
return name.includes(term) || email.includes(term);
+
filteredUsers.sort((a, b) => {
+
let aVal = a[userSortKey];
+
let bVal = b[userSortKey];
+
if (aVal === null || aVal === undefined) aVal = '';
+
if (bVal === null || bVal === undefined) bVal = '';
+
if (typeof aVal === 'string' && typeof bVal === 'string') {
+
comparison = aVal.localeCompare(bVal);
+
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
+
comparison = aVal - bVal;
+
comparison = String(aVal).localeCompare(String(bVal));
+
return userSortDirection === 'asc' ? comparison : -comparison;
+
if (filteredUsers.length === 0) {
+
usersTable.innerHTML = '<div class="empty-state">No users found</div>';
···
+
<th class="sortable ${userSortKey === 'name' ? userSortDirection : ''}" data-sort="name">User</th>
+
<th class="sortable ${userSortKey === 'email' ? userSortDirection : ''}" data-sort="email">Email</th>
+
<th class="sortable ${userSortKey === 'role' ? userSortDirection : ''}" data-sort="role">Role</th>
+
<th class="sortable ${userSortKey === 'transcription_count' ? userSortDirection : ''}" data-sort="transcription_count">Transcriptions</th>
+
<th class="sortable ${userSortKey === 'last_login' ? userSortDirection : ''}" data-sort="last_login">Last Login</th>
+
<th class="sortable ${userSortKey === 'created_at' ? userSortDirection : ''}" data-sort="created_at">Joined</th>
+
${filteredUsers.map(u => `
···
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
+
<td>${u.transcription_count}</td>
+
<td class="timestamp">${u.last_login ? formatTimestamp(u.last_login) : 'Never'}</td>
<td class="timestamp">${formatTimestamp(u.created_at)}</td>
+
<button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button>
···
usersTable.innerHTML = '';
usersTable.appendChild(table);
+
// Add sort event listeners
+
table.querySelectorAll('th.sortable').forEach(th => {
+
th.addEventListener('click', () => {
+
const sortKey = th.dataset.sort;
+
if (userSortKey === sortKey) {
+
userSortDirection = userSortDirection === 'asc' ? 'desc' : 'asc';
+
userSortDirection = 'asc';
// Add role change event listeners
table.querySelectorAll('.role-select').forEach(select => {
select.addEventListener('change', async (e) => {
···
+
// Add click event to table rows to open modal
+
table.querySelectorAll('tbody tr').forEach(row => {
+
row.addEventListener('click', (e) => {
+
// Don't open modal if clicking on delete button or role select
+
if (e.target.closest('.delete-user-btn') || e.target.closest('.role-select')) {
+
const userId = row.querySelector('.delete-user-btn').dataset.userId;
async function loadData() {
···
tab.classList.add('active');
document.getElementById(`${tabName}-tab`).classList.add('active');
+
document.getElementById('user-search').addEventListener('input', (e) => {
+
userSearchTerm = e.target.value.trim();