258 lines
9.1 KiB
Svelte
258 lines
9.1 KiB
Svelte
<script lang="ts">
|
||
import { scopeSettings, currentScope, type ScopeSettings } from '$lib/stores/scopeContext';
|
||
import { authFetch } from '$lib/auth/apiClient.js';
|
||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||
import { onMount } from 'svelte';
|
||
|
||
currentScope.set(null);
|
||
|
||
const API_BASE = import.meta.env.VITE_API_URL ?? '';
|
||
|
||
let settings = $state<ScopeSettings>({ ...$scopeSettings });
|
||
|
||
// --- API Keys ---
|
||
interface ApiKey {
|
||
id: string;
|
||
label: string;
|
||
createdAt: string | number;
|
||
lastUsedAt: string | number | null;
|
||
}
|
||
|
||
let apiKeysList = $state<ApiKey[]>([]);
|
||
let apiKeysLoading = $state(false);
|
||
let apiKeysError = $state<string | null>(null);
|
||
let newKeyLabel = $state('');
|
||
let newKeyCreating = $state(false);
|
||
let newKeyValue = $state<string | null>(null);
|
||
let confirmDelete = $state<ApiKey | null>(null);
|
||
|
||
function fmtDate(val: string | number | null | undefined): string {
|
||
if (!val) return '–';
|
||
return new Date(typeof val === 'number' ? val : val).toLocaleDateString('de-DE');
|
||
}
|
||
|
||
async function loadApiKeys() {
|
||
apiKeysLoading = true;
|
||
apiKeysError = null;
|
||
try {
|
||
const res = await authFetch(`${API_BASE}/api/api-keys`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
apiKeysList = await res.json() as ApiKey[];
|
||
} catch (e: unknown) {
|
||
apiKeysError = e instanceof Error ? e.message : 'Fehler';
|
||
} finally {
|
||
apiKeysLoading = false;
|
||
}
|
||
}
|
||
|
||
async function createApiKey() {
|
||
if (!newKeyLabel.trim()) return;
|
||
newKeyCreating = true;
|
||
apiKeysError = null;
|
||
try {
|
||
const res = await authFetch(`${API_BASE}/api/api-keys`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ label: newKeyLabel.trim() }),
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const data = await res.json() as ApiKey & { key: string };
|
||
newKeyValue = data.key;
|
||
newKeyLabel = '';
|
||
await loadApiKeys();
|
||
} catch (e: unknown) {
|
||
apiKeysError = e instanceof Error ? e.message : 'Fehler';
|
||
} finally {
|
||
newKeyCreating = false;
|
||
}
|
||
}
|
||
|
||
async function deleteApiKey(key: ApiKey) {
|
||
try {
|
||
const res = await authFetch(`${API_BASE}/api/api-keys/${key.id}`, { method: 'DELETE' });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
await loadApiKeys();
|
||
} catch (e: unknown) {
|
||
apiKeysError = e instanceof Error ? e.message : 'Fehler';
|
||
} finally {
|
||
confirmDelete = null;
|
||
}
|
||
}
|
||
|
||
onMount(loadApiKeys);
|
||
|
||
function save() {
|
||
scopeSettings.set({ ...settings });
|
||
}
|
||
|
||
function reset() {
|
||
scopeSettings.set({
|
||
businessColor: '#4a9eff',
|
||
privateColor: '#e8a944',
|
||
unknownColor: '#555566',
|
||
businessIcon: '🏭',
|
||
privateIcon: '🏠',
|
||
maxTitleLength: 22,
|
||
});
|
||
settings = { ...$scopeSettings };
|
||
}
|
||
|
||
const iconOptions = ['🏭', '🏢', '💼', '📊', '🔵', '⚙️', '🏠', '🏡', '🔒', '🟡', '❤️', '🌿'];
|
||
</script>
|
||
|
||
<div class="space-y-8 max-w-md">
|
||
<h1 class="border-b-2 pb-2.5 text-2xl font-bold" style="border-color: var(--scope-color)">Einstellungen</h1>
|
||
|
||
<section class="space-y-4">
|
||
<h2 class="text-sm font-semibold uppercase text-muted">Kontext-Farben & Icons</h2>
|
||
<p class="text-xs text-muted">Titelleiste und Sidebar-Logo färben sich je nach aktivem Kontext (Firma / Privat / unbekannt).</p>
|
||
|
||
<!-- Business -->
|
||
<div class="rounded border border-border bg-card-bg p-4 space-y-3">
|
||
<div class="flex items-center gap-2 font-semibold" style="color: {settings.businessColor}">
|
||
{settings.businessIcon} Firma
|
||
</div>
|
||
<div class="flex items-center gap-4">
|
||
<label class="text-xs text-muted w-16">Farbe</label>
|
||
<input type="color" bind:value={settings.businessColor} oninput={save}
|
||
class="h-8 w-16 cursor-pointer rounded border border-border bg-transparent" />
|
||
<span class="text-xs text-muted font-mono">{settings.businessColor}</span>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<span class="text-xs text-muted w-16">Icon</span>
|
||
{#each iconOptions as icon}
|
||
<button
|
||
class="text-xl rounded px-1 py-0.5 transition-all {settings.businessIcon === icon ? 'bg-accent/30 ring-1 ring-accent' : 'hover:bg-white/10'}"
|
||
onclick={() => { settings.businessIcon = icon; save(); }}
|
||
>{icon}</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Private -->
|
||
<div class="rounded border border-border bg-card-bg p-4 space-y-3">
|
||
<div class="flex items-center gap-2 font-semibold" style="color: {settings.privateColor}">
|
||
{settings.privateIcon} Privat
|
||
</div>
|
||
<div class="flex items-center gap-4">
|
||
<label class="text-xs text-muted w-16">Farbe</label>
|
||
<input type="color" bind:value={settings.privateColor} oninput={save}
|
||
class="h-8 w-16 cursor-pointer rounded border border-border bg-transparent" />
|
||
<span class="text-xs text-muted font-mono">{settings.privateColor}</span>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<span class="text-xs text-muted w-16">Icon</span>
|
||
{#each iconOptions as icon}
|
||
<button
|
||
class="text-xl rounded px-1 py-0.5 transition-all {settings.privateIcon === icon ? 'bg-accent/30 ring-1 ring-accent' : 'hover:bg-white/10'}"
|
||
onclick={() => { settings.privateIcon = icon; save(); }}
|
||
>{icon}</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Unknown -->
|
||
<div class="rounded border border-border bg-card-bg p-4 space-y-3">
|
||
<div class="flex items-center gap-2 font-semibold" style="color: {settings.unknownColor}">
|
||
– Unbekannt / gemischt
|
||
</div>
|
||
<div class="flex items-center gap-4">
|
||
<label class="text-xs text-muted w-16">Farbe</label>
|
||
<input type="color" bind:value={settings.unknownColor} oninput={save}
|
||
class="h-8 w-16 cursor-pointer rounded border border-border bg-transparent" />
|
||
<span class="text-xs text-muted font-mono">{settings.unknownColor}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="text-xs text-muted hover:text-white underline" onclick={reset}>
|
||
Auf Standard zurücksetzen
|
||
</button>
|
||
</section>
|
||
|
||
<!-- Title truncation -->
|
||
<section class="space-y-4">
|
||
<h2 class="text-sm font-semibold uppercase text-muted">Titel</h2>
|
||
<p class="text-xs text-muted">Titel, die diese Zeichenlänge überschreiten, werden automatisch gekürzt. Der vollständige Text wird als Notiz gespeichert.</p>
|
||
<div class="rounded border border-border bg-card-bg p-4 flex items-center gap-4">
|
||
<label class="text-xs text-muted w-40" for="maxTitleLength">Max. Titellänge</label>
|
||
<input
|
||
id="maxTitleLength"
|
||
type="number"
|
||
min="10"
|
||
max="100"
|
||
bind:value={settings.maxTitleLength}
|
||
oninput={save}
|
||
class="w-20 rounded border border-border bg-bg px-2 py-1 text-sm text-center focus:outline-none focus:ring-1 focus:ring-accent"
|
||
/>
|
||
<span class="text-xs text-muted">Zeichen</span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- API Keys -->
|
||
<section class="space-y-4">
|
||
<h2 class="text-sm font-semibold uppercase text-muted">API Keys</h2>
|
||
<p class="text-xs text-muted">Für Automationen und Maschinen-Zugänge (z.B. iOS Kurzbefehle). Der Key wird nur einmal angezeigt.</p>
|
||
|
||
{#if apiKeysError}
|
||
<p class="text-xs text-red-400">{apiKeysError}</p>
|
||
{/if}
|
||
|
||
{#if newKeyValue}
|
||
<div class="rounded border border-green-600 bg-green-950/40 p-3 space-y-2">
|
||
<p class="text-xs text-green-400 font-semibold">Neuer Key — jetzt kopieren, wird nicht mehr angezeigt:</p>
|
||
<code class="block break-all text-xs font-mono text-green-300 select-all">{newKeyValue}</code>
|
||
<button class="text-xs text-muted hover:text-white underline" onclick={() => newKeyValue = null}>Schliessen</button>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- existing keys -->
|
||
{#if apiKeysLoading}
|
||
<p class="text-xs text-muted">Laden...</p>
|
||
{:else if apiKeysList.length === 0}
|
||
<p class="text-xs text-muted">Keine API Keys vorhanden.</p>
|
||
{:else}
|
||
<ul class="space-y-2">
|
||
{#each apiKeysList as key (key.id)}
|
||
<li class="flex items-center justify-between rounded border border-border bg-card-bg px-3 py-2">
|
||
<div>
|
||
<span class="text-sm font-medium">{key.label}</span>
|
||
<span class="ml-3 text-xs text-muted">erstellt {fmtDate(key.createdAt)}</span>
|
||
{#if key.lastUsedAt}
|
||
<span class="ml-2 text-xs text-muted">zuletzt {fmtDate(key.lastUsedAt)}</span>
|
||
{/if}
|
||
</div>
|
||
<button
|
||
class="text-xs text-red-400 hover:text-red-300 ml-4 shrink-0"
|
||
onclick={() => confirmDelete = key}
|
||
>Löschen</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
|
||
<!-- create new -->
|
||
<div class="flex gap-2">
|
||
<input
|
||
type="text"
|
||
placeholder="Bezeichnung (z.B. iOS Kurzbefehle)"
|
||
bind:value={newKeyLabel}
|
||
class="flex-1 rounded border border-border bg-card-bg px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-accent"
|
||
onkeydown={(e) => e.key === 'Enter' && createApiKey()}
|
||
/>
|
||
<button
|
||
class="rounded bg-accent px-3 py-1.5 text-sm font-medium disabled:opacity-50"
|
||
disabled={!newKeyLabel.trim() || newKeyCreating}
|
||
onclick={createApiKey}
|
||
>{newKeyCreating ? '...' : 'Erstellen'}</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{#if confirmDelete}
|
||
<ConfirmDialog
|
||
message="API Key "{confirmDelete.label}" wirklich löschen? Alle Anwendungen, die diesen Key verwenden, verlieren den Zugriff."
|
||
onconfirm={() => deleteApiKey(confirmDelete!)}
|
||
oncancel={() => confirmDelete = null}
|
||
/>
|
||
{/if}
|