From c7abcd18175c62de00811cebf3c25762e3634c48 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sat, 21 Feb 2026 23:12:00 -0500 Subject: [PATCH 01/15] feat: implement server-side rendering for profile and account pages --- public/users/accounts.html | 88 ++++++++++++++++------ public/users/profile.html | 63 +++++++++++----- src/index.ts | 146 +++++++++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 42 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index 2acb4a3..cb78a76 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -6,7 +6,7 @@ Account Settings - + ← Back to Home

Account Settings

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

Loading...

-

+

{{ssr:account_name}}

+

{{ssr:account_plan_name}}

@@ -113,17 +119,17 @@

General Information

- +
- +
-
+

Team Members

Loading members...

@@ -143,15 +149,43 @@

Team Members

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; + const ssrProfileJson = document.body.getAttribute('data-ssr-profile'); + const ssrAccountJson = document.body.getAttribute('data-ssr-account'); + + if ( + ssrProfileJson && + !ssrProfileJson.startsWith('{{ssr:') && + ssrAccountJson && + !ssrAccountJson.startsWith('{{ssr:') + ) { + try { + const profile = JSON.parse(ssrProfileJson); + const account = JSON.parse(ssrAccountJson); + 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; @@ -189,7 +223,7 @@

Team Members

loadMembers(); } - loadAccountDetails(); + loadAccountDetails(data.account); } } catch (e) { showToast(e.message); @@ -297,11 +331,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; diff --git a/public/users/profile.html b/public/users/profile.html index 33a6894..0ad2fdc 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -6,7 +6,7 @@ User Profile - + ← Back to Home

User Profile

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

+

{{ssr:profile_email}}

- +
- - + +
@@ -183,15 +196,29 @@

Link another account

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; + 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 || '' }; diff --git a/src/index.ts b/src/index.ts index a0de567..ddb4508 100644 --- a/src/index.ts +++ b/src/index.ts @@ -122,6 +122,13 @@ export default { // Intercept requests to usersPath and serve them from the public/users directory. if (url.pathname.startsWith(usersPath)) { + const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/'; + const requestPath = url.pathname.replace(usersPathNormalized, ''); + + if (requestPath === 'profile.html' || requestPath === 'accounts.html') { + return handleSSR(request, env, url, usersPath, cookieManager); + } + url.pathname = url.pathname.replace(usersPath, '/users/'); const newRequest = new Request(url.toString(), request); newRequest.headers.set('x-skip-worker', 'true'); @@ -806,3 +813,142 @@ 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'); + const assetResponse = await env.ASSETS.fetch(assetRequest); + + if (!assetResponse.ok) { + return assetResponse; + } + + let html = await assetResponse.text(); + + const profile = { ...data.profile }; + const image = await userStub.getImage('avatar'); + if (image) { + profile.picture = usersPath + '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]; + + let account = 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, + }; + } + + // Prepare SSR values + const replacements: Record = { + profile_json: JSON.stringify(data).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;', + }; + + 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 accountPicture = accountAvatar ? `${usersPath}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;'; + } 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;'; + } + + 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 { + let rendered = html; + for (const [key, value] of Object.entries(replacements)) { + const placeholder = `{{ssr:${key}}}`; + rendered = rendered.split(placeholder).join(value); + } + return rendered; +} From 75c08d05d15df2bb9bfb42fa1dad9b9bf938102b Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sat, 21 Feb 2026 23:28:56 -0500 Subject: [PATCH 02/15] feat: correctly implement SSR for profile and account pages with redirect following --- public/users/accounts.html | 7 +- src/index.ts | 279 +++++++++++++++---------------------- test/integration.spec.ts | 23 +++ 3 files changed, 135 insertions(+), 174 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index cb78a76..3a05d88 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -154,12 +154,7 @@

Team Members

const ssrProfileJson = document.body.getAttribute('data-ssr-profile'); const ssrAccountJson = document.body.getAttribute('data-ssr-account'); - if ( - ssrProfileJson && - !ssrProfileJson.startsWith('{{ssr:') && - ssrAccountJson && - !ssrAccountJson.startsWith('{{ssr:') - ) { + if (ssrProfileJson && !ssrProfileJson.startsWith('{{ssr:') && ssrAccountJson && !ssrAccountJson.startsWith('{{ssr:')) { try { const profile = JSON.parse(ssrProfileJson); const account = JSON.parse(ssrAccountJson); diff --git a/src/index.ts b/src/index.ts index ddb4508..f403b01 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); @@ -122,13 +134,6 @@ export default { // Intercept requests to usersPath and serve them from the public/users directory. if (url.pathname.startsWith(usersPath)) { - const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/'; - const requestPath = url.pathname.replace(usersPathNormalized, ''); - - if (requestPath === 'profile.html' || requestPath === 'accounts.html') { - return handleSSR(request, env, url, usersPath, cookieManager); - } - url.pathname = url.pathname.replace(usersPath, '/users/'); const newRequest = new Request(url.toString(), request); newRequest.headers.set('x-skip-worker', 'true'); @@ -160,7 +165,7 @@ 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; async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: string, cookieManager: CookieManager): Promise { const user = await getUserFromSession(request, env, cookieManager); @@ -258,36 +263,64 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri return new Response('Not Found', { status: 404 }); } -async function getUserFromSession(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { - const cookieHeader = request.headers.get('Cookie'); - if (!cookieHeader) 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 cookies = parseCookies(cookieHeader); + const userStub = env.USER.get(env.USER.idFromString(user.id)); + const data = await userStub.validateSession(user.id); // This is actually session lookup, but using user ID for simplicity in some helpers? No, this is wrong in handleMe. + + // Re-read handleMe original logic + const cookieHeader = request.headers.get('Cookie'); + const cookies = parseCookies(cookieHeader || ''); const sessionCookieEncrypted = cookies['session_id']; + const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted!); + const [sessionId, doId] = sessionCookie!.split(':'); + const userStubReal = env.USER.get(env.USER.idFromString(doId)); + const fullData = await userStubReal.validateSession(sessionId); - if (!sessionCookieEncrypted) return null; + if (!fullData.valid) return Response.json(fullData, { status: 401 }); - const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); - if (!sessionCookie || !sessionCookie.includes(':')) return null; + const profile = { ...fullData.profile }; + const image = await userStubReal.getImage('avatar'); + if (image) { + const usersPath = env.USERS_PATH || DEFAULT_USERS_PATH; + profile.picture = usersPath + 'me/avatar'; + } else { + profile.picture = null; + } + + fullData.profile = profile; + fullData.is_admin = isAdmin({ id: doId, ...fullData.profile }, env); + fullData.is_impersonated = !!cookies['backup_session_id']; + + // Fetch memberships to find current account + const memberships = await userStubReal.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(); + fullData.account = { + ...accountInfo, + id: currentMembership.account_id, + role: currentMembership.role, + }; + } - const [sessionId, doId] = sessionCookie.split(':'); + return Response.json(fullData); +} - try { - const id = env.USER.idFromString(doId); - const userStub = env.USER.get(id); - const data = await userStub.validateSession(sessionId); +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 }); - if (data.valid) { - return { - id: doId, - profile: data.profile, - credential: data.credential, - }; - } - } catch (e) { - return null; - } - return null; + 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 { @@ -328,7 +361,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) { @@ -378,7 +411,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) { @@ -409,151 +442,48 @@ async function handleAccountDetails( return Response.json({ ...info, id: accountId, - role: membership ? membership.role : null, + role: membership ? (membership as any).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 }); - } -} - -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, 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 }); @@ -762,13 +692,16 @@ 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 { + ...info, + ...m, + }; + } catch (e) { + return { ...m, name: 'Unknown Account' }; + } }), ); @@ -850,7 +783,18 @@ async function handleSSR( assetUrl.pathname = url.pathname.replace(usersPath, '/users/'); const assetRequest = new Request(assetUrl.toString(), request); assetRequest.headers.set('x-skip-worker', 'true'); - const assetResponse = await env.ASSETS.fetch(assetRequest); + 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; @@ -861,7 +805,8 @@ async function handleSSR( const profile = { ...data.profile }; const image = await userStub.getImage('avatar'); if (image) { - profile.picture = usersPath + 'me/avatar'; + const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/'; + profile.picture = usersPathNormalized + 'me/avatar'; } else { profile.picture = null; } @@ -908,7 +853,8 @@ async function handleSSR( 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 accountPicture = accountAvatar ? `${usersPath}api/me/accounts/${account.id}/avatar` : null; + 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;'; @@ -945,10 +891,7 @@ async function handleSSR( } function renderSSR(html: string, replacements: Record): string { - let rendered = html; - for (const [key, value] of Object.entries(replacements)) { - const placeholder = `{{ssr:${key}}}`; - rendered = rendered.split(placeholder).join(value); - } - return rendered; + return html.replace(/\{\{ssr:([a-z0-9_]+)\}\}/g, (match, key) => { + return replacements[key] !== undefined ? replacements[key] : match; + }); } diff --git a/test/integration.spec.ts b/test/integration.spec.ts index e817db5..bc9bd55 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -260,4 +260,27 @@ 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' }); + + 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).not.toContain('{{ssr:profile_name}}'); + }); }); From dba1910a179b267e4eeb96ef82a9ea730f4dfafe Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sat, 21 Feb 2026 23:33:42 -0500 Subject: [PATCH 03/15] feat: render login credentials on SSR --- public/users/profile.html | 21 +++++++++++++++++---- src/index.ts | 4 ++++ test/integration.spec.ts | 11 +++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/public/users/profile.html b/public/users/profile.html index 0ad2fdc..ff55717 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -6,7 +6,7 @@ User Profile - + Link another account async function loadCredentials() { 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 SSR first + let credentials = null; + 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.

'; diff --git a/src/index.ts b/src/index.ts index f403b01..ea63f56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -818,6 +818,9 @@ async function handleSSR( 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; if (currentMembership) { const accountId = env.ACCOUNT.idFromString(currentMembership.account_id); @@ -835,6 +838,7 @@ async function handleSSR( // Prepare SSR values const replacements: Record = { 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 || '', diff --git a/test/integration.spec.ts b/test/integration.spec.ts index bc9bd55..4fd919f 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -270,6 +270,16 @@ describe('Integration Tests', () => { const { sessionId } = await stub.createSession(); await stub.updateProfile({ name: 'SSR Tester' }); + // Add credentials + const credentialStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName('test-provider')); + await credentialStub.put({ + user_id: userIdStr, + provider: 'test-provider', + subject_id: 'ssr-123', + profile_data: { name: 'SSR Tester' }, + }); + await stub.addCredential('test-provider', 'ssr-123'); + const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${userIdStr}`); const res = await SELF.fetch('http://example.com/users/profile.html', { @@ -281,6 +291,7 @@ describe('Integration Tests', () => { expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain('SSR Tester'); + expect(html).toContain('data-ssr-credentials="[{"provider":"test-provider"'); expect(html).not.toContain('{{ssr:profile_name}}'); }); }); From 7c10037f4a092596f31b1b1f84f61766375ff3ad Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sat, 21 Feb 2026 23:38:35 -0500 Subject: [PATCH 04/15] feat: implement SSR for credentials and fix handleMyAccounts regression --- public/users/profile.html | 5 +- src/index.ts | 146 ++++++++++++++++++++++++-------------- test/integration.spec.ts | 2 + 3 files changed, 95 insertions(+), 58 deletions(-) diff --git a/public/users/profile.html b/public/users/profile.html index ff55717..0e025eb 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -145,10 +145,7 @@

User Profile

Login Credentials

Manage the login methods linked to your account.

-
- -

Loading credentials...

-
+
{{ssr:credentials_list_html}}

Link another account

diff --git a/src/index.ts b/src/index.ts index ea63f56..2af5d64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -226,33 +226,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`); } } @@ -260,29 +257,29 @@ 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 handleMe(request: Request, env: StartupAPIEnv, 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 data = await userStub.validateSession(user.id); // This is actually session lookup, but using user ID for simplicity in some helpers? No, this is wrong in handleMe. - - // Re-read handleMe original logic + // Correct handleMe logic const cookieHeader = request.headers.get('Cookie'); const cookies = parseCookies(cookieHeader || ''); const sessionCookieEncrypted = cookies['session_id']; const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted!); const [sessionId, doId] = sessionCookie!.split(':'); - const userStubReal = env.USER.get(env.USER.idFromString(doId)); - const fullData = await userStubReal.validateSession(sessionId); + const userStub = env.USER.get(env.USER.idFromString(doId)); + const data = await userStub.validateSession(sessionId); - if (!fullData.valid) return Response.json(fullData, { status: 401 }); + if (!data.valid) return Response.json(data, { status: 401 }); - const profile = { ...fullData.profile }; - const image = await userStubReal.getImage('avatar'); + 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'; @@ -290,26 +287,26 @@ async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: Coo profile.picture = null; } - fullData.profile = profile; - fullData.is_admin = isAdmin({ id: doId, ...fullData.profile }, env); - fullData.is_impersonated = !!cookies['backup_session_id']; + 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 userStubReal.getMemberships(); + 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(); - fullData.account = { + data.account = { ...accountInfo, id: currentMembership.account_id, role: currentMembership.role, }; } - return Response.json(fullData); + return Response.json(data); } async function handleUpdateProfile(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { @@ -351,7 +348,7 @@ async function handleAccountMembers( request: Request, env: StartupAPIEnv, accountId: string, - extraParts: string[], + pathParts: string[], cookieManager: CookieManager, ): Promise { const user = await getUserFromSession(request, env, cookieManager); @@ -370,7 +367,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()); } @@ -378,20 +375,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)); } } @@ -420,8 +417,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 @@ -436,15 +439,7 @@ 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 as any).role : null, - billing, - }); + return new Response('Method Not Allowed', { status: 405 }); } async function getUserFromSession(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { @@ -696,11 +691,13 @@ async function handleMyAccounts(request: Request, env: StartupAPIEnv, cookieMana const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(m.account_id)); const info = await accountStub.getInfo(); return { - ...info, - ...m, + account_id: m.account_id, + name: info.name || 'Unknown Account', + role: m.role, + is_current: m.is_current, }; } catch (e) { - return { ...m, name: 'Unknown Account' }; + return { account_id: m.account_id, name: 'Unknown Account', role: m.role, is_current: m.is_current }; } }), ); @@ -848,6 +845,7 @@ async function handleSSR( 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), }; if (account) { @@ -899,3 +897,43 @@ function renderSSR(html: string, replacements: Record): string { 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 ''; +} diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 4fd919f..2b3744a 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -292,6 +292,8 @@ describe('Integration Tests', () => { const html = await res.text(); expect(html).toContain('SSR Tester'); expect(html).toContain('data-ssr-credentials="[{"provider":"test-provider"'); + expect(html).toContain('credential-item'); + expect(html).toContain('test-provider'); expect(html).not.toContain('{{ssr:profile_name}}'); }); }); From f3e50580bee054e020db3ffc777fb3f662683afe Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sat, 21 Feb 2026 23:42:13 -0500 Subject: [PATCH 05/15] fix: ensure all credentials are included in handleMe and used by loadCredentials --- public/users/profile.html | 25 +++++++++++++++---------- src/index.ts | 16 +++++++++------- test/integration.spec.ts | 28 +++++++++++++++++++--------- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/public/users/profile.html b/public/users/profile.html index 0e025eb..1060f9b 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -262,6 +262,8 @@

Link another account

document.getElementById('nav-account-item').style.display = 'block'; } + loadCredentials(data.credentials); + document.getElementById('save-btn').disabled = true; } } catch (e) { @@ -384,17 +386,20 @@

Link another account

setTimeout(() => (toast.style.opacity = 0), 3000); } - async function loadCredentials() { + async function loadCredentials(passedCredentials) { const list = document.getElementById('credentials-list'); try { - // Try to get data from SSR first - let credentials = null; - 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); + // Try to get data from passed credentials or SSR first + let credentials = passedCredentials; + + if (!credentials) { + 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); + } } } @@ -477,7 +482,7 @@

Link another account

} loadProfile(); - loadCredentials(); + // loadCredentials(); // Handled within loadProfile if data exists diff --git a/src/index.ts b/src/index.ts index 2af5d64..ab88446 100644 --- a/src/index.ts +++ b/src/index.ts @@ -287,13 +287,15 @@ async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: Coo 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]; + data.profile = profile; + data.is_admin = isAdmin({ id: doId, ...data.profile }, env); + 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); diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 2b3744a..544edfa 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -271,14 +271,23 @@ describe('Integration Tests', () => { await stub.updateProfile({ name: 'SSR Tester' }); // Add credentials - const credentialStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName('test-provider')); - await credentialStub.put({ + const googleCredStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName('google')); + await googleCredStub.put({ user_id: userIdStr, - provider: 'test-provider', - subject_id: 'ssr-123', - profile_data: { name: 'SSR Tester' }, + 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('test-provider', 'ssr-123'); + await stub.addCredential('twitch', 'twitch-456'); const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${userIdStr}`); @@ -291,9 +300,10 @@ describe('Integration Tests', () => { expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain('SSR Tester'); - expect(html).toContain('data-ssr-credentials="[{"provider":"test-provider"'); - expect(html).toContain('credential-item'); - expect(html).toContain('test-provider'); + expect(html).toContain('google'); + expect(html).toContain('twitch'); + expect(html).toContain('google@example.com'); + expect(html).toContain('twitch@example.com'); expect(html).not.toContain('{{ssr:profile_name}}'); }); }); From 70acd3a13a36110ef92dbee9dec0ad4e06513ac1 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sat, 21 Feb 2026 23:48:08 -0500 Subject: [PATCH 06/15] feat: render team members on account page using SSR --- public/users/accounts.html | 186 +++++++++++++++++++++---------------- src/index.ts | 86 +++++++++++++---- 2 files changed, 173 insertions(+), 99 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index 3a05d88..927cd37 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -6,7 +6,7 @@ Account Settings - + General Information

Team Members

-

Loading members...

+ {{ssr:account_members_list_html}}
@@ -147,83 +147,93 @@

Team Members

let currentUserId = null; let initialAccountInfo = {}; - async function init() { - try { - // Try to get data from SSR first - let data = null; - const ssrProfileJson = document.body.getAttribute('data-ssr-profile'); - const ssrAccountJson = document.body.getAttribute('data-ssr-account'); - - if (ssrProfileJson && !ssrProfileJson.startsWith('{{ssr:') && ssrAccountJson && !ssrAccountJson.startsWith('{{ssr:')) { - try { - const profile = JSON.parse(ssrProfileJson); - const account = JSON.parse(ssrAccountJson); - if (profile.valid) { - data = { - ...profile, - account: account, - }; + async function init() { + try { + // 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); + } + } + + 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; + currentUserId = data.profile.id; + + document.getElementById('account-name-heading').textContent = data.account.name || 'Account'; + document.getElementById('account-name').value = data.account.name || ''; + initialAccountInfo.name = data.account.name || ''; + document.getElementById('account-id-text').textContent = `ID: ${currentAccountId}`; + + document.getElementById('copy-id-btn').onclick = () => { + navigator.clipboard + .writeText(currentAccountId) + .then(() => { + showToast('Account ID copied to clipboard'); + }) + .catch((err) => { + console.error('Failed to copy: ', err); + }); + }; + + // 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-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'; + msg.style.margin = '0'; + section.appendChild(msg); + document.querySelector('.content-area').appendChild(section); + } else { + loadMembers(members); + } + + loadAccountDetails(data.account); + } + } catch (e) { + showToast(e.message); } - } catch (e) { - console.error('Failed to parse SSR data', e); } - } - - 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; - currentUserId = data.profile.id; - - document.getElementById('account-name-heading').textContent = data.account.name || 'Account'; - document.getElementById('account-name').value = data.account.name || ''; - initialAccountInfo.name = data.account.name || ''; - document.getElementById('account-id-text').textContent = `ID: ${currentAccountId}`; - - document.getElementById('copy-id-btn').onclick = () => { - navigator.clipboard - .writeText(currentAccountId) - .then(() => { - showToast('Account ID copied to clipboard'); - }) - .catch((err) => { - console.error('Failed to copy: ', err); - }); - }; - - // 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-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'; - msg.style.margin = '0'; - section.appendChild(msg); - document.querySelector('.content-area').appendChild(section); - } else { - loadMembers(); - } - - loadAccountDetails(data.account); - } - } catch (e) { - showToast(e.message); - } - } document.getElementById('account-name').addEventListener('input', (e) => { const hasChanged = e.target.value !== initialAccountInfo.name; @@ -369,12 +379,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) { + 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/src/index.ts b/src/index.ts index ab88446..7da31ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -264,11 +264,10 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri } 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 cookieHeader = request.headers.get('Cookie'); + if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); // Correct handleMe logic - const cookieHeader = request.headers.get('Cookie'); const cookies = parseCookies(cookieHeader || ''); const sessionCookieEncrypted = cookies['session_id']; const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted!); @@ -287,15 +286,16 @@ async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: Coo profile.picture = null; } - data.profile = profile; - data.is_admin = isAdmin({ id: doId, ...data.profile }, env); - 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]; + data.profile = profile; + data.is_admin = isAdmin({ id: doId, ...data.profile }, env); + 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); @@ -397,12 +397,7 @@ async function handleAccountMembers( return new Response('Not Found', { status: 404 }); } -async function handleAccountDetails( - request: Request, - env: StartupAPIEnv, - accountId: string, - cookieManager: CookieManager, -): Promise { +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 }); @@ -821,6 +816,7 @@ async function handleSSR( 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); @@ -832,6 +828,10 @@ async function handleSSR( 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 @@ -868,6 +868,14 @@ async function handleSSR( 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'] = ''; @@ -879,6 +887,8 @@ async function handleSSR( 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); @@ -939,3 +949,43 @@ function getProviderIcon(provider: string): string { } return ''; } + +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(''); +} From 68fb63ca6a455d6230aae03549d24f38703f54ac Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sat, 21 Feb 2026 23:58:07 -0500 Subject: [PATCH 07/15] feat: dynamic providers in HTML templates using SSR --- public/users/accounts.html | 171 ++++++++++++++++------------------ public/users/admin/index.html | 2 +- public/users/profile.html | 2 +- src/index.ts | 40 ++++++-- test/integration.spec.ts | 1 + wrangler.test.jsonc | 4 + 6 files changed, 119 insertions(+), 101 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index 927cd37..a02c088 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -8,7 +8,7 @@ @@ -131,9 +131,7 @@

General Information

Team Members

-
- {{ssr:account_members_list_html}} -
+
{{ssr:account_members_list_html}}
@@ -147,93 +145,88 @@

Team Members

let currentUserId = null; let initialAccountInfo = {}; - async function init() { - try { - // 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); - } - } - - 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; - currentUserId = data.profile.id; - - document.getElementById('account-name-heading').textContent = data.account.name || 'Account'; - document.getElementById('account-name').value = data.account.name || ''; - initialAccountInfo.name = data.account.name || ''; - document.getElementById('account-id-text').textContent = `ID: ${currentAccountId}`; - - document.getElementById('copy-id-btn').onclick = () => { - navigator.clipboard - .writeText(currentAccountId) - .then(() => { - showToast('Account ID copied to clipboard'); - }) - .catch((err) => { - console.error('Failed to copy: ', err); - }); - }; - - // 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-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'; - msg.style.margin = '0'; - section.appendChild(msg); - document.querySelector('.content-area').appendChild(section); - } else { - loadMembers(members); - } - - loadAccountDetails(data.account); - } - } catch (e) { - showToast(e.message); + async function init() { + try { + // 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); + } + } + + 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; + currentUserId = data.profile.id; + + document.getElementById('account-name-heading').textContent = data.account.name || 'Account'; + document.getElementById('account-name').value = data.account.name || ''; + initialAccountInfo.name = data.account.name || ''; + document.getElementById('account-id-text').textContent = `ID: ${currentAccountId}`; + + document.getElementById('copy-id-btn').onclick = () => { + navigator.clipboard + .writeText(currentAccountId) + .then(() => { + showToast('Account ID copied to clipboard'); + }) + .catch((err) => { + console.error('Failed to copy: ', err); + }); + }; + + // 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-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'; + msg.style.margin = '0'; + section.appendChild(msg); + document.querySelector('.content-area').appendChild(section); + } else { + loadMembers(members); + } + + loadAccountDetails(data.account); + } + } catch (e) { + showToast(e.message); + } + } document.getElementById('account-name').addEventListener('input', (e) => { const hasChanged = e.target.value !== initialAccountInfo.name; 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 1060f9b..dd3d3cd 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -8,7 +8,7 @@ diff --git a/src/index.ts b/src/index.ts index 7da31ed..f4190fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -150,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); } @@ -167,6 +160,17 @@ export default { }, } 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); if (!user || !isAdmin(user, env)) { @@ -180,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')); @@ -397,7 +411,12 @@ async function handleAccountMembers( return new Response('Not Found', { status: 404 }); } -async function handleAccountDetails(request: Request, env: StartupAPIEnv, accountId: string, cookieManager: CookieManager): Promise { +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 }); @@ -836,6 +855,7 @@ async function handleSSR( // 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', diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 544edfa..9901914 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -304,6 +304,7 @@ describe('Integration Tests', () => { 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}}'); }); }); diff --git a/wrangler.test.jsonc b/wrangler.test.jsonc index 2a33ddd..594601b 100644 --- a/wrangler.test.jsonc +++ b/wrangler.test.jsonc @@ -45,5 +45,9 @@ "SESSION_SECRET": "dev-secret", "ORIGIN_URL": "http://example.com", "ADMIN_IDS": "admin", + "GOOGLE_CLIENT_ID": "google-id", + "GOOGLE_CLIENT_SECRET": "google-secret", + "TWITCH_CLIENT_ID": "twitch-id", + "TWITCH_CLIENT_SECRET": "twitch-secret", }, } From bb861985e3b5f54328b893aa54146cbc9b980a3e Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 00:01:28 -0500 Subject: [PATCH 08/15] feat: dynamic providers in Link another account section using SSR --- public/users/profile.html | 31 +------------------------------ src/index.ts | 18 ++++++++++++++++++ worker-configuration.d.ts | 6 +----- 3 files changed, 20 insertions(+), 35 deletions(-) diff --git a/public/users/profile.html b/public/users/profile.html index dd3d3cd..7d89c95 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -149,36 +149,7 @@

Login Credentials

Link another account

- - + {{ssr:link_credentials_html}}
diff --git a/src/index.ts b/src/index.ts index f4190fe..16f17f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -868,6 +868,7 @@ async function handleSSR( 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) { @@ -970,6 +971,23 @@ function getProviderIcon(provider: string): string { 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.

'; diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 49b9680..299c60c 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 1b2f4131cc4427d006995ed2932f1531) +// Generated by Wrangler by running `wrangler types` (hash: 12bdce2ff75bc02e3aaa1fcd4e1b707b) // Runtime types generated with workerd@1.20260120.0 2025-09-27 global_fetch_strictly_public declare namespace Cloudflare { interface GlobalProps { @@ -15,8 +15,6 @@ declare namespace Cloudflare { AUTH_ORIGIN: string; TWITCH_CLIENT_ID: string; TWITCH_CLIENT_SECRET: string; - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; USER: DurableObjectNamespace; ACCOUNT: DurableObjectNamespace; SYSTEM: DurableObjectNamespace; @@ -29,8 +27,6 @@ declare namespace Cloudflare { AUTH_ORIGIN: string; TWITCH_CLIENT_ID: string; TWITCH_CLIENT_SECRET: string; - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; IMAGE_STORAGE: R2Bucket; ASSETS: Fetcher; USER: DurableObjectNamespace; From 5b74f3765f5cbbfdb1f2d66003b95b2531fc4e81 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 00:03:41 -0500 Subject: [PATCH 09/15] fix: twitch icon contrast and further dynamic provider improvements --- public/users/style.css | 8 ++++++++ src/index.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/public/users/style.css b/public/users/style.css index 056d40b..3afc74d 100644 --- a/public/users/style.css +++ b/public/users/style.css @@ -309,6 +309,10 @@ button:disabled { justify-content: center; } +.twitch-icon { + color: #9146ff; +} + .link-account-btn { display: flex; align-items: center; @@ -333,6 +337,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 16f17f0..590daad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -966,7 +966,7 @@ function getProviderIcon(provider: string): string { if (provider === 'google') { return ''; } else if (provider === 'twitch') { - return ''; + return ''; } return ''; } From 0e94cc28cea9e5bd3782c51485abed5ed7d6aeac Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 00:05:51 -0500 Subject: [PATCH 10/15] test: verify rendered providers match configuration --- public/users/profile.html | 4 +--- test/integration.spec.ts | 36 ++++++++++++++++++++++++++++++++++++ worker-configuration.d.ts | 6 +++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/public/users/profile.html b/public/users/profile.html index 7d89c95..61ab6f1 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -148,9 +148,7 @@

Login Credentials

{{ssr:credentials_list_html}}

Link another account

-
- {{ssr:link_credentials_html}} -
+
{{ssr:link_credentials_html}}
diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 9901914..19f0810 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -307,4 +307,40 @@ describe('Integration Tests', () => { 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('; ACCOUNT: DurableObjectNamespace; SYSTEM: DurableObjectNamespace; @@ -27,6 +29,8 @@ declare namespace Cloudflare { AUTH_ORIGIN: string; TWITCH_CLIENT_ID: string; TWITCH_CLIENT_SECRET: string; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; IMAGE_STORAGE: R2Bucket; ASSETS: Fetcher; USER: DurableObjectNamespace; From 327795b86f5df798b52154655fb5c91458782b6f Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 00:11:38 -0500 Subject: [PATCH 11/15] fix: ensure SSR data is only used on initial load to allow client-side updates --- public/users/accounts.html | 4 +++- public/users/profile.html | 18 +++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index a02c088..8e59c7f 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -144,6 +144,7 @@

Team Members

let currentUserRole = 0; let currentUserId = null; let initialAccountInfo = {}; + let isInitialLoad = true; async function init() { try { @@ -222,6 +223,7 @@

Team Members

} loadAccountDetails(data.account); + isInitialLoad = false; } } catch (e) { showToast(e.message); @@ -376,7 +378,7 @@

Team Members

const list = document.getElementById('members-list'); try { let members = ssrData; - if (!members) { + if (!members && isInitialLoad) { const ssrMembersJson = document.body.getAttribute('data-ssr-members'); if (ssrMembersJson && !ssrMembersJson.startsWith('{{ssr:')) { try { diff --git a/public/users/profile.html b/public/users/profile.html index 61ab6f1..4ced347 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -159,17 +159,20 @@

Link another account

const API_BASE = '/users/api'; let currentProvider = null; let initialProfile = {}; + let isInitialLoad = true; async function loadProfile() { try { // Try to get data from SSR first let data = null; - 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); + 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); + } } } @@ -234,6 +237,7 @@

Link another account

loadCredentials(data.credentials); document.getElementById('save-btn').disabled = true; + isInitialLoad = false; } } catch (e) { showToast(e.message); @@ -361,7 +365,7 @@

Link another account

// Try to get data from passed credentials or SSR first let credentials = passedCredentials; - if (!credentials) { + if (!credentials && isInitialLoad) { const ssrCredentialsJson = document.body.getAttribute('data-ssr-credentials'); if (ssrCredentialsJson && !ssrCredentialsJson.startsWith('{{ssr:')) { try { From 5648e9a9c6b6519a81270b39baf4e9fd218a484b Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 00:14:37 -0500 Subject: [PATCH 12/15] style: picture removal button grey with red hover --- public/users/accounts.html | 22 ++-------------------- public/users/profile.html | 22 ++-------------------- public/users/style.css | 27 +++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 40 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index 8e59c7f..75f076e 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -85,26 +85,8 @@

Account Settings

type="button" id="remove-avatar-btn" title="Remove image" - style=" - position: absolute; - top: -5px; - right: -5px; - width: 20px; - height: 20px; - border-radius: 50%; - background: #ea4335; - color: white; - border: 2px solid white; - cursor: pointer; - {{ssr:account_remove_btn_display}} - 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); - " + class="remove-image-btn" + style="{{ssr:account_remove_btn_display}}" > ✕ diff --git a/public/users/profile.html b/public/users/profile.html index 4ced347..2d01c4a 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -92,26 +92,8 @@

User Profile

type="button" id="remove-avatar-btn" title="Remove image" - style=" - position: absolute; - top: -5px; - right: -5px; - width: 20px; - height: 20px; - border-radius: 50%; - background: #ea4335; - color: white; - border: 2px solid white; - cursor: pointer; - {{ssr:profile_remove_btn_display}} - 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); - " + class="remove-image-btn" + style="{{ssr:profile_remove_btn_display}}" > ✕ diff --git a/public/users/style.css b/public/users/style.css index 3afc74d..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; From 6babce2fe5f44dc2529e9fd34a9a6130adbf719b Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 00:27:31 -0500 Subject: [PATCH 13/15] fix: refactor handleMe to fix TypeError and improve efficiency --- src/index.ts | 84 ++++++++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/src/index.ts b/src/index.ts index 590daad..5f43798 100644 --- a/src/index.ts +++ b/src/index.ts @@ -278,51 +278,57 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri } 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 user = await getUserFromSession(request, env, cookieManager); + if (!user) return new Response('Unauthorized', { status: 401 }); - // Correct handleMe logic - const cookies = parseCookies(cookieHeader || ''); - const sessionCookieEncrypted = cookies['session_id']; - const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted!); - const [sessionId, doId] = sessionCookie!.split(':'); - const userStub = env.USER.get(env.USER.idFromString(doId)); - const data = await userStub.validateSession(sessionId); + const { id: doId, sessionId, profile: initialProfile, credential } = user; - if (!data.valid) return Response.json(data, { status: 401 }); + try { + const id = env.USER.idFromString(doId); + const userStub = env.USER.get(id); - 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; - } + const data: any = { + valid: true, + profile: { ...initialProfile }, + credential, + }; - data.profile = profile; - data.is_admin = isAdmin({ id: doId, ...data.profile }, env); - data.is_impersonated = !!cookies['backup_session_id']; + 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; + } - // Fetch credentials - data.credentials = await userStub.listCredentials(); + data.is_admin = isAdmin({ id: doId, profile: data.profile, credential }, env); - // 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, - }; - } + const cookieHeader = request.headers.get('Cookie') || ''; + const cookies = parseCookies(cookieHeader); + data.is_impersonated = !!cookies['backup_session_id']; - return Response.json(data); + // 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 new Response('Unauthorized', { status: 401 }); + } } async function handleUpdateProfile(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { @@ -474,7 +480,7 @@ async function getUserFromSession(request: Request, env: StartupAPIEnv, cookieMa 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 }; + if (data.valid) return { id: doId, sessionId, profile: data.profile, credential: data.credential }; } catch (e) {} return null; } From 0730c3bbbdea268a6eb5d09064529386371a9556 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 00:33:28 -0500 Subject: [PATCH 14/15] feat: prevent DO creation for non-existent users by checking SystemDO index --- src/SystemDO.ts | 5 +++++ src/index.ts | 4 ++++ test/integration.spec.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/src/SystemDO.ts b/src/SystemDO.ts index c97e521..59467f3 100644 --- a/src/SystemDO.ts +++ b/src/SystemDO.ts @@ -55,6 +55,11 @@ export class SystemDO extends DurableObject { return users; } + async userExists(userId: string): Promise { + const result = this.sql.exec('SELECT 1 FROM users WHERE id = ?', userId); + return !result.next().done; + } + async getUserMemberships(userId: string) { const userStub = this.env.USER.get(this.env.USER.idFromString(userId)); return await userStub.getMemberships(); diff --git a/src/index.ts b/src/index.ts index 5f43798..2aee7d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -477,6 +477,10 @@ async function getUserFromSession(request: Request, env: StartupAPIEnv, cookieMa const [sessionId, doId] = sessionCookie.split(':'); try { + const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); + const exists = await systemStub.userExists(doId); + if (!exists) return null; + const id = env.USER.idFromString(doId); const userStub = env.USER.get(id); const data = await userStub.validateSession(sessionId); diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 19f0810..9fe70f3 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -14,6 +14,11 @@ 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(); + + // Register user in SystemDO index + const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); + await systemStub.registerUser({ id: userIdStr, name: 'Integration Tester' }); // Create session const { sessionId } = await stub.createSession(); @@ -53,6 +58,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(); @@ -99,6 +108,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: 'Credentials Tester' }); + // Create session const { sessionId } = await stub.createSession(); @@ -157,6 +170,11 @@ 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(); + + // Register user in SystemDO index + const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); + await systemStub.registerUser({ id: userIdStr, name: 'Integration Tester' }); // Create session const { sessionId } = await stub.createSession(); @@ -215,6 +233,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'); @@ -266,6 +288,10 @@ describe('Integration Tests', () => { const stub = 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: 'SSR Tester' }); + // Create session const { sessionId } = await stub.createSession(); await stub.updateProfile({ name: 'SSR Tester' }); @@ -313,6 +339,10 @@ describe('Integration Tests', () => { const stub = 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: 'SSR Tester' }); + const { sessionId } = await stub.createSession(); const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${userIdStr}`); From 2dbd3709e673718f71b1e2fc76fa70757f6b3dab Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 00:36:06 -0500 Subject: [PATCH 15/15] refactor: remove redundant SystemDO check and trust cookie for UserDO existence --- src/SystemDO.ts | 5 ----- src/index.ts | 4 ---- test/integration.spec.ts | 20 -------------------- 3 files changed, 29 deletions(-) diff --git a/src/SystemDO.ts b/src/SystemDO.ts index 59467f3..c97e521 100644 --- a/src/SystemDO.ts +++ b/src/SystemDO.ts @@ -55,11 +55,6 @@ export class SystemDO extends DurableObject { return users; } - async userExists(userId: string): Promise { - const result = this.sql.exec('SELECT 1 FROM users WHERE id = ?', userId); - return !result.next().done; - } - async getUserMemberships(userId: string) { const userStub = this.env.USER.get(this.env.USER.idFromString(userId)); return await userStub.getMemberships(); diff --git a/src/index.ts b/src/index.ts index 2aee7d7..5f43798 100644 --- a/src/index.ts +++ b/src/index.ts @@ -477,10 +477,6 @@ async function getUserFromSession(request: Request, env: StartupAPIEnv, cookieMa const [sessionId, doId] = sessionCookie.split(':'); try { - const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); - const exists = await systemStub.userExists(doId); - if (!exists) return null; - const id = env.USER.idFromString(doId); const userStub = env.USER.get(id); const data = await userStub.validateSession(sessionId); diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 9fe70f3..43c445f 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -16,10 +16,6 @@ describe('Integration Tests', () => { const stub = 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' }); - // Create session const { sessionId } = await stub.createSession(); @@ -108,10 +104,6 @@ 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: 'Credentials Tester' }); - // Create session const { sessionId } = await stub.createSession(); @@ -172,10 +164,6 @@ describe('Integration Tests', () => { const stub = 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' }); - // Create session const { sessionId } = await stub.createSession(); @@ -288,10 +276,6 @@ describe('Integration Tests', () => { const stub = 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: 'SSR Tester' }); - // Create session const { sessionId } = await stub.createSession(); await stub.updateProfile({ name: 'SSR Tester' }); @@ -339,10 +323,6 @@ describe('Integration Tests', () => { const stub = 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: 'SSR Tester' }); - const { sessionId } = await stub.createSession(); const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${userIdStr}`);