@startup-api/cloudflare 0.0.1

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 (39) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +114 -0
  3. package/package.json +53 -0
  4. package/public/index.html +405 -0
  5. package/public/users/accounts.html +504 -0
  6. package/public/users/admin/index.html +765 -0
  7. package/public/users/power-strip.js +658 -0
  8. package/public/users/profile.html +443 -0
  9. package/public/users/style.css +493 -0
  10. package/src/CookieManager.ts +56 -0
  11. package/src/PowerStrip.ts +23 -0
  12. package/src/StartupAPIEnv.ts +12 -0
  13. package/src/auth/GoogleProvider.ts +67 -0
  14. package/src/auth/OAuthProvider.ts +52 -0
  15. package/src/auth/TwitchProvider.ts +64 -0
  16. package/src/auth/index.ts +231 -0
  17. package/src/billing/PaymentEngine.ts +20 -0
  18. package/src/billing/Plan.ts +80 -0
  19. package/src/billing/plansConfig.ts +48 -0
  20. package/src/handlers/account.ts +246 -0
  21. package/src/handlers/admin.ts +144 -0
  22. package/src/handlers/auth.ts +54 -0
  23. package/src/handlers/ssr.ts +274 -0
  24. package/src/handlers/user.ts +168 -0
  25. package/src/handlers/utils.ts +120 -0
  26. package/src/index.ts +190 -0
  27. package/src/schemas/account.ts +37 -0
  28. package/src/schemas/admin.ts +10 -0
  29. package/src/schemas/billing.ts +11 -0
  30. package/src/schemas/credential.ts +38 -0
  31. package/src/schemas/membership.ts +9 -0
  32. package/src/schemas/session.ts +10 -0
  33. package/src/schemas/user.ts +22 -0
  34. package/src/storage/AccountDO.ts +370 -0
  35. package/src/storage/CredentialDO.ts +82 -0
  36. package/src/storage/SystemDO.ts +264 -0
  37. package/src/storage/UserDO.ts +385 -0
  38. package/worker-configuration.d.ts +11696 -0
  39. package/wrangler.template.jsonc +55 -0
@@ -0,0 +1,765 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Admin Dashboard</title>
7
+ <style>
8
+ body {
9
+ font-family:
10
+ system-ui,
11
+ -apple-system,
12
+ sans-serif;
13
+ padding: 2rem;
14
+ max-width: 1200px;
15
+ margin: 0 auto;
16
+ background: #f9f9f9;
17
+ }
18
+ h1,
19
+ h2 {
20
+ color: #333;
21
+ }
22
+ section {
23
+ background: white;
24
+ padding: 1.5rem;
25
+ border-radius: 8px;
26
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
27
+ margin-bottom: 2rem;
28
+ }
29
+ .search-bar {
30
+ display: flex;
31
+ gap: 0.5rem;
32
+ margin-bottom: 1rem;
33
+ }
34
+ input[type='text'] {
35
+ padding: 0.5rem;
36
+ border: 1px solid #ddd;
37
+ border-radius: 4px;
38
+ flex-grow: 1;
39
+ }
40
+ button {
41
+ padding: 0.5rem 1rem;
42
+ background: #1a73e8;
43
+ color: white;
44
+ border: none;
45
+ border-radius: 4px;
46
+ cursor: pointer;
47
+ }
48
+ button:hover {
49
+ background: #1557b0;
50
+ }
51
+ table {
52
+ width: 100%;
53
+ border-collapse: collapse;
54
+ }
55
+ th,
56
+ td {
57
+ text-align: left;
58
+ padding: 0.75rem;
59
+ border-bottom: 1px solid #eee;
60
+ }
61
+ th {
62
+ background: #f0f0f0;
63
+ }
64
+ .actions {
65
+ display: flex;
66
+ gap: 0.5rem;
67
+ }
68
+ .secondary-btn {
69
+ background: #eee;
70
+ color: #333;
71
+ }
72
+ .secondary-btn:hover {
73
+ background: #e0e0e0;
74
+ }
75
+ #toast {
76
+ position: fixed;
77
+ bottom: 1rem;
78
+ right: 1rem;
79
+ padding: 1rem;
80
+ background: #333;
81
+ color: white;
82
+ border-radius: 4px;
83
+ opacity: 0;
84
+ transition: opacity 0.3s;
85
+ }
86
+ dialog {
87
+ padding: 2rem;
88
+ border: none;
89
+ border-radius: 8px;
90
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
91
+ width: 400px;
92
+ }
93
+ dialog::backdrop {
94
+ background: rgba(0, 0, 0, 0.5);
95
+ }
96
+ dialog h3 {
97
+ margin-top: 0;
98
+ }
99
+ .form-group {
100
+ margin-bottom: 1rem;
101
+ }
102
+ .form-group label {
103
+ display: block;
104
+ margin-bottom: 0.5rem;
105
+ font-weight: 500;
106
+ }
107
+ .form-group input,
108
+ .form-group select {
109
+ width: 100%;
110
+ padding: 0.5rem;
111
+ border: 1px solid #ddd;
112
+ border-radius: 4px;
113
+ box-sizing: border-box;
114
+ }
115
+ .modal-actions {
116
+ display: flex;
117
+ justify-content: flex-end;
118
+ gap: 0.5rem;
119
+ margin-top: 1.5rem;
120
+ }
121
+ </style>
122
+ </head>
123
+ <body data-ssr-plans="{{ssr:plans_json}}">
124
+ <power-strip
125
+ providers="{{ssr:providers}}"
126
+ style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem"
127
+ >
128
+ <svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem"><path d="M7 2v11h3v9l7-12h-4l4-8z" fill="#ffcc00" /></svg>
129
+ </power-strip>
130
+ <script src="/users/power-strip.js" async></script>
131
+
132
+ <h1>StartupAPI Admin</h1>
133
+
134
+ <section id="users-section">
135
+ <h2>Users</h2>
136
+ <form
137
+ class="search-bar"
138
+ onsubmit="
139
+ event.preventDefault();
140
+ searchUsers();
141
+ "
142
+ >
143
+ <input type="text" id="user-search" placeholder="Search users by name or email..." />
144
+ <button type="submit">Search</button>
145
+ </form>
146
+ <table>
147
+ <thead>
148
+ <tr>
149
+ <th>ID</th>
150
+ <th>Name</th>
151
+ <th>Email</th>
152
+ <th>Provider</th>
153
+ <th>Created</th>
154
+ <th>Actions</th>
155
+ </tr>
156
+ </thead>
157
+ <tbody id="users-table">
158
+ <!-- Users -->
159
+ </tbody>
160
+ </table>
161
+ </section>
162
+
163
+ <section id="accounts-section">
164
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem">
165
+ <h2>Accounts</h2>
166
+ <button onclick="openCreateAccount()">Create Account</button>
167
+ </div>
168
+ <form
169
+ class="search-bar"
170
+ onsubmit="
171
+ event.preventDefault();
172
+ searchAccounts();
173
+ "
174
+ >
175
+ <input type="text" id="account-search" placeholder="Search accounts by name..." />
176
+ <button type="submit">Search</button>
177
+ </form>
178
+ <table>
179
+ <thead>
180
+ <tr>
181
+ <th>ID</th>
182
+ <th>Name</th>
183
+ <th>Status</th>
184
+ <th>Plan</th>
185
+ <th>Members</th>
186
+ <th>Created</th>
187
+ <th>Actions</th>
188
+ </tr>
189
+ </thead>
190
+ <tbody id="accounts-table">
191
+ <!-- Accounts -->
192
+ </tbody>
193
+ </table>
194
+ </section>
195
+
196
+ <div id="toast"></div>
197
+
198
+ <!-- Edit User Modal -->
199
+ <dialog id="edit-user-modal">
200
+ <h3>Edit User</h3>
201
+ <input type="hidden" id="edit-user-id" />
202
+ <div class="form-group">
203
+ <label for="edit-user-name">Name</label>
204
+ <input type="text" id="edit-user-name" />
205
+ </div>
206
+ <div class="form-group">
207
+ <label for="edit-user-email">Email</label>
208
+ <input type="text" id="edit-user-email" />
209
+ </div>
210
+ <div class="modal-actions">
211
+ <button class="secondary-btn" onclick="closeModal('edit-user-modal')">Cancel</button>
212
+ <button onclick="saveUser()">Save</button>
213
+ </div>
214
+ </dialog>
215
+
216
+ <!-- User Memberships Modal -->
217
+ <dialog id="user-memberships-modal" style="width: 500px">
218
+ <h3>User Memberships</h3>
219
+ <input type="hidden" id="memberships-user-id" />
220
+ <table style="font-size: 0.9rem">
221
+ <thead>
222
+ <tr>
223
+ <th>Account</th>
224
+ <th>Role</th>
225
+ <th>Current</th>
226
+ <th>Actions</th>
227
+ </tr>
228
+ </thead>
229
+ <tbody id="user-memberships-table-body">
230
+ <!-- Memberships -->
231
+ </tbody>
232
+ </table>
233
+ <div class="modal-actions">
234
+ <button class="secondary-btn" onclick="closeModal('user-memberships-modal')">Close</button>
235
+ </div>
236
+ </dialog>
237
+
238
+ <!-- Edit Account Modal -->
239
+ <dialog id="edit-account-modal">
240
+ <h3>Edit Account</h3>
241
+ <input type="hidden" id="edit-account-id" />
242
+ <div class="form-group">
243
+ <label for="edit-account-name">Name</label>
244
+ <input type="text" id="edit-account-name" />
245
+ </div>
246
+ <div class="form-group">
247
+ <label for="edit-account-status">Status</label>
248
+ <select id="edit-account-status">
249
+ <option value="active">Active</option>
250
+ <option value="suspended">Suspended</option>
251
+ <option value="canceled">Canceled</option>
252
+ </select>
253
+ </div>
254
+ <div class="form-group">
255
+ <label for="edit-account-plan">Plan</label>
256
+ <select id="edit-account-plan">
257
+ <!-- Populated dynamically -->
258
+ </select>
259
+ </div>
260
+ <div class="modal-actions">
261
+ <button class="secondary-btn" onclick="closeModal('edit-account-modal')">Cancel</button>
262
+ <button onclick="saveAccount()">Save</button>
263
+ </div>
264
+ </dialog>
265
+
266
+ <!-- Create Account Modal -->
267
+ <dialog id="create-account-modal">
268
+ <h3>Create New Account</h3>
269
+ <div class="form-group">
270
+ <label for="create-account-name">Account Name</label>
271
+ <input type="text" id="create-account-name" placeholder="e.g. Acme Corp" />
272
+ </div>
273
+ <div class="form-group">
274
+ <label for="create-account-owner">Initial Owner (Optional)</label>
275
+ <select id="create-account-owner">
276
+ <option value="">-- No Owner --</option>
277
+ <!-- Populated dynamically -->
278
+ </select>
279
+ </div>
280
+ <div class="form-group">
281
+ <label for="create-account-plan">Initial Plan</label>
282
+ <select id="create-account-plan">
283
+ <!-- Populated dynamically -->
284
+ </select>
285
+ </div>
286
+ <div class="modal-actions">
287
+ <button class="secondary-btn" onclick="closeModal('create-account-modal')">Cancel</button>
288
+ <button onclick="createAccount()">Create</button>
289
+ </div>
290
+ </dialog>
291
+
292
+ <!-- Members Modal -->
293
+ <dialog id="members-modal" style="width: 600px">
294
+ <h3>Manage Members</h3>
295
+ <input type="hidden" id="members-account-id" />
296
+
297
+ <div style="margin-bottom: 1.5rem">
298
+ <h4>Add Member</h4>
299
+ <div style="display: flex; gap: 0.5rem">
300
+ <select id="add-member-user" style="flex-grow: 1; padding: 0.5rem">
301
+ <option value="">-- Select User --</option>
302
+ </select>
303
+ <select id="add-member-role" style="width: 120px; padding: 0.5rem">
304
+ <option value="0">User</option>
305
+ <option value="1">Admin</option>
306
+ </select>
307
+ <button onclick="addMember()">Add</button>
308
+ </div>
309
+ </div>
310
+
311
+ <h4>Current Members</h4>
312
+ <table style="font-size: 0.9rem">
313
+ <thead>
314
+ <tr>
315
+ <th>User</th>
316
+ <th>Role</th>
317
+ <th>Joined</th>
318
+ <th>Actions</th>
319
+ </tr>
320
+ </thead>
321
+ <tbody id="members-table-body">
322
+ <!-- Members -->
323
+ </tbody>
324
+ </table>
325
+
326
+ <div class="modal-actions">
327
+ <button class="secondary-btn" onclick="closeModal('members-modal')">Close</button>
328
+ </div>
329
+ </dialog>
330
+
331
+ <script>
332
+ const API_BASE = '/users/admin/api';
333
+ let currentUsers = [];
334
+ let currentAccounts = [];
335
+ let availablePlans = [];
336
+
337
+ function getPlans() {
338
+ if (availablePlans.length > 0) return availablePlans;
339
+ const ssrPlans = document.body.getAttribute('data-ssr-plans');
340
+ if (ssrPlans && !ssrPlans.startsWith('{{ssr:')) {
341
+ try {
342
+ availablePlans = JSON.parse(ssrPlans);
343
+ return availablePlans;
344
+ } catch (e) {
345
+ console.error('Failed to parse SSR plans', e);
346
+ }
347
+ }
348
+ return [];
349
+ }
350
+
351
+ function populatePlanSelect(selectId, selectedValue) {
352
+ const select = document.getElementById(selectId);
353
+ const plans = getPlans();
354
+
355
+ // Hide the entire form-group if there's only one plan
356
+ const formGroup = select.closest('.form-group');
357
+ if (formGroup) {
358
+ formGroup.style.display = plans.length > 1 ? 'block' : 'none';
359
+ }
360
+
361
+ select.innerHTML = plans
362
+ .map((p) => `<option value="${p.slug}" ${p.slug === selectedValue ? 'selected' : ''}>${p.name}</option>`)
363
+ .join('');
364
+ }
365
+
366
+ async function fetchAPI(endpoint, options = {}) {
367
+ try {
368
+ const res = await fetch(`${API_BASE}${endpoint}`, options);
369
+ if (res.status === 403) {
370
+ showToast('Access Denied. Admin privileges required.');
371
+ return null;
372
+ }
373
+ if (!res.ok) throw new Error('Request failed');
374
+ return await res.json();
375
+ } catch (e) {
376
+ showToast(e.message);
377
+ return null;
378
+ }
379
+ }
380
+
381
+ async function searchUsers() {
382
+ const query = document.getElementById('user-search').value;
383
+ const users = await fetchAPI(`/users?q=${encodeURIComponent(query)}`);
384
+ if (users) {
385
+ currentUsers = users;
386
+ renderUsers(users);
387
+ }
388
+ }
389
+
390
+ async function searchAccounts() {
391
+ const query = document.getElementById('account-search').value;
392
+ const accounts = await fetchAPI(`/accounts?q=${encodeURIComponent(query)}`);
393
+ if (accounts) {
394
+ currentAccounts = accounts;
395
+ renderAccounts(accounts);
396
+ }
397
+ }
398
+
399
+ function renderUsers(users) {
400
+ const tbody = document.getElementById('users-table');
401
+ tbody.innerHTML = users
402
+ .map(
403
+ (u) => `
404
+ <tr>
405
+ <td>${u.id.substring(0, 8)}...</td>
406
+ <td>
407
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
408
+ <img src="${u.picture || ''}" style="width: 24px; height: 24px; border-radius: 50%;" onerror="this.style.display='none'">
409
+ ${u.name || '-'}
410
+ ${u.is_admin ? '<span style="background: #d93025; color: white; padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.7rem; font-weight: bold;">Admin</span>' : ''}
411
+ </div>
412
+ </td>
413
+ <td>${u.email || '-'}</td>
414
+ <td>${u.provider || '-'}</td>
415
+ <td>${new Date(u.created_at).toLocaleDateString()}</td>
416
+ <td class="actions">
417
+ <button class="secondary-btn" onclick="openEditUser('${u.id}')">Edit</button>
418
+ <button class="secondary-btn" onclick="openUserMemberships('${u.id}')">Memberships</button>
419
+ <button onclick="impersonate('${u.id}')">Impersonate</button>
420
+ <button style="background: #d93025;" onclick="deleteUser('${u.id}')">Delete</button>
421
+ </td>
422
+ </tr>
423
+ `,
424
+ )
425
+ .join('');
426
+ }
427
+
428
+ function renderAccounts(accounts) {
429
+ const tbody = document.getElementById('accounts-table');
430
+ const plans = getPlans();
431
+ tbody.innerHTML = accounts
432
+ .map((a) => {
433
+ const plan = plans.find((p) => p.slug === a.plan);
434
+ const planName = plan ? plan.name : a.plan;
435
+ return `
436
+ <tr>
437
+ <td>${a.id.substring(0, 8)}...</td>
438
+ <td>${a.name || '-'}</td>
439
+ <td>${a.status || '-'}</td>
440
+ <td>${planName || '-'}</td>
441
+ <td>${a.member_count || 0}</td>
442
+ <td>${new Date(a.created_at).toLocaleDateString()}</td>
443
+ <td class="actions">
444
+ <button class="secondary-btn" onclick="openEditAccount('${a.id}')">Edit</button>
445
+ <button class="secondary-btn" onclick="openMembers('${a.id}')">Members</button>
446
+ <button style="background: #d93025;" onclick="deleteAccount('${a.id}')">Delete</button>
447
+ </td>
448
+ </tr>
449
+ `;
450
+ })
451
+ .join('');
452
+ }
453
+
454
+ async function impersonate(userId) {
455
+ if (!confirm('Are you sure you want to log in as this user?')) return;
456
+
457
+ const res = await fetch(`${API_BASE}/impersonate`, {
458
+ method: 'POST',
459
+ headers: { 'Content-Type': 'application/json' },
460
+ body: JSON.stringify({ userId }),
461
+ });
462
+
463
+ if (res.ok) {
464
+ window.location.href = '/';
465
+ } else {
466
+ showToast('Impersonation failed');
467
+ }
468
+ }
469
+
470
+ function openEditUser(userId) {
471
+ const user = currentUsers.find((u) => u.id === userId);
472
+ if (!user) return;
473
+ document.getElementById('edit-user-id').value = user.id;
474
+ document.getElementById('edit-user-name').value = user.name || '';
475
+ document.getElementById('edit-user-email').value = user.email || '';
476
+ document.getElementById('edit-user-modal').showModal();
477
+ }
478
+
479
+ function openEditAccount(accountId) {
480
+ const account = currentAccounts.find((a) => a.id === accountId);
481
+ if (!account) return;
482
+ document.getElementById('edit-account-id').value = account.id;
483
+ document.getElementById('edit-account-name').value = account.name || '';
484
+ document.getElementById('edit-account-status').value = account.status || 'active';
485
+ populatePlanSelect('edit-account-plan', account.plan || 'free');
486
+ document.getElementById('edit-account-modal').showModal();
487
+ }
488
+
489
+ function openCreateAccount() {
490
+ const ownerSelect = document.getElementById('create-account-owner');
491
+ // Reset and populate owners from currentUsers
492
+ ownerSelect.innerHTML = '<option value="">-- No Owner --</option>';
493
+ currentUsers.forEach((u) => {
494
+ const option = document.createElement('option');
495
+ option.value = u.id;
496
+ option.textContent = `${u.name || u.id} (${u.email || 'no email'})`;
497
+ ownerSelect.appendChild(option);
498
+ });
499
+
500
+ document.getElementById('create-account-name').value = '';
501
+ populatePlanSelect('create-account-plan', 'free');
502
+ document.getElementById('create-account-modal').showModal();
503
+ }
504
+
505
+ async function createAccount() {
506
+ const name = document.getElementById('create-account-name').value;
507
+ const ownerId = document.getElementById('create-account-owner').value;
508
+ const plan = document.getElementById('create-account-plan').value;
509
+
510
+ if (!name) {
511
+ showToast('Account name is required');
512
+ return;
513
+ }
514
+
515
+ const res = await fetch(`${API_BASE}/accounts`, {
516
+ method: 'POST',
517
+ headers: { 'Content-Type': 'application/json' },
518
+ body: JSON.stringify({ name, ownerId, plan }),
519
+ });
520
+
521
+ if (res.ok) {
522
+ showToast('Account created');
523
+ closeModal('create-account-modal');
524
+ searchAccounts();
525
+ } else {
526
+ showToast('Creation failed');
527
+ }
528
+ }
529
+
530
+ async function openMembers(accountId) {
531
+ document.getElementById('members-account-id').value = accountId;
532
+
533
+ // Populate user select
534
+ const userSelect = document.getElementById('add-member-user');
535
+ userSelect.innerHTML = '<option value="">-- Select User --</option>';
536
+ currentUsers.forEach((u) => {
537
+ const option = document.createElement('option');
538
+ option.value = u.id;
539
+ option.textContent = `${u.name || u.id} (${u.email || 'no email'})`;
540
+ userSelect.appendChild(option);
541
+ });
542
+
543
+ await refreshMembers();
544
+ document.getElementById('members-modal').showModal();
545
+ }
546
+
547
+ async function refreshMembers() {
548
+ const accountId = document.getElementById('members-account-id').value;
549
+ const members = await fetchAPI(`/accounts/${accountId}/members`);
550
+ if (members) {
551
+ renderMembers(members);
552
+ }
553
+ }
554
+
555
+ function renderMembers(members) {
556
+ const tbody = document.getElementById('members-table-body');
557
+ tbody.innerHTML = members
558
+ .map((m) => {
559
+ const user = currentUsers.find((u) => u.id === m.user_id);
560
+ return `
561
+ <tr>
562
+ <td>${user ? user.name || user.email || m.user_id : m.user_id}</td>
563
+ <td>${m.role === 1 ? 'Admin' : 'User'}</td>
564
+ <td>${new Date(m.joined_at).toLocaleDateString()}</td>
565
+ <td>
566
+ <button class="secondary-btn" onclick="removeMember('${m.user_id}')">Remove</button>
567
+ </td>
568
+ </tr>
569
+ `;
570
+ })
571
+ .join('');
572
+ }
573
+
574
+ async function addMember() {
575
+ const accountId = document.getElementById('members-account-id').value;
576
+ const userId = document.getElementById('add-member-user').value;
577
+ const role = parseInt(document.getElementById('add-member-role').value);
578
+
579
+ if (!userId) {
580
+ showToast('Please select a user');
581
+ return;
582
+ }
583
+
584
+ const res = await fetch(`${API_BASE}/accounts/${accountId}/members`, {
585
+ method: 'POST',
586
+ headers: { 'Content-Type': 'application/json' },
587
+ body: JSON.stringify({ user_id: userId, role }),
588
+ });
589
+
590
+ if (res.ok) {
591
+ showToast('Member added');
592
+ refreshMembers();
593
+ } else {
594
+ showToast('Failed to add member');
595
+ }
596
+ }
597
+
598
+ async function removeMember(userId) {
599
+ if (!confirm('Are you sure you want to remove this member?')) return;
600
+
601
+ const accountId = document.getElementById('members-account-id').value;
602
+ const res = await fetch(`${API_BASE}/accounts/${accountId}/members/${userId}`, {
603
+ method: 'DELETE',
604
+ });
605
+
606
+ if (res.ok) {
607
+ showToast('Member removed');
608
+ refreshMembers();
609
+ } else {
610
+ showToast('Failed to remove member');
611
+ }
612
+ }
613
+
614
+ async function deleteAccount(accountId) {
615
+ if (!confirm('Are you sure you want to PERMANENTLY delete this account? All data will be lost.')) return;
616
+
617
+ const res = await fetch(`${API_BASE}/accounts/${accountId}`, {
618
+ method: 'DELETE',
619
+ });
620
+
621
+ if (res.ok) {
622
+ showToast('Account deleted');
623
+ searchAccounts();
624
+ } else {
625
+ showToast('Deletion failed');
626
+ }
627
+ }
628
+
629
+ function closeModal(id) {
630
+ document.getElementById(id).close();
631
+ }
632
+
633
+ // Close dialog when clicking on backdrop
634
+ document.querySelectorAll('dialog').forEach((dialog) => {
635
+ dialog.addEventListener('click', (event) => {
636
+ const rect = dialog.getBoundingClientRect();
637
+ const isInDialog =
638
+ rect.top <= event.clientY &&
639
+ event.clientY <= rect.top + rect.height &&
640
+ rect.left <= event.clientX &&
641
+ event.clientX <= rect.left + rect.width;
642
+ if (!isInDialog) {
643
+ dialog.close();
644
+ }
645
+ });
646
+ });
647
+
648
+ async function saveUser() {
649
+ const id = document.getElementById('edit-user-id').value;
650
+ const name = document.getElementById('edit-user-name').value;
651
+ const email = document.getElementById('edit-user-email').value;
652
+
653
+ const res = await fetch(`${API_BASE}/users/${id}`, {
654
+ method: 'PUT',
655
+ headers: { 'Content-Type': 'application/json' },
656
+ body: JSON.stringify({ name, email }),
657
+ });
658
+
659
+ if (res.ok) {
660
+ showToast('User updated');
661
+ closeModal('edit-user-modal');
662
+ searchUsers();
663
+ } else {
664
+ showToast('Update failed');
665
+ }
666
+ }
667
+
668
+ async function deleteUser(userId) {
669
+ if (!confirm('Are you sure you want to PERMANENTLY delete this user? All data will be lost.')) return;
670
+
671
+ const res = await fetch(`${API_BASE}/users/${userId}`, {
672
+ method: 'DELETE',
673
+ });
674
+
675
+ if (res.ok) {
676
+ showToast('User deleted');
677
+ searchUsers();
678
+ } else {
679
+ showToast('Deletion failed');
680
+ }
681
+ }
682
+
683
+ async function openUserMemberships(userId) {
684
+ document.getElementById('memberships-user-id').value = userId;
685
+ const memberships = await fetchAPI(`/users/${userId}/memberships`);
686
+ if (memberships) {
687
+ renderUserMemberships(memberships);
688
+ document.getElementById('user-memberships-modal').showModal();
689
+ }
690
+ }
691
+
692
+ function renderUserMemberships(memberships) {
693
+ const userId = document.getElementById('memberships-user-id').value;
694
+ const tbody = document.getElementById('user-memberships-table-body');
695
+ tbody.innerHTML = memberships
696
+ .map((m) => {
697
+ const account = currentAccounts.find((a) => a.id === m.account_id);
698
+ return `
699
+ <tr>
700
+ <td>${account ? account.name : m.account_id.substring(0, 8) + '...'}</td>
701
+ <td>${m.role === 1 ? 'Admin' : 'User'}</td>
702
+ <td>${m.is_current ? '✅' : ''}</td>
703
+ <td>
704
+ <button class="secondary-btn" onclick="removeUserFromAccount('${userId}', '${m.account_id}')">Remove</button>
705
+ </td>
706
+ </tr>
707
+ `;
708
+ })
709
+ .join('');
710
+ }
711
+
712
+ async function removeUserFromAccount(userId, accountId) {
713
+ if (!confirm('Are you sure you want to remove this user from the account?')) return;
714
+
715
+ const res = await fetch(`${API_BASE}/accounts/${accountId}/members/${userId}`, {
716
+ method: 'DELETE',
717
+ });
718
+
719
+ if (res.ok) {
720
+ showToast('User removed from account');
721
+ // Refresh memberships
722
+ const memberships = await fetchAPI(`/users/${userId}/memberships`);
723
+ if (memberships) {
724
+ renderUserMemberships(memberships);
725
+ }
726
+ searchAccounts(); // Refresh member counts in the main table
727
+ } else {
728
+ showToast('Failed to remove user from account');
729
+ }
730
+ }
731
+
732
+ async function saveAccount() {
733
+ const id = document.getElementById('edit-account-id').value;
734
+ const name = document.getElementById('edit-account-name').value;
735
+ const status = document.getElementById('edit-account-status').value;
736
+ const plan = document.getElementById('edit-account-plan').value;
737
+
738
+ const res = await fetch(`${API_BASE}/accounts/${id}`, {
739
+ method: 'PUT',
740
+ headers: { 'Content-Type': 'application/json' },
741
+ body: JSON.stringify({ name, status, plan }),
742
+ });
743
+
744
+ if (res.ok) {
745
+ showToast('Account updated');
746
+ closeModal('edit-account-modal');
747
+ searchAccounts();
748
+ } else {
749
+ showToast('Update failed');
750
+ }
751
+ }
752
+
753
+ function showToast(msg) {
754
+ const toast = document.getElementById('toast');
755
+ toast.innerText = msg;
756
+ toast.style.opacity = 1;
757
+ setTimeout(() => (toast.style.opacity = 0), 3000);
758
+ }
759
+
760
+ // Initial load
761
+ searchUsers();
762
+ searchAccounts();
763
+ </script>
764
+ </body>
765
+ </html>