@unbrained/pm-web 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +107 -0
  3. package/dist/auth.js +20 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/crypto.js +42 -0
  6. package/dist/crypto.js.map +1 -0
  7. package/dist/db.js +111 -0
  8. package/dist/db.js.map +1 -0
  9. package/dist/index.js +88 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/middleware/auth.js +16 -0
  12. package/dist/middleware/auth.js.map +1 -0
  13. package/dist/routes/admin.js +207 -0
  14. package/dist/routes/admin.js.map +1 -0
  15. package/dist/routes/auth.js +163 -0
  16. package/dist/routes/auth.js.map +1 -0
  17. package/dist/routes/github.js +354 -0
  18. package/dist/routes/github.js.map +1 -0
  19. package/dist/routes/groups.js +180 -0
  20. package/dist/routes/groups.js.map +1 -0
  21. package/dist/routes/pm.js +2446 -0
  22. package/dist/routes/pm.js.map +1 -0
  23. package/dist/routes/projects.js +151 -0
  24. package/dist/routes/projects.js.map +1 -0
  25. package/dist/routes/sharing.js +155 -0
  26. package/dist/routes/sharing.js.map +1 -0
  27. package/dist/server.js +64 -0
  28. package/dist/server.js.map +1 -0
  29. package/dist/services/pm-runner.js +190 -0
  30. package/dist/services/pm-runner.js.map +1 -0
  31. package/dist/services/sse.js +111 -0
  32. package/dist/services/sse.js.map +1 -0
  33. package/manifest.json +15 -0
  34. package/package.json +111 -0
  35. package/public/icons/icon-192.png +0 -0
  36. package/public/icons/icon-512.png +0 -0
  37. package/public/index.html +265 -0
  38. package/public/manifest.json +66 -0
  39. package/public/src/api.js +28 -0
  40. package/public/src/api.js.map +1 -0
  41. package/public/src/api.ts +29 -0
  42. package/public/src/app.js +926 -0
  43. package/public/src/app.js.map +1 -0
  44. package/public/src/app.ts +929 -0
  45. package/public/src/components/modals.js +62 -0
  46. package/public/src/components/modals.js.map +1 -0
  47. package/public/src/components/modals.ts +73 -0
  48. package/public/src/components/toast.js +10 -0
  49. package/public/src/components/toast.js.map +1 -0
  50. package/public/src/components/toast.ts +13 -0
  51. package/public/src/constants.js +30 -0
  52. package/public/src/constants.js.map +1 -0
  53. package/public/src/constants.ts +41 -0
  54. package/public/src/state.js +15 -0
  55. package/public/src/state.js.map +1 -0
  56. package/public/src/state.ts +19 -0
  57. package/public/src/types.js +5 -0
  58. package/public/src/types.js.map +1 -0
  59. package/public/src/types.ts +253 -0
  60. package/public/src/utils.js +57 -0
  61. package/public/src/utils.js.map +1 -0
  62. package/public/src/utils.ts +56 -0
  63. package/public/src/views/activity.js +47 -0
  64. package/public/src/views/activity.js.map +1 -0
  65. package/public/src/views/activity.ts +41 -0
  66. package/public/src/views/admin.js +435 -0
  67. package/public/src/views/admin.js.map +1 -0
  68. package/public/src/views/admin.ts +504 -0
  69. package/public/src/views/auth.js +81 -0
  70. package/public/src/views/auth.js.map +1 -0
  71. package/public/src/views/auth.ts +74 -0
  72. package/public/src/views/calendar.js +133 -0
  73. package/public/src/views/calendar.js.map +1 -0
  74. package/public/src/views/calendar.ts +129 -0
  75. package/public/src/views/comments-audit.js +109 -0
  76. package/public/src/views/comments-audit.js.map +1 -0
  77. package/public/src/views/comments-audit.ts +108 -0
  78. package/public/src/views/config.js +322 -0
  79. package/public/src/views/config.js.map +1 -0
  80. package/public/src/views/config.ts +344 -0
  81. package/public/src/views/context.js +98 -0
  82. package/public/src/views/context.js.map +1 -0
  83. package/public/src/views/context.ts +100 -0
  84. package/public/src/views/create.js +293 -0
  85. package/public/src/views/create.js.map +1 -0
  86. package/public/src/views/create.ts +246 -0
  87. package/public/src/views/dedupe.js +51 -0
  88. package/public/src/views/dedupe.js.map +1 -0
  89. package/public/src/views/dedupe.ts +43 -0
  90. package/public/src/views/export.js +300 -0
  91. package/public/src/views/export.js.map +1 -0
  92. package/public/src/views/export.ts +274 -0
  93. package/public/src/views/github.js +360 -0
  94. package/public/src/views/github.js.map +1 -0
  95. package/public/src/views/github.ts +308 -0
  96. package/public/src/views/graph-canvas.js +1986 -0
  97. package/public/src/views/graph-canvas.js.map +1 -0
  98. package/public/src/views/graph-canvas.ts +2218 -0
  99. package/public/src/views/graph.js +1824 -0
  100. package/public/src/views/graph.js.map +1 -0
  101. package/public/src/views/graph.ts +1891 -0
  102. package/public/src/views/groups.js +186 -0
  103. package/public/src/views/groups.js.map +1 -0
  104. package/public/src/views/groups.ts +172 -0
  105. package/public/src/views/guide.js +151 -0
  106. package/public/src/views/guide.js.map +1 -0
  107. package/public/src/views/guide.ts +162 -0
  108. package/public/src/views/health.js +105 -0
  109. package/public/src/views/health.js.map +1 -0
  110. package/public/src/views/health.ts +102 -0
  111. package/public/src/views/items.js +1306 -0
  112. package/public/src/views/items.js.map +1 -0
  113. package/public/src/views/items.ts +1196 -0
  114. package/public/src/views/normalize.js +67 -0
  115. package/public/src/views/normalize.js.map +1 -0
  116. package/public/src/views/normalize.ts +58 -0
  117. package/public/src/views/plan.js +454 -0
  118. package/public/src/views/plan.js.map +1 -0
  119. package/public/src/views/plan.ts +496 -0
  120. package/public/src/views/projects.js +204 -0
  121. package/public/src/views/projects.js.map +1 -0
  122. package/public/src/views/projects.ts +196 -0
  123. package/public/src/views/router.js +227 -0
  124. package/public/src/views/router.js.map +1 -0
  125. package/public/src/views/router.ts +188 -0
  126. package/public/src/views/search.js +103 -0
  127. package/public/src/views/search.js.map +1 -0
  128. package/public/src/views/search.ts +94 -0
  129. package/public/src/views/settings.js +272 -0
  130. package/public/src/views/settings.js.map +1 -0
  131. package/public/src/views/settings.ts +190 -0
  132. package/public/src/views/shared.js +49 -0
  133. package/public/src/views/shared.js.map +1 -0
  134. package/public/src/views/shared.ts +49 -0
  135. package/public/src/views/sharing.js +152 -0
  136. package/public/src/views/sharing.js.map +1 -0
  137. package/public/src/views/sharing.ts +139 -0
  138. package/public/src/views/stats.js +92 -0
  139. package/public/src/views/stats.js.map +1 -0
  140. package/public/src/views/stats.ts +88 -0
  141. package/public/src/views/templates.js +117 -0
  142. package/public/src/views/templates.js.map +1 -0
  143. package/public/src/views/templates.ts +113 -0
  144. package/public/src/views/validate.js +54 -0
  145. package/public/src/views/validate.js.map +1 -0
  146. package/public/src/views/validate.ts +48 -0
  147. package/public/styles.css +2231 -0
  148. package/public/sw.js +318 -0
  149. package/public/tsconfig.json +20 -0
  150. package/sql/schema.sql +105 -0
@@ -0,0 +1,504 @@
1
+ import { api } from '../api.js';
2
+ import { state } from '../state.js';
3
+ import type { AdminGroup, AdminProject, AdminUser } from '../types.js';
4
+ import { escHtml } from '../utils.js';
5
+ import { toast } from '../components/toast.js';
6
+ import { confirmDialog, createModal, showModal, hideModal } from '../components/modals.js';
7
+
8
+ type AdminOverview = {
9
+ users: AdminUser[];
10
+ projects: AdminProject[];
11
+ groups: AdminGroup[];
12
+ stats: {
13
+ users: number;
14
+ admins: number;
15
+ projects: number;
16
+ sharedProjects: number;
17
+ groups: number;
18
+ };
19
+ serverVersion?: string;
20
+ uptimeSeconds?: number;
21
+ };
22
+
23
+ type AuditEntry = {
24
+ id?: string;
25
+ action?: string;
26
+ actor_id?: string;
27
+ actor_email?: string;
28
+ actor_name?: string;
29
+ description?: string;
30
+ created_at?: string;
31
+ userId?: string;
32
+ userEmail?: string;
33
+ target?: string;
34
+ details?: string;
35
+ timestamp?: string;
36
+ };
37
+
38
+ type AuditApiEntry = {
39
+ id?: string;
40
+ action?: string;
41
+ actor_email?: string;
42
+ actor_name?: string;
43
+ description?: string;
44
+ created_at?: string;
45
+ };
46
+
47
+ type AdminAuditResponse = {
48
+ entries: AuditApiEntry[];
49
+ total: number;
50
+ limit: number;
51
+ offset: number;
52
+ };
53
+
54
+ function getAuditActor(entry: AuditEntry): string {
55
+ return entry.actor_name || entry.actor_email || entry.userId || entry.userEmail || '—';
56
+ }
57
+
58
+ function normalizeAuditEntries(entries: AuditApiEntry[]): AuditEntry[] {
59
+ return entries.map((entry) => ({
60
+ id: entry.id,
61
+ action: entry.action,
62
+ actor_email: entry.actor_email,
63
+ actor_name: entry.actor_name,
64
+ description: entry.description,
65
+ created_at: entry.created_at,
66
+ }));
67
+ }
68
+
69
+ function getAuditDescription(entry: AuditEntry): string {
70
+ if (entry.description) return entry.description;
71
+ if (entry.userId && entry.action) return `${entry.action} by ${entry.userId}`;
72
+ if (entry.action) return entry.action;
73
+ return '—';
74
+ }
75
+
76
+ let adminData: AdminOverview | null = null;
77
+ let adminTab: 'users' | 'projects' | 'groups' | 'audit' = 'users';
78
+ let userFilter = '';
79
+ let projectFilter = '';
80
+ let auditEntries: AuditEntry[] = [];
81
+ let auditFilter = '';
82
+ let currentPage = 1;
83
+ const PAGE_SIZE = 20;
84
+ let adminAuditTotal = 0;
85
+
86
+ async function loadAuditData(page = 1): Promise<void> {
87
+ if (!state.user?.is_admin) return;
88
+ const safePage = Math.max(1, page);
89
+ const offset = (safePage - 1) * PAGE_SIZE;
90
+ const data = await api('GET', `/admin/audit?limit=${PAGE_SIZE}&offset=${offset}`) as AdminAuditResponse;
91
+ auditEntries = normalizeAuditEntries(data.entries || []);
92
+ adminAuditTotal = data.total || 0;
93
+ currentPage = safePage;
94
+ }
95
+
96
+ function paginate<T>(items: T[], page: number): T[] {
97
+ const start = (page - 1) * PAGE_SIZE;
98
+ return items.slice(start, start + PAGE_SIZE);
99
+ }
100
+
101
+ function renderPagination(totalItems: number, currentPg: number, hook: string): string {
102
+ const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
103
+ if (totalPages <= 1) return '';
104
+ return `
105
+ <div class="admin-pagination" style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;font-size:12px;color:var(--text-muted)">
106
+ <span>Page ${currentPg} of ${totalPages} (${totalItems} items)</span>
107
+ <div style="display:flex;gap:4px">
108
+ <button class="btn btn-sm btn-secondary" ${currentPg <= 1 ? 'disabled' : ''} onclick="window.__app.adminSetPage(${currentPg - 1})" aria-label="Previous page">← Prev</button>
109
+ <button class="btn btn-sm btn-secondary" ${currentPg >= totalPages ? 'disabled' : ''} onclick="window.__app.adminSetPage(${currentPg + 1})" aria-label="Next page">Next →</button>
110
+ </div>
111
+ </div>`;
112
+ }
113
+
114
+ function renderUserRow(user: AdminUser): string {
115
+ return `
116
+ <tr>
117
+ <td>
118
+ <strong>${escHtml(user.display_name || user.email)}</strong>
119
+ <span>${escHtml(user.email)}</span>
120
+ </td>
121
+ <td>${user.is_admin ? '<span class="admin-pill admin-pill-strong">Admin</span>' : '<span class="admin-pill">User</span>'}</td>
122
+ <td>${user.has_github_token ? 'Connected' : 'Not connected'}</td>
123
+ <td>${new Date(user.created_at).toLocaleDateString()}</td>
124
+ <td>
125
+ <div style="display:flex;gap:4px;flex-wrap:wrap">
126
+ <button class="btn btn-secondary btn-sm" onclick="window.__app.setAdminRole('${escHtml(user.id)}', ${user.is_admin ? 'false' : 'true'})" aria-label="${user.is_admin ? 'Remove admin role' : 'Make admin'}">
127
+ ${user.is_admin ? 'Remove Admin' : 'Make Admin'}
128
+ </button>
129
+ <button class="btn btn-danger btn-sm" onclick="window.__app.adminDeleteUser('${escHtml(user.id)}','${escHtml(user.display_name || user.email)}')" aria-label="Delete user ${escHtml(user.display_name || user.email)}">
130
+ Delete
131
+ </button>
132
+ </div>
133
+ </td>
134
+ </tr>`;
135
+ }
136
+
137
+ function renderProjectRow(project: AdminProject): string {
138
+ const repo = project.github_owner && project.github_repo ? `${project.github_owner}/${project.github_repo}` : 'Not linked';
139
+ return `
140
+ <tr>
141
+ <td>
142
+ <strong>${escHtml(project.name)}</strong>
143
+ <span>${escHtml(project.slug)} · ${escHtml(project.prefix)}</span>
144
+ </td>
145
+ <td>${escHtml(project.owner_display_name || project.owner_email)}</td>
146
+ <td>${escHtml(repo)}</td>
147
+ <td>${project.github_sync_enabled ? '<span class="admin-pill admin-pill-strong">Sync on</span>' : '<span class="admin-pill">Sync off</span>'}</td>
148
+ <td>${new Date(project.created_at).toLocaleDateString()}</td>
149
+ <td>
150
+ <button class="btn btn-danger btn-sm" onclick="window.__app.adminDeleteProject('${escHtml(project.id)}','${escHtml(project.name)}')" aria-label="Delete project ${escHtml(project.name)}">
151
+ Delete
152
+ </button>
153
+ </td>
154
+ </tr>`;
155
+ }
156
+
157
+ function renderAuditRow(entry: AuditEntry): string {
158
+ const actor = getAuditActor(entry);
159
+ const details = getAuditDescription(entry);
160
+ return `
161
+ <tr>
162
+ <td style="white-space:nowrap">${escHtml(actor)}</td>
163
+ <td><span class="admin-pill">${escHtml(entry.action || '—')}</span></td>
164
+ <td>${escHtml(entry.target || '—')}</td>
165
+ <td style="max-width:300px;overflow:hidden;text-overflow:ellipsis">${escHtml(details || '—')}</td>
166
+ <td style="white-space:nowrap">${entry.created_at ? new Date(entry.created_at).toLocaleString() : entry.timestamp ? new Date(entry.timestamp).toLocaleString() : '—'}</td>
167
+ </tr>`;
168
+ }
169
+
170
+ function renderGroupCard(group: AdminGroup): string {
171
+ return `
172
+ <div class="admin-group-card">
173
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
174
+ <strong>${escHtml(group.name)}</strong>
175
+ <button class="btn btn-danger btn-sm" onclick="window.__app.adminDeleteGroup('${escHtml(group.id)}','${escHtml(group.name)}')" aria-label="Delete group ${escHtml(group.name)}" style="flex-shrink:0">Delete</button>
176
+ </div>
177
+ <span>${escHtml(group.owner_email)} · ${group.member_count} members</span>
178
+ ${group.description ? `<p>${escHtml(group.description)}</p>` : ''}
179
+ </div>`;
180
+ }
181
+
182
+ function formatUptime(seconds: number): string {
183
+ const h = Math.floor(seconds / 3600);
184
+ const m = Math.floor((seconds % 3600) / 60);
185
+ if (h > 0) return `${h}h ${m}m`;
186
+ return `${m}m`;
187
+ }
188
+
189
+ function renderAdmin(data: AdminOverview): string {
190
+ // Filter users
191
+ const filteredUsers = data.users.filter(u =>
192
+ !userFilter || u.email.toLowerCase().includes(userFilter.toLowerCase()) || (u.display_name || '').toLowerCase().includes(userFilter.toLowerCase())
193
+ );
194
+ const pagedUsers = paginate(filteredUsers, adminTab === 'users' ? currentPage : 1);
195
+
196
+ // Filter projects
197
+ const filteredProjects = data.projects.filter(p =>
198
+ !projectFilter || p.name.toLowerCase().includes(projectFilter.toLowerCase()) || p.slug.toLowerCase().includes(projectFilter.toLowerCase()) || p.owner_email.toLowerCase().includes(projectFilter.toLowerCase())
199
+ );
200
+ const pagedProjects = paginate(filteredProjects, adminTab === 'projects' ? currentPage : 1);
201
+
202
+ // Filter audit
203
+ const filteredAudit = auditEntries.filter(e =>
204
+ !auditFilter ||
205
+ (e.action || '').toLowerCase().includes(auditFilter.toLowerCase()) ||
206
+ (e.userEmail || '').toLowerCase().includes(auditFilter.toLowerCase()) ||
207
+ (e.target || '').toLowerCase().includes(auditFilter.toLowerCase()) ||
208
+ (e.details || '').toLowerCase().includes(auditFilter.toLowerCase())
209
+ );
210
+ const pagedAudit = adminTab === 'audit' ? filteredAudit : [];
211
+
212
+ const tabs = [
213
+ { id: 'users' as const, label: 'Users', count: filteredUsers.length },
214
+ { id: 'projects' as const, label: 'Projects', count: filteredProjects.length },
215
+ { id: 'groups' as const, label: 'Groups', count: data.groups.length },
216
+ { id: 'audit' as const, label: 'Audit Log', count: adminAuditTotal },
217
+ ];
218
+
219
+ return `
220
+ <div class="view-header">
221
+ <div>
222
+ <h1>Admin</h1>
223
+ <p class="view-subtitle">User, project, sharing, GitHub, and group oversight for pm-web.</p>
224
+ </div>
225
+ <div class="page-actions">
226
+ <button class="btn btn-secondary" onclick="window.__app.renderAdminView()" aria-label="Refresh admin data">Refresh</button>
227
+ </div>
228
+ </div>
229
+
230
+ <div class="admin-stats">
231
+ <div class="stat-card"><div class="stat-icon">◉</div><div class="stat-value">${data.stats.users}</div><div class="stat-label">Users</div></div>
232
+ <div class="stat-card"><div class="stat-icon">◇</div><div class="stat-value">${data.stats.admins}</div><div class="stat-label">Admins</div></div>
233
+ <div class="stat-card"><div class="stat-icon">⊞</div><div class="stat-value">${data.stats.projects}</div><div class="stat-label">Projects</div></div>
234
+ <div class="stat-card"><div class="stat-icon">⇄</div><div class="stat-value">${data.stats.sharedProjects}</div><div class="stat-label">Shares</div></div>
235
+ <div class="stat-card"><div class="stat-icon">◈</div><div class="stat-value">${data.stats.groups}</div><div class="stat-label">Groups</div></div>
236
+ ${data.uptimeSeconds !== undefined ? `<div class="stat-card"><div class="stat-icon">◎</div><div class="stat-value">${formatUptime(data.uptimeSeconds)}</div><div class="stat-label">Uptime</div></div>` : ''}
237
+ ${data.serverVersion ? `<div class="stat-card"><div class="stat-icon">◫</div><div class="stat-value">v${escHtml(data.serverVersion)}</div><div class="stat-label">Version</div></div>` : ''}
238
+ <div class="stat-card"><div class="stat-icon">◷</div><div class="stat-value" style="font-size:12px">${new Date().toLocaleTimeString()}</div><div class="stat-label">Last Refreshed</div></div>
239
+ </div>
240
+
241
+ <div class="tabs" role="tablist">
242
+ ${tabs.map(t => `<div class="tab${adminTab === t.id ? ' active' : ''}" role="tab" aria-selected="${adminTab === t.id}" tabindex="0" onclick="window.__app.adminSwitchTab('${t.id}')" onkeydown="if(event.key==='Enter')window.__app.adminSwitchTab('${t.id}')">${escHtml(t.label)} (${t.count})</div>`).join('')}
243
+ </div>
244
+
245
+ ${adminTab === 'users' ? `
246
+ <section class="admin-panel" aria-label="Users management">
247
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px">
248
+ <div class="graph-panel-title" style="margin-bottom:0">Users</div>
249
+ <div class="search-box-wrap" style="margin-bottom:0;max-width:300px;flex:1">
250
+ <span class="search-icon">⌕</span>
251
+ <input class="search-input" type="text" placeholder="Filter users…" value="${escHtml(userFilter)}" oninput="window.__app.adminFilterUsers(this.value)" aria-label="Filter users">
252
+ </div>
253
+ </div>
254
+ <div class="admin-table-wrap">
255
+ <table class="admin-table" role="table">
256
+ <thead><tr><th>User</th><th>Role</th><th>GitHub</th><th>Created</th><th>Actions</th></tr></thead>
257
+ <tbody>${pagedUsers.map(renderUserRow).join('')}</tbody>
258
+ </table>
259
+ </div>
260
+ ${renderPagination(filteredUsers.length, currentPage, 'users')}
261
+ </section>` : ''}
262
+
263
+ ${adminTab === 'projects' ? `
264
+ <section class="admin-panel" aria-label="Projects management">
265
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px">
266
+ <div class="graph-panel-title" style="margin-bottom:0">Projects</div>
267
+ <div class="search-box-wrap" style="margin-bottom:0;max-width:300px;flex:1">
268
+ <span class="search-icon">⌕</span>
269
+ <input class="search-input" type="text" placeholder="Filter projects…" value="${escHtml(projectFilter)}" oninput="window.__app.adminFilterProjects(this.value)" aria-label="Filter projects">
270
+ </div>
271
+ </div>
272
+ <div class="admin-table-wrap">
273
+ <table class="admin-table" role="table">
274
+ <thead><tr><th>Project</th><th>Owner</th><th>GitHub Repo</th><th>Sync</th><th>Created</th><th>Actions</th></tr></thead>
275
+ <tbody>${pagedProjects.map(renderProjectRow).join('')}</tbody>
276
+ </table>
277
+ </div>
278
+ ${renderPagination(filteredProjects.length, currentPage, 'projects')}
279
+ </section>` : ''}
280
+
281
+ ${adminTab === 'groups' ? `
282
+ <section class="admin-panel" aria-label="Groups management">
283
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px">
284
+ <div class="graph-panel-title" style="margin-bottom:0">Groups</div>
285
+ <button class="btn btn-primary btn-sm" onclick="window.__app.adminCreateGroup()" aria-label="Create new group">+ New Group</button>
286
+ </div>
287
+ <div class="admin-grid-list">
288
+ ${data.groups.length === 0
289
+ ? '<div class="empty-state"><div class="empty-state-text">No groups yet.</div></div>'
290
+ : data.groups.map(renderGroupCard).join('')}
291
+ </div>
292
+ </section>` : ''}
293
+
294
+ ${adminTab === 'audit' ? `
295
+ <section class="admin-panel" aria-label="Audit log">
296
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px">
297
+ <div class="graph-panel-title" style="margin-bottom:0">Audit Log</div>
298
+ <div class="search-box-wrap" style="margin-bottom:0;max-width:300px;flex:1">
299
+ <span class="search-icon">⌕</span>
300
+ <input class="search-input" type="text" placeholder="Filter audit log…" value="${escHtml(auditFilter)}" oninput="window.__app.adminFilterAudit(this.value)" aria-label="Filter audit log">
301
+ </div>
302
+ </div>
303
+ ${filteredAudit.length === 0
304
+ ? '<div class="empty-state"><div class="empty-state-text">No audit entries found.</div></div>'
305
+ : `<div class="admin-table-wrap">
306
+ <table class="admin-table" role="table">
307
+ <thead><tr><th>User</th><th>Action</th><th>Target</th><th>Details</th><th>Time</th></tr></thead>
308
+ <tbody>${pagedAudit.map(renderAuditRow).join('')}</tbody>
309
+ </table>
310
+ </div>
311
+ ${renderPagination(adminAuditTotal, currentPage, 'audit')}`}
312
+ </section>` : ''}`;
313
+ }
314
+
315
+ export async function renderAdminView(): Promise<void> {
316
+ const el = document.getElementById('content-admin');
317
+ if (!el) return;
318
+ if (!state.user?.is_admin) {
319
+ el.innerHTML = `<div class="empty-state"><div class="empty-state-text">Admin access is required to view this page.</div></div>`;
320
+ return;
321
+ }
322
+ el.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div></div>';
323
+ try {
324
+ adminData = await api('GET', '/admin/overview') as AdminOverview;
325
+ if (adminTab === 'audit') {
326
+ await loadAuditData(currentPage);
327
+ }
328
+ el.innerHTML = renderAdmin(adminData);
329
+ } catch (err: unknown) {
330
+ el.innerHTML = `<div class="empty-state"><div class="empty-state-text">Admin failed: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
331
+ }
332
+ }
333
+
334
+ export async function setAdminRole(userId: string, isAdmin: boolean): Promise<void> {
335
+ try {
336
+ await api('PATCH', `/admin/users/${encodeURIComponent(userId)}`, { isAdmin });
337
+ toast('Admin role updated', 'success');
338
+ await renderAdminView();
339
+ } catch (err: unknown) {
340
+ toast(err instanceof Error ? err.message : String(err), 'error');
341
+ }
342
+ }
343
+
344
+ export function adminSwitchTab(tab: 'users' | 'projects' | 'groups' | 'audit'): void {
345
+ adminTab = tab;
346
+ currentPage = 1;
347
+ if (tab === 'audit') {
348
+ void loadAuditData(1).then(() => {
349
+ const el = document.getElementById('content-admin');
350
+ if (adminData && el) el.innerHTML = renderAdmin(adminData);
351
+ }).catch((err: unknown) => {
352
+ const el = document.getElementById('content-admin');
353
+ if (el) el.innerHTML = `<div class="empty-state"><div class="empty-state-text">Failed to load audit log: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
354
+ });
355
+ return;
356
+ }
357
+ const el = document.getElementById('content-admin');
358
+ if (el && adminData) {
359
+ el.innerHTML = renderAdmin(adminData);
360
+ }
361
+ }
362
+
363
+ export function adminFilterUsers(filter: string): void {
364
+ userFilter = filter;
365
+ currentPage = 1;
366
+ if (adminData) {
367
+ const el = document.getElementById('content-admin');
368
+ if (el) el.innerHTML = renderAdmin(adminData);
369
+ }
370
+ }
371
+
372
+ export function adminFilterProjects(filter: string): void {
373
+ projectFilter = filter;
374
+ currentPage = 1;
375
+ if (adminData) {
376
+ const el = document.getElementById('content-admin');
377
+ if (el) el.innerHTML = renderAdmin(adminData);
378
+ }
379
+ }
380
+
381
+ export function adminFilterAudit(filter: string): void {
382
+ auditFilter = filter;
383
+ currentPage = 1;
384
+ if (adminData) {
385
+ const el = document.getElementById('content-admin');
386
+ if (adminTab === 'audit') {
387
+ void loadAuditData(1).then(() => {
388
+ if (el) el.innerHTML = renderAdmin(adminData as AdminOverview);
389
+ }).catch((err: unknown) => {
390
+ if (el) el.innerHTML = `<div class="empty-state"><div class="empty-state-text">Failed to reload audit log: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
391
+ });
392
+ return;
393
+ }
394
+ if (el) el.innerHTML = renderAdmin(adminData);
395
+ }
396
+ }
397
+
398
+ export async function adminSetPage(page: number): Promise<void> {
399
+ if (page < 1) return;
400
+ if (!adminData) return;
401
+
402
+ if (adminTab === 'audit') {
403
+ await loadAuditData(page);
404
+ const el = document.getElementById('content-admin');
405
+ if (el) el.innerHTML = renderAdmin(adminData);
406
+ return;
407
+ }
408
+
409
+ const userFilterLower = userFilter.toLowerCase();
410
+ const projectFilterLower = projectFilter.toLowerCase();
411
+ const filtered = adminTab === 'users'
412
+ ? adminData.users.filter((u) =>
413
+ !userFilter || u.email.toLowerCase().includes(userFilterLower) || (u.display_name || '').toLowerCase().includes(userFilterLower)
414
+ )
415
+ : adminData.projects.filter((p) =>
416
+ !projectFilter || p.name.toLowerCase().includes(projectFilterLower) || p.slug.toLowerCase().includes(projectFilterLower) || p.owner_email.toLowerCase().includes(projectFilterLower)
417
+ );
418
+ const totalPages = Math.max(1, Math.ceil((filtered?.length || 0) / PAGE_SIZE));
419
+ currentPage = Math.min(page, totalPages);
420
+
421
+ const el = document.getElementById('content-admin');
422
+ if (el) el.innerHTML = renderAdmin(adminData);
423
+ }
424
+
425
+ export async function adminDeleteUser(userId: string, userName: string): Promise<void> {
426
+ confirmDialog(
427
+ `Delete user "${userName}"?`,
428
+ 'This action cannot be undone. The user will be permanently removed.',
429
+ async () => {
430
+ try {
431
+ await api('DELETE', `/admin/users/${encodeURIComponent(userId)}`);
432
+ toast('User deleted', 'success');
433
+ await renderAdminView();
434
+ } catch (err: unknown) {
435
+ toast(err instanceof Error ? err.message : String(err), 'error');
436
+ }
437
+ },
438
+ true
439
+ );
440
+ }
441
+
442
+ export async function adminDeleteProject(projectId: string, projectName: string): Promise<void> {
443
+ confirmDialog(
444
+ `Delete project "${projectName}"?`,
445
+ 'This will permanently delete the project and all its items. This action cannot be undone.',
446
+ async () => {
447
+ try {
448
+ await api('DELETE', `/admin/projects/${encodeURIComponent(projectId)}`);
449
+ toast('Project deleted', 'success');
450
+ await renderAdminView();
451
+ } catch (err: unknown) {
452
+ toast(err instanceof Error ? err.message : String(err), 'error');
453
+ }
454
+ },
455
+ true
456
+ );
457
+ }
458
+
459
+ export async function adminDeleteGroup(groupId: string, groupName: string): Promise<void> {
460
+ confirmDialog(
461
+ `Delete group "${groupName}"?`,
462
+ 'This will permanently remove the group and all its memberships.',
463
+ async () => {
464
+ try {
465
+ await api('DELETE', `/admin/groups/${encodeURIComponent(groupId)}`);
466
+ toast('Group deleted', 'success');
467
+ await renderAdminView();
468
+ } catch (err: unknown) {
469
+ toast(err instanceof Error ? err.message : String(err), 'error');
470
+ }
471
+ },
472
+ true
473
+ );
474
+ }
475
+
476
+ export function adminCreateGroup(): void {
477
+ const id = 'admin-create-group-' + Date.now();
478
+ createModal(id, 'Create Group', `
479
+ <div class="form-group">
480
+ <label class="form-label" for="admin-group-name">Group Name</label>
481
+ <input class="form-input" id="admin-group-name" type="text" placeholder="e.g. Engineering" required aria-required="true">
482
+ </div>
483
+ <div class="form-group">
484
+ <label class="form-label" for="admin-group-desc">Description</label>
485
+ <input class="form-input" id="admin-group-desc" type="text" placeholder="Optional description">
486
+ </div>
487
+ <div class="form-error" id="admin-group-error" style="display:none"></div>
488
+ `, `<button class="btn btn-primary" id="${id}-submit">Create Group</button>`);
489
+ showModal(id);
490
+ document.getElementById(`${id}-submit`)?.addEventListener('click', async () => {
491
+ const name = (document.getElementById('admin-group-name') as HTMLInputElement)?.value?.trim();
492
+ const desc = (document.getElementById('admin-group-desc') as HTMLInputElement)?.value?.trim();
493
+ const errEl = document.getElementById('admin-group-error');
494
+ if (!name) { if (errEl) { errEl.textContent = 'Group name is required'; errEl.style.display = 'block'; } return; }
495
+ try {
496
+ await api('POST', '/admin/groups', { name, description: desc });
497
+ toast('Group created', 'success');
498
+ hideModal(id);
499
+ await renderAdminView();
500
+ } catch (err: unknown) {
501
+ if (errEl) { errEl.textContent = err instanceof Error ? err.message : String(err); errEl.style.display = 'block'; }
502
+ }
503
+ });
504
+ }
@@ -0,0 +1,81 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // AUTH VIEW
3
+ // ═══════════════════════════════════════════════════════════════
4
+ import { state } from '../state.js';
5
+ import { api } from '../api.js';
6
+ import { bootApp } from '../app.js';
7
+ export function switchAuthTab(tab) {
8
+ state.authTab = tab;
9
+ document.getElementById('tab-login')?.classList.toggle('active', tab === 'login');
10
+ document.getElementById('tab-register')?.classList.toggle('active', tab === 'register');
11
+ const fieldName = document.getElementById('field-name');
12
+ if (fieldName)
13
+ fieldName.style.display = tab === 'register' ? '' : 'none';
14
+ const authTitle = document.getElementById('auth-title');
15
+ if (authTitle)
16
+ authTitle.textContent = tab === 'login' ? 'Welcome back' : 'Create account';
17
+ const authSub = document.getElementById('auth-sub');
18
+ if (authSub)
19
+ authSub.textContent = tab === 'login' ? 'Sign in to your account to continue' : 'Join pm-web and start managing projects';
20
+ const authBtnText = document.getElementById('auth-btn-text');
21
+ if (authBtnText)
22
+ authBtnText.textContent = tab === 'login' ? 'Sign In' : 'Create Account';
23
+ const authError = document.getElementById('auth-error');
24
+ if (authError)
25
+ authError.style.display = 'none';
26
+ }
27
+ export async function submitAuth(e) {
28
+ e.preventDefault();
29
+ const btn = document.getElementById('auth-submit');
30
+ const errEl = document.getElementById('auth-error');
31
+ const emailEl = document.getElementById('auth-email');
32
+ const passwordEl = document.getElementById('auth-password');
33
+ const nameEl = document.getElementById('auth-name');
34
+ if (!btn || !errEl || !emailEl || !passwordEl)
35
+ return;
36
+ const email = emailEl.value.trim();
37
+ const password = passwordEl.value;
38
+ const name = nameEl?.value.trim() || '';
39
+ errEl.style.display = 'none';
40
+ btn.disabled = true;
41
+ const span = btn.querySelector('span');
42
+ if (span)
43
+ span.textContent = 'Please wait…';
44
+ try {
45
+ let data;
46
+ if (state.authTab === 'login') {
47
+ data = await api('POST', '/auth/login', { email, password });
48
+ }
49
+ else {
50
+ data = await api('POST', '/auth/register', { email, password, displayName: name || email.split('@')[0] });
51
+ }
52
+ state.user = data.user;
53
+ await bootApp();
54
+ }
55
+ catch (err) {
56
+ errEl.textContent = err instanceof Error ? err.message : String(err);
57
+ errEl.style.display = 'block';
58
+ btn.disabled = false;
59
+ if (span)
60
+ span.textContent = state.authTab === 'login' ? 'Sign In' : 'Create Account';
61
+ }
62
+ }
63
+ export async function logout() {
64
+ try {
65
+ await api('POST', '/auth/logout', {});
66
+ }
67
+ catch (_) { /* ignore */ }
68
+ state.user = null;
69
+ state.projects = [];
70
+ state.currentProject = null;
71
+ showAuth();
72
+ }
73
+ export function showAuth() {
74
+ const authScreen = document.getElementById('auth-screen');
75
+ const mainApp = document.getElementById('main-app');
76
+ if (authScreen)
77
+ authScreen.style.display = 'flex';
78
+ if (mainApp)
79
+ mainApp.style.display = 'none';
80
+ }
81
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,74 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // AUTH VIEW
3
+ // ═══════════════════════════════════════════════════════════════
4
+ import { state } from '../state.js';
5
+ import { api } from '../api.js';
6
+ import { bootApp } from '../app.js';
7
+ import type { User } from '../types.js';
8
+
9
+ export function switchAuthTab(tab: 'login' | 'register'): void {
10
+ state.authTab = tab;
11
+ document.getElementById('tab-login')?.classList.toggle('active', tab==='login');
12
+ document.getElementById('tab-register')?.classList.toggle('active', tab==='register');
13
+ const fieldName = document.getElementById('field-name') as HTMLElement | null;
14
+ if (fieldName) fieldName.style.display = tab==='register' ? '' : 'none';
15
+ const authTitle = document.getElementById('auth-title');
16
+ if (authTitle) authTitle.textContent = tab==='login' ? 'Welcome back' : 'Create account';
17
+ const authSub = document.getElementById('auth-sub');
18
+ if (authSub) authSub.textContent = tab==='login' ? 'Sign in to your account to continue' : 'Join pm-web and start managing projects';
19
+ const authBtnText = document.getElementById('auth-btn-text');
20
+ if (authBtnText) authBtnText.textContent = tab==='login' ? 'Sign In' : 'Create Account';
21
+ const authError = document.getElementById('auth-error') as HTMLElement | null;
22
+ if (authError) authError.style.display = 'none';
23
+ }
24
+
25
+ export async function submitAuth(e: Event): Promise<void> {
26
+ e.preventDefault();
27
+ const btn = document.getElementById('auth-submit') as HTMLButtonElement | null;
28
+ const errEl = document.getElementById('auth-error') as HTMLElement | null;
29
+ const emailEl = document.getElementById('auth-email') as HTMLInputElement | null;
30
+ const passwordEl = document.getElementById('auth-password') as HTMLInputElement | null;
31
+ const nameEl = document.getElementById('auth-name') as HTMLInputElement | null;
32
+
33
+ if (!btn || !errEl || !emailEl || !passwordEl) return;
34
+
35
+ const email = emailEl.value.trim();
36
+ const password = passwordEl.value;
37
+ const name = nameEl?.value.trim() || '';
38
+
39
+ errEl.style.display = 'none';
40
+ btn.disabled = true;
41
+ const span = btn.querySelector('span');
42
+ if (span) span.textContent = 'Please wait…';
43
+
44
+ try {
45
+ let data: { user: User };
46
+ if (state.authTab === 'login') {
47
+ data = await api('POST','/auth/login',{email,password});
48
+ } else {
49
+ data = await api('POST','/auth/register',{email,password,displayName:name||email.split('@')[0]});
50
+ }
51
+ state.user = data.user;
52
+ await bootApp();
53
+ } catch(err: unknown) {
54
+ errEl.textContent = err instanceof Error ? err.message : String(err);
55
+ errEl.style.display = 'block';
56
+ btn.disabled = false;
57
+ if (span) span.textContent = state.authTab==='login' ? 'Sign In' : 'Create Account';
58
+ }
59
+ }
60
+
61
+ export async function logout(): Promise<void> {
62
+ try { await api('POST','/auth/logout',{}); } catch(_) { /* ignore */ }
63
+ state.user = null;
64
+ state.projects = [];
65
+ state.currentProject = null;
66
+ showAuth();
67
+ }
68
+
69
+ export function showAuth(): void {
70
+ const authScreen = document.getElementById('auth-screen');
71
+ const mainApp = document.getElementById('main-app');
72
+ if (authScreen) authScreen.style.display = 'flex';
73
+ if (mainApp) mainApp.style.display = 'none';
74
+ }