@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,504 @@
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>Account Settings</title>
7
+ <link rel="stylesheet" href="/users/style.css" />
8
+ </head>
9
+ <body
10
+ data-ssr-account="{{ssr:account_json}}"
11
+ data-ssr-profile="{{ssr:profile_json}}"
12
+ data-ssr-members="{{ssr:account_members_json}}"
13
+ data-ssr-plans="{{ssr:plans_json}}"
14
+ >
15
+ <power-strip
16
+ providers="{{ssr:providers}}"
17
+ style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem"
18
+ >
19
+ <svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem"><path d="M7 2v11h3v9l7-12h-4l4-8z" fill="#ffcc00" /></svg>
20
+ </power-strip>
21
+ <script src="/users/power-strip.js" async></script>
22
+
23
+ <div class="header-area">
24
+ <a href="/" class="back-link">← Back to Home</a>
25
+ <h1 class="page-subtitle">Account Settings</h1>
26
+ <div id="account-name-heading" class="page-title">{{ssr:account_name}}</div>
27
+ <div id="account-id-subtitle" class="subtitle">
28
+ <span class="id-text" id="account-id-text">ID: {{ssr:account_id}}</span>
29
+ <button id="copy-id-btn" class="copy-btn" title="Copy Account ID">
30
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
31
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
32
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
33
+ </svg>
34
+ </button>
35
+ </div>
36
+ </div>
37
+
38
+ <div class="main-layout">
39
+ <nav class="sidebar">
40
+ <ul class="nav-list">
41
+ <li class="nav-item">
42
+ <a href="/users/profile.html" class="nav-link">Profile</a>
43
+ </li>
44
+ <li class="nav-item" id="nav-account-item">
45
+ <a href="/users/accounts.html" class="nav-link active">Account Settings</a>
46
+ </li>
47
+ </ul>
48
+ </nav>
49
+
50
+ <div class="content-area">
51
+ <section id="account-info-section" style="{{ssr:account_info_section_display}}">
52
+ <div class="avatar-section">
53
+ <div style="position: relative">
54
+ <img
55
+ id="account-avatar"
56
+ class="account-avatar-large"
57
+ src="{{ssr:account_picture}}"
58
+ alt="Account Avatar"
59
+ style="{{ssr:account_picture_display}}"
60
+ />
61
+ <div
62
+ id="account-avatar-placeholder"
63
+ class="account-avatar-large"
64
+ style="background: #f1f3f4; {{ssr:account_placeholder_display}} align-items: center; justify-content: center; color: #5f6368"
65
+ >
66
+ <svg viewBox="0 0 24 24" style="width: 48px; height: 48px; fill: currentColor">
67
+ <path
68
+ d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5-9h10v2H7z"
69
+ />
70
+ </svg>
71
+ </div>
72
+ <input type="file" id="avatar-input" accept="image/*" style="display: none" />
73
+ <button
74
+ type="button"
75
+ id="change-avatar-btn"
76
+ class="secondary-btn"
77
+ style="
78
+ position: absolute;
79
+ bottom: -0.5rem;
80
+ left: 50%;
81
+ transform: translateX(-50%);
82
+ padding: 0.25rem 0.5rem;
83
+ font-size: 0.75rem;
84
+ white-space: nowrap;
85
+ "
86
+ >
87
+ Change
88
+ </button>
89
+ <button
90
+ type="button"
91
+ id="remove-avatar-btn"
92
+ title="Remove image"
93
+ class="remove-image-btn"
94
+ style="{{ssr:account_remove_btn_display}}"
95
+ >
96
+
97
+ </button>
98
+ </div>
99
+ <div>
100
+ <h2 id="display-account-name" style="margin: 0; color: #333">{{ssr:account_name}}</h2>
101
+ <p id="display-account-plan" style="margin: 0.25rem 0 0 0; color: #666">{{ssr:account_plan_name}}</p>
102
+ </div>
103
+ </div>
104
+
105
+ <h2>General Information</h2>
106
+ <form id="account-info-form">
107
+ <div class="form-group">
108
+ <label for="account-name">Account Name</label>
109
+ <input type="text" id="account-name" name="name" maxlength="50" value="{{ssr:account_name}}" required />
110
+ </div>
111
+ <div class="form-group">
112
+ <label>Plan</label>
113
+ <input type="text" id="account-plan-display" value="{{ssr:account_plan_name}}" disabled />
114
+ </div>
115
+ <button type="submit" id="save-account-btn" disabled>Save Changes</button>
116
+ </form>
117
+ </section>
118
+
119
+ <section id="members-section" style="{{ssr:account_members_section_display}}">
120
+ <h2>Team Members</h2>
121
+ <div id="members-list">{{ssr:account_members_list_html}}</div>
122
+ </section>
123
+ </div>
124
+ </div>
125
+
126
+ <div id="toast"></div>
127
+
128
+ <script>
129
+ const API_BASE = '/users/api';
130
+ let currentAccountId = null;
131
+ let currentUserRole = 0;
132
+ let currentUserId = null;
133
+ let initialAccountInfo = {};
134
+ let isInitialLoad = true;
135
+
136
+ async function init() {
137
+ try {
138
+ // Try to get data from SSR first
139
+ let data = null;
140
+ let members = null;
141
+ const ssrProfileJson = document.body.getAttribute('data-ssr-profile');
142
+ const ssrAccountJson = document.body.getAttribute('data-ssr-account');
143
+ const ssrMembersJson = document.body.getAttribute('data-ssr-members');
144
+
145
+ if (ssrProfileJson && !ssrProfileJson.startsWith('{{ssr:') && ssrAccountJson && !ssrAccountJson.startsWith('{{ssr:')) {
146
+ try {
147
+ const profile = JSON.parse(ssrProfileJson);
148
+ const account = JSON.parse(ssrAccountJson);
149
+ if (ssrMembersJson && !ssrMembersJson.startsWith('{{ssr:')) {
150
+ members = JSON.parse(ssrMembersJson);
151
+ }
152
+ if (profile.valid) {
153
+ data = {
154
+ ...profile,
155
+ account: account,
156
+ };
157
+ }
158
+ } catch (e) {
159
+ console.error('Failed to parse SSR data', e);
160
+ }
161
+ }
162
+
163
+ if (!data) {
164
+ const res = await fetch(`${API_BASE}/me`);
165
+ if (!res.ok) {
166
+ if (res.status === 401) {
167
+ window.location.href = '/';
168
+ return;
169
+ }
170
+ throw new Error('Failed to load user info');
171
+ }
172
+ data = await res.json();
173
+ }
174
+
175
+ if (data.valid && data.account) {
176
+ currentAccountId = data.account.id;
177
+ currentUserRole = data.account.role;
178
+ currentUserId = data.profile.id;
179
+
180
+ document.getElementById('account-name-heading').textContent = data.account.name || 'Account';
181
+ document.getElementById('account-name').value = data.account.name || '';
182
+ initialAccountInfo.name = data.account.name || '';
183
+ document.getElementById('account-id-text').textContent = `ID: ${currentAccountId}`;
184
+
185
+ document.getElementById('copy-id-btn').onclick = () => {
186
+ navigator.clipboard
187
+ .writeText(currentAccountId)
188
+ .then(() => {
189
+ showToast('Account ID copied to clipboard');
190
+ })
191
+ .catch((err) => {
192
+ console.error('Failed to copy: ', err);
193
+ });
194
+ };
195
+
196
+ // Check if user is admin of the account or system admin
197
+ if (currentUserRole !== 1 && !data.is_admin) {
198
+ document.getElementById('members-section').style.display = 'none';
199
+ document.getElementById('account-info-section').style.display = 'none';
200
+
201
+ const section = document.createElement('section');
202
+ const msg = document.createElement('p');
203
+ msg.textContent = "You do not have permission to manage this account's information.";
204
+ msg.style.color = '#666';
205
+ msg.style.margin = '0';
206
+ section.appendChild(msg);
207
+ document.querySelector('.content-area').appendChild(section);
208
+ } else {
209
+ loadMembers(members);
210
+ }
211
+
212
+ loadAccountDetails(data.account);
213
+ isInitialLoad = false;
214
+ }
215
+ } catch (e) {
216
+ showToast(e.message);
217
+ }
218
+ }
219
+
220
+ document.getElementById('account-name').addEventListener('input', (e) => {
221
+ const hasChanged = e.target.value !== initialAccountInfo.name;
222
+ document.getElementById('save-account-btn').disabled = !hasChanged;
223
+ });
224
+
225
+ document.getElementById('change-avatar-btn').onclick = () => {
226
+ document.getElementById('avatar-input').click();
227
+ };
228
+
229
+ document.getElementById('avatar-input').onchange = async (e) => {
230
+ const file = e.target.files[0];
231
+ if (!file) return;
232
+
233
+ try {
234
+ const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/avatar`, {
235
+ method: 'PUT',
236
+ headers: {
237
+ 'Content-Type': file.type,
238
+ },
239
+ body: await file.arrayBuffer(),
240
+ });
241
+
242
+ if (res.ok) {
243
+ showToast('Account avatar updated');
244
+ // Refresh image
245
+ const img = document.getElementById('account-avatar');
246
+ img.src = `${API_BASE}/me/accounts/${currentAccountId}/avatar?t=${Date.now()}`;
247
+ img.style.display = 'block';
248
+ document.getElementById('account-avatar-placeholder').style.display = 'none';
249
+ document.getElementById('remove-avatar-btn').style.display = 'flex';
250
+
251
+ // Refresh power-strip
252
+ const powerStrip = document.querySelector('power-strip');
253
+ if (powerStrip && typeof powerStrip.refresh === 'function') {
254
+ powerStrip.refresh();
255
+ }
256
+ } else {
257
+ const err = await res.text();
258
+ throw new Error(err || 'Failed to upload avatar');
259
+ }
260
+ } catch (e) {
261
+ showToast(e.message);
262
+ }
263
+ };
264
+
265
+ document.getElementById('account-info-form').onsubmit = async (e) => {
266
+ e.preventDefault();
267
+ const name = document.getElementById('account-name').value;
268
+
269
+ try {
270
+ const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}`, {
271
+ method: 'POST',
272
+ headers: { 'Content-Type': 'application/json' },
273
+ body: JSON.stringify({ name }),
274
+ });
275
+
276
+ if (res.ok) {
277
+ showToast('Account name updated');
278
+ initialAccountInfo.name = name;
279
+ document.getElementById('account-name-heading').textContent = name;
280
+ document.getElementById('save-account-btn').disabled = true;
281
+
282
+ // Refresh power-strip
283
+ const powerStrip = document.querySelector('power-strip');
284
+ if (powerStrip && typeof powerStrip.refresh === 'function') {
285
+ powerStrip.refresh();
286
+ }
287
+ } else {
288
+ throw new Error('Failed to update account name');
289
+ }
290
+ } catch (e) {
291
+ showToast(e.message);
292
+ }
293
+ };
294
+
295
+ document.getElementById('remove-avatar-btn').onclick = async () => {
296
+ if (!confirm('Are you sure you want to remove the account profile picture?')) return;
297
+
298
+ try {
299
+ const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/avatar`, {
300
+ method: 'DELETE',
301
+ });
302
+
303
+ if (res.ok) {
304
+ showToast('Account avatar removed');
305
+ loadAccountDetails();
306
+
307
+ // Refresh power-strip
308
+ const powerStrip = document.querySelector('power-strip');
309
+ if (powerStrip && typeof powerStrip.refresh === 'function') {
310
+ powerStrip.refresh();
311
+ }
312
+ } else {
313
+ const err = await res.text();
314
+ throw new Error(err || 'Failed to remove account avatar');
315
+ }
316
+ } catch (e) {
317
+ showToast(e.message);
318
+ }
319
+ };
320
+
321
+ async function loadAccountDetails(ssrData) {
322
+ try {
323
+ let data = ssrData;
324
+ if (!data || !data.billing) {
325
+ const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}`);
326
+ if (res.ok) {
327
+ data = await res.json();
328
+ }
329
+ }
330
+
331
+ if (data) {
332
+ if (data.name) {
333
+ document.getElementById('account-name').value = data.name;
334
+ document.getElementById('display-account-name').textContent = data.name;
335
+ initialAccountInfo.name = data.name;
336
+ }
337
+
338
+ // Get plan name from available plans
339
+ let availablePlans = [];
340
+ const ssrPlans = document.body.getAttribute('data-ssr-plans');
341
+ if (ssrPlans && !ssrPlans.startsWith('{{ssr:')) {
342
+ try {
343
+ availablePlans = JSON.parse(ssrPlans);
344
+ } catch (e) {}
345
+ }
346
+
347
+ const planSlug = data.billing?.state?.plan_slug || data.plan;
348
+ const plan = availablePlans.find((p) => p.slug === planSlug);
349
+ const planName = plan ? plan.name : planSlug || 'Free';
350
+
351
+ document.getElementById('account-plan-display').value = planName;
352
+ document.getElementById('display-account-plan').textContent = planName;
353
+
354
+ // Hide plan if only one plan is available
355
+ if (availablePlans.length <= 1) {
356
+ document.getElementById('display-account-plan').style.display = 'none';
357
+ const planFormGroup = document.getElementById('account-plan-display').closest('.form-group');
358
+ if (planFormGroup) {
359
+ planFormGroup.style.display = 'none';
360
+ }
361
+ } else {
362
+ document.getElementById('display-account-plan').style.display = 'block';
363
+ const planFormGroup = document.getElementById('account-plan-display').closest('.form-group');
364
+ if (planFormGroup) {
365
+ planFormGroup.style.display = 'block';
366
+ }
367
+ }
368
+
369
+ // Load avatar
370
+ const avatarRes = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/avatar`);
371
+ if (avatarRes.ok) {
372
+ const img = document.getElementById('account-avatar');
373
+ img.src = `${API_BASE}/me/accounts/${currentAccountId}/avatar`;
374
+ img.style.display = 'block';
375
+ document.getElementById('account-avatar-placeholder').style.display = 'none';
376
+ document.getElementById('remove-avatar-btn').style.display = 'flex';
377
+ } else {
378
+ document.getElementById('account-avatar').style.display = 'none';
379
+ document.getElementById('account-avatar-placeholder').style.display = 'flex';
380
+ document.getElementById('remove-avatar-btn').style.display = 'none';
381
+ }
382
+ }
383
+ } catch (e) {
384
+ console.error('Error loading account details:', e);
385
+ }
386
+ }
387
+
388
+ async function loadMembers(ssrData) {
389
+ const list = document.getElementById('members-list');
390
+ try {
391
+ let members = ssrData;
392
+ if (!members && isInitialLoad) {
393
+ const ssrMembersJson = document.body.getAttribute('data-ssr-members');
394
+ if (ssrMembersJson && !ssrMembersJson.startsWith('{{ssr:')) {
395
+ try {
396
+ members = JSON.parse(ssrMembersJson);
397
+ } catch (e) {
398
+ console.error('Failed to parse SSR members', e);
399
+ }
400
+ }
401
+ }
402
+
403
+ if (!members) {
404
+ const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/members`);
405
+ if (!res.ok) throw new Error('Failed to load members');
406
+ members = await res.json();
407
+ }
408
+
409
+ if (members.length === 0) {
410
+ list.innerHTML = '<p>No members found.</p>';
411
+ return;
412
+ }
413
+
414
+ list.innerHTML = members
415
+ .map((m) => {
416
+ const isAdmin = m.role === 1;
417
+ const isSelf = m.user_id === currentUserId;
418
+ const avatarContent = m.picture
419
+ ? `<img src="${m.picture}" class="member-avatar" alt="${m.name}" />`
420
+ : `<div class="member-avatar">
421
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
422
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
423
+ <circle cx="12" cy="7" r="4"></circle>
424
+ </svg>
425
+ </div>`;
426
+
427
+ return `
428
+ <div class="member-item">
429
+ <div class="member-info">
430
+ ${avatarContent}
431
+ <div class="member-details">
432
+ <div class="member-name" title="${m.name}${isSelf ? ' (You)' : ''}">${m.name} ${isSelf ? '(You)' : ''}</div>
433
+ <div class="member-role">
434
+ <select onchange="updateRole('${m.user_id}', this.value)" ${isSelf ? 'disabled title="You cannot change your own role"' : ''} class="role-select">
435
+ <option value="0" ${m.role === 0 ? 'selected' : ''}>Member</option>
436
+ <option value="1" ${m.role === 1 ? 'selected' : ''}>Admin</option>
437
+ </select>
438
+ </div>
439
+ </div>
440
+ </div>
441
+ <button class="remove-btn" onclick="removeMember('${m.user_id}')" ${isSelf ? 'disabled title="You cannot remove yourself"' : ''}>
442
+ Remove
443
+ </button>
444
+ </div>
445
+ `;
446
+ })
447
+ .join('');
448
+ } catch (e) {
449
+ list.innerHTML = `<p style="color: red;">${e.message}</p>`;
450
+ }
451
+ }
452
+
453
+ async function updateRole(userId, newRole) {
454
+ try {
455
+ const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/members/${userId}`, {
456
+ method: 'PATCH',
457
+ headers: { 'Content-Type': 'application/json' },
458
+ body: JSON.stringify({ role: parseInt(newRole) }),
459
+ });
460
+
461
+ if (res.ok) {
462
+ showToast('Role updated');
463
+ loadMembers();
464
+ } else {
465
+ const err = await res.text();
466
+ throw new Error(err || 'Failed to update role');
467
+ }
468
+ } catch (e) {
469
+ showToast(e.message);
470
+ loadMembers(); // Refresh to reset select
471
+ }
472
+ }
473
+
474
+ async function removeMember(userId) {
475
+ if (!confirm(`Are you sure you want to remove user ${userId} from this account?`)) return;
476
+
477
+ try {
478
+ const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/members/${userId}`, {
479
+ method: 'DELETE',
480
+ });
481
+
482
+ if (res.ok) {
483
+ showToast('Member removed');
484
+ loadMembers();
485
+ } else {
486
+ const err = await res.text();
487
+ throw new Error(err || 'Failed to remove member');
488
+ }
489
+ } catch (e) {
490
+ showToast(e.message);
491
+ }
492
+ }
493
+
494
+ function showToast(msg) {
495
+ const toast = document.getElementById('toast');
496
+ toast.innerText = msg;
497
+ toast.style.opacity = 1;
498
+ setTimeout(() => (toast.style.opacity = 0), 3000);
499
+ }
500
+
501
+ init();
502
+ </script>
503
+ </body>
504
+ </html>