diff --git a/public/users/power-strip.js b/public/users/power-strip.js index bac7b3c..8519ea4 100644 --- a/public/users/power-strip.js +++ b/public/users/power-strip.js @@ -4,6 +4,7 @@ class PowerStrip extends HTMLElement { this.attachShadow({ mode: 'open' }); this.basePath = this.detectBasePath(); this.user = null; + this.accounts = []; } detectBasePath() { @@ -38,6 +39,11 @@ class PowerStrip extends HTMLElement { const data = await res.json(); if (data.valid) { this.user = data.profile; + // Fetch accounts if logged in + const accountsRes = await fetch(`${this.basePath}/me/accounts`); + if (accountsRes.ok) { + this.accounts = await accountsRes.json(); + } } } } catch (e) { @@ -45,6 +51,26 @@ class PowerStrip extends HTMLElement { } } + async switchAccount(accountId) { + try { + const res = await fetch(`${this.basePath}/me/accounts/switch`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ account_id: accountId }), + }); + + if (res.ok) { + window.location.reload(); + } else { + console.error('Failed to switch account'); + } + } catch (e) { + console.error('Error switching account', e); + } + } + getProviderIcon(provider) { if (provider === 'google') { return ` @@ -92,10 +118,40 @@ class PowerStrip extends HTMLElement { } let content = ''; + let accountSwitcher = ''; if (providers.length > 0 && providers[0] !== '') { if (this.user) { const providerIcon = this.getProviderIcon(this.user.provider); + const currentAccount = this.accounts.find(a => a.is_current) || (this.accounts.length > 0 ? this.accounts[0] : null); + const accountName = currentAccount ? currentAccount.name : 'No Account'; + + let switchButton = ''; + if (this.accounts.length > 1) { + switchButton = ``; + + const accountList = this.accounts.map(acc => ` + ${acc.name} + ${acc.is_current ? 'Current' : ''} + + `).join(''); + + accountSwitcher = ` + +
+
+

Switch Account

+ +
+ +
+
+ `; + } + content = `
@@ -104,7 +160,11 @@ class PowerStrip extends HTMLElement { ${providerIcon}
- ${this.user.name} +
+ ${this.user.name} + ${accountName} +
+ ${switchButton} Logout `; @@ -147,10 +207,13 @@ class PowerStrip extends HTMLElement { padding: 0.125rem 0.375rem; transition: background-color 0.2s; border-radius: 0.25rem; - font-size: 0.9rem; + font-size: 0.8rem; font-weight: 500; color: #444; text-decoration: none; + border: none; + background: transparent; + line-height: inherit; } .trigger:hover { @@ -158,6 +221,10 @@ class PowerStrip extends HTMLElement { text-decoration: underline; color: #1a73e8; } + + .switch-btn { + color: #1a73e8; + } svg.bolt, ::slotted(svg) { width: 1rem !important; @@ -206,17 +273,34 @@ class PowerStrip extends HTMLElement { .provider-badge.google { color: #3c4043; } .provider-badge.twitch { color: #9146FF; } + .user-info { + display: flex; + flex-direction: column; + line-height: 1; + justify-content: center; + } + .user-name { - font-size: 0.9rem; + font-size: 0.8rem; color: #333; - max-width: 15rem; + max-width: 10rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + } + + .account-label { + font-size: 0.65rem; + color: #666; + max-width: 10rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @media (max-width: 25rem) { - .user-name { + .user-info { display: none; } } @@ -333,6 +417,44 @@ class PowerStrip extends HTMLElement { background-color: #7d2ee6; border-color: #7d2ee6; } + + /* Account Switcher Styling */ + .account-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .account-item { + padding: 0.75rem; + border: 1px solid #eee; + border-radius: 0.375rem; + background: white; + text-align: left; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background-color 0.2s; + font-size: 1rem; + } + + .account-item:hover { + background-color: #f5f5f5; + } + + .account-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; + }
@@ -351,33 +473,73 @@ class PowerStrip extends HTMLElement {
+ + ${accountSwitcher} `; } addEventListeners() { - const trigger = this.shadowRoot.getElementById('login-trigger'); - const dialog = this.shadowRoot.getElementById('login-dialog'); - const closeBtn = this.shadowRoot.getElementById('close-dialog'); + const loginTrigger = this.shadowRoot.getElementById('login-trigger'); + const loginDialog = this.shadowRoot.getElementById('login-dialog'); + const closeLoginBtn = this.shadowRoot.getElementById('close-dialog'); - if (trigger) { - trigger.addEventListener('click', () => { - dialog.showModal(); + if (loginTrigger) { + loginTrigger.addEventListener('click', () => { + loginDialog.showModal(); }); } - closeBtn.addEventListener('click', () => { - dialog.close(); - }); + if (closeLoginBtn) { + closeLoginBtn.addEventListener('click', () => { + loginDialog.close(); + }); + } - dialog.addEventListener('click', (e) => { - const rect = dialog.getBoundingClientRect(); - const isInDialog = - rect.top <= e.clientY && e.clientY <= rect.top + rect.height && rect.left <= e.clientX && e.clientX <= rect.left + rect.width; - if (!isInDialog) { - dialog.close(); - } - }); + if (loginDialog) { + loginDialog.addEventListener('click', (e) => { + const rect = loginDialog.getBoundingClientRect(); + const isInDialog = + rect.top <= e.clientY && e.clientY <= rect.top + rect.height && rect.left <= e.clientX && e.clientX <= rect.left + rect.width; + if (!isInDialog) { + loginDialog.close(); + } + }); + } + + // Account Switcher Logic + const switchTrigger = this.shadowRoot.getElementById('switch-account-trigger'); + const accountDialog = this.shadowRoot.getElementById('account-dialog'); + const closeAccountBtn = this.shadowRoot.getElementById('close-account-dialog'); + + if (switchTrigger && accountDialog) { + switchTrigger.addEventListener('click', () => { + accountDialog.showModal(); + }); + + if (closeAccountBtn) { + closeAccountBtn.addEventListener('click', () => { + accountDialog.close(); + }); + } + + accountDialog.addEventListener('click', (e) => { + const rect = accountDialog.getBoundingClientRect(); + const isInDialog = + rect.top <= e.clientY && e.clientY <= rect.top + rect.height && rect.left <= e.clientX && e.clientX <= rect.left + rect.width; + if (!isInDialog) { + accountDialog.close(); + } + }); + + const accountItems = this.shadowRoot.querySelectorAll('.account-item'); + accountItems.forEach(item => { + item.addEventListener('click', () => { + const accountId = item.getAttribute('data-id'); + this.switchAccount(accountId); + }); + }); + } } } -customElements.define('power-strip', PowerStrip); +customElements.define('power-strip', PowerStrip); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5ba119a..c2d4919 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,14 @@ export default { return handleMeImage(request, env, 'provider-icon'); } + if (url.pathname === usersPath + 'me/accounts') { + return handleMyAccounts(request, env); + } + + if (url.pathname === usersPath + 'me/accounts/switch' && request.method === 'POST') { + return handleSwitchAccount(request, env); + } + if (url.pathname === usersPath + 'logout') { return handleLogout(request, env, usersPath); } @@ -193,3 +201,95 @@ function parseCookies(cookieHeader: string): Record { {} as Record, ); } + +async function handleMyAccounts(request: Request, env: StartupAPIEnv): Promise { + const cookieHeader = request.headers.get('Cookie'); + if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); + + const cookies = parseCookies(cookieHeader); + const sessionCookie = cookies['session_id']; + + if (!sessionCookie || !sessionCookie.includes(':')) { + return new Response('Unauthorized', { status: 401 }); + } + + const [sessionId, doId] = sessionCookie.split(':'); + + try { + const id = env.USER.idFromString(doId); + const userStub = env.USER.get(id); + const validateRes = await userStub.fetch('http://do/validate-session', { + method: 'POST', + body: JSON.stringify({ sessionId }), + }); + + if (!validateRes.ok) return validateRes; + + // Fetch memberships + const membershipsRes = await userStub.fetch('http://do/memberships'); + const memberships = (await membershipsRes.json()) as any[]; + + const accounts = await Promise.all( + memberships.map(async (m) => { + const accountId = env.ACCOUNT.idFromString(m.account_id); + const accountStub = env.ACCOUNT.get(accountId); + const infoRes = await accountStub.fetch('http://do/info'); + let info = {}; + if (infoRes.ok) { + info = await infoRes.json(); + } + return { + ...m, + ...info, + }; + }), + ); + + return Response.json(accounts); + } catch (e) { + return new Response('Unauthorized', { status: 401 }); + } +} + +async function handleSwitchAccount(request: Request, env: StartupAPIEnv): Promise { + const cookieHeader = request.headers.get('Cookie'); + if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); + + const cookies = parseCookies(cookieHeader); + const sessionCookie = cookies['session_id']; + + if (!sessionCookie || !sessionCookie.includes(':')) { + return new Response('Unauthorized', { status: 401 }); + } + + const [sessionId, doId] = sessionCookie.split(':'); + const { account_id } = (await request.json()) as { account_id: string }; + + if (!account_id) { + return new Response('Missing account_id', { status: 400 }); + } + + try { + const id = env.USER.idFromString(doId); + const userStub = env.USER.get(id); + const validateRes = await userStub.fetch('http://do/validate-session', { + method: 'POST', + body: JSON.stringify({ sessionId }), + }); + + if (!validateRes.ok) return validateRes; + + const switchRes = await userStub.fetch('http://do/switch-account', { + method: 'POST', + body: JSON.stringify({ account_id }), + }); + + if (!switchRes.ok) { + return switchRes; + } + + return Response.json({ success: true }); + } catch (e) { + return new Response('Unauthorized', { status: 401 }); + } +} diff --git a/test/account_switching.spec.ts b/test/account_switching.spec.ts new file mode 100644 index 0000000..015f62b --- /dev/null +++ b/test/account_switching.spec.ts @@ -0,0 +1,99 @@ +import { env, SELF } from 'cloudflare:test'; +import { describe, it, expect } from 'vitest'; + +describe('Account Switching Integration', () => { + it('should list accounts and switch between them', async () => { + // 1. Setup User and Session + const userId = env.USER.newUniqueId(); + const userStub = env.USER.get(userId); + const userIdStr = userId.toString(); + + const sessionRes = await userStub.fetch('http://do/sessions', { method: 'POST' }); + const { sessionId } = (await sessionRes.json()) as any; + const cookieHeader = `session_id=${sessionId}:${userIdStr}`; + + // 2. Setup Accounts + // Account 1 (Personal) + const acc1Id = env.ACCOUNT.newUniqueId(); + const acc1Stub = env.ACCOUNT.get(acc1Id); + const acc1IdStr = acc1Id.toString(); + + await acc1Stub.fetch('http://do/info', { + method: 'POST', + body: JSON.stringify({ name: 'Personal Account', personal: true }), + }); + // Add user to Account 1 + await acc1Stub.fetch('http://do/members', { + method: 'POST', + body: JSON.stringify({ user_id: userIdStr, role: 1 }), + }); + // Add membership to User (Current) + await userStub.fetch('http://do/memberships', { + method: 'POST', + body: JSON.stringify({ account_id: acc1IdStr, role: 1, is_current: true }), + }); + + // Account 2 (Team) + const acc2Id = env.ACCOUNT.newUniqueId(); + const acc2Stub = env.ACCOUNT.get(acc2Id); + const acc2IdStr = acc2Id.toString(); + + await acc2Stub.fetch('http://do/info', { + method: 'POST', + body: JSON.stringify({ name: 'Team Account', personal: false }), + }); + // Add user to Account 2 + await acc2Stub.fetch('http://do/members', { + method: 'POST', + body: JSON.stringify({ user_id: userIdStr, role: 0 }), + }); + // Add membership to User (Not Current) + await userStub.fetch('http://do/memberships', { + method: 'POST', + body: JSON.stringify({ account_id: acc2IdStr, role: 0, is_current: false }), + }); + + // 3. Test GET /users/me/accounts + const listRes = await SELF.fetch('http://example.com/users/me/accounts', { + headers: { Cookie: cookieHeader }, + }); + expect(listRes.status).toBe(200); + const accounts = (await listRes.json()) as any[]; + expect(accounts.length).toBe(2); + const acc1 = accounts.find((a) => a.account_id === acc1IdStr); + const acc2 = accounts.find((a) => a.account_id === acc2IdStr); + + expect(acc1.name).toBe('Personal Account'); + expect(acc1.is_current).toBe(1); // SQLite boolean as integer + expect(acc2.name).toBe('Team Account'); + expect(acc2.is_current).toBe(0); + + // 4. Test Switch to Account 2 + const switchRes = await SELF.fetch('http://example.com/users/me/accounts/switch', { + method: 'POST', + headers: { Cookie: cookieHeader }, + body: JSON.stringify({ account_id: acc2IdStr }), + }); + expect(switchRes.status).toBe(200); + + // 5. Verify Switch via /users/me + const meRes = await SELF.fetch('http://example.com/users/me', { + headers: { Cookie: cookieHeader }, + }); + expect(meRes.status).toBe(200); + const meData = (await meRes.json()) as any; + expect(meData.account.id).toBe(acc2IdStr); + expect(meData.account.name).toBe('Team Account'); + + // 6. Verify List reflects change + const listRes2 = await SELF.fetch('http://example.com/users/me/accounts', { + headers: { Cookie: cookieHeader }, + }); + const accounts2 = (await listRes2.json()) as any[]; + const acc1_after = accounts2.find((a) => a.account_id === acc1IdStr); + const acc2_after = accounts2.find((a) => a.account_id === acc2IdStr); + + expect(acc1_after.is_current).toBe(0); + expect(acc2_after.is_current).toBe(1); + }); +});