feat: bootstrap admin auth, DB schema, and encrypted API keys
- Add Prisma schema for AdminUser, Source, Article, SearchRule, SearchRun, and encrypted ApiKey storage. - Implement JWT session middleware and admin login/logout. - Add AES-256-GCM encryption helpers for API keys. - Seed default news sources and admin credentials. - Create admin shell layout and login page.
This commit is contained in:
1066
package-lock.json
generated
1066
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -6,21 +6,43 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "prisma db seed",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
|
"lucide-react": "^1.20.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
|
"openai": "^6.43.0",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
127
prisma/schema.prisma
Normal file
127
prisma/schema.prisma
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AdminUser {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Source {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
baseUrl String?
|
||||||
|
apiCode String @unique
|
||||||
|
defaultBias Bias?
|
||||||
|
credibilityScore Float @default(5)
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
articles Article[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Article {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
summary String?
|
||||||
|
url String @unique
|
||||||
|
sourceId String
|
||||||
|
source Source @relation(fields: [sourceId], references: [id])
|
||||||
|
imageUrl String?
|
||||||
|
publishedAt DateTime?
|
||||||
|
fetchedAt DateTime @default(now())
|
||||||
|
primaryBias Bias
|
||||||
|
biasConfidence Float @default(0)
|
||||||
|
politicalTags String @default("[]")
|
||||||
|
rankScore Float @default(0)
|
||||||
|
credibilityScore Float @default(5)
|
||||||
|
status ArticleStatus @default(DRAFT)
|
||||||
|
topics String @default("[]")
|
||||||
|
rawData String @default("{}")
|
||||||
|
aiExplanation String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([primaryBias, status])
|
||||||
|
@@index([status, publishedAt])
|
||||||
|
@@index([rankScore])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
provider ApiProvider
|
||||||
|
encryptedKey String
|
||||||
|
label String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([provider, isActive])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SearchRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
query String
|
||||||
|
sources String @default("[]")
|
||||||
|
schedule String @default("0 */6 * * *")
|
||||||
|
maxResults Int @default(20)
|
||||||
|
autoPublish Boolean @default(false)
|
||||||
|
minRankScore Float @default(5)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
aiProvider ApiProvider @default(KIMI)
|
||||||
|
aiModel String?
|
||||||
|
lastRunAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
runs SearchRun[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model SearchRun {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ruleId String
|
||||||
|
rule SearchRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
|
||||||
|
status RunStatus @default(RUNNING)
|
||||||
|
stage String? // e.g. "FETCHING", "ANALYZING", "SAVING"
|
||||||
|
progress String? // human-readable progress, e.g. "3 of 12"
|
||||||
|
articlesFound Int @default(0)
|
||||||
|
error String?
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
endedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Bias {
|
||||||
|
LEFT
|
||||||
|
CENTER
|
||||||
|
RIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ArticleStatus {
|
||||||
|
DRAFT
|
||||||
|
PUBLISHED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApiProvider {
|
||||||
|
NEWSAPI
|
||||||
|
GNEWS
|
||||||
|
KIMI
|
||||||
|
WEBSEARCH
|
||||||
|
NEWSDATA
|
||||||
|
BRAVE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RunStatus {
|
||||||
|
RUNNING
|
||||||
|
SUCCESS
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
44
prisma/seed.ts
Normal file
44
prisma/seed.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const sources = [
|
||||||
|
{ name: 'NewsAPI', apiCode: 'newsapi', baseUrl: 'https://newsapi.org', credibilityScore: 7 },
|
||||||
|
{ name: 'GNews', apiCode: 'gnews', baseUrl: 'https://gnews.io', credibilityScore: 7 },
|
||||||
|
{ name: 'NewsData', apiCode: 'newsdata', baseUrl: 'https://newsdata.io', credibilityScore: 7 },
|
||||||
|
{ name: 'Web Search', apiCode: 'websearch', baseUrl: null, credibilityScore: 5 },
|
||||||
|
{ name: 'Brave Search', apiCode: 'brave', baseUrl: 'https://search.brave.com', credibilityScore: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
await prisma.source.upsert({
|
||||||
|
where: { apiCode: source.apiCode },
|
||||||
|
update: {},
|
||||||
|
create: source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
||||||
|
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH;
|
||||||
|
|
||||||
|
if (adminPasswordHash) {
|
||||||
|
await prisma.adminUser.upsert({
|
||||||
|
where: { username: adminUsername },
|
||||||
|
update: { passwordHash: adminPasswordHash },
|
||||||
|
create: { username: adminUsername, passwordHash: adminPasswordHash },
|
||||||
|
});
|
||||||
|
console.log(`Seeded admin user: ${adminUsername}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seeded default sources.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
43
src/app/admin/dashboard/page.tsx
Normal file
43
src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { Bias } from '@prisma/client';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default async function AdminDashboardPage() {
|
||||||
|
const counts = await Promise.all([
|
||||||
|
prisma.article.count({ where: { status: 'PUBLISHED' } }),
|
||||||
|
prisma.article.count({ where: { status: 'PUBLISHED', primaryBias: Bias.LEFT } }),
|
||||||
|
prisma.article.count({ where: { status: 'PUBLISHED', primaryBias: Bias.CENTER } }),
|
||||||
|
prisma.article.count({ where: { status: 'PUBLISHED', primaryBias: Bias.RIGHT } }),
|
||||||
|
prisma.article.count({ where: { status: 'DRAFT' } }),
|
||||||
|
prisma.searchRule.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [total, left, center, right, drafts, rules] = counts;
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ label: 'Published Articles', value: total, href: '/admin/articles' },
|
||||||
|
{ label: 'Left Bias', value: left, href: '/admin/articles?bias=LEFT' },
|
||||||
|
{ label: 'Center Bias', value: center, href: '/admin/articles?bias=CENTER' },
|
||||||
|
{ label: 'Right Bias', value: right, href: '/admin/articles?bias=RIGHT' },
|
||||||
|
{ label: 'Drafts', value: drafts, href: '/admin/articles?status=DRAFT' },
|
||||||
|
{ label: 'Auto Rules', value: rules, href: '/admin/rules' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Link
|
||||||
|
key={card.label}
|
||||||
|
href={card.href}
|
||||||
|
className="bg-white dark:bg-neutral-900 p-6 rounded-xl shadow-sm border dark:border-neutral-800 hover:shadow-md transition"
|
||||||
|
>
|
||||||
|
<div className="text-3xl font-bold">{card.value}</div>
|
||||||
|
<div className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">{card.label}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/admin/layout.tsx
Normal file
5
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminLayout from '@/components/admin/AdminLayout';
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <AdminLayout>{children}</AdminLayout>;
|
||||||
|
}
|
||||||
91
src/app/admin/login/page.tsx
Normal file
91
src/app/admin/login/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useState } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || 'Login failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = searchParams.get('from') || '/admin/dashboard';
|
||||||
|
router.push(from);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError('An unexpected error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-xl shadow-lg p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-6 text-center">Admin Login</h1>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg dark:bg-neutral-800 dark:border-neutral-700"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Logging in...' : 'Log in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-4">
|
||||||
|
<Suspense fallback={<div className="text-center">Loading...</div>}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/admin/page.tsx
Normal file
5
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function AdminIndexPage() {
|
||||||
|
redirect('/admin/dashboard');
|
||||||
|
}
|
||||||
33
src/app/api/auth/login/route.ts
Normal file
33
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createSession } from '@/lib/session';
|
||||||
|
import { validateCredentials } from '@/lib/credentials';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username and password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await validateCredentials(username, password);
|
||||||
|
if (!valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid credentials' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createSession(username);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Authentication failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/app/api/auth/logout/route.ts
Normal file
7
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { destroySession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
await destroySession();
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
10
src/app/api/auth/me/route.ts
Normal file
10
src/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { verifySession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await verifySession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ authenticated: true, username: session.username });
|
||||||
|
}
|
||||||
130
src/components/admin/AdminLayout.tsx
Normal file
130
src/components/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Search,
|
||||||
|
FileText,
|
||||||
|
Newspaper,
|
||||||
|
Key,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
History,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/admin/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ href: '/admin/discover', label: 'Discover', icon: Search },
|
||||||
|
{ href: '/admin/articles', label: 'Articles', icon: FileText },
|
||||||
|
{ href: '/admin/rules', label: 'Auto Rules', icon: Newspaper },
|
||||||
|
{ href: '/admin/sources', label: 'Sources', icon: Newspaper },
|
||||||
|
{ href: '/admin/keys', label: 'API Keys', icon: Key },
|
||||||
|
{ href: '/admin/runs', label: 'Run History', icon: History },
|
||||||
|
{ href: '/admin/settings', label: 'Settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
router.push('/admin/login');
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950 flex">
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
<aside className="hidden md:flex w-64 flex-col bg-white dark:bg-neutral-900 border-r dark:border-neutral-800">
|
||||||
|
<div className="p-4 border-b dark:border-neutral-800">
|
||||||
|
<Link href="/" className="text-xl font-bold">
|
||||||
|
BiasNews Admin
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-2 space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium ${
|
||||||
|
active
|
||||||
|
? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="p-2 border-t dark:border-neutral-800">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile header */}
|
||||||
|
<div className="md:hidden fixed top-0 left-0 right-0 h-14 bg-white dark:bg-neutral-900 border-b dark:border-neutral-800 flex items-center justify-between px-4 z-50">
|
||||||
|
<Link href="/" className="text-lg font-bold">
|
||||||
|
BiasNews Admin
|
||||||
|
</Link>
|
||||||
|
<button onClick={() => setMobileOpen(!mobileOpen)}>
|
||||||
|
{mobileOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile sidebar overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div className="md:hidden fixed inset-0 z-40 bg-white dark:bg-neutral-900 pt-14">
|
||||||
|
<nav className="p-2 space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium ${
|
||||||
|
active
|
||||||
|
? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="flex-1 md:p-8 pt-20 md:pt-8 px-4 pb-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/lib/api-keys.ts
Normal file
62
src/lib/api-keys.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { prisma } from './db';
|
||||||
|
import { decrypt, encrypt } from './encryption';
|
||||||
|
import { ApiProvider } from '@prisma/client';
|
||||||
|
|
||||||
|
export async function getActiveKey(provider: ApiProvider): Promise<string | null> {
|
||||||
|
const key = await prisma.apiKey.findFirst({
|
||||||
|
where: { provider, isActive: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (!key) return null;
|
||||||
|
return decrypt(key.encryptedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listKeys() {
|
||||||
|
return prisma.apiKey.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createKey(data: {
|
||||||
|
provider: ApiProvider;
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}) {
|
||||||
|
return prisma.apiKey.create({
|
||||||
|
data: {
|
||||||
|
provider: data.provider,
|
||||||
|
encryptedKey: encrypt(data.key),
|
||||||
|
label: data.label,
|
||||||
|
isActive: data.isActive ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateKey(
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
key?: string;
|
||||||
|
label?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const update: Record<string, unknown> = {};
|
||||||
|
if (data.key !== undefined) update.encryptedKey = encrypt(data.key);
|
||||||
|
if (data.label !== undefined) update.label = data.label;
|
||||||
|
if (data.isActive !== undefined) update.isActive = data.isActive;
|
||||||
|
|
||||||
|
return prisma.apiKey.update({
|
||||||
|
where: { id },
|
||||||
|
data: update,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteKey(id: string) {
|
||||||
|
return prisma.apiKey.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskKey(key: string): string {
|
||||||
|
if (key.length <= 8) return '••••••••';
|
||||||
|
return key.slice(0, 4) + '••••••••' + key.slice(-4);
|
||||||
|
}
|
||||||
10
src/lib/credentials.ts
Normal file
10
src/lib/credentials.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { prisma } from './db';
|
||||||
|
|
||||||
|
export async function validateCredentials(username: string, password: string) {
|
||||||
|
const admin = await prisma.adminUser.findUnique({
|
||||||
|
where: { username },
|
||||||
|
});
|
||||||
|
if (!admin) return false;
|
||||||
|
return bcrypt.compare(password, admin.passwordHash);
|
||||||
|
}
|
||||||
7
src/lib/db.ts
Normal file
7
src/lib/db.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
32
src/lib/encryption.ts
Normal file
32
src/lib/encryption.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const IV_LENGTH = 16;
|
||||||
|
const AUTH_TAG_LENGTH = 16;
|
||||||
|
const SALT_LENGTH = 64;
|
||||||
|
|
||||||
|
function getKey() {
|
||||||
|
const secret = process.env.API_KEY_ENCRYPTION_SECRET;
|
||||||
|
if (!secret) throw new Error('API_KEY_ENCRYPTION_SECRET is not set');
|
||||||
|
return crypto.scryptSync(secret, 'bias-news-aggregator', 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encrypt(text: string): string {
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
const combined = Buffer.concat([iv, authTag, encrypted]);
|
||||||
|
return combined.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(encrypted: string): string {
|
||||||
|
const combined = Buffer.from(encrypted, 'base64');
|
||||||
|
const iv = combined.subarray(0, IV_LENGTH);
|
||||||
|
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
||||||
|
const encryptedText = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
const decrypted = Buffer.concat([decipher.update(encryptedText), decipher.final()]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
}
|
||||||
62
src/lib/session.ts
Normal file
62
src/lib/session.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
const SESSION_COOKIE = 'admin_session';
|
||||||
|
const SESSION_DURATION_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
||||||
|
|
||||||
|
export async function getSecret() {
|
||||||
|
const secret = process.env.NEXTAUTH_SECRET;
|
||||||
|
if (!secret) throw new Error('NEXTAUTH_SECRET is not set');
|
||||||
|
return new TextEncoder().encode(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminSession = {
|
||||||
|
username: string;
|
||||||
|
exp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createSession(username: string) {
|
||||||
|
const secret = await getSecret();
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
|
||||||
|
const token = await new SignJWT({ username })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setExpirationTime(exp)
|
||||||
|
.sign(secret);
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(SESSION_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: SESSION_DURATION_SECONDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroySession() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete(SESSION_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySession(request?: NextRequest): Promise<AdminSession | null> {
|
||||||
|
const cookieStore = request ? request.cookies : await cookies();
|
||||||
|
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secret = await getSecret();
|
||||||
|
const { payload } = await jwtVerify(token, secret);
|
||||||
|
return payload as AdminSession;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdminSession(request?: NextRequest) {
|
||||||
|
const session = await verifySession(request);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
33
src/middleware.ts
Normal file
33
src/middleware.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { verifySession } from './lib/session';
|
||||||
|
|
||||||
|
const ADMIN_ROUTE = '/admin';
|
||||||
|
const LOGIN_ROUTE = '/admin/login';
|
||||||
|
const API_ADMIN_ROUTE = '/api/admin';
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const pathname = request.nextUrl.pathname;
|
||||||
|
|
||||||
|
if (pathname.startsWith(LOGIN_ROUTE)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith(ADMIN_ROUTE) || pathname.startsWith(API_ADMIN_ROUTE)) {
|
||||||
|
const session = await verifySession(request);
|
||||||
|
if (!session) {
|
||||||
|
if (pathname.startsWith(API_ADMIN_ROUTE)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const loginUrl = new URL(LOGIN_ROUTE, request.url);
|
||||||
|
loginUrl.searchParams.set('from', pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/admin/:path*', '/api/admin/:path*'],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user