@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,443 @@
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>User Profile</title>
7
+ <link rel="stylesheet" href="/users/style.css" />
8
+ </head>
9
+ <body data-ssr-profile="{{ssr:profile_json}}" data-ssr-credentials="{{ssr:credentials_json}}">
10
+ <power-strip
11
+ providers="{{ssr:providers}}"
12
+ style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem"
13
+ >
14
+ <svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem"><path d="M7 2v11h3v9l7-12h-4l4-8z" fill="#ffcc00" /></svg>
15
+ </power-strip>
16
+ <script src="/users/power-strip.js" async></script>
17
+
18
+ <div class="header-area">
19
+ <a href="/" class="back-link">← Back to Home</a>
20
+ <h1 class="page-subtitle">User Profile</h1>
21
+ <div id="page-title" class="page-title">{{ssr:profile_name}}</div>
22
+ <div id="user-id-subtitle" class="subtitle">
23
+ <span class="id-text" id="user-id-text">ID: {{ssr:profile_id}}</span>
24
+ <button id="copy-id-btn" class="copy-btn" title="Copy User ID">
25
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
26
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
27
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
28
+ </svg>
29
+ </button>
30
+ </div>
31
+ </div>
32
+
33
+ <div class="main-layout">
34
+ <nav class="sidebar">
35
+ <ul class="nav-list">
36
+ <li class="nav-item">
37
+ <a href="/users/profile.html" class="nav-link active">Profile</a>
38
+ </li>
39
+ <li class="nav-item" id="nav-account-item" style="{{ssr:nav_account_display}}">
40
+ <a href="/users/accounts.html" class="nav-link">Account Settings</a>
41
+ </li>
42
+ </ul>
43
+ </nav>
44
+
45
+ <div class="content-area">
46
+ <section>
47
+ <div class="avatar-section">
48
+ <div style="position: relative">
49
+ <img
50
+ id="profile-picture"
51
+ class="avatar-large"
52
+ src="{{ssr:profile_picture}}"
53
+ alt="Profile Picture"
54
+ style="{{ssr:profile_picture_display}}"
55
+ />
56
+ <div
57
+ id="profile-avatar-placeholder"
58
+ class="avatar-large"
59
+ style="background: #f1f3f4; {{ssr:profile_placeholder_display}} align-items: center; justify-content: center; color: #5f6368"
60
+ >
61
+ <svg
62
+ viewBox="0 0 24 24"
63
+ fill="none"
64
+ stroke="currentColor"
65
+ stroke-width="2"
66
+ stroke-linecap="round"
67
+ stroke-linejoin="round"
68
+ style="width: 48px; height: 48px"
69
+ >
70
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
71
+ <circle cx="12" cy="7" r="4"></circle>
72
+ </svg>
73
+ </div>
74
+ <input type="file" id="avatar-input" accept="image/*" style="display: none" />
75
+ <button
76
+ type="button"
77
+ id="change-avatar-btn"
78
+ class="secondary-btn"
79
+ style="
80
+ position: absolute;
81
+ bottom: -0.5rem;
82
+ left: 50%;
83
+ transform: translateX(-50%);
84
+ padding: 0.25rem 0.5rem;
85
+ font-size: 0.75rem;
86
+ white-space: nowrap;
87
+ "
88
+ >
89
+ Change
90
+ </button>
91
+ <button
92
+ type="button"
93
+ id="remove-avatar-btn"
94
+ title="Remove image"
95
+ class="remove-image-btn"
96
+ style="{{ssr:profile_remove_btn_display}}"
97
+ >
98
+
99
+ </button>
100
+ </div>
101
+ <div>
102
+ <p id="display-email" style="margin: 0.25rem 0 0 0; color: #666">{{ssr:profile_email}}</p>
103
+ </div>
104
+ </div>
105
+
106
+ <form id="profile-form">
107
+ <div class="form-group">
108
+ <label for="name">Name</label>
109
+ <input type="text" id="name" name="name" placeholder="Your Name" value="{{ssr:profile_name}}" required />
110
+ </div>
111
+ <div class="form-group">
112
+ <label for="email">Email {{ssr:profile_provider_label}}</label>
113
+ <input
114
+ type="email"
115
+ id="email"
116
+ name="email"
117
+ value="{{ssr:profile_email}}"
118
+ disabled
119
+ title="Email is managed by your login provider"
120
+ />
121
+ </div>
122
+ <button type="submit" id="save-btn" disabled>Save Changes</button>
123
+ </form>
124
+ </section>
125
+
126
+ <section>
127
+ <h2>Login Credentials</h2>
128
+ <p style="color: #666; font-size: 0.9rem; margin-bottom: 1.5rem">Manage the login methods linked to your account.</p>
129
+
130
+ <div id="credentials-list" style="margin-bottom: 2rem">{{ssr:credentials_list_html}}</div>
131
+
132
+ <h3>Link another account</h3>
133
+ <div style="display: flex; gap: 1rem; margin-top: 1rem">{{ssr:link_credentials_html}}</div>
134
+ </section>
135
+ </div>
136
+ </div>
137
+
138
+ <div id="toast"></div>
139
+
140
+ <script>
141
+ const API_BASE = '/users/api';
142
+ let currentProvider = null;
143
+ let initialProfile = {};
144
+ let isInitialLoad = true;
145
+
146
+ async function loadProfile() {
147
+ try {
148
+ // Try to get data from SSR first
149
+ let data = null;
150
+ if (isInitialLoad) {
151
+ const ssrProfileJson = document.body.getAttribute('data-ssr-profile');
152
+ if (ssrProfileJson && !ssrProfileJson.startsWith('{{ssr:')) {
153
+ try {
154
+ data = JSON.parse(ssrProfileJson);
155
+ } catch (e) {
156
+ console.error('Failed to parse SSR profile', e);
157
+ }
158
+ }
159
+ }
160
+
161
+ if (!data) {
162
+ const res = await fetch(`${API_BASE}/me`);
163
+ if (!res.ok) {
164
+ if (res.status === 401) {
165
+ window.location.href = '/';
166
+ return;
167
+ }
168
+ throw new Error('Failed to load profile');
169
+ }
170
+ data = await res.json();
171
+ }
172
+
173
+ if (data.valid && data.profile) {
174
+ const p = data.profile;
175
+ initialProfile = { name: p.name || '' };
176
+ currentProvider = data.credential ? data.credential.provider : null;
177
+ document.getElementById('name').value = p.name || '';
178
+ document.getElementById('email').value = p.email || '';
179
+ document.getElementById('page-title').textContent = p.name || 'Anonymous';
180
+ document.getElementById('display-email').textContent = p.email || '';
181
+
182
+ const userId = p.id;
183
+ document.getElementById('user-id-text').textContent = `ID: ${userId}`;
184
+ document.getElementById('copy-id-btn').onclick = () => {
185
+ navigator.clipboard
186
+ .writeText(userId)
187
+ .then(() => {
188
+ showToast('User ID copied to clipboard');
189
+ })
190
+ .catch((err) => {
191
+ console.error('Failed to copy: ', err);
192
+ });
193
+ };
194
+
195
+ if (p.picture) {
196
+ const img = document.getElementById('profile-picture');
197
+ img.src = p.picture;
198
+ img.style.display = 'block';
199
+ document.getElementById('profile-avatar-placeholder').style.display = 'none';
200
+ document.getElementById('remove-avatar-btn').style.display = 'flex';
201
+ } else {
202
+ const img = document.getElementById('profile-picture');
203
+ img.src = '';
204
+ img.style.display = 'none';
205
+ document.getElementById('profile-avatar-placeholder').style.display = 'flex';
206
+ document.getElementById('remove-avatar-btn').style.display = 'none';
207
+ }
208
+
209
+ const emailLabel = document.querySelector('label[for="email"]');
210
+ if (p.provider) {
211
+ const providerName = p.provider.charAt(0).toUpperCase() + p.provider.slice(1);
212
+ emailLabel.textContent = `Email (from ${providerName})`;
213
+ }
214
+
215
+ if (data.account && (data.account.role === 1 || data.is_admin)) {
216
+ document.getElementById('nav-account-item').style.display = 'block';
217
+ }
218
+
219
+ loadCredentials(data.credentials);
220
+
221
+ document.getElementById('save-btn').disabled = true;
222
+ isInitialLoad = false;
223
+ }
224
+ } catch (e) {
225
+ showToast(e.message);
226
+ }
227
+ }
228
+
229
+ document.getElementById('name').addEventListener('input', (e) => {
230
+ const hasChanged = e.target.value !== initialProfile.name;
231
+ document.getElementById('save-btn').disabled = !hasChanged;
232
+ });
233
+
234
+ document.getElementById('change-avatar-btn').onclick = () => {
235
+ document.getElementById('avatar-input').click();
236
+ };
237
+
238
+ document.getElementById('avatar-input').onchange = async (e) => {
239
+ const file = e.target.files[0];
240
+ if (!file) return;
241
+
242
+ if (file.size > 1024 * 1024) {
243
+ showToast('Image too large (max 1MB)');
244
+ return;
245
+ }
246
+
247
+ try {
248
+ const res = await fetch('/users/me/avatar', {
249
+ method: 'PUT',
250
+ headers: {
251
+ 'Content-Type': file.type,
252
+ },
253
+ body: await file.arrayBuffer(),
254
+ });
255
+
256
+ if (res.ok) {
257
+ showToast('Avatar updated');
258
+ // Refresh image by appending timestamp to bypass cache
259
+ const img = document.getElementById('profile-picture');
260
+ img.src = `/users/me/avatar?t=${Date.now()}`;
261
+ img.style.display = 'block';
262
+ document.getElementById('profile-avatar-placeholder').style.display = 'none';
263
+ document.getElementById('remove-avatar-btn').style.display = 'flex';
264
+
265
+ loadProfile();
266
+
267
+ // Refresh power-strip
268
+ const powerStrip = document.querySelector('power-strip');
269
+ if (powerStrip && typeof powerStrip.refresh === 'function') {
270
+ powerStrip.refresh();
271
+ }
272
+ } else {
273
+ const err = await res.text();
274
+ throw new Error(err || 'Failed to upload avatar');
275
+ }
276
+ } catch (e) {
277
+ showToast(e.message);
278
+ }
279
+ };
280
+
281
+ document.getElementById('remove-avatar-btn').onclick = async () => {
282
+ if (!confirm('Are you sure you want to remove your profile picture?')) return;
283
+
284
+ try {
285
+ const res = await fetch('/users/me/avatar', {
286
+ method: 'DELETE',
287
+ });
288
+
289
+ if (res.ok) {
290
+ showToast('Avatar removed');
291
+ loadProfile();
292
+
293
+ // Refresh power-strip
294
+ const powerStrip = document.querySelector('power-strip');
295
+ if (powerStrip && typeof powerStrip.refresh === 'function') {
296
+ powerStrip.refresh();
297
+ }
298
+ } else {
299
+ const err = await res.text();
300
+ throw new Error(err || 'Failed to remove avatar');
301
+ }
302
+ } catch (e) {
303
+ showToast(e.message);
304
+ }
305
+ };
306
+
307
+ document.getElementById('profile-form').onsubmit = async (e) => {
308
+ e.preventDefault();
309
+ const name = document.getElementById('name').value;
310
+
311
+ try {
312
+ const res = await fetch(`${API_BASE}/me/profile`, {
313
+ method: 'POST',
314
+ headers: {
315
+ 'Content-Type': 'application/json',
316
+ },
317
+ body: JSON.stringify({ name }),
318
+ });
319
+
320
+ if (res.ok) {
321
+ showToast('Profile updated successfully');
322
+ loadProfile(); // Refresh page content
323
+
324
+ // Refresh power-strip
325
+ const powerStrip = document.querySelector('power-strip');
326
+ if (powerStrip && typeof powerStrip.refresh === 'function') {
327
+ powerStrip.refresh();
328
+ }
329
+ } else {
330
+ throw new Error('Failed to update profile');
331
+ }
332
+ } catch (e) {
333
+ showToast(e.message);
334
+ }
335
+ };
336
+
337
+ function showToast(msg) {
338
+ const toast = document.getElementById('toast');
339
+ toast.innerText = msg;
340
+ toast.style.opacity = 1;
341
+ setTimeout(() => (toast.style.opacity = 0), 3000);
342
+ }
343
+
344
+ async function loadCredentials(passedCredentials) {
345
+ const list = document.getElementById('credentials-list');
346
+ try {
347
+ // Try to get data from passed credentials or SSR first
348
+ let credentials = passedCredentials;
349
+
350
+ if (!credentials && isInitialLoad) {
351
+ const ssrCredentialsJson = document.body.getAttribute('data-ssr-credentials');
352
+ if (ssrCredentialsJson && !ssrCredentialsJson.startsWith('{{ssr:')) {
353
+ try {
354
+ credentials = JSON.parse(ssrCredentialsJson);
355
+ } catch (e) {
356
+ console.error('Failed to parse SSR credentials', e);
357
+ }
358
+ }
359
+ }
360
+
361
+ if (!credentials) {
362
+ const res = await fetch(`${API_BASE}/me/credentials`);
363
+ if (!res.ok) throw new Error('Failed to load credentials');
364
+ credentials = await res.json();
365
+ }
366
+
367
+ if (credentials.length === 0) {
368
+ list.innerHTML = '<p>No credentials linked.</p>';
369
+ return;
370
+ }
371
+
372
+ list.innerHTML = credentials
373
+ .map((c) => {
374
+ const isCurrent = c.provider === currentProvider;
375
+ return `
376
+ <div class="credential-item ${isCurrent ? 'active' : ''}">
377
+ <div class="credential-info">
378
+ <div class="provider-icon">
379
+ ${getProviderIcon(c.provider)}
380
+ </div>
381
+ <div>
382
+ <div style="font-weight: 600;">
383
+ ${c.provider.charAt(0).toUpperCase() + c.provider.slice(1)}
384
+ ${isCurrent ? '<span class="current-badge">logged in</span>' : ''}
385
+ </div>
386
+ <div style="font-size: 0.8rem; color: #666;">${c.email || c.subject_id}</div>
387
+ </div>
388
+ </div>
389
+ <button class="remove-btn" onclick="removeCredential('${c.provider}')" ${isCurrent || credentials.length === 1 ? 'disabled title="' + (isCurrent ? 'Cannot remove the method you are currently logged in with' : 'Cannot remove your last login method') + '"' : ''}>
390
+ Remove
391
+ </button>
392
+ </div>
393
+ `;
394
+ })
395
+ .join('');
396
+ } catch (e) {
397
+ list.innerHTML = `<p style="color: red;">${e.message}</p>`;
398
+ }
399
+ }
400
+
401
+ async function removeCredential(provider) {
402
+ if (!confirm(`Are you sure you want to remove ${provider} as a login method?`)) return;
403
+
404
+ try {
405
+ const res = await fetch(`${API_BASE}/me/credentials`, {
406
+ method: 'DELETE',
407
+ headers: { 'Content-Type': 'application/json' },
408
+ body: JSON.stringify({ provider }),
409
+ });
410
+
411
+ if (res.ok) {
412
+ showToast('Credential removed');
413
+ loadCredentials();
414
+ } else {
415
+ const error = await res.text();
416
+ throw new Error(error || 'Failed to remove credential');
417
+ }
418
+ } catch (e) {
419
+ showToast(e.message);
420
+ }
421
+ }
422
+
423
+ function getProviderIcon(provider) {
424
+ if (provider === 'google') {
425
+ return `<svg viewBox="0 0 24 24" width="24" height="24">
426
+ <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
427
+ <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
428
+ <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
429
+ <path d="M12 5.38c1.62 0 3.06.56 4.21 1.66l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
430
+ </svg>`;
431
+ } else if (provider === 'twitch') {
432
+ return `<svg viewBox="0 0 24 24" width="24" height="24" style="color: #9146FF;">
433
+ <path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z" fill="currentColor"/>
434
+ </svg>`;
435
+ }
436
+ return '';
437
+ }
438
+
439
+ loadProfile();
440
+ // loadCredentials(); // Handled within loadProfile if data exists
441
+ </script>
442
+ </body>
443
+ </html>