···
282
+
background: rgba(0, 0, 0, 0.04);
292
+
background: rgba(0, 0, 0, 0.5);
294
+
align-items: center;
295
+
justify-content: center;
304
+
background: var(--background);
305
+
border-radius: 8px;
310
+
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.3);
315
+
border-bottom: 2px solid var(--secondary);
317
+
justify-content: space-between;
318
+
align-items: center;
324
+
color: var(--text);
329
+
background: transparent;
333
+
color: var(--text);
338
+
align-items: center;
339
+
justify-content: center;
340
+
border-radius: 4px;
341
+
transition: background 0.2s;
344
+
.modal-close:hover {
345
+
background: var(--secondary);
353
+
margin-bottom: 2rem;
356
+
.detail-section:last-child {
360
+
.detail-section-title {
361
+
font-size: 1.125rem;
363
+
color: var(--text);
364
+
margin-bottom: 1rem;
365
+
padding-bottom: 0.5rem;
366
+
border-bottom: 2px solid var(--secondary);
371
+
justify-content: space-between;
372
+
align-items: center;
373
+
padding: 0.75rem 0;
374
+
border-bottom: 1px solid var(--secondary);
377
+
.detail-row:last-child {
378
+
border-bottom: none;
383
+
color: var(--text);
387
+
color: var(--text);
392
+
margin-bottom: 1rem;
398
+
color: var(--text);
399
+
margin-bottom: 0.5rem;
404
+
padding: 0.5rem 0.75rem;
405
+
border: 2px solid var(--secondary);
406
+
border-radius: 4px;
408
+
font-family: inherit;
409
+
background: var(--background);
410
+
color: var(--text);
413
+
.form-input:focus {
415
+
border-color: var(--primary);
419
+
padding: 0.5rem 1rem;
421
+
border-radius: 4px;
424
+
font-family: inherit;
426
+
transition: all 0.2s;
430
+
background: var(--primary);
434
+
.btn-primary:hover {
435
+
background: var(--gunmetal);
438
+
.btn-primary:disabled {
440
+
cursor: not-allowed;
444
+
background: #dc2626;
448
+
.btn-danger:hover {
449
+
background: #b91c1c;
452
+
.btn-danger:disabled {
454
+
cursor: not-allowed;
465
+
justify-content: space-between;
466
+
align-items: center;
468
+
border: 2px solid var(--secondary);
469
+
border-radius: 4px;
470
+
margin-bottom: 0.5rem;
473
+
.passkey-item:last-child {
483
+
color: var(--text);
484
+
margin-bottom: 0.25rem;
488
+
font-size: 0.875rem;
489
+
color: var(--text);
499
+
padding: 0.25rem 0.75rem;
500
+
font-size: 0.875rem;
504
+
text-align: center;
506
+
color: var(--text);
508
+
background: rgba(0, 0, 0, 0.02);
509
+
border-radius: 4px;
513
+
display: inline-block;
514
+
padding: 0.25rem 0.75rem;
515
+
border-radius: 4px;
516
+
font-size: 0.875rem;
520
+
.password-status.has-password {
521
+
background: #dcfce7;
525
+
.password-status.no-password {
526
+
background: #fee2e2;
533
+
margin-bottom: 1rem;
534
+
padding: 0.5rem 0.75rem;
535
+
border: 2px solid var(--secondary);
536
+
border-radius: 4px;
538
+
font-family: inherit;
539
+
background: var(--background);
540
+
color: var(--text);
545
+
border-color: var(--primary);
551
+
position: relative;
554
+
th.sortable:hover {
555
+
background: var(--gunmetal);
558
+
th.sortable::after {
560
+
margin-left: 0.5rem;
564
+
th.sortable.asc::after {
569
+
th.sortable.desc::after {
582
+
justify-content: space-between;
583
+
align-items: center;
585
+
border: 2px solid var(--secondary);
586
+
border-radius: 4px;
587
+
margin-bottom: 0.5rem;
590
+
.session-item:last-child {
600
+
color: var(--text);
601
+
margin-bottom: 0.25rem;
605
+
font-size: 0.875rem;
606
+
color: var(--text);
616
+
text-align: center;
618
+
color: var(--text);
620
+
background: rgba(0, 0, 0, 0.02);
621
+
border-radius: 4px;
626
+
justify-content: space-between;
627
+
align-items: center;
628
+
margin-bottom: 1rem;
···
<div id="users-tab" class="tab-content">
<h2 class="section-title">All Users</h2>
682
+
<input type="text" id="user-search" class="search" placeholder="Search by name or email..." />
<div id="users-table"></div>
689
+
<div id="user-modal" class="modal">
690
+
<div class="modal-content">
691
+
<div class="modal-header">
692
+
<h2 class="modal-title">User Details</h2>
693
+
<button class="modal-close" aria-label="Close">×</button>
695
+
<div class="modal-body">
696
+
<div class="detail-section">
697
+
<h3 class="detail-section-title">User Information</h3>
698
+
<div class="detail-row">
699
+
<span class="detail-label">Email</span>
700
+
<span class="detail-value" id="modal-email">-</span>
702
+
<div class="detail-row">
703
+
<span class="detail-label">Name</span>
704
+
<span class="detail-value" id="modal-name">-</span>
706
+
<div class="detail-row">
707
+
<span class="detail-label">Role</span>
708
+
<span class="detail-value" id="modal-role">-</span>
710
+
<div class="detail-row">
711
+
<span class="detail-label">Joined</span>
712
+
<span class="detail-value" id="modal-joined">-</span>
714
+
<div class="detail-row">
715
+
<span class="detail-label">Last Login</span>
716
+
<span class="detail-value" id="modal-last-login">-</span>
718
+
<div class="detail-row">
719
+
<span class="detail-label">Transcriptions</span>
720
+
<span class="detail-value" id="modal-transcription-count">-</span>
722
+
<div class="detail-row">
723
+
<span class="detail-label">Password Status</span>
724
+
<span id="modal-password-status">-</span>
728
+
<div class="detail-section">
729
+
<h3 class="detail-section-title">Change Name</h3>
730
+
<form id="change-name-form">
731
+
<div class="form-group">
732
+
<label class="form-label" for="new-name">New Name</label>
733
+
<input type="text" id="new-name" class="form-input" placeholder="Enter new name">
735
+
<button type="submit" class="btn btn-primary">Update Name</button>
739
+
<div class="detail-section">
740
+
<h3 class="detail-section-title">Change Email</h3>
741
+
<form id="change-email-form">
742
+
<div class="form-group">
743
+
<label class="form-label" for="new-email">New Email</label>
744
+
<input type="email" id="new-email" class="form-input" placeholder="Enter new email">
746
+
<button type="submit" class="btn btn-primary">Update Email</button>
750
+
<div class="detail-section">
751
+
<h3 class="detail-section-title">Change Password</h3>
752
+
<form id="change-password-form">
753
+
<div class="form-group">
754
+
<label class="form-label" for="new-password">New Password</label>
755
+
<input type="password" id="new-password" class="form-input" placeholder="Enter new password (min 8 characters)">
757
+
<button type="submit" class="btn btn-primary">Update Password</button>
761
+
<div class="detail-section">
762
+
<h3 class="detail-section-title">Active Sessions</h3>
763
+
<div class="section-actions">
764
+
<span class="detail-label" id="session-count">0 active sessions</span>
765
+
<button id="logout-all-btn" class="btn btn-danger btn-small">Logout All Devices</button>
767
+
<div id="sessions-container">
768
+
<div class="loading">Loading sessions...</div>
772
+
<div class="detail-section">
773
+
<h3 class="detail-section-title">Passkeys</h3>
774
+
<div id="passkeys-container">
775
+
<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');
789
+
const userModal = document.getElementById('user-modal');
790
+
const modalClose = userModal.querySelector('.modal-close');
let currentUserEmail = null;
793
+
let currentModalUserId = null;
795
+
let userSortKey = 'created_at';
796
+
let userSortDirection = 'desc';
797
+
let userSearchTerm = '';
async function getCurrentUser() {
···
return date.toLocaleString();
823
+
function parseUserAgent(userAgent) {
824
+
if (!userAgent) return '🖥️ Unknown Device';
825
+
if (userAgent.includes('iPhone')) return '📱 iPhone';
826
+
if (userAgent.includes('iPad')) return '📱 iPad';
827
+
if (userAgent.includes('Android')) return '📱 Android';
828
+
if (userAgent.includes('Mac')) return '💻 Mac';
829
+
if (userAgent.includes('Windows')) return '💻 Windows';
830
+
if (userAgent.includes('Linux')) return '💻 Linux';
831
+
return '🖥️ Unknown Device';
835
+
function openUserModal(userId) {
836
+
currentModalUserId = userId;
837
+
userModal.classList.add('active');
838
+
loadUserDetails(userId);
841
+
function closeUserModal() {
842
+
userModal.classList.remove('active');
843
+
currentModalUserId = null;
846
+
async function loadUserDetails(userId) {
848
+
const res = await fetch(`/api/admin/users/${userId}/details`);
850
+
throw new Error('Failed to load user details');
853
+
const user = await res.json();
855
+
document.getElementById('modal-email').textContent = user.email;
856
+
document.getElementById('modal-name').textContent = user.name || 'Not set';
857
+
document.getElementById('modal-role').textContent = user.role;
858
+
document.getElementById('modal-joined').textContent = formatTimestamp(user.created_at);
859
+
document.getElementById('modal-last-login').textContent = user.last_login ? formatTimestamp(user.last_login) : 'Never';
860
+
document.getElementById('modal-transcription-count').textContent = user.transcriptionCount;
862
+
const passwordStatus = document.getElementById('modal-password-status');
863
+
if (user.hasPassword) {
864
+
passwordStatus.innerHTML = '<span class="password-status has-password">Has password</span>';
866
+
passwordStatus.innerHTML = '<span class="password-status no-password">No password (passkey only)</span>';
869
+
document.getElementById('new-name').value = user.name || '';
870
+
document.getElementById('new-email').value = user.email;
872
+
renderSessions(user.sessions, userId);
873
+
renderPasskeys(user.passkeys, userId);
875
+
alert('Failed to load user details');
880
+
function renderSessions(sessions, userId) {
881
+
const container = document.getElementById('sessions-container');
882
+
const sessionCount = document.getElementById('session-count');
883
+
const logoutAllBtn = document.getElementById('logout-all-btn');
885
+
sessionCount.textContent = `${sessions.length} active session${sessions.length !== 1 ? 's' : ''}`;
887
+
if (sessions.length === 0) {
888
+
container.innerHTML = '<div class="empty-sessions">No active sessions</div>';
889
+
logoutAllBtn.disabled = true;
893
+
logoutAllBtn.disabled = false;
895
+
const list = document.createElement('ul');
896
+
list.className = 'session-list';
897
+
list.innerHTML = sessions.map(s => `
898
+
<li class="session-item">
899
+
<div class="session-info">
900
+
<div class="session-device">${parseUserAgent(s.user_agent)}</div>
901
+
<div class="session-meta">
902
+
IP: ${s.ip_address || 'Unknown'} •
903
+
Created: ${formatTimestamp(s.created_at)} •
904
+
Expires: ${formatTimestamp(s.expires_at)}
907
+
<div class="session-actions">
908
+
<button class="btn btn-danger btn-small revoke-session-btn" data-session-id="${s.id}" data-user-id="${userId}">
915
+
container.innerHTML = '';
916
+
container.appendChild(list);
918
+
// Add revoke event listeners
919
+
list.querySelectorAll('.revoke-session-btn').forEach(btn => {
920
+
btn.addEventListener('click', async (e) => {
921
+
const button = e.target;
922
+
const sessionId = button.dataset.sessionId;
923
+
const userId = button.dataset.userId;
925
+
if (!confirm('Revoke this session? The user will be logged out of this device.')) {
929
+
button.disabled = true;
930
+
button.textContent = 'Revoking...';
933
+
const res = await fetch(`/api/admin/users/${userId}/sessions/${sessionId}`, {
938
+
throw new Error('Failed to revoke session');
941
+
await loadUserDetails(userId);
943
+
alert('Failed to revoke session');
944
+
button.disabled = false;
945
+
button.textContent = 'Revoke';
951
+
function renderPasskeys(passkeys, userId) {
952
+
const container = document.getElementById('passkeys-container');
954
+
if (passkeys.length === 0) {
955
+
container.innerHTML = '<div class="empty-passkeys">No passkeys registered</div>';
959
+
const list = document.createElement('ul');
960
+
list.className = 'passkey-list';
961
+
list.innerHTML = passkeys.map(pk => `
962
+
<li class="passkey-item">
963
+
<div class="passkey-info">
964
+
<div class="passkey-name">${pk.name || 'Unnamed Passkey'}</div>
965
+
<div class="passkey-meta">
966
+
Created: ${formatTimestamp(pk.created_at)}
967
+
${pk.last_used_at ? ` • Last used: ${formatTimestamp(pk.last_used_at)}` : ''}
970
+
<div class="passkey-actions">
971
+
<button class="btn btn-danger btn-small revoke-passkey-btn" data-passkey-id="${pk.id}" data-user-id="${userId}">
978
+
container.innerHTML = '';
979
+
container.appendChild(list);
981
+
list.querySelectorAll('.revoke-passkey-btn').forEach(btn => {
982
+
btn.addEventListener('click', async (e) => {
983
+
const button = e.target;
984
+
const passkeyId = button.dataset.passkeyId;
985
+
const userId = button.dataset.userId;
987
+
if (!confirm('Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.')) {
991
+
button.disabled = true;
992
+
button.textContent = 'Revoking...';
995
+
const res = await fetch(`/api/admin/users/${userId}/passkeys/${passkeyId}`, {
1000
+
throw new Error('Failed to revoke passkey');
1003
+
await loadUserDetails(userId);
1005
+
alert('Failed to revoke passkey');
1006
+
button.disabled = false;
1007
+
button.textContent = 'Revoke';
1013
+
modalClose.addEventListener('click', closeUserModal);
1014
+
userModal.addEventListener('click', (e) => {
1015
+
if (e.target === userModal) {
1020
+
document.getElementById('change-name-form').addEventListener('submit', async (e) => {
1021
+
e.preventDefault();
1022
+
const name = document.getElementById('new-name').value.trim();
1025
+
alert('Please enter a name');
1029
+
const submitBtn = e.target.querySelector('button[type="submit"]');
1030
+
submitBtn.disabled = true;
1031
+
submitBtn.textContent = 'Updating...';
1034
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/name`, {
1036
+
headers: {'Content-Type': 'application/json'},
1037
+
body: JSON.stringify({name})
1041
+
throw new Error('Failed to update name');
1044
+
alert('Name updated successfully');
1045
+
await loadUserDetails(currentModalUserId);
1048
+
alert('Failed to update name');
1050
+
submitBtn.disabled = false;
1051
+
submitBtn.textContent = 'Update Name';
1055
+
document.getElementById('change-email-form').addEventListener('submit', async (e) => {
1056
+
e.preventDefault();
1057
+
const email = document.getElementById('new-email').value.trim();
1059
+
if (!email || !email.includes('@')) {
1060
+
alert('Please enter a valid email');
1064
+
const submitBtn = e.target.querySelector('button[type="submit"]');
1065
+
submitBtn.disabled = true;
1066
+
submitBtn.textContent = 'Updating...';
1069
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/email`, {
1071
+
headers: {'Content-Type': 'application/json'},
1072
+
body: JSON.stringify({email})
1076
+
const data = await res.json();
1077
+
throw new Error(data.error || 'Failed to update email');
1080
+
alert('Email updated successfully');
1081
+
await loadUserDetails(currentModalUserId);
1084
+
alert(error.message || 'Failed to update email');
1086
+
submitBtn.disabled = false;
1087
+
submitBtn.textContent = 'Update Email';
1091
+
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
1092
+
e.preventDefault();
1093
+
const password = document.getElementById('new-password').value;
1095
+
if (password.length < 8) {
1096
+
alert('Password must be at least 8 characters');
1100
+
if (!confirm('Are you sure you want to change this user\'s password? This will log them out of all devices.')) {
1104
+
const submitBtn = e.target.querySelector('button[type="submit"]');
1105
+
submitBtn.disabled = true;
1106
+
submitBtn.textContent = 'Updating...';
1109
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/password`, {
1111
+
headers: {'Content-Type': 'application/json'},
1112
+
body: JSON.stringify({password})
1116
+
throw new Error('Failed to update password');
1119
+
alert('Password updated successfully. User has been logged out of all devices.');
1120
+
document.getElementById('new-password').value = '';
1121
+
await loadUserDetails(currentModalUserId);
1123
+
alert('Failed to update password');
1125
+
submitBtn.disabled = false;
1126
+
submitBtn.textContent = 'Update Password';
1130
+
document.getElementById('logout-all-btn').addEventListener('click', async (e) => {
1131
+
if (!confirm('Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.')) {
1135
+
const button = e.target;
1136
+
button.disabled = true;
1137
+
button.textContent = 'Logging out...';
1140
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/sessions`, {
1145
+
throw new Error('Failed to logout all devices');
1148
+
alert('User logged out from all devices');
1149
+
await loadUserDetails(currentModalUserId);
1151
+
alert('Failed to logout all devices');
1153
+
button.disabled = false;
1154
+
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) {
450
-
if (users.length === 0) {
451
-
usersTable.innerHTML = '<div class="empty-state">No users yet</div>';
1243
+
// Filter users based on search term
1244
+
let filteredUsers = users.filter(u => {
1245
+
if (!userSearchTerm) return true;
1246
+
const term = userSearchTerm.toLowerCase();
1247
+
const name = (u.name || '').toLowerCase();
1248
+
const email = u.email.toLowerCase();
1249
+
return name.includes(term) || email.includes(term);
1253
+
filteredUsers.sort((a, b) => {
1254
+
let aVal = a[userSortKey];
1255
+
let bVal = b[userSortKey];
1257
+
// Handle null values
1258
+
if (aVal === null || aVal === undefined) aVal = '';
1259
+
if (bVal === null || bVal === undefined) bVal = '';
1261
+
let comparison = 0;
1262
+
if (typeof aVal === 'string' && typeof bVal === 'string') {
1263
+
comparison = aVal.localeCompare(bVal);
1264
+
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
1265
+
comparison = aVal - bVal;
1267
+
comparison = String(aVal).localeCompare(String(bVal));
1270
+
return userSortDirection === 'asc' ? comparison : -comparison;
1273
+
if (filteredUsers.length === 0) {
1274
+
usersTable.innerHTML = '<div class="empty-state">No users found</div>';
···
1282
+
<th class="sortable ${userSortKey === 'name' ? userSortDirection : ''}" data-sort="name">User</th>
1283
+
<th class="sortable ${userSortKey === 'email' ? userSortDirection : ''}" data-sort="email">Email</th>
1284
+
<th class="sortable ${userSortKey === 'role' ? userSortDirection : ''}" data-sort="role">Role</th>
1285
+
<th class="sortable ${userSortKey === 'transcription_count' ? userSortDirection : ''}" data-sort="transcription_count">Transcriptions</th>
1286
+
<th class="sortable ${userSortKey === 'last_login' ? userSortDirection : ''}" data-sort="last_login">Last Login</th>
1287
+
<th class="sortable ${userSortKey === 'created_at' ? userSortDirection : ''}" data-sort="created_at">Joined</th>
1292
+
${filteredUsers.map(u => `
···
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
1312
+
<td>${u.transcription_count}</td>
1313
+
<td class="timestamp">${u.last_login ? formatTimestamp(u.last_login) : 'Never'}</td>
<td class="timestamp">${formatTimestamp(u.created_at)}</td>
489
-
<button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button>
1316
+
<div class="actions">
1317
+
<button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button>
···
usersTable.innerHTML = '';
usersTable.appendChild(table);
1327
+
// Add sort event listeners
1328
+
table.querySelectorAll('th.sortable').forEach(th => {
1329
+
th.addEventListener('click', () => {
1330
+
const sortKey = th.dataset.sort;
1331
+
if (userSortKey === sortKey) {
1332
+
userSortDirection = userSortDirection === 'asc' ? 'desc' : 'asc';
1334
+
userSortKey = sortKey;
1335
+
userSortDirection = 'asc';
1337
+
renderUsers(allUsers);
// Add role change event listeners
table.querySelectorAll('.role-select').forEach(select => {
select.addEventListener('change', async (e) => {
···
1431
+
// Add click event to table rows to open modal
1432
+
table.querySelectorAll('tbody tr').forEach(row => {
1433
+
row.addEventListener('click', (e) => {
1434
+
// Don't open modal if clicking on delete button or role select
1435
+
if (e.target.closest('.delete-user-btn') || e.target.closest('.role-select')) {
1439
+
const userId = row.querySelector('.delete-user-btn').dataset.userId;
1440
+
openUserModal(userId);
async function loadData() {
···
tab.classList.add('active');
document.getElementById(`${tabName}-tab`).classList.add('active');
1494
+
document.getElementById('user-search').addEventListener('input', (e) => {
1495
+
userSearchTerm = e.target.value.trim();
1496
+
renderUsers(allUsers);