mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
a33857fa39
commit
db8c2574d6
7 changed files with 933 additions and 151 deletions
3
apps/mana/apps/web/src/lib/byok/index.ts
Normal file
3
apps/mana/apps/web/src/lib/byok/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { byokVault } from './vault';
|
||||
export { initByok } from './init';
|
||||
export type { ByokKeyRecord, ByokKeyPlain } from './types';
|
||||
78
apps/mana/apps/web/src/lib/byok/init.ts
Normal file
78
apps/mana/apps/web/src/lib/byok/init.ts
Normal 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;
|
||||
}
|
||||
44
apps/mana/apps/web/src/lib/byok/types.ts
Normal file
44
apps/mana/apps/web/src/lib/byok/types.ts
Normal 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;
|
||||
}
|
||||
183
apps/mana/apps/web/src/lib/byok/vault.ts
Normal file
183
apps/mana/apps/web/src/lib/byok/vault.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue