Feat: Lock-Feature & Server-Authentication + Experimentals

This commit is contained in:
Youwes09
2026-03-26 23:21:39 -05:00
parent 2c93d8743d
commit ac6b70fb32
12 changed files with 816 additions and 69 deletions
+428 -13
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import { tick } from "svelte";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush, ListChecks } from "phosphor-svelte";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush, ListChecks, Lock } from "phosphor-svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getVersion } from "@tauri-apps/api/app";
import { open as openUrl } from "@tauri-apps/plugin-shell";
import { gql, thumbUrl } from "../../lib/client";
import { GET_DOWNLOADS_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS } from "../../lib/queries";
import { GET_DOWNLOADS_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries";
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen } from "../../store/state.svelte";
import { cache } from "../../lib/cache";
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
@@ -14,7 +14,7 @@
import type { Keybinds } from "../../lib/keybinds";
import type { Tracker } from "../../lib/types";
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "tracking" | "about" | "devtools";
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "tracking" | "security" | "about" | "devtools";
const TABS: { id: Tab; label: string; icon: any }[] = [
{ id: "general", label: "General", icon: Gear },
@@ -26,6 +26,7 @@
{ id: "storage", label: "Storage", icon: HardDrives },
{ id: "folders", label: "Folders", icon: FolderSimple },
{ id: "tracking", label: "Tracking", icon: ListChecks },
{ id: "security", label: "Security", icon: Lock },
{ id: "about", label: "About", icon: Info },
{ id: "devtools", label: "Dev Tools", icon: Wrench },
];
@@ -196,6 +197,145 @@
let splashTriggered = $state(false);
let showAuthPass = $state(false);
let showSocksPass = $state(false);
let pinInput = $state(store.settings.appLockPin ?? "");
let pinError = $state("");
let secLoading = $state(false);
let secError = $state<string | null>(null);
let secSaved = $state<string | null>(null);
let authUsername = $state(store.settings.serverAuthUser ?? "");
let authPassword = $state(store.settings.serverAuthPass ?? "");
let socksEnabled = $state(store.settings.socksProxyEnabled ?? false);
let socksHost = $state(store.settings.socksProxyHost ?? "");
let socksPort = $state(store.settings.socksProxyPort ?? "1080");
let socksVersion = $state(store.settings.socksProxyVersion ?? 5);
let socksUsername = $state(store.settings.socksProxyUsername ?? "");
let socksPassword = $state(store.settings.socksProxyPassword ?? "");
let flareEnabled = $state(store.settings.flareSolverrEnabled ?? false);
let flareUrl = $state(store.settings.flareSolverrUrl ?? "http://localhost:8191");
let flareTimeout = $state(store.settings.flareSolverrTimeout ?? 60);
let flareSession = $state(store.settings.flareSolverrSessionName ?? "moku");
let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15);
let flareFallback = $state(store.settings.flareSolverrFallback ?? false);
function showSaved(key: string) {
secSaved = key; secError = null;
setTimeout(() => { if (secSaved === key) secSaved = null; }, 2000);
}
async function loadServerSecurity() {
try {
const res = await gql<{ settings: {
authMode: string; authUsername: string;
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
socksProxyVersion: number; socksProxyUsername: string;
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
flareSolverrSessionName: string; flareSolverrSessionTtl: number;
flareSolverrAsResponseFallback: boolean;
}}>(GET_SERVER_SECURITY);
const s = res.settings;
const authOn = s.authMode === "BASIC_AUTH";
updateSettings({ serverAuthEnabled: authOn, serverAuthUser: s.authUsername });
authUsername = s.authUsername;
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
socksUsername = s.socksProxyUsername;
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl;
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName;
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback;
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrFallback: flareFallback,
});
} catch {}
}
$effect(() => { if (tab === "security") loadServerSecurity(); });
async function enableAuth() {
if (!authUsername.trim() || !authPassword.trim()) {
secError = "Username and password are required"; return;
}
secLoading = true; secError = null;
updateSettings({ serverAuthEnabled: true, serverAuthUser: authUsername, serverAuthPass: authPassword });
try {
await gql(SET_SERVER_AUTH, { authMode: "BASIC_AUTH", authUsername: authUsername.trim(), authPassword: authPassword.trim() });
showSaved("auth");
} catch (e: any) {
updateSettings({ serverAuthEnabled: false });
secError = e?.message ?? "Failed to enable authentication";
} finally { secLoading = false; }
}
async function disableAuth() {
secLoading = true; secError = null;
try {
await gql(SET_SERVER_AUTH, { authMode: "NONE", authUsername: "", authPassword: "" });
updateSettings({ serverAuthEnabled: false, serverAuthUser: "", serverAuthPass: "" });
authUsername = ""; authPassword = "";
showSaved("auth");
} catch (e: any) {
secError = e?.message ?? "Failed to disable authentication";
} finally { secLoading = false; }
}
async function saveSocksProxy() {
secLoading = true; secError = null;
try {
await gql(SET_SOCKS_PROXY, {
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost.trim(),
socksProxyPort: socksPort.trim(), socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername.trim(), socksProxyPassword: socksPassword.trim(),
});
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost,
socksProxyPort: socksPort, socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername, socksProxyPassword: socksPassword,
});
showSaved("socks");
} catch (e: any) {
secError = e?.message ?? "Failed to save SOCKS proxy";
} finally { secLoading = false; }
}
async function saveFlareSolverr() {
secLoading = true; secError = null;
try {
await gql(SET_FLARESOLVERR, {
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl.trim(),
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession.trim(),
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
});
updateSettings({
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrFallback: flareFallback,
});
showSaved("flare");
} catch (e: any) {
secError = e?.message ?? "Failed to save FlareSolverr";
} finally { secLoading = false; }
}
function commitPin() {
const cleaned = pinInput.replace(/\D/g, "").slice(0, 8);
pinInput = cleaned;
if (cleaned.length >= 4) {
updateSettings({ appLockPin: cleaned }); pinError = "";
} else if (cleaned.length > 0) {
pinError = "PIN must be at least 4 digits";
} else {
updateSettings({ appLockPin: "" }); pinError = "";
}
}
// ── Tracker state ─────────────────────────────────────────────────────────────
let trackers: Tracker[] = $state([]);
@@ -486,13 +626,27 @@
<div class="section">
<p class="section-title">Interface Scale</p>
<div class="scale-row">
<input type="range" min={70} max={200} step={5} value={store.settings.uiScale}
<input type="range" min={50} max={200} step={5} value={store.settings.uiScale}
oninput={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" />
<span class="scale-val">{store.settings.uiScale}%</span>
<input
type="number" min={50} max={200} step={1}
class="scale-val-input"
value={store.settings.uiScale}
oninput={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiScale: n });
}}
onblur={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (isNaN(n) || n < 50) { updateSettings({ uiScale: 50 }); e.currentTarget.value = "50"; }
else if (n > 200) { updateSettings({ uiScale: 200 }); e.currentTarget.value = "200"; }
}}
/>
<span class="scale-pct">%</span>
<button class="step-btn" onclick={() => updateSettings({ uiScale: 100 })} disabled={store.settings.uiScale === 100} title="Reset"></button>
</div>
<p class="scale-hint">
{#each [70,80,90,100,110,125,150,175,200] as v}
{#each [50,60,70,80,90,100,110,125,150,175,200] as v}
<button class="scale-preset" class:active={store.settings.uiScale === v} onclick={() => updateSettings({ uiScale: v })}>{v}%</button>
{/each}
</p>
@@ -793,11 +947,11 @@
{:else if tab === "keybinds"}
<div class="panel">
<div class="section">
<div class="kb-header">
<div class="section-title-row">
<p class="section-title">Keyboard shortcuts</p>
<button class="reset-all-btn" onclick={resetKeybinds}>Reset all</button>
<button class="sec-action-btn" onclick={resetKeybinds}>Reset all</button>
</div>
<p class="kb-hint">Click a key to rebind, then press the new combination.</p>
<p class="kb-hint">Click a binding to rebind, then press the new key combination.</p>
<div class="kb-list">
{#each Object.keys(KEYBIND_LABELS) as key}
{@const k = key as keyof Keybinds}
@@ -1052,6 +1206,230 @@
</div>
{:else if tab === "security"}
<div class="panel">
{#if secError}
<div class="sec-banner sec-banner-error">{secError}</div>
{/if}
<div class="section">
<div class="section-title-row">
<p class="section-title">Server Authentication</p>
<span class="sec-status-pill" class:sec-pill-on={store.settings.serverAuthEnabled}>
{store.settings.serverAuthEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Username</span>
</div>
<input class="text-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Password</span>
</div>
<div class="sec-field-wrap">
<input class="text-input" type={showAuthPass ? "text" : "password"} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
<button class="sec-eye-btn" onclick={() => showAuthPass = !showAuthPass} title={showAuthPass ? "Hide password" : "Show password"} tabindex="-1">
{#if showAuthPass}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div>
</div>
<div class="step-row">
<div class="toggle-info"></div>
<div class="sec-btn-row">
{#if store.settings.serverAuthEnabled}
<button class="sec-action-btn sec-action-danger" onclick={disableAuth} disabled={secLoading}>
{secLoading ? "Saving…" : "Disable"}
</button>
{/if}
<button class="sec-action-btn sec-action-primary" onclick={enableAuth} disabled={secLoading || !authUsername.trim() || !authPassword.trim()}>
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthEnabled ? "Update" : "Enable"}
</button>
</div>
</div>
</div>
<div class="section">
<div class="section-title-row">
<p class="section-title">App Lock</p>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">PIN lock</span>
<span class="toggle-desc">Require a PIN on launch and after idle timeout</span>
</div>
<button role="switch" aria-checked={store.settings.appLockEnabled ?? false} aria-label="Enable PIN lock" class="toggle" class:on={store.settings.appLockEnabled} onclick={() => updateSettings({ appLockEnabled: !store.settings.appLockEnabled })}><span class="toggle-thumb"></span></button>
</label>
{#if store.settings.appLockEnabled}
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">PIN</span>
<span class="toggle-desc">48 digits</span>
</div>
<div class="sec-pin-wrap">
<div class="sec-pin-row">
<input class="text-input sec-pin-input" type="password" inputmode="numeric" maxlength={8} value={pinInput}
oninput={(e) => { pinInput = e.currentTarget.value.replace(/\D/g, "").slice(0, 8); pinError = ""; }}
onkeydown={(e) => e.key === "Enter" && commitPin()} placeholder="••••" autocomplete="off" />
<button class="sec-action-btn sec-action-primary"
onclick={commitPin}
disabled={pinInput.length > 0 && pinInput.length < 4}>
{store.settings.appLockPin && pinInput === store.settings.appLockPin ? "Saved ✓" : "Save"}
</button>
</div>
{#if pinError}<span class="sec-pin-error">{pinError}</span>{/if}
</div>
</div>
{/if}
</div>
<div class="section">
<div class="section-title-row">
<p class="section-title">SOCKS Proxy</p>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">Enable SOCKS proxy</span>
<span class="toggle-desc">Route Suwayomi traffic through a SOCKS4/5 proxy</span>
</div>
<button role="switch" aria-checked={socksEnabled} aria-label="Enable SOCKS proxy" class="toggle" class:on={socksEnabled} onclick={() => socksEnabled = !socksEnabled}><span class="toggle-thumb"></span></button>
</label>
{#if socksEnabled}
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Version</span>
</div>
<div class="select-wrap" id="socks-ver">
<button class="select-btn" onclick={() => toggleSelect("socks-ver")}>
<span>SOCKS{socksVersion}</span>
<svg class="select-caret" class:open={selectOpen === "socks-ver"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "socks-ver"}
<div class="select-menu">
{#each [[4,"SOCKS4"],[5,"SOCKS5"]] as [v, l]}
<button class="select-option" class:active={socksVersion === v} onclick={() => { socksVersion = v as number; selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Host</span>
</div>
<input class="text-input" bind:value={socksHost} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Port</span>
</div>
<input class="text-input sec-port-input" bind:value={socksPort} placeholder="1080" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Username</span>
<span class="toggle-desc">Optional</span>
</div>
<input class="text-input" bind:value={socksUsername} placeholder="Username" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Password</span>
<span class="toggle-desc">Optional</span>
</div>
<div class="sec-field-wrap">
<input class="text-input" type={showSocksPass ? "text" : "password"} bind:value={socksPassword} placeholder="Password" autocomplete="off" spellcheck="false" />
<button class="sec-eye-btn" onclick={() => showSocksPass = !showSocksPass} title={showSocksPass ? "Hide password" : "Show password"} tabindex="-1">
{#if showSocksPass}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div>
</div>
<div class="step-row">
<div class="toggle-info"></div>
<button class="sec-action-btn sec-action-primary" onclick={saveSocksProxy} disabled={secLoading}>
{secLoading ? "Saving…" : secSaved === "socks" ? "Saved ✓" : "Save"}
</button>
</div>
{/if}
</div>
<div class="section">
<div class="section-title-row">
<p class="section-title">FlareSolverr</p>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">Enable FlareSolverr</span>
<span class="toggle-desc">Bypass Cloudflare challenges for sources that require it</span>
</div>
<button role="switch" aria-checked={flareEnabled} aria-label="Enable FlareSolverr" class="toggle" class:on={flareEnabled} onclick={() => flareEnabled = !flareEnabled}><span class="toggle-thumb"></span></button>
</label>
{#if flareEnabled}
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">URL</span>
<span class="toggle-desc">FlareSolverr instance address</span>
</div>
<input class="text-input" bind:value={flareUrl} placeholder="http://localhost:8191" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Timeout</span>
<span class="toggle-desc">Max wait per request, in seconds</span>
</div>
<div class="step-controls">
<button class="step-btn" onclick={() => flareTimeout = Math.max(10, flareTimeout - 10)}></button>
<span class="step-val">{flareTimeout}s</span>
<button class="step-btn" onclick={() => flareTimeout = Math.min(300, flareTimeout + 10)}>+</button>
</div>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Session name</span>
<span class="toggle-desc">Reuse browser session across requests</span>
</div>
<input class="text-input" bind:value={flareSession} placeholder="moku" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Session TTL</span>
<span class="toggle-desc">Minutes before session is refreshed</span>
</div>
<div class="step-controls">
<button class="step-btn" onclick={() => flareTtl = Math.max(1, flareTtl - 1)}></button>
<span class="step-val">{flareTtl}m</span>
<button class="step-btn" onclick={() => flareTtl = Math.min(60, flareTtl + 1)}>+</button>
</div>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">Response fallback</span>
<span class="toggle-desc">Use FlareSolverr's response when the direct request fails</span>
</div>
<button role="switch" aria-checked={flareFallback} aria-label="Use as response fallback" class="toggle" class:on={flareFallback} onclick={() => flareFallback = !flareFallback}><span class="toggle-thumb"></span></button>
</label>
<div class="step-row">
<div class="toggle-info"></div>
<button class="sec-action-btn sec-action-primary" onclick={saveFlareSolverr} disabled={secLoading}>
{secLoading ? "Saving…" : secSaved === "flare" ? "Saved ✓" : "Save"}
</button>
</div>
{/if}
</div>
</div>
{:else if tab === "about"}
<div class="panel">
@@ -1287,7 +1665,18 @@
.scale-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
.scale-slider { flex: 1; }
.scale-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 40px; text-align: center; }
.scale-val-input {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary);
width: 42px; text-align: center; padding: 3px 4px;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); outline: none;
transition: border-color var(--t-base);
-moz-appearance: textfield;
}
.scale-val-input::-webkit-inner-spin-button,
.scale-val-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.scale-val-input:focus { border-color: var(--border-strong); }
.scale-pct { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); margin-left: calc(var(--sp-1) * -1); }
.scale-hint { padding: 0 var(--sp-3) var(--sp-2); display: flex; gap: var(--sp-1); flex-wrap: wrap; }
.scale-preset { font-family: var(--font-ui); font-size: var(--text-2xs); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.scale-preset:hover { color: var(--text-muted); border-color: var(--border-strong); }
@@ -1310,9 +1699,6 @@
.theme-card-check { position: absolute; top: 6px; right: 6px; font-size: 10px; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 4px; }
/* Keybinds */
.kb-header { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-3) var(--sp-2); }
.reset-all-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.reset-all-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.kb-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-3) var(--sp-3); }
.kb-list { display: flex; flex-direction: column; gap: 1px; }
.kb-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md); transition: background var(--t-fast); }
@@ -1477,6 +1863,35 @@
.oauth-input:focus { border-color: var(--border-focus); }
.oauth-btns { display: flex; align-items: center; gap: var(--sp-2); }
/* ── Security tab ───────────────────────────────────────────────────── */
.sec-banner { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-2); border-radius: var(--radius-sm); }
.sec-banner-error { color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); }
.section-title-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-3) var(--sp-2); }
.section-title-row .section-title { padding: 0; }
.sec-status-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: var(--bg-overlay); flex-shrink: 0; cursor: default; }
.sec-pill-on { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.sec-field-wrap { position: relative; flex-shrink: 0; }
.sec-field-wrap .text-input { padding-right: 34px; }
.sec-eye-btn { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; padding: 0; border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.sec-eye-btn:hover { color: var(--text-muted); }
.sec-btn-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.sec-action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.sec-action-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
.sec-action-btn:disabled { opacity: 0.35; cursor: default; }
.sec-action-primary { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.sec-action-primary:hover:not(:disabled) { filter: brightness(1.1); }
.sec-action-danger { border-color: var(--color-error); color: var(--color-error); }
.sec-action-danger:hover:not(:disabled) { background: var(--color-error-bg); }
.sec-pin-wrap { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.sec-pin-wrap .sec-pin-row { display: flex; align-items: center; gap: var(--sp-2); }
.sec-pin-input { width: 96px; text-align: center; letter-spacing: 0.25em; }
.sec-port-input { width: 88px; }
.sec-pin-error { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } }
</style>