diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index b57053052..a2b26efc8 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,11 +1,12 @@ { - "source_hash": "02327e893b4f909b2352c5c218a0f4600c7d1dba1b38d3fc8daeb1249dde2672", + "source_hash": "276c1c649eee16c120f9bfe29e54582f9cc50c5a9c1c1b0acc99023b4dd8ac0f", "build_inputs": [ "package.json", "tsconfig.json", "build.mjs", "src/index.ts", "src/account_controller.ts", + "src/auth_controller.ts", "src/shell.ts", "src/shell_view.ts", "src/services.ts", diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index d35bef363..50ec9fc54 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -418,6 +418,109 @@ }); } + // src/auth_controller.ts + function asHTMLElement2(target) { + return target instanceof HTMLElement ? target : null; + } + function getElement2(id) { + return document.getElementById(id); + } + function installAuthController(deps) { + var loginState = { + emailValue: "", + sending: false, + success: false, + error: "" + }; + function syncBootstrapEmail(email) { + if (!loginState.emailValue) { + loginState.emailValue = email || ""; + } + } + async function sendMagicLink() { + var email = String(loginState.emailValue || "").trim(); + if (!email) { + var input = getElement2("portal-login-email"); + if (input) input.focus(); + return; + } + loginState.sending = true; + loginState.error = ""; + loginState.success = false; + deps.renderPortal(); + try { + var response = await fetch(deps.getMagicLinkRequestPath(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }) + }); + if (response.ok || response.status === 404) { + loginState.sending = false; + loginState.success = true; + deps.renderPortal(); + return; + } + if (response.status === 429) { + loginState.error = "Too many requests. Please wait a moment and try again."; + } else { + loginState.error = "Something went wrong. Please try again."; + } + } catch (_) { + loginState.error = "Network error. Please check your connection and try again."; + } + loginState.sending = false; + deps.renderPortal(); + } + document.addEventListener("click", function(event) { + var portalActionEl = asHTMLElement2(event.target)?.closest("[data-portal-action]"); + if (portalActionEl) { + var portalAction = portalActionEl.getAttribute("data-portal-action") || ""; + switch (portalAction) { + case "send-magic-link": + event.preventDefault(); + void sendMagicLink(); + return; + case "resend-magic-link": + event.preventDefault(); + loginState.success = false; + loginState.error = ""; + deps.renderPortal(); + void sendMagicLink(); + return; + default: + break; + } + } + var logoutBtn = asHTMLElement2(event.target)?.closest("#logout-btn"); + if (!logoutBtn) { + return; + } + event.preventDefault(); + logoutBtn.disabled = true; + logoutBtn.textContent = "Signing out\u2026"; + (async function() { + try { + await fetch(deps.getLogoutPath(), { method: "POST" }); + } catch (_) { + } + window.location.href = deps.getPortalPath(); + })(); + }); + document.addEventListener("input", function(event) { + var target = asHTMLElement2(event.target); + if (!target) return; + if (target.getAttribute("data-portal-input") === "login-email") { + loginState.emailValue = target.value; + } + }); + return { + getLoginState: function() { + return loginState; + }, + syncBootstrapEmail + }; + } + // src/shell_view.ts function escapeHTML(value) { return String(value || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); @@ -514,24 +617,12 @@ var LOGOUT_PATH = getLogoutPath(); var ACCOUNT_API_BASE_PATH = getAccountAPIBasePath(); var PORTAL_API_BASE_PATH = getPortalAPIBasePath(); - var loginState = { - emailValue: "", - sending: false, - success: false, - error: "" - }; - function getElement2(id) { - return document.getElementById(id); - } - function asHTMLElement2(target) { - return target instanceof HTMLElement ? target : null; - } function renderHeader() { var userInfo = document.getElementById("portal-user-info"); if (!userInfo) return; userInfo.innerHTML = renderHeaderHTML({ bootstrap: portalBootstrap, - loginState, + loginState: authController.getLoginState(), signupPath: SIGNUP_PATH, accountAPIBasePath: ACCOUNT_API_BASE_PATH }); @@ -542,7 +633,7 @@ if (!root) return; var context = { bootstrap: portalBootstrap, - loginState, + loginState: authController.getLoginState(), signupPath: SIGNUP_PATH, accountAPIBasePath: ACCOUNT_API_BASE_PATH }; @@ -559,8 +650,8 @@ LOGOUT_PATH = getLogoutPath(); ACCOUNT_API_BASE_PATH = getAccountAPIBasePath(); PORTAL_API_BASE_PATH = getPortalAPIBasePath(); - if (!portalBootstrap.authenticated && !loginState.emailValue) { - loginState.emailValue = portalBootstrap.email || ""; + if (!portalBootstrap.authenticated) { + authController.syncBootstrapEmail(portalBootstrap.email || ""); } renderPortalApp(); } @@ -583,7 +674,7 @@ return false; } function showToast(msg, isError = false) { - var t = getElement2("toast"); + var t = document.getElementById("toast"); if (!t) return; t.textContent = msg; t.className = "toast visible" + (isError ? " error" : ""); @@ -592,81 +683,17 @@ t.className = "toast"; }, 4e3); } - async function sendMagicLink() { - var email = String(loginState.emailValue || "").trim(); - if (!email) { - var input = getElement2("portal-login-email"); - if (input) input.focus(); - return; - } - loginState.sending = true; - loginState.error = ""; - loginState.success = false; - renderPortalApp(); - try { - var response = await fetch(MAGIC_LINK_REQUEST_PATH, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }) - }); - if (response.ok || response.status === 404) { - loginState.sending = false; - loginState.success = true; - renderPortalApp(); - return; - } - if (response.status === 429) { - loginState.error = "Too many requests. Please wait a moment and try again."; - } else { - loginState.error = "Something went wrong. Please try again."; - } - } catch (_) { - loginState.error = "Network error. Please check your connection and try again."; - } - loginState.sending = false; - renderPortalApp(); - } - document.addEventListener("click", function(event) { - var portalActionEl = asHTMLElement2(event.target)?.closest("[data-portal-action]"); - if (portalActionEl) { - var portalAction = portalActionEl.getAttribute("data-portal-action") || ""; - switch (portalAction) { - case "send-magic-link": - event.preventDefault(); - sendMagicLink(); - return; - case "resend-magic-link": - event.preventDefault(); - loginState.success = false; - loginState.error = ""; - renderPortalApp(); - sendMagicLink(); - return; - default: - break; - } - } - var logoutBtn = asHTMLElement2(event.target)?.closest("#logout-btn"); - if (logoutBtn) { - event.preventDefault(); - logoutBtn.disabled = true; - logoutBtn.textContent = "Signing out\u2026"; - (async function() { - try { - await fetch(LOGOUT_PATH, { method: "POST" }); - } catch (_) { - } - window.location.href = PORTAL_PATH; - })(); - return; - } - }); - document.addEventListener("input", function(event) { - var target = asHTMLElement2(event.target); - if (!target) return; - if (target.getAttribute("data-portal-input") === "login-email") { - loginState.emailValue = target.value; - } + var authController = installAuthController({ + getMagicLinkRequestPath: function() { + return MAGIC_LINK_REQUEST_PATH; + }, + getLogoutPath: function() { + return LOGOUT_PATH; + }, + getPortalPath: function() { + return PORTAL_PATH; + }, + renderPortal: renderPortalApp }); installAccountController({ getAccountAPIBasePath: function() { @@ -681,7 +708,6 @@ refreshBootstrap, showToast }); - loginState.emailValue = portalBootstrap.email || ""; applyBootstrap(portalBootstrap); if (portalBootstrap.authenticated) { refreshBootstrap(); diff --git a/internal/cloudcp/portal/frontend/build.mjs b/internal/cloudcp/portal/frontend/build.mjs index 8aa6028bf..0713fd127 100644 --- a/internal/cloudcp/portal/frontend/build.mjs +++ b/internal/cloudcp/portal/frontend/build.mjs @@ -19,6 +19,7 @@ const buildInputs = [ 'build.mjs', 'src/index.ts', 'src/account_controller.ts', + 'src/auth_controller.ts', 'src/shell.ts', 'src/shell_view.ts', 'src/services.ts', diff --git a/internal/cloudcp/portal/frontend/src/auth_controller.ts b/internal/cloudcp/portal/frontend/src/auth_controller.ts new file mode 100644 index 000000000..c5da8f65b --- /dev/null +++ b/internal/cloudcp/portal/frontend/src/auth_controller.ts @@ -0,0 +1,124 @@ +import type { PortalLoginState } from './types'; + +type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; + +export interface AuthControllerDeps { + getMagicLinkRequestPath: () => string; + getLogoutPath: () => string; + getPortalPath: () => string; + renderPortal: () => void; +} + +export interface AuthController { + getLoginState: () => PortalLoginState; + syncBootstrapEmail: (email: string) => void; +} + +function asHTMLElement(target: EventTarget | null): HTMLElement | null { + return target instanceof HTMLElement ? target : null; +} + +function getElement(id: string): T | null { + return document.getElementById(id) as T | null; +} + +export function installAuthController(deps: AuthControllerDeps): AuthController { + var loginState: PortalLoginState = { + emailValue: '', + sending: false, + success: false, + error: '', + }; + + function syncBootstrapEmail(email: string) { + if (!loginState.emailValue) { + loginState.emailValue = email || ''; + } + } + + async function sendMagicLink() { + var email = String(loginState.emailValue || '').trim(); + if (!email) { + var input = getElement('portal-login-email'); + if (input) input.focus(); + return; + } + loginState.sending = true; + loginState.error = ''; + loginState.success = false; + deps.renderPortal(); + try { + var response = await fetch(deps.getMagicLinkRequestPath(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email }) + }); + if (response.ok || response.status === 404) { + loginState.sending = false; + loginState.success = true; + deps.renderPortal(); + return; + } + if (response.status === 429) { + loginState.error = 'Too many requests. Please wait a moment and try again.'; + } else { + loginState.error = 'Something went wrong. Please try again.'; + } + } catch (_) { + loginState.error = 'Network error. Please check your connection and try again.'; + } + loginState.sending = false; + deps.renderPortal(); + } + + document.addEventListener('click', function(event) { + var portalActionEl = asHTMLElement(event.target)?.closest('[data-portal-action]'); + if (portalActionEl) { + var portalAction = portalActionEl.getAttribute('data-portal-action') || ''; + switch (portalAction) { + case 'send-magic-link': + event.preventDefault(); + void sendMagicLink(); + return; + case 'resend-magic-link': + event.preventDefault(); + loginState.success = false; + loginState.error = ''; + deps.renderPortal(); + void sendMagicLink(); + return; + default: + break; + } + } + + var logoutBtn = asHTMLElement(event.target)?.closest('#logout-btn') as HTMLButtonElement | null; + if (!logoutBtn) { + return; + } + event.preventDefault(); + logoutBtn.disabled = true; + logoutBtn.textContent = 'Signing out…'; + (async function() { + try { + await fetch(deps.getLogoutPath(), { method: 'POST' }); + } catch (_) {} + window.location.href = deps.getPortalPath(); + })(); + }); + + document.addEventListener('input', function(event) { + var target = asHTMLElement(event.target) as FormValueElement | null; + if (!target) return; + if (target.getAttribute('data-portal-input') === 'login-email') { + loginState.emailValue = target.value; + } + }); + + return { + getLoginState: function() { + return loginState; + }, + syncBootstrapEmail, + }; +} diff --git a/internal/cloudcp/portal/frontend/src/shell.ts b/internal/cloudcp/portal/frontend/src/shell.ts index b12631273..d06ccbeb0 100644 --- a/internal/cloudcp/portal/frontend/src/shell.ts +++ b/internal/cloudcp/portal/frontend/src/shell.ts @@ -13,12 +13,13 @@ import { setBootstrap, } from './runtime'; import { installAccountController } from './account_controller'; +import { installAuthController } from './auth_controller'; import { renderAuthenticatedPortalHTML, renderHeaderHTML, renderSignedOutPortalHTML, } from './shell_view'; -import type { PortalBootstrapData, PortalLoginState } from './types'; +import type { PortalBootstrapData } from './types'; var portalBootstrap: PortalBootstrapData = getBootstrap(); var LICENSE_API_BASE = getCommercialAPIBaseURL(); @@ -30,30 +31,14 @@ var LOGOUT_PATH = getLogoutPath(); var ACCOUNT_API_BASE_PATH = getAccountAPIBasePath(); var PORTAL_API_BASE_PATH = getPortalAPIBasePath(); -var loginState: PortalLoginState = { - emailValue: '', - sending: false, - success: false, - error: '', -}; - -type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; type ToastElement = HTMLElement & { _timer?: ReturnType }; -function getElement(id): T | null { - return document.getElementById(id) as T | null; -} - -function asHTMLElement(target: EventTarget | null): HTMLElement | null { - return target instanceof HTMLElement ? target : null; -} - function renderHeader() { var userInfo = document.getElementById('portal-user-info'); if (!userInfo) return; userInfo.innerHTML = renderHeaderHTML({ bootstrap: portalBootstrap, - loginState: loginState, + loginState: authController.getLoginState(), signupPath: SIGNUP_PATH, accountAPIBasePath: ACCOUNT_API_BASE_PATH, }); @@ -65,7 +50,7 @@ function renderPortalApp() { if (!root) return; var context = { bootstrap: portalBootstrap, - loginState: loginState, + loginState: authController.getLoginState(), signupPath: SIGNUP_PATH, accountAPIBasePath: ACCOUNT_API_BASE_PATH, }; @@ -85,8 +70,8 @@ function applyBootstrap(data) { LOGOUT_PATH = getLogoutPath(); ACCOUNT_API_BASE_PATH = getAccountAPIBasePath(); PORTAL_API_BASE_PATH = getPortalAPIBasePath(); - if (!portalBootstrap.authenticated && !loginState.emailValue) { - loginState.emailValue = portalBootstrap.email || ''; + if (!portalBootstrap.authenticated) { + authController.syncBootstrapEmail(portalBootstrap.email || ''); } renderPortalApp(); } @@ -110,7 +95,7 @@ async function refreshBootstrap() { } function showToast(msg, isError = false) { - var t = getElement('toast'); + var t = document.getElementById('toast') as ToastElement | null; if (!t) return; t.textContent = msg; t.className = 'toast visible' + (isError ? ' error' : ''); @@ -118,92 +103,17 @@ function showToast(msg, isError = false) { t._timer = setTimeout(function() { t.className = 'toast'; }, 4000); } -function resetLoginState(options) { - loginState.sending = false; - loginState.error = ''; - loginState.success = false; - if (options && options.keepEmail) return; - loginState.emailValue = ''; -} - -async function sendMagicLink() { - var email = String(loginState.emailValue || '').trim(); - if (!email) { - var input = getElement('portal-login-email'); - if (input) input.focus(); - return; - } - loginState.sending = true; - loginState.error = ''; - loginState.success = false; - renderPortalApp(); - try { - var response = await fetch(MAGIC_LINK_REQUEST_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: email }) - }); - if (response.ok || response.status === 404) { - loginState.sending = false; - loginState.success = true; - renderPortalApp(); - return; - } - if (response.status === 429) { - loginState.error = 'Too many requests. Please wait a moment and try again.'; - } else { - loginState.error = 'Something went wrong. Please try again.'; - } - } catch (_) { - loginState.error = 'Network error. Please check your connection and try again.'; - } - loginState.sending = false; - renderPortalApp(); -} - -document.addEventListener('click', function(event) { - var portalActionEl = asHTMLElement(event.target)?.closest('[data-portal-action]'); - if (portalActionEl) { - var portalAction = portalActionEl.getAttribute('data-portal-action') || ''; - switch (portalAction) { - case 'send-magic-link': - event.preventDefault(); - sendMagicLink(); - return; - case 'resend-magic-link': - event.preventDefault(); - loginState.success = false; - loginState.error = ''; - renderPortalApp(); - sendMagicLink(); - return; - default: - break; - } - } - - var logoutBtn = asHTMLElement(event.target)?.closest('#logout-btn') as HTMLButtonElement | null; - if (logoutBtn) { - event.preventDefault(); - logoutBtn.disabled = true; - logoutBtn.textContent = 'Signing out…'; - (async function() { - try { - await fetch(LOGOUT_PATH, { method: 'POST' }); - } catch (_) {} - window.location.href = PORTAL_PATH; - })(); - return; - } - -}); - -document.addEventListener('input', function(event) { - var target = asHTMLElement(event.target) as FormValueElement | null; - if (!target) return; - if (target.getAttribute('data-portal-input') === 'login-email') { - loginState.emailValue = target.value; - } +var authController = installAuthController({ + getMagicLinkRequestPath: function() { + return MAGIC_LINK_REQUEST_PATH; + }, + getLogoutPath: function() { + return LOGOUT_PATH; + }, + getPortalPath: function() { + return PORTAL_PATH; + }, + renderPortal: renderPortalApp, }); installAccountController({ @@ -220,7 +130,6 @@ installAccountController({ showToast: showToast }); -loginState.emailValue = portalBootstrap.email || ''; applyBootstrap(portalBootstrap); if (portalBootstrap.authenticated) { refreshBootstrap();