mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Feat: Update Suwayomi (Stable -> Preview) + UI Login
This commit is contained in:
@@ -38,14 +38,9 @@ In-Progress:
|
||||
|
||||
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
|
||||
|
||||
Notes from last time:
|
||||
- Storage has been configured, now just need protocols
|
||||
- Export/Import
|
||||
- Migration
|
||||
- Data-Clean
|
||||
- Add Disable Auto-Completed Feature to Library
|
||||
- Implement Unwired Fixes (DOCUMENT)
|
||||
- Cap ReaderSettings Zoom (100)
|
||||
- Fix SeriesDetail Chapter Amount (Link to Scanlator Filtering)
|
||||
|
||||
- MAJOR Migration
|
||||
- Completed GQL Migration
|
||||
- Completed Types Migration (May need Refactor)
|
||||
- Completed Shared Migration
|
||||
- Completed Features Migration
|
||||
Notes from last time:
|
||||
|
||||
@@ -2,12 +2,12 @@ use std::path::PathBuf;
|
||||
|
||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = false
|
||||
server.webUIEnabled = true
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.webUIInterface = "browser"
|
||||
server.webUIFlavor = "WebUI"
|
||||
server.webUIChannel = "stable"
|
||||
server.webUIChannel = "preview"
|
||||
server.electronPath = ""
|
||||
server.debugLogsEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
@@ -37,7 +37,7 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
|
||||
|
||||
let patched = patch_conf_key(
|
||||
patch_conf_key(
|
||||
patch_conf_key(contents, "server.webUIEnabled", "false"),
|
||||
patch_conf_key(contents, "server.webUIEnabled", "true"),
|
||||
"server.initialOpenInBrowserEnabled",
|
||||
"false",
|
||||
),
|
||||
|
||||
+7
-2
@@ -128,7 +128,7 @@
|
||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||
|
||||
{:else if !appReady && !boot.loginRequired && !boot.unsupportedMode}
|
||||
{:else if !appReady && !boot.loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
||||
failed={boot.failed} notConfigured={boot.notConfigured}
|
||||
showCards={store.settings.splashCards ?? true}
|
||||
@@ -136,7 +136,7 @@
|
||||
onRetry={retryBoot}
|
||||
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
||||
|
||||
{:else if boot.unsupportedMode || boot.loginRequired}
|
||||
{:else if boot.loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<AuthGate onReady={() => { appReady = true; }} />
|
||||
|
||||
@@ -146,6 +146,11 @@
|
||||
onDismiss={() => { idle = false; }} />
|
||||
{/if}
|
||||
|
||||
{#if boot.sessionExpired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<AuthGate onReady={() => { boot.sessionExpired = false; }} />
|
||||
{/if}
|
||||
|
||||
<div id="app-shell" class="root">
|
||||
{#if !store.activeChapter}<TitleBar />{/if}
|
||||
<div class="content">
|
||||
|
||||
+15
-4
@@ -1,5 +1,6 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { fetchAuthenticated } from "../core/auth";
|
||||
import { fetchAuthenticated, AuthRequiredError } from "../core/auth";
|
||||
import { boot } from "@store/boot.svelte";
|
||||
|
||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||
|
||||
@@ -43,12 +44,13 @@ async function fetchWithRetry(
|
||||
for (let i = 0; i < retries; i++) {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
try {
|
||||
const res = await fetchAuthenticated(url, init, signal);
|
||||
const res = await fetchAuthenticated(url, init, signal, boot.skipped);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return res;
|
||||
} catch (e: any) {
|
||||
if (e?.authRequired) throw e;
|
||||
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (e instanceof AuthRequiredError) throw e;
|
||||
if (i === retries - 1) throw e;
|
||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||
}
|
||||
@@ -70,6 +72,15 @@ export async function gql<T>(
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
const json: GQLResponse<T> = await res.json();
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||
if (json.errors?.length) {
|
||||
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
||||
if (isAuthError && !boot.skipped) {
|
||||
boot.sessionExpired = true;
|
||||
boot.loginRequired = true;
|
||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||
throw new AuthRequiredError(json.errors[0].message);
|
||||
}
|
||||
throw new Error(json.errors[0].message);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export const PUSH_KOSYNC_PROGRESS = `
|
||||
export const LOGIN_USER = `
|
||||
mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
accessToken refreshToken
|
||||
accessToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+89
-24
@@ -1,10 +1,29 @@
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
|
||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN";
|
||||
|
||||
export class AuthRequiredError extends Error {
|
||||
constructor(msg = "Authentication required") {
|
||||
super(msg);
|
||||
this.name = "AuthRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
let _accessToken: string | null = null;
|
||||
|
||||
export const uiAuth = {
|
||||
getToken: () => _accessToken,
|
||||
setToken: (t: string) => { _accessToken = t; },
|
||||
clearToken: () => { _accessToken = null; },
|
||||
};
|
||||
|
||||
export const authSession = {
|
||||
clearTokens() {},
|
||||
hasSession(): boolean { return true; },
|
||||
clearTokens() { uiAuth.clearToken(); },
|
||||
hasSession(): boolean {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") return _accessToken !== null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function getServerBase(): string {
|
||||
@@ -22,24 +41,72 @@ function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
export function fetchAuthenticated(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
function bearerHeader(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function gqlBody(query: string, variables?: Record<string, unknown>): string {
|
||||
return JSON.stringify({ query, ...(variables ? { variables } : {}) });
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
skipped = false,
|
||||
): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const baseHeaders = (init.headers ?? {}) as Record<string, string>;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
return fetch(url, {
|
||||
...init, signal, credentials: "omit",
|
||||
headers: { ...(init.headers as Record<string, string> ?? {}), ...(user && pass ? basicHeader(user, pass) : {}) },
|
||||
headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "UI_LOGIN") {
|
||||
const token = uiAuth.getToken();
|
||||
if (!token) {
|
||||
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
return fetch(url, {
|
||||
...init, signal, credentials: "omit",
|
||||
headers: { ...baseHeaders, ...bearerHeader(token) },
|
||||
});
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, signal, credentials: "omit" });
|
||||
}
|
||||
|
||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST", credentials: "omit",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: gqlBody(
|
||||
`mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) { accessToken }
|
||||
}`,
|
||||
{ username: user, password: pass },
|
||||
),
|
||||
signal: timeoutSignal(8000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
const token: string | undefined = json?.data?.login?.accessToken;
|
||||
if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
|
||||
uiAuth.setToken(token);
|
||||
updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST", credentials: "omit",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
body: gqlBody("{ __typename }"),
|
||||
signal: timeoutSignal(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||
@@ -47,39 +114,37 @@ export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
updateSettings({ serverAuthPass: "" });
|
||||
uiAuth.clearToken();
|
||||
updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" });
|
||||
}
|
||||
|
||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
|
||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> {
|
||||
const base = getServerBase();
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
if (mode === "UI_LOGIN" && !_accessToken) return "auth_required";
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||
} else if (mode === "UI_LOGIN" && _accessToken) {
|
||||
Object.assign(headers, bearerHeader(_accessToken));
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST", credentials: "omit", headers,
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
body: gqlBody("{ __typename }"),
|
||||
signal: timeoutSignal(5000),
|
||||
});
|
||||
|
||||
if (res.ok) return "ok";
|
||||
if (res.status === 401) {
|
||||
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
||||
if (/basic/i.test(wwwAuth)) {
|
||||
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
||||
return "auth_required";
|
||||
}
|
||||
if (/bearer/i.test(wwwAuth)) {
|
||||
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
} else if (mode === "NONE") {
|
||||
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
|
||||
}
|
||||
return "unsupported_mode";
|
||||
}
|
||||
if (res.status === 401) return "auth_required";
|
||||
return "unreachable";
|
||||
} catch { return "unreachable"; }
|
||||
} catch {
|
||||
return "unreachable";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
const VAULT_KEY = "moku-credential-vault";
|
||||
const SALT_ITERATIONS = 200_000;
|
||||
const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"];
|
||||
|
||||
export interface VaultPayload {
|
||||
refreshToken?: string;
|
||||
basicUser?: string;
|
||||
basicPass?: string;
|
||||
authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE";
|
||||
}
|
||||
|
||||
interface StoredVault {
|
||||
salt: string;
|
||||
iv: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
function toB64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
function fromB64(s: string): Uint8Array {
|
||||
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" },
|
||||
keyMat,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
KEY_USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
export function vaultExists(): boolean {
|
||||
return !!localStorage.getItem(VAULT_KEY);
|
||||
}
|
||||
|
||||
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveKey(pin, salt);
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const cipher = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
enc.encode(JSON.stringify(payload)),
|
||||
);
|
||||
|
||||
localStorage.setItem(VAULT_KEY, JSON.stringify({
|
||||
salt: toB64(salt),
|
||||
iv: toB64(iv),
|
||||
data: toB64(cipher),
|
||||
} satisfies StoredVault));
|
||||
}
|
||||
|
||||
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
||||
const raw = localStorage.getItem(VAULT_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const stored = JSON.parse(raw) as StoredVault;
|
||||
const key = await deriveKey(pin, fromB64(stored.salt));
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: fromB64(stored.iv) },
|
||||
key,
|
||||
fromB64(stored.data),
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearVault(): void {
|
||||
localStorage.removeItem(VAULT_KEY);
|
||||
}
|
||||
|
||||
export async function rekeyVault(oldPin: string, newPin: string): Promise<boolean> {
|
||||
const payload = await unlockVault(oldPin);
|
||||
if (!payload) return false;
|
||||
await lockVault(newPin, payload);
|
||||
return true;
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
|
||||
export type { PersistedData } from "./persist";
|
||||
export type { PersistedData } from "./persist";
|
||||
|
||||
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
|
||||
export type { VaultPayload } from "./credentialVault";
|
||||
@@ -153,4 +153,14 @@ export async function loadBackups(): Promise<BackupEntry[]> {
|
||||
export async function persistBackups(list: BackupEntry[]): Promise<void> {
|
||||
await backupsStore.set("backupList", list);
|
||||
await backupsStore.save();
|
||||
}
|
||||
|
||||
export async function resetAuthSettings(): Promise<void> {
|
||||
const current = await settingsStore.get<any>("settings") ?? {};
|
||||
current.serverAuthMode = "NONE";
|
||||
current.serverAuthUser = "";
|
||||
current.serverAuthPass = "";
|
||||
await settingsStore.set("settings", current);
|
||||
await settingsStore.save();
|
||||
localStorage.removeItem("moku-credential-vault");
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
|
||||
} from "@api/mutations";
|
||||
import { addToast, setActiveDownloads } from "@store/state.svelte";
|
||||
import { boot } from "@store/boot.svelte";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
||||
import {
|
||||
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
||||
@@ -104,6 +105,7 @@ class DownloadStore {
|
||||
}
|
||||
|
||||
async poll() {
|
||||
if (boot.sessionExpired) return;
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then((d) => this.applyStatus(d.downloadStatus))
|
||||
.catch(console.error)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings, addToast } from "@store/state.svelte";
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { authSession } from "@core/auth";
|
||||
import { GET_SERVER_SECURITY } from "@api/queries/extensions";
|
||||
@@ -10,8 +10,6 @@
|
||||
|
||||
let showAuthPass = $state(false);
|
||||
let showSocksPass = $state(false);
|
||||
let pinInput = $state(store.settings.appLockPin ?? "");
|
||||
let pinError = $state("");
|
||||
let secLoading = $state(false);
|
||||
let secError = $state<string | null>(null);
|
||||
let secSaved = $state<string | null>(null);
|
||||
@@ -21,11 +19,6 @@
|
||||
let authUsername = $state(store.settings.serverAuthUser ?? "");
|
||||
let authPassword = $state("");
|
||||
|
||||
const authModeUnsupported = $derived(
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ||
|
||||
store.settings.serverAuthMode === "UI_LOGIN"
|
||||
);
|
||||
|
||||
let socksEnabled = $state(store.settings.socksProxyEnabled ?? false);
|
||||
let socksHost = $state(store.settings.socksProxyHost ?? "");
|
||||
let socksPort = $state(store.settings.socksProxyPort ?? "1080");
|
||||
@@ -60,9 +53,9 @@
|
||||
flareSolverrAsResponseFallback: boolean;
|
||||
}}>(GET_SERVER_SECURITY);
|
||||
const s = res.settings;
|
||||
const mode = (s.authMode ?? "NONE") as "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
authMode = mode; authUsername = s.authUsername;
|
||||
updateSettings({ serverAuthMode: mode, serverAuthUser: s.authUsername });
|
||||
authMode = store.settings.serverAuthMode ?? "NONE";
|
||||
authUsername = s.authUsername || store.settings.serverAuthUser || "";
|
||||
updateSettings({ serverAuthUser: authUsername });
|
||||
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
|
||||
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
|
||||
socksUsername = s.socksProxyUsername;
|
||||
@@ -80,19 +73,28 @@
|
||||
}
|
||||
|
||||
async function saveAuth() {
|
||||
if (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim())) {
|
||||
secError = "Username and password are required for Basic Auth"; return;
|
||||
if ((authMode === "BASIC_AUTH" || authMode === "UI_LOGIN") && (!authUsername.trim() || !authPassword.trim())) {
|
||||
secError = "Username and password are required"; return;
|
||||
}
|
||||
secLoading = true; secError = null;
|
||||
const prev = { mode: store.settings.serverAuthMode, user: store.settings.serverAuthUser, pass: store.settings.serverAuthPass };
|
||||
const newUser = authMode === "BASIC_AUTH" ? authUsername.trim() : "";
|
||||
const newPass = authMode === "BASIC_AUTH" ? authPassword.trim() : "";
|
||||
if (authMode === "BASIC_AUTH" && !prev.pass.trim())
|
||||
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: newPass });
|
||||
|
||||
try {
|
||||
const newUser = authMode !== "NONE" ? authUsername.trim() : "";
|
||||
const newPass = authMode !== "NONE" ? authPassword.trim() : "";
|
||||
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
|
||||
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: newPass });
|
||||
if (authMode === "NONE") { authSession.clearTokens(); authPassword = ""; }
|
||||
|
||||
if (authMode === "UI_LOGIN") {
|
||||
authSession.clearTokens();
|
||||
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: newUser, serverAuthPass: "" });
|
||||
} else if (authMode === "BASIC_AUTH") {
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: newUser, serverAuthPass: newPass });
|
||||
} else {
|
||||
authSession.clearTokens();
|
||||
updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" });
|
||||
}
|
||||
|
||||
authPassword = "";
|
||||
showSaved("auth");
|
||||
} catch (e: any) {
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
|
||||
@@ -152,12 +154,13 @@
|
||||
} finally { secLoading = false; }
|
||||
}
|
||||
|
||||
function commitPin() {
|
||||
const cleaned = pinInput.replace(/\D/g, "").slice(0, 8);
|
||||
pinInput = cleaned;
|
||||
if (cleaned.length >= 4) { updateSettings({ appLockPin: cleaned }); pinError = ""; }
|
||||
else if (cleaned.length > 0) { pinError = "PIN must be at least 4 digits"; }
|
||||
else { updateSettings({ appLockPin: "" }); pinError = ""; }
|
||||
function forceResetAuth() {
|
||||
authSession.clearTokens();
|
||||
authMode = "NONE";
|
||||
authUsername = "";
|
||||
authPassword = "";
|
||||
updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" });
|
||||
showSaved("auth");
|
||||
}
|
||||
|
||||
const EyeOpen = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`;
|
||||
@@ -173,22 +176,16 @@
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">
|
||||
Server Authentication
|
||||
<span class="s-pill" class:on={store.settings.serverAuthMode === "BASIC_AUTH"} class: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 class="s-pill" class:on={store.settings.serverAuthMode === "BASIC_AUTH" || store.settings.serverAuthMode === "UI_LOGIN"}>
|
||||
{store.settings.serverAuthMode === "BASIC_AUTH" ? "Basic Auth" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Disabled"}
|
||||
</span>
|
||||
</p>
|
||||
<div class="s-section-body">
|
||||
{#if authModeUnsupported}
|
||||
<div class="s-banner s-banner-warn">
|
||||
<strong>{store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : "UI Login"}</strong> is not supported — only <strong>Basic Auth</strong> works here. Switch your server to <code>basic_auth</code> and set the mode to <strong>Basic</strong>.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Mode</span><span class="s-desc">How Suwayomi verifies requests</span></div>
|
||||
<div class="s-row-info"><span class="s-label">Mode</span><span class="s-desc">How Moku authenticates with the server</span></div>
|
||||
<div class="s-segment">
|
||||
{#each [{ value: "NONE", label: "None" }, { value: "BASIC_AUTH", label: "Basic" }] as opt}
|
||||
{#each [{ value: "NONE", label: "None" }, { value: "BASIC_AUTH", label: "Basic" }, { value: "UI_LOGIN", label: "UI Login" }] as opt}
|
||||
<button class="s-segment-btn" class:active={authMode === opt.value}
|
||||
onclick={() => authMode = opt.value as any} disabled={secLoading}>{opt.label}</button>
|
||||
{/each}
|
||||
@@ -213,7 +210,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<div class="s-row-info">
|
||||
<button class="s-ghost-btn" onclick={forceResetAuth} disabled={secLoading} title="Force reset local auth state">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if store.settings.serverAuthMode !== "NONE"}
|
||||
<button class="s-btn s-btn-danger" onclick={clearAuth} disabled={secLoading}>
|
||||
@@ -229,33 +231,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">App Lock</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">PIN lock</span><span class="s-desc">Require a PIN on launch and after idle timeout</span></div>
|
||||
<button role="switch" aria-checked={store.settings.appLockEnabled ?? false} class="s-toggle" class:on={store.settings.appLockEnabled}
|
||||
onclick={() => updateSettings({ appLockEnabled: !store.settings.appLockEnabled })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if store.settings.appLockEnabled}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">PIN</span><span class="s-desc">4–8 digits</span></div>
|
||||
<div class="s-btn-row">
|
||||
<input class="s-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" style="width:120px;letter-spacing:0.2em" />
|
||||
<button class="s-btn s-btn-accent" onclick={commitPin} disabled={pinInput.length > 0 && pinInput.length < 4}>
|
||||
{store.settings.appLockPin && pinInput === store.settings.appLockPin ? "Saved ✓" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if pinError}<div class="s-row"><span class="s-pin-error">{pinError}</span></div>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">SOCKS Proxy</p>
|
||||
<div class="s-section-body">
|
||||
@@ -358,4 +333,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
|
||||
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
|
||||
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
</style>
|
||||
@@ -14,43 +14,31 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if boot.unsupportedMode}
|
||||
{#if boot.loginRequired}
|
||||
<div class="auth-overlay">
|
||||
<div class="auth-card anim-scale-in">
|
||||
<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 boot.loginRequired}
|
||||
<div class="auth-overlay">
|
||||
<div class="auth-card anim-scale-in">
|
||||
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||
<p class="auth-title">moku</p>
|
||||
<span class="auth-mode-badge">Basic Auth</span>
|
||||
<span class="auth-mode-badge">
|
||||
{store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Basic Auth"}
|
||||
</span>
|
||||
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||
|
||||
{#if boot.loginError}
|
||||
<p class="auth-error">{boot.loginError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="auth-fields">
|
||||
<input class="auth-input" type="text" placeholder="Username"
|
||||
bind:value={boot.loginUser} disabled={boot.loginBusy} autocomplete="username"
|
||||
bind:value={boot.loginUser} disabled={boot.loginBusy}
|
||||
autocomplete="username"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
<input class="auth-input" type="password" placeholder="Password"
|
||||
bind:value={boot.loginPass} disabled={boot.loginBusy} autocomplete="current-password"
|
||||
bind:value={boot.loginPass} disabled={boot.loginBusy}
|
||||
autocomplete="current-password"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
</div>
|
||||
|
||||
<button class="auth-btn" onclick={handleLogin}
|
||||
disabled={boot.loginBusy || !boot.loginUser.trim() || !boot.loginPass.trim()}>
|
||||
{boot.loginBusy ? "Signing in…" : "Sign in"}
|
||||
@@ -62,15 +50,12 @@
|
||||
|
||||
<style>
|
||||
.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); text-align: center; }
|
||||
.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); text-align: center; outline: 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; }
|
||||
@@ -81,4 +66,4 @@
|
||||
.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>
|
||||
@@ -23,8 +23,14 @@
|
||||
onReady, onRetry, onBypass, onDismiss,
|
||||
}: Props = $props();
|
||||
|
||||
const serverAuthActive = $derived(
|
||||
store.settings.serverAuthMode === "BASIC_AUTH" || store.settings.serverAuthMode === "UI_LOGIN"
|
||||
);
|
||||
|
||||
const lockEnabled = $derived(
|
||||
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
|
||||
store.settings.appLockEnabled &&
|
||||
(store.settings.appLockPin?.length ?? 0) >= 4 &&
|
||||
(mode === "idle" || !serverAuthActive)
|
||||
);
|
||||
|
||||
let pinEntry = $state("");
|
||||
|
||||
+68
-51
@@ -1,20 +1,21 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { probeServer, loginBasic } from "@core/auth";
|
||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||
import { loadAllStores } from "@core/persistence/persist";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { probeServer, loginBasic, loginUI } from "@core/auth";
|
||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||
import { loadAllStores } from "@core/persistence/persist";
|
||||
|
||||
const MAX_ATTEMPTS = 40;
|
||||
|
||||
export const boot = $state({
|
||||
serverProbeOk: false,
|
||||
failed: false,
|
||||
notConfigured: false,
|
||||
loginRequired: false,
|
||||
unsupportedMode: false,
|
||||
loginUser: "",
|
||||
loginPass: "",
|
||||
loginError: null as string | null,
|
||||
loginBusy: false,
|
||||
serverProbeOk: false,
|
||||
failed: false,
|
||||
notConfigured: false,
|
||||
loginRequired: false,
|
||||
loginError: null as string | null,
|
||||
loginBusy: false,
|
||||
loginUser: "",
|
||||
loginPass: "",
|
||||
sessionExpired: false,
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
let probeGeneration = 0;
|
||||
@@ -26,50 +27,56 @@ export async function initStore() {
|
||||
|
||||
export function startProbe() {
|
||||
const gen = ++probeGeneration;
|
||||
boot.failed = false;
|
||||
boot.loginRequired = false;
|
||||
boot.unsupportedMode = false;
|
||||
let tries = 0;
|
||||
boot.failed = false;
|
||||
boot.loginRequired = false;
|
||||
boot.skipped = false;
|
||||
let tries = 0;
|
||||
|
||||
async function probe() {
|
||||
if (gen !== probeGeneration) return;
|
||||
tries++;
|
||||
|
||||
const result = await probeServer();
|
||||
if (gen !== probeGeneration) return;
|
||||
|
||||
if (result === "ok") {
|
||||
boot.serverProbeOk = true;
|
||||
boot.loginRequired = false;
|
||||
trackingState.bootSync().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === "auth_required") {
|
||||
boot.serverProbeOk = true;
|
||||
const savedUser = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const savedPass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
if (savedUser && savedPass) {
|
||||
try {
|
||||
await loginBasic(savedUser, savedPass);
|
||||
boot.loginRequired = false;
|
||||
trackingState.bootSync().catch(() => {});
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
boot.loginRequired = true;
|
||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||
return;
|
||||
}
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
|
||||
if (result === "unsupported_mode") {
|
||||
boot.serverProbeOk = true;
|
||||
boot.unsupportedMode = true;
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) {
|
||||
try {
|
||||
await loginBasic(user, pass);
|
||||
if (gen !== probeGeneration) return;
|
||||
trackingState.bootSync().catch(() => {});
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||
boot.loginRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "UI_LOGIN") {
|
||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||
boot.loginRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
trackingState.bootSync().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; }
|
||||
const delay = Math.min(750 + tries * 250, 3000);
|
||||
setTimeout(probe, delay);
|
||||
setTimeout(probe, Math.min(750 + tries * 250, 3000));
|
||||
}
|
||||
|
||||
setTimeout(probe, 2000);
|
||||
@@ -79,18 +86,27 @@ export function stopProbe() {
|
||||
probeGeneration++;
|
||||
}
|
||||
|
||||
export async function submitLogin(onSuccess: () => void) {
|
||||
export async function submitLogin(onSuccess: () => void): Promise<void> {
|
||||
if (!boot.loginUser.trim() || !boot.loginPass.trim()) {
|
||||
boot.loginError = "Username and password are required";
|
||||
return;
|
||||
}
|
||||
boot.loginBusy = true;
|
||||
boot.loginError = null;
|
||||
|
||||
try {
|
||||
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim());
|
||||
boot.loginRequired = false;
|
||||
boot.loginPass = "";
|
||||
boot.loginError = null;
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") {
|
||||
await loginUI(boot.loginUser.trim(), boot.loginPass.trim());
|
||||
} else {
|
||||
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim());
|
||||
}
|
||||
|
||||
boot.loginRequired = false;
|
||||
boot.sessionExpired = false;
|
||||
boot.skipped = false;
|
||||
boot.loginPass = "";
|
||||
boot.loginError = null;
|
||||
trackingState.bootSync().catch(() => {});
|
||||
onSuccess();
|
||||
} catch (e: any) {
|
||||
@@ -101,18 +117,19 @@ export async function submitLogin(onSuccess: () => void) {
|
||||
}
|
||||
|
||||
export function retryBoot() {
|
||||
boot.serverProbeOk = false;
|
||||
boot.failed = false;
|
||||
boot.notConfigured = false;
|
||||
boot.loginRequired = false;
|
||||
boot.unsupportedMode = false;
|
||||
boot.serverProbeOk = false;
|
||||
boot.failed = false;
|
||||
boot.notConfigured = false;
|
||||
boot.loginRequired = false;
|
||||
boot.skipped = false;
|
||||
startProbe();
|
||||
}
|
||||
|
||||
export function bypassBoot(onReady: () => void) {
|
||||
probeGeneration++;
|
||||
boot.serverProbeOk = true;
|
||||
boot.loginRequired = false;
|
||||
boot.unsupportedMode = false;
|
||||
boot.serverProbeOk = true;
|
||||
boot.loginRequired = false;
|
||||
boot.sessionExpired = false;
|
||||
boot.skipped = true;
|
||||
onReady();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user