feat(06-01): add first-run setup page with server URL input and validation

- Create setup.html with centered card on dark OKLCH background
- Validate URL via URL constructor, reject malformed input (T-06-01)
- Warn on non-HTTPS non-localhost URLs but allow (internal LAN support)
- Persist server_url to tauri-plugin-store config.json
- Navigate WebView to configured server after save
- All visible strings in German (Verbinden, Server-URL eingeben, etc.)
- Design tokens match Tessera OKLCH color system (primary, dark bg)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 10:14:17 +02:00
parent 48e3a693aa
commit d944aaa5a0
+254
View File
@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tessera - Setup</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: oklch(0.17 0.01 260);
color: oklch(0.95 0 0);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.setup-card {
text-align: center;
max-width: 440px;
width: 100%;
padding: 48px 32px;
background: oklch(0.23 0.01 260);
border-radius: 12px;
border: 1px solid oklch(0.30 0.01 260);
}
h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
color: oklch(0.91 0.19 102);
}
.subtitle {
font-size: 0.875rem;
color: oklch(0.65 0 0);
margin-bottom: 32px;
}
label {
display: block;
text-align: left;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 8px;
color: oklch(0.85 0 0);
}
input[type="url"] {
width: 100%;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid oklch(0.30 0.01 260);
background: oklch(0.20 0.01 260);
color: oklch(0.95 0 0);
font-size: 1rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
input[type="url"]:focus {
border-color: oklch(0.91 0.19 102);
}
input[type="url"]::placeholder {
color: oklch(0.45 0 0);
}
.error-message {
display: none;
text-align: left;
font-size: 0.8125rem;
color: oklch(0.55 0.2 27);
margin-top: 6px;
}
.warning-message {
display: none;
text-align: left;
font-size: 0.8125rem;
color: oklch(0.75 0.15 85);
margin-top: 6px;
}
button {
margin-top: 24px;
width: 100%;
padding: 12px 32px;
border-radius: 8px;
border: none;
background: oklch(0.91 0.19 102);
color: oklch(0.20 0.02 90);
cursor: pointer;
font-size: 1rem;
font-weight: 600;
font-family: inherit;
transition: opacity 0.2s;
}
button:hover {
opacity: 0.9;
}
button:active {
opacity: 0.8;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="setup-card">
<h1>Tessera</h1>
<p class="subtitle">Desktop-Client einrichten</p>
<label for="server-url">Server-URL eingeben:</label>
<input
id="server-url"
type="url"
placeholder="https://tessera.example.com"
value="http://localhost:3000"
autocomplete="off"
spellcheck="false"
/>
<p id="error-msg" class="error-message"></p>
<p id="warning-msg" class="warning-message"></p>
<button id="connect-btn" type="button">Verbinden</button>
</div>
<script type="module">
import { load } from '@tauri-apps/plugin-store';
const urlInput = document.getElementById('server-url');
const connectBtn = document.getElementById('connect-btn');
const errorMsg = document.getElementById('error-msg');
const warningMsg = document.getElementById('warning-msg');
/**
* Validate a server URL using the URL constructor.
* Returns { valid: boolean, warning: string | null, error: string | null }
*/
function validateUrl(input) {
const trimmed = input.trim();
if (!trimmed) {
return { valid: false, warning: null, error: 'Bitte eine URL eingeben.' };
}
let parsed;
try {
parsed = new URL(trimmed);
} catch {
return { valid: false, warning: null, error: 'Ungueltige URL. Bitte eine gueltige URL eingeben (z.B. https://tessera.example.com).' };
}
// Only allow http and https protocols
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { valid: false, warning: null, error: 'Nur HTTP und HTTPS URLs sind erlaubt.' };
}
// Warn on non-https for non-localhost addresses (T-06-01 mitigation)
const isLocalhost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '::1';
if (parsed.protocol === 'http:' && !isLocalhost) {
return {
valid: true,
warning: 'Warnung: Unverschluesselte Verbindung (HTTP). Fuer Produktivumgebungen wird HTTPS empfohlen.',
error: null,
};
}
return { valid: true, warning: null, error: null };
}
function showError(message) {
errorMsg.textContent = message;
errorMsg.style.display = 'block';
warningMsg.style.display = 'none';
}
function showWarning(message) {
warningMsg.textContent = message;
warningMsg.style.display = 'block';
errorMsg.style.display = 'none';
}
function clearMessages() {
errorMsg.style.display = 'none';
warningMsg.style.display = 'none';
}
async function connect() {
clearMessages();
const rawUrl = urlInput.value;
const result = validateUrl(rawUrl);
if (!result.valid) {
showError(result.error);
return;
}
if (result.warning) {
showWarning(result.warning);
}
// Normalize the URL
const normalizedUrl = new URL(rawUrl.trim()).href;
// Disable button during save
connectBtn.disabled = true;
connectBtn.textContent = 'Verbinde...';
try {
const store = await load('config.json', { autoSave: true });
await store.set('server_url', normalizedUrl);
// Navigate the WebView to the configured server
window.location.href = normalizedUrl;
} catch (err) {
showError('Fehler beim Speichern der Konfiguration: ' + (err.message || err));
connectBtn.disabled = false;
connectBtn.textContent = 'Verbinden';
}
}
// Button click handler
connectBtn.addEventListener('click', connect);
// Enter key support
urlInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
connect();
}
});
// Clear error messages on input change
urlInput.addEventListener('input', clearMessages);
</script>
</body>
</html>