From ba90eff5dbdb091d41d95a39ac7f9ddd06851483 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 23 Oct 2026 10:12:41 -0400 Subject: [PATCH 1/4] feat(frontend): add a terminal theme and three-way theme switcher Adds the cyber theme to the console and login page, replaces the dark/light toggle with a theme switcher, and moves the login page script to /login.js so it is served under the existing CSP. --- docker/Dockerfile | 1 + packages/api/public/login.js | 112 ++++++++++++++++++++ packages/api/src/auth.ts | 4 +- packages/api/src/loginPage.ts | 3 +- packages/frontend/index.html | 2 +- packages/frontend/src/components/Layout.tsx | 19 ++-- packages/frontend/src/index.css | 72 +++++++++++++ packages/frontend/src/lib/useTheme.ts | 8 +- 8 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 packages/api/public/login.js diff --git a/docker/Dockerfile b/docker/Dockerfile index f9be892..011e304 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -27,6 +27,7 @@ RUN apk add --no-cache ca-certificates libstdc++ WORKDIR /app COPY --from=api-build /app/server ./server COPY --from=frontend-build /app/packages/frontend/dist ./public +COPY packages/api/public ./public COPY TheCloud.png ./public/TheCloud.png COPY svgAsset/authentik.svg ./public/authentik.svg COPY aws-icons-svg ./public/aws-icons diff --git a/packages/api/public/login.js b/packages/api/public/login.js new file mode 100644 index 0000000..133a980 --- /dev/null +++ b/packages/api/public/login.js @@ -0,0 +1,112 @@ +(() => { + const root = document.documentElement; + const buttons = document.querySelectorAll('.tg button'); + const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; + + function setTheme(theme) { + root.setAttribute('data-theme', theme); + try { + localStorage.setItem('thecloud-theme', theme); + } catch (ignored) { + // Theme persistence is optional when browser storage is unavailable. + } + buttons.forEach((button) => button.classList.toggle('on', button.dataset.t === theme)); + } + + let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; + try { + const savedTheme = localStorage.getItem('thecloud-theme'); + if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') { + initialTheme = savedTheme; + } + } catch (ignored) { + // The OS preference remains the fallback when browser storage is unavailable. + } + setTheme(initialTheme); + + buttons.forEach((button) => button.addEventListener('click', () => setTheme(button.dataset.t))); + document.addEventListener('keydown', (event) => { + if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey && !event.altKey && !/INPUT|TEXTAREA/.test(event.target.tagName)) { + const themes = ['dark', 'light', 'cyber']; + setTheme(themes[(themes.indexOf(root.getAttribute('data-theme')) + 1) % themes.length]); + } + }); + + const lines = ['hello, engineer.', 'aws s3 mb s3://my-bucket', 'your own AWS, on your own hardware.', 'sfn start-execution --local', 'welcome to TheCloud.']; + const typewriter = document.getElementById('tw'); + let line = 0; + let count = 0; + let deleting = false; + if (reducedMotion) { + typewriter.textContent = lines[2]; + } else { + function type() { + const word = lines[line]; + if (deleting) { + count--; + typewriter.textContent = word.slice(0, count); + if (count === 0) { + deleting = false; + line = (line + 1) % lines.length; + setTimeout(type, 400); + return; + } + setTimeout(type, 35); + return; + } + count++; + typewriter.textContent = word.slice(0, count); + if (count === word.length) { + deleting = true; + setTimeout(type, 2200); + return; + } + setTimeout(type, 75 + Math.random() * 50); + } + type(); + } + + const hero = document.getElementById('top'); + const glow = document.getElementById('grid-glow'); + if (!reducedMotion) { + hero.addEventListener('mousemove', (event) => { + const rect = hero.getBoundingClientRect(); + glow.style.left = `${event.clientX - rect.left}px`; + glow.style.top = `${event.clientY - rect.top}px`; + glow.style.opacity = '1'; + }); + hero.addEventListener('mouseleave', () => { + glow.style.opacity = '0'; + }); + } + + const progress = document.getElementById('bar'); + addEventListener('scroll', () => { + const height = document.documentElement.scrollHeight - innerHeight; + progress.style.width = `${height > 0 ? scrollY / height * 100 : 0}%`; + }, {passive: true}); + + const form = document.querySelector('[data-sso-form]'); + if (form) { + form.addEventListener('submit', () => { + const button = form.querySelector('.btn'); + button.classList.add('is-loading'); + button.disabled = true; + document.getElementById('label').textContent = 'Redirecting...'; + }); + } + + const copy = document.getElementById('cp'); + if (copy) { + copy.addEventListener('click', () => { + navigator.clipboard.writeText(document.getElementById('code').innerText).then(() => { + copy.textContent = 'copied'; + copy.classList.add('ok'); + setTimeout(() => { + copy.textContent = 'copy'; + copy.classList.remove('ok'); + }, 1800); + }); + }); + } +})(); diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index 15ba34b..c28c4fe 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -288,7 +288,7 @@ function loginErrorMessage(code: string | undefined): string | null { return 'We could not complete your sign-in. Please try again.' } -function isPublic(path: string): boolean { return path === '/login' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' } +function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' } function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) } function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' } function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) } @@ -336,4 +336,4 @@ function parseTrustedProxies(value: string | undefined): BlockList | null { } return proxies } -function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-mgwziK4YXYUt2nNB711bt/nNzs3je0rpErGfX/6eqmc='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') } +function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-AbRSUfgtaJwJXaS+/FXX4ATIx390dHnVN1Z7G7jqB8s='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') } diff --git a/packages/api/src/loginPage.ts b/packages/api/src/loginPage.ts index 3b3e985..c41a8aa 100644 --- a/packages/api/src/loginPage.ts +++ b/packages/api/src/loginPage.ts @@ -19,6 +19,7 @@ export function renderLoginPage(options: LoginPageOptions): string { + Sign in · TheCloud - - - - -
- - -
-
-
- -
-

Your own cloud, on your own hardware.

-
    -
  • Invoke functionsLambda
  • -
  • Store objectsS3
  • -
  • Queue messagesSQS
  • -
  • Publish eventsSNS
  • -
  • Query tablesDynamoDB
  • -
  • Orchestrate workflowsStep Functions
  • -
-

Build and test against AWS services locally, with SSO in front.

-
-
-
- - - -