Compare commits
15 commits
main
...
delivery-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14445a092c | ||
|
|
3a54ba40d3 | ||
|
|
3816e5e2e8 | ||
|
|
50c2664078 | ||
|
|
5ca984a018 | ||
|
|
c22dc09cc7 | ||
|
|
534ad07a73 | ||
|
|
05419e9457 | ||
|
|
4889ead690 | ||
|
|
efe19f0cda | ||
|
|
90407c4f8d | ||
|
|
9b6d8f0555 | ||
|
|
bc300f54f2 | ||
|
|
7eae4fde33 | ||
|
|
23f623db2c |
24 changed files with 1744 additions and 362 deletions
31
.env.example
31
.env.example
|
|
@ -1,11 +1,22 @@
|
|||
# OpenRouteService API Key
|
||||
# Get your free key at https://openrouteservice.org/dev/#/signup
|
||||
# Free tier: 2,000 requests/day
|
||||
OPENROUTE_API_KEY=your_api_key_here
|
||||
# ─── Admin Login ────────────────────────────────────────────────────
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
# Email configuration (for quote requests)
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=user@example.com
|
||||
# SMTP_PASS=your_password
|
||||
# EMAIL_TO=info@foamking.dk
|
||||
# ─── Email (SMTP) ──────────────────────────────────────────────────
|
||||
# Office 365: smtp.office365.com, port 587
|
||||
# Requires SMTP AUTH enabled for the sending account in Exchange Admin Center.
|
||||
# See SETUP.md / OPSÆTNING.md for instructions.
|
||||
SMTP_HOST=smtp.office365.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=tilbud@foamking.dk
|
||||
SMTP_PASS=your_password_here
|
||||
EMAIL_FROM_NAME=Foam King Prisberegner
|
||||
EMAIL_TO=info@foamking.dk
|
||||
|
||||
# ─── Base URL ───────────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_BASE_URL=https://beregner.foamking.dk
|
||||
|
||||
# ─── Distance Calculation (Optional) ───────────────────────────────
|
||||
# Without this key, distances are calculated from a built-in postal code table.
|
||||
# Get a free key at https://openrouteservice.org/dev/#/signup (2,000 requests/day)
|
||||
# OPENROUTE_API_KEY=your_api_key_here
|
||||
|
|
|
|||
123
OPSÆTNING.md
Normal file
123
OPSÆTNING.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# FoamKing Opsætningsguide
|
||||
|
||||
Komplet vejledning til at sætte FoamKing prisberegneren i drift.
|
||||
|
||||
---
|
||||
|
||||
## 1. Forudsætninger
|
||||
|
||||
- **Node.js** 18 eller nyere (20 LTS anbefales)
|
||||
- **npm** (følger med Node.js)
|
||||
|
||||
## 2. Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 3. Konfiguration af miljøvariabler
|
||||
|
||||
Kopier eksempelfilen og udfyld med jeres egne værdier:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Åbn `.env.local` i en teksteditor og konfigurer hver sektion som beskrevet nedenfor.
|
||||
|
||||
### Admin-login (Påkrævet)
|
||||
|
||||
```env
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme
|
||||
```
|
||||
|
||||
Disse oplysninger bruges til at logge ind på `/intern/login`. Skift begge værdier inden I går i produktion.
|
||||
|
||||
### E-mail — Office 365 SMTP (Påkrævet)
|
||||
|
||||
```env
|
||||
SMTP_HOST=smtp.office365.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=tilbud@foamking.dk
|
||||
SMTP_PASS=your_password_here
|
||||
EMAIL_FROM_NAME=Foam King Prisberegner
|
||||
EMAIL_TO=info@foamking.dk
|
||||
```
|
||||
|
||||
Applikationen sender tilbudsforespørgsler via Office 365. For at det virker, skal **SMTP AUTH være aktiveret** på afsenderpostkassen:
|
||||
|
||||
1. Gå til **Exchange Admin Center** — [admin.exchange.microsoft.com](https://admin.exchange.microsoft.com)
|
||||
2. Gå til **Recipients** → **Mailboxes**
|
||||
3. Vælg postkassen **tilbud@foamking.dk**
|
||||
4. Klik på **Manage email apps settings** (eller Mail flow → Email apps)
|
||||
5. Aktivér **Authenticated SMTP (SMTP AUTH)**
|
||||
6. Gem
|
||||
|
||||
> **Bemærk:** Hvis jeres organisation bruger Security Defaults eller Conditional Access-politikker, der blokerer legacy-godkendelse, kan det være nødvendigt at oprette en undtagelse for denne postkasse. Hvis MFA er aktiveret på kontoen, skal I bruge et App Password i stedet for den almindelige adgangskode.
|
||||
|
||||
### Base-URL (Påkrævet)
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_BASE_URL=https://beregner.foamking.dk
|
||||
```
|
||||
|
||||
Bruges når der genereres links i udgående e-mails (f.eks. links til tilbudssider).
|
||||
|
||||
### Afstandsberegning (Valgfrit)
|
||||
|
||||
```env
|
||||
OPENROUTE_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
Uden denne nøgle bruger applikationen en indbygget afstandstabel baseret på postnumre, hvilket fungerer fint i de fleste tilfælde. Ønsker I mere præcise køreafstande, kan I oprette en gratis API-nøgle på [openrouteservice.org](https://openrouteservice.org/dev/#/signup) (2.000 forespørgsler pr. dag).
|
||||
|
||||
## 4. Opret database
|
||||
|
||||
```bash
|
||||
npm run setup
|
||||
```
|
||||
|
||||
Dette opretter SQLite-databasen og de nødvendige tabeller.
|
||||
|
||||
## 5. Byg og start
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
Applikationen starter på **port 3001**.
|
||||
|
||||
## 6. Ruteoversigt
|
||||
|
||||
| Rute | Beskrivelse |
|
||||
| -------------------- | ---------------------------------------------- |
|
||||
| `/` | Offentlig prisberegner |
|
||||
| `/tilbud/[id]` | Offentlig tilbudsvisning (link fra e-mail) |
|
||||
| `/intern` | Admin-dashboard — tilbudsstyring |
|
||||
| `/intern/historik` | Admin — tilbudshistorik |
|
||||
| `/intern/beregner` | Admin — detaljeret beregner med prisopbygning |
|
||||
| `/intern/login` | Admin-loginside |
|
||||
|
||||
## 7. Tilpasset autentificering
|
||||
|
||||
Standardopsætningen bruger miljøvariablerne `ADMIN_EMAIL` og `ADMIN_PASSWORD` med sessioner i hukommelsen. Det er enkelt og tilstrækkeligt til en setup med en enkelt administrator.
|
||||
|
||||
Vil I integrere jeres egen autentificering (JWT, OAuth, SSO), skal I redigere **`lib/auth.ts`**. Hele applikationen bruger kun tre funktioner:
|
||||
|
||||
- `checkAuth()` — tjekker om den aktuelle forespørgsel er autentificeret
|
||||
- `login(email, password)` — autentificerer en bruger og opretter en session
|
||||
- `logout()` — afslutter den aktuelle session
|
||||
|
||||
Middlewaren i **`middleware.ts`** tjekker for en session-cookie på alle `/intern/*`-ruter og omdirigerer ikke-autentificerede besøgende til `/intern/login`. Opdater den, hvis I skifter til en anden mekanisme (f.eks. en JWT i en `Authorization`-header).
|
||||
|
||||
## 8. Udvikling
|
||||
|
||||
```bash
|
||||
npm run dev # Start udviklingsserver (port 3001)
|
||||
npm run build # Produktionsbuild
|
||||
npm start # Start produktionsserver
|
||||
npm run lint # Kør ESLint
|
||||
npm run setup # Opret / nulstil database
|
||||
```
|
||||
123
SETUP.md
Normal file
123
SETUP.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# FoamKing Setup Guide
|
||||
|
||||
Complete setup instructions for deploying the FoamKing flooring price calculator.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- **Node.js** 18 or later (20 LTS recommended)
|
||||
- **npm** (included with Node.js)
|
||||
|
||||
## 2. Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 3. Configure Environment
|
||||
|
||||
Copy the example file and fill in your values:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Open `.env.local` in a text editor and configure each section as described below.
|
||||
|
||||
### Admin Login (Required)
|
||||
|
||||
```env
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme
|
||||
```
|
||||
|
||||
These credentials are used to sign in at `/intern/login`. Change both values before going to production.
|
||||
|
||||
### Email — Office 365 SMTP (Required)
|
||||
|
||||
```env
|
||||
SMTP_HOST=smtp.office365.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=tilbud@foamking.dk
|
||||
SMTP_PASS=your_password_here
|
||||
EMAIL_FROM_NAME=Foam King Prisberegner
|
||||
EMAIL_TO=info@foamking.dk
|
||||
```
|
||||
|
||||
The application sends quote-request emails through Office 365. For this to work, **SMTP AUTH must be enabled** on the sending mailbox:
|
||||
|
||||
1. Go to **Exchange Admin Center** — [admin.exchange.microsoft.com](https://admin.exchange.microsoft.com)
|
||||
2. Navigate to **Recipients** → **Mailboxes**
|
||||
3. Select the **tilbud@foamking.dk** mailbox
|
||||
4. Click **Manage email apps settings** (or Mail flow → Email apps)
|
||||
5. Enable **Authenticated SMTP (SMTP AUTH)**
|
||||
6. Save
|
||||
|
||||
> **Note:** If your organization uses Security Defaults or Conditional Access policies that block legacy authentication, you may need to create an exclusion for this mailbox. If MFA is enabled on the account, use an App Password instead of the regular password.
|
||||
|
||||
### Base URL (Required)
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_BASE_URL=https://beregner.foamking.dk
|
||||
```
|
||||
|
||||
Used when generating links in outgoing emails (e.g. links to quote pages).
|
||||
|
||||
### Distance Calculation (Optional)
|
||||
|
||||
```env
|
||||
OPENROUTE_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
Without this key the application uses a built-in postal code distance table, which is perfectly fine for most cases. If you want more precise driving distances, sign up for a free API key at [openrouteservice.org](https://openrouteservice.org/dev/#/signup) (2,000 requests per day).
|
||||
|
||||
## 4. Initialize Database
|
||||
|
||||
```bash
|
||||
npm run setup
|
||||
```
|
||||
|
||||
This creates the SQLite database and required tables.
|
||||
|
||||
## 5. Build and Start
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
The application starts on **port 3001**.
|
||||
|
||||
## 6. Route Overview
|
||||
|
||||
| Route | Description |
|
||||
| -------------------- | -------------------------------------------- |
|
||||
| `/` | Public price calculator |
|
||||
| `/tilbud/[id]` | Public quote view (linked from email) |
|
||||
| `/intern` | Admin dashboard — quote management |
|
||||
| `/intern/historik` | Admin — quote history |
|
||||
| `/intern/beregner` | Admin — detailed calculator with price breakdown |
|
||||
| `/intern/login` | Admin login page |
|
||||
|
||||
## 7. Custom Authentication
|
||||
|
||||
The default authentication uses the `ADMIN_EMAIL` and `ADMIN_PASSWORD` environment variables with in-memory sessions. This is simple and sufficient for a single-admin setup.
|
||||
|
||||
To integrate your own authentication (JWT, OAuth, SSO), edit **`lib/auth.ts`**. The entire application relies on only three functions:
|
||||
|
||||
- `checkAuth()` — checks whether the current request is authenticated
|
||||
- `login(email, password)` — authenticates a user and creates a session
|
||||
- `logout()` — destroys the current session
|
||||
|
||||
The middleware in **`middleware.ts`** checks for a session cookie on all `/intern/*` routes and redirects unauthenticated visitors to `/intern/login`. Update it if you switch to a different mechanism (e.g. a JWT in an `Authorization` header).
|
||||
|
||||
## 8. Development
|
||||
|
||||
```bash
|
||||
npm run dev # Start development server (port 3001)
|
||||
npm run build # Production build
|
||||
npm start # Start production server
|
||||
npm run lint # Run ESLint
|
||||
npm run setup # Initialize / reset database
|
||||
```
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { login } from "@/lib/auth"
|
||||
import { rateLimit } from "@/lib/rate-limit"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get("x-forwarded-for") || "unknown"
|
||||
if (!rateLimit(ip, 5, 60_000)) {
|
||||
return NextResponse.json({ error: "For mange forsøg. Prøv igen om lidt." }, { status: 429 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { email, password } = await request.json()
|
||||
|
||||
|
|
@ -15,12 +21,8 @@ export async function POST(request: NextRequest) {
|
|||
return NextResponse.json({ error: result.error }, { status: 401 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: { name: result.user.name, email: result.user.email },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Login error:", error)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ export async function POST() {
|
|||
try {
|
||||
await logout()
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { createUserWithPassword } from "@/lib/auth"
|
||||
import { getUserByEmail } from "@/lib/db"
|
||||
|
||||
// One-time setup endpoint to create initial user
|
||||
// Only works if no users exist yet or for the specific email
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, password, name } = await request.json()
|
||||
|
||||
if (!email || !password || !name) {
|
||||
return NextResponse.json({ error: "Email, password og navn er påkrævet" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = getUserByEmail(email)
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ error: "Bruger findes allerede" }, { status: 409 })
|
||||
}
|
||||
|
||||
const user = await createUserWithPassword(email, password, name)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: { id: user.id, email: user.email, name: user.name },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Setup error:", error)
|
||||
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import nodemailer from "nodemailer"
|
|||
import { formatPrice, type CalculationDetails } from "@/lib/calculations"
|
||||
import { FLOORING_TYPES } from "@/lib/constants"
|
||||
import { saveQuote } from "@/lib/db"
|
||||
import { rateLimit } from "@/lib/rate-limit"
|
||||
|
||||
interface QuoteRequestBody {
|
||||
customerInfo: {
|
||||
|
|
@ -234,6 +235,11 @@ function formatFoamKingEmail(
|
|||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get("x-forwarded-for") || "unknown"
|
||||
if (!rateLimit(ip, 10, 60_000)) {
|
||||
return NextResponse.json({ error: "For mange anmodninger. Prøv igen om lidt." }, { status: 429 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body: QuoteRequestBody = await request.json()
|
||||
const { customerInfo, calculationDetails } = body
|
||||
|
|
@ -296,8 +302,7 @@ export async function POST(request: NextRequest) {
|
|||
success: true,
|
||||
message: "Tak! Vi har modtaget din anmodning og sendt en bekræftelse til din email.",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Quote request error:", error)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Der opstod en fejl. Prøv igen senere." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,25 @@
|
|||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getAllQuotes, updateQuoteStatus, type QuoteStatus } from "@/lib/db"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { checkAuth } from "@/lib/auth"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
const { authenticated } = await checkAuth()
|
||||
if (!authenticated) {
|
||||
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
|
||||
}
|
||||
|
||||
const quotes = getAllQuotes()
|
||||
return NextResponse.json({ quotes })
|
||||
} catch (error) {
|
||||
console.error("Get quotes error:", error)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
const { authenticated } = await checkAuth()
|
||||
if (!authenticated) {
|
||||
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
|
||||
}
|
||||
|
||||
|
|
@ -41,8 +40,7 @@ export async function PATCH(request: NextRequest) {
|
|||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Update quote error:", error)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,10 +36,6 @@ export default function AdminPage() {
|
|||
setIsRequestingQuote(true)
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
console.log("Quote request (test mode):", {
|
||||
customerInfo,
|
||||
calculationDetails: calculationResult,
|
||||
})
|
||||
alert("TEST MODE: Tilbudsanmodning ville blive sendt til info@foamking.dk")
|
||||
} catch (error) {
|
||||
alert("Der opstod en fejl. Prøv igen senere.")
|
||||
|
|
@ -40,15 +40,15 @@ export default function HistorikPage() {
|
|||
const res = await fetch("/api/quotes")
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
router.push("/login")
|
||||
router.push("/intern/login")
|
||||
return
|
||||
}
|
||||
throw new Error("Failed to fetch quotes")
|
||||
}
|
||||
const data = await res.json()
|
||||
setQuotes(data.quotes)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch quotes:", error)
|
||||
} catch {
|
||||
// fetch failed
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -57,10 +57,10 @@ export default function HistorikPage() {
|
|||
async function handleLogout() {
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST" })
|
||||
router.push("/login")
|
||||
router.push("/intern/login")
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error)
|
||||
} catch {
|
||||
// logout failed
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ export default function HistorikPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/dashboard">
|
||||
<Link href="/intern">
|
||||
<Button variant="ghost" size="sm">
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Dashboard</span>
|
||||
|
|
@ -35,7 +35,7 @@ export default function LoginPage() {
|
|||
return
|
||||
}
|
||||
|
||||
router.push("/dashboard")
|
||||
router.push("/intern")
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError("Der opstod en fejl. Prøv igen.")
|
||||
|
|
@ -38,15 +38,15 @@ export default function DashboardPage() {
|
|||
const res = await fetch("/api/quotes")
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
router.push("/login")
|
||||
router.push("/intern/login")
|
||||
return
|
||||
}
|
||||
throw new Error("Failed to fetch quotes")
|
||||
}
|
||||
const data = await res.json()
|
||||
setQuotes(data.quotes)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch quotes:", error)
|
||||
} catch {
|
||||
// fetch failed
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -84,10 +84,10 @@ export default function DashboardPage() {
|
|||
async function handleLogout() {
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST" })
|
||||
router.push("/login")
|
||||
router.push("/intern/login")
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error)
|
||||
} catch {
|
||||
// logout failed
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +133,7 @@ export default function DashboardPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/historik">
|
||||
<Link href="/intern/historik">
|
||||
<Button variant="ghost" size="sm">
|
||||
<List className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Historik</span>
|
||||
|
|
@ -37,11 +37,11 @@ const formSchema = z.object({
|
|||
.length(4, "Postnummer skal være 4 cifre")
|
||||
.refine(validateDanishPostalCode, "Ugyldigt dansk postnummer"),
|
||||
address: z.string().optional(),
|
||||
area: z.coerce
|
||||
area: z
|
||||
.number()
|
||||
.min(CONSTRAINTS.MIN_AREA, `Minimum areal er ${CONSTRAINTS.MIN_AREA} m²`)
|
||||
.max(CONSTRAINTS.MAX_AREA, `Maximum areal er ${CONSTRAINTS.MAX_AREA} m²`),
|
||||
height: z.coerce
|
||||
height: z
|
||||
.number()
|
||||
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum højde er ${CONSTRAINTS.MIN_HEIGHT} cm`)
|
||||
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum højde er ${CONSTRAINTS.MAX_HEIGHT} cm`),
|
||||
|
|
|
|||
|
|
@ -37,11 +37,11 @@ const formSchema = z.object({
|
|||
.length(4, "Postnummer skal være 4 cifre")
|
||||
.refine(validateDanishPostalCode, "Vi dækker desværre ikke dette område"),
|
||||
address: z.string().optional(),
|
||||
area: z.coerce
|
||||
area: z
|
||||
.number()
|
||||
.min(CONSTRAINTS.MIN_AREA, `Minimum ${CONSTRAINTS.MIN_AREA} m²`)
|
||||
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA} m²`),
|
||||
height: z.coerce
|
||||
height: z
|
||||
.number()
|
||||
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum ${CONSTRAINTS.MIN_HEIGHT} cm`)
|
||||
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum ${CONSTRAINTS.MAX_HEIGHT} cm`),
|
||||
|
|
|
|||
134
docs/plans/2026-02-22-delivery-cleanup-design.md
Normal file
134
docs/plans/2026-02-22-delivery-cleanup-design.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# FoamKing Delivery Cleanup Design
|
||||
|
||||
Date: 2026-02-22
|
||||
|
||||
## Goal
|
||||
|
||||
Prepare the FoamKing calculator for zip delivery to customer. Clean up routes, security, auth, DB, and strip debug/dev artifacts. Customer will integrate into their own infrastructure.
|
||||
|
||||
## 1. Route Restructure
|
||||
|
||||
| Before | After | Access |
|
||||
|--------|-------|--------|
|
||||
| `/` | `/` | Public — calculator |
|
||||
| `/tilbud/[slug]` | `/tilbud/[slug]` | Public — quote view |
|
||||
| `/admin` | `/intern/beregner` | Protected — detailed calculator breakdown |
|
||||
| `/dashboard` | `/intern` | Protected — quote management (kanban) |
|
||||
| `/historik` | `/intern/historik` | Protected — quote archive |
|
||||
| `/login` | `/intern/login` | Public — login page |
|
||||
|
||||
Middleware protects `/intern/*` except `/intern/login`.
|
||||
|
||||
## 2. Authentication
|
||||
|
||||
**Replace DB-based auth with env-based single admin user.**
|
||||
|
||||
`lib/auth.ts` exports:
|
||||
- `checkAuth(request)` — single function, clear comments for JWT/OAuth swap
|
||||
- `login(email, password)` — compares against `ADMIN_EMAIL`/`ADMIN_PASSWORD` env vars
|
||||
- `logout(token)` — clears session
|
||||
|
||||
Implementation: in-memory session Map or signed cookie. No DB tables for users/sessions.
|
||||
|
||||
Remove:
|
||||
- `users` table
|
||||
- `sessions` table
|
||||
- `/api/auth/setup` endpoint
|
||||
- `bcrypt` dependency
|
||||
|
||||
`.env` config:
|
||||
```
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme
|
||||
```
|
||||
|
||||
## 3. Database Cleanup
|
||||
|
||||
**Keep SQLite (`better-sqlite3`), clean schema, add seed script.**
|
||||
|
||||
Single table:
|
||||
```sql
|
||||
CREATE TABLE quotes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
postal_code TEXT NOT NULL,
|
||||
address TEXT,
|
||||
area REAL NOT NULL,
|
||||
height REAL NOT NULL,
|
||||
include_floor_heating INTEGER DEFAULT 1,
|
||||
flooring_type TEXT DEFAULT 'STANDARD',
|
||||
customer_name TEXT NOT NULL,
|
||||
customer_email TEXT NOT NULL,
|
||||
customer_phone TEXT NOT NULL,
|
||||
remarks TEXT,
|
||||
total_excl_vat REAL NOT NULL,
|
||||
total_incl_vat REAL NOT NULL,
|
||||
status TEXT DEFAULT 'new',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
email_opened_at TEXT
|
||||
);
|
||||
```
|
||||
|
||||
- Quote IDs start at 1000
|
||||
- No migration hacks (clean CREATE TABLE only)
|
||||
- `lib/db.ts` opens DB and exports connection — no schema creation on import
|
||||
|
||||
**Seed script** (`npm run setup`):
|
||||
- Creates `data/` directory
|
||||
- Creates quotes table
|
||||
- Sets auto-increment start to 1000
|
||||
- Prints success message
|
||||
|
||||
## 4. Security Hardening
|
||||
|
||||
- Remove all hardcoded credentials from codebase
|
||||
- Remove `docs/mail.txt` from delivery
|
||||
- Rate limiting on `/api/quote-request` and `/api/auth/login` (simple in-memory counter)
|
||||
- Validate/sanitize all inputs server-side
|
||||
- Cookies: `httpOnly`, `secure`, `sameSite: strict`
|
||||
- Remove `/api/auth/setup` endpoint
|
||||
|
||||
## 5. Debug Stripping
|
||||
|
||||
- Remove all `console.log` statements
|
||||
- Remove "Admin Mode" toggle from public pages
|
||||
- Remove dev-only comments and TODO markers
|
||||
|
||||
## 6. Distance Calculation
|
||||
|
||||
Unchanged — dual approach:
|
||||
- **Default:** Hardcoded postal code lookup table (no API key needed)
|
||||
- **Optional:** OpenRouteService API for precise driving distances (free tier, 2000 req/day)
|
||||
|
||||
## 7. Build & Zip Delivery
|
||||
|
||||
**`scripts/build-release.sh`:**
|
||||
|
||||
1. Create temp directory `/tmp/foamking-release-<timestamp>/`
|
||||
2. Copy shipping files:
|
||||
- `app/`, `components/`, `lib/`, `public/`, `scripts/setup.js`
|
||||
- `middleware.ts`
|
||||
- `package.json`, `tsconfig.json`, `tailwind.config.ts`
|
||||
- `postcss.config.mjs`, `eslint.config.mjs`, `next.config.ts`
|
||||
- `.env.example`, `OPSÆTNING.md`, `SETUP.md`
|
||||
3. Run `npm install` + `npm run build` to verify compilation
|
||||
4. Remove `node_modules/` and `.next/` from temp dir
|
||||
5. Zip as `foamking-beregner-<date>.zip`
|
||||
|
||||
**Excluded from zip:**
|
||||
- `.git/`, `docs/`, `node_modules/`, `.next/`, `data/`
|
||||
- `.env.local`, `CLAUDE.md`, `README.md`, `package-lock.json`
|
||||
|
||||
## 8. Setup Documentation
|
||||
|
||||
**`OPSÆTNING.md`** (Danish) + **`SETUP.md`** (English) covering:
|
||||
|
||||
1. Prerequisites (Node.js, npm)
|
||||
2. Install dependencies (`npm install`)
|
||||
3. Configure `.env.local`:
|
||||
- `ADMIN_EMAIL` / `ADMIN_PASSWORD` (required)
|
||||
- SMTP: Office 365 — `smtp.office365.com:587` with STARTTLS, enable SMTP AUTH for `tilbud@foamking.dk` in Exchange Admin Center (required)
|
||||
- `OPENROUTE_API_KEY` (optional — for precise distance calculation)
|
||||
4. Run `npm run setup` (creates database)
|
||||
5. Build and start (`npm run build && npm start`)
|
||||
6. Route overview: `/` = public calculator, `/intern` = admin dashboard
|
||||
7. Auth customization: how to replace `checkAuth()` in `lib/auth.ts` for JWT/OAuth
|
||||
1031
docs/plans/2026-02-22-delivery-cleanup.md
Normal file
1031
docs/plans/2026-02-22-delivery-cleanup.md
Normal file
File diff suppressed because it is too large
Load diff
125
lib/auth.ts
125
lib/auth.ts
|
|
@ -1,27 +1,10 @@
|
|||
import bcrypt from "bcrypt"
|
||||
import { cookies } from "next/headers"
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
getSession,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
createUser as dbCreateUser,
|
||||
cleanExpiredSessions,
|
||||
type User,
|
||||
} from "./db"
|
||||
|
||||
const SALT_ROUNDS = 10
|
||||
const SESSION_COOKIE_NAME = "session"
|
||||
const SESSION_DURATION_DAYS = 7
|
||||
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash)
|
||||
}
|
||||
// In-memory session store. Replace this with your own session/token system.
|
||||
const sessions = new Map<string, { email: string; expiresAt: Date }>()
|
||||
|
||||
function generateSessionId(): string {
|
||||
const bytes = new Uint8Array(32)
|
||||
|
|
@ -31,86 +14,78 @@ function generateSessionId(): string {
|
|||
.join("")
|
||||
}
|
||||
|
||||
// ─── AUTH INTERFACE ────────────────────────────────────────────────
|
||||
// To integrate your own auth (JWT, OAuth, etc.), replace these functions.
|
||||
// The rest of the application only calls checkAuth(), login(), and logout().
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if the current request is authenticated.
|
||||
* Replace this function to integrate JWT, OAuth, or any other auth system.
|
||||
*/
|
||||
export async function checkAuth(): Promise<{ authenticated: boolean; email?: string }> {
|
||||
const cookieStore = await cookies()
|
||||
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value
|
||||
|
||||
if (!sessionId) return { authenticated: false }
|
||||
|
||||
const session = sessions.get(sessionId)
|
||||
if (!session) return { authenticated: false }
|
||||
|
||||
if (session.expiresAt < new Date()) {
|
||||
sessions.delete(sessionId)
|
||||
return { authenticated: false }
|
||||
}
|
||||
|
||||
return { authenticated: true, email: session.email }
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with email and password.
|
||||
* Default: compares against ADMIN_EMAIL/ADMIN_PASSWORD env vars.
|
||||
* Replace this function to integrate your own auth system.
|
||||
*/
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ success: true; user: User } | { success: false; error: string }> {
|
||||
const user = getUserByEmail(email)
|
||||
): Promise<{ success: true } | { success: false; error: string }> {
|
||||
const adminEmail = process.env.ADMIN_EMAIL
|
||||
const adminPassword = process.env.ADMIN_PASSWORD
|
||||
|
||||
if (!user) {
|
||||
if (!adminEmail || !adminPassword) {
|
||||
return { success: false, error: "Admin-konfiguration mangler (ADMIN_EMAIL/ADMIN_PASSWORD)" }
|
||||
}
|
||||
|
||||
if (email !== adminEmail || password !== adminPassword) {
|
||||
return { success: false, error: "Forkert email eller adgangskode" }
|
||||
}
|
||||
|
||||
const validPassword = await verifyPassword(password, user.passwordHash)
|
||||
if (!validPassword) {
|
||||
return { success: false, error: "Forkert email eller adgangskode" }
|
||||
}
|
||||
|
||||
// Clean up old sessions periodically
|
||||
cleanExpiredSessions()
|
||||
|
||||
// Create new session
|
||||
const sessionId = generateSessionId()
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + SESSION_DURATION_DAYS)
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS)
|
||||
|
||||
createSession(sessionId, user.id, expiresAt)
|
||||
sessions.set(sessionId, { email, expiresAt })
|
||||
|
||||
// Set cookie
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
sameSite: "strict",
|
||||
expires: expiresAt,
|
||||
path: "/",
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: { id: user.id, email: user.email, name: user.name, createdAt: user.createdAt },
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out the current session.
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
const cookieStore = await cookies()
|
||||
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value
|
||||
|
||||
if (sessionId) {
|
||||
deleteSession(sessionId)
|
||||
sessions.delete(sessionId)
|
||||
cookieStore.delete(SESSION_COOKIE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
const cookieStore = await cookies()
|
||||
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value
|
||||
|
||||
if (!sessionId) return null
|
||||
|
||||
const session = getSession(sessionId)
|
||||
if (!session) return null
|
||||
|
||||
// Check if session is expired
|
||||
if (session.expiresAt < new Date()) {
|
||||
deleteSession(sessionId)
|
||||
return null
|
||||
}
|
||||
|
||||
return getUserById(session.userId)
|
||||
}
|
||||
|
||||
export async function isAuthenticated(): Promise<boolean> {
|
||||
const user = await getCurrentUser()
|
||||
return user !== null
|
||||
}
|
||||
|
||||
// Helper to create users (run from CLI or seed script)
|
||||
export async function createUserWithPassword(
|
||||
email: string,
|
||||
password: string,
|
||||
name: string
|
||||
): Promise<User> {
|
||||
const passwordHash = await hashPassword(password)
|
||||
return dbCreateUser(email, passwordHash, name)
|
||||
}
|
||||
|
|
|
|||
174
lib/db.ts
174
lib/db.ts
|
|
@ -1,11 +1,10 @@
|
|||
import Database from "better-sqlite3"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
|
||||
// Database file stored in project root
|
||||
const DB_PATH = path.join(process.cwd(), "data", "quotes.db")
|
||||
|
||||
// Ensure data directory exists
|
||||
import fs from "fs"
|
||||
const dataDir = path.dirname(DB_PATH)
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true })
|
||||
|
|
@ -13,7 +12,10 @@ if (!fs.existsSync(dataDir)) {
|
|||
|
||||
const db = new Database(DB_PATH)
|
||||
|
||||
// Initialize database schema
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
db.pragma("journal_mode = WAL")
|
||||
|
||||
// Create schema if not exists
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS quotes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -21,65 +23,38 @@ db.exec(`
|
|||
address TEXT,
|
||||
area REAL NOT NULL,
|
||||
height REAL NOT NULL,
|
||||
include_floor_heating INTEGER NOT NULL DEFAULT 1,
|
||||
flooring_type TEXT NOT NULL DEFAULT 'STANDARD',
|
||||
include_floor_heating INTEGER DEFAULT 1,
|
||||
flooring_type TEXT DEFAULT 'STANDARD',
|
||||
customer_name TEXT NOT NULL,
|
||||
customer_email TEXT NOT NULL,
|
||||
customer_phone TEXT NOT NULL,
|
||||
remarks TEXT,
|
||||
total_excl_vat REAL,
|
||||
total_incl_vat REAL,
|
||||
status TEXT NOT NULL DEFAULT 'new',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
total_excl_vat REAL NOT NULL,
|
||||
total_incl_vat REAL NOT NULL,
|
||||
status TEXT DEFAULT 'new',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
email_opened_at TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
// Migration: Add status column if it doesn't exist
|
||||
try {
|
||||
db.exec("ALTER TABLE quotes ADD COLUMN status TEXT NOT NULL DEFAULT 'new'")
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Migration: Add email_opened_at column for tracking
|
||||
try {
|
||||
db.exec("ALTER TABLE quotes ADD COLUMN email_opened_at TEXT")
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Users table for authentication
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
// Sessions table for login sessions
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`)
|
||||
|
||||
// Start IDs at 1000
|
||||
// Ensure quote IDs start at 1000
|
||||
const countResult = db.prepare("SELECT COUNT(*) as count FROM quotes").get() as { count: number }
|
||||
if (countResult.count === 0) {
|
||||
try {
|
||||
const seqExists = db.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'quotes'").get()
|
||||
if (!seqExists) {
|
||||
db.exec(
|
||||
"INSERT INTO quotes (id, postal_code, area, height, customer_name, customer_email, customer_phone) VALUES (999, '0000', 0, 0, 'init', 'init', 'init')"
|
||||
"INSERT INTO quotes (id, postal_code, area, height, customer_name, customer_email, customer_phone, total_excl_vat, total_incl_vat) VALUES (999, '0000', 0, 0, 'init', 'init', 'init', 0, 0)"
|
||||
)
|
||||
db.exec("DELETE FROM quotes WHERE id = 999")
|
||||
// Reset autoincrement to start at 1000
|
||||
db.exec("UPDATE sqlite_sequence SET seq = 999 WHERE name = 'quotes'")
|
||||
}
|
||||
} catch {
|
||||
// sqlite_sequence may not exist yet on fresh DB — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface QuoteInput {
|
||||
postalCode: string
|
||||
address?: string
|
||||
|
|
@ -91,8 +66,8 @@ export interface QuoteInput {
|
|||
customerEmail: string
|
||||
customerPhone: string
|
||||
remarks?: string
|
||||
totalExclVat?: number
|
||||
totalInclVat?: number
|
||||
totalExclVat: number
|
||||
totalInclVat: number
|
||||
}
|
||||
|
||||
export type QuoteStatus = "new" | "contacted" | "accepted" | "rejected"
|
||||
|
|
@ -104,6 +79,8 @@ export interface StoredQuote extends QuoteInput {
|
|||
emailOpenedAt: string | null
|
||||
}
|
||||
|
||||
// ─── Queries ───────────────────────────────────────────────────────
|
||||
|
||||
export function saveQuote(quote: QuoteInput): { id: number; slug: string } {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO quotes (
|
||||
|
|
@ -123,8 +100,8 @@ export function saveQuote(quote: QuoteInput): { id: number; slug: string } {
|
|||
quote.customerEmail,
|
||||
quote.customerPhone,
|
||||
quote.remarks || null,
|
||||
quote.totalExclVat || null,
|
||||
quote.totalInclVat || null
|
||||
quote.totalExclVat,
|
||||
quote.totalInclVat
|
||||
)
|
||||
|
||||
const id = result.lastInsertRowid as number
|
||||
|
|
@ -140,39 +117,32 @@ export function getQuoteBySlug(slug: string): StoredQuote | null {
|
|||
const [, postalCode, idStr] = match
|
||||
const id = parseInt(idStr, 10)
|
||||
|
||||
const stmt = db.prepare("SELECT * FROM quotes WHERE id = ? AND postal_code = ?")
|
||||
const row = stmt.get(id, postalCode) as any
|
||||
|
||||
const row = db.prepare("SELECT * FROM quotes WHERE id = ? AND postal_code = ?").get(id, postalCode) as any
|
||||
if (!row) return null
|
||||
|
||||
return rowToQuote(row)
|
||||
}
|
||||
|
||||
export function getQuoteById(id: number): StoredQuote | null {
|
||||
const stmt = db.prepare("SELECT * FROM quotes WHERE id = ?")
|
||||
const row = stmt.get(id) as any
|
||||
const row = db.prepare("SELECT * FROM quotes WHERE id = ?").get(id) as any
|
||||
if (!row) return null
|
||||
return rowToQuote(row)
|
||||
}
|
||||
|
||||
export function getAllQuotes(): StoredQuote[] {
|
||||
const stmt = db.prepare("SELECT * FROM quotes ORDER BY id DESC")
|
||||
const rows = stmt.all() as any[]
|
||||
const rows = db.prepare("SELECT * FROM quotes ORDER BY id DESC").all() as any[]
|
||||
return rows.map(rowToQuote)
|
||||
}
|
||||
|
||||
export function updateQuoteStatus(id: number, status: QuoteStatus): boolean {
|
||||
const stmt = db.prepare("UPDATE quotes SET status = ? WHERE id = ?")
|
||||
const result = stmt.run(status, id)
|
||||
const result = db.prepare("UPDATE quotes SET status = ? WHERE id = ?").run(status, id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function markEmailOpened(id: number): boolean {
|
||||
// Only update if not already set (first open)
|
||||
const stmt = db.prepare(
|
||||
const result = db.prepare(
|
||||
"UPDATE quotes SET email_opened_at = ? WHERE id = ? AND email_opened_at IS NULL"
|
||||
)
|
||||
const result = stmt.run(new Date().toISOString(), id)
|
||||
).run(new Date().toISOString(), id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
|
|
@ -196,77 +166,3 @@ function rowToQuote(row: any): StoredQuote {
|
|||
emailOpenedAt: row.email_opened_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
// User management
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface UserWithPassword extends User {
|
||||
passwordHash: string
|
||||
}
|
||||
|
||||
export function createUser(email: string, passwordHash: string, name: string): User {
|
||||
const stmt = db.prepare("INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)")
|
||||
const result = stmt.run(email, passwordHash, name)
|
||||
return {
|
||||
id: result.lastInsertRowid as number,
|
||||
email,
|
||||
name,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserByEmail(email: string): UserWithPassword | null {
|
||||
const stmt = db.prepare("SELECT * FROM users WHERE email = ?")
|
||||
const row = stmt.get(email) as any
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
passwordHash: row.password_hash,
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserById(id: number): User | null {
|
||||
const stmt = db.prepare("SELECT id, email, name, created_at FROM users WHERE id = ?")
|
||||
const row = stmt.get(id) as any
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// Session management
|
||||
export function createSession(sessionId: string, userId: number, expiresAt: Date): void {
|
||||
const stmt = db.prepare("INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)")
|
||||
stmt.run(sessionId, userId, expiresAt.toISOString())
|
||||
}
|
||||
|
||||
export function getSession(sessionId: string): { userId: number; expiresAt: Date } | null {
|
||||
const stmt = db.prepare("SELECT user_id, expires_at FROM sessions WHERE id = ?")
|
||||
const row = stmt.get(sessionId) as any
|
||||
if (!row) return null
|
||||
return {
|
||||
userId: row.user_id,
|
||||
expiresAt: new Date(row.expires_at),
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSession(sessionId: string): void {
|
||||
const stmt = db.prepare("DELETE FROM sessions WHERE id = ?")
|
||||
stmt.run(sessionId)
|
||||
}
|
||||
|
||||
export function cleanExpiredSessions(): void {
|
||||
const stmt = db.prepare("DELETE FROM sessions WHERE expires_at < ?")
|
||||
stmt.run(new Date().toISOString())
|
||||
}
|
||||
|
|
|
|||
41
lib/rate-limit.ts
Normal file
41
lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Simple in-memory rate limiter. No external dependencies.
|
||||
// Tracks requests per IP with a sliding window.
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>()
|
||||
|
||||
// Clean up expired entries periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.resetAt < now) store.delete(key)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
/**
|
||||
* Check if a request should be rate-limited.
|
||||
* @param key - Unique identifier (e.g. IP address)
|
||||
* @param limit - Max requests per window
|
||||
* @param windowMs - Time window in milliseconds
|
||||
* @returns true if the request is allowed, false if rate-limited
|
||||
*/
|
||||
export function rateLimit(key: string, limit: number, windowMs: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = store.get(key)
|
||||
|
||||
if (!entry || entry.resetAt < now) {
|
||||
store.set(key, { count: 1, resetAt: now + windowMs })
|
||||
return true
|
||||
}
|
||||
|
||||
if (entry.count >= limit) {
|
||||
return false
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,34 +1,26 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
|
||||
// Routes that require authentication
|
||||
const protectedPaths = ["/dashboard", "/historik", "/admin"]
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// Check if path requires authentication
|
||||
const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path))
|
||||
|
||||
if (!isProtectedPath) {
|
||||
// Don't protect the login page
|
||||
if (pathname === "/intern/login") {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Check for session cookie
|
||||
// Check for session cookie on all /intern/* routes
|
||||
const sessionCookie = request.cookies.get("session")
|
||||
|
||||
if (!sessionCookie?.value) {
|
||||
// Redirect to login
|
||||
const loginUrl = new URL("/login", request.url)
|
||||
const loginUrl = new URL("/intern/login", request.url)
|
||||
loginUrl.searchParams.set("redirect", pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
// Cookie exists - let the page validate the session
|
||||
// (Session validation happens server-side in the page)
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/dashboard/:path*", "/historik/:path*", "/admin/:path*"],
|
||||
matcher: ["/intern/:path*"],
|
||||
}
|
||||
|
|
|
|||
46
package-lock.json
generated
46
package-lock.json
generated
|
|
@ -16,7 +16,6 @@
|
|||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.6.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
|
|
@ -31,7 +30,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.3.3",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "25.0.5",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
|
|
@ -3905,16 +3903,6 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
|
|
@ -4924,20 +4912,6 @@
|
|||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.6.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.0.tgz",
|
||||
|
|
@ -7705,26 +7679,6 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
"format:check": "prettier --check .",
|
||||
"setup": "node scripts/setup.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
|
|
@ -20,7 +21,6 @@
|
|||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.6.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
|
|
@ -35,7 +35,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.3.3",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "25.0.5",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
|
|
|
|||
69
scripts/build-release.sh
Executable file
69
scripts/build-release.sh
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
RELEASE_DIR="/tmp/foamking-release-${TIMESTAMP}"
|
||||
ZIP_NAME="foamking-beregner-${DATE}.zip"
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
echo "FoamKing Release Builder"
|
||||
echo "========================"
|
||||
echo ""
|
||||
|
||||
# Create temp directory
|
||||
mkdir -p "${RELEASE_DIR}"
|
||||
echo "Build directory: ${RELEASE_DIR}"
|
||||
|
||||
# Copy shipping files
|
||||
echo "Copying files..."
|
||||
cp -r "${PROJECT_DIR}/app" "${RELEASE_DIR}/"
|
||||
cp -r "${PROJECT_DIR}/components" "${RELEASE_DIR}/"
|
||||
cp -r "${PROJECT_DIR}/lib" "${RELEASE_DIR}/"
|
||||
cp -r "${PROJECT_DIR}/public" "${RELEASE_DIR}/"
|
||||
mkdir -p "${RELEASE_DIR}/scripts"
|
||||
cp "${PROJECT_DIR}/scripts/setup.js" "${RELEASE_DIR}/scripts/"
|
||||
cp "${PROJECT_DIR}/middleware.ts" "${RELEASE_DIR}/"
|
||||
cp "${PROJECT_DIR}/package.json" "${RELEASE_DIR}/"
|
||||
cp "${PROJECT_DIR}/tsconfig.json" "${RELEASE_DIR}/"
|
||||
cp "${PROJECT_DIR}/tailwind.config.ts" "${RELEASE_DIR}/"
|
||||
cp "${PROJECT_DIR}/postcss.config.mjs" "${RELEASE_DIR}/"
|
||||
cp "${PROJECT_DIR}/eslint.config.mjs" "${RELEASE_DIR}/"
|
||||
cp "${PROJECT_DIR}/next.config.ts" "${RELEASE_DIR}/"
|
||||
cp "${PROJECT_DIR}/.env.example" "${RELEASE_DIR}/"
|
||||
cp "${PROJECT_DIR}/SETUP.md" "${RELEASE_DIR}/"
|
||||
cp "${PROJECT_DIR}/OPSÆTNING.md" "${RELEASE_DIR}/"
|
||||
|
||||
# Copy .prettierrc if it exists
|
||||
[ -f "${PROJECT_DIR}/.prettierrc.json" ] && cp "${PROJECT_DIR}/.prettierrc.json" "${RELEASE_DIR}/"
|
||||
|
||||
# Verify build
|
||||
echo ""
|
||||
echo "Installing dependencies..."
|
||||
cd "${RELEASE_DIR}"
|
||||
npm install 2>&1 | tail -1
|
||||
|
||||
echo "Verifying build..."
|
||||
npm run build 2>&1 | tail -3
|
||||
|
||||
# Clean up build artifacts
|
||||
echo ""
|
||||
echo "Cleaning up..."
|
||||
rm -rf "${RELEASE_DIR}/node_modules"
|
||||
rm -rf "${RELEASE_DIR}/.next"
|
||||
|
||||
# Create archive
|
||||
cd /tmp
|
||||
if command -v zip &> /dev/null; then
|
||||
zip -rq "${ZIP_NAME}" "foamking-release-${TIMESTAMP}"
|
||||
ARCHIVE="/tmp/${ZIP_NAME}"
|
||||
else
|
||||
TAR_NAME="foamking-beregner-${DATE}.tar.gz"
|
||||
tar -czf "${TAR_NAME}" "foamking-release-${TIMESTAMP}"
|
||||
ARCHIVE="/tmp/${TAR_NAME}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Release built successfully!"
|
||||
echo "Archive: ${ARCHIVE}"
|
||||
echo "Size: $(du -h "${ARCHIVE}" | cut -f1)"
|
||||
65
scripts/setup.js
Normal file
65
scripts/setup.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const Database = require("better-sqlite3")
|
||||
const path = require("path")
|
||||
const fs = require("fs")
|
||||
|
||||
const DB_PATH = path.join(process.cwd(), "data", "quotes.db")
|
||||
const dataDir = path.dirname(DB_PATH)
|
||||
|
||||
console.log("FoamKing Prisberegner - Database Setup")
|
||||
console.log("======================================\n")
|
||||
|
||||
// Create data directory
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true })
|
||||
console.log("Created data/ directory")
|
||||
}
|
||||
|
||||
// Check if DB already exists
|
||||
const dbExists = fs.existsSync(DB_PATH)
|
||||
if (dbExists) {
|
||||
console.log("Database already exists at", DB_PATH)
|
||||
console.log("Delete data/quotes.db to start fresh.\n")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH)
|
||||
|
||||
db.pragma("journal_mode = WAL")
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE quotes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
postal_code TEXT NOT NULL,
|
||||
address TEXT,
|
||||
area REAL NOT NULL,
|
||||
height REAL NOT NULL,
|
||||
include_floor_heating INTEGER DEFAULT 1,
|
||||
flooring_type TEXT DEFAULT 'STANDARD',
|
||||
customer_name TEXT NOT NULL,
|
||||
customer_email TEXT NOT NULL,
|
||||
customer_phone TEXT NOT NULL,
|
||||
remarks TEXT,
|
||||
total_excl_vat REAL NOT NULL,
|
||||
total_incl_vat REAL NOT NULL,
|
||||
status TEXT DEFAULT 'new',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
email_opened_at TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
// Set auto-increment to start at 1000
|
||||
db.exec(
|
||||
"INSERT INTO quotes (id, postal_code, area, height, customer_name, customer_email, customer_phone, total_excl_vat, total_incl_vat) VALUES (999, '0000', 0, 0, 'init', 'init', 'init', 0, 0)"
|
||||
)
|
||||
db.exec("DELETE FROM quotes WHERE id = 999")
|
||||
|
||||
db.close()
|
||||
|
||||
console.log("Database created at", DB_PATH)
|
||||
console.log("Quote IDs will start at 1000.\n")
|
||||
console.log("Next steps:")
|
||||
console.log(" 1. Configure .env.local (see SETUP.md / OPSÆTNING.md)")
|
||||
console.log(" 2. npm run build")
|
||||
console.log(" 3. npm start")
|
||||
Loading…
Add table
Reference in a new issue