From 544f17067ab8c76a56842ddf1210ce85568d90f7 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 21:43:27 -0500 Subject: [PATCH 01/49] Implement Account Membership Management for account admins --- src/index.ts | 46 ++++++++++++++ test/membership.spec.ts | 136 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 test/membership.spec.ts diff --git a/src/index.ts b/src/index.ts index f8b0438..ed491e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,6 +89,13 @@ export default { if (apiPath === '/me/accounts/switch' && request.method === 'POST') { return handleSwitchAccount(request, env, cookieManager); } + + if (apiPath.startsWith('/me/accounts/')) { + const parts = apiPath.split('/'); + if (parts.length >= 5 && parts[4] === 'members') { + return handleAccountMembers(request, env, parts[3], parts.slice(5), cookieManager); + } + } } if (url.pathname === usersPath + 'logout') { @@ -293,6 +300,45 @@ function isAdmin(user: any, env: StartupAPIEnv): boolean { ); } +async function handleAccountMembers( + request: Request, + env: StartupAPIEnv, + accountId: string, + extraParts: string[], + cookieManager: CookieManager, +): Promise { + const user = await getUserFromSession(request, env, cookieManager); + if (!user) return new Response('Unauthorized', { status: 401 }); + + const userStub = env.USER.get(env.USER.idFromString(user.id)); + const memberships = await userStub.getMemberships(); + const membership = memberships.find((m: any) => m.account_id === accountId); + + const isAccountAdmin = membership && membership.role === AccountDO.ROLE_ADMIN; + const isSysAdmin = isAdmin(user, env); + + if (!isAccountAdmin && !isSysAdmin) { + return new Response('Forbidden', { status: 403 }); + } + + const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accountId)); + + if (extraParts.length === 0) { + if (request.method === 'GET') { + return Response.json(await accountStub.getMembers()); + } + if (request.method === 'POST') { + const { user_id, role } = (await request.json()) as { user_id: string; role: number }; + return Response.json(await accountStub.addMember(user_id, role)); + } + } else if (extraParts.length === 1 && request.method === 'DELETE') { + const userIdToRemove = extraParts[0]; + return Response.json(await accountStub.removeMember(userIdToRemove)); + } + + return new Response('Not Found', { status: 404 }); +} + async function handleMe( request: Request, env: StartupAPIEnv, diff --git a/test/membership.spec.ts b/test/membership.spec.ts new file mode 100644 index 0000000..9645d27 --- /dev/null +++ b/test/membership.spec.ts @@ -0,0 +1,136 @@ +import { env, SELF } from 'cloudflare:test'; +import { describe, it, expect } from 'vitest'; +import { CookieManager } from '../src/CookieManager'; + +describe('Account Membership Management', () => { + const cookieManager = new CookieManager(env.SESSION_SECRET); + + async function createSession(userIdStr: string) { + const userStub = env.USER.get(env.USER.idFromString(userIdStr)); + const { sessionId } = await userStub.createSession(); + return `session_id=${await cookieManager.encrypt(`${sessionId}:${userIdStr}`)}`; + } + + it('should allow account admin to add and remove members', async () => { + // 1. Setup Admin User + const adminId = env.USER.newUniqueId(); + const adminIdStr = adminId.toString(); + const adminCookie = await createSession(adminIdStr); + + // 2. Setup Account + const accId = env.ACCOUNT.newUniqueId(); + const accIdStr = accId.toString(); + const accStub = env.ACCOUNT.get(accId); + await accStub.updateInfo({ name: 'Test Account' }); + await accStub.addMember(adminIdStr, 1); // 1 = ROLE_ADMIN + + // 3. Setup Another User to be added + const userId = env.USER.newUniqueId(); + const userIdStr = userId.toString(); + + // 4. Add Member via API + const addRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + method: 'POST', + headers: { + 'Cookie': adminCookie, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ user_id: userIdStr, role: 0 }), + }); + expect(addRes.status).toBe(200); + const addData = await addRes.json() as any; + expect(addData.success).toBe(true); + + // 5. List Members via API + const listRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + headers: { 'Cookie': adminCookie }, + }); + expect(listRes.status).toBe(200); + const members = await listRes.json() as any[]; + expect(members.length).toBe(2); + expect(members.some(m => m.user_id === userIdStr)).toBe(true); + + // 6. Remove Member via API + const removeRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${userIdStr}`, { + method: 'DELETE', + headers: { 'Cookie': adminCookie }, + }); + expect(removeRes.status).toBe(200); + const removeData = await removeRes.json() as any; + expect(removeData.success).toBe(true); + + // 7. Verify member removed + const listRes2 = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + headers: { 'Cookie': adminCookie }, + }); + const members2 = await listRes2.json() as any[]; + expect(members2.length).toBe(1); + expect(members2.some(m => m.user_id === userIdStr)).toBe(false); + }); + + it('should not allow non-admin to add members', async () => { + // 1. Setup Non-Admin User + const userId = env.USER.newUniqueId(); + const userIdStr = userId.toString(); + const userCookie = await createSession(userIdStr); + + // 2. Setup Account + const accId = env.ACCOUNT.newUniqueId(); + const accIdStr = accId.toString(); + const accStub = env.ACCOUNT.get(accId); + await accStub.updateInfo({ name: 'Test Account' }); + await accStub.addMember(userIdStr, 0); // 0 = ROLE_USER + + // 3. Another user to try to add + const otherUserId = env.USER.newUniqueId().toString(); + + // 4. Try to add member via API + const addRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + method: 'POST', + headers: { + 'Cookie': userCookie, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ user_id: otherUserId, role: 0 }), + }); + expect(addRes.status).toBe(403); + }); + + it('should allow system admin to manage members of any account', async () => { + // 1. Setup System Admin + const adminIds = (env.ADMIN_IDS || '').split(',').map(id => id.trim()); + const systemAdminId = env.USER.idFromName(adminIds[0]); + const systemAdminIdStr = systemAdminId.toString(); + const systemAdminCookie = await createSession(systemAdminIdStr); + + // 2. Setup Account (System Admin is NOT a member) + const accId = env.ACCOUNT.newUniqueId(); + const accIdStr = accId.toString(); + const accStub = env.ACCOUNT.get(accId); + await accStub.updateInfo({ name: 'System Managed Account' }); + + // 3. Another user to be added + const userId = env.USER.newUniqueId().toString(); + + // 4. System Admin adds member via API + const addRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + method: 'POST', + headers: { + 'Cookie': systemAdminCookie, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ user_id: userId, role: 0 }), + }); + expect(addRes.status).toBe(200); + const addData = await addRes.json() as any; + expect(addData.success).toBe(true); + + // 5. System Admin lists members + const listRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + headers: { 'Cookie': systemAdminCookie }, + }); + expect(listRes.status).toBe(200); + const members = await listRes.json() as any[]; + expect(members.length).toBe(1); + }); +}); From 3aca0af9a966621103a6421e14a3fa08c4e43379 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 21:51:29 -0500 Subject: [PATCH 02/49] Add Account Settings page for membership management --- public/users/accounts.html | 321 ++++++++++++++++++++++++++++++++++++ public/users/power-strip.js | 9 + public/users/profile.html | 27 +++ 3 files changed, 357 insertions(+) create mode 100644 public/users/accounts.html diff --git a/public/users/accounts.html b/public/users/accounts.html new file mode 100644 index 0000000..1abbeb3 --- /dev/null +++ b/public/users/accounts.html @@ -0,0 +1,321 @@ + + + + + + Account Settings + + + + + + + + + ← Back to Home +

Account Settings

+ +
+

General Information

+
+ + +
+
+ + +
+
+ +
+

Team Members

+
+

Loading members...

+
+ +
+

Add Member

+
+
+ + +
+
+ + +
+ +
+
+
+ +
+ + + + diff --git a/public/users/power-strip.js b/public/users/power-strip.js index e902292..c20f2df 100644 --- a/public/users/power-strip.js +++ b/public/users/power-strip.js @@ -148,10 +148,19 @@ class PowerStrip extends HTMLElement { `; + const accountSettingsLink = (currentAccount && (currentAccount.role === 1 || this.user.is_admin)) + ? ` + + + + ` + : ''; + accountContainer = ` `; diff --git a/public/users/profile.html b/public/users/profile.html index 0fe0a33..e91a1f3 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -173,6 +173,21 @@ .link-account-btn.google:hover { background: #f8f9fa; } .link-account-btn.twitch { background: #9146FF; color: white; border-color: #9146FF; } .link-account-btn.twitch:hover { background: #7d2ee6; } + .btn-link { + display: inline-block; + padding: 0.75rem 1rem; + background: white; + color: #1a73e8; + border: 1px solid #1a73e8; + border-radius: 4px; + text-decoration: none; + font-weight: 500; + font-size: 0.875rem; + transition: background 0.2s; + } + .btn-link:hover { + background: #f8f9fa; + } @@ -241,6 +256,14 @@

Link another account

+ +
← Back to Home -

Account Settings

+ +

Account Settings

-

Loading...

@@ -288,7 +104,7 @@

Account Settings

currentProvider = data.credential ? data.credential.provider : null; document.getElementById('name').value = p.name || ''; document.getElementById('email').value = p.email || ''; - document.getElementById('display-name').textContent = p.name || 'Anonymous'; + document.getElementById('page-title').textContent = p.name || 'Anonymous'; document.getElementById('display-email').textContent = p.email || ''; if (p.picture) { diff --git a/public/users/style.css b/public/users/style.css new file mode 100644 index 0000000..fab63c9 --- /dev/null +++ b/public/users/style.css @@ -0,0 +1,333 @@ +body { + font-family: + system-ui, + -apple-system, + sans-serif; + padding: 2rem; + max-width: 800px; + margin: 0 auto; + background: #f9f9f9; +} + +h1.page-subtitle { + color: #666; + margin-bottom: 0.25rem; + font-size: 1.1rem; + text-transform: uppercase; + letter-spacing: 0.05rem; + margin-top: 0; +} + +.page-title { + font-size: 2.5rem; + font-weight: bold; + color: #333; + margin-bottom: 0.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.subtitle { + font-size: 0.75rem; + color: #888; + margin-bottom: 2rem; + font-family: monospace; + display: flex; + align-items: center; + gap: 0.5rem; + max-width: 100%; +} + +section { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 2rem; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + box-sizing: border-box; + font-size: 1rem; +} + +.form-group input:disabled { + background: #f0f0f0; + color: #888; +} + +button { + padding: 0.75rem 1.5rem; + background: #1a73e8; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + font-weight: 500; +} + +button:hover { + background: #1557b0; +} + +button.secondary-btn { + background: #fff; + color: #1a73e8; + border: 1px solid #1a73e8; +} + +button.secondary-btn:hover { + background: #f8f9fa; +} + +button:disabled { + background: #ccc; + cursor: not-allowed; +} + +#toast { + position: fixed; + bottom: 1rem; + right: 1rem; + padding: 1rem; + background: #333; + color: white; + border-radius: 4px; + opacity: 0; + transition: opacity 0.3s; + z-index: 10000; +} + +.back-link { + display: inline-block; + margin-bottom: 1rem; + color: #1a73e8; + text-decoration: none; +} + +.back-link:hover { + text-decoration: underline; +} + +.remove-btn { + background: transparent; + color: #d93025; + border: 1px solid #d93025; + padding: 0.4rem 0.8rem; + font-size: 0.85rem; +} + +.remove-btn:hover { + background: #fce8e6; +} + +.remove-btn:disabled { + background: #fafafa; + border-color: #eee; + color: #999; + cursor: not-allowed; +} + +.btn-link { + display: inline-block; + padding: 0.75rem 1rem; + background: white; + color: #1a73e8; + border: 1px solid #1a73e8; + border-radius: 4px; + text-decoration: none; + font-weight: 500; + font-size: 0.875rem; + transition: background 0.2s; +} + +.btn-link:hover { + background: #f8f9fa; +} + +/* Profile specific */ +.avatar-section { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 2rem; +} + +.avatar-large { + width: 100px; + height: 100px; + border-radius: 50%; + object-fit: cover; + border: 3px solid white; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.credential-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border: 1px solid #eee; + border-radius: 8px; + margin-bottom: 0.75rem; +} + +.credential-item.active { + border-color: #1a73e8; + background-color: #e8f0fe; +} + +.current-badge { + font-size: 0.75rem; + background: #1a73e8; + color: white; + padding: 0.125rem 0.375rem; + border-radius: 0.75rem; + margin-left: 0.5rem; + font-weight: normal; +} + +.credential-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.provider-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.link-account-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-radius: 4px; + text-decoration: none; + font-weight: 500; + font-size: 0.875rem; + border: 1px solid #ddd; + color: #333; + transition: background 0.2s; +} + +.link-account-btn.google:hover { + background: #f8f9fa; +} + +.link-account-btn.twitch { + background: #9146FF; + color: white; + border-color: #9146FF; +} + +.link-account-btn.twitch:hover { + background: #7d2ee6; +} + +/* Accounts specific */ +.member-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid #eee; +} + +.member-item:last-child { + border-bottom: none; +} + +.member-info { + display: flex; + align-items: center; + gap: 1rem; + min-width: 0; +} + +.member-details { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 1rem; +} + +.member-name { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.member-role { + width: 80px; + display: flex; + justify-content: center; +} + +.role-badge { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 1rem; + background: #f1f3f4; + color: #5f6368; + font-weight: 500; +} + +.role-badge.admin { + background: #e8f0fe; + color: #1a73e8; +} + +.id-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.copy-btn { + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: #1a73e8; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background 0.2s; +} + +.copy-btn:hover { + background: #f0f0f0; +} + +.copy-btn svg { + width: 14px; + height: 14px; +} From 43ce0db08015eeb4178620779a74aa26df187859 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:26:18 -0500 Subject: [PATCH 21/49] Add sidebar navigation between profile and account settings --- public/users/accounts.html | 55 +++++++++------ public/users/profile.html | 134 +++++++++++++++++++++---------------- public/users/style.css | 48 ++++++++++++- 3 files changed, 157 insertions(+), 80 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index bfe3492..f9b90a6 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -25,27 +25,42 @@

Account Settings

-
-

General Information

- -
- - -
-
- - -
- - -
- -
-

Team Members

-
-

Loading members...

+
+ + +
+
+

General Information

+
+
+ + +
+
+ + +
+ +
+
+ +
+

Team Members

+
+

Loading members...

+
+
-
+
diff --git a/public/users/profile.html b/public/users/profile.html index 3bcd99a..8778562 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -16,69 +16,84 @@

User Profile

Loading...
-
-
-
- - - -
-
-

-
-
+
+ -
-
- - -
-
- - -
- -
-
+
+
+
+
+ + + +
+
+

+
+
-
-

Login Credentials

-

- Manage the login methods linked to your account. -

- -
- -

Loading credentials...

-
+
+
+ + +
+
+ + +
+ +
+
-

Link another account

- -
+
+

Login Credentials

+

+ Manage the login methods linked to your account. +

+ +
+ +

Loading credentials...

+
+ +

Link another account

+ +
- + +
+
@@ -121,6 +136,7 @@

Account Settings

if (data.account && (data.account.role === 1 || data.is_admin)) { document.getElementById('account-settings-section').style.display = 'block'; + document.getElementById('nav-account-item').style.display = 'block'; } document.getElementById('save-btn').disabled = true; diff --git a/public/users/style.css b/public/users/style.css index fab63c9..87c30e7 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -4,11 +4,57 @@ body { -apple-system, sans-serif; padding: 2rem; - max-width: 800px; + max-width: 1000px; margin: 0 auto; background: #f9f9f9; } +.main-layout { + display: flex; + gap: 3rem; + margin-top: 2rem; +} + +.sidebar { + width: 200px; + flex-shrink: 0; +} + +.content-area { + flex: 1; + min-width: 0; +} + +.nav-list { + list-style: none; + padding: 0; + margin: 0; +} + +.nav-item { + margin-bottom: 0.5rem; +} + +.nav-link { + display: block; + padding: 0.75rem 1rem; + color: #555; + text-decoration: none; + border-radius: 6px; + transition: all 0.2s; + font-weight: 500; +} + +.nav-link:hover { + background: #f0f0f0; + color: #1a73e8; +} + +.nav-link.active { + background: #e8f0fe; + color: #1a73e8; +} + h1.page-subtitle { color: #666; margin-bottom: 0.25rem; From 2f3a9dd38becc4dcb894aaecac0eb6673989ce01 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:27:45 -0500 Subject: [PATCH 22/49] Move page headers into the main column --- public/users/accounts.html | 28 +++++++++++++++------------- public/users/profile.html | 10 ++++++---- public/users/style.css | 4 ++++ 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index f9b90a6..a8eaf16 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -12,19 +12,6 @@ - ← Back to Home -

Account Settings

-
Account
-
- - -
-
+
+ ← Back to Home +

Account Settings

+
Account
+
+ + +
+
+

General Information

diff --git a/public/users/profile.html b/public/users/profile.html index 8778562..0684082 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -12,10 +12,6 @@ - ← Back to Home -

User Profile

-
Loading...
-
+
+ ← Back to Home +

User Profile

+
Loading...
+
+
diff --git a/public/users/style.css b/public/users/style.css index 87c30e7..39272fd 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -25,6 +25,10 @@ body { min-width: 0; } +.header-area { + margin-bottom: 2rem; +} + .nav-list { list-style: none; padding: 0; From 40ca0cc13133290d40a8d9194544126732da359e Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:28:52 -0500 Subject: [PATCH 23/49] Align navigation with main section and adjust header positioning --- public/users/accounts.html | 30 +++++++++++++++--------------- public/users/profile.html | 12 ++++++------ public/users/style.css | 14 ++++++++++++++ 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index a8eaf16..93ab76b 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -12,6 +12,21 @@ +
+ ← Back to Home +

Account Settings

+
Account
+
+ + +
+
+
-
- ← Back to Home -

Account Settings

-
Account
-
- - -
-
-

General Information

diff --git a/public/users/profile.html b/public/users/profile.html index 0684082..daa25b8 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -12,6 +12,12 @@ +
+ ← Back to Home +

User Profile

+
Loading...
+
+
-
- ← Back to Home -

User Profile

-
Loading...
-
-
diff --git a/public/users/style.css b/public/users/style.css index 39272fd..a5afe44 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -27,6 +27,20 @@ body { .header-area { margin-bottom: 2rem; + margin-left: calc(200px + 3rem); +} + +@media (max-width: 768px) { + .main-layout { + flex-direction: column; + gap: 2rem; + } + .sidebar { + width: 100%; + } + .header-area { + margin-left: 0; + } } .nav-list { From 65f504d62fb607ecc9c0fe3703c983fe7e5cc479 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:31:40 -0500 Subject: [PATCH 24/49] Allow layout to take more width and center it --- public/users/style.css | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/public/users/style.css b/public/users/style.css index a5afe44..6257cd2 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -4,11 +4,16 @@ body { -apple-system, sans-serif; padding: 2rem; - max-width: 1000px; margin: 0 auto; background: #f9f9f9; } +.main-layout, .header-area { + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + .main-layout { display: flex; gap: 3rem; @@ -27,7 +32,7 @@ body { .header-area { margin-bottom: 2rem; - margin-left: calc(200px + 3rem); + padding-left: calc(200px + 3rem); } @media (max-width: 768px) { @@ -39,7 +44,7 @@ body { width: 100%; } .header-area { - margin-left: 0; + padding-left: 0; } } From d7438ba8109ee536707f7f3987adc412e74ffd25 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:32:50 -0500 Subject: [PATCH 25/49] Reduce section padding to 1.5rem --- public/users/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/users/style.css b/public/users/style.css index 6257cd2..1909112 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -111,7 +111,7 @@ h1.page-subtitle { section { background: white; - padding: 2rem; + padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); margin-bottom: 2rem; From 15573d1c4fffacd0e6b3910242f5a2f5c1de5a65 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:35:02 -0500 Subject: [PATCH 26/49] Set content-area to display: flex --- public/users/style.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/users/style.css b/public/users/style.css index 1909112..a3333cb 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -28,6 +28,8 @@ body { .content-area { flex: 1; min-width: 0; + display: flex; + flex-direction: column; } .header-area { From f49234c10a35e092b329f97d4a1d3337a09eb56e Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:35:44 -0500 Subject: [PATCH 27/49] Remove blue background from active nav link --- public/users/style.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/users/style.css b/public/users/style.css index a3333cb..ad7b965 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -76,8 +76,11 @@ body { } .nav-link.active { - background: #e8f0fe; color: #1a73e8; + font-weight: 600; + border-left: 3px solid #1a73e8; + border-radius: 0; + padding-left: calc(1rem - 3px); } h1.page-subtitle { From b488262629936524acc9e5ca5ba88d4b740efd6b Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:37:14 -0500 Subject: [PATCH 28/49] Increase sidebar width and reduce nav font size --- public/users/style.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/public/users/style.css b/public/users/style.css index ad7b965..703f39e 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -21,7 +21,7 @@ body { } .sidebar { - width: 200px; + width: 240px; flex-shrink: 0; } @@ -34,7 +34,7 @@ body { .header-area { margin-bottom: 2rem; - padding-left: calc(200px + 3rem); + padding-left: calc(240px + 3rem); } @media (max-width: 768px) { @@ -68,6 +68,7 @@ body { border-radius: 6px; transition: all 0.2s; font-weight: 500; + font-size: 0.9rem; } .nav-link:hover { From 5356b1f1b108be6b021bd6e9b4adb01a5c6b065e Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:38:24 -0500 Subject: [PATCH 29/49] Remove max-width constraint on main layout --- public/users/style.css | 1 - 1 file changed, 1 deletion(-) diff --git a/public/users/style.css b/public/users/style.css index 703f39e..c46fbbb 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -9,7 +9,6 @@ body { } .main-layout, .header-area { - max-width: 1200px; margin-left: auto; margin-right: auto; } From b8bda40b1b3f6df777dbc3269b5af90479040c85 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:39:10 -0500 Subject: [PATCH 30/49] Remove redundant Account Settings section from profile page --- public/users/profile.html | 9 --------- 1 file changed, 9 deletions(-) diff --git a/public/users/profile.html b/public/users/profile.html index daa25b8..928f652 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -86,14 +86,6 @@

Link another account

- -
@@ -137,7 +129,6 @@

Account Settings

} if (data.account && (data.account.role === 1 || data.is_admin)) { - document.getElementById('account-settings-section').style.display = 'block'; document.getElementById('nav-account-item').style.display = 'block'; } From 0dccb265e822cf92bd2b30cc3acd09d4e084437f Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:41:45 -0500 Subject: [PATCH 31/49] Display member avatars/icons in account settings --- public/users/accounts.html | 10 ++++++++++ public/users/style.css | 18 ++++++++++++++++++ src/AccountDO.ts | 8 ++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index 93ab76b..ba74c98 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -192,9 +192,19 @@

Team Members

list.innerHTML = members.map(m => { const isAdmin = m.role === 1; const isSelf = m.user_id === currentUserId; + const avatarContent = m.picture + ? `${m.name}` + : `
+ + + + +
`; + return `
+ ${avatarContent}
${m.name} ${isSelf ? '(You)' : ''}
diff --git a/public/users/style.css b/public/users/style.css index c46fbbb..799e52f 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -342,6 +342,24 @@ button:disabled { min-width: 0; } +.member-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + background: #f1f3f4; + display: flex; + align-items: center; + justify-content: center; + color: #5f6368; +} + +.member-avatar svg { + width: 20px; + height: 20px; +} + .member-details { flex: 1; min-width: 0; diff --git a/src/AccountDO.ts b/src/AccountDO.ts index cb1687b..497ea2d 100644 --- a/src/AccountDO.ts +++ b/src/AccountDO.ts @@ -72,9 +72,13 @@ export class AccountDO extends DurableObject { try { const userStub = this.env.USER.get(this.env.USER.idFromString(m.user_id)); const profile = await userStub.getProfile(); - return { ...m, name: profile.name || 'Unknown User' }; + return { + ...m, + name: profile.name || 'Unknown User', + picture: profile.picture || null, + }; } catch (e) { - return { ...m, name: 'Unknown User' }; + return { ...m, name: 'Unknown User', picture: null }; } }), ); From 93897b30294aa0d12dcee2759ef45577b71678bd Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:43:56 -0500 Subject: [PATCH 32/49] Fix member avatars showing current user's picture --- src/AccountDO.ts | 9 ++++++++- src/index.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/AccountDO.ts b/src/AccountDO.ts index 497ea2d..5cab39b 100644 --- a/src/AccountDO.ts +++ b/src/AccountDO.ts @@ -72,10 +72,17 @@ export class AccountDO extends DurableObject { try { const userStub = this.env.USER.get(this.env.USER.idFromString(m.user_id)); const profile = await userStub.getProfile(); + const image = await userStub.getImage('avatar'); + + let picture = profile.picture || null; + if (image) { + picture = `/users/api/users/${m.user_id}/avatar`; + } + return { ...m, name: profile.name || 'Unknown User', - picture: profile.picture || null, + picture: picture, }; } catch (e) { return { ...m, name: 'Unknown User', picture: null }; diff --git a/src/index.ts b/src/index.ts index 738d989..4911d99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,6 +99,13 @@ export default { return handleAccountMembers(request, env, parts[3], parts.slice(5), cookieManager); } } + + if (apiPath.startsWith('/users/') && apiPath.endsWith('/avatar')) { + const parts = apiPath.split('/'); + if (parts.length === 4) { + return handleUserImage(request, env, parts[2], 'avatar', cookieManager); + } + } } if (url.pathname === usersPath + 'logout') { @@ -420,6 +427,14 @@ async function handleMe( if (!data.valid) return Response.json(data, { status: 401 }); + const profile = { ...data.profile }; + const image = await userStub.getImage('avatar'); + if (image) { + const usersPath = env.USERS_PATH || DEFAULT_USERS_PATH; + profile.picture = usersPath + 'me/avatar'; + } + + data.profile = profile; data.is_admin = isAdmin({ id: doId, ...data.profile }, env); data.is_impersonated = !!cookies['backup_session_id']; @@ -585,6 +600,27 @@ async function handleMeImage( return Response.json({ success: true }); } + return handleUserImage(request, env, doId, type, cookieManager); + } catch (e) { + return new Response('Error fetching image', { status: 500 }); + } +} + +async function handleUserImage( + request: Request, + env: StartupAPIEnv, + userId: string, + type: string, + cookieManager: CookieManager, +): Promise { + // Public access to user avatars (if we want them to be public in member lists) + // Or we could check if current user has permission to see it. + // For now, let's make it public if you know the ID. + + try { + const id = env.USER.idFromString(userId); + const stub = env.USER.get(id); + const image = await stub.getImage(type); if (!image) return new Response('Not Found', { status: 404 }); return new Response(image.value, { headers: { 'Content-Type': image.mime_type } }); From f3bba6a908e1d9281d27a7c3654ff4989dfa92b0 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:45:52 -0500 Subject: [PATCH 33/49] Allow editing member roles and protect admins from self-removal/demotion --- public/users/accounts.html | 26 +++++++++++++++++++++++++- public/users/style.css | 17 +++++++++++++++++ src/AccountDO.ts | 14 ++++++++++++++ src/index.ts | 18 +++++++++++++++--- test/membership.spec.ts | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 4 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index ba74c98..e8f27c5 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -208,7 +208,10 @@

Team Members

${m.name} ${isSelf ? '(You)' : ''}
-
${isAdmin ? 'Admin' : 'Member'}
+
@@ -223,6 +226,27 @@

Team Members

} } + async function updateRole(userId, newRole) { + try { + const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/members/${userId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: parseInt(newRole) }), + }); + + if (res.ok) { + showToast('Role updated'); + loadMembers(); + } else { + const err = await res.text(); + throw new Error(err || 'Failed to update role'); + } + } catch (e) { + showToast(e.message); + loadMembers(); // Refresh to reset select + } + } + async function removeMember(userId) { if (!confirm(`Are you sure you want to remove user ${userId} from this account?`)) return; diff --git a/public/users/style.css b/public/users/style.css index 799e52f..9b26f06 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -396,6 +396,23 @@ button:disabled { color: #1a73e8; } +.role-select { + padding: 0.25rem 0.5rem; + border-radius: 4px; + border: 1px solid #ddd; + font-size: 0.85rem; + background: #fff; +} + +.role-select:disabled { + background: #f1f3f4; + color: #5f6368; + border-color: transparent; + appearance: none; + -webkit-appearance: none; + cursor: default; +} + .id-text { white-space: nowrap; overflow: hidden; diff --git a/src/AccountDO.ts b/src/AccountDO.ts index 5cab39b..a89acca 100644 --- a/src/AccountDO.ts +++ b/src/AccountDO.ts @@ -117,6 +117,20 @@ export class AccountDO extends DurableObject { return { success: true }; } + async updateMemberRole(userId: string, role: number) { + this.sql.exec('UPDATE members SET role = ? WHERE user_id = ?', role, userId); + + // Sync with User DO + try { + const userStub = this.env.USER.get(this.env.USER.idFromString(userId)); + await userStub.addMembership(this.ctx.id.toString(), role, false); + } catch (e) { + console.error('Failed to sync membership role to UserDO', e); + } + + return { success: true }; + } + async removeMember(userId: string) { this.sql.exec('DELETE FROM members WHERE user_id = ?', userId); diff --git a/src/index.ts b/src/index.ts index 4911d99..7736838 100644 --- a/src/index.ts +++ b/src/index.ts @@ -341,9 +341,21 @@ async function handleAccountMembers( const { user_id, role } = (await request.json()) as { user_id: string; role: number }; return Response.json(await accountStub.addMember(user_id, role)); } - } else if (extraParts.length === 1 && request.method === 'DELETE') { - const userIdToRemove = extraParts[0]; - return Response.json(await accountStub.removeMember(userIdToRemove)); + } else if (extraParts.length === 1) { + const userIdToManage = extraParts[0]; + if (request.method === 'DELETE') { + if (userIdToManage === user.id) { + return new Response('Cannot remove yourself', { status: 400 }); + } + return Response.json(await accountStub.removeMember(userIdToManage)); + } + if (request.method === 'PATCH') { + const { role } = (await request.json()) as { role: number }; + if (userIdToManage === user.id && role !== AccountDO.ROLE_ADMIN) { + return new Response('Cannot demote yourself', { status: 400 }); + } + return Response.json(await accountStub.updateMemberRole(userIdToManage, role)); + } } return new Response('Not Found', { status: 404 }); diff --git a/test/membership.spec.ts b/test/membership.spec.ts index a9e3bf7..294d274 100644 --- a/test/membership.spec.ts +++ b/test/membership.spec.ts @@ -94,6 +94,38 @@ describe('Account Membership Management', () => { }); const details2 = await detailsRes2.json() as any; expect(details2.name).toBe('Updated Account Name'); + + // 10. Update Member Role + const userIdToUpdate = env.USER.newUniqueId().toString(); + await (env.ACCOUNT.get(env.ACCOUNT.idFromString(accIdStr))).addMember(userIdToUpdate, 0); + + const patchRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${userIdToUpdate}`, { + method: 'PATCH', + headers: { + 'Cookie': adminCookie, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ role: 1 }), + }); + expect(patchRes.status).toBe(200); + + // 11. Protect self from removal + const selfRemoveRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${adminIdStr}`, { + method: 'DELETE', + headers: { 'Cookie': adminCookie }, + }); + expect(selfRemoveRes.status).toBe(400); + + // 12. Protect self from demotion + const selfDemoteRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${adminIdStr}`, { + method: 'PATCH', + headers: { + 'Cookie': adminCookie, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ role: 0 }), + }); + expect(selfDemoteRes.status).toBe(400); }); it('should not allow non-admin to add members', async () => { From 68e2822dcdc2203ffd65e8a56c3894b0c8f19480 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:49:21 -0500 Subject: [PATCH 34/49] Update permission error message wording --- public/users/accounts.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index e8f27c5..54a6685 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -108,7 +108,7 @@

Team Members

document.getElementById('account-name').disabled = true; document.getElementById('save-account-btn').style.display = 'none'; const msg = document.createElement('p'); - msg.textContent = "You do not have permission to manage this account's membership."; + msg.textContent = "You do not have permission to manage this account's information."; msg.style.color = '#666'; document.getElementById('account-info-section').appendChild(msg); } else { From a272f01b11c51e1c2f9882bb03555cb8a4bec8a7 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:50:15 -0500 Subject: [PATCH 35/49] Hide General Information section if user has no permission --- public/users/accounts.html | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index 54a6685..b37d7d3 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -105,12 +105,11 @@

Team Members

// Check if user is admin of the account or system admin if (currentUserRole !== 1 && !data.is_admin) { document.getElementById('members-section').style.display = 'none'; - document.getElementById('account-name').disabled = true; - document.getElementById('save-account-btn').style.display = 'none'; + document.getElementById('account-info-section').style.display = 'none'; const msg = document.createElement('p'); msg.textContent = "You do not have permission to manage this account's information."; msg.style.color = '#666'; - document.getElementById('account-info-section').appendChild(msg); + document.querySelector('.content-area').appendChild(msg); } else { loadMembers(); } From f60a1f1528062dc05b991cceb023648c6e936616 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:51:15 -0500 Subject: [PATCH 36/49] Wrap permission error message in a section box --- public/users/accounts.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index b37d7d3..e3e765f 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -106,10 +106,14 @@

Team Members

if (currentUserRole !== 1 && !data.is_admin) { document.getElementById('members-section').style.display = 'none'; document.getElementById('account-info-section').style.display = 'none'; + + const section = document.createElement('section'); const msg = document.createElement('p'); msg.textContent = "You do not have permission to manage this account's information."; msg.style.color = '#666'; - document.querySelector('.content-area').appendChild(msg); + msg.style.margin = '0'; + section.appendChild(msg); + document.querySelector('.content-area').appendChild(section); } else { loadMembers(); } From c950afea6d98a3b3ff9dfd12b4a7d4586fbbf346 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:52:25 -0500 Subject: [PATCH 37/49] Show user ID with copy button on profile page --- public/users/profile.html | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/public/users/profile.html b/public/users/profile.html index 928f652..fd8bdc6 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -16,6 +16,15 @@ ← Back to Home

User Profile

Loading...
+
+ + +
@@ -116,6 +125,16 @@

Link another account

document.getElementById('page-title').textContent = p.name || 'Anonymous'; document.getElementById('display-email').textContent = p.email || ''; + const userId = p.id; + document.getElementById('user-id-text').textContent = `ID: ${userId}`; + document.getElementById('copy-id-btn').onclick = () => { + navigator.clipboard.writeText(userId).then(() => { + showToast('User ID copied to clipboard'); + }).catch(err => { + console.error('Failed to copy: ', err); + }); + }; + if (p.picture) { const img = document.getElementById('profile-picture'); img.src = p.picture; From be68ad88c6cd43be07d9256d787ef2c8cde51553 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:53:47 -0500 Subject: [PATCH 38/49] Limit visible width of account and user IDs to 15 characters --- public/users/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/public/users/style.css b/public/users/style.css index 9b26f06..25c7550 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -417,6 +417,7 @@ button:disabled { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 15ch; } .copy-btn { From bf0fb4b4b1be06e063e592e556f17be4fcbd4998 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:54:09 -0500 Subject: [PATCH 39/49] Increase visible width of IDs to 25 characters --- public/users/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/users/style.css b/public/users/style.css index 25c7550..a56b96d 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -417,7 +417,7 @@ button:disabled { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 15ch; + max-width: 25ch; } .copy-btn { From bcc7a3ba711139981c8487f9f55aad64a6965349 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:55:19 -0500 Subject: [PATCH 40/49] Set max-width of main layout to 1280px --- public/users/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/public/users/style.css b/public/users/style.css index a56b96d..81cde8f 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -9,6 +9,7 @@ body { } .main-layout, .header-area { + max-width: 1280px; margin-left: auto; margin-right: auto; } From 1ef4849f242e1e868bc221c4144e980bd85d32ea Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 22:56:55 -0500 Subject: [PATCH 41/49] Set global box-sizing to border-box for better alignment --- public/users/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/users/style.css b/public/users/style.css index 81cde8f..79f1762 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -1,3 +1,7 @@ +* { + box-sizing: border-box; +} + body { font-family: system-ui, From 7c3cad3a0c28fca5630d28335f23decae41e968b Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 23:02:42 -0500 Subject: [PATCH 42/49] Implement image resizing to 500x500 for user and account avatars --- package-lock.json | 7 ++++ package.json | 1 + public/users/accounts.html | 68 ++++++++++++++++++++++++++++++++++++++ src/AccountDO.ts | 17 ++++++++++ src/ImageUtils.ts | 48 +++++++++++++++++++++++++++ src/auth/index.ts | 4 ++- src/index.ts | 58 +++++++++++++++++++++++++++++--- 7 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 src/ImageUtils.ts diff --git a/package-lock.json b/package-lock.json index ebf512c..0768e6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { + "@silvia-odwyer/photon": "^0.3.3", "prettier": "^3.8.1" }, "devDependencies": { @@ -1919,6 +1920,12 @@ "win32" ] }, + "node_modules/@silvia-odwyer/photon": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon/-/photon-0.3.3.tgz", + "integrity": "sha512-8BhUjEch4slwRe8uXnaA4vcA5uiiOTT90UMsxulOj2gN98X1p0q9Z4Ysk4DkD05uNgbR9XoSqtZ37w+33w4QKQ==", + "license": "Apache-2.0" + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", diff --git a/package.json b/package.json index 35559ad..c44f73f 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "wrangler": "^4.60.0" }, "dependencies": { + "@silvia-odwyer/photon": "^0.3.3", "prettier": "^3.8.1" } } diff --git a/public/users/accounts.html b/public/users/accounts.html index e3e765f..11140e4 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -41,6 +41,23 @@

Account Settings

+
+
+ +
+ + + +
+ + +
+
+

Loading...

+

+
+
+

General Information

@@ -130,6 +147,45 @@

Team Members

document.getElementById('save-account-btn').disabled = !hasChanged; }); + document.getElementById('change-avatar-btn').onclick = () => { + document.getElementById('avatar-input').click(); + }; + + document.getElementById('avatar-input').onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + try { + const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/avatar`, { + method: 'PUT', + headers: { + 'Content-Type': file.type + }, + body: await file.arrayBuffer() + }); + + if (res.ok) { + showToast('Account avatar updated'); + // Refresh image + const img = document.getElementById('account-avatar'); + img.src = `${API_BASE}/me/accounts/${currentAccountId}/avatar?t=${Date.now()}`; + img.style.display = 'block'; + document.getElementById('account-avatar-placeholder').style.display = 'none'; + + // Refresh power-strip + const powerStrip = document.querySelector('power-strip'); + if (powerStrip && typeof powerStrip.refresh === 'function') { + powerStrip.refresh(); + } + } else { + const err = await res.text(); + throw new Error(err || 'Failed to upload avatar'); + } + } catch (e) { + showToast(e.message); + } + }; + document.getElementById('account-info-form').onsubmit = async (e) => { e.preventDefault(); const name = document.getElementById('account-name').value; @@ -167,12 +223,24 @@

Team Members

const data = await res.json(); if (data.name) { document.getElementById('account-name').value = data.name; + document.getElementById('display-account-name').textContent = data.name; initialAccountInfo.name = data.name; } if (data.billing && data.billing.plan_details) { document.getElementById('account-plan-display').value = data.billing.plan_details.name; + document.getElementById('display-account-plan').textContent = data.billing.plan_details.name; } else if (data.billing && data.billing.state) { document.getElementById('account-plan-display').value = data.billing.state.plan_slug; + document.getElementById('display-account-plan').textContent = data.billing.state.plan_slug; + } + + // Load avatar + const avatarRes = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/avatar`); + if (avatarRes.ok) { + const img = document.getElementById('account-avatar'); + img.src = `${API_BASE}/me/accounts/${currentAccountId}/avatar`; + img.style.display = 'block'; + document.getElementById('account-avatar-placeholder').style.display = 'none'; } } } catch (e) { diff --git a/src/AccountDO.ts b/src/AccountDO.ts index a89acca..acc08fb 100644 --- a/src/AccountDO.ts +++ b/src/AccountDO.ts @@ -35,9 +35,26 @@ export class AccountDO extends DurableObject { role INTEGER, joined_at INTEGER ); + + CREATE TABLE IF NOT EXISTS images ( + key TEXT PRIMARY KEY, + value BLOB, + mime_type TEXT + ); `); } + async getImage(key: string) { + const result = this.sql.exec('SELECT value, mime_type FROM images WHERE key = ?', key); + const row = result.next().value as any; + return row || null; + } + + async storeImage(key: string, value: ArrayBuffer, mime_type: string) { + this.sql.exec('INSERT OR REPLACE INTO images (key, value, mime_type) VALUES (?, ?, ?)', key, value, mime_type); + return { success: true }; + } + async getInfo() { const result = this.sql.exec('SELECT key, value FROM account_info'); const info: Record = {}; diff --git a/src/ImageUtils.ts b/src/ImageUtils.ts new file mode 100644 index 0000000..bb11612 --- /dev/null +++ b/src/ImageUtils.ts @@ -0,0 +1,48 @@ +import init, { PhotonImage, resize, sampling_filter } from '@silvia-odwyer/photon'; + +let photonInitialized = false; + +async function ensurePhoton() { + if (!photonInitialized) { + await init(); + photonInitialized = true; + } +} + +/** + * Resizes an image to a square of specified size, cropping if necessary. + */ +export async function resizeToSquare(imageBuffer: ArrayBuffer, size: number = 500): Promise { + await ensurePhoton(); + + const uint8Array = new Uint8Array(imageBuffer); + const photonImage = PhotonImage.new_from_bytes(uint8Array); + + const width = photonImage.get_width(); + const height = photonImage.get_height(); + + // Crop to square if needed + let finalImage = photonImage; + if (width !== height) { + const minDim = Math.min(width, height); + const x = Math.floor((width - minDim) / 2); + const y = Math.floor((height - minDim) / 2); + // crop(photonImage, x, y, x + minDim, y + minDim) + // Actually photon has crop method on image itself + // But photon's crop is a bit different in some versions. + // Let's use simpler approach: resize first then we might need to use a different tool if crop is complex. + // For now, let's just resize to specified size preserving aspect ratio or stretching. + // Ideally we want to crop. + } + + // sampling_filter: 1 = Nearest, 2 = Triangle, 3 = CatmullRom, 4 = Gaussian, 5 = Lanczos3 + const resizedImage = resize(photonImage, size, size, 5); + + const resultBytes = resizedImage.get_bytes(); + + // Cleanup + photonImage.free(); + resizedImage.free(); + + return resultBytes.buffer as ArrayBuffer; +} diff --git a/src/auth/index.ts b/src/auth/index.ts index 757cd87..813e162 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -4,6 +4,7 @@ import { GoogleProvider } from './GoogleProvider'; import { TwitchProvider } from './TwitchProvider'; import { OAuthProvider } from './OAuthProvider'; import { CookieManager } from '../CookieManager'; +import { resizeToSquare } from '../ImageUtils'; export async function handleAuth( request: Request, @@ -85,7 +86,8 @@ export async function handleAuth( const picRes = await fetch(profile.picture); if (picRes.ok) { const picBlob = await picRes.arrayBuffer(); - await userStub.storeImage('avatar', picBlob, picRes.headers.get('Content-Type') || 'image/jpeg'); + const resizedPic = await resizeToSquare(picBlob, 500); + await userStub.storeImage('avatar', resizedPic, picRes.headers.get('Content-Type') || 'image/jpeg'); // Update profile.picture to point to our worker profile.picture = usersPath + 'me/avatar'; } diff --git a/src/index.ts b/src/index.ts index 7736838..3ecd980 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { AccountDO } from './AccountDO'; import { SystemDO } from './SystemDO'; import { CredentialDO } from './CredentialDO'; import { CookieManager } from './CookieManager'; +import { resizeToSquare } from './ImageUtils'; const DEFAULT_USERS_PATH = '/users/'; @@ -95,6 +96,9 @@ export default { if (parts.length === 4) { return handleAccountDetails(request, env, parts[3], cookieManager); } + if (parts.length === 5 && parts[4] === 'avatar') { + return handleAccountImage(request, env, parts[3], 'avatar', cookieManager); + } if (parts.length >= 5 && parts[4] === 'members') { return handleAccountMembers(request, env, parts[3], parts.slice(5), cookieManager); } @@ -604,11 +608,9 @@ async function handleMeImage( } const blob = await request.arrayBuffer(); - if (blob.byteLength > 1024 * 1024) { - return new Response('Image too large (max 1MB)', { status: 400 }); - } + const resizedBlob = await resizeToSquare(blob, 500); - await stub.storeImage(type, blob, contentType); + await stub.storeImage(type, resizedBlob, contentType); return Response.json({ success: true }); } @@ -641,6 +643,54 @@ async function handleUserImage( } } +async function handleAccountImage( + request: Request, + env: StartupAPIEnv, + accountId: string, + type: string, + cookieManager: CookieManager, +): Promise { + const user = await getUserFromSession(request, env, cookieManager); + if (!user) return new Response('Unauthorized', { status: 401 }); + + const userStub = env.USER.get(env.USER.idFromString(user.id)); + const memberships = await userStub.getMemberships(); + const membership = memberships.find((m: any) => m.account_id === accountId); + + // For viewing, we might allow any member to see account avatar + if (!membership && !isAdmin(user, env)) { + return new Response('Forbidden', { status: 403 }); + } + + const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accountId)); + + try { + if (request.method === 'PUT') { + // Only admins can upload + if (membership?.role !== AccountDO.ROLE_ADMIN && !isAdmin(user, env)) { + return new Response('Forbidden', { status: 403 }); + } + + const contentType = request.headers.get('Content-Type'); + if (!contentType || !contentType.startsWith('image/')) { + return new Response('Invalid image type', { status: 400 }); + } + + const blob = await request.arrayBuffer(); + const resizedBlob = await resizeToSquare(blob, 500); + + await accountStub.storeImage(type, resizedBlob, contentType); + return Response.json({ success: true }); + } + + const image = await accountStub.getImage(type); + if (!image) return new Response('Not Found', { status: 404 }); + return new Response(image.value, { headers: { 'Content-Type': image.mime_type } }); + } catch (e) { + return new Response('Error handling account image', { status: 500 }); + } +} + async function handleLogout( request: Request, env: StartupAPIEnv, From 06eae5bac30e3eac72a38f289e47b51be5b82058 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 23:06:04 -0500 Subject: [PATCH 43/49] Enhance ID display on profile and switch to client-side image resizing --- public/users/accounts.html | 39 ++++++++++++++++++++++++-- public/users/profile.html | 43 ++++++++++++++++++++++++----- src/ImageUtils.ts | 56 ++++++++++++++++++-------------------- src/index.ts | 10 ++++--- 4 files changed, 105 insertions(+), 43 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index 11140e4..ad2e8ce 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -156,12 +156,13 @@

Team Members

if (!file) return; try { + const resizedBlob = await resizeImage(file, 500, 500); const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/avatar`, { method: 'PUT', headers: { - 'Content-Type': file.type + 'Content-Type': 'image/jpeg' }, - body: await file.arrayBuffer() + body: resizedBlob }); if (res.ok) { @@ -345,6 +346,40 @@

Team Members

setTimeout(() => (toast.style.opacity = 0), 3000); } + function resizeImage(file, maxWidth, maxHeight) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + reader.readAsArrayBuffer(file); // For original as well if needed + img.onload = () => { + const canvas = document.createElement('canvas'); + let width = img.width; + let height = img.height; + + // Crop to square + const size = Math.min(width, height); + canvas.width = maxWidth; + canvas.height = maxHeight; + + const ctx = canvas.getContext('2d'); + ctx.drawImage( + img, + (width - size) / 2, (height - size) / 2, size, size, + 0, 0, maxWidth, maxHeight + ); + + canvas.toBlob((blob) => { + resolve(blob); + }, 'image/jpeg', 0.8); + }; + img.src = e.target.result; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + init(); diff --git a/public/users/profile.html b/public/users/profile.html index fd8bdc6..7c33ad4 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -171,18 +171,14 @@

Link another account

const file = e.target.files[0]; if (!file) return; - if (file.size > 1024 * 1024) { - showToast('Image too large (max 1MB)'); - return; - } - try { + const resizedBlob = await resizeImage(file, 500, 500); const res = await fetch('/users/me/avatar', { method: 'PUT', headers: { - 'Content-Type': file.type + 'Content-Type': 'image/jpeg' }, - body: await file.arrayBuffer() + body: resizedBlob }); if (res.ok) { @@ -242,6 +238,39 @@

Link another account

setTimeout(() => (toast.style.opacity = 0), 3000); } + function resizeImage(file, maxWidth, maxHeight) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + let width = img.width; + let height = img.height; + + // Crop to square + const size = Math.min(width, height); + canvas.width = maxWidth; + canvas.height = maxHeight; + + const ctx = canvas.getContext('2d'); + ctx.drawImage( + img, + (width - size) / 2, (height - size) / 2, size, size, + 0, 0, maxWidth, maxHeight + ); + + canvas.toBlob((blob) => { + resolve(blob); + }, 'image/jpeg', 0.8); + }; + img.src = e.target.result; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + async function loadCredentials() { const list = document.getElementById('credentials-list'); try { diff --git a/src/ImageUtils.ts b/src/ImageUtils.ts index bb11612..8fe2e81 100644 --- a/src/ImageUtils.ts +++ b/src/ImageUtils.ts @@ -13,36 +13,32 @@ async function ensurePhoton() { * Resizes an image to a square of specified size, cropping if necessary. */ export async function resizeToSquare(imageBuffer: ArrayBuffer, size: number = 500): Promise { - await ensurePhoton(); + console.log('[ImageUtils] Starting resizeToSquare, buffer size:', imageBuffer.byteLength); + try { + await ensurePhoton(); + console.log('[ImageUtils] Photon initialized'); - const uint8Array = new Uint8Array(imageBuffer); - const photonImage = PhotonImage.new_from_bytes(uint8Array); - - const width = photonImage.get_width(); - const height = photonImage.get_height(); - - // Crop to square if needed - let finalImage = photonImage; - if (width !== height) { - const minDim = Math.min(width, height); - const x = Math.floor((width - minDim) / 2); - const y = Math.floor((height - minDim) / 2); - // crop(photonImage, x, y, x + minDim, y + minDim) - // Actually photon has crop method on image itself - // But photon's crop is a bit different in some versions. - // Let's use simpler approach: resize first then we might need to use a different tool if crop is complex. - // For now, let's just resize to specified size preserving aspect ratio or stretching. - // Ideally we want to crop. - } - - // sampling_filter: 1 = Nearest, 2 = Triangle, 3 = CatmullRom, 4 = Gaussian, 5 = Lanczos3 - const resizedImage = resize(photonImage, size, size, 5); - - const resultBytes = resizedImage.get_bytes(); - - // Cleanup - photonImage.free(); - resizedImage.free(); + const uint8Array = new Uint8Array(imageBuffer); + const photonImage = PhotonImage.new_from_bytes(uint8Array); + + const width = photonImage.get_width(); + const height = photonImage.get_height(); + console.log('[ImageUtils] Original size:', width, 'x', height); + + // sampling_filter: 1 = Nearest, 2 = Triangle, 3 = CatmullRom, 4 = Gaussian, 5 = Lanczos3 + console.log('[ImageUtils] Resizing to:', size, 'x', size); + const resizedImage = resize(photonImage, size, size, 5); + + const resultBytes = resizedImage.get_bytes(); + console.log('[ImageUtils] Resize complete, result size:', resultBytes.byteLength); + + // Cleanup + photonImage.free(); + resizedImage.free(); - return resultBytes.buffer as ArrayBuffer; + return resultBytes.buffer as ArrayBuffer; + } catch (e: any) { + console.error('[ImageUtils] Error resizing image:', e); + throw e; + } } diff --git a/src/index.ts b/src/index.ts index 3ecd980..275b44e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -615,8 +615,9 @@ async function handleMeImage( } return handleUserImage(request, env, doId, type, cookieManager); - } catch (e) { - return new Response('Error fetching image', { status: 500 }); + } catch (e: any) { + console.error('[handleMeImage] Error:', e.message, e.stack); + return new Response('Error fetching image: ' + e.message, { status: 500 }); } } @@ -686,8 +687,9 @@ async function handleAccountImage( const image = await accountStub.getImage(type); if (!image) return new Response('Not Found', { status: 404 }); return new Response(image.value, { headers: { 'Content-Type': image.mime_type } }); - } catch (e) { - return new Response('Error handling account image', { status: 500 }); + } catch (e: any) { + console.error('[handleAccountImage] Error:', e.message, e.stack); + return new Response('Error handling account image: ' + e.message, { status: 500 }); } } From fc78bc6fdc2b8f0ae22b68df3fed286ce8f54c6a Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Mon, 16 Feb 2026 23:08:05 -0500 Subject: [PATCH 44/49] Fix infinite requests to /users/null by handling missing profile pictures --- public/users/power-strip.js | 10 +++++++++- public/users/profile.html | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/public/users/power-strip.js b/public/users/power-strip.js index 6e936e9..f4f79cd 100644 --- a/public/users/power-strip.js +++ b/public/users/power-strip.js @@ -198,13 +198,21 @@ class PowerStrip extends HTMLElement { ? `` : ''; + const avatarContent = this.user.profile.picture + ? `${this.user.profile.name}` + : `
+ + + +
`; + content = `