ba048a15-58c0-44e5-8a0c-3cb9d71bd91d
This commit is contained in:
1 parent
72ec15c2ab
commit
d492182b4e
7 files changed
+275
-34
No files matched your search
Binary file not shown.
@@ -19,7 +19,12 @@ model User {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String
|
name String
|
||||||
role String @default("owner") // owner|admin|sales|pm|developer|finance|readonly|client|member
|
role String @default("owner") // owner|admin|sales|pm|developer|finance|readonly|client|member|reseller
|
||||||
|
// --- Account → Company link ---
|
||||||
|
// For reseller/client accounts: the Company this user manages. Null for
|
||||||
|
// the platform owner + internal staff. Set at registration.
|
||||||
|
companyId String?
|
||||||
|
company Company? @relation(fields: [companyId], references: [id])
|
||||||
// --- Authentication ---
|
// --- Authentication ---
|
||||||
// scrypt-based password hash (format: "salt:hash", both hex). Null = seeded
|
// scrypt-based password hash (format: "salt:hash", both hex). Null = seeded
|
||||||
// user that hasn't claimed their account via registration yet.
|
// user that hasn't claimed their account via registration yet.
|
||||||
@@ -143,6 +148,7 @@ model Company {
|
|||||||
agencyApplication AgencyApplication?
|
agencyApplication AgencyApplication?
|
||||||
instances Instance[]
|
instances Instance[]
|
||||||
repoAssignments RepoAssignment[]
|
repoAssignments RepoAssignment[]
|
||||||
|
users User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Person {
|
model Person {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
otpExpiry,
|
otpExpiry,
|
||||||
otpMaxAttempts,
|
otpMaxAttempts,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
|
import { ensureAdminUser } from '@/lib/ensure-admin'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -35,6 +36,10 @@ const loginSchema = z.object({
|
|||||||
// exist. Rate limiting is handled by the global middleware (120 req/min/IP).
|
// exist. Rate limiting is handled by the global middleware (120 req/min/IP).
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
|
// Ensure the admin account exists (env-managed) so it's always loggable,
|
||||||
|
// even on a fresh deploy with an empty database.
|
||||||
|
await ensureAdminUser()
|
||||||
|
|
||||||
const parsed = loginSchema.safeParse(await req.json())
|
const parsed = loginSchema.safeParse(await req.json())
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: 'Ungültige Anmeldedaten' }, { status: 400 })
|
return NextResponse.json({ error: 'Ungültige Anmeldedaten' }, { status: 400 })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { hashPassword, validatePasswordStrength } from '@/lib/auth'
|
import { hashPassword, validatePasswordStrength } from '@/lib/auth'
|
||||||
|
import { ensureAdminUser, isAdminEmail } from '@/lib/ensure-admin'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -9,38 +10,57 @@ const registerSchema = z.object({
|
|||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
email: z.string().email().max(200).toLowerCase(),
|
email: z.string().email().max(200).toLowerCase(),
|
||||||
password: z.string().min(8).max(200),
|
password: z.string().min(8).max(200),
|
||||||
|
accountType: z.enum(['user', 'reseller']).default('user'),
|
||||||
|
// Reseller-only: the company they're registering as. Required when
|
||||||
|
// accountType='reseller'. For 'user' this is ignored.
|
||||||
|
companyName: z.string().min(1).max(200).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// POST /api/auth/register
|
// POST /api/auth/register
|
||||||
//
|
//
|
||||||
// Email + password registration. Creates (or claims) a user account.
|
// Self-service registration for normal users + resellers. The platform owner
|
||||||
|
// is NOT created here — the owner account is bootstrapped from env
|
||||||
|
// (ADMIN_EMAIL + ADMIN_PASSWORD) by ensureAdminUser(), which we call first so
|
||||||
|
// the admin email is reserved before accepting signups.
|
||||||
//
|
//
|
||||||
// Claim flow: if the email already belongs to a seeded User with no
|
// accountType:
|
||||||
// passwordHash (e.g. demo fixtures), registration sets the password on
|
// 'user' → role='client', no company created (internal/staff-like).
|
||||||
// that existing user instead of failing — this lets seeded team members
|
// 'reseller' → role='reseller' + a new Company with isAgency=true,
|
||||||
// claim their accounts.
|
// agencyStatus='pending' + an AgencyApplication record.
|
||||||
//
|
// Admin must approve before the reseller can onboard clients.
|
||||||
// Role assignment:
|
|
||||||
// - First account with a password on the system → owner (bootstrap)
|
|
||||||
// - Everyone else → 'member' (default staff role; can be elevated by an
|
|
||||||
// owner in settings later).
|
|
||||||
//
|
//
|
||||||
// Email verification: a verification code is emailed if SMTP is configured.
|
// Email verification: a verification code is emailed if SMTP is configured.
|
||||||
// Until verified, the user can still log in (verification is encouraged but
|
|
||||||
// not blocking for this single-tenant agency tool). Resend via /verify-email.
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
|
// Ensure the admin account exists so the reserved email is taken.
|
||||||
|
await ensureAdminUser()
|
||||||
|
|
||||||
const parsed = registerSchema.safeParse(await req.json())
|
const parsed = registerSchema.safeParse(await req.json())
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: 'Angaben ungültig', details: parsed.error.flatten() }, { status: 400 })
|
return NextResponse.json({ error: 'Angaben ungültig', details: parsed.error.flatten() }, { status: 400 })
|
||||||
}
|
}
|
||||||
const { name, email, password } = parsed.data
|
const { name, email, password, accountType, companyName } = parsed.data
|
||||||
|
|
||||||
|
// The admin email is reserved — never registerable here.
|
||||||
|
if (isAdminEmail(email)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Diese E-Mail ist reserviert. Bitte melden Sie sich direkt an.' },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const pwError = validatePasswordStrength(password)
|
const pwError = validatePasswordStrength(password)
|
||||||
if (pwError) {
|
if (pwError) {
|
||||||
return NextResponse.json({ error: pwError }, { status: 400 })
|
return NextResponse.json({ error: pwError }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accountType === 'reseller' && !companyName?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Für Reseller-Registrierung ist ein Firmenname erforderlich.' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Check for an existing user with this email that already has a password
|
// Check for an existing user with this email that already has a password
|
||||||
// (claimed) — that's a real conflict.
|
// (claimed) — that's a real conflict.
|
||||||
const existing = await db.user.findUnique({ where: { email } })
|
const existing = await db.user.findUnique({ where: { email } })
|
||||||
@@ -52,21 +72,55 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = hashPassword(password)
|
const passwordHash = hashPassword(password)
|
||||||
|
const role = accountType === 'reseller' ? 'reseller' : 'client'
|
||||||
|
|
||||||
// Decide the role: first account with a password becomes the owner.
|
// For resellers: create the Company + AgencyApplication up front, then link
|
||||||
const usersWithPassword = await db.user.count({ where: { NOT: { passwordHash: null } } })
|
// the new User to it. We do this in a transaction so a partial failure
|
||||||
const role = usersWithPassword === 0 ? 'owner' : 'member'
|
// doesn't leave orphan records.
|
||||||
|
let companyId: string | null = null
|
||||||
|
if (accountType === 'reseller') {
|
||||||
|
const result = await db.$transaction(async (tx) => {
|
||||||
|
const company = await tx.company.create({
|
||||||
|
data: {
|
||||||
|
name: companyName!.trim(),
|
||||||
|
email,
|
||||||
|
source: 'self-signup',
|
||||||
|
isAgency: true,
|
||||||
|
agencyStatus: 'pending',
|
||||||
|
agencyAppliedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await tx.agencyApplication.create({
|
||||||
|
data: {
|
||||||
|
companyId: company.id,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: `signup:${email}`,
|
||||||
|
action: 'reseller.signup',
|
||||||
|
entity: 'Company',
|
||||||
|
entityId: company.id,
|
||||||
|
after: JSON.stringify({ name: company.name, email, status: 'pending' }),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return company.id
|
||||||
|
})
|
||||||
|
companyId = result
|
||||||
|
}
|
||||||
|
|
||||||
let user
|
let user
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Claim the seeded account (keep id, role may be elevated if first).
|
// Claim a seeded (password-less) user — keep id, set role + password.
|
||||||
user = await db.user.update({
|
user = await db.user.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { name, passwordHash, role: usersWithPassword === 0 ? 'owner' : existing.role },
|
data: { name, passwordHash, role, companyId },
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
user = await db.user.create({
|
user = await db.user.create({
|
||||||
data: { name, email, passwordHash, role },
|
data: { name, email, passwordHash, role, companyId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,15 +143,28 @@ export async function POST(req: Request) {
|
|||||||
html: `<h2>Willkommen bei AOS!</h2><p>Ihr Bestätigungscode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p><p>Mit freundlichen Grüßen<br>DFM Solutions</p>`,
|
html: `<h2>Willkommen bei AOS!</h2><p>Ihr Bestätigungscode lautet:</p><p style="font-size:28px;font-weight:bold;letter-spacing:8px;text-align:center;background:#f5f5f5;padding:16px;border-radius:8px;">${code}</p><p>Der Code ist 5 Minuten gültig.</p><p>Mit freundlichen Grüßen<br>DFM Solutions</p>`,
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
// Don't auto-login after registration: require explicit login so the
|
// Notify the platform owner about new reseller signups (best-effort).
|
||||||
// user learns the flow (and enters 2FA if they later enable it).
|
if (accountType === 'reseller') {
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL
|
||||||
|
if (adminEmail) {
|
||||||
|
await sendEmail({
|
||||||
|
to: adminEmail,
|
||||||
|
subject: `Neue Reseller-Bewerbung — ${companyName}`,
|
||||||
|
text: `Eine neue Reseller-Bewerbung ist eingegangen.\n\nFirma: ${companyName}\nE-Mail: ${email}\n\nBitte im Admin-Bereich prüfen und genehmigen.`,
|
||||||
|
html: `<p>Eine neue Reseller-Bewerbung ist eingegangen.</p><p><strong>Firma:</strong> ${companyName}<br><strong>E-Mail:</strong> ${email}</p><p>Bitte im Admin-Bereich prüfen und genehmigen.</p>`,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
message: 'Konto erstellt. Bitte melden Sie sich an.',
|
message: accountType === 'reseller'
|
||||||
|
? 'Reseller-Konto erstellt. Nach Prüfung durch den Administrator erhalten Sie Zugang.'
|
||||||
|
: 'Konto erstellt. Bitte melden Sie sich an.',
|
||||||
role,
|
role,
|
||||||
|
accountType,
|
||||||
emailVerificationSent: true,
|
emailVerificationSent: true,
|
||||||
// If this is the very first owner, flag it so the UI can show guidance.
|
...(accountType === 'reseller' ? { resellerStatus: 'pending' } : {}),
|
||||||
isFirstOwner: role === 'owner',
|
|
||||||
}, { status: 201 })
|
}, { status: 201 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import Link from 'next/link'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Loader2, Lock, Mail, User, ShieldCheck } from 'lucide-react'
|
import { Loader2, Lock, Mail, User, ShieldCheck, Building2, Briefcase } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type AccountType = 'user' | 'reseller'
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const [accountType, setAccountType] = useState<AccountType>('user')
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
|
const [companyName, setCompanyName] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [confirm, setConfirm] = useState('')
|
const [confirm, setConfirm] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -27,6 +32,10 @@ export default function RegisterPage() {
|
|||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!name || !email || !password) return
|
if (!name || !email || !password) return
|
||||||
|
if (accountType === 'reseller' && !companyName.trim()) {
|
||||||
|
setError('Für Reseller-Registrierung ist ein Firmenname erforderlich.')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (password !== confirm) {
|
if (password !== confirm) {
|
||||||
setError('Passwörter stimmen nicht überein.')
|
setError('Passwörter stimmen nicht überein.')
|
||||||
return
|
return
|
||||||
@@ -37,7 +46,13 @@ export default function RegisterPage() {
|
|||||||
const res = await fetch('/api/auth/register', {
|
const res = await fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ name, email: email.toLowerCase(), password }),
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
password,
|
||||||
|
accountType,
|
||||||
|
...(accountType === 'reseller' ? { companyName: companyName.trim() } : {}),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -67,9 +82,68 @@ export default function RegisterPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Account type selector */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAccountType('user')}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-1.5 rounded-lg border p-3 text-center transition',
|
||||||
|
accountType === 'user'
|
||||||
|
? 'border-foreground bg-foreground/5'
|
||||||
|
: 'border-border hover:bg-muted/40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User className={cn('size-5', accountType === 'user' ? 'text-foreground' : 'text-muted-foreground')} />
|
||||||
|
<span className={cn('text-xs font-medium', accountType === 'user' ? 'text-foreground' : 'text-muted-foreground')}>
|
||||||
|
Benutzer
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAccountType('reseller')}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-1.5 rounded-lg border p-3 text-center transition',
|
||||||
|
accountType === 'reseller'
|
||||||
|
? 'border-foreground bg-foreground/5'
|
||||||
|
: 'border-border hover:bg-muted/40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Building2 className={cn('size-5', accountType === 'reseller' ? 'text-foreground' : 'text-muted-foreground')} />
|
||||||
|
<span className={cn('text-xs font-medium', accountType === 'reseller' ? 'text-foreground' : 'text-muted-foreground')}>
|
||||||
|
Reseller
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{accountType === 'reseller' && (
|
||||||
|
<p className="flex items-start gap-1.5 rounded-md border border-border bg-muted/40 p-2.5 text-[11px] text-muted-foreground">
|
||||||
|
<Briefcase className="mt-0.5 size-3 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Reseller können eigene Kunden anlegen und lizenzieren. Nach der Registrierung prüft der Administrator Ihre Bewerbung — Sie erhalten Zugang nach Freigabe.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
{accountType === 'reseller' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="companyName">Firmenname</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Building2 className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="companyName"
|
||||||
|
value={companyName}
|
||||||
|
onChange={(e) => setCompanyName(e.target.value)}
|
||||||
|
placeholder="Muster Agentur GmbH"
|
||||||
|
autoComplete="organization"
|
||||||
|
autoFocus
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">{accountType === 'reseller' ? 'Ansprechpartner' : 'Name'}</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<User className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -78,7 +152,7 @@ export default function RegisterPage() {
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Max Mustermann"
|
placeholder="Max Mustermann"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
autoFocus
|
autoFocus={accountType !== 'reseller'}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,10 +224,10 @@ export default function RegisterPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full gap-2"
|
className="w-full gap-2"
|
||||||
disabled={loading || !name || !email || !password || !confirm || !pwRules.every((r) => r.ok)}
|
disabled={loading || !name || !email || !password || !confirm || !pwRules.every((r) => r.ok) || (accountType === 'reseller' && !companyName.trim())}
|
||||||
>
|
>
|
||||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||||
Konto erstellen
|
{accountType === 'reseller' ? 'Reseller-Konto erstellen' : 'Konto erstellen'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
|
import { useFetch } from '@/hooks/use-fetch'
|
||||||
import { translate } from '@/lib/i18n'
|
import { translate } from '@/lib/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -104,6 +105,14 @@ const NAV: {
|
|||||||
export function SidebarNav() {
|
export function SidebarNav() {
|
||||||
const { view, setView, setSidebarOpen, setCommandOpen, setPortalMode, locale } = useAos()
|
const { view, setView, setSidebarOpen, setCommandOpen, setPortalMode, locale } = useAos()
|
||||||
const t = (k: string) => translate(k, locale)
|
const t = (k: string) => translate(k, locale)
|
||||||
|
const { data: meData } = useFetch<{ user: { name: string; email: string; role: string } } | null>('/api/auth/me')
|
||||||
|
const user = meData?.user
|
||||||
|
const initials = (user?.name ?? '?')
|
||||||
|
.split(' ')
|
||||||
|
.map((p) => p[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
||||||
@@ -202,11 +211,11 @@ export function SidebarNav() {
|
|||||||
<div className="border-t border-sidebar-border px-3 py-3">
|
<div className="border-t border-sidebar-border px-3 py-3">
|
||||||
<Button variant="ghost" className="h-auto w-full justify-start gap-3 px-2 py-2">
|
<Button variant="ghost" className="h-auto w-full justify-start gap-3 px-2 py-2">
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
|
<div className="flex size-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
|
||||||
MB
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start leading-tight">
|
<div className="flex flex-col items-start leading-tight">
|
||||||
<span className="text-sm font-medium">Max Brandt</span>
|
<span className="text-sm font-medium">{user?.name ?? '—'}</span>
|
||||||
<span className="text-[11px] text-muted-foreground">Owner</span>
|
<span className="text-[11px] text-muted-foreground capitalize">{user?.role ?? ''}</span>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { hashPassword, verifyPassword } from '@/lib/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureAdminUser — idempotent bootstrap of the platform owner account.
|
||||||
|
*
|
||||||
|
* Reads ADMIN_EMAIL + ADMIN_PASSWORD from env and makes sure a User exists
|
||||||
|
* with that email and role='owner'. If the user exists but the password
|
||||||
|
* doesn't match the env value, it's updated (so changing ADMIN_PASSWORD in
|
||||||
|
* env propagates on next boot without a manual migration).
|
||||||
|
*
|
||||||
|
* Memoized per-process: the DB work happens at most once. Safe to call from
|
||||||
|
* every login/register request — the cached promise resolves instantly after
|
||||||
|
* the first run.
|
||||||
|
*
|
||||||
|
* Called from:
|
||||||
|
* - login route (so the admin can always log in, even on a fresh deploy)
|
||||||
|
* - register route (so the admin email is reserved before accepting signups)
|
||||||
|
*/
|
||||||
|
let adminBootstrapPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
export function ensureAdminUser(): Promise<void> {
|
||||||
|
if (!adminBootstrapPromise) {
|
||||||
|
adminBootstrapPromise = doEnsureAdminUser().catch((err) => {
|
||||||
|
// Reset so the next call retries — bootstrap failure shouldn't permanently
|
||||||
|
// break login/register, but we want to know about it.
|
||||||
|
adminBootstrapPromise = null
|
||||||
|
console.error('[ensureAdminUser] failed:', err instanceof Error ? err.message : err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return adminBootstrapPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doEnsureAdminUser(): Promise<void> {
|
||||||
|
const email = process.env.ADMIN_EMAIL
|
||||||
|
const password = process.env.ADMIN_PASSWORD
|
||||||
|
if (!email || !password) {
|
||||||
|
// No admin configured — skip silently (dev convenience).
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.user.findUnique({ where: { email } })
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
// Create the admin account.
|
||||||
|
await db.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name: 'Administrator',
|
||||||
|
role: 'owner',
|
||||||
|
passwordHash: hashPassword(password),
|
||||||
|
emailVerifiedAt: new Date(), // admin is pre-verified
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(`[ensureAdminUser] created owner account for ${email}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists — make sure role + password are in sync with env.
|
||||||
|
const needsRoleUpdate = existing.role !== 'owner'
|
||||||
|
const needsPwUpdate = !existing.passwordHash || !verifyPassword(password, existing.passwordHash)
|
||||||
|
if (needsRoleUpdate || needsPwUpdate) {
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
role: 'owner',
|
||||||
|
...(needsPwUpdate ? { passwordHash: hashPassword(password) } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (needsPwUpdate) {
|
||||||
|
console.log(`[ensureAdminUser] synced password for ${email} from env`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the given email is the reserved admin email. */
|
||||||
|
export function isAdminEmail(email: string): boolean {
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL
|
||||||
|
return !!adminEmail && email.toLowerCase() === adminEmail.toLowerCase()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user