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:
@@ -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>
|
||||||
Reference in New Issue
Block a user