ref(frontend): Replace Bootstrap with Tailwind CSS

Swap the Bootstrap CDN for a self-hosted Tailwind CSS build, mirroring
the myice setup: standalone Tailwind CLI v3.4.17, a one-off build-css.sh
script, and a gitignored generated style.css.

- Add tailwind.config.js, style-input.css, build-css.sh at repo root
- Rewrite templates/index.html: drop Bootstrap link/script, link
  /style.css, convert all classes to Tailwind utilities; showAlert now
  uses a class lookup so the JIT scanner detects dynamic classes
- Add /style.css FileResponse route in main.py; drop cdn.jsdelivr.net
  from script-src/style-src in the CSP
- Flip test assertion to assert cdn.jsdelivr.net is absent from CSP

The generated style.css and tailwindcss binary are gitignored; run
./build-css.sh to regenerate (or --watch for dev).
This commit is contained in:
2026-07-08 14:10:37 +02:00
parent 1a807cfeb4
commit 9c7f2bde11
7 changed files with 100 additions and 34 deletions
+5 -1
View File
@@ -131,4 +131,8 @@ dmypy.json
.idea/ .idea/
# Additional files from existing .gitignore # Additional files from existing .gitignore
.envrc .envrc
# Tailwind CSS generated artifacts
style.css
tailwindcss
Executable
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
set -euo pipefail
# Build CSS using Tailwind CSS standalone CLI
# For local development, run:
# ./build-css.sh # one-off build
# ./build-css.sh --watch # watch mode
version="v3.4.17"
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Linux*) platform="linux";;
Darwin*) platform="macos";;
*) echo "Unsupported OS: $os"; exit 1;;
esac
case "$arch" in
x86_64) arch="x64";;
arm64) arch="arm64";;
aarch64) arch="arm64";;
*) echo "Unsupported architecture: $arch"; exit 1;;
esac
binary="tailwindcss-${platform}-${arch}"
url="https://github.com/tailwindlabs/tailwindcss/releases/download/${version}/${binary}"
if [ ! -f "tailwindcss" ]; then
echo "Downloading Tailwind CSS standalone CLI (${version} ${platform}/${arch})..."
curl -sL "$url" -o tailwindcss
chmod +x tailwindcss
fi
if [ "${1:-}" == "--watch" ]; then
echo "Building CSS in watch mode..."
./tailwindcss -i ./style-input.css -o ./style.css --watch
else
echo "Building CSS..."
./tailwindcss -i ./style-input.css -o ./style.css --minify
fi
+7 -2
View File
@@ -335,8 +335,8 @@ async def read_root():
) )
csp = ( csp = (
"default-src 'self'; " "default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; " f"script-src 'self' 'nonce-{nonce}'; "
"style-src 'self' https://cdn.jsdelivr.net; " "style-src 'self'; "
"connect-src 'self' https://login.infomaniak.com https://api.coingecko.com; " "connect-src 'self' https://login.infomaniak.com https://api.coingecko.com; "
"img-src 'self'; " "img-src 'self'; "
"object-src 'none'; " "object-src 'none'; "
@@ -389,6 +389,11 @@ async def favicon():
"""Serve the favicon""" """Serve the favicon"""
return FileResponse("templates/favicon.ico") return FileResponse("templates/favicon.ico")
@app.get("/style.css")
async def stylesheet():
"""Serve the Tailwind-generated stylesheet"""
return FileResponse("style.css", media_type="text/css")
@app.get("/config", response_model=ClientConfig) @app.get("/config", response_model=ClientConfig)
async def get_client_config(): async def get_client_config():
"""Serve client configuration to frontend""" """Serve client configuration to frontend"""
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./templates/index.html"],
theme: {
extend: {},
},
plugins: [],
}
+35 -30
View File
@@ -4,54 +4,53 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OIDC Login</title> <title>OIDC Login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link rel="stylesheet" href="/style.css">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</head> </head>
<body> <body>
<div class="container mt-5"> <div class="container mx-auto mt-5 px-4">
<div class="row justify-content-center"> <div class="flex justify-center">
<div class="col-md-6"> <div class="w-full md:w-1/2">
<div class="card"> <div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="card-header"> <div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<h1 class="text-center h3">OIDC Login</h1> <h1 class="text-center text-2xl font-bold">OIDC Login</h1>
</div> </div>
<div class="card-body"> <div class="p-4">
<!-- Login Section --> <!-- Login Section -->
<section id="login-section" class="text-center"> <section id="login-section" class="text-center">
<p>Login with your Infomaniak account</p> <p>Login with your Infomaniak account</p>
<button id="login-btn" class="btn btn-primary w-100" aria-label="Login with Infomaniak">Login with Infomaniak</button> <button id="login-btn" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition" aria-label="Login with Infomaniak">Login with Infomaniak</button>
</section> </section>
<!-- User Info Section --> <!-- User Info Section -->
<section id="user-info" class="d-none"> <section id="user-info" class="hidden">
<div class="mb-4"> <div class="mb-4">
<h2 class="h5">Welcome, <span id="user-name">User</span>!</h2> <h2 class="text-xl font-bold">Welcome, <span id="user-name">User</span>!</h2>
<p class="mb-0">Email: <span id="user-email"></span></p> <p class="mb-0">Email: <span id="user-email"></span></p>
</div> </div>
<!-- Secret Phrase Card --> <!-- Secret Phrase Card -->
<div class="card mb-4"> <div class="bg-white rounded-lg shadow-md mb-4 overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="px-4 py-3 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
<h3 class="h5 mb-0">Secret Phrase</h3> <h3 class="text-xl font-bold mb-0">Secret Phrase</h3>
<button id="refresh-secret-btn" class="btn btn-secondary btn-sm" aria-label="Refresh secret phrase">Refresh</button> <button id="refresh-secret-btn" class="bg-gray-500 hover:bg-gray-600 text-white text-sm py-1 px-2 rounded transition" aria-label="Refresh secret phrase">Refresh</button>
</div> </div>
<div class="card-body"> <div class="p-4">
<div id="secret-result" class="mb-0">Checking for secret phrase...</div> <div id="secret-result" class="mb-0">Checking for secret phrase...</div>
</div> </div>
</div> </div>
<!-- Bitcoin Price Card --> <!-- Bitcoin Price Card -->
<div class="card mb-4"> <div class="bg-white rounded-lg shadow-md mb-4 overflow-hidden">
<div class="card-header"> <div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<h3 class="h5 mb-0">Bitcoin Price in CHF</h3> <h3 class="text-xl font-bold mb-0">Bitcoin Price in CHF</h3>
</div> </div>
<div class="card-body"> <div class="p-4">
<button id="fetch-btc-btn" class="btn btn-success mb-3" aria-label="Fetch Bitcoin Price">Fetch Bitcoin Price</button> <button id="fetch-btc-btn" class="bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded mb-3 transition" aria-label="Fetch Bitcoin Price">Fetch Bitcoin Price</button>
<div id="btc-price-result" class="mb-0"></div> <div id="btc-price-result" class="mb-0"></div>
</div> </div>
</div> </div>
<button id="logout-btn" class="btn btn-danger w-100" aria-label="Logout">Logout</button> <button id="logout-btn" class="w-full bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded transition" aria-label="Logout">Logout</button>
</section> </section>
</div> </div>
</div> </div>
@@ -157,12 +156,18 @@
* Show an alert message in the specified element * Show an alert message in the specified element
* @param {HTMLElement} element - Element to show the message in * @param {HTMLElement} element - Element to show the message in
* @param {string} message - Message to display * @param {string} message - Message to display
* @param {string} type - Bootstrap alert type (success, danger, warning, info) * @param {string} type - Alert type (success, danger, warning, info)
*/ */
showAlert(element, message, type = 'info') { showAlert(element, message, type = 'info') {
const alertClasses = {
success: 'bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded',
danger: 'bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded',
warning: 'bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded',
info: 'bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded'
};
element.replaceChildren(); element.replaceChildren();
const alert = document.createElement('div'); const alert = document.createElement('div');
alert.className = `alert alert-${type}`; alert.className = alertClasses[type] || alertClasses.info;
alert.setAttribute('role', 'alert'); alert.setAttribute('role', 'alert');
if (typeof message === 'string') { if (typeof message === 'string') {
alert.textContent = message; alert.textContent = message;
@@ -427,8 +432,8 @@
* @param {Object} user - User object with email, first_name, last_name * @param {Object} user - User object with email, first_name, last_name
*/ */
showUserInfo(user) { showUserInfo(user) {
elements.loginSection.classList.add('d-none'); elements.loginSection.classList.add('hidden');
elements.userInfo.classList.remove('d-none'); elements.userInfo.classList.remove('hidden');
// Display user's full name // Display user's full name
elements.userName.textContent = utils.getUserDisplayName(user); elements.userName.textContent = utils.getUserDisplayName(user);
@@ -441,8 +446,8 @@
* Hide user information section * Hide user information section
*/ */
hideUserInfo() { hideUserInfo() {
elements.loginSection.classList.remove('d-none'); elements.loginSection.classList.remove('hidden');
elements.userInfo.classList.add('d-none'); elements.userInfo.classList.add('hidden');
}, },
/** /**
@@ -699,4 +704,4 @@
})(); })();
</script> </script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -38,7 +38,7 @@ def test_root_serves_csp_with_nonce():
csp = response.headers.get("content-security-policy", "") csp = response.headers.get("content-security-policy", "")
assert "nonce-" in csp assert "nonce-" in csp
assert "script-src" in csp assert "script-src" in csp
assert "cdn.jsdelivr.net" in csp assert "cdn.jsdelivr.net" not in csp
assert "connect-src" in csp assert "connect-src" in csp
assert "login.infomaniak.com" in csp assert "login.infomaniak.com" in csp
assert "api.coingecko.com" in csp assert "api.coingecko.com" in csp