feat(role): add 'verwaltung' role with gated access to settings and tools
All checks were successful
Staging Build / build (push) Successful in 3m26s

This commit is contained in:
DanielS
2026-07-03 10:10:57 +02:00
parent ca07dd0079
commit 4f69fd7e44
7 changed files with 129 additions and 41 deletions

View File

@@ -5,6 +5,8 @@ import { useState, useEffect } from 'react';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Download, Upload, Database, AlertTriangle, Loader2 } from 'lucide-react'; import { Download, Upload, Database, AlertTriangle, Loader2 } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
export default function AdminSettings() { export default function AdminSettings() {
const [demoActive, setDemoActive] = useState(true); const [demoActive, setDemoActive] = useState(true);
@@ -13,11 +15,32 @@ export default function AdminSettings() {
const [statusMsg, setStatusMsg] = useState(''); const [statusMsg, setStatusMsg] = useState('');
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>(''); const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => { useEffect(() => {
const state = localStorage.getItem('demo_banner_disabled') !== 'true'; async function checkAccess() {
setDemoActive(state); const supabase = createClient();
}, []); const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single();
if (!userData || userData.role === 'verwaltung') {
router.push('/admin');
} else {
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
setDemoActive(state);
setLoading(false);
}
}
checkAccess();
}, [router]);
const toggleDemo = (checked: boolean) => { const toggleDemo = (checked: boolean) => {
setDemoActive(checked); setDemoActive(checked);
@@ -85,6 +108,10 @@ export default function AdminSettings() {
} }
}; };
if (loading) {
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
return ( return (
<div className="p-6 max-w-4xl mx-auto text-slate-900 dark:text-white space-y-8"> <div className="p-6 max-w-4xl mx-auto text-slate-900 dark:text-white space-y-8">
{/* Page Header */} {/* Page Header */}

View File

@@ -20,10 +20,13 @@ export default async function AdminLayout({
.eq('id', user.id) .eq('id', user.id)
.single() .single()
if (!userData || userData.role !== 'admin') { const userRole = userData?.role
if (!userData || (userRole !== 'admin' && userRole !== 'verwaltung')) {
redirect('/') redirect('/')
} }
const isVerwaltung = userRole === 'verwaltung'
return ( return (
<div className="flex min-h-screen bg-slate-50 dark:bg-[#020617] text-slate-900 dark:text-white"> <div className="flex min-h-screen bg-slate-50 dark:bg-[#020617] text-slate-900 dark:text-white">
{/* Sidebar */} {/* Sidebar */}
@@ -64,21 +67,25 @@ export default async function AdminLayout({
Firmen Firmen
</Link> </Link>
<div className="pt-2 pb-1 px-3"> {!isVerwaltung && (
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-600">Einstellungen</span> <>
</div> <div className="pt-2 pb-1 px-3">
<Link href="/admin/einstellungen" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all"> <span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-600">Einstellungen</span>
<Settings className="w-5 h-5" /> </div>
Allgemein <Link href="/admin/einstellungen" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
</Link> <Settings className="w-5 h-5" />
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all"> Allgemein
<Wrench className="w-5 h-5" /> </Link>
SMTP <Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
</Link> <Wrench className="w-5 h-5" />
<Link href="/admin/tools" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all"> SMTP
<Database className="w-5 h-5" /> </Link>
DB-Tools <Link href="/admin/tools" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
</Link> <Database className="w-5 h-5" />
DB-Tools
</Link>
</>
)}
</nav> </nav>
<div className="p-4 border-t border-slate-200 dark:border-white/5 space-y-4"> <div className="p-4 border-t border-slate-200 dark:border-white/5 space-y-4">

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
// Admin Settings page view & edit SMTP configuration and send test email
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Loader2 } from 'lucide-react';
interface Settings { interface Settings {
host: string; host: string;
@@ -17,17 +19,36 @@ export default function SettingsPage() {
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const router = useRouter(); const router = useRouter();
// Load current settings // Load current settings and verify access
useEffect(() => { useEffect(() => {
fetch('/api/admin/smtp-settings') async function checkAccessAndLoad() {
.then((res) => res.json()) const supabase = createClient();
.then((data) => { const { data: { user } } = await supabase.auth.getUser();
if (data.settings) setSettings(data.settings as Settings); if (!user) {
else setMessage('Failed to load settings'); router.push('/auth/login');
}) return;
.catch(() => setMessage('Failed to load settings')) }
.finally(() => setLoading(false)); const { data: userData } = await supabase
}, []); .from('users')
.select('role')
.eq('id', user.id)
.single();
if (!userData || userData.role === 'verwaltung') {
router.push('/admin');
return;
}
fetch('/api/admin/smtp-settings')
.then((res) => res.json())
.then((data) => {
if (data.settings) setSettings(data.settings as Settings);
else setMessage('Failed to load settings');
})
.catch(() => setMessage('Failed to load settings'))
.finally(() => setLoading(false));
}
checkAccessAndLoad();
}, [router]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target; const { name, value, type, checked } = e.target;
@@ -69,7 +90,7 @@ export default function SettingsPage() {
else setMessage(data.error || 'Failed to send test email'); else setMessage(data.error || 'Failed to send test email');
}; };
if (loading) return <div className="p-8">Loading</div>; if (loading) return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
return ( return (
<div className="max-w-2xl mx-auto p-8 bg-black/30 backdrop-blur-xl rounded-lg text-white"> <div className="max-w-2xl mx-auto p-8 bg-black/30 backdrop-blur-xl rounded-lg text-white">

View File

@@ -11,6 +11,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Loader2, RefreshCw, Wrench, Database, AlertTriangle, CheckCircle2, XCircle, Terminal, ArrowLeft } from "lucide-react"; import { Loader2, RefreshCw, Wrench, Database, AlertTriangle, CheckCircle2, XCircle, Terminal, ArrowLeft } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
interface TableStatus { interface TableStatus {
exists: boolean; exists: boolean;
@@ -34,6 +36,8 @@ export default function AdminToolsPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [logs, setLogs] = useState<string[]>([]); const [logs, setLogs] = useState<string[]>([]);
const [activeAction, setActiveAction] = useState<string | null>(null); const [activeAction, setActiveAction] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
const addLog = (message: string) => { const addLog = (message: string) => {
const timestamp = new Date().toLocaleTimeString(); const timestamp = new Date().toLocaleTimeString();
@@ -88,8 +92,31 @@ export default function AdminToolsPage() {
}; };
useEffect(() => { useEffect(() => {
loadStatus(); async function checkAccess() {
}, []); const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single();
if (!userData || userData.role === 'verwaltung') {
router.push('/admin');
return;
}
setLoading(false);
loadStatus();
}
checkAccess();
}, [router]);
if (loading) {
return <div className="min-h-screen bg-[#020617] text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
return ( return (
<div className="min-h-screen bg-[#020617] text-white px-4 py-12 relative overflow-hidden"> <div className="min-h-screen bg-[#020617] text-white px-4 py-12 relative overflow-hidden">

View File

@@ -30,7 +30,7 @@ import { Plus, Building2 } from 'lucide-react'
const userSchema = z.object({ const userSchema = z.object({
email: z.string().email('Ungültige E-Mail-Adresse'), email: z.string().email('Ungültige E-Mail-Adresse'),
role: z.enum(['admin', 'partner']), role: z.enum(['admin', 'partner', 'verwaltung']),
company_id: z.string().optional().or(z.literal('')), company_id: z.string().optional().or(z.literal('')),
first_name: z.string().optional().or(z.literal('')), first_name: z.string().optional().or(z.literal('')),
last_name: z.string().optional().or(z.literal('')), last_name: z.string().optional().or(z.literal('')),
@@ -185,6 +185,7 @@ export function UserDialog({ companies }: UserDialogProps) {
> >
<option value="partner" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Partner</option> <option value="partner" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Partner</option>
<option value="admin" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Admin</option> <option value="admin" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Admin</option>
<option value="verwaltung" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Verwaltung</option>
</select> </select>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@@ -17,17 +17,19 @@ import { deleteUser, resetPassword, updateUserRole, updateUserProfile } from '@/
import { assignCompanyToUser } from '@/lib/actions/companies' import { assignCompanyToUser } from '@/lib/actions/companies'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
type Role = 'admin' | 'partner' | 'gesperrt' type Role = 'admin' | 'partner' | 'verwaltung' | 'gesperrt'
const ROLE_LABELS: Record<Role, string> = { const ROLE_LABELS: Record<Role, string> = {
admin: 'Admin', admin: 'Admin',
partner: 'Partner', partner: 'Partner',
verwaltung: 'Verwaltung',
gesperrt: 'Gesperrt', gesperrt: 'Gesperrt',
} }
const ROLE_BADGE_CLASSES: Record<Role, string> = { const ROLE_BADGE_CLASSES: Record<Role, string> = {
admin: 'bg-purple-500/20 text-purple-400 border border-purple-500/30', admin: 'bg-purple-500/20 text-purple-400 border border-purple-500/30',
partner: 'bg-blue-500/20 text-blue-400 border border-blue-500/30', partner: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
verwaltung: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
gesperrt: 'bg-red-500/20 text-red-400 border border-red-500/30', gesperrt: 'bg-red-500/20 text-red-400 border border-red-500/30',
} }
@@ -205,7 +207,7 @@ export function UserList({ initialUsers, companies }: UserListProps) {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{displayUsers.map((user: any) => { {displayUsers.map((user: any) => {
const role: Role = (['admin', 'partner', 'gesperrt'].includes(user.role) ? user.role : 'partner') as Role const role: Role = (['admin', 'partner', 'verwaltung', 'gesperrt'].includes(user.role) ? user.role : 'partner') as Role
const isLoadingRole = loadingId === user.id + '-role' const isLoadingRole = loadingId === user.id + '-role'
const isLoadingCompany = loadingId === user.id + '-company' const isLoadingCompany = loadingId === user.id + '-company'
const isLoadingDelete = loadingId === user.id const isLoadingDelete = loadingId === user.id
@@ -288,14 +290,17 @@ export function UserList({ initialUsers, companies }: UserListProps) {
onChange={(e) => handleRoleChange(user.id, e.target.value as Role)} onChange={(e) => handleRoleChange(user.id, e.target.value as Role)}
className={`text-xs font-semibold rounded px-3 py-1.5 cursor-pointer focus:outline-none focus:ring-2 transition-colors bg-white dark:bg-slate-950/80 ${role === 'admin' className={`text-xs font-semibold rounded px-3 py-1.5 cursor-pointer focus:outline-none focus:ring-2 transition-colors bg-white dark:bg-slate-950/80 ${role === 'admin'
? 'text-purple-600 dark:text-purple-300 border border-purple-500/40 focus:ring-purple-500/50' ? 'text-purple-600 dark:text-purple-300 border border-purple-500/40 focus:ring-purple-500/50'
: role === 'gesperrt' : role === 'verwaltung'
? 'text-red-600 dark:text-red-300 border border-red-500/40 focus:ring-red-500/50' ? 'text-emerald-600 dark:text-emerald-300 border border-emerald-500/40 focus:ring-emerald-500/50'
: 'text-blue-600 dark:text-blue-300 border border-blue-500/40 focus:ring-blue-500/50' : role === 'gesperrt'
? 'text-red-600 dark:text-red-300 border border-red-500/40 focus:ring-red-500/50'
: 'text-blue-600 dark:text-blue-300 border border-blue-500/40 focus:ring-blue-500/50'
}`} }`}
style={{ appearance: 'auto' }} style={{ appearance: 'auto' }}
> >
<option value="admin" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Admin</option> <option value="admin" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Admin</option>
<option value="partner" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Partner</option> <option value="partner" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Partner</option>
<option value="verwaltung" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Verwaltung</option>
<option value="gesperrt" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Gesperrt</option> <option value="gesperrt" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Gesperrt</option>
</select> </select>
)} )}

View File

@@ -49,7 +49,7 @@ export async function getUsers() {
})); }));
} }
export async function createUser(data: { email: string; role?: 'admin' | 'partner'; email_confirm?: boolean; company_id?: string; first_name?: string; last_name?: string }) { export async function createUser(data: { email: string; role?: 'admin' | 'partner' | 'verwaltung'; email_confirm?: boolean; company_id?: string; first_name?: string; last_name?: string }) {
const admin = createAdminClient(); const admin = createAdminClient();
const { data: { user }, error } = await admin.auth.admin.createUser({ const { data: { user }, error } = await admin.auth.admin.createUser({
email: data.email, email: data.email,
@@ -152,7 +152,7 @@ export async function deleteUser(id: string) {
} }
} }
export async function updateUserRole(id: string, role: 'admin' | 'partner' | 'gesperrt') { export async function updateUserRole(id: string, role: 'admin' | 'partner' | 'verwaltung' | 'gesperrt') {
const admin = createAdminClient(); const admin = createAdminClient();
const { error } = await admin const { error } = await admin
.from('users') .from('users')