···
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;
···
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;
···
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>
387
+
<user-modal id="user-modal"></user-modal>
<script type="module" src="../components/auth.ts"></script>
390
+
<script type="module" src="../components/user-modal.ts"></script>
const errorMessage = document.getElementById('error-message');
const loading = document.getElementById('loading');
···
const transcriptionsTable = document.getElementById('transcriptions-table');
const usersTable = document.getElementById('users-table');
const userModal = document.getElementById('user-modal');
790
-
const modalClose = userModal.querySelector('.modal-close');
let currentUserEmail = null;
793
-
let currentModalUserId = null;
let userSortKey = 'created_at';
let userSortDirection = 'desc';
···
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';
function openUserModal(userId) {
836
-
currentModalUserId = userId;
837
-
userModal.classList.add('active');
838
-
loadUserDetails(userId);
431
+
userModal.setAttribute('open', '');
432
+
userModal.userId = userId;
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';
436
+
userModal.removeAttribute('open');
437
+
userModal.userId = null;
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);
440
+
// Listen for modal close and user update events
441
+
userModal.addEventListener('close', closeUserModal);
442
+
userModal.addEventListener('user-updated', () => loadData());
userModal.addEventListener('click', (e) => {
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';