🪻 distributed transcription service thistle.dunkirk.sh

feat: allow sorting the table and add more details

dunkirk.sh 7b66c45e 12c8fdc6

verified
Changed files
+1627 -15
src
+244
src/components/admin-data-table.ts
···
+
import { LitElement, html, css } from "lit";
+
import { customElement, property } from "lit/decorators.js";
+
+
export interface TableColumn {
+
key: string;
+
label: string;
+
sortable?: boolean;
+
render?: (value: unknown, row: unknown) => unknown;
+
}
+
+
@customElement("admin-data-table")
+
export class AdminDataTable extends LitElement {
+
@property({ type: Array }) columns: TableColumn[] = [];
+
@property({ type: Array }) data: unknown[] = [];
+
@property({ type: String }) searchPlaceholder = "Search...";
+
@property({ type: String }) emptyMessage = "No data available";
+
@property({ type: Boolean}) loading = false;
+
+
@property({ type: String }) private searchTerm = "";
+
@property({ type: String }) private sortKey = "";
+
@property({ type: String }) private sortDirection: "asc" | "desc" = "asc";
+
+
static override styles = css`
+
:host {
+
display: block;
+
}
+
+
.controls {
+
margin-bottom: 1rem;
+
display: flex;
+
gap: 1rem;
+
align-items: center;
+
}
+
+
.search {
+
flex: 1;
+
max-width: 20rem;
+
padding: 0.5rem 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 4px;
+
font-size: 1rem;
+
font-family: inherit;
+
background: var(--background);
+
color: var(--text);
+
}
+
+
.search:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
table {
+
width: 100%;
+
border-collapse: collapse;
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 8px;
+
overflow: hidden;
+
}
+
+
thead {
+
background: var(--primary);
+
color: white;
+
}
+
+
th {
+
padding: 1rem;
+
text-align: left;
+
font-weight: 600;
+
user-select: none;
+
}
+
+
th.sortable {
+
cursor: pointer;
+
position: relative;
+
}
+
+
th.sortable:hover {
+
background: var(--gunmetal);
+
}
+
+
.sort-indicator {
+
margin-left: 0.5rem;
+
opacity: 0.6;
+
}
+
+
td {
+
padding: 1rem;
+
border-top: 1px solid var(--secondary);
+
color: var(--text);
+
}
+
+
tbody tr {
+
cursor: pointer;
+
}
+
+
tbody tr:hover {
+
background: rgba(0, 0, 0, 0.02);
+
}
+
+
.empty-state, .loading {
+
text-align: center;
+
padding: 3rem;
+
color: var(--text);
+
opacity: 0.6;
+
}
+
`;
+
+
private get filteredData() {
+
let result = [...this.data];
+
+
if (this.searchTerm) {
+
const term = this.searchTerm.toLowerCase();
+
result = result.filter((row) => {
+
return this.columns.some((col) => {
+
const value = (row as Record<string, unknown>)[col.key];
+
return String(value).toLowerCase().includes(term);
+
});
+
});
+
}
+
+
if (this.sortKey) {
+
result.sort((a, b) => {
+
const aVal = (a as Record<string, unknown>)[this.sortKey];
+
const bVal = (b as Record<string, unknown>)[this.sortKey];
+
+
let comparison = 0;
+
if (typeof aVal === "string" && typeof bVal === "string") {
+
comparison = aVal.localeCompare(bVal);
+
} else if (typeof aVal === "number" && typeof bVal === "number") {
+
comparison = aVal - bVal;
+
} else {
+
const aStr = String(aVal);
+
const bStr = String(bVal);
+
comparison = aStr.localeCompare(bStr);
+
}
+
+
return this.sortDirection === "asc" ? comparison : -comparison;
+
});
+
}
+
+
return result;
+
}
+
+
private handleSearch(e: Event) {
+
this.searchTerm = (e.target as HTMLInputElement).value;
+
}
+
+
private handleSort(column: TableColumn) {
+
if (!column.sortable) return;
+
+
if (this.sortKey === column.key) {
+
this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc";
+
} else {
+
this.sortKey = column.key;
+
this.sortDirection = "asc";
+
}
+
}
+
+
private renderCell(column: TableColumn, row: unknown) {
+
const value = (row as Record<string, unknown>)[column.key];
+
if (column.render) {
+
return column.render(value, row);
+
}
+
return value;
+
}
+
+
private handleRowClick(row: unknown) {
+
this.dispatchEvent(
+
new CustomEvent("row-click", {
+
detail: row,
+
bubbles: true,
+
composed: true,
+
}),
+
);
+
}
+
+
override render() {
+
if (this.loading) {
+
return html`<div class="loading">Loading...</div>`;
+
}
+
+
const filtered = this.filteredData;
+
+
return html`
+
<div class="controls">
+
<input
+
type="text"
+
class="search"
+
placeholder=${this.searchPlaceholder}
+
@input=${this.handleSearch}
+
value=${this.searchTerm}
+
/>
+
</div>
+
+
${
+
filtered.length === 0
+
? html`<div class="empty-state">${this.emptyMessage}</div>`
+
: html`
+
<table>
+
<thead>
+
<tr>
+
${this.columns.map(
+
(col) => html`
+
<th
+
class=${col.sortable ? "sortable" : ""}
+
@click=${() => this.handleSort(col)}
+
>
+
${col.label}
+
${
+
col.sortable && this.sortKey === col.key
+
? html`<span class="sort-indicator">
+
${this.sortDirection === "asc" ? "▲" : "▼"}
+
</span>`
+
: ""
+
}
+
</th>
+
`,
+
)}
+
</tr>
+
</thead>
+
<tbody>
+
${filtered.map(
+
(row) => html`
+
<tr @click=${() => this.handleRowClick(row)}>
+
${this.columns.map(
+
(col) => html`<td>${this.renderCell(col, row)}</td>`,
+
)}
+
</tr>
+
`,
+
)}
+
</tbody>
+
</table>
+
`
+
}
+
`;
+
}
+
}
+
+
declare global {
+
interface HTMLElementTagNameMap {
+
"admin-data-table": AdminDataTable;
+
}
+
}
+8
src/db/schema.ts
···
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
`,
},
+
{
+
version: 8,
+
name: "Add last_login to users",
+
sql: `
+
ALTER TABLE users ADD COLUMN last_login INTEGER;
+
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login);
+
`,
+
},
];
function getCurrentVersion(): number {
+240 -1
src/index.ts
···
cleanupExpiredSessions,
createSession,
createUser,
+
deleteAllUserSessions,
deleteSession,
+
deleteSessionById,
deleteTranscription,
deleteUser,
getAllTranscriptions,
getAllUsers,
+
getAllUsersWithStats,
getSession,
getSessionFromRequest,
+
getSessionsForUser,
getUserBySession,
getUserSessionsForUser,
updateUserAvatar,
updateUserEmail,
+
updateUserEmailAddress,
updateUserName,
updateUserPassword,
updateUserRole,
···
GET: async (req) => {
try {
requireAdmin(req);
-
const users = getAllUsers();
+
const users = getAllUsersWithStats();
return Response.json(users);
} catch (error) {
return handleError(error);
···
updateUserRole(userId, role);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/users/:id/details": {
+
GET: async (req) => {
+
try {
+
requireAdmin(req);
+
const userId = Number.parseInt(req.params.id, 10);
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
+
const user = db
+
.query<
+
{
+
id: number;
+
email: string;
+
name: string | null;
+
avatar: string;
+
created_at: number;
+
role: UserRole;
+
password_hash: string | null;
+
last_login: number | null;
+
},
+
[number]
+
>(
+
"SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?",
+
)
+
.get(userId);
+
+
if (!user) {
+
return Response.json({ error: "User not found" }, { status: 404 });
+
}
+
+
const passkeys = getPasskeysForUser(userId);
+
const sessions = getSessionsForUser(userId);
+
+
// Get transcription count
+
const transcriptionCount = db
+
.query<{ count: number }, [number]>(
+
"SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?",
+
)
+
.get(userId)?.count ?? 0;
+
+
return Response.json({
+
id: user.id,
+
email: user.email,
+
name: user.name,
+
avatar: user.avatar,
+
created_at: user.created_at,
+
role: user.role,
+
last_login: user.last_login,
+
hasPassword: !!user.password_hash,
+
transcriptionCount,
+
passkeys: passkeys.map((pk) => ({
+
id: pk.id,
+
name: pk.name,
+
created_at: pk.created_at,
+
last_used_at: pk.last_used_at,
+
})),
+
sessions: sessions.map((s) => ({
+
id: s.id,
+
ip_address: s.ip_address,
+
user_agent: s.user_agent,
+
created_at: s.created_at,
+
expires_at: s.expires_at,
+
})),
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/users/:id/password": {
+
PUT: async (req) => {
+
try {
+
requireAdmin(req);
+
const userId = Number.parseInt(req.params.id, 10);
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
+
const body = await req.json();
+
const { password } = body as { password: string };
+
+
if (!password || password.length < 8) {
+
return Response.json(
+
{ error: "Password must be at least 8 characters" },
+
{ status: 400 },
+
);
+
}
+
+
await updateUserPassword(userId, password);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/users/:id/passkeys/:passkeyId": {
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const userId = Number.parseInt(req.params.id, 10);
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
+
const { passkeyId } = req.params;
+
deletePasskey(passkeyId, userId);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/users/:id/name": {
+
PUT: async (req) => {
+
try {
+
requireAdmin(req);
+
const userId = Number.parseInt(req.params.id, 10);
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
+
const body = await req.json();
+
const { name } = body as { name: string };
+
+
if (!name || name.trim().length === 0) {
+
return Response.json(
+
{ error: "Name cannot be empty" },
+
{ status: 400 },
+
);
+
}
+
+
updateUserName(userId, name.trim());
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/users/:id/email": {
+
PUT: async (req) => {
+
try {
+
requireAdmin(req);
+
const userId = Number.parseInt(req.params.id, 10);
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
+
const body = await req.json();
+
const { email } = body as { email: string };
+
+
if (!email || !email.includes("@")) {
+
return Response.json(
+
{ error: "Invalid email address" },
+
{ status: 400 },
+
);
+
}
+
+
// Check if email already exists
+
const existing = db
+
.query<{ id: number }, [string, number]>(
+
"SELECT id FROM users WHERE email = ? AND id != ?",
+
)
+
.get(email, userId);
+
+
if (existing) {
+
return Response.json(
+
{ error: "Email already in use" },
+
{ status: 400 },
+
);
+
}
+
+
updateUserEmailAddress(userId, email);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/users/:id/sessions": {
+
GET: async (req) => {
+
try {
+
requireAdmin(req);
+
const userId = Number.parseInt(req.params.id, 10);
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
+
const sessions = getSessionsForUser(userId);
+
return Response.json(sessions);
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const userId = Number.parseInt(req.params.id, 10);
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
+
deleteAllUserSessions(userId);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/users/:id/sessions/:sessionId": {
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const userId = Number.parseInt(req.params.id, 10);
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
+
const { sessionId } = req.params;
+
const success = deleteSessionById(sessionId, userId);
+
+
if (!success) {
+
return Response.json(
+
{ error: "Session not found" },
+
{ status: 404 },
+
);
+
}
+
return Response.json({ success: true });
} catch (error) {
return handleError(error);
+186
src/lib/admin.test.ts
···
+
import { afterEach, beforeEach, expect, test } from "bun:test";
+
import { Database } from "bun:sqlite";
+
+
let testDb: Database;
+
+
beforeEach(() => {
+
testDb = new Database(":memory:");
+
+
testDb.run(`
+
CREATE TABLE users (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
email TEXT UNIQUE NOT NULL,
+
password_hash TEXT,
+
name TEXT,
+
avatar TEXT DEFAULT 'd',
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
role TEXT NOT NULL DEFAULT 'user'
+
)
+
`);
+
+
testDb.run(`
+
CREATE TABLE passkeys (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
credential_id TEXT NOT NULL UNIQUE,
+
public_key TEXT NOT NULL,
+
counter INTEGER NOT NULL DEFAULT 0,
+
transports TEXT,
+
name TEXT,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
last_used_at INTEGER,
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
)
+
`);
+
});
+
+
afterEach(() => {
+
testDb.close();
+
});
+
+
test("admin can update user name", async () => {
+
const result = testDb.run(
+
"INSERT INTO users (email, password_hash, name, avatar) VALUES (?, ?, ?, ?)",
+
["test@example.com", "password123", "Old Name", "avatar1"],
+
);
+
+
const userId = Number(result.lastInsertRowid);
+
+
testDb.run("UPDATE users SET name = ? WHERE id = ?", ["New Name", userId]);
+
+
const user = testDb
+
.query<{ name: string }, [number]>("SELECT name FROM users WHERE id = ?")
+
.get(userId);
+
+
expect(user?.name).toBe("New Name");
+
});
+
+
test("admin can update user password", async () => {
+
const result = testDb.run(
+
"INSERT INTO users (email, password_hash) VALUES (?, ?)",
+
["test@example.com", "password123"],
+
);
+
+
const userId = Number(result.lastInsertRowid);
+
+
testDb.run("UPDATE users SET password_hash = ? WHERE id = ?", [
+
"newpassword456",
+
userId,
+
]);
+
+
const user = testDb
+
.query<{ password_hash: string }, [number]>(
+
"SELECT password_hash FROM users WHERE id = ?",
+
)
+
.get(userId);
+
+
expect(user?.password_hash).toBe("newpassword456");
+
});
+
+
test("admin can view user passkeys", async () => {
+
const result = testDb.run(
+
"INSERT INTO users (email, password_hash) VALUES (?, ?)",
+
["test@example.com", "password123"],
+
);
+
+
const userId = Number(result.lastInsertRowid);
+
+
testDb.run(
+
"INSERT INTO passkeys (id, user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?, ?)",
+
["pk1", userId, "cred1", "pubkey1", 0],
+
);
+
+
testDb.run(
+
"INSERT INTO passkeys (id, user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?, ?)",
+
["pk2", userId, "cred2", "pubkey2", 0],
+
);
+
+
const passkeys = testDb
+
.query<{ id: string }, [number]>(
+
"SELECT id FROM passkeys WHERE user_id = ?",
+
)
+
.all(userId);
+
+
expect(passkeys.length).toBe(2);
+
});
+
+
test("admin can revoke user passkey", async () => {
+
const result = testDb.run(
+
"INSERT INTO users (email, password_hash) VALUES (?, ?)",
+
["test@example.com", "password123"],
+
);
+
+
const userId = Number(result.lastInsertRowid);
+
+
testDb.run(
+
"INSERT INTO passkeys (id, user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?, ?)",
+
["pk1", userId, "cred1", "pubkey1", 0],
+
);
+
+
let passkeys = testDb
+
.query<{ id: string }, [number]>(
+
"SELECT id FROM passkeys WHERE user_id = ?",
+
)
+
.all(userId);
+
expect(passkeys.length).toBe(1);
+
+
testDb.run("DELETE FROM passkeys WHERE id = ? AND user_id = ?", [
+
"pk1",
+
userId,
+
]);
+
+
passkeys = testDb
+
.query<{ id: string }, [number]>(
+
"SELECT id FROM passkeys WHERE user_id = ?",
+
)
+
.all(userId);
+
expect(passkeys.length).toBe(0);
+
});
+
+
test("updating password clears user sessions", async () => {
+
testDb.run(`
+
CREATE TABLE sessions (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
ip_address TEXT,
+
user_agent TEXT,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
expires_at INTEGER NOT NULL,
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
)
+
`);
+
+
const result = testDb.run(
+
"INSERT INTO users (email, password_hash) VALUES (?, ?)",
+
["test@example.com", "password123"],
+
);
+
+
const userId = Number(result.lastInsertRowid);
+
+
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
+
testDb.run(
+
"INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)",
+
["session1", userId, expiresAt],
+
);
+
+
let sessions = testDb
+
.query<{ id: string }, [number]>(
+
"SELECT id FROM sessions WHERE user_id = ?",
+
)
+
.all(userId);
+
expect(sessions.length).toBe(1);
+
+
testDb.run("UPDATE users SET password_hash = ? WHERE id = ?", [
+
"newpassword",
+
userId,
+
]);
+
testDb.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
+
+
sessions = testDb
+
.query<{ id: string }, [number]>(
+
"SELECT id FROM sessions WHERE user_id = ?",
+
)
+
.all(userId);
+
expect(sessions.length).toBe(0);
+
});
+
+74 -4
src/lib/auth.ts
···
avatar: string;
created_at: number;
role: UserRole;
+
last_login: number | null;
}
export interface Session {
···
const user = db
.query<User, [number]>(
-
"SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
+
"SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?",
)
.get(session.user_id);
···
const user = db
.query<User, [number]>(
-
"SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
+
"SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?",
)
.get(Number(result.lastInsertRowid));
···
password_hash: string;
created_at: number;
role: UserRole;
+
last_login: number | null;
},
[string]
>(
-
"SELECT id, email, name, avatar, password_hash, created_at, role FROM users WHERE email = ?",
+
"SELECT id, email, name, avatar, password_hash, created_at, role, last_login FROM users WHERE email = ?",
)
.get(email);
···
if (password !== result.password_hash) return null;
+
// Update last_login
+
const now = Math.floor(Date.now() / 1000);
+
db.run("UPDATE users SET last_login = ? WHERE id = ?", [now, result.id]);
+
return {
id: result.id,
email: result.email,
···
avatar: result.avatar,
created_at: result.created_at,
role: result.role,
+
last_login: now,
};
}
···
avatar: string;
created_at: number;
role: UserRole;
+
last_login: number | null;
},
[]
-
>("SELECT id, email, name, avatar, created_at, role FROM users ORDER BY created_at DESC")
+
>("SELECT id, email, name, avatar, created_at, role, last_login FROM users ORDER BY created_at DESC")
.all();
}
···
// Files might not exist, ignore errors
}
}
+
+
export function getSessionsForUser(userId: number): Session[] {
+
const now = Math.floor(Date.now() / 1000);
+
return db
+
.query<Session, [number, number]>(
+
"SELECT id, user_id, ip_address, user_agent, created_at, expires_at FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC",
+
)
+
.all(userId, now);
+
}
+
+
export function deleteSessionById(
+
sessionId: string,
+
userId: number,
+
): boolean {
+
const result = db.run(
+
"DELETE FROM sessions WHERE id = ? AND user_id = ?",
+
[sessionId, userId],
+
);
+
return result.changes > 0;
+
}
+
+
export function deleteAllUserSessions(userId: number): void {
+
db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
+
}
+
+
export function updateUserEmailAddress(
+
userId: number,
+
newEmail: string,
+
): void {
+
db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]);
+
}
+
+
export interface UserWithStats {
+
id: number;
+
email: string;
+
name: string | null;
+
avatar: string;
+
created_at: number;
+
role: UserRole;
+
last_login: number | null;
+
transcription_count: number;
+
}
+
+
export function getAllUsersWithStats(): UserWithStats[] {
+
return db
+
.query<UserWithStats, []>(
+
`SELECT
+
u.id,
+
u.email,
+
u.name,
+
u.avatar,
+
u.created_at,
+
u.role,
+
u.last_login,
+
COUNT(t.id) as transcription_count
+
FROM users u
+
LEFT JOIN transcriptions t ON u.id = t.user_id
+
GROUP BY u.id
+
ORDER BY u.created_at DESC`,
+
)
+
.all();
+
}
+5 -2
src/lib/passkey.ts
···
// Remove used challenge
authenticationChallenges.delete(expectedChallenge);
-
// Update last used timestamp and counter
+
// Update last used timestamp and counter for passkey
const now = Math.floor(Date.now() / 1000);
db.run(
"UPDATE passkeys SET last_used_at = ?, counter = ? WHERE id = ?",
[now, verification.authenticationInfo.newCounter, passkey.id],
);
+
// Update user's last_login
+
db.run("UPDATE users SET last_login = ? WHERE id = ?", [now, passkey.user_id]);
+
// Get user
const user = db
.query<User, [number]>(
-
"SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
+
"SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?",
)
.get(passkey.user_id);
+870 -8
src/pages/admin.html
···
opacity: 0.5;
cursor: not-allowed;
}
+
+
tbody tr {
+
cursor: pointer;
+
}
+
+
tbody tr:hover {
+
background: rgba(0, 0, 0, 0.04);
+
}
+
+
.modal {
+
display: none;
+
position: fixed;
+
top: 0;
+
left: 0;
+
right: 0;
+
bottom: 0;
+
background: rgba(0, 0, 0, 0.5);
+
z-index: 1000;
+
align-items: center;
+
justify-content: center;
+
padding: 2rem;
+
}
+
+
.modal.active {
+
display: flex;
+
}
+
+
.modal-content {
+
background: var(--background);
+
border-radius: 8px;
+
max-width: 40rem;
+
width: 100%;
+
max-height: 80vh;
+
overflow-y: auto;
+
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.3);
+
}
+
+
.modal-header {
+
padding: 1.5rem;
+
border-bottom: 2px solid var(--secondary);
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
}
+
+
.modal-title {
+
font-size: 1.5rem;
+
font-weight: 600;
+
color: var(--text);
+
margin: 0;
+
}
+
+
.modal-close {
+
background: transparent;
+
border: none;
+
font-size: 1.5rem;
+
cursor: pointer;
+
color: var(--text);
+
padding: 0;
+
width: 2rem;
+
height: 2rem;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
border-radius: 4px;
+
transition: background 0.2s;
+
}
+
+
.modal-close:hover {
+
background: var(--secondary);
+
}
+
+
.modal-body {
+
padding: 1.5rem;
+
}
+
+
.detail-section {
+
margin-bottom: 2rem;
+
}
+
+
.detail-section:last-child {
+
margin-bottom: 0;
+
}
+
+
.detail-section-title {
+
font-size: 1.125rem;
+
font-weight: 600;
+
color: var(--text);
+
margin-bottom: 1rem;
+
padding-bottom: 0.5rem;
+
border-bottom: 2px solid var(--secondary);
+
}
+
+
.detail-row {
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
padding: 0.75rem 0;
+
border-bottom: 1px solid var(--secondary);
+
}
+
+
.detail-row:last-child {
+
border-bottom: none;
+
}
+
+
.detail-label {
+
font-weight: 500;
+
color: var(--text);
+
}
+
+
.detail-value {
+
color: var(--text);
+
opacity: 0.8;
+
}
+
+
.form-group {
+
margin-bottom: 1rem;
+
}
+
+
.form-label {
+
display: block;
+
font-weight: 500;
+
color: var(--text);
+
margin-bottom: 0.5rem;
+
}
+
+
.form-input {
+
width: 100%;
+
padding: 0.5rem 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 4px;
+
font-size: 1rem;
+
font-family: inherit;
+
background: var(--background);
+
color: var(--text);
+
}
+
+
.form-input:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.btn {
+
padding: 0.5rem 1rem;
+
border: none;
+
border-radius: 4px;
+
font-size: 1rem;
+
font-weight: 500;
+
font-family: inherit;
+
cursor: pointer;
+
transition: all 0.2s;
+
}
+
+
.btn-primary {
+
background: var(--primary);
+
color: white;
+
}
+
+
.btn-primary:hover {
+
background: var(--gunmetal);
+
}
+
+
.btn-primary:disabled {
+
opacity: 0.5;
+
cursor: not-allowed;
+
}
+
+
.btn-danger {
+
background: #dc2626;
+
color: white;
+
}
+
+
.btn-danger:hover {
+
background: #b91c1c;
+
}
+
+
.btn-danger:disabled {
+
opacity: 0.5;
+
cursor: not-allowed;
+
}
+
+
.passkey-list {
+
list-style: none;
+
padding: 0;
+
margin: 0;
+
}
+
+
.passkey-item {
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
padding: 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 4px;
+
margin-bottom: 0.5rem;
+
}
+
+
.passkey-item:last-child {
+
margin-bottom: 0;
+
}
+
+
.passkey-info {
+
flex: 1;
+
}
+
+
.passkey-name {
+
font-weight: 500;
+
color: var(--text);
+
margin-bottom: 0.25rem;
+
}
+
+
.passkey-meta {
+
font-size: 0.875rem;
+
color: var(--text);
+
opacity: 0.6;
+
}
+
+
.passkey-actions {
+
display: flex;
+
gap: 0.5rem;
+
}
+
+
.btn-small {
+
padding: 0.25rem 0.75rem;
+
font-size: 0.875rem;
+
}
+
+
.empty-passkeys {
+
text-align: center;
+
padding: 2rem;
+
color: var(--text);
+
opacity: 0.6;
+
background: rgba(0, 0, 0, 0.02);
+
border-radius: 4px;
+
}
+
+
.password-status {
+
display: inline-block;
+
padding: 0.25rem 0.75rem;
+
border-radius: 4px;
+
font-size: 0.875rem;
+
font-weight: 500;
+
}
+
+
.password-status.has-password {
+
background: #dcfce7;
+
color: #166534;
+
}
+
+
.password-status.no-password {
+
background: #fee2e2;
+
color: #991b1b;
+
}
+
+
.search {
+
width: 100%;
+
max-width: 30rem;
+
margin-bottom: 1rem;
+
padding: 0.5rem 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 4px;
+
font-size: 1rem;
+
font-family: inherit;
+
background: var(--background);
+
color: var(--text);
+
}
+
+
.search:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
th.sortable {
+
cursor: pointer;
+
user-select: none;
+
position: relative;
+
}
+
+
th.sortable:hover {
+
background: var(--gunmetal);
+
}
+
+
th.sortable::after {
+
content: '';
+
margin-left: 0.5rem;
+
opacity: 0.3;
+
}
+
+
th.sortable.asc::after {
+
content: '▲';
+
opacity: 1;
+
}
+
+
th.sortable.desc::after {
+
content: '▼';
+
opacity: 1;
+
}
+
+
.session-list {
+
list-style: none;
+
padding: 0;
+
margin: 0;
+
}
+
+
.session-item {
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
padding: 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 4px;
+
margin-bottom: 0.5rem;
+
}
+
+
.session-item:last-child {
+
margin-bottom: 0;
+
}
+
+
.session-info {
+
flex: 1;
+
}
+
+
.session-device {
+
font-weight: 500;
+
color: var(--text);
+
margin-bottom: 0.25rem;
+
}
+
+
.session-meta {
+
font-size: 0.875rem;
+
color: var(--text);
+
opacity: 0.6;
+
}
+
+
.session-actions {
+
display: flex;
+
gap: 0.5rem;
+
}
+
+
.empty-sessions {
+
text-align: center;
+
padding: 2rem;
+
color: var(--text);
+
opacity: 0.6;
+
background: rgba(0, 0, 0, 0.02);
+
border-radius: 4px;
+
}
+
+
.section-actions {
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
margin-bottom: 1rem;
+
}
</style>
</head>
···
<div id="users-tab" class="tab-content">
<div class="section">
<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>
</div>
</div>
</main>
+
<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">&times;</button>
+
</div>
+
<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>
+
<div class="detail-row">
+
<span class="detail-label">Name</span>
+
<span class="detail-value" id="modal-name">-</span>
+
</div>
+
<div class="detail-row">
+
<span class="detail-label">Role</span>
+
<span class="detail-value" id="modal-role">-</span>
+
</div>
+
<div class="detail-row">
+
<span class="detail-label">Joined</span>
+
<span class="detail-value" id="modal-joined">-</span>
+
</div>
+
<div class="detail-row">
+
<span class="detail-label">Last Login</span>
+
<span class="detail-value" id="modal-last-login">-</span>
+
</div>
+
<div class="detail-row">
+
<span class="detail-label">Transcriptions</span>
+
<span class="detail-value" id="modal-transcription-count">-</span>
+
</div>
+
<div class="detail-row">
+
<span class="detail-label">Password Status</span>
+
<span id="modal-password-status">-</span>
+
</div>
+
</div>
+
+
<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">
+
</div>
+
<button type="submit" class="btn btn-primary">Update Name</button>
+
</form>
+
</div>
+
+
<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">
+
</div>
+
<button type="submit" class="btn btn-primary">Update Email</button>
+
</form>
+
</div>
+
+
<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)">
+
</div>
+
<button type="submit" class="btn btn-primary">Update Password</button>
+
</form>
+
</div>
+
+
<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>
+
<div id="sessions-container">
+
<div class="loading">Loading sessions...</div>
+
</div>
+
</div>
+
+
<div class="detail-section">
+
<h3 class="detail-section-title">Passkeys</h3>
+
<div id="passkeys-container">
+
<div class="loading">Loading passkeys...</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
<script type="module" src="../components/auth.ts"></script>
<script type="module">
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 allUsers = [];
+
let userSortKey = 'created_at';
+
let userSortDirection = 'desc';
+
let userSearchTerm = '';
// Get current user info
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';
+
}
+
+
// Modal functions
+
function openUserModal(userId) {
+
currentModalUserId = userId;
+
userModal.classList.add('active');
+
loadUserDetails(userId);
+
}
+
+
function closeUserModal() {
+
userModal.classList.remove('active');
+
currentModalUserId = null;
+
}
+
+
async function loadUserDetails(userId) {
+
try {
+
const res = await fetch(`/api/admin/users/${userId}/details`);
+
if (!res.ok) {
+
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>';
+
} else {
+
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);
+
} catch {
+
alert('Failed to load user details');
+
closeUserModal();
+
}
+
}
+
+
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;
+
return;
+
}
+
+
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>
+
</div>
+
<div class="session-actions">
+
<button class="btn btn-danger btn-small revoke-session-btn" data-session-id="${s.id}" data-user-id="${userId}">
+
Revoke
+
</button>
+
</div>
+
</li>
+
`).join('');
+
+
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.')) {
+
return;
+
}
+
+
button.disabled = true;
+
button.textContent = 'Revoking...';
+
+
try {
+
const res = await fetch(`/api/admin/users/${userId}/sessions/${sessionId}`, {
+
method: 'DELETE'
+
});
+
+
if (!res.ok) {
+
throw new Error('Failed to revoke session');
+
}
+
+
await loadUserDetails(userId);
+
} catch {
+
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>';
+
return;
+
}
+
+
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>
+
</div>
+
<div class="passkey-actions">
+
<button class="btn btn-danger btn-small revoke-passkey-btn" data-passkey-id="${pk.id}" data-user-id="${userId}">
+
Revoke
+
</button>
+
</div>
+
</li>
+
`).join('');
+
+
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.')) {
+
return;
+
}
+
+
button.disabled = true;
+
button.textContent = 'Revoking...';
+
+
try {
+
const res = await fetch(`/api/admin/users/${userId}/passkeys/${passkeyId}`, {
+
method: 'DELETE'
+
});
+
+
if (!res.ok) {
+
throw new Error('Failed to revoke passkey');
+
}
+
+
await loadUserDetails(userId);
+
} catch {
+
alert('Failed to revoke passkey');
+
button.disabled = false;
+
button.textContent = 'Revoke';
+
}
+
});
+
});
+
}
+
+
modalClose.addEventListener('click', closeUserModal);
+
userModal.addEventListener('click', (e) => {
+
if (e.target === userModal) {
+
closeUserModal();
+
}
+
});
+
+
document.getElementById('change-name-form').addEventListener('submit', async (e) => {
+
e.preventDefault();
+
const name = document.getElementById('new-name').value.trim();
+
+
if (!name) {
+
alert('Please enter a name');
+
return;
+
}
+
+
const submitBtn = e.target.querySelector('button[type="submit"]');
+
submitBtn.disabled = true;
+
submitBtn.textContent = 'Updating...';
+
+
try {
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/name`, {
+
method: 'PUT',
+
headers: {'Content-Type': 'application/json'},
+
body: JSON.stringify({name})
+
});
+
+
if (!res.ok) {
+
throw new Error('Failed to update name');
+
}
+
+
alert('Name updated successfully');
+
await loadUserDetails(currentModalUserId);
+
await loadData();
+
} catch {
+
alert('Failed to update name');
+
} finally {
+
submitBtn.disabled = false;
+
submitBtn.textContent = 'Update Name';
+
}
+
});
+
+
document.getElementById('change-email-form').addEventListener('submit', async (e) => {
+
e.preventDefault();
+
const email = document.getElementById('new-email').value.trim();
+
+
if (!email || !email.includes('@')) {
+
alert('Please enter a valid email');
+
return;
+
}
+
+
const submitBtn = e.target.querySelector('button[type="submit"]');
+
submitBtn.disabled = true;
+
submitBtn.textContent = 'Updating...';
+
+
try {
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/email`, {
+
method: 'PUT',
+
headers: {'Content-Type': 'application/json'},
+
body: JSON.stringify({email})
+
});
+
+
if (!res.ok) {
+
const data = await res.json();
+
throw new Error(data.error || 'Failed to update email');
+
}
+
+
alert('Email updated successfully');
+
await loadUserDetails(currentModalUserId);
+
await loadData();
+
} catch (error) {
+
alert(error.message || 'Failed to update email');
+
} finally {
+
submitBtn.disabled = false;
+
submitBtn.textContent = 'Update Email';
+
}
+
});
+
+
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
+
e.preventDefault();
+
const password = document.getElementById('new-password').value;
+
+
if (password.length < 8) {
+
alert('Password must be at least 8 characters');
+
return;
+
}
+
+
if (!confirm('Are you sure you want to change this user\'s password? This will log them out of all devices.')) {
+
return;
+
}
+
+
const submitBtn = e.target.querySelector('button[type="submit"]');
+
submitBtn.disabled = true;
+
submitBtn.textContent = 'Updating...';
+
+
try {
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/password`, {
+
method: 'PUT',
+
headers: {'Content-Type': 'application/json'},
+
body: JSON.stringify({password})
+
});
+
+
if (!res.ok) {
+
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);
+
} catch {
+
alert('Failed to update password');
+
} finally {
+
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.')) {
+
return;
+
}
+
+
const button = e.target;
+
button.disabled = true;
+
button.textContent = 'Logging out...';
+
+
try {
+
const res = await fetch(`/api/admin/users/${currentModalUserId}/sessions`, {
+
method: 'DELETE'
+
});
+
+
if (!res.ok) {
+
throw new Error('Failed to logout all devices');
+
}
+
+
alert('User logged out from all devices');
+
await loadUserDetails(currentModalUserId);
+
} catch {
+
alert('Failed to logout all devices');
+
} finally {
+
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) {
-
if (users.length === 0) {
-
usersTable.innerHTML = '<div class="empty-state">No users yet</div>';
+
allUsers = 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);
+
});
+
+
// Sort users
+
filteredUsers.sort((a, b) => {
+
let aVal = a[userSortKey];
+
let bVal = b[userSortKey];
+
+
// Handle null values
+
if (aVal === null || aVal === undefined) aVal = '';
+
if (bVal === null || bVal === undefined) bVal = '';
+
+
let comparison = 0;
+
if (typeof aVal === 'string' && typeof bVal === 'string') {
+
comparison = aVal.localeCompare(bVal);
+
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
+
comparison = aVal - bVal;
+
} else {
+
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>';
return;
}
···
table.innerHTML = `
<thead>
<tr>
-
<th>User</th>
-
<th>Email</th>
-
<th>Role</th>
-
<th>Joined</th>
+
<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>
<th>Actions</th>
</tr>
</thead>
<tbody>
-
${users.map(u => `
+
${filteredUsers.map(u => `
<tr>
<td>
<div class="user-info">
···
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>
</td>
+
<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>
<td>
-
<button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button>
+
<div class="actions">
+
<button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button>
+
</div>
</td>
</tr>
`).join('')}
···
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';
+
} else {
+
userSortKey = sortKey;
+
userSortDirection = 'asc';
+
}
+
renderUsers(allUsers);
+
});
+
});
+
// 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')) {
+
return;
+
}
+
+
const userId = row.querySelector('.delete-user-btn').dataset.userId;
+
openUserModal(userId);
+
});
+
});
}
async function loadData() {
···
tab.classList.add('active');
document.getElementById(`${tabName}-tab`).classList.add('active');
});
+
});
+
+
// User search
+
document.getElementById('user-search').addEventListener('input', (e) => {
+
userSearchTerm = e.target.value.trim();
+
renderUsers(allUsers);
});
// Initialize