Archived
Next.js 14 + TypeScript + Tailwind + Prisma/SQLite. - LLM synthesis abstraction (OpenAI/Anthropic/Ollama) + admin settings UI - RSS ingestion pipeline (parser + cheerio content) + node-cron worker - AdSense AdUnit placements (header/in-feed/in-article/sidebar) - Sources analysis attribution, nofollow links, canonical/OG, sitemap/robots - Admin auth (ADMIN_API_KEY, timingSafeEqual, fail-closed 401) - Multi-cell lady-ga-ga marker, sitemap, robots, legal pages - Comprehensive README (setup, LLM config, AdSense, migrations, deploy)
122 lines
3.8 KiB
TypeScript
122 lines
3.8 KiB
TypeScript
'use client';
|
|
|
|
import { ReactNode, useCallback, useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
type Status = 'unknown' | 'authed' | 'denied';
|
|
|
|
/**
|
|
* Client-side admin gate.
|
|
*
|
|
* 1. If `x-admin-key` is stored in sessionStorage → validate via
|
|
* /api/admin/verify.
|
|
* 2. Otherwise show a key entry form (basic-auth fallback for users who
|
|
* prefer the `ADMIN_USER:ADMIN_KEY` header).
|
|
*
|
|
* The key is never written to localStorage and is cleared on a hard tab
|
|
* close. For hardening, front the admin area with real HTTP Basic auth
|
|
* at the reverse-proxy layer too.
|
|
*/
|
|
export default function AdminGate({ children }: { children: ReactNode }) {
|
|
const [status, setStatus] = useState<Status>('unknown');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const router = useRouter();
|
|
const [keyInput, setKeyInput] = useState('');
|
|
|
|
const validate = useCallback(
|
|
async (key: string) => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch('/api/admin/verify', {
|
|
headers: { 'x-admin-key': key },
|
|
});
|
|
if (res.ok) {
|
|
sessionStorage.setItem('mb_admin_key', key);
|
|
setStatus('authed');
|
|
} else {
|
|
setStatus('denied');
|
|
setError('Invalid admin key.');
|
|
}
|
|
} catch {
|
|
setError('Network error');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const stored = sessionStorage.getItem('mb_admin_key');
|
|
if (stored) validate(stored);
|
|
else setStatus('denied');
|
|
}, [validate]);
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (keyInput.trim()) await validate(keyInput.trim());
|
|
}
|
|
|
|
function logout() {
|
|
sessionStorage.removeItem('mb_admin_key');
|
|
setStatus('denied');
|
|
setKeyInput('');
|
|
router.refresh();
|
|
}
|
|
|
|
if (status === 'authed') {
|
|
return (
|
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6">
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<h1 className="font-display text-2xl font-bold text-ink-900">
|
|
Admin · Settings
|
|
</h1>
|
|
<button
|
|
onClick={logout}
|
|
className="rounded-md border border-ink-300 px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
|
>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-md px-4 py-24">
|
|
<div className="rounded-2xl border border-ink-200 bg-white p-8 shadow-sm">
|
|
<h1 className="font-display text-2xl font-bold text-ink-900">Admin</h1>
|
|
<p className="mt-1 text-sm text-ink-500">
|
|
Enter the admin key from <code>ADMIN_API_KEY</code> (or your{' '}
|
|
<code>USER:ADMIN_API_KEY</code> basic-auth string) to continue.
|
|
</p>
|
|
<form onSubmit={submit} className="mt-6 space-y-4">
|
|
<input
|
|
type="password"
|
|
value={keyInput}
|
|
onChange={(e) => setKeyInput(e.target.value)}
|
|
placeholder="Admin key"
|
|
autoFocus
|
|
className="w-full rounded-lg border border-ink-300 px-3 py-2.5 text-sm focus:border-maple-600 focus:outline-none"
|
|
/>
|
|
{error && <p className="text-sm text-red-700">{error}</p>}
|
|
<button
|
|
type="submit"
|
|
disabled={busy}
|
|
className="w-full rounded-lg bg-maple-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-maple-700 disabled:opacity-50"
|
|
>
|
|
{busy ? 'Verifying…' : 'Unlock'}
|
|
</button>
|
|
</form>
|
|
<p className="mt-4 text-xs text-ink-400">
|
|
Tip: keep the key in a password manager; it is only held in your
|
|
browser session while you have this tab open.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|