Add authentication feature with session management

- Implemented authentication logic in src/server/auth.js, including session token creation and verification.
- Integrated authentication routes for login, logout, and user session retrieval in the Fastify server.
- Updated docker-compose.yml to include optional authentication environment variables.
- Added @fastify/cookie dependency for cookie management in package.json and package-lock.json.
- Enhanced the frontend with login form and session handling in App.svelte, including UI updates for authenticated states.
- Styled authentication components in app.css for better user experience.
This commit is contained in:
2026-08-03 07:50:00 +07:00
parent 3799ad59f8
commit 704394169a
8 changed files with 628 additions and 176 deletions
+295 -176
View File
@@ -8,6 +8,15 @@
let loadingChapters = $state({});
let expanded = $state({});
let authReady = $state(false);
let authRequired = $state(false);
let authenticated = $state(false);
let authUsername = $state("");
let loginUser = $state("admin");
let loginPass = $state("");
let loginError = $state("");
let loginBusy = $state(false);
async function api(path, options = {}) {
const method = (options.method || "GET").toUpperCase();
const headers = { ...(options.headers || {}) };
@@ -28,14 +37,66 @@
method,
headers,
body,
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (res.status === 401 && authRequired) {
authenticated = false;
authUsername = "";
}
if (!res.ok) {
throw new Error(data.error || data.message || res.statusText || "Request failed");
}
return data;
}
async function checkAuth() {
const data = await api("/api/auth/me");
authRequired = Boolean(data.authRequired);
authenticated = Boolean(data.authenticated);
authUsername = typeof data.username === "string" ? data.username : "";
authReady = true;
}
async function login(event) {
event.preventDefault();
loginError = "";
loginBusy = true;
try {
const data = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({
username: loginUser.trim(),
password: loginPass,
}),
});
authRequired = Boolean(data.authRequired);
authenticated = Boolean(data.authenticated);
authUsername = typeof data.username === "string" ? data.username : loginUser.trim();
loginPass = "";
await refresh();
} catch (err) {
loginError = err instanceof Error ? err.message : String(err);
authenticated = false;
} finally {
loginBusy = false;
}
}
async function logout() {
error = "";
try {
await api("/api/auth/logout", { method: "POST" });
} catch {
/* still clear local session state */
}
authenticated = false;
authUsername = "";
titles = [];
jobs = [];
chaptersByTitle = {};
}
async function refresh() {
const [t, j] = await Promise.all([
api("/api/titles"),
@@ -168,14 +229,31 @@
}
$effect(() => {
void checkAuth().catch((err) => {
error = err instanceof Error ? err.message : String(err);
authReady = true;
});
});
$effect(() => {
if (!authReady || (authRequired && !authenticated)) {
return;
}
void refresh();
const id = setInterval(() => {
void (async () => {
await refresh();
for (const [titleId, isOpen] of Object.entries(expanded)) {
if (isOpen) {
await loadChapters(Number(titleId));
if (authRequired && !authenticated) {
return;
}
try {
await refresh();
for (const [titleId, isOpen] of Object.entries(expanded)) {
if (isOpen) {
await loadChapters(Number(titleId));
}
}
} catch {
/* 401 handled in api() */
}
})();
}, 2000);
@@ -184,195 +262,236 @@
</script>
<main class="app-shell">
<header>
<header class="app-header">
<h1>Watch & Download</h1>
{#if authReady && authRequired && authenticated}
<div class="header-auth">
{#if authUsername}
<span class="muted">{authUsername}</span>
{/if}
<button type="button" class="outline contrast" onclick={logout}>Log out</button>
</div>
{/if}
</header>
{#if error}
<p class="error-banner" role="alert">{error}</p>
{/if}
<form class="add-title" onsubmit={addTitle}>
<label>
Series URL
<input
type="url"
bind:value={url}
placeholder=""
required
disabled={busy}
/>
</label>
<button type="submit" disabled={busy || !url.trim()}>
{busy ? "Working…" : "Add & watch"}
</button>
</form>
<section class="jobs-strip" aria-live="polite">
<div class="section-heading">
<h2>Activity</h2>
<button
type="button"
class="outline contrast"
disabled={jobs.length === 0}
onclick={clearQueue}
>
Clear queue
{#if !authReady}
<p class="muted">Loading…</p>
{:else if authRequired && !authenticated}
{#if loginError}
<p class="error-banner" role="alert">{loginError}</p>
{/if}
<form class="login-form" onsubmit={login}>
<label>
Username
<input
type="text"
bind:value={loginUser}
autocomplete="username"
required
disabled={loginBusy}
/>
</label>
<label>
Password
<input
type="password"
bind:value={loginPass}
autocomplete="current-password"
required
disabled={loginBusy}
/>
</label>
<button type="submit" disabled={loginBusy || !loginPass}>
{loginBusy ? "Signing in…" : "Sign in"}
</button>
</div>
{#if jobs.length === 0}
<p class="muted">No active jobs.</p>
{:else}
<ul class="job-list">
{#each jobs as job (job.id)}
<li class="job-row">
<div class="job-info">
<strong>{job.status}</strong>
{job.message || job.type}
{#if job.title_name}
<span class="muted">({job.title_name})</span>
{/if}
</div>
{#if job.chapter_id}
<button
type="button"
class="outline contrast job-cancel"
onclick={() => cancelChapter(job.chapter_id, job.title_id)}
>
Cancel
</button>
{/if}
</li>
{/each}
</ul>
</form>
{:else}
{#if error}
<p class="error-banner" role="alert">{error}</p>
{/if}
</section>
<section>
<h2>Titles</h2>
{#if titles.length === 0}
<p class="muted">No titles yet. Add a series URL above.</p>
{:else}
{#each titles as title (title.id)}
<article>
<header>
<div class="title-meta">
<strong>{title.title}</strong>
<span class="muted">{title.source}</span>
<span class="progress-text">
{title.downloaded} / {title.total} downloaded
{#if title.downloading > 0}
· {title.downloading} downloading
<form class="add-title" onsubmit={addTitle}>
<label>
Series URL
<input
type="url"
bind:value={url}
placeholder=""
required
disabled={busy}
/>
</label>
<button type="submit" disabled={busy || !url.trim()}>
{busy ? "Working…" : "Add & watch"}
</button>
</form>
<section class="jobs-strip" aria-live="polite">
<div class="section-heading">
<h2>Activity</h2>
<button
type="button"
class="outline contrast"
disabled={jobs.length === 0}
onclick={clearQueue}
>
Clear queue
</button>
</div>
{#if jobs.length === 0}
<p class="muted">No active jobs.</p>
{:else}
<ul class="job-list">
{#each jobs as job (job.id)}
<li class="job-row">
<div class="job-info">
<strong>{job.status}</strong>
{job.message || job.type}
{#if job.title_name}
<span class="muted">({job.title_name})</span>
{/if}
{#if title.failed > 0}
· {title.failed} failed
{/if}
</span>
</div>
<p class="muted url-line">
<a href={title.url} target="_blank" rel="noreferrer">{title.url}</a>
{#if title.last_checked_at}
· last checked {title.last_checked_at}
</div>
{#if job.chapter_id}
<button
type="button"
class="outline contrast job-cancel"
onclick={() => cancelChapter(job.chapter_id, job.title_id)}
>
Cancel
</button>
{/if}
· watch {title.enabled ? "on" : "off"}
</p>
<div class="row-actions">
<button type="button" class="outline" onclick={() => toggleExpand(title.id)}>
{expanded[title.id] ? "Hide chapters" : "Show chapters"}
</button>
<button type="button" class="outline" disabled={busy} onclick={() => syncTitle(title.id)}>
Sync now
</button>
<button type="button" class="outline secondary" onclick={() => toggleEnabled(title)}>
{title.enabled ? "Disable watch" : "Enable watch"}
</button>
<button type="button" class="outline contrast" onclick={() => removeTitle(title.id)}>
Remove
</button>
</div>
</header>
</li>
{/each}
</ul>
{/if}
</section>
{#if expanded[title.id]}
{#if loadingChapters[title.id] && !chaptersByTitle[title.id]}
<p class="muted">Loading chapters…</p>
{:else}
<ul class="chapter-list">
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
<li class="chapter-card">
<div class="chapter-label">{ch.label}</div>
<span class={`status status-${ch.status}`}>{ch.status}</span>
{#if ch.error}
<div class="muted chapter-error">{ch.error}</div>
{/if}
<div class="chapter-actions">
{#if ch.status === "downloading"}
<section>
<h2>Titles</h2>
{#if titles.length === 0}
<p class="muted">No titles yet. Add a series URL above.</p>
{:else}
{#each titles as title (title.id)}
<article>
<header>
<div class="title-meta">
<strong>{title.title}</strong>
<span class="muted">{title.source}</span>
<span class="progress-text">
{title.downloaded} / {title.total} downloaded
{#if title.downloading > 0}
· {title.downloading} downloading
{/if}
{#if title.failed > 0}
· {title.failed} failed
{/if}
</span>
</div>
<p class="muted url-line">
<a href={title.url} target="_blank" rel="noreferrer">{title.url}</a>
{#if title.last_checked_at}
· last checked {title.last_checked_at}
{/if}
· watch {title.enabled ? "on" : "off"}
</p>
<div class="row-actions">
<button type="button" class="outline" onclick={() => toggleExpand(title.id)}>
{expanded[title.id] ? "Hide chapters" : "Show chapters"}
</button>
<button type="button" class="outline" disabled={busy} onclick={() => syncTitle(title.id)}>
Sync now
</button>
<button type="button" class="outline secondary" onclick={() => toggleEnabled(title)}>
{title.enabled ? "Disable watch" : "Enable watch"}
</button>
<button type="button" class="outline contrast" onclick={() => removeTitle(title.id)}>
Remove
</button>
</div>
</header>
{#if expanded[title.id]}
{#if loadingChapters[title.id] && !chaptersByTitle[title.id]}
<p class="muted">Loading chapters…</p>
{:else}
<ul class="chapter-list">
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
<li class="chapter-card">
<div class="chapter-label">{ch.label}</div>
<span class={`status status-${ch.status}`}>{ch.status}</span>
{#if ch.error}
<div class="muted chapter-error">{ch.error}</div>
{/if}
<div class="chapter-actions">
{#if ch.status === "downloading"}
<button
type="button"
class="outline contrast"
onclick={() => cancelChapter(ch.id, title.id)}
>
Cancel
</button>
{/if}
<button
type="button"
class="outline contrast"
onclick={() => cancelChapter(ch.id, title.id)}
class="outline"
onclick={() => redownload(ch.id, title.id)}
>
Cancel
Redownload
</button>
{/if}
<button
type="button"
class="outline"
onclick={() => redownload(ch.id, title.id)}
>
Redownload
</button>
</div>
</li>
{/each}
</ul>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Chapter</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
</div>
</li>
{/each}
</ul>
<div class="table-wrap">
<table>
<thead>
<tr>
<td>{ch.label}</td>
<td>
<span class={`status status-${ch.status}`}>{ch.status}</span>
{#if ch.error}
<div class="muted">{ch.error}</div>
{/if}
</td>
<td>
<div class="chapter-actions">
{#if ch.status === "downloading"}
<th>Chapter</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
<tr>
<td>{ch.label}</td>
<td>
<span class={`status status-${ch.status}`}>{ch.status}</span>
{#if ch.error}
<div class="muted">{ch.error}</div>
{/if}
</td>
<td>
<div class="chapter-actions">
{#if ch.status === "downloading"}
<button
type="button"
class="outline contrast"
onclick={() => cancelChapter(ch.id, title.id)}
>
Cancel
</button>
{/if}
<button
type="button"
class="outline contrast"
onclick={() => cancelChapter(ch.id, title.id)}
class="outline"
onclick={() => redownload(ch.id, title.id)}
>
Cancel
Redownload
</button>
{/if}
<button
type="button"
class="outline"
onclick={() => redownload(ch.id, title.id)}
>
Redownload
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/if}
</article>
{/each}
{/if}
</section>
</article>
{/each}
{/if}
</section>
{/if}
</main>
+34
View File
@@ -39,6 +39,40 @@ h1 {
font-size: clamp(1.5rem, 5vw, 2rem);
line-height: 1.2;
overflow-wrap: anywhere;
margin-bottom: 0;
}
.app-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1rem;
margin-bottom: 1.25rem;
}
.header-auth {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.header-auth button {
width: auto;
margin-bottom: 0;
}
form.login-form {
display: grid;
gap: 0.75rem;
max-width: 22rem;
margin-block: 1rem 2rem;
}
form.login-form button {
width: 100%;
margin-bottom: 0;
}
.muted {