Files
floci-ui/packages/api/src/loginPage.ts
T
Zakaria ba90eff5db 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.
2026-10-23 10:12:41 -04:00

54 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
type LoginPageOptions = {
mode: 'local' | 'oidc'
csrf: string
returnTo: string
error: string | null
styleNonce: string
}
export function renderLoginPage(options: LoginPageOptions): string {
const destination = options.returnTo === '/console/aws' ? '' : `<div class="dest">Landing on <span>${escapeHtml(options.returnTo)}</span></div>`
const alert = options.error ? `<div class="alert" role="alert">${escapeHtml(options.error)}</div>` : ''
const signIn = options.mode === 'oidc'
? `<form method="get" action="/api/auth/oidc/login" data-sso-form><input type="hidden" name="returnTo" value="${escapeHtml(options.returnTo)}"><button class="btn" type="submit"><img class="authentik-icon" src="/authentik.svg" alt="" aria-hidden="true"><span class="sp" aria-hidden="true"></span><span id="label">Sign in with Authentik</span></button></form>`
: `<form method="post" action="/api/auth/login" class="local-form"><input type="hidden" name="csrf" value="${escapeHtml(options.csrf)}"><input type="hidden" name="returnTo" value="${escapeHtml(options.returnTo)}"><label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus><label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required><button class="btn" type="submit">Sign in</button></form>`
return `<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<link rel="icon" href="/TheCloud.png" />
<title>Sign in · TheCloud</title>
<style nonce="${options.styleNonce}">
:root[data-theme=dark]{--bg:#07111d;--rail:#091727;--panel:#0c1a2b;--fg:#e8f1fa;--dim:#bdd0e3;--mut:#8aa3bc;--ac:#6cc4ff;--acr:108,196,255;--ac2:#ffb547;--bd:rgba(232,241,250,.1);--bd2:rgba(232,241,250,.2);--on:#06223a;--err:#ff9a9a;--errbg:#3a1822}
:root[data-theme=light]{--bg:#eaf2fa;--rail:#f6faff;--panel:#fff;--fg:#0e2238;--dim:#2f4660;--mut:#4a627a;--ac:#0a6fc2;--acr:10,111,194;--ac2:#c26a00;--bd:rgba(14,34,56,.12);--bd2:rgba(14,34,56,.26);--on:#fff;--err:#a4262c;--errbg:#fdeaea}
:root[data-theme=cyber]{--bg:#02060a;--rail:#050d10;--panel:#050d10;--fg:#4eff96;--dim:#8affb8;--mut:#2f9a68;--ac:#ffea00;--acr:255,234,0;--ac2:#ff10f0;--bd:rgba(78,255,150,.2);--bd2:rgba(78,255,150,.42);--on:#02060a;--err:#ff8da1;--errbg:#2a0a14}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-width:320px;background:var(--bg);color:var(--fg);font-family:system-ui,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}.mono{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace}.serif{font-family:ui-serif,"Iowan Old Style","Palatino Linotype",Georgia,serif}.bar{position:fixed;top:0;left:0;height:3px;width:0;z-index:10;background:linear-gradient(90deg,var(--ac),var(--ac2))}.shell{display:grid;grid-template-columns:minmax(310px,24vw) 1fr;min-height:100vh}.rail{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;padding:38px;border-right:1px solid var(--bd);background:var(--rail)}.logo{display:flex;align-items:center;gap:16px;color:var(--ac);text-decoration:none}.brand-mark{width:82px;height:82px;object-fit:contain;filter:drop-shadow(0 8px 16px rgba(0,0,0,.18))}.brand-name{font-size:30px;font-weight:750;letter-spacing:-.06em}.login{max-width:304px;margin:auto 0;transform:translateY(-44px)}.login h1{font-size:17px;margin:0 0 7px}.lead{margin:0;color:var(--mut);font-size:14px;line-height:1.55}.dest,.alert{margin-top:16px;padding:10px 12px;border-radius:6px;font-size:12px;line-height:1.45}.dest{border:1px dashed var(--bd2);color:var(--mut);overflow-wrap:anywhere}.dest span{color:var(--fg)}.alert{border:1px solid var(--err);background:var(--errbg);color:var(--err)}.btn{width:100%;min-height:50px;margin-top:22px;display:flex;align-items:center;justify-content:center;gap:10px;border:0;border-radius:999px;background:var(--ac);color:var(--on);font:700 14px system-ui,sans-serif;cursor:pointer;transition:transform .2s,box-shadow .2s}.btn:hover{transform:translateY(-2px);box-shadow:0 12px 28px rgba(var(--acr),.3)}.btn:focus-visible,.tg button:focus-visible{outline:2px solid var(--ac);outline-offset:3px}.authentik-icon{width:20px;height:20px;object-fit:contain}.sp{display:none;width:14px;height:14px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}.btn.is-loading .sp{display:block}.btn.is-loading .authentik-icon{display:none}@keyframes spin{to{transform:rotate(360deg)}}.fine{margin:14px 0 0;text-align:center;color:var(--mut);font-size:12px;line-height:1.45}.local-form{display:grid;gap:8px;margin-top:18px}.local-form label{margin-top:5px;color:var(--mut);font:11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em}.local-form input:not([type=hidden]){min-height:42px;padding:0 12px;border:1px solid var(--bd2);border-radius:5px;background:var(--panel);color:var(--fg)}.local-form .btn{margin-top:14px}.rail-bottom{color:var(--mut);font-size:12px;line-height:1.55}.tg{display:flex;width:max-content;gap:3px;margin-bottom:18px;padding:3px;border:1px solid var(--bd2);border-radius:99px}.tg button{display:grid;place-items:center;width:29px;height:29px;border:0;border-radius:50%;background:none;color:var(--mut);cursor:pointer}.tg button.on{background:var(--ac);color:var(--on)}.canvas{min-width:0;padding:20px}.hero{position:relative;min-height:760px;display:grid;place-items:center;overflow:hidden;padding:80px 48px;border:1px solid var(--bd2);border-radius:28px;background:linear-gradient(var(--bd) 1px,transparent 1px),linear-gradient(90deg,var(--bd) 1px,transparent 1px),var(--panel);background-size:56px 56px}.hero::before{content:"";position:absolute;width:720px;height:720px;left:-300px;top:-250px;background:radial-gradient(circle,rgba(var(--acr),.22),transparent 68%);pointer-events:none}.hero::after{content:"";position:absolute;width:600px;height:600px;right:-280px;bottom:-280px;background:radial-gradient(circle,rgba(255,181,71,.16),transparent 68%);pointer-events:none}.grid-glow{position:absolute;width:640px;height:640px;border-radius:50%;background:radial-gradient(circle,rgba(var(--acr),.18),transparent 62%);transform:translate(-50%,-50%);pointer-events:none;opacity:0;transition:opacity .4s}.welcome{position:relative;z-index:1;width:min(100%,1040px);text-align:center;transform:translateY(-24px)}.tag{display:inline-block;margin-bottom:20px;padding:5px 9px;border:1px solid rgba(var(--acr),.45);border-radius:3px;background:rgba(var(--acr),.08);color:var(--ac);font:500 11px ui-monospace,monospace;letter-spacing:.1em;text-transform:uppercase}.welcome h2{max-width:800px;min-height:2.25em;margin:0 auto 14px;font-size:clamp(2.35rem,5vw,4.8rem);line-height:1.05;letter-spacing:-.05em}.prompt{color:var(--ac);font-family:ui-monospace,monospace}#tw::after{content:"▌";color:var(--ac);margin-left:5px;animation:blink 1s steps(1) infinite}@keyframes blink{50%{opacity:0}}.sub{margin:0 auto 42px;max-width:40rem;color:var(--dim);font-size:clamp(1.05rem,1.8vw,1.3rem);line-height:1.55}.capabilities{display:grid;grid-template-columns:repeat(6,minmax(120px,1fr));gap:14px}.capability{min-height:158px;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:18px 10px;border:1px solid var(--bd2);border-radius:18px;background:rgba(12,26,43,.76);box-shadow:0 20px 50px rgba(0,0,0,.12);transition:transform .2s,border-color .2s}.capability:hover{transform:translateY(-5px);border-color:var(--ac)}.capability img{width:46px;height:46px;object-fit:contain;margin-bottom:15px}.capability strong{font-size:13px;line-height:1.2}.capability small{margin-top:5px;color:var(--mut);font-size:12px}.note{margin:38px 0 0;color:var(--dim);font-size:14px}.quickstart{display:grid;grid-template-columns:minmax(0,.4fr) minmax(0,.6fr);gap:48px;margin-top:20px;padding:96px 56px;align-items:start;border:1px solid var(--bd2);border-radius:28px;background:var(--panel)}.quickstart h2{margin:14px 0 20px;font-size:clamp(2rem,4vw,3.2rem);line-height:1.05;letter-spacing:-.03em}.quickstart p{color:var(--mut);line-height:1.65}.win{overflow:hidden;border:1px solid var(--bd2);border-radius:8px;background:#050d17;box-shadow:0 30px 60px -34px #000}.win header{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid #ffffff1a;color:#8aa3bc;font-size:12px}.win header span{display:flex;align-items:center;gap:8px}.win header i{width:11px;height:11px;border-radius:50%;display:inline-block}.r{background:#ff5f57}.y{background:#febc2e}.g{background:#28c840}.cp{border:1px solid #ffffff33;border-radius:3px;background:none;color:#8aa3bc;padding:5px 11px;font:500 11px ui-monospace,monospace;cursor:pointer}.cp.ok{background:var(--ac);border-color:var(--ac);color:var(--on)}pre{margin:0;padding:22px 24px;overflow-x:auto;color:#e8f1fa;font:13px/1.75 ui-monospace,monospace}.k{color:#ff7b72}.s{color:#a5d6ff}.f{color:#d2a8ff}.c{color:#6e7681;font-style:italic}.o{color:#ffa657}footer{padding:30px 36px;border-top:1px solid var(--bd);color:var(--mut);font-size:13px}footer div{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap}kbd{padding:1px 6px;border:1px solid var(--bd2);border-radius:3px;color:var(--fg)}[data-theme=light] .capability{background:#fff;border-color:#91acc7;box-shadow:0 16px 34px rgba(14,34,56,.11)}[data-theme=light] .capability strong{color:#0e2238}[data-theme=light] .capability small{color:#2f4660}
@media(max-width:1100px){.capabilities{grid-template-columns:repeat(3,minmax(140px,1fr))}.hero{padding-inline:32px}}@media(max-width:760px){.shell{display:block}.rail{position:relative;height:auto;min-height:0;padding:24px;border-right:0;border-bottom:1px solid var(--bd)}.logo{font-size:26px}.login{max-width:440px;margin:50px auto;transform:none}.rail-bottom{display:flex;justify-content:space-between;align-items:center}.tg{margin:0}.canvas{padding:14px}.hero{min-height:0;padding:70px 20px;border-radius:22px}.welcome{transform:none}.capabilities{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.capability{min-height:140px;border-radius:12px}.quickstart{grid-template-columns:1fr;margin-top:14px;padding:64px 24px;gap:30px;border-radius:22px}footer{padding:28px 10px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}.grid-glow{display:none}}
</style>
</head>
<body>
<div class="bar" id="bar"></div>
<div class="shell">
<aside class="rail"><a class="logo" href="#top"><img class="brand-mark" src="/TheCloud.png" alt=""><span class="brand-name">TheCloud</span></a><main class="login"><h1>Welcome back</h1><p class="lead">Continue with your Authentik account to open the TheCloud console.</p>${destination}${alert}${signIn}<p class="fine">Single sign-on. MFA happens there.</p></main><div class="rail-bottom"><div class="tg" role="group" aria-label="Theme"><button data-t="dark" aria-label="Dark theme">◐</button><button data-t="light" aria-label="Light theme">☼</button><button data-t="cyber" aria-label="Cyber theme">›_</button></div><span>TheCloud is powered by Floci.</span></div></aside>
<div class="canvas"><section class="hero" id="top"><div class="grid-glow" id="grid-glow"></div><div class="welcome"><h2 class="serif"><span class="prompt">$</span> <span id="tw"></span></h2><p class="sub">Your own cloud, on your own hardware.</p><div class="capabilities"><article class="capability"><img src="/aws-icons/Compute/Lambda.svg" alt=""><strong>Invoke functions</strong><small>Lambda</small></article><article class="capability"><img src="/aws-icons/Storage/Simple-Storage-Service.svg" alt=""><strong>Store objects</strong><small>S3</small></article><article class="capability"><img src="/aws-icons/App-Integration/Simple-Queue-Service.svg" alt=""><strong>Queue messages</strong><small>SQS</small></article><article class="capability"><img src="/aws-icons/App-Integration/Simple-Notification-Service.svg" alt=""><strong>Publish events</strong><small>SNS</small></article><article class="capability"><img src="/aws-icons/Database/DynamoDB.svg" alt=""><strong>Query tables</strong><small>DynamoDB</small></article><article class="capability"><img src="/aws-icons/App-Integration/Step-Functions.svg" alt=""><strong>Orchestrate workflows</strong><small>Step Functions</small></article></div><p class="note">Build and test against AWS services locally, with SSO in front.</p></div></section>
<section class="quickstart" id="quickstart"><div><span class="tag">quickstart</span><h2 class="serif">Same CLI. Different cloud.</h2><p>Point any AWS SDK or the AWS CLI at your TheCloud endpoint and your code runs unchanged, with nothing leaving your network.</p></div><div class="win"><header><span><i class="r"></i><i class="y"></i><i class="g"></i>quickstart.sh</span><button class="cp" id="cp" type="button">copy</button></header><pre><code id="code"><span class="c"># point the AWS CLI at TheCloud (sample values)</span>
<span class="k">export</span> AWS_ENDPOINT_URL=<span class="s">http://localhost:4566</span>
<span class="k">export</span> AWS_ACCESS_KEY_ID=<span class="s">test</span> AWS_SECRET_ACCESS_KEY=<span class="s">test</span>
<span class="k">export</span> AWS_DEFAULT_REGION=<span class="s">us-east-1</span>
<span class="f">aws</span> s3 mb s3://demo-bucket
<span class="f">aws</span> sqs create-queue <span class="o">--queue-name</span> fulfilment-queue
<span class="f">aws</span> stepfunctions list-state-machines</code></pre></div></section>
<footer class="mono"><div><span><span class="prompt">$</span> TheCloud</span><span>press <kbd>T</kbd> to switch theme</span></div></footer></div></div>
<script defer src="/login.js"></script>
</body>
</html>`
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (character) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[character] ?? character))
}