diff --git a/public/users/accounts.html b/public/users/accounts.html index 2acb4a3..75f076e 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -6,9 +6,9 @@ Account Settings - + @@ -18,9 +18,9 @@
← Back to Home

Account Settings

-
Account
+
{{ssr:account_name}}
- + ID: {{ssr:account_id}}
-

Loading...

-

+

{{ssr:account_name}}

+

{{ssr:account_plan_name}}

@@ -113,21 +101,19 @@

General Information

- +
- +
-
+

Team Members

-
-

Loading members...

-
+
{{ssr:account_members_list_html}}
@@ -140,18 +126,47 @@

Team Members

let currentUserRole = 0; let currentUserId = null; let initialAccountInfo = {}; + let isInitialLoad = true; async function init() { try { - const res = await fetch(`${API_BASE}/me`); - if (!res.ok) { - if (res.status === 401) { - window.location.href = '/'; - return; + // Try to get data from SSR first + let data = null; + let members = null; + const ssrProfileJson = document.body.getAttribute('data-ssr-profile'); + const ssrAccountJson = document.body.getAttribute('data-ssr-account'); + const ssrMembersJson = document.body.getAttribute('data-ssr-members'); + + if (ssrProfileJson && !ssrProfileJson.startsWith('{{ssr:') && ssrAccountJson && !ssrAccountJson.startsWith('{{ssr:')) { + try { + const profile = JSON.parse(ssrProfileJson); + const account = JSON.parse(ssrAccountJson); + if (ssrMembersJson && !ssrMembersJson.startsWith('{{ssr:')) { + members = JSON.parse(ssrMembersJson); + } + if (profile.valid) { + data = { + ...profile, + account: account, + }; + } + } catch (e) { + console.error('Failed to parse SSR data', e); } - throw new Error('Failed to load user info'); } - const data = await res.json(); + + if (!data) { + const res = await fetch(`${API_BASE}/me`); + if (!res.ok) { + if (res.status === 401) { + window.location.href = '/'; + return; + } + throw new Error('Failed to load user info'); + } + data = await res.json(); + } + if (data.valid && data.account) { currentAccountId = data.account.id; currentUserRole = data.account.role; @@ -186,10 +201,11 @@

Team Members

section.appendChild(msg); document.querySelector('.content-area').appendChild(section); } else { - loadMembers(); + loadMembers(members); } - loadAccountDetails(); + loadAccountDetails(data.account); + isInitialLoad = false; } } catch (e) { showToast(e.message); @@ -297,11 +313,17 @@

Team Members

} }; - async function loadAccountDetails() { + async function loadAccountDetails(ssrData) { try { - const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}`); - if (res.ok) { - const data = await res.json(); + let data = ssrData; + if (!data) { + const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}`); + if (res.ok) { + data = await res.json(); + } + } + + if (data) { if (data.name) { document.getElementById('account-name').value = data.name; document.getElementById('display-account-name').textContent = data.name; @@ -334,12 +356,26 @@

Team Members

} } - async function loadMembers() { + async function loadMembers(ssrData) { const list = document.getElementById('members-list'); try { - const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/members`); - if (!res.ok) throw new Error('Failed to load members'); - const members = await res.json(); + let members = ssrData; + if (!members && isInitialLoad) { + const ssrMembersJson = document.body.getAttribute('data-ssr-members'); + if (ssrMembersJson && !ssrMembersJson.startsWith('{{ssr:')) { + try { + members = JSON.parse(ssrMembersJson); + } catch (e) { + console.error('Failed to parse SSR members', e); + } + } + } + + if (!members) { + const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/members`); + if (!res.ok) throw new Error('Failed to load members'); + members = await res.json(); + } if (members.length === 0) { list.innerHTML = '

No members found.

'; diff --git a/public/users/admin/index.html b/public/users/admin/index.html index 00b2941..54c83d7 100644 --- a/public/users/admin/index.html +++ b/public/users/admin/index.html @@ -122,7 +122,7 @@ diff --git a/public/users/profile.html b/public/users/profile.html index 33a6894..2d01c4a 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -6,9 +6,9 @@ User Profile - + @@ -18,9 +18,9 @@
← Back to Home

User Profile

-
Loading...
+
{{ssr:profile_name}}
- + ID: {{ssr:profile_id}}
-

+

{{ssr:profile_email}}

- +
- - + +
@@ -132,44 +127,10 @@

User Profile

Login Credentials

Manage the login methods linked to your account.

-
- -

Loading credentials...

-
+
{{ssr:credentials_list_html}}

Link another account

-
- - -
+
{{ssr:link_credentials_html}}
@@ -180,18 +141,35 @@

Link another account

const API_BASE = '/users/api'; let currentProvider = null; let initialProfile = {}; + let isInitialLoad = true; async function loadProfile() { try { - const res = await fetch(`${API_BASE}/me`); - if (!res.ok) { - if (res.status === 401) { - window.location.href = '/'; - return; + // Try to get data from SSR first + let data = null; + if (isInitialLoad) { + const ssrProfileJson = document.body.getAttribute('data-ssr-profile'); + if (ssrProfileJson && !ssrProfileJson.startsWith('{{ssr:')) { + try { + data = JSON.parse(ssrProfileJson); + } catch (e) { + console.error('Failed to parse SSR profile', e); + } } - throw new Error('Failed to load profile'); } - const data = await res.json(); + + if (!data) { + const res = await fetch(`${API_BASE}/me`); + if (!res.ok) { + if (res.status === 401) { + window.location.href = '/'; + return; + } + throw new Error('Failed to load profile'); + } + data = await res.json(); + } + if (data.valid && data.profile) { const p = data.profile; initialProfile = { name: p.name || '' }; @@ -238,7 +216,10 @@

Link another account

document.getElementById('nav-account-item').style.display = 'block'; } + loadCredentials(data.credentials); + document.getElementById('save-btn').disabled = true; + isInitialLoad = false; } } catch (e) { showToast(e.message); @@ -360,12 +341,28 @@

Link another account

setTimeout(() => (toast.style.opacity = 0), 3000); } - async function loadCredentials() { + async function loadCredentials(passedCredentials) { const list = document.getElementById('credentials-list'); try { - const res = await fetch(`${API_BASE}/me/credentials`); - if (!res.ok) throw new Error('Failed to load credentials'); - const credentials = await res.json(); + // Try to get data from passed credentials or SSR first + let credentials = passedCredentials; + + if (!credentials && isInitialLoad) { + const ssrCredentialsJson = document.body.getAttribute('data-ssr-credentials'); + if (ssrCredentialsJson && !ssrCredentialsJson.startsWith('{{ssr:')) { + try { + credentials = JSON.parse(ssrCredentialsJson); + } catch (e) { + console.error('Failed to parse SSR credentials', e); + } + } + } + + if (!credentials) { + const res = await fetch(`${API_BASE}/me/credentials`); + if (!res.ok) throw new Error('Failed to load credentials'); + credentials = await res.json(); + } if (credentials.length === 0) { list.innerHTML = '

No credentials linked.

'; @@ -440,7 +437,7 @@

Link another account

} loadProfile(); - loadCredentials(); + // loadCredentials(); // Handled within loadProfile if data exists diff --git a/public/users/style.css b/public/users/style.css index 056d40b..13eff87 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -244,6 +244,33 @@ button:disabled { background: #f8f9fa; } +.remove-image-btn { + position: absolute; + top: -5px; + right: -5px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #bdc1c6; + color: white; + border: 2px solid white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + line-height: 1; + font-weight: bold; + font-size: 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: background-color 0.2s; + z-index: 10; +} + +.remove-image-btn:hover { + background: #ea4335; +} + /* Profile specific */ .avatar-section { display: flex; @@ -309,6 +336,10 @@ button:disabled { justify-content: center; } +.twitch-icon { + color: #9146ff; +} + .link-account-btn { display: flex; align-items: center; @@ -333,6 +364,10 @@ button:disabled { border-color: #9146ff; } +.link-account-btn.twitch .twitch-icon { + color: white; +} + .link-account-btn.twitch:hover { background: #7d2ee6; } diff --git a/src/index.ts b/src/index.ts index a0de567..5f43798 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,18 @@ export default { const cookieManager = new CookieManager(env.SESSION_SECRET); + // SSR Routes + const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/'; + if (url.pathname.startsWith(usersPathNormalized)) { + const subPath = url.pathname.slice(usersPathNormalized.length); + const isProfile = subPath === 'profile.html' || subPath === 'profile'; + const isAccounts = subPath === 'accounts.html' || subPath === 'accounts'; + + if (isProfile || isAccounts) { + return handleSSR(request, env, url, usersPath, cookieManager); + } + } + // Handle OAuth Routes if (url.pathname.startsWith(usersPath + 'auth/')) { return handleAuth(request, env, url, usersPath, cookieManager); @@ -138,14 +150,7 @@ export default { newRequest.headers.set('Host', url.host); const response = await fetch(newRequest); - - const providers: string[] = []; - if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { - providers.push('google'); - } - if (env.TWITCH_CLIENT_ID && env.TWITCH_CLIENT_SECRET) { - providers.push('twitch'); - } + const providers = getActiveProviders(env); return injectPowerStrip(response, usersPath, providers); } @@ -153,7 +158,18 @@ export default { // do not modify the request as it will loop through the same worker again return env.ASSETS.fetch(request); }, -} satisfies ExportedHandler; +} satisfies ExportedHandler; + +function getActiveProviders(env: StartupAPIEnv): string[] { + const providers: string[] = []; + if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { + providers.push('google'); + } + if (env.TWITCH_CLIENT_ID && env.TWITCH_CLIENT_SECRET) { + providers.push('twitch'); + } + return providers; +} async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: string, cookieManager: CookieManager): Promise { const user = await getUserFromSession(request, env, cookieManager); @@ -168,7 +184,17 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri url.pathname = '/users/admin/'; const newRequest = new Request(url.toString(), request); newRequest.headers.set('x-skip-worker', 'true'); - return env.ASSETS.fetch(newRequest); + const response = await env.ASSETS.fetch(newRequest); + if (!response.ok) return response; + + let html = await response.text(); + html = renderSSR(html, { + providers: getActiveProviders(env).join(','), + }); + + return new Response(html, { + headers: { 'Content-Type': 'text/html' }, + }); } const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); @@ -214,33 +240,30 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri return Response.json(await accountStub.removeMember(parts[3])); } } - } else if (apiPath === 'impersonate' && request.method === 'POST') { - const { userId } = (await request.json()) as { userId: string }; + } else if (parts[0] === 'impersonate' && request.method === 'POST') { + const { user_id } = (await request.json()) as { user_id: string }; + if (!user_id) return new Response('Missing user_id', { status: 400 }); - if (user.id === userId) { + if (user_id === user.id) { return new Response('Cannot impersonate yourself', { status: 400 }); } - // Get current session to backup + const userDOId = env.USER.idFromString(user_id); + const userStub = env.USER.get(userDOId); + const session = await userStub.createSession({ provider: 'admin-impersonation', impersonator: user.id }); + const cookieHeader = request.headers.get('Cookie'); const cookies = parseCookies(cookieHeader || ''); const currentSessionEncrypted = cookies['session_id']; - // Create a session for the target user - const targetUserStub = env.USER.get(env.USER.idFromString(userId)); - const { sessionId } = await targetUserStub.createSession(); - - const doId = userId; - const sessionValue = `${sessionId}:${doId}`; - const encryptedSession = await cookieManager.encrypt(sessionValue); - const headers = new Headers(); - headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`); + const newSessionIdEncrypted = await cookieManager.encrypt(`${session.sessionId}:${user_id}`); + headers.set('Set-Cookie', `session_id=${newSessionIdEncrypted}; Path=/; HttpOnly; Secure; SameSite=Lax`); if (currentSessionEncrypted) { - const decryptedCurrentSession = await cookieManager.decrypt(currentSessionEncrypted); - if (decryptedCurrentSession) { - const encryptedBackup = await cookieManager.encrypt(decryptedCurrentSession); - headers.append('Set-Cookie', `backup_session_id=${encryptedBackup}; Path=/; HttpOnly; Secure; SameSite=Lax`); + const backupSession = await cookieManager.decrypt(currentSessionEncrypted); + if (backupSession) { + const backupSessionEncrypted = await cookieManager.encrypt(backupSession); + headers.append('Set-Cookie', `backup_session_id=${backupSessionEncrypted}; Path=/; HttpOnly; Secure; SameSite=Lax`); } } @@ -248,39 +271,75 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri } } - return new Response('Not Found', { status: 404 }); + url.pathname = '/users/admin' + path; + const newRequest = new Request(url.toString(), request); + newRequest.headers.set('x-skip-worker', 'true'); + return env.ASSETS.fetch(newRequest); } -async function getUserFromSession(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { - const cookieHeader = request.headers.get('Cookie'); - if (!cookieHeader) return null; - - const cookies = parseCookies(cookieHeader); - const sessionCookieEncrypted = cookies['session_id']; - - if (!sessionCookieEncrypted) return null; - - const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); - if (!sessionCookie || !sessionCookie.includes(':')) return null; +async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { + const user = await getUserFromSession(request, env, cookieManager); + if (!user) return new Response('Unauthorized', { status: 401 }); - const [sessionId, doId] = sessionCookie.split(':'); + const { id: doId, sessionId, profile: initialProfile, credential } = user; try { const id = env.USER.idFromString(doId); const userStub = env.USER.get(id); - const data = await userStub.validateSession(sessionId); - if (data.valid) { - return { - id: doId, - profile: data.profile, - credential: data.credential, + const data: any = { + valid: true, + profile: { ...initialProfile }, + credential, + }; + + const image = await userStub.getImage('avatar'); + if (image) { + const usersPath = env.USERS_PATH || DEFAULT_USERS_PATH; + data.profile.picture = usersPath + 'me/avatar'; + } else { + data.profile.picture = null; + } + + data.is_admin = isAdmin({ id: doId, profile: data.profile, credential }, env); + + const cookieHeader = request.headers.get('Cookie') || ''; + const cookies = parseCookies(cookieHeader); + data.is_impersonated = !!cookies['backup_session_id']; + + // Fetch credentials + data.credentials = await userStub.listCredentials(); + + // Fetch memberships to find current account + const memberships = await userStub.getMemberships(); + const currentMembership = memberships.find((m: any) => m.is_current) || memberships[0]; + + if (currentMembership) { + const accountId = env.ACCOUNT.idFromString(currentMembership.account_id); + const accountStub = env.ACCOUNT.get(accountId); + const accountInfo = await accountStub.getInfo(); + data.account = { + ...accountInfo, + id: currentMembership.account_id, + role: currentMembership.role, }; } + + return Response.json(data); } catch (e) { - return null; + return new Response('Unauthorized', { status: 401 }); } - return null; +} + +async function handleUpdateProfile(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { + const user = await getUserFromSession(request, env, cookieManager); + if (!user) return new Response('Unauthorized', { status: 401 }); + + const profileData = await request.json(); + const userStub = env.USER.get(env.USER.idFromString(user.id)); + await userStub.updateProfile(profileData); + + return Response.json({ success: true }); } function isAdmin(user: any, env: StartupAPIEnv): boolean { @@ -311,7 +370,7 @@ async function handleAccountMembers( request: Request, env: StartupAPIEnv, accountId: string, - extraParts: string[], + pathParts: string[], cookieManager: CookieManager, ): Promise { const user = await getUserFromSession(request, env, cookieManager); @@ -321,7 +380,7 @@ async function handleAccountMembers( const memberships = await userStub.getMemberships(); const membership = memberships.find((m: any) => m.account_id === accountId); - const isAccountAdmin = membership && membership.role === AccountDO.ROLE_ADMIN; + const isAccountAdmin = membership && (membership as any).role === AccountDO.ROLE_ADMIN; const isSysAdmin = isAdmin(user, env); if (!isAccountAdmin && !isSysAdmin) { @@ -330,7 +389,7 @@ async function handleAccountMembers( const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accountId)); - if (extraParts.length === 0) { + if (pathParts.length === 0) { if (request.method === 'GET') { return Response.json(await accountStub.getMembers()); } @@ -338,20 +397,20 @@ 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) { - const userIdToManage = extraParts[0]; + } else if (pathParts.length === 1) { + const targetUserId = pathParts[0]; if (request.method === 'DELETE') { - if (userIdToManage === user.id) { + if (targetUserId === user.id) { return new Response('Cannot remove yourself', { status: 400 }); } - return Response.json(await accountStub.removeMember(userIdToManage)); + return Response.json(await accountStub.removeMember(targetUserId)); } if (request.method === 'PATCH') { const { role } = (await request.json()) as { role: number }; - if (userIdToManage === user.id && role !== AccountDO.ROLE_ADMIN) { + if (targetUserId === user.id && role !== AccountDO.ROLE_ADMIN) { return new Response('Cannot demote yourself', { status: 400 }); } - return Response.json(await accountStub.updateMemberRole(userIdToManage, role)); + return Response.json(await accountStub.updateMemberRole(targetUserId, role)); } } @@ -371,7 +430,7 @@ async function handleAccountDetails( const memberships = await userStub.getMemberships(); const membership = memberships.find((m: any) => m.account_id === accountId); - const isAccountAdmin = membership && membership.role === AccountDO.ROLE_ADMIN; + const isAccountAdmin = membership && (membership as any).role === AccountDO.ROLE_ADMIN; const isSysAdmin = isAdmin(user, env); if (!isAccountAdmin && !isSysAdmin) { @@ -380,8 +439,14 @@ async function handleAccountDetails( const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accountId)); + if (request.method === 'GET') { + const info = await accountStub.getInfo(); + const billing = await accountStub.getBillingInfo(); + return Response.json({ ...info, billing, role: membership?.role }); + } + if (request.method === 'POST') { - const data = (await request.json()) as any; + const data = await request.json(); const result = await accountStub.updateInfo(data); // Sync with SystemDO index if name changed @@ -396,157 +461,46 @@ async function handleAccountDetails( 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, cookieManager: CookieManager): Promise { - const cookieHeader = request.headers.get('Cookie'); - if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); - - const cookies = parseCookies(cookieHeader); - const sessionCookieEncrypted = cookies['session_id']; - - if (!sessionCookieEncrypted) { - return new Response('Unauthorized', { status: 401 }); - } - - const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); - 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 data = await userStub.validateSession(sessionId); - - 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'; - } else { - profile.picture = null; - } - - data.profile = profile; - data.is_admin = isAdmin({ id: doId, ...data.profile }, env); - data.is_impersonated = !!cookies['backup_session_id']; - - // Fetch memberships to find current account - const memberships = await userStub.getMemberships(); - const currentMembership = memberships.find((m: any) => m.is_current) || memberships[0]; - - if (currentMembership) { - const accountId = env.ACCOUNT.idFromString(currentMembership.account_id); - const accountStub = env.ACCOUNT.get(accountId); - const accountInfo = await accountStub.getInfo(); - data.account = { - ...accountInfo, - id: currentMembership.account_id, - role: currentMembership.role, - }; - } - - return Response.json(data); - } catch (e) { - return new Response('Unauthorized', { status: 401 }); - } + return new Response('Method Not Allowed', { status: 405 }); } -async function handleUpdateProfile(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { +async function getUserFromSession(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { const cookieHeader = request.headers.get('Cookie'); - if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); + if (!cookieHeader) return null; const cookies = parseCookies(cookieHeader); const sessionCookieEncrypted = cookies['session_id']; - - if (!sessionCookieEncrypted) { - return new Response('Unauthorized', { status: 401 }); - } + if (!sessionCookieEncrypted) return null; const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); - if (!sessionCookie || !sessionCookie.includes(':')) { - return new Response('Unauthorized', { status: 401 }); - } + if (!sessionCookie || !sessionCookie.includes(':')) return null; const [sessionId, doId] = sessionCookie.split(':'); - try { const id = env.USER.idFromString(doId); const userStub = env.USER.get(id); const data = await userStub.validateSession(sessionId); - - if (!data.valid) return Response.json(data, { status: 401 }); - - const profileData = (await request.json()) as any; - return Response.json(await userStub.updateProfile(profileData)); - } catch (e) { - return new Response('Unauthorized', { status: 401 }); - } + if (data.valid) return { id: doId, sessionId, profile: data.profile, credential: data.credential }; + } catch (e) {} + return null; } async function handleListCredentials(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { - const cookieHeader = request.headers.get('Cookie'); - if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); - - const cookies = parseCookies(cookieHeader); - const sessionCookieEncrypted = cookies['session_id']; - - if (!sessionCookieEncrypted) { - return new Response('Unauthorized', { status: 401 }); - } - - const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); - if (!sessionCookie || !sessionCookie.includes(':')) { - return new Response('Unauthorized', { status: 401 }); - } - - const [, doId] = sessionCookie.split(':'); + const user = await getUserFromSession(request, env, cookieManager); + if (!user) return new Response('Unauthorized', { status: 401 }); - try { - const id = env.USER.idFromString(doId); - const userStub = env.USER.get(id); - return Response.json(await userStub.listCredentials()); - } catch (e) { - return new Response('Unauthorized', { status: 401 }); - } + const userStub = env.USER.get(env.USER.idFromString(user.id)); + return Response.json(await userStub.listCredentials()); } async function handleDeleteCredential(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { - const cookieHeader = request.headers.get('Cookie'); - if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); - - const cookies = parseCookies(cookieHeader); - const sessionCookieEncrypted = cookies['session_id']; - - if (!sessionCookieEncrypted) { - return new Response('Unauthorized', { status: 401 }); - } - - const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); - if (!sessionCookie || !sessionCookie.includes(':')) { - return new Response('Unauthorized', { status: 401 }); - } + const user = await getUserFromSession(request, env, cookieManager); + if (!user) return new Response('Unauthorized', { status: 401 }); - const [, doId] = sessionCookie.split(':'); + const { provider } = (await request.json()) as { provider: string }; + const userStub = env.USER.get(env.USER.idFromString(user.id)); try { - const id = env.USER.idFromString(doId); - const userStub = env.USER.get(id); - const { provider } = (await request.json()) as any; return Response.json(await userStub.deleteCredential(provider)); } catch (e: any) { return new Response(e.message, { status: 400 }); @@ -755,13 +709,18 @@ async function handleMyAccounts(request: Request, env: StartupAPIEnv, cookieMana const accounts = await Promise.all( memberships.map(async (m: any) => { - const accountId = env.ACCOUNT.idFromString(m.account_id); - const accountStub = env.ACCOUNT.get(accountId); - const info = await accountStub.getInfo(); - return { - ...info, - ...m, - }; + try { + const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(m.account_id)); + const info = await accountStub.getInfo(); + return { + account_id: m.account_id, + name: info.name || 'Unknown Account', + role: m.role, + is_current: m.is_current, + }; + } catch (e) { + return { account_id: m.account_id, name: 'Unknown Account', role: m.role, is_current: m.is_current }; + } }), ); @@ -806,3 +765,271 @@ async function handleSwitchAccount(request: Request, env: StartupAPIEnv, cookieM return new Response(e.message, { status: 400 }); } } + +async function handleSSR( + request: Request, + env: StartupAPIEnv, + url: URL, + usersPath: string, + cookieManager: CookieManager, +): Promise { + const cookieHeader = request.headers.get('Cookie'); + const cookies = parseCookies(cookieHeader || ''); + const sessionCookieEncrypted = cookies['session_id']; + + if (!sessionCookieEncrypted) { + return Response.redirect(url.origin + '/', 302); + } + + const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); + if (!sessionCookie || !sessionCookie.includes(':')) { + return Response.redirect(url.origin + '/', 302); + } + + const [sessionId, doId] = sessionCookie.split(':'); + + try { + const id = env.USER.idFromString(doId); + const userStub = env.USER.get(id); + const data = await userStub.validateSession(sessionId); + + if (!data.valid) { + return Response.redirect(url.origin + '/', 302); + } + + // Get HTML from assets + const assetUrl = new URL(url.toString()); + assetUrl.pathname = url.pathname.replace(usersPath, '/users/'); + const assetRequest = new Request(assetUrl.toString(), request); + assetRequest.headers.set('x-skip-worker', 'true'); + let assetResponse = await env.ASSETS.fetch(assetRequest); + + // Follow one level of redirect if needed (e.g. for canonical URLs) + if (assetResponse.status === 301 || assetResponse.status === 302) { + const location = assetResponse.headers.get('Location'); + if (location) { + const followUrl = new URL(location, assetUrl.toString()); + const followRequest = new Request(followUrl.toString(), request); + followRequest.headers.set('x-skip-worker', 'true'); + assetResponse = await env.ASSETS.fetch(followRequest); + } + } + + if (!assetResponse.ok) { + return assetResponse; + } + + let html = await assetResponse.text(); + + const profile = { ...data.profile }; + const image = await userStub.getImage('avatar'); + if (image) { + const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/'; + profile.picture = usersPathNormalized + 'me/avatar'; + } else { + profile.picture = null; + } + + data.profile = profile; + data.is_admin = isAdmin({ id: doId, ...data.profile }, env); + + // Fetch memberships to find current account + const memberships = await userStub.getMemberships(); + const currentMembership = memberships.find((m: any) => m.is_current) || memberships[0]; + + // Fetch credentials + const credentials = await userStub.listCredentials(); + + let account = null; + let accountMembers = null; + if (currentMembership) { + const accountId = env.ACCOUNT.idFromString(currentMembership.account_id); + const accountStub = env.ACCOUNT.get(accountId); + const accountInfo = await accountStub.getInfo(); + const billing = await accountStub.getBillingInfo(); + account = { + ...accountInfo, + billing, + id: currentMembership.account_id, + role: currentMembership.role, + }; + // Fetch members only if it's the accounts page or if needed + if (url.pathname.endsWith('/accounts.html') || url.pathname.endsWith('/accounts')) { + accountMembers = await accountStub.getMembers(); + } + } + + // Prepare SSR values + const replacements: Record = { + providers: getActiveProviders(env).join(','), + profile_json: JSON.stringify(data).replace(/"/g, '"'), + credentials_json: JSON.stringify(credentials).replace(/"/g, '"'), + profile_name: profile.name || 'Anonymous', + profile_id: doId, + profile_email: profile.email || '', + profile_picture: profile.picture || '', + profile_picture_display: profile.picture ? 'display: block;' : 'display: none;', + profile_placeholder_display: profile.picture ? 'display: none;' : 'display: flex;', + profile_remove_btn_display: profile.picture ? 'display: flex;' : 'display: none;', + profile_provider_label: profile.provider ? `(from ${profile.provider.charAt(0).toUpperCase() + profile.provider.slice(1)})` : '', + nav_account_display: account && (account.role === 1 || data.is_admin) ? 'display: block;' : 'display: none;', + credentials_list_html: renderCredentialsList(credentials, data.credential?.provider), + link_credentials_html: renderLinkCredentialsList(getActiveProviders(env)), + }; + + if (account) { + replacements['account_json'] = JSON.stringify(account).replace(/"/g, '"'); + replacements['account_name'] = account.name || 'Account'; + replacements['account_id'] = account.id; + replacements['account_plan_name'] = account.billing?.plan_details?.name || account.billing?.state?.plan_slug || 'free'; + + const accountAvatar = await env.ACCOUNT.get(env.ACCOUNT.idFromString(account.id)).getImage('avatar'); + const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/'; + const accountPicture = accountAvatar ? `${usersPathNormalized}api/me/accounts/${account.id}/avatar` : null; + + replacements['account_picture'] = accountPicture || ''; + replacements['account_picture_display'] = accountPicture ? 'display: block;' : 'display: none;'; + replacements['account_placeholder_display'] = accountPicture ? 'display: none;' : 'display: flex;'; + replacements['account_remove_btn_display'] = accountPicture ? 'display: flex;' : 'display: none;'; + + const isAccountAdmin = account.role === 1 || data.is_admin; + replacements['account_info_section_display'] = isAccountAdmin ? 'display: block;' : 'display: none;'; + replacements['account_members_section_display'] = isAccountAdmin ? 'display: block;' : 'display: none;'; + + if (accountMembers) { + replacements['account_members_json'] = JSON.stringify(accountMembers).replace(/"/g, '"'); + replacements['account_members_list_html'] = renderAccountMembersList(accountMembers, doId); + } else { + replacements['account_members_json'] = '[]'; + replacements['account_members_list_html'] = '

Loading members...

'; + } + } else { + replacements['account_json'] = 'null'; + replacements['account_name'] = ''; + replacements['account_id'] = ''; + replacements['account_plan_name'] = ''; + replacements['account_picture'] = ''; + replacements['account_picture_display'] = 'display: none;'; + replacements['account_placeholder_display'] = 'display: flex;'; + replacements['account_remove_btn_display'] = 'display: none;'; + replacements['account_info_section_display'] = 'display: none;'; + replacements['account_members_section_display'] = 'display: none;'; + replacements['account_members_json'] = '[]'; + replacements['account_members_list_html'] = ''; + } + + html = renderSSR(html, replacements); + + return new Response(html, { + headers: { + 'Content-Type': 'text/html', + }, + }); + } catch (e: any) { + console.error('[handleSSR] Error:', e.message, e.stack); + return new Response('Error rendering page: ' + e.message, { status: 500 }); + } +} + +function renderSSR(html: string, replacements: Record): string { + return html.replace(/\{\{ssr:([a-z0-9_]+)\}\}/g, (match, key) => { + return replacements[key] !== undefined ? replacements[key] : match; + }); +} + +function renderCredentialsList(credentials: any[], currentProvider?: string): string { + if (!credentials || credentials.length === 0) { + return '

No credentials linked.

'; + } + + return credentials + .map((c) => { + const isCurrent = c.provider === currentProvider; + return ` +
+
+
+ ${getProviderIcon(c.provider)} +
+
+
+ ${c.provider.charAt(0).toUpperCase() + c.provider.slice(1)} + ${isCurrent ? 'logged in' : ''} +
+
${c.profile_data?.email || c.subject_id}
+
+
+ +
+ `; + }) + .join(''); +} + +function getProviderIcon(provider: string): string { + if (provider === 'google') { + return ''; + } else if (provider === 'twitch') { + return ''; + } + return ''; +} + +function renderLinkCredentialsList(providers: string[]): string { + if (providers.length === 0) { + return ''; + } + + return providers + .map((provider) => { + return ` + + `; + }) + .join(''); +} + +function renderAccountMembersList(members: any[], currentUserId: string): string { + if (!members || members.length === 0) { + return '

No members found.

'; + } + + return members + .map((m) => { + const isSelf = m.user_id === currentUserId; + const avatarContent = m.picture + ? `${m.name}` + : `
+ + + + +
`; + + return ` +
+
+ ${avatarContent} +
+
${m.name} ${isSelf ? '(You)' : ''}
+
+ +
+
+
+ +
+ `; + }) + .join(''); +} diff --git a/test/integration.spec.ts b/test/integration.spec.ts index e817db5..43c445f 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -14,6 +14,7 @@ describe('Integration Tests', () => { // 1. Manually set up a UserDO with a session const id = env.USER.newUniqueId(); const stub = env.USER.get(id); + const userIdStr = id.toString(); // Create session const { sessionId } = await stub.createSession(); @@ -53,6 +54,10 @@ describe('Integration Tests', () => { const stub = env.USER.get(id); const doId = id.toString(); + // Register user in SystemDO index + const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); + await systemStub.registerUser({ id: doId, name: 'Original Name' }); + // Create session const { sessionId } = await stub.createSession(); @@ -157,6 +162,7 @@ describe('Integration Tests', () => { it('should serve avatar image from /me/avatar', async () => { const id = env.USER.newUniqueId(); const stub = env.USER.get(id); + const userIdStr = id.toString(); // Create session const { sessionId } = await stub.createSession(); @@ -215,6 +221,10 @@ describe('Integration Tests', () => { const userStub = env.USER.get(id); const userIdStr = id.toString(); + // Register user in SystemDO index + const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); + await systemStub.registerUser({ id: userIdStr, name: 'Integration Tester' }); + // Store initial avatar const initialAvatar = new Uint8Array([1, 1, 1, 1]); await userStub.storeImage('avatar', initialAvatar.buffer, 'image/png'); @@ -260,4 +270,87 @@ describe('Integration Tests', () => { expect(storedImage.mime_type).toBe('image/png'); expect(new Uint8Array(storedImage.value)).toEqual(initialAvatar); }); + + it('should server-side render profile.html', async () => { + const id = env.USER.newUniqueId(); + const stub = env.USER.get(id); + const userIdStr = id.toString(); + + // Create session + const { sessionId } = await stub.createSession(); + await stub.updateProfile({ name: 'SSR Tester' }); + + // Add credentials + const googleCredStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName('google')); + await googleCredStub.put({ + user_id: userIdStr, + provider: 'google', + subject_id: 'google-123', + profile_data: { email: 'google@example.com' }, + }); + await stub.addCredential('google', 'google-123'); + + const twitchCredStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName('twitch')); + await twitchCredStub.put({ + user_id: userIdStr, + provider: 'twitch', + subject_id: 'twitch-456', + profile_data: { email: 'twitch@example.com' }, + }); + await stub.addCredential('twitch', 'twitch-456'); + + const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${userIdStr}`); + + const res = await SELF.fetch('http://example.com/users/profile.html', { + headers: { + Cookie: `session_id=${encryptedCookie}`, + }, + }); + + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('SSR Tester'); + expect(html).toContain('google'); + expect(html).toContain('twitch'); + expect(html).toContain('google@example.com'); + expect(html).toContain('twitch@example.com'); + expect(html).toContain('providers="google,twitch"'); + expect(html).not.toContain('{{ssr:profile_name}}'); + }); + + it('should render correct providers in "Link another account" section', async () => { + const id = env.USER.newUniqueId(); + const stub = env.USER.get(id); + const userIdStr = id.toString(); + + const { sessionId } = await stub.createSession(); + const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${userIdStr}`); + + const res = await SELF.fetch('http://example.com/users/profile.html', { + headers: { + Cookie: `session_id=${encryptedCookie}`, + }, + }); + + expect(res.status).toBe(200); + const html = await res.text(); + + // Check that configured providers are present + expect(html).toContain('link-account-btn google'); + expect(html).toContain('link-account-btn twitch'); + + // Check that some non-existent provider is NOT present + expect(html).not.toContain('link-account-btn github'); + }); + + it('should inject correct providers into login overlay via PowerStrip', async () => { + // When proxying to origin (example.com), it should inject the power-strip + const res = await SELF.fetch('http://example.com/'); + + expect(res.status).toBe(200); + const html = await res.text(); + + // Check that power-strip element was injected with correct providers + expect(html).toContain('