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/index.sample.html b/index.sample.html
deleted file mode 100644
index c68b276..0000000
--- a/index.sample.html
+++ /dev/null
@@ -1,176 +0,0 @@
-
-
-
-
-
-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.
-
-
-
-
-
-
-
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