diff --git a/public/users/accounts.html b/public/users/accounts.html new file mode 100644 index 0000000..6d256ce --- /dev/null +++ b/public/users/accounts.html @@ -0,0 +1,351 @@ + + + + + + Account Settings + + + + + + + + +
+ ← Back to Home +

Account Settings

+
Account
+
+ + +
+
+ +
+ + +
+
+
+
+ + + + +
+
+

Loading...

+

+
+
+ +

General Information

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

Team Members

+
+

Loading members...

+
+
+
+
+ +
+ + + + diff --git a/public/users/power-strip.js b/public/users/power-strip.js index e902292..f4f79cd 100644 --- a/public/users/power-strip.js +++ b/public/users/power-strip.js @@ -135,7 +135,7 @@ class PowerStrip extends HTMLElement { if (this.user) { const providerIcon = this.getProviderIcon(this.user.credential.provider); const currentAccount = this.accounts.find((a) => a.is_current) || (this.accounts.length > 0 ? this.accounts[0] : null); - const accountName = currentAccount ? (currentAccount.personal ? this.user.profile.name : currentAccount.name) : 'No Account'; + const accountName = currentAccount ? currentAccount.name : 'No Account'; let switchButton = ''; let accountContainer = ''; @@ -148,10 +148,19 @@ class PowerStrip extends HTMLElement { `; + const accountSettingsLink = (currentAccount && (currentAccount.role === 1 || this.user.is_admin)) + ? ` + + + + ` + : ''; + accountContainer = `
${accountName} ${switchButton} + ${accountSettingsLink}
`; @@ -189,13 +198,21 @@ class PowerStrip extends HTMLElement { ? `` : ''; + const avatarContent = this.user.profile.picture + ? `${this.user.profile.name}` + : `
+ + + +
`; + content = `
${adminLink} ${impersonationLink} ${accountContainer}
- ${this.user.profile.name} + ${avatarContent}
${providerIcon}
@@ -507,6 +524,7 @@ class PowerStrip extends HTMLElement { align-items: center; transition: background-color 0.2s; font-size: 1rem; + gap: 1rem; } .account-item:hover { diff --git a/public/users/profile.html b/public/users/profile.html index 0fe0a33..9ae36b9 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -4,176 +4,7 @@ User Profile - + @@ -181,65 +12,91 @@ - ← Back to Home -

Edit Profile

- -
-
-
- - - -
-
-

Loading...

-

-
+
+ ← Back to Home +

User Profile

+
Loading...
+
+ +
+
-
-
- - -
-
- - -
- -
-
+
+ -
-

Login Credentials

-

- Manage the login methods linked to your account. -

- -
- -

Loading credentials...

-
+
+
+
+
+ + + +
+
+

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

Login Credentials

+

+ Manage the login methods linked to your account. +

+ +
+ +

Loading credentials...

+
-

Link another account

-
-
+
@@ -265,13 +122,27 @@

Link another account

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 || ''; + 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; img.style.display = 'block'; + } else { + const img = document.getElementById('profile-picture'); + img.src = ''; + img.style.display = 'none'; } const emailLabel = document.querySelector('label[for="email"]'); @@ -280,6 +151,10 @@

Link another account

emailLabel.textContent = `Email (from ${providerName})`; } + if (data.account && (data.account.role === 1 || data.is_admin)) { + document.getElementById('nav-account-item').style.display = 'block'; + } + document.getElementById('save-btn').disabled = true; } } catch (e) { diff --git a/public/users/style.css b/public/users/style.css new file mode 100644 index 0000000..9bf095f --- /dev/null +++ b/public/users/style.css @@ -0,0 +1,457 @@ +* { + box-sizing: border-box; +} + +body { + font-family: + system-ui, + -apple-system, + sans-serif; + padding: 2rem; + margin: 0 auto; + background: #f9f9f9; +} + +.main-layout, .header-area { + max-width: 1280px; + margin-left: auto; + margin-right: auto; +} + +.main-layout { + display: flex; + gap: 3rem; + margin-top: 2rem; +} + +.sidebar { + width: 240px; + flex-shrink: 0; +} + +.content-area { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.header-area { + margin-bottom: 2rem; + padding-left: calc(240px + 3rem); +} + +@media (max-width: 768px) { + .main-layout { + flex-direction: column; + gap: 2rem; + } + .sidebar { + width: 100%; + } + .header-area { + padding-left: 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; + font-size: 0.9rem; +} + +.nav-link:hover { + background: #f0f0f0; + color: #1a73e8; +} + +.nav-link.active { + color: #1a73e8; + font-weight: 600; + border-left: 3px solid #1a73e8; + border-radius: 0; + padding-left: calc(1rem - 3px); +} + +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: 1.5rem; + 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); +} + +.account-avatar-large { + width: 100px; + height: 100px; + border-radius: 8px; + 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-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; + 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; +} + +.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; + text-overflow: ellipsis; + max-width: 25ch; +} + +.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; +} diff --git a/src/AccountDO.ts b/src/AccountDO.ts index 4c17134..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 = {}; @@ -52,7 +69,11 @@ export class AccountDO extends DurableObject { try { this.ctx.storage.transactionSync(() => { for (const [key, value] of Object.entries(data)) { - this.sql.exec('INSERT OR REPLACE INTO account_info (key, value) VALUES (?, ?)', key, JSON.stringify(value)); + let valToStore = value; + if (key === 'name' && typeof value === 'string') { + valToStore = value.substring(0, 50); + } + this.sql.exec('INSERT OR REPLACE INTO account_info (key, value) VALUES (?, ?)', key, JSON.stringify(valToStore)); } }); return { success: true }; @@ -62,8 +83,30 @@ export class AccountDO extends DurableObject { } async getMembers() { - const result = this.sql.exec('SELECT user_id, role, joined_at FROM members'); - return Array.from(result); + const result = Array.from(this.sql.exec('SELECT user_id, role, joined_at FROM members')); + const membersWithNames = await Promise.all( + result.map(async (m: any) => { + 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: picture, + }; + } catch (e) { + return { ...m, name: 'Unknown User', picture: null }; + } + }), + ); + return membersWithNames; } async addMember(user_id: string, role: number) { @@ -91,6 +134,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/SystemDO.ts b/src/SystemDO.ts index cf2a9e7..c97e521 100644 --- a/src/SystemDO.ts +++ b/src/SystemDO.ts @@ -160,6 +160,8 @@ export class SystemDO extends DurableObject { async registerAccount(data: { id?: string; name: string; status?: string; plan?: string; ownerId?: string }) { let accountIdStr = data.id; + const accountName = (data.name || '').substring(0, 50); + if (!accountIdStr) { const id = this.env.ACCOUNT.newUniqueId(); accountIdStr = id.toString(); @@ -167,7 +169,7 @@ export class SystemDO extends DurableObject { // Initialize AccountDO const stub = this.env.ACCOUNT.get(id); await stub.updateInfo({ - name: data.name, + name: accountName, }); // If owner provided, add them as ADMIN @@ -181,7 +183,7 @@ export class SystemDO extends DurableObject { this.sql.exec( 'INSERT OR REPLACE INTO accounts (id, name, status, plan, member_count, created_at) VALUES (?, ?, ?, ?, ?, ?)', accountIdStr, - data.name, + accountName, data.status || 'active', data.plan || 'free', data.ownerId ? 1 : 0, @@ -215,10 +217,15 @@ export class SystemDO extends DurableObject { } async updateAccount(accountId: string, data: any) { + const sanitizedData = { ...data }; + if (sanitizedData.name !== undefined) { + sanitizedData.name = sanitizedData.name.substring(0, 50); + } + // Update AccountDO try { const stub = this.env.ACCOUNT.get(this.env.ACCOUNT.idFromString(accountId)); - await stub.updateInfo(data); + await stub.updateInfo(sanitizedData); } catch (e) { console.error('Failed to update AccountDO', e); } @@ -227,9 +234,9 @@ export class SystemDO extends DurableObject { const updates: string[] = []; const args: any[] = []; - if (data.name !== undefined) { + if (sanitizedData.name !== undefined) { updates.push('name = ?'); - args.push(data.name); + args.push(sanitizedData.name); } if (data.status !== undefined) { updates.push('status = ?'); diff --git a/src/index.ts b/src/index.ts index f8b0438..0040baa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,6 +89,26 @@ 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 === 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); + } + } + + 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') { @@ -293,6 +313,106 @@ 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) { + 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 }); +} + +async function handleAccountDetails( + request: Request, + env: StartupAPIEnv, + accountId: 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 (request.method === 'POST') { + const data = await request.json() as any; + const result = await accountStub.updateInfo(data); + + // Sync with SystemDO index if name changed + if (data.name) { + try { + const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); + await systemStub.updateAccount(accountId, { name: data.name }); + } catch (e) { + console.error('Failed to sync account name to SystemDO', e); + } + } + return Response.json(result); + } + + const info = await accountStub.getInfo(); + const billing = await accountStub.getBillingInfo(); + + return Response.json({ + ...info, + id: accountId, + role: membership ? membership.role : null, + billing, + }); +} + async function handleMe( request: Request, env: StartupAPIEnv, @@ -322,6 +442,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']; @@ -487,6 +615,28 @@ async function handleMeImage( return Response.json({ success: true }); } + return handleUserImage(request, env, doId, type, cookieManager); + } catch (e: any) { + console.error('[handleMeImage] Error:', e.message, e.stack); + return new Response('Error fetching image: ' + e.message, { 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 } }); @@ -495,6 +645,57 @@ async function handleMeImage( } } +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(); + if (blob.byteLength > 1024 * 1024) { + return new Response('Image too large (max 1MB)', { status: 400 }); + } + + await accountStub.storeImage(type, blob, 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: any) { + console.error('[handleAccountImage] Error:', e.message, e.stack); + return new Response('Error handling account image: ' + e.message, { status: 500 }); + } +} + async function handleLogout( request: Request, env: StartupAPIEnv, diff --git a/test/membership.spec.ts b/test/membership.spec.ts new file mode 100644 index 0000000..0508b6a --- /dev/null +++ b/test/membership.spec.ts @@ -0,0 +1,195 @@ +import { env, SELF } from 'cloudflare:test'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { CookieManager } from '../src/CookieManager'; + +describe('Permission Enforcement', () => { + 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}`)}`; + } + + async function setupAccount(name: string) { + const accId = env.ACCOUNT.newUniqueId(); + const accIdStr = accId.toString(); + const accStub = env.ACCOUNT.get(accId); + await accStub.updateInfo({ name }); + return { accId, accIdStr, accStub }; + } + + async function setupUser(role?: number, accIdStr?: string) { + const userId = env.USER.newUniqueId(); + const userIdStr = userId.toString(); + const cookie = await createSession(userIdStr); + + if (accIdStr !== undefined && role !== undefined) { + const accStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accIdStr)); + await accStub.addMember(userIdStr, role); + } + + return { userId, userIdStr, cookie }; + } + + it('Account Admin can perform all management tasks', async () => { + const { accIdStr } = await setupAccount('Admin Test Account'); + const { cookie: adminCookie, userIdStr: adminIdStr } = await setupUser(1, accIdStr); // ROLE_ADMIN = 1 + const { userIdStr: otherUserId } = await setupUser(); + + // 1. Can list members + const listRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + headers: { 'Cookie': adminCookie }, + }); + expect(listRes.status).toBe(200); + + // 2. Can add members + 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: otherUserId, role: 0 }), + }); + expect(addRes.status).toBe(200); + + // 3. Can update roles + const patchRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${otherUserId}`, { + method: 'PATCH', + headers: { 'Cookie': adminCookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 1 }), + }); + expect(patchRes.status).toBe(200); + + // 4. Can update account name + const updateRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}`, { + method: 'POST', + headers: { 'Cookie': adminCookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'New Name' }), + }); + expect(updateRes.status).toBe(200); + + // 5. Can remove others + const removeRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${otherUserId}`, { + method: 'DELETE', + headers: { 'Cookie': adminCookie }, + }); + expect(removeRes.status).toBe(200); + + // 6. Cannot remove themselves + 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); + + // 7. Cannot demote themselves + 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('Regular Member is forbidden from management tasks', async () => { + const { accIdStr } = await setupAccount('Member Test Account'); + const { cookie: memberCookie } = await setupUser(0, accIdStr); // ROLE_USER = 0 + const { userIdStr: otherUserId } = await setupUser(); + + // 1. Cannot list members + const listRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + headers: { 'Cookie': memberCookie }, + }); + expect(listRes.status).toBe(403); + + // 2. Cannot add members + const addRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + method: 'POST', + headers: { 'Cookie': memberCookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: otherUserId, role: 0 }), + }); + expect(addRes.status).toBe(403); + + // 3. Cannot update roles + const patchRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${otherUserId}`, { + method: 'PATCH', + headers: { 'Cookie': memberCookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 1 }), + }); + expect(patchRes.status).toBe(403); + + // 4. Cannot update account name + const updateRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}`, { + method: 'POST', + headers: { 'Cookie': memberCookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Hacker Name' }), + }); + expect(updateRes.status).toBe(403); + + // 5. Cannot get account details (including billing) + const detailsRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}`, { + headers: { 'Cookie': memberCookie }, + }); + expect(detailsRes.status).toBe(403); + }); + + it('Non-member is forbidden from management tasks', async () => { + const { accIdStr } = await setupAccount('Non-member Test Account'); + const { cookie: nonMemberCookie } = await setupUser(); + const { userIdStr: otherUserId } = await setupUser(); + + // 1. Cannot list members + const listRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + headers: { 'Cookie': nonMemberCookie }, + }); + expect(listRes.status).toBe(403); + + // 2. Cannot add members + const addRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + method: 'POST', + headers: { 'Cookie': nonMemberCookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: otherUserId, role: 0 }), + }); + expect(addRes.status).toBe(403); + + // 3. Cannot update account name + const updateRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}`, { + method: 'POST', + headers: { 'Cookie': nonMemberCookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Hacker Name' }), + }); + expect(updateRes.status).toBe(403); + }); + + it('System Admin can bypass all checks', async () => { + const { accIdStr } = await setupAccount('System Admin Test Account'); + + 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); + + const { userIdStr: otherUserId } = await setupUser(); + + // 1. Can list members of any account + const listRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, { + headers: { 'Cookie': systemAdminCookie }, + }); + expect(listRes.status).toBe(200); + + // 2. Can add members to any account + 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: otherUserId, role: 0 }), + }); + expect(addRes.status).toBe(200); + + // 3. Can update account name of any account + const updateRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}`, { + method: 'POST', + headers: { 'Cookie': systemAdminCookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'System Updated Name' }), + }); + expect(updateRes.status).toBe(200); + }); +});