feat(02-02): user CRUD API + admin page, tenant CRUD API + admin page

- Create UserController with GET/POST/PATCH/DELETE endpoints at /users
  - ADMIN sees own-tenant users only; SUPER_ADMIN sees all (T-02-10)
  - ADMIN cannot escalate to SUPER_ADMIN role (T-02-08)
  - ADMIN cannot delete self or cross-tenant users
- Create TenantController with GET/POST/PATCH/DELETE at /tenants
  - SUPER_ADMIN-only access (D-10)
  - Tenant deletion blocked if active users exist (T-02-09)
- Create CreateUserDto, UpdateUserDto, CreateTenantDto with class-validator
- Create admin/users page with user table, create/edit/delete modals
- Create admin/tenants page with tenant table, create/edit/deactivate (SUPER_ADMIN only)
- Add admin section to sidebar: Verwaltung > Benutzer + Mandanten
  - Verwaltung visible for ADMIN/SUPER_ADMIN; Tenants link SUPER_ADMIN only
- Install @nestjs/mapped-types for PartialType DTO pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 13:38:54 +02:00
parent e7b2a70fc9
commit bfb04eac66
12 changed files with 1240 additions and 0 deletions
@@ -0,0 +1,326 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface Tenant {
id: string;
name: string;
slug: string;
isActive: boolean;
createdAt: string;
userCount: number;
}
interface TenantFormData {
name: string;
slug: string;
}
/**
* Tenant management page -- SUPER_ADMIN only (D-10).
*/
export default function AdminTenantsPage() {
const t = useTranslations('admin.tenants');
const tCommon = useTranslations('common');
const currentUser = useAuthStore((s) => s.user);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [formData, setFormData] = useState<TenantFormData>({
name: '',
slug: '',
});
// Access check: only SUPER_ADMIN
const hasAccess = currentUser?.role === 'SUPER_ADMIN';
const fetchTenants = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/tenants`, {
credentials: 'include',
});
if (res.ok) {
setTenants(await res.json());
}
} catch {
// silently fail
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (hasAccess) {
fetchTenants();
} else {
setLoading(false);
}
}, [hasAccess, fetchTenants]);
const openCreate = () => {
setEditingTenant(null);
setFormData({ name: '', slug: '' });
setShowForm(true);
};
const openEdit = (tenant: Tenant) => {
setEditingTenant(tenant);
setFormData({ name: tenant.name, slug: tenant.slug });
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const url = editingTenant
? `${API_URL}/tenants/${editingTenant.id}`
: `${API_URL}/tenants`;
const method = editingTenant ? 'PATCH' : 'POST';
const body: Record<string, unknown> = editingTenant
? { name: formData.name }
: { name: formData.name, slug: formData.slug };
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
if (res.ok) {
setShowForm(false);
fetchTenants();
}
} catch {
// silently fail
}
};
const handleToggleActive = async (tenant: Tenant) => {
try {
await fetch(`${API_URL}/tenants/${tenant.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ isActive: !tenant.isActive }),
});
fetchTenants();
} catch {
// silently fail
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`${API_URL}/tenants/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (res.ok) {
setDeleteConfirm(null);
fetchTenants();
}
} catch {
// silently fail
}
};
if (!hasAccess) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<button
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
{t('create')}
</button>
</div>
{/* Tenants table */}
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : tenants.length === 0 ? (
<p className="text-muted-foreground">{t('noTenants')}</p>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('name')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('slug')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('status')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('userCount')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
{t('actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{tenants.map((tenant) => (
<tr
key={tenant.id}
className="hover:bg-muted/30 transition-colors"
>
<td className="px-4 py-3 font-medium text-foreground">
{tenant.name}
</td>
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">
{tenant.slug}
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
tenant.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
}`}
>
{tenant.isActive ? tCommon('active') : tCommon('inactive')}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
{tenant.userCount}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEdit(tenant)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
{tCommon('edit')}
</button>
<button
onClick={() => handleToggleActive(tenant)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
{tenant.isActive ? tCommon('inactive') : tCommon('active')}
</button>
<button
onClick={() => setDeleteConfirm(tenant.id)}
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
>
{tCommon('delete')}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Create/Edit modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<h2 className="text-lg font-bold text-foreground mb-4">
{editingTenant ? t('edit') : t('create')}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{t('name')}
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
{!editingTenant && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{t('slug')}
</label>
<input
type="text"
required
pattern="^[a-z0-9-]+$"
value={formData.slug}
onChange={(e) =>
setFormData({ ...formData, slug: e.target.value })
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
placeholder="my-tenant"
/>
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
{tCommon('cancel')}
</button>
<button
type="submit"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
{tCommon('save')}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete confirmation modal */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<p className="text-sm text-foreground mb-4">
{t('deleteConfirm')}
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
{tCommon('cancel')}
</button>
<button
onClick={() => handleDelete(deleteConfirm)}
className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:opacity-90 transition-opacity"
>
{tCommon('delete')}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,407 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface User {
id: string;
username: string;
email: string;
displayName: string | null;
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
isActive: boolean;
tenantId: string;
createdAt: string;
}
interface UserFormData {
username: string;
email: string;
password: string;
displayName: string;
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
}
/**
* Admin users page -- User CRUD management (D-12).
* ADMIN and SUPER_ADMIN can access. ADMIN sees only own-tenant users.
*/
export default function AdminUsersPage() {
const t = useTranslations('admin.users');
const tCommon = useTranslations('common');
const tHeader = useTranslations('header');
const currentUser = useAuthStore((s) => s.user);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [formData, setFormData] = useState<UserFormData>({
username: '',
email: '',
password: '',
displayName: '',
role: 'USER',
});
// Access check: only ADMIN and SUPER_ADMIN
const hasAccess =
currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const fetchUsers = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/users`, {
credentials: 'include',
});
if (res.ok) {
setUsers(await res.json());
}
} catch {
// silently fail
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (hasAccess) {
fetchUsers();
} else {
setLoading(false);
}
}, [hasAccess, fetchUsers]);
const openCreate = () => {
setEditingUser(null);
setFormData({
username: '',
email: '',
password: '',
displayName: '',
role: 'USER',
});
setShowForm(true);
};
const openEdit = (user: User) => {
setEditingUser(user);
setFormData({
username: user.username,
email: user.email,
password: '',
displayName: user.displayName ?? '',
role: user.role,
});
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const url = editingUser
? `${API_URL}/users/${editingUser.id}`
: `${API_URL}/users`;
const method = editingUser ? 'PATCH' : 'POST';
const body: Record<string, unknown> = {
username: formData.username,
email: formData.email,
displayName: formData.displayName || undefined,
role: formData.role,
};
// Only include password if provided
if (formData.password) {
body.password = formData.password;
}
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
if (res.ok) {
setShowForm(false);
fetchUsers();
}
} catch {
// silently fail
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`${API_URL}/users/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (res.ok) {
setDeleteConfirm(null);
fetchUsers();
}
} catch {
// silently fail
}
};
if (!hasAccess) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
</div>
);
}
const roleBadgeClass = (role: string) => {
switch (role) {
case 'SUPER_ADMIN':
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
case 'ADMIN':
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
default:
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
}
};
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<button
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
{t('create')}
</button>
</div>
{/* Users table */}
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : users.length === 0 ? (
<p className="text-muted-foreground">{t('noUsers')}</p>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('username')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('email')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('displayName')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('role')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('status')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
{t('actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map((user) => (
<tr key={user.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">
{user.username}
</td>
<td className="px-4 py-3 text-muted-foreground">
{user.email}
</td>
<td className="px-4 py-3 text-muted-foreground">
{user.displayName ?? '-'}
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
>
{tHeader(`role.${user.role}`)}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
user.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
}`}
>
{user.isActive ? tCommon('active') : tCommon('inactive')}
</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEdit(user)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
{tCommon('edit')}
</button>
<button
onClick={() => setDeleteConfirm(user.id)}
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
>
{tCommon('delete')}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Create/Edit modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<h2 className="text-lg font-bold text-foreground mb-4">
{editingUser ? t('edit') : t('create')}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{t('username')}
</label>
<input
type="text"
required
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{t('email')}
</label>
<input
type="email"
required
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{t('password')}
{editingUser && (
<span className="ml-1 text-xs text-muted-foreground">
(optional)
</span>
)}
</label>
<input
type="password"
required={!editingUser}
minLength={8}
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{t('displayName')}
</label>
<input
type="text"
value={formData.displayName}
onChange={(e) =>
setFormData({ ...formData, displayName: e.target.value })
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{t('role')}
</label>
<select
value={formData.role}
onChange={(e) =>
setFormData({
...formData,
role: e.target.value as UserFormData['role'],
})
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="USER">{tHeader('role.USER')}</option>
<option value="ADMIN">{tHeader('role.ADMIN')}</option>
{currentUser?.role === 'SUPER_ADMIN' && (
<option value="SUPER_ADMIN">
{tHeader('role.SUPER_ADMIN')}
</option>
)}
</select>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
{tCommon('cancel')}
</button>
<button
type="submit"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
{tCommon('save')}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete confirmation modal */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<p className="text-sm text-foreground mb-4">
{t('deleteConfirm')}
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
{tCommon('cancel')}
</button>
<button
onClick={() => handleDelete(deleteConfirm)}
className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:opacity-90 transition-opacity"
>
{tCommon('delete')}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -3,6 +3,7 @@
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useAuthStore } from '@/lib/stores/auth-store';
import { SidebarFooter } from '@/components/layout/sidebar-footer';
export function Sidebar() {
@@ -10,6 +11,11 @@ export function Sidebar() {
const tCommon = useTranslations('common');
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
const [categoriesOpen, setCategoriesOpen] = useState(false);
const user = useAuthStore((s) => s.user);
// Admin section visibility based on role
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
const sidebarContent = (
<div className="flex h-full flex-col bg-sidebar">
@@ -100,6 +106,74 @@ export function Sidebar() {
)}
</div>
)}
{/* Admin section -- Verwaltung (D-12) */}
{isAdmin && (
<div className="mt-6">
{!isCollapsed && (
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('admin')}
</div>
)}
<ul className="flex flex-col gap-1">
{/* Users link -- ADMIN and SUPER_ADMIN */}
<li>
<a
href="/admin/users"
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
{!isCollapsed && <span className="truncate">{t('users')}</span>}
</a>
</li>
{/* Tenants link -- SUPER_ADMIN only (D-10) */}
{isSuperAdmin && (
<li>
<a
href="/admin/tenants"
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
{!isCollapsed && (
<span className="truncate">{t('tenants')}</span>
)}
</a>
</li>
)}
</ul>
</div>
)}
</nav>
{/* Collapse/Expand toggle */}