mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Fix: Revise Authentication Methods & Add Edge-Case Handling for Auth
This commit is contained in:
+134
-27
@@ -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<string | null>(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<string, string> = 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;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if devSplash}
|
||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||
{:else if !appReady}
|
||||
{:else if !appReady && !loginRequired && !unsupportedMode}
|
||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
||||
showCards={store.settings.splashCards ?? true}
|
||||
onReady={() => appReady = true}
|
||||
onReady={() => { appReady = true; }}
|
||||
onRetry={handleRetry}
|
||||
onBypass={handleBypass} />
|
||||
{:else if unsupportedMode}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<div class="auth-overlay">
|
||||
<div class="auth-card">
|
||||
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||
<p class="auth-title">moku</p>
|
||||
<span class="auth-mode-badge auth-mode-badge--warn">{
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth"
|
||||
}</span>
|
||||
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||
<p class="auth-body">
|
||||
<strong>{
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode"
|
||||
}</strong> is not supported. Switch your server to <strong>Basic Auth</strong> and update Settings → Security.
|
||||
</p>
|
||||
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Continue anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<div class="auth-overlay">
|
||||
<div class="auth-card">
|
||||
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||
<p class="auth-title">moku</p>
|
||||
<span class="auth-mode-badge">Basic Auth</span>
|
||||
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||
{#if loginError}
|
||||
<p class="auth-error">{loginError}</p>
|
||||
{/if}
|
||||
<div class="auth-fields">
|
||||
<input class="auth-input" type="text" placeholder="Username"
|
||||
bind:value={loginUser} disabled={loginBusy} autocomplete="username"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
<input class="auth-input" type="password" placeholder="Password"
|
||||
bind:value={loginPass} disabled={loginBusy} autocomplete="current-password"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
</div>
|
||||
<button class="auth-btn" onclick={handleLogin}
|
||||
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}>
|
||||
{loginBusy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div id="app-shell" class="root">
|
||||
{#if idle && !store.activeChapter}
|
||||
@@ -358,4 +442,27 @@
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.content { flex: 1; overflow: hidden; }
|
||||
|
||||
/* Auth overlay — floats above the SplashScreen */
|
||||
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); animation: authIn 0.28s cubic-bezier(0.16,1,0.3,1) both; text-align: center; }
|
||||
@keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; }
|
||||
.auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; }
|
||||
.auth-mode-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-full); padding: 2px 10px; }
|
||||
.auth-mode-badge--warn { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
||||
.auth-host { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: -4px 0 0; }
|
||||
.auth-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); margin: 0; }
|
||||
.auth-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.auth-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin: 0; width: 100%; box-sizing: border-box; }
|
||||
.auth-fields { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; }
|
||||
.auth-input { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); outline: none; box-sizing: border-box; transition: border-color var(--t-base), box-shadow var(--t-base); font-family: inherit; }
|
||||
.auth-input:focus { border-color: var(--border-focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
.auth-input:disabled { opacity: 0.5; }
|
||||
.auth-btn { width: 100%; padding: 9px; border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-sm); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); }
|
||||
.auth-btn:hover:not(:disabled) { opacity: 0.85; }
|
||||
.auth-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
.auth-btn--ghost { background: none; border-color: transparent; color: var(--text-faint); font-size: var(--text-xs); padding: 4px; }
|
||||
.auth-btn--ghost:hover:not(:disabled) { color: var(--text-muted); opacity: 1; }
|
||||
</style>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
let secSaved = $state<string | null>(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());
|
||||
});
|
||||
</script>
|
||||
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
||||
<div class="backdrop" role="presentation" tabindex="-1" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
||||
<div class="modal" role="dialog" aria-label="Settings">
|
||||
<div class="sidebar">
|
||||
<p class="modal-title">Settings</p>
|
||||
@@ -1662,41 +1703,76 @@
|
||||
<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 class="sec-status-pill" class:sec-pill-on={store.settings.serverAuthMode === "BASIC_AUTH"} class:sec-pill-warn={authModeUnsupported}>
|
||||
{store.settings.serverAuthMode === "BASIC_AUTH" ? "Basic Auth" :
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login — unsupported" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login — unsupported" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if authModeUnsupported}
|
||||
<div class="sec-banner sec-banner-warn" style="margin: var(--sp-2) var(--sp-3) 0;">
|
||||
<strong>{store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : "UI Login"}</strong> is not supported by this client — only <strong>Basic Auth</strong> works here.
|
||||
Switch your Suwayomi server to <code>basic_auth</code> and set the mode below to <strong>Basic</strong>, then save.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="step-row">
|
||||
<div class="toggle-info">
|
||||
<span class="toggle-label">Username</span>
|
||||
<span class="toggle-label">Mode</span>
|
||||
<span class="toggle-desc">How Suwayomi verifies requests</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 class="auth-mode-group">
|
||||
{#each [
|
||||
{ value: "NONE", label: "None" },
|
||||
{ value: "BASIC_AUTH", label: "Basic" },
|
||||
] as opt}
|
||||
<button
|
||||
class="auth-mode-btn"
|
||||
class:auth-mode-active={authMode === opt.value}
|
||||
onclick={() => authMode = opt.value as any}
|
||||
disabled={secLoading}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if authMode !== "NONE"}
|
||||
<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" : "Show"} 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>
|
||||
|
||||
|
||||
{/if}
|
||||
|
||||
<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}>
|
||||
{#if store.settings.serverAuthMode !== "NONE"}
|
||||
<button class="sec-action-btn sec-action-danger" onclick={clearAuth} 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
|
||||
class="sec-action-btn sec-action-primary"
|
||||
onclick={saveAuth}
|
||||
disabled={secLoading || (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim()))}
|
||||
>
|
||||
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthMode === "BASIC_AUTH" ? "Update" : authMode === "NONE" ? "Save" : "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1716,21 +1792,29 @@
|
||||
<div class="step-row">
|
||||
<div class="toggle-info">
|
||||
<span class="toggle-label">PIN</span>
|
||||
<span class="toggle-desc">4–8 digits</span>
|
||||
<span class="toggle-desc">4–8 digits, saved on Enter or Save button</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 class="sec-btn-row">
|
||||
<input
|
||||
class="text-input"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength={8}
|
||||
placeholder="4–8 digits"
|
||||
value={pinInput}
|
||||
oninput={(e) => { pinInput = e.currentTarget.value.replace(/\D/g, "").slice(0, 8); pinError = ""; }}
|
||||
onkeydown={(e) => e.key === "Enter" && commitPin()}
|
||||
autocomplete="off"
|
||||
aria-label="Enter PIN"
|
||||
style="width:120px;letter-spacing:0.2em"
|
||||
/>
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1882,9 +1966,13 @@
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Blocked Genre Tags</p>
|
||||
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-2);display:block">
|
||||
Manga whose genres contain any of these substrings are filtered out. Case-insensitive, partial match.
|
||||
</p>
|
||||
<div class="section-title-row" style="padding-top:0;padding-bottom:var(--sp-2)">
|
||||
<span class="toggle-desc" style="flex:1">Manga matching any of these substrings are filtered. Case-insensitive, partial match.</span>
|
||||
<button class="kb-reset" style="font-size:var(--text-xs);padding:2px 10px;flex-shrink:0" onclick={() => tagsRevealed = !tagsRevealed}>
|
||||
{tagsRevealed ? "Hide" : `Show (${(store.settings.nsfwFilteredTags ?? []).length})`}
|
||||
</button>
|
||||
</div>
|
||||
{#if tagsRevealed}
|
||||
<div class="content-tag-grid">
|
||||
{#each (store.settings.nsfwFilteredTags ?? []) as tag}
|
||||
<span class="content-tag">
|
||||
@@ -1894,6 +1982,7 @@
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="content-tag-add">
|
||||
<input
|
||||
class="text-input"
|
||||
@@ -2210,14 +2299,6 @@
|
||||
.storage-bar-fill.critical { background: var(--color-error); }
|
||||
.storage-bar-labels { display: flex; justify-content: space-between; }
|
||||
.storage-bar-used, .storage-bar-free { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.storage-legend { display: flex; flex-direction: column; gap: var(--sp-1); padding: 0 var(--sp-3); }
|
||||
.storage-legend-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.storage-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.storage-dot-manga { background: var(--accent); }
|
||||
.storage-dot-free { background: var(--bg-overlay); border: 1px solid var(--border-strong); }
|
||||
.storage-dot-app { background: var(--text-faint); }
|
||||
.storage-legend-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; }
|
||||
.storage-legend-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.storage-path-note { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3) 0; word-break: break-all; }
|
||||
|
||||
/* ── Migration banner ───────────────────────────────────────── */
|
||||
@@ -2263,9 +2344,6 @@
|
||||
.folder-row:hover { background: var(--bg-raised); }
|
||||
.folder-row-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.folder-row-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.folder-tab-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.folder-tab-toggle.on { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.folder-tab-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.folder-delete:hover:not(:disabled) { color: var(--color-error) !important; }
|
||||
.folder-hidden { opacity: 0.35; }
|
||||
.folder-default-active { color: var(--accent-fg) !important; }
|
||||
@@ -2360,13 +2438,14 @@
|
||||
.tracker-connected-btns { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; }
|
||||
.oauth-flow { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; align-items: flex-end; }
|
||||
.oauth-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); text-align: right; }
|
||||
.oauth-hint strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.oauth-hint code { font-family: monospace; font-size: 10px; color: var(--text-muted); background: var(--bg-overlay); padding: 1px 4px; border-radius: 3px; }
|
||||
.oauth-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 10px; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); outline: none; transition: border-color var(--t-base); }
|
||||
.oauth-input:focus { border-color: var(--border-focus); }
|
||||
.oauth-btns { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.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 { 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); line-height: var(--leading-snug); }
|
||||
.sec-banner-error { color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); }
|
||||
.sec-banner-warn { color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); }
|
||||
.sec-banner-warn code { font-family: monospace; font-size: 10px; background: color-mix(in srgb, var(--color-error) 12%, transparent); padding: 1px 4px; border-radius: 3px; }
|
||||
.sec-pill-warn { border-color: var(--color-error); color: var(--color-error); background: var(--color-error-bg); }
|
||||
.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; }
|
||||
@@ -2379,14 +2458,15 @@
|
||||
.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; }
|
||||
.auth-mode-group { display: flex; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: 2px; flex-shrink: 0; }
|
||||
.auth-mode-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); white-space: nowrap; }
|
||||
.auth-mode-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.auth-mode-btn.auth-mode-active { background: var(--bg-surface); color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||||
.auth-mode-btn:disabled { opacity: 0.4; 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 } }
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { store, updateSettings } from "../store/state.svelte";
|
||||
|
||||
// Only NONE and BASIC_AUTH are supported. SIMPLE_LOGIN and UI_LOGIN are
|
||||
// recognised as values the server may report, but this client will not
|
||||
// attempt to authenticate with them — it will show an unsupported-mode
|
||||
// warning instead.
|
||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
|
||||
export const authSession = {
|
||||
// These stubs exist so callers that imported authSession don't break.
|
||||
// Basic-auth credentials are never stored client-side; they are sent
|
||||
// per-request via the Authorization header.
|
||||
clearTokens() {},
|
||||
hasSession(): boolean { return true; },
|
||||
};
|
||||
|
||||
function getServerBase(): string {
|
||||
const url = store.settings.serverUrl;
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
function buildRequestInit(init: RequestInit, extraHeaders: Record<string, string> = {}): RequestInit {
|
||||
return {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { ...(init.headers as Record<string, string> ?? {}), ...extraHeaders },
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
const headers = user && pass ? basicHeader(user, pass) : {};
|
||||
return fetch(url, buildRequestInit({ ...init, signal }, headers));
|
||||
}
|
||||
|
||||
// SIMPLE_LOGIN, UI_LOGIN, and any future unknown modes: send the request
|
||||
// unauthenticated. The probe/login gate in App.svelte will have already
|
||||
// shown an unsupported-mode warning so the user knows requests may fail.
|
||||
return fetch(url, { ...init, signal });
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||
// Persist credentials through the store so fetchAuthenticated picks them up.
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
// Basic auth has no server-side session to invalidate.
|
||||
updateSettings({ serverAuthPass: "" });
|
||||
}
|
||||
|
||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
|
||||
const base = getServerBase();
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
try {
|
||||
let headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
// If we have credentials, try them — a 200 means we're good.
|
||||
// If we don't have credentials yet, fall through to the unauthenticated
|
||||
// probe so we still get the WWW-Authenticate header back.
|
||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
|
||||
if (res.ok) return "ok";
|
||||
|
||||
if (res.status === 401) {
|
||||
// Sniff the WWW-Authenticate header to auto-detect the server's scheme.
|
||||
const wwwAuth = (res.headers.get("WWW-Authenticate") ?? "").toLowerCase();
|
||||
|
||||
if (/basic/i.test(wwwAuth)) {
|
||||
// Server wants Basic Auth — update the stored mode so the login gate
|
||||
// shows the right UI and fetchAuthenticated uses the right scheme.
|
||||
if (mode !== "BASIC_AUTH") {
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
||||
}
|
||||
return "auth_required";
|
||||
}
|
||||
|
||||
// Any other 401 (Bearer, Digest, cookie-based, etc.) is unsupported.
|
||||
// Try to figure out what it is for a better warning label.
|
||||
if (/bearer/i.test(wwwAuth)) {
|
||||
// Likely SIMPLE_LOGIN or UI_LOGIN — store it so the warning names it.
|
||||
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
} else if (mode === "NONE") {
|
||||
// Unknown scheme and we had no mode stored — store a sentinel so the
|
||||
// warning fires instead of an infinite auth_required loop.
|
||||
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
|
||||
}
|
||||
return "unsupported_mode";
|
||||
}
|
||||
|
||||
return "unreachable";
|
||||
} catch { return "unreachable"; }
|
||||
}
|
||||
+12
-20
@@ -1,4 +1,5 @@
|
||||
import { store } from "../store/state.svelte";
|
||||
import { fetchAuthenticated } from "./auth";
|
||||
|
||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||
|
||||
@@ -7,15 +8,6 @@ function getServerUrl(): string {
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||
}
|
||||
|
||||
function getAuthHeader(): Record<string, string> {
|
||||
const s = store.settings;
|
||||
if (!s.serverAuthEnabled) return {};
|
||||
const user = typeof s.serverAuthUser === "string" ? s.serverAuthUser.trim() : "";
|
||||
const pass = typeof s.serverAuthPass === "string" ? s.serverAuthPass.trim() : "";
|
||||
if (user && pass) return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
return {};
|
||||
}
|
||||
|
||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||
|
||||
export function thumbUrl(path: string): string {
|
||||
@@ -41,22 +33,22 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
}
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
retries = 3,
|
||||
delayMs = 300,
|
||||
retries = 3,
|
||||
delayMs = 300,
|
||||
): Promise<Response> {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal });
|
||||
const res = await fetchAuthenticated(url, init, signal);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return res;
|
||||
} catch (e: any) {
|
||||
if (e?.authRequired) throw e;
|
||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||
if (i === retries - 1) throw e;
|
||||
@@ -67,14 +59,14 @@ async function fetchWithRetry(
|
||||
}
|
||||
|
||||
export async function gql<T>(
|
||||
query: string,
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const res = await fetchWithRetry(gqlUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...getAuthHeader() },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
}, signal);
|
||||
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
@@ -888,3 +888,20 @@ export const LOGOUT_TRACKER = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LOGIN_USER = `
|
||||
mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
accessToken
|
||||
refreshToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REFRESH_TOKEN = `
|
||||
mutation RefreshToken {
|
||||
refreshToken {
|
||||
accessToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -243,7 +243,7 @@ export interface Settings {
|
||||
mangaPrefs: Record<number, Partial<MangaPrefs>>;
|
||||
serverAuthUser: string;
|
||||
serverAuthPass: string;
|
||||
serverAuthEnabled: boolean;
|
||||
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
socksProxyEnabled: boolean;
|
||||
socksProxyHost: string;
|
||||
socksProxyPort: string;
|
||||
@@ -314,7 +314,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
mangaPrefs: {},
|
||||
serverAuthUser: "",
|
||||
serverAuthPass: "",
|
||||
serverAuthEnabled: false,
|
||||
serverAuthMode: "NONE",
|
||||
socksProxyEnabled: false,
|
||||
socksProxyHost: "",
|
||||
socksProxyPort: "1080",
|
||||
|
||||
Reference in New Issue
Block a user