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 { getVersion } from "@tauri-apps/api/app";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { gql } from "./lib/client";
|
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 { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||||
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte";
|
||||||
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
|
import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord";
|
||||||
@@ -76,6 +78,13 @@
|
|||||||
let idle = $state(false);
|
let idle = $state(false);
|
||||||
let devSplash = $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 platformScale = $state(1.0);
|
||||||
let _appliedZoom = -1;
|
let _appliedZoom = -1;
|
||||||
let _vhRafId: number | null = null;
|
let _vhRafId: number | null = null;
|
||||||
@@ -195,30 +204,35 @@
|
|||||||
function startProbe() {
|
function startProbe() {
|
||||||
cancelProbe = false;
|
cancelProbe = false;
|
||||||
failed = false;
|
failed = false;
|
||||||
|
loginRequired = false;
|
||||||
let tries = 0;
|
let tries = 0;
|
||||||
|
|
||||||
async function probe() {
|
async function probe() {
|
||||||
if (cancelProbe) return;
|
if (cancelProbe) return;
|
||||||
tries++;
|
tries++;
|
||||||
try {
|
const result = await probeServer();
|
||||||
const rawUrl = store.settings.serverUrl;
|
if (cancelProbe) return;
|
||||||
const base = typeof rawUrl === "string" && rawUrl.trim()
|
|
||||||
? rawUrl.replace(/\/$/, "")
|
if (result === "ok") {
|
||||||
: "http://127.0.0.1:4567";
|
serverProbeOk = true;
|
||||||
const s = store.settings;
|
loginRequired = false;
|
||||||
const auth: Record<string, string> = s.serverAuthEnabled && s.serverAuthUser && s.serverAuthPass
|
return;
|
||||||
? { Authorization: `Basic ${btoa(`${s.serverAuthUser.trim()}:${s.serverAuthPass.trim()}`)}` }
|
}
|
||||||
: {};
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
if (result === "auth_required") {
|
||||||
method: "POST",
|
serverProbeOk = true;
|
||||||
headers: { "Content-Type": "application/json", ...auth },
|
loginRequired = true;
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
return;
|
||||||
signal: AbortSignal.timeout(2000),
|
}
|
||||||
});
|
|
||||||
if (res.ok && !cancelProbe) { serverProbeOk = true; return; }
|
if (result === "unsupported_mode") {
|
||||||
} catch {}
|
serverProbeOk = true;
|
||||||
if (tries >= MAX_ATTEMPTS && !cancelProbe) { failed = true; return; }
|
unsupportedMode = true;
|
||||||
if (!cancelProbe) setTimeout(probe, 750);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tries >= MAX_ATTEMPTS) { failed = true; return; }
|
||||||
|
setTimeout(probe, 750);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(probe, 800);
|
setTimeout(probe, 800);
|
||||||
@@ -310,29 +324,99 @@
|
|||||||
return () => window.removeEventListener("keydown", handleZoomKey);
|
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() {
|
function handleRetry() {
|
||||||
failed = false;
|
failed = false;
|
||||||
notConfigured = false;
|
notConfigured = false;
|
||||||
serverProbeOk = false;
|
serverProbeOk = false;
|
||||||
|
loginRequired = false;
|
||||||
|
unsupportedMode = false;
|
||||||
startProbe();
|
startProbe();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBypass() {
|
function handleBypass() {
|
||||||
cancelProbe = true;
|
cancelProbe = true;
|
||||||
serverProbeOk = true;
|
serverProbeOk = true;
|
||||||
appReady = true;
|
loginRequired = false;
|
||||||
|
unsupportedMode = false;
|
||||||
|
appReady = true;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if devSplash}
|
{#if devSplash}
|
||||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||||
{:else if !appReady}
|
{:else if !appReady && !loginRequired && !unsupportedMode}
|
||||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
|
||||||
showCards={store.settings.splashCards ?? true}
|
showCards={store.settings.splashCards ?? true}
|
||||||
onReady={() => appReady = true}
|
onReady={() => { appReady = true; }}
|
||||||
onRetry={handleRetry}
|
onRetry={handleRetry}
|
||||||
onBypass={handleBypass} />
|
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}
|
{:else}
|
||||||
<div id="app-shell" class="root">
|
<div id="app-shell" class="root">
|
||||||
{#if idle && !store.activeChapter}
|
{#if idle && !store.activeChapter}
|
||||||
@@ -358,4 +442,27 @@
|
|||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.content { flex: 1; 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>
|
</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 { 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); }
|
.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; }
|
.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 ──────────────────────────────────────────────────── */
|
/* ── 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; }
|
.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 { 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 type { Category, Source } from "../../lib/types";
|
||||||
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories } from "../../store/state.svelte";
|
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 { cache } from "../../lib/cache";
|
||||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
||||||
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
|
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
|
||||||
@@ -433,8 +434,17 @@
|
|||||||
let secLoading = $state(false);
|
let secLoading = $state(false);
|
||||||
let secError = $state<string | null>(null);
|
let secError = $state<string | null>(null);
|
||||||
let secSaved = $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 authUsername = $state(store.settings.serverAuthUser ?? "");
|
||||||
let authPassword = $state(store.settings.serverAuthPass ?? "");
|
let authPassword = $state("");
|
||||||
let socksEnabled = $state(store.settings.socksProxyEnabled ?? false);
|
let socksEnabled = $state(store.settings.socksProxyEnabled ?? false);
|
||||||
let socksHost = $state(store.settings.socksProxyHost ?? "");
|
let socksHost = $state(store.settings.socksProxyHost ?? "");
|
||||||
let socksPort = $state(store.settings.socksProxyPort ?? "1080");
|
let socksPort = $state(store.settings.socksProxyPort ?? "1080");
|
||||||
@@ -463,9 +473,10 @@
|
|||||||
flareSolverrAsResponseFallback: boolean;
|
flareSolverrAsResponseFallback: boolean;
|
||||||
}}>(GET_SERVER_SECURITY);
|
}}>(GET_SERVER_SECURITY);
|
||||||
const s = res.settings;
|
const s = res.settings;
|
||||||
const authOn = s.authMode === "BASIC_AUTH";
|
const mode = (s.authMode ?? "NONE") as "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||||
updateSettings({ serverAuthEnabled: authOn, serverAuthUser: s.authUsername });
|
authMode = mode;
|
||||||
authUsername = s.authUsername;
|
authUsername = s.authUsername;
|
||||||
|
updateSettings({ serverAuthMode: mode, serverAuthUser: s.authUsername });
|
||||||
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
|
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
|
||||||
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
|
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
|
||||||
socksUsername = s.socksProxyUsername;
|
socksUsername = s.socksProxyUsername;
|
||||||
@@ -483,28 +494,57 @@
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
$effect(() => { if (tab === "security" && !secLoaded) loadServerSecurity(); });
|
$effect(() => { if (tab === "security" && !secLoaded) loadServerSecurity(); });
|
||||||
async function enableAuth() {
|
async function saveAuth() {
|
||||||
if (!authUsername.trim() || !authPassword.trim()) {
|
if (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim())) {
|
||||||
secError = "Username and password are required"; return;
|
secError = "Username and password are required for Basic Auth"; return;
|
||||||
}
|
}
|
||||||
secLoading = true; secError = null;
|
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 {
|
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");
|
showSaved("auth");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
updateSettings({ serverAuthEnabled: false });
|
// Roll back to previous values on failure.
|
||||||
secError = e?.message ?? "Failed to enable authentication";
|
updateSettings({ serverAuthMode: prevMode, serverAuthUser: prevUser, serverAuthPass: prevPass });
|
||||||
|
secError = e?.message ?? "Failed to save authentication settings";
|
||||||
} finally { secLoading = false; }
|
} finally { secLoading = false; }
|
||||||
}
|
}
|
||||||
async function disableAuth() {
|
|
||||||
|
async function clearAuth() {
|
||||||
secLoading = true; secError = null;
|
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 {
|
try {
|
||||||
await gql(SET_SERVER_AUTH, { authMode: "NONE", authUsername: "", authPassword: "" });
|
await gql(SET_SERVER_AUTH, { authMode: "NONE", authUsername: "", authPassword: "" });
|
||||||
updateSettings({ serverAuthEnabled: false, serverAuthUser: "", serverAuthPass: "" });
|
updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" });
|
||||||
authUsername = ""; authPassword = "";
|
authMode = "NONE"; authUsername = ""; authPassword = "";
|
||||||
|
authSession.clearTokens();
|
||||||
showSaved("auth");
|
showSaved("auth");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
updateSettings({ serverAuthMode: prevMode, serverAuthUser: prevUser, serverAuthPass: prevPass });
|
||||||
secError = e?.message ?? "Failed to disable authentication";
|
secError = e?.message ?? "Failed to disable authentication";
|
||||||
} finally { secLoading = false; }
|
} finally { secLoading = false; }
|
||||||
}
|
}
|
||||||
@@ -761,6 +801,7 @@
|
|||||||
let contentSources: Source[] = $state([]);
|
let contentSources: Source[] = $state([]);
|
||||||
let contentSourcesLoading: boolean = $state(false);
|
let contentSourcesLoading: boolean = $state(false);
|
||||||
let newTagInput: string = $state("");
|
let newTagInput: string = $state("");
|
||||||
|
let tagsRevealed: boolean = $state(false);
|
||||||
let sourceSearch: string = $state("");
|
let sourceSearch: string = $state("");
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (tab === "content" && contentSources.length === 0 && !contentSourcesLoading) {
|
if (tab === "content" && contentSources.length === 0 && !contentSourcesLoading) {
|
||||||
@@ -834,7 +875,7 @@
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
});
|
});
|
||||||
</script>
|
</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="modal" role="dialog" aria-label="Settings">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<p class="modal-title">Settings</p>
|
<p class="modal-title">Settings</p>
|
||||||
@@ -1662,41 +1703,76 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
<p class="section-title">Server Authentication</p>
|
<p class="section-title">Server Authentication</p>
|
||||||
<span class="sec-status-pill" class:sec-pill-on={store.settings.serverAuthEnabled}>
|
<span class="sec-status-pill" class:sec-pill-on={store.settings.serverAuthMode === "BASIC_AUTH"} class:sec-pill-warn={authModeUnsupported}>
|
||||||
{store.settings.serverAuthEnabled ? "Enabled" : "Disabled"}
|
{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>
|
</span>
|
||||||
</div>
|
</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="step-row">
|
||||||
<div class="toggle-info">
|
<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>
|
</div>
|
||||||
<input class="text-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
<div class="auth-mode-group">
|
||||||
</div>
|
{#each [
|
||||||
<div class="step-row">
|
{ value: "NONE", label: "None" },
|
||||||
<div class="toggle-info">
|
{ value: "BASIC_AUTH", label: "Basic" },
|
||||||
<span class="toggle-label">Password</span>
|
] as opt}
|
||||||
</div>
|
<button
|
||||||
<div class="sec-field-wrap">
|
class="auth-mode-btn"
|
||||||
<input class="text-input" type={showAuthPass ? "text" : "password"} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
class:auth-mode-active={authMode === opt.value}
|
||||||
<button class="sec-eye-btn" onclick={() => showAuthPass = !showAuthPass} title={showAuthPass ? "Hide password" : "Show password"} tabindex="-1">
|
onclick={() => authMode = opt.value as any}
|
||||||
{#if showAuthPass}
|
disabled={secLoading}
|
||||||
<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>
|
>{opt.label}</button>
|
||||||
{:else}
|
{/each}
|
||||||
<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>
|
</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="step-row">
|
||||||
<div class="toggle-info"></div>
|
<div class="toggle-info"></div>
|
||||||
<div class="sec-btn-row">
|
<div class="sec-btn-row">
|
||||||
{#if store.settings.serverAuthEnabled}
|
{#if store.settings.serverAuthMode !== "NONE"}
|
||||||
<button class="sec-action-btn sec-action-danger" onclick={disableAuth} disabled={secLoading}>
|
<button class="sec-action-btn sec-action-danger" onclick={clearAuth} disabled={secLoading}>
|
||||||
{secLoading ? "Saving…" : "Disable"}
|
{secLoading ? "Saving…" : "Disable"}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="sec-action-btn sec-action-primary" onclick={enableAuth} disabled={secLoading || !authUsername.trim() || !authPassword.trim()}>
|
<button
|
||||||
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthEnabled ? "Update" : "Enable"}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1716,21 +1792,29 @@
|
|||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info">
|
<div class="toggle-info">
|
||||||
<span class="toggle-label">PIN</span>
|
<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>
|
||||||
<div class="sec-pin-wrap">
|
<div class="sec-btn-row">
|
||||||
<div class="sec-pin-row">
|
<input
|
||||||
<input class="text-input sec-pin-input" type="password" inputmode="numeric" maxlength={8} value={pinInput}
|
class="text-input"
|
||||||
oninput={(e) => { pinInput = e.currentTarget.value.replace(/\D/g, "").slice(0, 8); pinError = ""; }}
|
type="password"
|
||||||
onkeydown={(e) => e.key === "Enter" && commitPin()} placeholder="••••" autocomplete="off" />
|
inputmode="numeric"
|
||||||
<button class="sec-action-btn sec-action-primary"
|
maxlength={8}
|
||||||
onclick={commitPin}
|
placeholder="4–8 digits"
|
||||||
disabled={pinInput.length > 0 && pinInput.length < 4}>
|
value={pinInput}
|
||||||
{store.settings.appLockPin && pinInput === store.settings.appLockPin ? "Saved ✓" : "Save"}
|
oninput={(e) => { pinInput = e.currentTarget.value.replace(/\D/g, "").slice(0, 8); pinError = ""; }}
|
||||||
</button>
|
onkeydown={(e) => e.key === "Enter" && commitPin()}
|
||||||
</div>
|
autocomplete="off"
|
||||||
{#if pinError}<span class="sec-pin-error">{pinError}</span>{/if}
|
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>
|
</div>
|
||||||
|
{#if pinError}<span class="sec-pin-error">{pinError}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -1882,9 +1966,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="section-title">Blocked Genre Tags</p>
|
<p class="section-title">Blocked Genre Tags</p>
|
||||||
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-2);display:block">
|
<div class="section-title-row" style="padding-top:0;padding-bottom:var(--sp-2)">
|
||||||
Manga whose genres contain any of these substrings are filtered out. Case-insensitive, partial match.
|
<span class="toggle-desc" style="flex:1">Manga matching any of these substrings are filtered. Case-insensitive, partial match.</span>
|
||||||
</p>
|
<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">
|
<div class="content-tag-grid">
|
||||||
{#each (store.settings.nsfwFilteredTags ?? []) as tag}
|
{#each (store.settings.nsfwFilteredTags ?? []) as tag}
|
||||||
<span class="content-tag">
|
<span class="content-tag">
|
||||||
@@ -1894,6 +1982,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="content-tag-add">
|
<div class="content-tag-add">
|
||||||
<input
|
<input
|
||||||
class="text-input"
|
class="text-input"
|
||||||
@@ -2210,14 +2299,6 @@
|
|||||||
.storage-bar-fill.critical { background: var(--color-error); }
|
.storage-bar-fill.critical { background: var(--color-error); }
|
||||||
.storage-bar-labels { display: flex; justify-content: space-between; }
|
.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-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; }
|
.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 ───────────────────────────────────────── */
|
/* ── Migration banner ───────────────────────────────────────── */
|
||||||
@@ -2263,9 +2344,6 @@
|
|||||||
.folder-row:hover { background: var(--bg-raised); }
|
.folder-row:hover { background: var(--bg-raised); }
|
||||||
.folder-row-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); }
|
.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-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-delete:hover:not(:disabled) { color: var(--color-error) !important; }
|
||||||
.folder-hidden { opacity: 0.35; }
|
.folder-hidden { opacity: 0.35; }
|
||||||
.folder-default-active { color: var(--accent-fg) !important; }
|
.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; }
|
.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-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 { 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 { 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-input:focus { border-color: var(--border-focus); }
|
||||||
.oauth-btns { display: flex; align-items: center; gap: var(--sp-2); }
|
.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-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 { 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; }
|
.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-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 { 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:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
.sec-action-btn:disabled { opacity: 0.35; cursor: default; }
|
.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 { 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-primary:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
.sec-action-danger { border-color: var(--color-error); color: var(--color-error); }
|
.sec-action-danger { border-color: var(--color-error); color: var(--color-error); }
|
||||||
.sec-action-danger:hover:not(:disabled) { background: var(--color-error-bg); }
|
.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); }
|
.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 fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
@keyframes scaleIn { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); 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 { store } from "../store/state.svelte";
|
||||||
|
import { fetchAuthenticated } from "./auth";
|
||||||
|
|
||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
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;
|
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`; }
|
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||||
|
|
||||||
export function thumbUrl(path: string): string {
|
export function thumbUrl(path: string): string {
|
||||||
@@ -41,22 +33,22 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWithRetry(
|
async function fetchWithRetry(
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit,
|
init: RequestInit,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
retries = 3,
|
retries = 3,
|
||||||
delayMs = 300,
|
delayMs = 300,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { ...init, signal });
|
const res = await fetchAuthenticated(url, init, signal);
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
return res;
|
return res;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (e?.authRequired) throw e;
|
||||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||||
if (i === retries - 1) throw e;
|
if (i === retries - 1) throw e;
|
||||||
@@ -67,14 +59,14 @@ async function fetchWithRetry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function gql<T>(
|
export async function gql<T>(
|
||||||
query: string,
|
query: string,
|
||||||
variables?: Record<string, unknown>,
|
variables?: Record<string, unknown>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetchWithRetry(gqlUrl(), {
|
const res = await fetchWithRetry(gqlUrl(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", ...getAuthHeader() },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
}, signal);
|
}, signal);
|
||||||
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
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>>;
|
mangaPrefs: Record<number, Partial<MangaPrefs>>;
|
||||||
serverAuthUser: string;
|
serverAuthUser: string;
|
||||||
serverAuthPass: string;
|
serverAuthPass: string;
|
||||||
serverAuthEnabled: boolean;
|
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||||
socksProxyEnabled: boolean;
|
socksProxyEnabled: boolean;
|
||||||
socksProxyHost: string;
|
socksProxyHost: string;
|
||||||
socksProxyPort: string;
|
socksProxyPort: string;
|
||||||
@@ -314,7 +314,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
mangaPrefs: {},
|
mangaPrefs: {},
|
||||||
serverAuthUser: "",
|
serverAuthUser: "",
|
||||||
serverAuthPass: "",
|
serverAuthPass: "",
|
||||||
serverAuthEnabled: false,
|
serverAuthMode: "NONE",
|
||||||
socksProxyEnabled: false,
|
socksProxyEnabled: false,
|
||||||
socksProxyHost: "",
|
socksProxyHost: "",
|
||||||
socksProxyPort: "1080",
|
socksProxyPort: "1080",
|
||||||
|
|||||||
Reference in New Issue
Block a user