v4.1
This commit is contained in:
parent
cc7a77f21b
commit
c7fd939f41
19 changed files with 155 additions and 6999 deletions
|
|
@ -11,13 +11,9 @@ NEXTAUTH_SECRET=your-generated-secret-key
|
||||||
# Data directory for persistent storage (config, backups, database)
|
# Data directory for persistent storage (config, backups, database)
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
|
|
||||||
# Admin username for login
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
|
|
||||||
# Admin password hash (generated with bcryptjs)
|
# SQLite database file (should be under DATA_DIR)
|
||||||
# To generate a hash, run: node -e "console.log(require('bcryptjs').hashSync('your-password', 10))"
|
DATABASE_FILE=database.db
|
||||||
# Example hash for password 'admin': $2a$10$your-bcrypt-hash-here
|
|
||||||
ADMIN_PASSWORD_HASH=$2a$10$your-bcrypt-hash-here
|
|
||||||
|
|
||||||
# Optional: Polling interval for status checks (in milliseconds)
|
# Optional: Polling interval for status checks (in milliseconds)
|
||||||
# STATUS_POLL_INTERVAL=30000
|
# STATUS_POLL_INTERVAL=30000
|
||||||
53
Dockerfile
53
Dockerfile
|
|
@ -1,68 +1,49 @@
|
||||||
# Multi-stage build for Next.js standalone output
|
# Multi-stage build for Next.js standalone output
|
||||||
FROM node:20-alpine AS base
|
|
||||||
|
# Verwende ein robustes Debian-basiertes Node-Image für native Module
|
||||||
|
FROM node:20-slim AS base
|
||||||
|
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN \
|
RUN npm ci
|
||||||
if [ -f package-lock.json ]; then npm ci; \
|
|
||||||
else echo "Lockfile not found." && exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
|
||||||
|
# Build source code
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate Prisma client
|
|
||||||
RUN npx prisma generate
|
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
# Kopiere gebaute App und node_modules
|
||||||
RUN adduser --system --uid 1001 nextjs
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/src ./src
|
||||||
|
COPY --from=builder /app/data ./data
|
||||||
|
|
||||||
# Set the correct permission for prerender cache
|
# Erstelle Datenverzeichnis für SQLite, falls nicht vorhanden
|
||||||
RUN mkdir .next
|
RUN mkdir -p /app/data
|
||||||
RUN chown nextjs:nodejs .next
|
|
||||||
|
|
||||||
# Create data directory for persistent storage
|
|
||||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT 3000
|
ENV PORT 3000
|
||||||
# set hostname to localhost
|
|
||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
# server.js is created by next build from the standalone output
|
CMD ["npx", "next", "start"]
|
||||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
49
README.md
49
README.md
|
|
@ -46,17 +46,11 @@ Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, ge
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Schritt 1.1: Prisma Client generieren
|
|
||||||
|
|
||||||
```bash
|
### Schritt 1.1: Datenbank wird automatisch erstellt
|
||||||
npm run prisma:generate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Schritt 1.2: Datenbank-Schema anwenden
|
Es ist **keine** externe Datenbank und kein Setup-Script nötig. Die App verwendet eine lokale SQLite-Datenbank (`better-sqlite3`). Die Datenbankdatei wird beim ersten Start automatisch unter dem in `.env` definierten `DATA_DIR` angelegt (Standard: `./data/database.db`).
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run prisma:db-push
|
|
||||||
```
|
|
||||||
|
|
||||||
### Schritt 2: Umgebungsvariablen konfigurieren
|
### Schritt 2: Umgebungsvariablen konfigurieren
|
||||||
|
|
||||||
|
|
@ -71,29 +65,15 @@ Bearbeite `.env` und setze die erforderlichen Variablen:
|
||||||
```env
|
```env
|
||||||
PORT=3000
|
PORT=3000
|
||||||
HOSTNAME=0.0.0.0
|
HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
# NextAuth Secret (generieren mit: openssl rand -base64 32)
|
|
||||||
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
|
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
|
||||||
|
DATA_DIR=./data
|
||||||
# SQLite database URL für persistenten Benutzerzugriff
|
DATABASE_FILE=database.db
|
||||||
DATABASE_URL=file:./dev.db
|
|
||||||
|
|
||||||
# Admin-Anmeldedaten (optional, solange noch kein erster Benutzer angelegt wurde)
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD_HASH=<siehe unten>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Schritt 3: Admin-Passwort-Hash generieren
|
|
||||||
|
|
||||||
Um den Passwort-Hash zu generieren, führe aus:
|
### Schritt 3: First-Run-Setup (Admin-Benutzer anlegen)
|
||||||
|
|
||||||
```bash
|
Beim ersten Start ist `/setup` verfügbar, solange noch kein Benutzer existiert. Dort kannst du den ersten Admin-Benutzer anlegen. Danach ist `/setup` gesperrt und du nutzt `/login`.
|
||||||
node scripts/generate-password-hash.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Das Script fordert dich auf, ein Passwort einzugeben und gibt einen bcryptjs-Hash aus.
|
|
||||||
|
|
||||||
Kopiere den Hash in deine `.env` Datei als `ADMIN_PASSWORD_HASH`.
|
|
||||||
|
|
||||||
### Schritt 4: Entwicklungsserver starten
|
### Schritt 4: Entwicklungsserver starten
|
||||||
|
|
||||||
|
|
@ -103,12 +83,16 @@ npm run dev
|
||||||
|
|
||||||
Der Server läuft unter `http://localhost:3000`
|
Der Server läuft unter `http://localhost:3000`
|
||||||
|
|
||||||
|
|
||||||
## Login & Admin-Zugang
|
## Login & Admin-Zugang
|
||||||
|
|
||||||
1. Navigiere zu `http://localhost:3000/setup`, wenn noch kein Administrator angelegt wurde.
|
1. Navigiere zu `http://localhost:3000/setup`, wenn noch **kein** Benutzer existiert.
|
||||||
2. Erstelle den ersten Admin-Benutzer.
|
2. Lege den ersten Admin-Benutzer an (Benutzername und Passwort werden in der Datenbank gespeichert, Passwort wird mit bcryptjs gehasht).
|
||||||
3. Danach melde dich unter `http://localhost:3000/login` an.
|
3. Danach melde dich unter `http://localhost:3000/login` an.
|
||||||
4. Nach erfolgreichem Login wird zu `/admin` weitergeleitet
|
4. Nach erfolgreichem Login wirst du zu `/admin` weitergeleitet.
|
||||||
|
## Speicherort der Datenbank
|
||||||
|
|
||||||
|
Die SQLite-Datenbankdatei liegt standardmäßig unter `./data/database.db` (relativ zum Projektverzeichnis oder `/app/data/database.db` im Docker-Container). Der Pfad kann über `DATA_DIR` und `DATABASE_FILE` in der `.env` angepasst werden.
|
||||||
|
|
||||||
## Admin-Interface
|
## Admin-Interface
|
||||||
|
|
||||||
|
|
@ -168,9 +152,6 @@ npm run lint # ESLint ausführen
|
||||||
| `PORT` | Port für den Server | `3000` |
|
| `PORT` | Port für den Server | `3000` |
|
||||||
| `HOSTNAME` | Server-Hostname | `0.0.0.0` |
|
| `HOSTNAME` | Server-Hostname | `0.0.0.0` |
|
||||||
| `NEXTAUTH_SECRET` | Secret für JWT-Signing | `openssl rand -base64 32` |
|
| `NEXTAUTH_SECRET` | Secret für JWT-Signing | `openssl rand -base64 32` |
|
||||||
| `DATABASE_URL` | SQLite-Verbindungs-URL für Prisma | `file:./dev.db` |
|
|
||||||
| `ADMIN_USERNAME` | Admin-Benutzername | `admin` |
|
|
||||||
| `ADMIN_PASSWORD_HASH` | Bcryptjs-Hash des Admin-Passworts | `$2a$10$...` |
|
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
|
|
@ -220,7 +201,6 @@ docker-compose up --build
|
||||||
docker build -t homelab-dashboard .
|
docker build -t homelab-dashboard .
|
||||||
docker run -p 3000:3000 \
|
docker run -p 3000:3000 \
|
||||||
-e NEXTAUTH_SECRET=your-secret \
|
-e NEXTAUTH_SECRET=your-secret \
|
||||||
-e DATABASE_URL=file:/app/data/database.db \
|
|
||||||
-e DATA_DIR=/app/data \
|
-e DATA_DIR=/app/data \
|
||||||
-v $(pwd)/data:/app/data \
|
-v $(pwd)/data:/app/data \
|
||||||
homelab-dashboard
|
homelab-dashboard
|
||||||
|
|
@ -257,7 +237,6 @@ docker-compose down
|
||||||
|
|
||||||
5. **Environment Variables**:
|
5. **Environment Variables**:
|
||||||
- **NEXTAUTH_SECRET**: Dein generierter Secret (openssl rand -base64 32)
|
- **NEXTAUTH_SECRET**: Dein generierter Secret (openssl rand -base64 32)
|
||||||
- **DATABASE_URL**: file:/app/data/database.db
|
|
||||||
- **DATA_DIR**: /app/data
|
- **DATA_DIR**: /app/data
|
||||||
- **NODE_ENV**: production
|
- **NODE_ENV**: production
|
||||||
|
|
||||||
|
|
@ -287,7 +266,6 @@ services:
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- HOSTNAME=0.0.0.0
|
- HOSTNAME=0.0.0.0
|
||||||
- NEXTAUTH_SECRET=your-nextauth-secret
|
- NEXTAUTH_SECRET=your-nextauth-secret
|
||||||
- DATABASE_URL=file:/app/data/database.db
|
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/homelab-dashboard/data:/app/data
|
- /mnt/user/appdata/homelab-dashboard/data:/app/data
|
||||||
|
|
@ -298,7 +276,6 @@ services:
|
||||||
- **Container startet nicht**: Logs prüfen mit `docker-compose logs`
|
- **Container startet nicht**: Logs prüfen mit `docker-compose logs`
|
||||||
- **Daten nicht persistent**: Volume-Mapping überprüfen
|
- **Daten nicht persistent**: Volume-Mapping überprüfen
|
||||||
- **Authentifizierung fehlt**: NEXTAUTH_SECRET setzen
|
- **Authentifizierung fehlt**: NEXTAUTH_SECRET setzen
|
||||||
- **Datenbank-Fehler**: DATABASE_URL und DATA_DIR prüfen
|
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export const runtime = "nodejs";
|
||||||
import NextAuth from 'next-auth';
|
import NextAuth from 'next-auth';
|
||||||
import { authOptions } from '@/auth';
|
import { authOptions } from '@/auth';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export const runtime = "nodejs";
|
||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createBackup, listBackups, restoreBackup } from '@/src/lib/config/backup-config';
|
import { createBackup, listBackups, restoreBackup } from '@/src/lib/config/backup-config';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
export const runtime = "nodejs";
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { hash } from 'bcryptjs';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { createInitialAdmin, hasAnyUser } from '@/src/lib/db/user';
|
import { createInitialAdmin, hasAnyUser } from '@/src/lib/db/user';
|
||||||
|
|
||||||
|
|
@ -42,10 +42,8 @@ export async function POST(request: Request) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hash(result.data.password, 10);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createInitialAdmin(result.data.username, passwordHash);
|
await createInitialAdmin(result.data.username, result.data.password);
|
||||||
return NextResponse.json({ message: 'Administrator wurde erfolgreich angelegt.' }, { status: 201 });
|
return NextResponse.json({ message: 'Administrator wurde erfolgreich angelegt.' }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INITIAL_ADMIN_ALREADY_EXISTS') {
|
if (error instanceof Error && error.message === 'INITIAL_ADMIN_ALREADY_EXISTS') {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export const runtime = "nodejs";
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { hasAnyUser } from '@/src/lib/db/user';
|
import { hasAnyUser } from '@/src/lib/db/user';
|
||||||
import LoginForm from '@/src/components/LoginForm';
|
import LoginForm from '@/src/components/LoginForm';
|
||||||
|
|
@ -5,17 +6,8 @@ import LoginForm from '@/src/components/LoginForm';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function LoginPage() {
|
export default async function LoginPage() {
|
||||||
let hasUser = false;
|
if (!hasAnyUser()) {
|
||||||
|
|
||||||
try {
|
|
||||||
hasUser = await hasAnyUser();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to determine user setup status on /login:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasUser && !process.env.ADMIN_PASSWORD_HASH) {
|
|
||||||
redirect('/setup');
|
redirect('/setup');
|
||||||
}
|
}
|
||||||
|
|
||||||
return <LoginForm />;
|
return <LoginForm />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export const runtime = "nodejs";
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { hasAnyUser } from '@/src/lib/db/user';
|
import { hasAnyUser } from '@/src/lib/db/user';
|
||||||
import SetupForm from '@/src/components/SetupForm';
|
import SetupForm from '@/src/components/SetupForm';
|
||||||
|
|
@ -5,15 +6,8 @@ import SetupForm from '@/src/components/SetupForm';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function SetupPage() {
|
export default async function SetupPage() {
|
||||||
try {
|
if (hasAnyUser()) {
|
||||||
const hasUser = await hasAnyUser();
|
|
||||||
|
|
||||||
if (hasUser) {
|
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to determine user setup status on /setup:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <SetupForm />;
|
return <SetupForm />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
auth.ts
37
auth.ts
|
|
@ -14,19 +14,15 @@ export const authOptions: NextAuthOptions = {
|
||||||
if (!credentials?.username || !credentials?.password) {
|
if (!credentials?.username || !credentials?.password) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = credentials.username.trim();
|
const username = credentials.username.trim();
|
||||||
const password = credentials.password;
|
const password = credentials.password;
|
||||||
if (process.env.DATABASE_URL) {
|
|
||||||
try {
|
try {
|
||||||
const dbUser = await getUserByUsername(username);
|
const dbUser = getUserByUsername(username);
|
||||||
|
|
||||||
if (dbUser) {
|
if (dbUser) {
|
||||||
const isPasswordValid = await compare(password, dbUser.passwordHash);
|
const isPasswordValid = await compare(password, dbUser.password_hash);
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(dbUser.id),
|
id: String(dbUser.id),
|
||||||
name: username,
|
name: username,
|
||||||
|
|
@ -34,40 +30,13 @@ export const authOptions: NextAuthOptions = {
|
||||||
username,
|
username,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (hasAnyUser()) {
|
||||||
if (await hasAnyUser()) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Database authentication error:', error);
|
console.error('Database authentication error:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
|
||||||
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH;
|
|
||||||
|
|
||||||
if (!adminPasswordHash || username !== adminUsername) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const isPasswordValid = await compare(password, adminPasswordHash);
|
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: '1',
|
|
||||||
name: 'Admin',
|
|
||||||
email: 'admin@homelab.local',
|
|
||||||
username: adminUsername,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Password comparison error:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {};
|
||||||
output: 'standalone',
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
|
|
|
||||||
6788
package-lock.json
generated
6788
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -2,16 +2,17 @@
|
||||||
"name": "homelab-dashboard",
|
"name": "homelab-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint app src lib --ext .ts,.tsx",
|
"lint": "eslint app src lib --ext .ts,.tsx"
|
||||||
"prisma:generate": "prisma generate",
|
|
||||||
"prisma:db-push": "prisma db push"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"better-sqlite3": "^9.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "^16.2.3",
|
"next": "^16.2.3",
|
||||||
"next-auth": "^4.24.14",
|
"next-auth": "^4.24.14",
|
||||||
|
|
@ -29,7 +30,6 @@
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-next": "^15.0.3",
|
"eslint-config-next": "^15.0.3",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"prisma": "^5.22.0",
|
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1 @@
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "sqlite"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
username String @unique
|
|
||||||
passwordHash String
|
|
||||||
role String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
1
src/lib/db-init.ts
Normal file
1
src/lib/db-init.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
import './db'; // just import to ensure DB is initialized
|
||||||
26
src/lib/db.ts
Normal file
26
src/lib/db.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { DATA_DIR } from './config/paths';
|
||||||
|
|
||||||
|
const DB_FILE = path.join(DATA_DIR, 'database.db');
|
||||||
|
|
||||||
|
// Ensure DATA_DIR exists
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open or create the database
|
||||||
|
export const db = new Database(DB_FILE);
|
||||||
|
|
||||||
|
// Initialize users table if not exists
|
||||||
|
const init = db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
init.run();
|
||||||
|
|
@ -1,12 +1 @@
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var prisma: PrismaClient | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const prisma = globalThis.prisma ?? new PrismaClient();
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
globalThis.prisma = prisma;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,34 @@
|
||||||
import { prisma } from './prisma';
|
import { db } from '../db';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
export async function getUserByUsername(username: string) {
|
export interface User {
|
||||||
return prisma.user.findUnique({
|
id: number;
|
||||||
where: { username },
|
username: string;
|
||||||
});
|
password_hash: string;
|
||||||
|
role: string;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(username: string, passwordHash: string, role = 'admin') {
|
export function getUserByUsername(username: string): User | undefined {
|
||||||
return prisma.user.create({
|
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
passwordHash,
|
|
||||||
role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hasAnyUser() {
|
export function hasAnyUser(): boolean {
|
||||||
const count = await prisma.user.count();
|
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||||
return count > 0;
|
return row.count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createInitialAdmin(username: string, passwordHash: string) {
|
export async function createUser(username: string, password: string, role = 'admin'): Promise<User> {
|
||||||
return prisma.$transaction(async (tx: any) => {
|
const password_hash = await bcrypt.hash(password, 10);
|
||||||
const existingUsers = await tx.user.count();
|
const stmt = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)');
|
||||||
|
stmt.run(username, password_hash, role);
|
||||||
|
return getUserByUsername(username)!;
|
||||||
|
}
|
||||||
|
|
||||||
if (existingUsers > 0) {
|
export async function createInitialAdmin(username: string, password: string): Promise<User> {
|
||||||
|
if (hasAnyUser()) {
|
||||||
throw new Error('INITIAL_ADMIN_ALREADY_EXISTS');
|
throw new Error('INITIAL_ADMIN_ALREADY_EXISTS');
|
||||||
}
|
}
|
||||||
|
return createUser(username, password, 'admin');
|
||||||
return tx.user.create({
|
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
passwordHash,
|
|
||||||
role: 'admin',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
33
src/lib/users.ts
Normal file
33
src/lib/users.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { db } from './db';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
role: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserByUsername(username: string): User | undefined {
|
||||||
|
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAnyUser(): boolean {
|
||||||
|
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||||
|
return row.count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(username: string, password: string, role = 'admin'): Promise<User> {
|
||||||
|
const password_hash = await bcrypt.hash(password, 10);
|
||||||
|
const stmt = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)');
|
||||||
|
const info = stmt.run(username, password_hash, role);
|
||||||
|
return getUserByUsername(username)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyUser(username: string, password: string): Promise<User | null> {
|
||||||
|
const user = getUserByUsername(username);
|
||||||
|
if (!user) return null;
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
return valid ? user : null;
|
||||||
|
}
|
||||||
7
types/user.d.ts
vendored
Normal file
7
types/user.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
role: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue