diff --git a/src/App.svelte b/src/App.svelte index da46795..81dcdbb 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5,6 +5,8 @@ import { getVersion } from "@tauri-apps/api/app"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { gql } from "./lib/client"; + import logoUrl from "./assets/moku-icon-splash.svg"; + import { probeServer, loginBasic, authSession, logout } from "./lib/auth"; import { GET_DOWNLOAD_STATUS } from "./lib/queries"; import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte"; import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord"; @@ -76,6 +78,13 @@ let idle = $state(false); let devSplash = $state(false); + let loginRequired = $state(false); + let loginUser = $state(store.settings.serverAuthUser ?? ""); + let loginPass = $state(""); + let loginError = $state(null); + let loginBusy = $state(false); + let unsupportedMode = $state(false); + let platformScale = $state(1.0); let _appliedZoom = -1; let _vhRafId: number | null = null; @@ -195,30 +204,35 @@ function startProbe() { cancelProbe = false; failed = false; + loginRequired = false; let tries = 0; async function probe() { if (cancelProbe) return; tries++; - try { - const rawUrl = store.settings.serverUrl; - const base = typeof rawUrl === "string" && rawUrl.trim() - ? rawUrl.replace(/\/$/, "") - : "http://127.0.0.1:4567"; - const s = store.settings; - const auth: Record = s.serverAuthEnabled && s.serverAuthUser && s.serverAuthPass - ? { Authorization: `Basic ${btoa(`${s.serverAuthUser.trim()}:${s.serverAuthPass.trim()}`)}` } - : {}; - const res = await fetch(`${base}/api/graphql`, { - method: "POST", - headers: { "Content-Type": "application/json", ...auth }, - body: JSON.stringify({ query: "{ __typename }" }), - signal: AbortSignal.timeout(2000), - }); - if (res.ok && !cancelProbe) { serverProbeOk = true; return; } - } catch {} - if (tries >= MAX_ATTEMPTS && !cancelProbe) { failed = true; return; } - if (!cancelProbe) setTimeout(probe, 750); + const result = await probeServer(); + if (cancelProbe) return; + + if (result === "ok") { + serverProbeOk = true; + loginRequired = false; + return; + } + + if (result === "auth_required") { + serverProbeOk = true; + loginRequired = true; + return; + } + + if (result === "unsupported_mode") { + serverProbeOk = true; + unsupportedMode = true; + return; + } + + if (tries >= MAX_ATTEMPTS) { failed = true; return; } + setTimeout(probe, 750); } setTimeout(probe, 800); @@ -310,29 +324,99 @@ return () => window.removeEventListener("keydown", handleZoomKey); }); + async function handleLogin() { + if (!loginUser.trim() || !loginPass.trim()) { + loginError = "Username and password are required"; + return; + } + loginBusy = true; + loginError = null; + try { + await loginBasic(loginUser.trim(), loginPass.trim()); + loginRequired = false; + loginPass = ""; + loginError = null; + appReady = true; + } catch (e: any) { + loginError = e?.message ?? "Login failed"; + } finally { + loginBusy = false; + } + } + function handleRetry() { - failed = false; - notConfigured = false; - serverProbeOk = false; + failed = false; + notConfigured = false; + serverProbeOk = false; + loginRequired = false; + unsupportedMode = false; startProbe(); } function handleBypass() { - cancelProbe = true; - serverProbeOk = true; - appReady = true; + cancelProbe = true; + serverProbeOk = true; + loginRequired = false; + unsupportedMode = false; + appReady = true; } {#if devSplash} setTimeout(() => devSplash = false, 340)} /> -{:else if !appReady} +{:else if !appReady && !loginRequired && !unsupportedMode} appReady = true} + onReady={() => { appReady = true; }} onRetry={handleRetry} onBypass={handleBypass} /> +{:else if unsupportedMode} + +
+
+ +

moku

+ { + store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : + store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth" + } +

{store.settings.serverUrl || "localhost:4567"}

+

+ { + store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : + store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode" + } is not supported. Switch your server to Basic Auth and update Settings → Security. +

+ +
+
+{:else if loginRequired} + +
+
+ +

moku

+ Basic Auth +

{store.settings.serverUrl || "localhost:4567"}

+ {#if loginError} +

{loginError}

+ {/if} +
+ e.key === "Enter" && handleLogin()} /> + e.key === "Enter" && handleLogin()} /> +
+ + +
+
{:else}
{#if idle && !store.activeChapter} @@ -358,4 +442,27 @@ \ No newline at end of file diff --git a/src/components/pages/Library.svelte b/src/components/pages/Library.svelte index b25e3b0..4514842 100644 --- a/src/components/pages/Library.svelte +++ b/src/components/pages/Library.svelte @@ -1011,7 +1011,7 @@ .panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); } .panel-check-on { background: var(--accent); border-color: var(--accent); } .dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; } - .sort-caret { flex-shrink: 0; } + :global(.sort-caret) { flex-shrink: 0; } /* ── Selection toolbar ──────────────────────────────────────────────────── */ .select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; } diff --git a/src/components/settings/Settings.svelte b/src/components/settings/Settings.svelte index 7851022..0e975a0 100644 --- a/src/components/settings/Settings.svelte +++ b/src/components/settings/Settings.svelte @@ -10,6 +10,7 @@ import { GET_DOWNLOADS_PATH, SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_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 type { Category, Source } from "../../lib/types"; import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories } from "../../store/state.svelte"; + import { authSession } from "../../lib/auth"; import { cache } from "../../lib/cache"; import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds"; import type { Settings, FitMode, Theme } from "../../store/state.svelte"; @@ -433,8 +434,17 @@ let secLoading = $state(false); let secError = $state(null); let secSaved = $state(null); + let authMode = $state(store.settings.serverAuthMode ?? "NONE"); + // Warning is based on what the server has confirmed (store value), not the + // local draft — so it doesn't fire just because the store has a stale value + // before loadServerSecurity runs, and it clears once the user saves a + // supported mode. + const authModeUnsupported = $derived( + store.settings.serverAuthMode === "SIMPLE_LOGIN" || + store.settings.serverAuthMode === "UI_LOGIN" + ); let authUsername = $state(store.settings.serverAuthUser ?? ""); - let authPassword = $state(store.settings.serverAuthPass ?? ""); + let authPassword = $state(""); let socksEnabled = $state(store.settings.socksProxyEnabled ?? false); let socksHost = $state(store.settings.socksProxyHost ?? ""); let socksPort = $state(store.settings.socksProxyPort ?? "1080"); @@ -463,9 +473,10 @@ flareSolverrAsResponseFallback: boolean; }}>(GET_SERVER_SECURITY); const s = res.settings; - const authOn = s.authMode === "BASIC_AUTH"; - updateSettings({ serverAuthEnabled: authOn, serverAuthUser: s.authUsername }); - authUsername = s.authUsername; + const mode = (s.authMode ?? "NONE") as "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN"; + authMode = mode; + authUsername = s.authUsername; + updateSettings({ serverAuthMode: mode, serverAuthUser: s.authUsername }); socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost; socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion; socksUsername = s.socksProxyUsername; @@ -483,28 +494,57 @@ } catch {} } $effect(() => { if (tab === "security" && !secLoaded) loadServerSecurity(); }); - async function enableAuth() { - if (!authUsername.trim() || !authPassword.trim()) { - secError = "Username and password are required"; return; + async function saveAuth() { + if (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim())) { + secError = "Username and password are required for Basic Auth"; return; } secLoading = true; secError = null; - updateSettings({ serverAuthEnabled: true, serverAuthUser: authUsername, serverAuthPass: authPassword }); + + const prevMode = store.settings.serverAuthMode; + const prevUser = store.settings.serverAuthUser; + const prevPass = store.settings.serverAuthPass; + const newUser = authMode === "BASIC_AUTH" ? authUsername.trim() : ""; + const newPass = authMode === "BASIC_AUTH" ? authPassword.trim() : ""; + + // The store must contain valid credentials while the mutation request is + // in-flight so fetchAuthenticated can authenticate it: + // - Updating credentials: server still accepts the OLD password, so keep + // the old credentials in the store until the server confirms the change. + // - First-time enable (store has no pass yet): pre-commit the new + // credentials because there is nothing else to send. + const isFirstTimeEnable = authMode === "BASIC_AUTH" && !prevPass.trim(); + if (isFirstTimeEnable) { + updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: newPass }); + } + try { - await gql(SET_SERVER_AUTH, { authMode: "BASIC_AUTH", authUsername: authUsername.trim(), authPassword: authPassword.trim() }); + await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass }); + // On success, commit new credentials (no-op if already pre-committed). + updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: newPass }); + if (authMode === "NONE") { authSession.clearTokens(); authPassword = ""; } showSaved("auth"); } catch (e: any) { - updateSettings({ serverAuthEnabled: false }); - secError = e?.message ?? "Failed to enable authentication"; + // Roll back to previous values on failure. + updateSettings({ serverAuthMode: prevMode, serverAuthUser: prevUser, serverAuthPass: prevPass }); + secError = e?.message ?? "Failed to save authentication settings"; } finally { secLoading = false; } } - async function disableAuth() { + + async function clearAuth() { secLoading = true; secError = null; + const prevMode = store.settings.serverAuthMode; + const prevUser = store.settings.serverAuthUser; + const prevPass = store.settings.serverAuthPass; + // Keep existing credentials in the store so the disable-auth mutation + // goes out authenticated, then clear them on success. try { await gql(SET_SERVER_AUTH, { authMode: "NONE", authUsername: "", authPassword: "" }); - updateSettings({ serverAuthEnabled: false, serverAuthUser: "", serverAuthPass: "" }); - authUsername = ""; authPassword = ""; + updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" }); + authMode = "NONE"; authUsername = ""; authPassword = ""; + authSession.clearTokens(); showSaved("auth"); } catch (e: any) { + updateSettings({ serverAuthMode: prevMode, serverAuthUser: prevUser, serverAuthPass: prevPass }); secError = e?.message ?? "Failed to disable authentication"; } finally { secLoading = false; } } @@ -761,6 +801,7 @@ let contentSources: Source[] = $state([]); let contentSourcesLoading: boolean = $state(false); let newTagInput: string = $state(""); + let tagsRevealed: boolean = $state(false); let sourceSearch: string = $state(""); $effect(() => { if (tab === "content" && contentSources.length === 0 && !contentSourcesLoading) { @@ -834,7 +875,7 @@ return Array.from(map.values()); }); -