Files
Moku/src/lib/components/settings/sections/SecuritySettings.svelte
T
2026-06-08 20:27:22 -05:00

414 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { requestManager } from '$lib/request-manager'
import { retryBoot } from '$lib/state/boot.svelte'
import { authSession, configureAuth } from '$lib/core/auth'
interface Props { selectOpen: string | null; toggleSelect: (id: string) => void }
let { selectOpen, toggleSelect }: Props = $props()
let showAuthPass = $state(false)
let showSocksPass = $state(false)
let secLoading = $state(false)
let secError = $state<string | null>(null)
let secSaved = $state<string | null>(null)
let secLoaded = $state(false)
function normalizeForUI(mode: string | undefined): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN') return mode
return 'NONE'
}
let authMode = $state(normalizeForUI(settingsState.settings.serverAuthMode))
let authUsername = $state(settingsState.settings.serverAuthUser ?? '')
let authPassword = $state('')
let authDirty = $state(false)
let socksEnabled = $state(settingsState.settings.socksProxyEnabled ?? false)
let socksHost = $state(settingsState.settings.socksProxyHost ?? '')
let socksPort = $state(settingsState.settings.socksProxyPort ?? '1080')
let socksVersion = $state(settingsState.settings.socksProxyVersion ?? 5)
let socksUsername = $state(settingsState.settings.socksProxyUsername ?? '')
let socksPassword = $state(settingsState.settings.socksProxyPassword ?? '')
let flareEnabled = $state(settingsState.settings.flareSolverrEnabled ?? false)
let flareUrl = $state(settingsState.settings.flareSolverrUrl ?? 'http://localhost:8191')
let flareTimeout = $state(settingsState.settings.flareSolverrTimeout ?? 60)
let flareSession = $state(settingsState.settings.flareSolverrSessionName ?? 'moku')
let flareTtl = $state(settingsState.settings.flareSolverrSessionTtl ?? 15)
let flareFallback = $state(settingsState.settings.flareSolverrAsResponseFallback ?? false)
let lockEnabled = $state(settingsState.settings.appLockEnabled ?? false)
let lockPin = $state(settingsState.settings.appLockEnabled ? (settingsState.settings.appLockPin ?? '') : '')
let lockPinVis = $state(false)
let lockError = $state<string | null>(null)
let lockSaved = $state(false)
function onLockToggle() {
lockEnabled = !lockEnabled
lockError = null
lockSaved = false
if (!lockEnabled) {
lockPin = ''
updateSettings({ appLockEnabled: false, appLockPin: '' })
}
}
function onLockPinInput() {
lockPin = lockPin.replace(/\D/g, '')
lockError = null
lockSaved = false
}
function saveLockPin() {
if (lockPin.length < 4) { lockError = 'PIN must be at least 4 digits'; return }
updateSettings({ appLockEnabled: true, appLockPin: lockPin })
lockSaved = true
setTimeout(() => lockSaved = false, 2000)
}
function showSaved(key: string) {
secSaved = key; secError = null
setTimeout(() => { if (secSaved === key) secSaved = null }, 2000)
}
$effect(() => {
if (!secLoaded) { secLoaded = true; loadServerSecurity() }
})
async function loadServerSecurity() {
try {
const s = await requestManager.extensions.getServerSecurity()
if (!authDirty) {
authMode = normalizeForUI(s.authMode)
authUsername = s.authUsername || ''
updateSettings({ serverAuthMode: authMode, serverAuthUser: 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, flareSolverrAsResponseFallback: flareFallback,
})
} catch (e: any) {
console.warn('[SecuritySettings] loadServerSecurity failed:', e?.message ?? e)
}
}
async function saveAuth() {
if (authMode === 'NONE') { await clearAuth(); return }
if (!authUsername.trim() || !authPassword.trim()) { secError = 'Username and password are required'; return }
secLoading = true; secError = null
try {
const newUser = authUsername.trim()
const newPass = authPassword.trim()
await requestManager.extensions.setServerAuth({ authMode, authUsername: newUser, authPassword: newPass })
authSession.clearTokens()
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: authMode === 'BASIC_AUTH' ? newPass : '' })
configureAuth(settingsState.settings.serverUrl ?? '', authMode as any, newUser, authMode === 'BASIC_AUTH' ? newPass : undefined)
authPassword = ''
authDirty = false
showSaved('auth')
retryBoot(authMode as any, newUser, newPass)
} catch (e: any) {
secError = e?.message ?? 'Failed to save authentication settings'
} finally { secLoading = false }
}
async function clearAuth() {
secLoading = true; secError = null
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
try {
await requestManager.extensions.setServerAuth({ authMode: 'NONE', authUsername: '', authPassword: '' })
authSession.clearTokens()
configureAuth(settingsState.settings.serverUrl ?? '', 'NONE')
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
authMode = 'NONE'; authUsername = ''; authPassword = ''; authDirty = false
showSaved('auth')
} catch (e: any) {
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
secError = e?.message ?? 'Failed to disable authentication'
} finally { secLoading = false }
}
async function saveSocksProxy() {
secLoading = true; secError = null
try {
await requestManager.extensions.setSocksProxy({
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 requestManager.extensions.setFlareSolverr({
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, flareSolverrAsResponseFallback: flareFallback,
})
showSaved('flare')
} catch (e: any) {
secError = e?.message ?? 'Failed to save FlareSolverr'
} finally { secLoading = false }
}
function forceResetAuth() {
authSession.clearTokens()
authMode = 'NONE'
authUsername = ''
authPassword = ''
authDirty = false
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
showSaved('auth')
}
const EyeOpen = `<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>`
const EyeClose = `<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>`
</script>
<div class="s-panel">
{#if secError}
<div class="s-banner s-banner-error">{secError}</div>
{/if}
<div class="s-section">
<p class="s-section-title">
Server Authentication
<span class="s-pill" class:on={settingsState.settings.serverAuthMode === 'BASIC_AUTH' || settingsState.settings.serverAuthMode === 'UI_LOGIN'}>
{settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Basic Auth' :
settingsState.settings.serverAuthMode === 'UI_LOGIN' ? 'UI Login' : 'Disabled'}
</span>
</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Mode</span><span class="s-desc">How Moku authenticates with the server</span></div>
<div class="s-segment">
{#each [{ value: 'NONE', label: 'None' }, { value: 'BASIC_AUTH', label: 'Basic' }, { value: 'UI_LOGIN', label: 'UI Login' }] as opt}
<button class="s-segment-btn" class:active={authMode === opt.value}
onclick={() => { authMode = opt.value as any; authPassword = ''; authDirty = true }} disabled={secLoading}>{opt.label}</button>
{/each}
</div>
</div>
{#if authMode !== 'NONE'}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Username</span></div>
<input class="s-input" bind:value={authUsername} oninput={() => authDirty = true} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Password</span></div>
<div class="s-field-wrap">
<input class="s-input" type={showAuthPass ? 'text' : 'password'} bind:value={authPassword} oninput={() => authDirty = true} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
<button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1" aria-label={showAuthPass ? 'Hide password' : 'Show password'}>{@html showAuthPass ? EyeClose : EyeOpen}</button>
</div>
</div>
{/if}
{#if authMode !== 'NONE' && settingsState.settings.serverAuthMode === authMode && !authPassword}
<div class="s-row">
<span class="s-desc" style="color: var(--text-muted)">Re-enter your password to update credentials.</span>
</div>
{/if}
{#if settingsState.settings.serverAuthMode === 'BASIC_AUTH'}
<div class="s-row">
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
</div>
{/if}
<div class="s-row">
<div class="s-row-info">
<button class="s-ghost-btn" onclick={forceResetAuth} disabled={secLoading} title="Force reset local auth state">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Reset
</button>
</div>
<div class="s-btn-row">
{#if settingsState.settings.serverAuthMode !== 'NONE'}
<button class="s-btn s-btn-danger" onclick={clearAuth} disabled={secLoading}>
{secLoading ? 'Saving…' : 'Disable'}
</button>
{/if}
<button class="s-btn s-btn-accent" onclick={saveAuth}
disabled={secLoading || (authMode !== 'NONE' && (!authUsername.trim() || !authPassword.trim()))}>
{#if secLoading}
Saving…
{:else if secSaved === 'auth'}
Saved ✓
{:else if authMode === 'NONE'}
Save
{:else}
{authDirty ? 'Enable' : 'Save'}
{/if}
</button>
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">SOCKS Proxy</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enable SOCKS proxy</span><span class="s-desc">Route Suwayomi traffic through a SOCKS4/5 proxy</span></div>
<button role="switch" aria-checked={socksEnabled} aria-label="Enable SOCKS proxy" class="s-toggle" class:on={socksEnabled}
onclick={() => { socksEnabled = !socksEnabled; saveSocksProxy() }}><span class="s-toggle-thumb"></span></button>
</label>
{#if socksEnabled}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Version</span></div>
<div class="s-select" id="socks-ver">
<button class="s-select-btn" onclick={() => toggleSelect('socks-ver')}>
<span>SOCKS{socksVersion}</span>
<svg class="s-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="s-select-menu">
{#each [[4, 'SOCKS4'], [5, 'SOCKS5']] as [v, l]}
<button class="s-select-option" class:active={socksVersion === v} onclick={() => { socksVersion = v as number; toggleSelect('socks-ver') }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Host</span></div>
<input class="s-input" bind:value={socksHost} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Port</span></div>
<input class="s-input" style="width:80px" bind:value={socksPort} placeholder="1080" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Username</span><span class="s-desc">Optional</span></div>
<input class="s-input" bind:value={socksUsername} placeholder="Username" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Password</span><span class="s-desc">Optional</span></div>
<div class="s-field-wrap">
<input class="s-input" type={showSocksPass ? 'text' : 'password'} bind:value={socksPassword} placeholder="Password" autocomplete="off" spellcheck="false" />
<button class="s-eye-btn" onclick={() => showSocksPass = !showSocksPass} tabindex="-1" aria-label={showSocksPass ? 'Hide password' : 'Show password'}>{@html showSocksPass ? EyeClose : EyeOpen}</button>
</div>
</div>
<div class="s-row">
<div class="s-row-info"></div>
<button class="s-btn s-btn-accent" onclick={saveSocksProxy} disabled={secLoading}>
{secLoading ? 'Saving…' : secSaved === 'socks' ? 'Saved ✓' : 'Save'}
</button>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">App Lock</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info">
<span class="s-label">Require PIN on launch</span>
<span class="s-desc">Lock the app behind a numeric PIN when it opens</span>
</div>
<button role="switch" aria-checked={lockEnabled} aria-label="Enable app lock" class="s-toggle" class:on={lockEnabled}
onclick={onLockToggle}><span class="s-toggle-thumb"></span></button>
</label>
{#if lockEnabled}
{#if lockError}
<div class="s-banner s-banner-error" style="margin: 0">{lockError}</div>
{/if}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">PIN</span>
<span class="s-desc">Minimum 4 digits</span>
</div>
<div class="s-pin-row">
<div class="s-field-wrap">
<input class="s-input" type={lockPinVis ? 'text' : 'password'} inputmode="numeric" pattern="\d*"
bind:value={lockPin} oninput={onLockPinInput} placeholder="••••" autocomplete="off" spellcheck="false" maxlength="8" />
<button class="s-eye-btn" onclick={() => lockPinVis = !lockPinVis} tabindex="-1" aria-label={lockPinVis ? 'Hide PIN' : 'Show PIN'}>{@html lockPinVis ? EyeClose : EyeOpen}</button>
</div>
<button class="s-btn s-btn-accent" onclick={saveLockPin} disabled={!lockPin}>
{lockSaved ? 'Saved ✓' : 'Save'}
</button>
</div>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">FlareSolverr</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enable FlareSolverr</span><span class="s-desc">Bypass Cloudflare challenges for sources that require it</span></div>
<button role="switch" aria-checked={flareEnabled} aria-label="Enable FlareSolverr" class="s-toggle" class:on={flareEnabled}
onclick={() => { flareEnabled = !flareEnabled; saveFlareSolverr() }}><span class="s-toggle-thumb"></span></button>
</label>
{#if flareEnabled}
<div class="s-row">
<div class="s-row-info"><span class="s-label">URL</span><span class="s-desc">FlareSolverr instance address</span></div>
<input class="s-input" bind:value={flareUrl} placeholder="http://localhost:8191" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Timeout</span><span class="s-desc">Max wait per request, in seconds</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => flareTimeout = Math.max(10, flareTimeout - 10)}></button>
<span class="s-step-val">{flareTimeout}s</span>
<button class="s-step-btn" onclick={() => flareTimeout = Math.min(300, flareTimeout + 10)}>+</button>
</div>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Session name</span><span class="s-desc">Reuse browser session across requests</span></div>
<input class="s-input" bind:value={flareSession} placeholder="moku" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Session TTL</span><span class="s-desc">Minutes before session is refreshed</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => flareTtl = Math.max(1, flareTtl - 1)}></button>
<span class="s-step-val">{flareTtl}m</span>
<button class="s-step-btn" onclick={() => flareTtl = Math.min(60, flareTtl + 1)}>+</button>
</div>
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Response fallback</span><span class="s-desc">Use FlareSolverr's response when the direct request fails</span></div>
<button role="switch" aria-checked={flareFallback} aria-label="Response fallback" class="s-toggle" class:on={flareFallback}
onclick={() => flareFallback = !flareFallback}><span class="s-toggle-thumb"></span></button>
</label>
<div class="s-row">
<div class="s-row-info"></div>
<button class="s-btn s-btn-accent" onclick={saveFlareSolverr} disabled={secLoading}>
{secLoading ? 'Saving…' : secSaved === 'flare' ? 'Saved ✓' : 'Save'}
</button>
</div>
{/if}
</div>
</div>
</div>
<style>
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
.s-pin-row { display: flex; align-items: center; gap: 8px; }
</style>