From cda21a60ec9638f52c6119b9c3e070640fe105d3 Mon Sep 17 00:00:00 2001 From: averel10 Date: Fri, 13 Mar 2026 08:11:40 +0100 Subject: [PATCH] feat: implement one-time admin token generation and upgrade process --- README.md | 18 ++++ src/app/actions/set-user-admin.ts | 30 ++++++ src/app/actions/verify-admin-token.ts | 14 +++ src/app/admin/page.tsx | 3 +- src/components/AdminTokenForm.tsx | 139 ++++++++++++++++++++++++++ src/instrumentation.ts | 36 ++++++- 6 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 src/app/actions/set-user-admin.ts create mode 100644 src/app/actions/verify-admin-token.ts create mode 100644 src/components/AdminTokenForm.tsx diff --git a/README.md b/README.md index 9efebb3..36baed5 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,24 @@ Open [http://localhost:3000](http://localhost:3000) in your browser. 4. Review and run `npm run db:push` 5. Update relevant server actions and components +## Admin Signup System + +The platform automatically generates a one-time admin token on startup if no admin exists in the database. This token is logged to the console and can only be used once. + +### Upgrading to Admin Account + +1. Sign in with your regular account +2. Try to access `/admin` +3. You'll see a form asking for the admin token +4. Paste the token from the console output +5. Click "Admin aktivieren" to upgrade your account + +After successful upgrade: +- The token becomes invalid +- Your account gains full admin privileges +- No new token is generated (admin now exists) +- Trying to upgrade another account will fail (token is spent) + ## Development Notes - Database schema is defined in `src/lib/model/` diff --git a/src/app/actions/set-user-admin.ts b/src/app/actions/set-user-admin.ts new file mode 100644 index 0000000..6697ad7 --- /dev/null +++ b/src/app/actions/set-user-admin.ts @@ -0,0 +1,30 @@ +'use server'; + +import { auth } from '@/lib/auth'; +import db from '@/lib/db'; +import { user } from '@/lib/model/auth-schema'; +import { eq } from 'drizzle-orm'; +import { headers } from 'next/headers'; +import { verifyAdminToken } from './verify-admin-token'; + +export async function setUserAsAdmin(email: string, adminToken: string): Promise<{ success: boolean; error?: string }> { + // Verify the token is valid + const tokenResult = await verifyAdminToken(adminToken); + + if (!tokenResult.valid) { + return { success: false, error: 'Invalid or expired admin token' }; + } + + try { + // Update user admin flag + await db.update(user).set({ admin: true }).where(eq(user.email, email)); + + // Clear the token so it can't be used again + delete process.env.ADMIN_SIGNUP_TOKEN; + + return { success: true }; + } catch (error) { + console.error('Error setting admin:', error); + return { success: false, error: 'Failed to set admin status' }; + } +} diff --git a/src/app/actions/verify-admin-token.ts b/src/app/actions/verify-admin-token.ts new file mode 100644 index 0000000..528d666 --- /dev/null +++ b/src/app/actions/verify-admin-token.ts @@ -0,0 +1,14 @@ +'use server'; + +export async function verifyAdminToken(token: string): Promise<{ valid: boolean }> { + const storedToken = process.env.ADMIN_SIGNUP_TOKEN; + + // Token is invalid if: + // 1. No token exists (already used or no token was generated) + // 2. Provided token doesn't match + if (!storedToken || token !== storedToken) { + return { valid: false }; + } + + return { valid: true }; +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 2a718b0..53a729b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,5 +1,6 @@ import DatasetsList from "@/components/DatasetsList"; import CreateDatasetModal from "@/components/CreateDatasetModal"; +import { AdminTokenForm } from "@/components/AdminTokenForm"; import { requireAdmin } from "@/lib/auth"; import { redirect } from "next/navigation"; import Link from "next/link"; @@ -12,7 +13,7 @@ export default async function AdminPage() { } if (!result.admin) { - redirect("/"); + return ; } return ( diff --git a/src/components/AdminTokenForm.tsx b/src/components/AdminTokenForm.tsx new file mode 100644 index 0000000..b9a8ade --- /dev/null +++ b/src/components/AdminTokenForm.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useState } from 'react'; +import { setUserAsAdmin } from '@/app/actions/set-user-admin'; +import { useRouter } from 'next/navigation'; + +interface AdminTokenFormProps { + userEmail: string; +} + +export function AdminTokenForm({ userEmail }: AdminTokenFormProps) { + const [adminToken, setAdminToken] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const result = await setUserAsAdmin(userEmail, adminToken); + + if (result.success) { + setSuccess(true); + setAdminToken(''); + // Refresh the page to verify admin status + setTimeout(() => { + router.refresh(); + }, 1500); + } else { + setError(result.error || 'Invalid admin token'); + } + } catch (err: any) { + setError(err.message || 'Ein Fehler ist aufgetreten'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ {/* Header */} +
+

Admin-Zugriff erforderlich

+

+ Sie benötigen Admin-Rechte, um diese Seite zu besuchen +

+
+ + {/* Form */} +
+ {/* Success Message */} + {success && ( +
+

+ ✓ Admin-Status erfolgreich aktiviert! Seite wird aktualisiert... +

+
+ )} + + {/* Error Message */} + {error && ( +
+ + + + {error} +
+ )} + + {/* User Info */} +
+

+ Benutzer: {userEmail} +

+
+ + {/* Admin Token Input */} +
+ + setAdminToken(e.target.value)} + placeholder="Admin-Token eingeben" + required + disabled={loading || success} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-colors disabled:bg-gray-100 disabled:text-gray-500" + /> +

+ Geben Sie den Admin-Token ein, um Admin-Rechte zu aktivieren +

+
+ + {/* Submit Button */} + +
+
+
+
+ ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 577a107..64d4962 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,9 +1,15 @@ -import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; -import db from '@/lib/db'; -import { join } from 'path'; +import { eq } from 'drizzle-orm'; export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { + // Import nodejs-only modules dynamically + const { migrate } = await import('drizzle-orm/better-sqlite3/migrator'); + const { join } = await import('path'); + const { randomBytes } = await import('crypto'); + const { count } = await import('drizzle-orm'); + const db = (await import('@/lib/db')).default; + const { user } = await import('@/lib/model/auth-schema'); + try { console.log('Running database migrations...'); @@ -13,8 +19,30 @@ export async function register() { migrate(db, { migrationsFolder }); console.log('✓ Database migrations completed successfully'); + + // Check if any admin exists + const adminCount = await db.select({ count: count() }).from(user).where(eq(user.admin, true)); + const hasAdmin = adminCount.length > 0 && adminCount[0].count > 0; + + // If no admin exists, generate a one-time token + if (!hasAdmin) { + const token = randomBytes(32).toString('hex'); + process.env.ADMIN_SIGNUP_TOKEN = token; + console.log('\n╔════════════════════════════════════════════════════════════════╗'); + console.log('║ ADMIN TOKEN GENERATED ║'); + console.log('╠════════════════════════════════════════════════════════════════╣'); + console.log(`║ Token: ${token} ║`); + console.log('║ ║'); + console.log('║ This token can be used ONCE to upgrade a user to admin. ║'); + console.log('║ After the first use, it will become invalid. ║'); + console.log('║ ║'); + console.log('║ 1. Sign in with your account ║'); + console.log('║ 2. Try to access /admin ║'); + console.log('║ 3. Enter this token when prompted ║'); + console.log('╚════════════════════════════════════════════════════════════════╝\n'); + } } catch (error) { - console.error('Failed to run database migrations:', error); + console.error('Failed to initialize app:', error); process.exit(1); } }