Merge pull request #4 from Salastil/claude/source-management-content-1w2yno

Replace admin username/password with a per-launch API key, and disabl…
This commit is contained in:
Salastil
2026-07-21 16:06:17 -04:00
committed by GitHub
23 changed files with 206 additions and 222 deletions
+5 -2
View File
@@ -15,11 +15,14 @@ doesn't know or care which one it's talking to. Switch between them by changing
```bash
cd backend
cp .env.example .env # set ADMIN_PASSWORD at minimum
cp .env.example .env
npm install
npm run dev
```
The console prints an admin API key on every startup (a fresh one each time) — copy
it into the admin login page. See `backend/README.md` for details.
See `backend/README.md` for what's fully implemented vs. stubbed (Telegram adapter,
image-selection heuristic vs. vision model, etc.), and how it behaves when Ollama
isn't reachable.
@@ -49,7 +52,7 @@ Open http://localhost:5173.
- **Article page** (`/article/:id`) — merge badge, hero image with single-source attribution, body, video slot, tag chips, thread continuation banners (both directions — "newer coverage" / "earlier coverage"), sources footer
- **Article cards** — show source count (`⇄ N sources`), single-source attribution, or a video indicator, matching the design decided earlier
- **Light/dark theme toggle** — slider in the masthead, top right, left of the settings cog. Dark is a genuine slate palette (not an inverted light theme). Persists via `localStorage`, respects system preference on first load, no flash-of-wrong-theme (set before hydration in `app.html`).
- **Admin panel** (`/admin/settings`) — six tabs, all wired to the mock backend's `/api/admin/*` routes:
- **Admin panel** (`/admin/settings`) — disabled by default; set `ADMIN_PANEL_ENABLED=true` in `frontend/.env` to turn on the cog icon and the `/admin/*` pages (see `frontend/.env.example`). Six tabs, all wired to the mock backend's `/api/admin/*` routes:
- **Merge** — strictness slider, poll interval, hold-before-publish, follow-up thresholds, category priority (reorderable), tag dedup threshold, tag expiry
- **Sources** — list, add, enable/disable, delete RSS/API/Telegram feeds
- **Models** — AI service status, per-task model selection (embedding/image/synthesis), fetched from the mock's simulated Ollama catalog
+4 -3
View File
@@ -3,8 +3,9 @@ FRONTEND_ORIGIN=http://localhost:5173
DB_PATH=./data/homefeed.db
MEDIA_DIR=./data/media
# Seeded once on first run — change the password after logging in.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-immediately
# There's no admin username/password to configure here — the backend generates a
# random API key on every startup and prints it to the console. Copy that key into
# the admin login page (every /api/admin/* request requires it as an X-Api-Key
# header). It changes on every restart, so check the console output each time.
NODE_ENV=development
+12 -3
View File
@@ -10,7 +10,6 @@ contract the frontend already consumes from the mock backend — plus the full
```bash
cp .env.example .env
# edit .env — at minimum set ADMIN_PASSWORD to something real
npm install
npm run dev
```
@@ -18,6 +17,14 @@ npm run dev
Runs on `:4000` by default. Point the frontend's `VITE_BACKEND_URL` at it instead of
the mock backend and everything else keeps working unchanged — same API contract.
On startup, the console prints an admin API key — a fresh random value generated
every time the process starts (see `api/apiKey.ts`), not stored anywhere and not
configurable via `.env`. Every `/api/admin/*` request must send it as an
`X-Api-Key` header (enforced in `api/auth.ts`); the admin login page just asks for
this key and stashes it in the browser's `localStorage` rather than issuing its own
session. Restarting the backend invalidates the previous key — check the console
each time.
You'll also need a running Ollama instance (see `AI_SERVICE_HOST`/`AI_SERVICE_PORT` in
the admin panel's Connections tab, or `PATCH /api/admin/settings` directly) with at
minimum an embedding model (e.g. `nomic-embed-text`) and a generation model (e.g.
@@ -88,8 +95,10 @@ real RSS parsing, real HTTP calls to a stub Ollama server, real media download t
disk, real tag dedup across separate synthesis calls):
- SQLite schema + repository layer for every entity in `homefeed-data-schema.md`
- Session auth (scrypt password hashing, httpOnly cookie, CORS locked to the
configured frontend origin) protecting all `/api/admin/*` routes
- Per-launch API key auth (random key printed to the console on every startup,
checked via a timing-safe comparison against an `X-Api-Key` header on every
request, CORS locked to the configured frontend origin) protecting all
`/api/admin/*` routes
- RSS adapter (real parsing, images/video extraction) and a generic JSON API adapter
(configurable field mapping)
- Poller respecting per-source poll intervals
-34
View File
@@ -9,7 +9,6 @@
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@fastify/cookie": "^11.1.1",
"@fastify/cors": "^11.3.0",
"@mozilla/readability": "^0.6.0",
"fastify": "^5.10.0",
@@ -696,26 +695,6 @@
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/cookie": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.1.tgz",
"integrity": "sha512-sJ0NXzGVYjUB4OynPZRsIcQ1mKSP4rW45xLCN0aelRq5Vl37xVVbz5kJ6Y0a9m2T0mCUjYCuvlUA9QlTafrZWw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"cookie": "^2.0.0",
"fastify-plugin": "^6.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz",
@@ -1295,19 +1274,6 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
-1
View File
@@ -12,7 +12,6 @@
},
"license": "UNLICENSED",
"dependencies": {
"@fastify/cookie": "^11.1.1",
"@fastify/cors": "^11.3.0",
"@mozilla/readability": "^0.6.0",
"fastify": "^5.10.0",
+8
View File
@@ -0,0 +1,8 @@
// Generated once per process start — not persisted, not configurable via env. Every
// restart invalidates the previous key, which is the whole point: the only way to
// learn the current key is to have console/log access to the running process (see
// index.ts's startup banner), which is a meaningfully different trust boundary than a
// password someone could guess or brute-force over the network.
import { randomBytes } from 'node:crypto';
export const ADMIN_API_KEY = randomBytes(24).toString('hex');
+22 -35
View File
@@ -1,44 +1,31 @@
import type { FastifyInstance } from 'fastify';
import { getAdminUserByUsername, createSession, isSessionValid, deleteSession } from '../storage/db/auth.js';
import { verifyPassword } from './password.js';
import { timingSafeEqual } from 'node:crypto';
import { ADMIN_API_KEY } from './apiKey.js';
const SESSION_COOKIE = 'homefeed_session';
function isValidKey(provided: string | undefined): boolean {
if (!provided) return false;
// Buffers of mismatched length would make timingSafeEqual throw rather than
// return false — checking length first keeps this a normal "wrong key" case for
// any header of a different length rather than a runtime error.
const providedBuf = Buffer.from(provided);
const expectedBuf = Buffer.from(ADMIN_API_KEY);
if (providedBuf.length !== expectedBuf.length) return false;
return timingSafeEqual(providedBuf, expectedBuf);
}
/**
* Guards every /api/admin/* route with the process's current API key (see
* api/apiKey.ts) — there's no session or login endpoint anymore: the key itself is
* the credential, checked on every single request, exactly the way a bot or curl
* script hitting these routes unauthenticated is meant to be stopped cold.
*/
export async function registerAuth(app: FastifyInstance) {
app.post('/api/admin/login', async (req, reply) => {
const { username, password } = req.body as { username?: string; password?: string };
if (!username || !password) return reply.code(400).send({ error: 'username and password required' });
const user = getAdminUserByUsername(username);
if (!user || !verifyPassword(password, user.password_hash)) {
// Deliberately generic — doesn't reveal whether the username exists.
return reply.code(401).send({ error: 'invalid credentials' });
}
const session = createSession(req.ip ?? null);
reply.setCookie(SESSION_COOKIE, session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
path: '/',
expires: new Date(session.expiresAt)
});
return { ok: true };
});
app.post('/api/admin/logout', async (req, reply) => {
const sessionId = req.cookies[SESSION_COOKIE];
if (sessionId) deleteSession(sessionId);
reply.clearCookie(SESSION_COOKIE, { path: '/' });
return { ok: true };
});
// Guards every /api/admin/* route except login itself.
app.addHook('preHandler', async (req, reply) => {
if (!req.url.startsWith('/api/admin/') || req.url === '/api/admin/login') return;
if (!req.url.startsWith('/api/admin/')) return;
const sessionId = req.cookies[SESSION_COOKIE];
if (!sessionId || !isSessionValid(sessionId)) {
const header = req.headers['x-api-key'];
const provided = Array.isArray(header) ? header[0] : header;
if (!isValidKey(provided)) {
return reply.code(401).send({ error: 'unauthorized' });
}
});
-19
View File
@@ -1,19 +0,0 @@
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
const KEY_LEN = 64;
export function hashPassword(password: string): string {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, KEY_LEN);
return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
export function verifyPassword(password: string, stored: string): boolean {
const [saltHex, hashHex] = stored.split(':');
if (!saltHex || !hashHex) return false;
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(hashHex, 'hex');
const actual = scryptSync(password, salt, KEY_LEN);
if (actual.length !== expected.length) return false;
return timingSafeEqual(actual, expected);
}
+14 -8
View File
@@ -1,10 +1,9 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import fs from 'node:fs';
import path from 'node:path';
import { migrate } from './storage/db/index.js';
import { ensureAdminUserSeeded } from './storage/db/auth.js';
import { ADMIN_API_KEY } from './api/apiKey.js';
import { registerAuth } from './api/auth.js';
import { registerPublicRoutes } from './api/public.js';
import { registerAdminRoutes } from './api/admin.js';
@@ -15,12 +14,21 @@ const PORT = Number(process.env.PORT) || 4000;
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173';
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';
function printApiKeyBanner() {
const line = '='.repeat(64);
// Deliberately console.log, not the DB-backed logger — the Logs tab in the admin
// panel is itself behind this key, so printing there would be unreachable until
// you already have the key. This is the one and only place it's ever surfaced.
console.log(`\n${line}`);
console.log(' Homefeed admin API key (required for every /api/admin/* request)');
console.log(` ${ADMIN_API_KEY}`);
console.log(' This key is generated fresh on every restart — it will not be the same next time.');
console.log(`${line}\n`);
}
async function main() {
migrate();
ensureAdminUserSeeded(
process.env.ADMIN_USERNAME || 'admin',
process.env.ADMIN_PASSWORD || 'change-me-immediately'
);
printApiKeyBanner();
const app = Fastify({ logger: false });
@@ -32,10 +40,8 @@ async function main() {
// reaches a route handler.
await app.register(cors, {
origin: FRONTEND_ORIGIN,
credentials: true,
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS']
});
await app.register(cookie);
// Overrides Fastify's default JSON body parser, which throws "Body cannot be empty
// when content-type is set to 'application/json'" for any bodyless request (DELETE,
-2
View File
@@ -4,7 +4,6 @@ import { runEventRecaps } from './eventsRecap.js';
import { runRetentionSweep } from './retention.js';
import { OllamaProvider } from '../inference/ollama-provider.js';
import * as settingsDb from '../storage/db/settings.js';
import { pruneExpiredSessions } from '../storage/db/auth.js';
import { logger } from '../storage/db/logs.js';
const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency
@@ -56,7 +55,6 @@ export function startScheduler() {
setInterval(() => {
try {
runRetentionSweep(settingsDb.getSettings());
pruneExpiredSessions();
logger.info('retention', 'Retention sweep completed');
} catch (err) {
logger.error('retention', `Retention tick failed: ${(err as Error).message}`);
-53
View File
@@ -1,53 +0,0 @@
import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import { hashPassword } from '../../api/password.js';
import { logger } from './logs.js';
const SESSION_TTL_HOURS = 24;
export function ensureAdminUserSeeded(defaultUsername: string, defaultPassword: string) {
const existing = db.prepare('SELECT id FROM admin_users LIMIT 1').get();
if (existing) return;
db.prepare('INSERT INTO admin_users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)').run(
randomUUID(),
defaultUsername,
hashPassword(defaultPassword),
new Date().toISOString()
);
logger.warn('auth', `Seeded initial admin user "${defaultUsername}". Change this password after first login.`);
}
export function getAdminUserByUsername(username: string) {
return db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username) as
| { id: string; username: string; password_hash: string }
| undefined;
}
export function createSession(ip: string | null): { id: string; expiresAt: string } {
const id = randomUUID();
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_TTL_HOURS * 3600_000).toISOString();
db.prepare('INSERT INTO sessions (id, created_at, expires_at, ip) VALUES (?, ?, ?, ?)').run(
id,
now.toISOString(),
expiresAt,
ip
);
return { id, expiresAt };
}
export function isSessionValid(id: string): boolean {
const row = db.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(id) as
| { expires_at: string }
| undefined;
if (!row) return false;
return new Date(row.expires_at).getTime() > Date.now();
}
export function deleteSession(id: string) {
db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
}
export function pruneExpiredSessions() {
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(new Date().toISOString());
}
+7 -14
View File
@@ -18,6 +18,13 @@ db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA foreign_keys = ON;');
export function migrate() {
// Admin auth moved from username/password + sessions to a per-launch API key (see
// api/apiKey.ts, api/auth.ts) — these tables, and any stored password hash or live
// session in them, have no further purpose and are dropped rather than left as
// orphaned schema/data.
db.exec('DROP TABLE IF EXISTS admin_users;');
db.exec('DROP TABLE IF EXISTS sessions;');
db.exec(`
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
@@ -133,20 +140,6 @@ export function migrate() {
is_default INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS admin_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
ip TEXT
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
+11
View File
@@ -0,0 +1,11 @@
# Where the frontend talks to the backend (real or mock). Also settable at runtime
# via the connection setup screen, which saves to this browser's localStorage and
# takes priority over this build-time value — see src/lib/config.ts.
VITE_BACKEND_URL=http://localhost:4000
# The admin panel (cog icon in the masthead, and the /admin/* pages themselves) is
# disabled by default on every deployment. Set this to "true" to turn it on for a
# given deployment. This only controls whether the admin UI renders at all — the
# backend's per-launch API key (printed to its console on startup) is what actually
# protects every /api/admin/* request regardless of this setting.
ADMIN_PANEL_ENABLED=false
+18
View File
@@ -11,6 +11,7 @@
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
@@ -525,6 +526,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1281,6 +1292,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
+19 -15
View File
@@ -1,4 +1,5 @@
import { getBackendUrl } from './config';
import { getApiKey, setApiKey, clearApiKey } from './adminAuth';
import type {
AdminSettings,
AdminSource,
@@ -13,10 +14,13 @@ async function request<T>(path: string, options: RequestInit = {}, fetchFn: type
// application/json ("Body cannot be empty when content-type is set to
// 'application/json'") — so this header is only attached when there's actually a
// body to send (PATCH/POST with a JSON payload), never for bodyless DELETE/POST calls.
const headers = options.body ? { 'Content-Type': 'application/json', ...(options.headers || {}) } : options.headers;
const headers: Record<string, string> = { ...(options.headers as Record<string, string> | undefined) };
if (options.body) headers['Content-Type'] = 'application/json';
const apiKey = getApiKey();
if (apiKey) headers['X-Api-Key'] = apiKey;
const res = await fetchFn(`${getBackendUrl()}${path}`, {
...options,
credentials: 'include',
headers
});
if (res.status === 401) {
@@ -29,22 +33,22 @@ async function request<T>(path: string, options: RequestInit = {}, fetchFn: type
return res.json();
}
// Auth
export async function login(username: string, password: string, fetchFn: typeof fetch = fetch): Promise<void> {
const res = await fetchFn(`${getBackendUrl()}/api/admin/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Login failed (${res.status})`);
// Auth — there's no backend session to create; "logging in" means storing the
// entered key locally and confirming it actually works with one real authenticated
// call (getSettings has no side effects), and "logging out" is just discarding it.
export async function login(apiKey: string, fetchFn: typeof fetch = fetch): Promise<void> {
setApiKey(apiKey);
try {
await getSettings(fetchFn);
} catch (err) {
clearApiKey();
if ((err as { status?: number }).status === 401) throw new Error('Invalid API key');
throw err;
}
}
export async function logout(fetchFn: typeof fetch = fetch): Promise<void> {
await fetchFn(`${getBackendUrl()}/api/admin/logout`, { method: 'POST', credentials: 'include' });
export async function logout(): Promise<void> {
clearApiKey();
}
// Settings
+23
View File
@@ -0,0 +1,23 @@
// The admin API key isn't a backend-issued session — it lives entirely in this
// browser's localStorage, attached as an X-Api-Key header on every /api/admin/*
// request (see adminApi.ts). There's nothing to invalidate server-side on "logout";
// clearing it here is the whole operation.
const STORAGE_KEY = 'homefeed:adminApiKey';
export function getApiKey(): string | null {
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem(STORAGE_KEY);
}
export function setApiKey(key: string) {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, key);
}
}
export function clearApiKey() {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { LayoutServerLoad } from './$types';
// The admin panel is off by default on every deployment — it only appears (cog icon
// and the /admin/* pages themselves, see admin/+layout.svelte) once this is
// explicitly turned on. This is a UI-visibility gate only; the backend's API key
// check on every /api/admin/* request is what actually protects it either way.
export const load: LayoutServerLoad = async () => {
return { adminPanelEnabled: process.env.ADMIN_PANEL_ENABLED === 'true' };
};
+10 -8
View File
@@ -37,14 +37,16 @@
</nav>
<div class="controls">
<ThemeToggle />
<a class="cog" href="/admin/settings" aria-label="Admin settings" title="Admin settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</a>
{#if data.adminPanelEnabled}
<a class="cog" href="/admin/settings" aria-label="Admin settings" title="Admin settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</a>
{/if}
</div>
</div>
</header>
+2 -2
View File
@@ -1,7 +1,7 @@
import type { LayoutLoad } from './$types';
import { getCategories } from '$lib/api';
export const load: LayoutLoad = async ({ fetch }) => {
export const load: LayoutLoad = async ({ fetch, data }) => {
const categories = await getCategories(fetch);
return { categories };
return { ...data, categories };
};
+22 -9
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { logout } from '$lib/adminApi';
import type { LayoutData } from './$types';
let { children } = $props();
let { children, data }: { children: any; data: LayoutData } = $props();
async function handleLogout() {
await logout();
@@ -10,17 +11,29 @@
}
</script>
<div class="admin-shell">
<div class="page admin-inner">
<div class="top-row">
<a class="back" href="/">← Back to site</a>
<button class="logout" onclick={handleLogout}>Log out</button>
</div>
{@render children()}
{#if !data.adminPanelEnabled}
<div class="page disabled-notice">
<p>The admin panel is disabled on this deployment.</p>
</div>
</div>
{:else}
<div class="admin-shell">
<div class="page admin-inner">
<div class="top-row">
<a class="back" href="/">← Back to site</a>
<button class="logout" onclick={handleLogout}>Log out</button>
</div>
{@render children()}
</div>
</div>
{/if}
<style>
.disabled-notice {
padding-top: 60px;
text-align: center;
color: var(--text-secondary);
font-size: 14px;
}
.admin-shell {
min-height: 100vh;
}
+5 -6
View File
@@ -1,7 +1,6 @@
// The admin section talks to a different origin (the backend) than the frontend
// itself. During SSR, the `load` function's fetch runs on the Node server, which has
// no access to the browser's cookie jar — it can't attach the session cookie to a
// cross-origin request. Disabling SSR here means all admin data fetching happens in
// the actual browser instead, where credentials: 'include' works correctly against
// whatever cookie the browser already holds from login.
// The admin API key lives in this browser's localStorage (see adminAuth.ts), which
// is only reachable from client-side code — a server-rendered `load` function
// running on the Node server during SSR has no access to it and couldn't attach it
// to a cross-origin request. Disabling SSR here means all admin data fetching
// happens in the actual browser instead, where the stored key is available.
export const ssr = false;
+14 -8
View File
@@ -3,8 +3,7 @@
import { page } from '$app/stores';
import { login } from '$lib/adminApi';
let username = $state('admin');
let password = $state('');
let apiKey = $state('');
let error = $state('');
let loading = $state(false);
@@ -13,7 +12,7 @@
error = '';
loading = true;
try {
await login(username, password);
await login(apiKey);
const redirectTo = $page.url.searchParams.get('redirectTo') || '/admin/settings';
await goto(redirectTo);
} catch (err) {
@@ -27,12 +26,13 @@
<div class="wrap">
<form onsubmit={handleSubmit}>
<span class="title">Admin login</span>
<p class="hint">
Find the API key printed in your backend server's console output when it starts up. It's
generated fresh every restart, so check there again if this one stops working.
</p>
<label class="field-label" for="username">Username</label>
<input id="username" type="text" bind:value={username} autocomplete="username" />
<label class="field-label" for="password">Password</label>
<input id="password" type="password" bind:value={password} autocomplete="current-password" />
<label class="field-label" for="apiKey">API Key</label>
<input id="apiKey" type="password" bind:value={apiKey} autocomplete="off" />
{#if error}<div class="error">{error}</div>{/if}
@@ -58,6 +58,12 @@
font-weight: 500;
margin-bottom: 8px;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
margin: 0 0 4px;
}
.field-label {
font-size: 11px;
color: var(--text-muted);