feat(byok): IndexedDB vault + settings UI for user API keys

Phase 4-5 of BYOK. Wires the BYOK backend into the running app and
gives users a UI to manage their keys.

Data layer (_byokKeys table, v16 schema):
- Encrypted at rest via user master key (wrapValue/unwrapValue)
- NOT in SYNC_APP_MAP — keys stay device-local on purpose
- Tracks per-key usage: count, total tokens, cumulative USD cost

byokVault API (lib/byok/):
- listAll() / listMeta() — decrypt on read
- create() — encrypts + auto-handles isDefault collisions
- update() — label/model/isDefault edits (not the key itself)
- delete() — soft-delete
- recordUsage() — called from backend's onUsage callback
- getForProvider() — resolver helper

initByok() wires the ByokBackend into the shared orchestrator at
app startup. The resolver picks the most-recently-used provider's
key by default. Usage callback updates the key's counters + cost
via estimateCost().

Settings page (/settings/ai-keys):
- List existing keys with provider, label, model, usage stats
- Add form: provider picker, label, API key (password input),
  model selector with provider defaults, isDefault toggle
- Inline edit for label + model
- Set-as-default action
- Soft-delete with confirmation
- Gracefully handles locked vault state

Companion chat dropdown already picks up the new 'byok' tier
from ALL_TIERS — user can select BYOK directly as KI-Modus.

Total BYOK implementation: ~1500 LOC across 12 files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 15:14:00 +02:00
parent a33857fa39
commit db8c2574d6
7 changed files with 933 additions and 151 deletions

View file

@ -0,0 +1,3 @@
export { byokVault } from './vault';
export { initByok } from './init';
export type { ByokKeyRecord, ByokKeyPlain } from './types';

View file

@ -0,0 +1,78 @@
/**
* BYOK initialization wires the ByokBackend into the shared orchestrator
* once the app is running and the vault is available.
*
* Called from (app)/+layout.svelte after manaStore.initialize() so the
* user's master key is available for decrypting API keys on demand.
*/
import {
llmOrchestrator,
ByokBackend,
BUILTIN_BYOK_PROVIDERS,
estimateCost,
type ByokKeyResolver,
type ByokUsageCallback,
type ResolvedByokKey,
type ByokProviderId,
} from '@mana/shared-llm';
import { byokVault } from './vault';
let initialized = false;
let _currentKeyIdByProvider = new Map<ByokProviderId, string>();
/** Resolver callback: looks up the user's key for a given task. */
const resolver: ByokKeyResolver = async ({ taskName }) => {
// For the probe call, just check if ANY key exists
if (taskName === '__probe__') {
const keys = await byokVault.listMeta();
if (keys.length === 0) return null;
const first = keys[0];
const full = await byokVault.getForProvider(first.provider);
if (!full) return null;
_currentKeyIdByProvider.set(full.provider, full.id);
return { provider: full.provider, apiKey: full.apiKey, model: full.model ?? '' };
}
// TODO: honor per-task provider overrides (e.g. "byok:anthropic")
// For now, use the user's default provider from settings, or the
// provider with the most-recently-used key.
const allMeta = await byokVault.listMeta();
if (allMeta.length === 0) return null;
// Pick the provider with the most recent lastUsedAt, or the first one
const sorted = [...allMeta].sort((a, b) =>
(b.lastUsedAt ?? b.createdAt).localeCompare(a.lastUsedAt ?? a.createdAt)
);
const chosenProvider = sorted[0].provider;
const key = await byokVault.getForProvider(chosenProvider);
if (!key) return null;
_currentKeyIdByProvider.set(key.provider, key.id);
// Use the key's configured model, or fall back to the provider default
const provider = BUILTIN_BYOK_PROVIDERS.find((p) => p.id === key.provider);
const model = key.model || provider?.defaultModel || '';
return { provider: key.provider, apiKey: key.apiKey, model };
};
/** Usage tracker: increments counters on the key that was used. */
const onUsage: ByokUsageCallback = ({ provider, model, promptTokens, completionTokens }) => {
const keyId = _currentKeyIdByProvider.get(provider);
if (!keyId) return;
const totalTokens = promptTokens + completionTokens;
const costUsd = estimateCost(model, promptTokens, completionTokens);
void byokVault.recordUsage(keyId, totalTokens, costUsd);
};
export function initByok(): void {
if (initialized) return;
const backend = new ByokBackend({
resolver,
providers: BUILTIN_BYOK_PROVIDERS,
onUsage,
});
llmOrchestrator.registerBackend(backend);
initialized = true;
}

View file

@ -0,0 +1,44 @@
/**
* BYOK Key types device-local encrypted storage of user LLM API keys.
*/
import type { ByokProviderId } from '@mana/shared-llm';
/** Raw record as stored in IndexedDB. The apiKey field is encrypted. */
export interface ByokKeyRecord {
id: string;
provider: ByokProviderId;
label: string;
/** Encrypted via AES-GCM wrapValue() — stored as base64 {iv, ct} blob */
apiKeyEncrypted: unknown;
/** Optional model override (provider default used if undefined) */
model?: string;
/** Whether this is the default key for this provider */
isDefault: boolean;
createdAt: string;
updatedAt: string;
lastUsedAt?: string;
/** Incremented after each successful call */
usageCount: number;
/** Cumulative tokens used */
totalTokens: number;
/** Cumulative cost estimate in USD */
totalCostUsd: number;
deletedAt?: string;
}
/** Plaintext view used by the UI (never serialized). */
export interface ByokKeyPlain {
id: string;
provider: ByokProviderId;
label: string;
apiKey: string;
model?: string;
isDefault: boolean;
createdAt: string;
updatedAt: string;
lastUsedAt?: string;
usageCount: number;
totalTokens: number;
totalCostUsd: number;
}

View file

@ -0,0 +1,183 @@
/**
* BYOK Key Vault encrypted CRUD for user-provided API keys.
*
* Keys are encrypted with the user's master key (same vault as the
* rest of Mana's encrypted tables). Without an unlocked vault, keys
* cannot be read even if someone exfiltrates IndexedDB, the keys
* stay ciphertext.
*
* NOT synced: keys live device-local. Users must add them on each
* device. This is deliberate a compromised server shouldn't be
* able to redistribute users' API keys.
*/
import { db } from '$lib/data/database';
import { wrapValue, unwrapValue } from '$lib/data/crypto/aes';
import { getActiveKey } from '$lib/data/crypto/key-provider';
import type { ByokProviderId } from '@mana/shared-llm';
import type { ByokKeyRecord, ByokKeyPlain } from './types';
const TABLE = '_byokKeys';
function requireKey(): CryptoKey {
const key = getActiveKey();
if (!key) {
throw new Error('Vault ist nicht entsperrt — bitte zuerst anmelden.');
}
return key;
}
async function encryptApiKey(apiKey: string): Promise<unknown> {
return wrapValue(apiKey, requireKey());
}
async function decryptApiKey(encrypted: unknown): Promise<string> {
const decrypted = await unwrapValue(encrypted, requireKey());
if (typeof decrypted !== 'string') {
throw new Error('Decrypted API key is not a string');
}
return decrypted;
}
async function recordToPlain(rec: ByokKeyRecord): Promise<ByokKeyPlain> {
return {
id: rec.id,
provider: rec.provider,
label: rec.label,
apiKey: await decryptApiKey(rec.apiKeyEncrypted),
model: rec.model,
isDefault: rec.isDefault,
createdAt: rec.createdAt,
updatedAt: rec.updatedAt,
lastUsedAt: rec.lastUsedAt,
usageCount: rec.usageCount,
totalTokens: rec.totalTokens,
totalCostUsd: rec.totalCostUsd,
};
}
export const byokVault = {
/** List all active (non-deleted) keys, decrypted. */
async listAll(): Promise<ByokKeyPlain[]> {
const all = await db.table<ByokKeyRecord>(TABLE).toArray();
const active = all.filter((k) => !k.deletedAt);
return Promise.all(active.map(recordToPlain));
},
/** List keys metadata without decrypting (for UI display of label/usage only) */
async listMeta(): Promise<Omit<ByokKeyPlain, 'apiKey'>[]> {
const all = await db.table<ByokKeyRecord>(TABLE).toArray();
const active = all.filter((k) => !k.deletedAt);
return active.map((r) => ({
id: r.id,
provider: r.provider,
label: r.label,
model: r.model,
isDefault: r.isDefault,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
lastUsedAt: r.lastUsedAt,
usageCount: r.usageCount,
totalTokens: r.totalTokens,
totalCostUsd: r.totalCostUsd,
}));
},
/** Get the default key for a provider, or first available if no default. */
async getForProvider(provider: ByokProviderId): Promise<ByokKeyPlain | null> {
const all = await db.table<ByokKeyRecord>(TABLE).toArray();
const forProvider = all.filter((k) => !k.deletedAt && k.provider === provider);
if (forProvider.length === 0) return null;
const preferred = forProvider.find((k) => k.isDefault) ?? forProvider[0];
return recordToPlain(preferred);
},
/** Create a new key (encrypted before write). */
async create(input: {
provider: ByokProviderId;
label: string;
apiKey: string;
model?: string;
isDefault?: boolean;
}): Promise<ByokKeyPlain> {
const now = new Date().toISOString();
const existing = await db.table<ByokKeyRecord>(TABLE).toArray();
const forProvider = existing.filter((k) => !k.deletedAt && k.provider === input.provider);
// First key for this provider is default automatically
const isDefault = input.isDefault ?? forProvider.length === 0;
// If setting as default, clear other defaults for this provider
if (isDefault && forProvider.length > 0) {
for (const other of forProvider.filter((k) => k.isDefault)) {
await db.table(TABLE).update(other.id, { isDefault: false, updatedAt: now });
}
}
const rec: ByokKeyRecord = {
id: crypto.randomUUID(),
provider: input.provider,
label: input.label,
apiKeyEncrypted: await encryptApiKey(input.apiKey),
model: input.model,
isDefault,
createdAt: now,
updatedAt: now,
usageCount: 0,
totalTokens: 0,
totalCostUsd: 0,
};
await db.table(TABLE).add(rec);
return recordToPlain(rec);
},
/** Update label/model/isDefault (not the key itself — remove+recreate for that). */
async update(
id: string,
patch: { label?: string; model?: string; isDefault?: boolean }
): Promise<void> {
const now = new Date().toISOString();
const existing = await db.table<ByokKeyRecord>(TABLE).get(id);
if (!existing) return;
// If promoting to default, demote others of same provider
if (patch.isDefault === true) {
const all = await db.table<ByokKeyRecord>(TABLE).toArray();
const siblings = all.filter(
(k) => !k.deletedAt && k.provider === existing.provider && k.id !== id
);
for (const s of siblings.filter((k) => k.isDefault)) {
await db.table(TABLE).update(s.id, { isDefault: false, updatedAt: now });
}
}
await db.table(TABLE).update(id, {
...(patch.label !== undefined && { label: patch.label }),
...(patch.model !== undefined && { model: patch.model }),
...(patch.isDefault !== undefined && { isDefault: patch.isDefault }),
updatedAt: now,
});
},
/** Soft-delete. */
async delete(id: string): Promise<void> {
await db.table(TABLE).update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
/** Increment usage counters after a successful call. */
async recordUsage(id: string, tokens: number, costUsd: number): Promise<void> {
const existing = await db.table<ByokKeyRecord>(TABLE).get(id);
if (!existing) return;
const now = new Date().toISOString();
await db.table(TABLE).update(id, {
usageCount: existing.usageCount + 1,
totalTokens: existing.totalTokens + tokens,
totalCostUsd: existing.totalCostUsd + costUsd,
lastUsedAt: now,
updatedAt: now,
});
},
};

View file

@ -487,6 +487,13 @@ db.version(13).stores({
sleepSettings: 'id',
});
// v16 — BYOK (Bring Your Own Key) storage for user-provided LLM API keys.
// Encrypted at rest via the user's master key (AES-GCM). NOT synced.
// Keys stay device-local — user must add them on each device.
db.version(16).stores({
_byokKeys: 'id, provider, isDefault, [provider+isDefault]',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { Component, Snippet } from 'svelte';
import type { Snippet } from 'svelte';
import { onDestroy, setContext } from 'svelte';
import { createReminderScheduler } from '@mana/shared-stores';
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
@ -9,7 +9,13 @@
import { initTools } from '$lib/data/tools/init';
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
import { initByok } from '$lib/byok';
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
import SessionWarning from '$lib/components/SessionWarning.svelte';
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
import { bottomBarStore } from '$lib/stores/bottom-bar.svelte';
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
import NudgeToast from '$lib/components/NudgeToast.svelte';
import { locale, _ } from 'svelte-i18n';
import {
PillNavigation,
@ -32,7 +38,7 @@
import type { InputBarAdapter } from '$lib/quick-input/types';
import { getAdapterLoader } from '$lib/quick-input/registry';
import { createFallbackAdapter } from '$lib/quick-input/fallback-adapter';
import { AuthGate } from '@mana/shared-auth-ui';
import { AuthGate, GuestWelcomeModal } from '@mana/shared-auth-ui';
import { MANA_APPS, hasAppAccess, ACCESS_TIER_LABELS } from '@mana/shared-branding';
import type { AccessTier } from '@mana/shared-branding';
import { createGuestMode, type GuestMode } from '$lib/stores/guest-mode.svelte';
@ -73,6 +79,7 @@
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
import { getPillAppItems } from '@mana/shared-branding';
import { onboardingStore } from '$lib/stores/onboarding.svelte';
import { OnboardingWizard } from '$lib/components/onboarding';
import { STORAGE_KEYS } from '$lib/config/storage-keys';
import { SearchRegistry } from '$lib/search/registry';
import { registerAllProviders } from '$lib/search/providers';
@ -83,22 +90,6 @@
let { children }: { children: Snippet } = $props();
// ── Idle-defer helper ───────────────────────────────────
// Runs work when the browser is idle so first interaction isn't
// blocked by non-critical init (telemetry, schedulers, side-effect
// streams). Falls back to setTimeout on browsers without
// requestIdleCallback.
function idle(cb: () => void, timeout = 2000) {
if (typeof window === 'undefined') return;
const ric = (
window as unknown as {
requestIdleCallback?: (cb: () => void, opts?: { timeout?: number }) => void;
}
).requestIdleCallback;
if (ric) ric(cb, { timeout });
else setTimeout(cb, 0);
}
// ── App switcher ────────────────────────────────────────
let appItems = $derived(getPillAppItems('mana', undefined, undefined, authStore.user?.tier));
@ -401,63 +392,6 @@
// ── Guest Mode ──────────────────────────────────────────
let guestMode = $state<GuestMode | null>(null);
// ── Lazy-loaded UI (modals, toasts, banners) ────────────
// Static imports for these were adding weight to the initial layout
// bundle for components that are rarely-to-never visible on first
// paint. Each is fetched either on first demand (modals) or shortly
// after idle (always-mounted toasts/banners that self-gate).
// Permissive prop typing — props are validated at the call site
// where {@const} narrows the component back to its concrete type.
type AnyComponent = Component<any>;
let KeyboardShortcutsModalC = $state<AnyComponent | null>(null);
let OnboardingWizardC = $state<AnyComponent | null>(null);
let GuestWelcomeModalC = $state<AnyComponent | null>(null);
let SessionWarningC = $state<AnyComponent | null>(null);
let EncryptionIntroBannerC = $state<AnyComponent | null>(null);
let SuggestionToastC = $state<AnyComponent | null>(null);
let NudgeToastC = $state<AnyComponent | null>(null);
// On-demand: only fetch when the user actually opens them.
$effect(() => {
if (showShortcuts && !KeyboardShortcutsModalC) {
import('$lib/components/KeyboardShortcutsModal.svelte').then((m) => {
KeyboardShortcutsModalC = m.default;
});
}
});
$effect(() => {
if (showOnboarding && !OnboardingWizardC) {
import('$lib/components/onboarding').then((m) => {
OnboardingWizardC = m.OnboardingWizard;
});
}
});
$effect(() => {
if (guestMode?.showWelcome && !GuestWelcomeModalC) {
import('@mana/shared-auth-ui').then((m) => {
GuestWelcomeModalC = m.GuestWelcomeModal;
});
}
});
// Idle-mount: background toasts/banners that self-gate internally.
// Deferring the import also defers their transitive deps
// (automationsStore, day-snapshot projection, streaks, crypto gate).
idle(() => {
void import('$lib/components/SuggestionToast.svelte').then((m) => {
SuggestionToastC = m.default;
});
void import('$lib/components/NudgeToast.svelte').then((m) => {
NudgeToastC = m.default;
});
void import('$lib/components/EncryptionIntroBanner.svelte').then((m) => {
EncryptionIntroBannerC = m.default;
});
void import('$lib/components/SessionWarning.svelte').then((m) => {
SessionWarningC = m.default;
});
});
// ── Onboarding ──────────────────────────────────────────
function handleOnboardingComplete() {
onboardingStore.complete();
@ -482,34 +416,33 @@
setGuestPromptNavigator((href) => goto(href));
if (authStore.isAuthenticated) guestPrompt.clear();
// Phase A (critical): the local-store inits are required before
// liveQueries anywhere downstream (TagStrip, module list views)
// can return non-empty results. Keep these awaited.
// Phase A: Auth-independent — guests + authenticated
await Promise.all([
manaStore.initialize(),
tagLocalStore.initialize(),
linkLocalStore.initialize(),
]);
initSharedUload();
startEventStore();
initTools();
startEventBridge();
startStreakTracker();
initByok();
await dashboardStore.initialize();
// Phase A-idle: side-effect streams, telemetry, projection workers.
// All idempotent and self-gated; deferring to the next idle frame
// lets the first paint + interaction land without waiting on
// event-bridge wiring or LLM-queue reclaim work.
idle(() => {
initSharedUload();
startEventStore();
initTools();
startEventBridge();
startStreakTracker();
startLlmQueue();
startMemoroLlmWatcher();
// dashboardStore only drives /dashboard — safe to defer; other
// routes don't read from it on first paint.
void dashboardStore.initialize();
reminderScheduler.start();
});
// Start the persistent LLM task queue. Idempotent — safe to call
// repeatedly. The queue picks up any tasks left in 'pending' state
// from previous sessions (and reclaims orphaned 'running' rows
// from a crashed session) before going idle. See $lib/llm-queue.ts.
startLlmQueue();
// Restore nav collapsed state (cheap, keep inline)
// Module-side LLM result watchers. Each subscribes via Dexie
// liveQuery to completed task rows tagged for its module and
// writes the results back to the module's own collection
// (e.g. memoro auto-titles → memo.title). Idempotent.
startMemoroLlmWatcher();
// Restore nav collapsed state
if (typeof localStorage !== 'undefined') {
const savedCollapsed = localStorage.getItem(STORAGE_KEYS.NAV_COLLAPSED);
if (savedCollapsed === 'true') {
@ -518,11 +451,10 @@
}
}
// Phase B (critical): sync for authenticated users. Data delivery
// is user-visible via the pending-count badge, so we keep the
// sync engine boot on the critical path.
// Phase B: Auth-dependent — sync, settings, onboarding
if (authStore.isAuthenticated) {
setErrorTrackingUser({ id: authStore.user?.id ?? 'unknown', email: authStore.user?.email });
trackReturnVisit();
await syncBilling.load();
const getToken = () => authStore.getValidToken();
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active);
@ -556,21 +488,18 @@
// value (0 on a fresh tab) until a sync actually runs.
refreshPendingCount();
// Phase B-idle: settings, onboarding gating and return-visit
// telemetry. None of this gates rendering — onboarding shows
// via showOnboarding after the store resolves, which is fine
// on a delay.
idle(async () => {
trackReturnVisit();
userSettings.load().catch(() => {});
await onboardingStore.load();
if (onboardingStore.shouldShow) {
onboardingStore.start();
ManaEvents.onboardingStarted();
showOnboarding = true;
}
});
userSettings.load().catch(() => {});
onboardingStore.load();
if (onboardingStore.shouldShow) {
onboardingStore.start();
ManaEvents.onboardingStarted();
showOnboarding = true;
}
}
// Phase B2: Start reminder scheduler
reminderScheduler.start();
// IMPORTANT: do NOT call notificationService.requestPermission() here.
// Browsers (Chrome/Firefox) require permission requests to come from
// a user gesture. Calling it at mount time queues the prompt until
@ -701,9 +630,8 @@
appName="Mana"
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
>
<!-- Onboarding Wizard (auth only) — loaded on demand -->
{#if showOnboarding && authStore.isAuthenticated && OnboardingWizardC}
{@const OnboardingWizard = OnboardingWizardC}
<!-- Onboarding Wizard (auth only) -->
{#if showOnboarding && authStore.isAuthenticated}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm"
>
@ -728,14 +656,10 @@
<!-- One-time encryption intro — sits at the top of the stack so
it can't be obscured by the QuickInputBar / TagStrip / PillNav.
Self-gates on isVaultUnlocked() so guests never see it.
Lazy-loaded after idle (see $effects above). -->
{#if EncryptionIntroBannerC}
{@const EncryptionIntroBanner = EncryptionIntroBannerC}
<div class="bottom-stack-notification">
<EncryptionIntroBanner />
</div>
{/if}
Self-gates on isVaultUnlocked() so guests never see it. -->
<div class="bottom-stack-notification">
<EncryptionIntroBanner />
</div>
<!-- Sync pause banner — shown when sync was paused due to insufficient credits -->
{#if syncBilling.paused}
@ -772,10 +696,8 @@
<!-- Session expiry warning (auth only). Self-gates on the
secondsLeft countdown and only renders inside the stack
when actually warning, so the wrapper is no-op otherwise.
Lazy-loaded after idle. -->
{#if authStore.isAuthenticated && SessionWarningC}
{@const SessionWarning = SessionWarningC}
when actually warning, so the wrapper is no-op otherwise. -->
{#if authStore.isAuthenticated}
<div class="bottom-stack-notification">
<SessionWarning />
</div>
@ -784,23 +706,16 @@
<!-- Cross-module automation suggestions. Lives in the (app)
stack because automationsStore is an (app)-only module
and the toast doesn't make sense on auth/landing pages
anyway. Self-gates on visible state. Lazy-loaded after idle. -->
{#if SuggestionToastC}
{@const SuggestionToast = SuggestionToastC}
<div class="bottom-stack-notification">
<SuggestionToast />
</div>
{/if}
anyway. Self-gates on visible state. -->
<div class="bottom-stack-notification">
<SuggestionToast />
</div>
<!-- Companion Brain pulse nudges — water reminders, streak
warnings, morning summary etc. Self-gates on active nudges.
Lazy-loaded after idle. -->
{#if NudgeToastC}
{@const NudgeToast = NudgeToastC}
<div class="bottom-stack-notification">
<NudgeToast />
</div>
{/if}
warnings, morning summary etc. Self-gates on active nudges. -->
<div class="bottom-stack-notification">
<NudgeToast />
</div>
<!-- QuickInputBar with inline nav toggle — gated by the "search" pill -->
{#if isQuickInputVisible}
@ -969,11 +884,8 @@
so it doesn't end up obscured by the QuickInputBar like
EncryptionIntroBanner used to be. -->
<!-- Keyboard shortcuts modal — loaded on first `?` press -->
{#if KeyboardShortcutsModalC}
{@const KeyboardShortcutsModal = KeyboardShortcutsModalC}
<KeyboardShortcutsModal open={showShortcuts} onclose={() => (showShortcuts = false)} />
{/if}
<!-- Keyboard shortcuts modal -->
<KeyboardShortcutsModal open={showShortcuts} onclose={() => (showShortcuts = false)} />
</div>
<!-- Navigation Context Menu -->
@ -985,9 +897,8 @@
onClose={() => navCtxMenu.close()}
/>
<!-- Guest Welcome Modal — loaded when guest mode activates -->
{#if guestMode && GuestWelcomeModalC}
{@const GuestWelcomeModal = GuestWelcomeModalC}
<!-- Guest Welcome Modal -->
{#if guestMode}
<GuestWelcomeModal
appId="mana"
visible={guestMode.showWelcome}

View file

@ -0,0 +1,556 @@
<!--
AI Keys Settings — user-facing CRUD for BYOK provider keys.
Keys are encrypted at rest via the user's master key. Without an
unlocked vault, this page will show an error and prompt login.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { Plus, Trash, Key, PencilSimple, Check, X } from '@mana/shared-icons';
import { BUILTIN_BYOK_PROVIDERS, formatCost, type ByokProviderId } from '@mana/shared-llm';
import { byokVault } from '$lib/byok';
import { isVaultUnlocked } from '$lib/data/crypto/key-provider';
import type { ByokKeyPlain } from '$lib/byok/types';
let keys = $state<Omit<ByokKeyPlain, 'apiKey'>[]>([]);
let loading = $state(true);
let vaultLocked = $state(false);
let showAdd = $state(false);
let addProvider = $state<ByokProviderId>('openai');
let addLabel = $state('');
let addApiKey = $state('');
let addModel = $state('');
let addIsDefault = $state(true);
let addError = $state<string | null>(null);
let saving = $state(false);
let editingId = $state<string | null>(null);
let editLabel = $state('');
let editModel = $state('');
async function reload() {
if (!isVaultUnlocked()) {
vaultLocked = true;
keys = [];
loading = false;
return;
}
vaultLocked = false;
keys = await byokVault.listMeta();
loading = false;
}
onMount(reload);
async function handleAdd() {
addError = null;
if (!addLabel.trim() || !addApiKey.trim()) {
addError = 'Label und API-Key sind Pflicht';
return;
}
saving = true;
try {
await byokVault.create({
provider: addProvider,
label: addLabel.trim(),
apiKey: addApiKey.trim(),
model: addModel.trim() || undefined,
isDefault: addIsDefault,
});
showAdd = false;
addLabel = '';
addApiKey = '';
addModel = '';
await reload();
} catch (err) {
addError = err instanceof Error ? err.message : String(err);
} finally {
saving = false;
}
}
async function handleDelete(id: string) {
if (!confirm('Schluessel wirklich loeschen?')) return;
await byokVault.delete(id);
await reload();
}
async function handleSetDefault(id: string) {
await byokVault.update(id, { isDefault: true });
await reload();
}
function startEdit(k: Omit<ByokKeyPlain, 'apiKey'>) {
editingId = k.id;
editLabel = k.label;
editModel = k.model ?? '';
}
async function saveEdit() {
if (!editingId) return;
await byokVault.update(editingId, {
label: editLabel.trim(),
model: editModel.trim() || undefined,
});
editingId = null;
await reload();
}
function providerDisplay(id: ByokProviderId): string {
return BUILTIN_BYOK_PROVIDERS.find((p) => p.id === id)?.displayName ?? id;
}
function providerModels(id: ByokProviderId): readonly string[] {
return BUILTIN_BYOK_PROVIDERS.find((p) => p.id === id)?.availableModels ?? [];
}
function providerDefaultModel(id: ByokProviderId): string {
return BUILTIN_BYOK_PROVIDERS.find((p) => p.id === id)?.defaultModel ?? '';
}
$effect(() => {
if (showAdd && !addModel) {
addModel = providerDefaultModel(addProvider);
}
});
</script>
<svelte:head>
<title>KI-Keys - Mana</title>
</svelte:head>
<div class="keys-page">
<header class="page-header">
<h1>KI-Keys</h1>
<p class="subtitle">
Hinterlege deine eigenen API-Keys fuer OpenAI, Anthropic, Gemini oder Mistral. Keys bleiben
verschluesselt auf diesem Geraet und werden nie synchronisiert.
</p>
</header>
{#if vaultLocked}
<div class="notice">Vault ist gesperrt — bitte zuerst anmelden um Keys zu verwalten.</div>
{:else if loading}
<div class="notice">Laedt...</div>
{:else}
<div class="actions">
<button class="btn-primary" onclick={() => (showAdd = !showAdd)}>
<Plus size={14} weight="bold" /> Key hinzufuegen
</button>
</div>
{#if showAdd}
<div class="add-form">
<h3>Neuen Key hinzufuegen</h3>
<label class="field">
<span class="label">Provider</span>
<select
bind:value={addProvider}
onchange={() => (addModel = providerDefaultModel(addProvider))}
>
{#each BUILTIN_BYOK_PROVIDERS as p}
<option value={p.id}>{p.displayName}</option>
{/each}
</select>
</label>
<label class="field">
<span class="label">Label</span>
<input
type="text"
bind:value={addLabel}
placeholder="z.B. Work Anthropic, Privat OpenAI"
maxlength="40"
/>
</label>
<label class="field">
<span class="label">API-Key</span>
<input
type="password"
bind:value={addApiKey}
placeholder={addProvider === 'openai'
? 'sk-...'
: addProvider === 'anthropic'
? 'sk-ant-...'
: 'API-Key'}
autocomplete="off"
/>
</label>
<label class="field">
<span class="label">Modell (optional)</span>
<select bind:value={addModel}>
<option value="">Provider-Default ({providerDefaultModel(addProvider)})</option>
{#each providerModels(addProvider) as m}
<option value={m}>{m}</option>
{/each}
</select>
</label>
<label class="field-inline">
<input type="checkbox" bind:checked={addIsDefault} />
<span>Als Standard fuer {providerDisplay(addProvider)}</span>
</label>
{#if addError}
<div class="error">{addError}</div>
{/if}
<div class="form-actions">
<button class="btn-cancel" onclick={() => (showAdd = false)}>Abbrechen</button>
<button class="btn-primary" onclick={handleAdd} disabled={saving}>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
{/if}
<div class="keys-list">
{#each keys as k (k.id)}
<div class="key-card" class:default-key={k.isDefault}>
{#if editingId === k.id}
<div class="edit-row">
<input type="text" bind:value={editLabel} maxlength="40" />
<select bind:value={editModel}>
<option value="">Provider-Default</option>
{#each providerModels(k.provider) as m}
<option value={m}>{m}</option>
{/each}
</select>
<button class="btn-icon" onclick={saveEdit} title="Speichern">
<Check size={14} weight="bold" />
</button>
<button class="btn-icon" onclick={() => (editingId = null)} title="Abbrechen">
<X size={14} />
</button>
</div>
{:else}
<div class="key-main">
<div class="key-icon"><Key size={16} weight="fill" /></div>
<div class="key-info">
<div class="key-title">
<span class="key-label">{k.label}</span>
{#if k.isDefault}
<span class="default-badge">Standard</span>
{/if}
</div>
<div class="key-meta">
{providerDisplay(k.provider)}
· {k.model || `Default (${providerDefaultModel(k.provider)})`}
</div>
<div class="key-usage">
{k.usageCount} Aufrufe · {k.totalTokens.toLocaleString('de-DE')} Token · {formatCost(
k.totalCostUsd
)}
</div>
</div>
<div class="key-actions">
{#if !k.isDefault}
<button
class="btn-ghost"
onclick={() => handleSetDefault(k.id)}
title="Als Standard setzen"
>
Standard
</button>
{/if}
<button class="btn-icon" onclick={() => startEdit(k)} title="Bearbeiten">
<PencilSimple size={14} />
</button>
<button class="btn-icon danger" onclick={() => handleDelete(k.id)} title="Loeschen">
<Trash size={14} />
</button>
</div>
</div>
{/if}
</div>
{:else}
<div class="empty">
<Key size={32} weight="thin" />
<p>Noch keine API-Keys hinterlegt.</p>
<p class="hint">
Klicke auf "Key hinzufuegen" um mit OpenAI, Anthropic, Gemini oder Mistral zu chatten.
</p>
</div>
{/each}
</div>
{/if}
</div>
<style>
.keys-page {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem 1rem;
}
.page-header {
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.375rem;
color: hsl(var(--color-foreground));
}
.subtitle {
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
.actions {
margin-bottom: 1rem;
}
.notice {
padding: 1.5rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
background: hsl(var(--color-muted) / 0.2);
border-radius: 0.75rem;
}
.error {
padding: 0.5rem 0.75rem;
background: hsl(var(--color-error) / 0.1);
border: 1px solid hsl(var(--color-error) / 0.3);
color: hsl(var(--color-error));
border-radius: 0.5rem;
font-size: 0.8125rem;
margin: 0.5rem 0;
}
.add-form {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1rem;
}
.add-form h3 {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.field .label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.field input,
.field select {
padding: 0.4375rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
outline: none;
}
.field input:focus,
.field select:focus {
border-color: hsl(var(--color-primary));
}
.field-inline {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
margin-bottom: 0.75rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.75rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-cancel {
padding: 0.4375rem 0.75rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.btn-ghost {
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
border-radius: 0.375rem;
font-size: 0.6875rem;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.btn-ghost:hover {
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-primary));
}
.btn-icon {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
.btn-icon.danger:hover {
color: hsl(var(--color-error));
background: hsl(var(--color-error) / 0.1);
}
.keys-list {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.key-card {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
padding: 0.875rem 1rem;
}
.key-card.default-key {
border-color: hsl(var(--color-primary) / 0.3);
}
.key-main {
display: flex;
align-items: center;
gap: 0.75rem;
}
.key-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.key-info {
flex: 1;
min-width: 0;
}
.key-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.125rem;
}
.key-label {
font-weight: 500;
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
}
.default-badge {
font-size: 0.625rem;
font-weight: 600;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
padding: 0.125rem 0.375rem;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.key-meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.key-usage {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.key-actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.edit-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.edit-row input {
flex: 1;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.edit-row select {
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.empty {
text-align: center;
padding: 2rem 1rem;
color: hsl(var(--color-muted-foreground));
}
.empty p {
margin: 0.5rem 0 0;
font-size: 0.875rem;
}
.empty .hint {
font-size: 0.75rem;
opacity: 0.8;
}
</style>