Archived
MapleBrief: production Canadian news aggregator
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)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user