@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.
- package/LICENSE +13 -0
- package/README.md +114 -0
- package/package.json +53 -0
- package/public/index.html +405 -0
- package/public/users/accounts.html +504 -0
- package/public/users/admin/index.html +765 -0
- package/public/users/power-strip.js +658 -0
- package/public/users/profile.html +443 -0
- package/public/users/style.css +493 -0
- package/src/CookieManager.ts +56 -0
- package/src/PowerStrip.ts +23 -0
- package/src/StartupAPIEnv.ts +12 -0
- package/src/auth/GoogleProvider.ts +67 -0
- package/src/auth/OAuthProvider.ts +52 -0
- package/src/auth/TwitchProvider.ts +64 -0
- package/src/auth/index.ts +231 -0
- package/src/billing/PaymentEngine.ts +20 -0
- package/src/billing/Plan.ts +80 -0
- package/src/billing/plansConfig.ts +48 -0
- package/src/handlers/account.ts +246 -0
- package/src/handlers/admin.ts +144 -0
- package/src/handlers/auth.ts +54 -0
- package/src/handlers/ssr.ts +274 -0
- package/src/handlers/user.ts +168 -0
- package/src/handlers/utils.ts +120 -0
- package/src/index.ts +190 -0
- package/src/schemas/account.ts +37 -0
- package/src/schemas/admin.ts +10 -0
- package/src/schemas/billing.ts +11 -0
- package/src/schemas/credential.ts +38 -0
- package/src/schemas/membership.ts +9 -0
- package/src/schemas/session.ts +10 -0
- package/src/schemas/user.ts +22 -0
- package/src/storage/AccountDO.ts +370 -0
- package/src/storage/CredentialDO.ts +82 -0
- package/src/storage/SystemDO.ts +264 -0
- package/src/storage/UserDO.ts +385 -0
- package/worker-configuration.d.ts +11696 -0
- 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>
|