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:
@@ -13,6 +13,7 @@
|
|||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsNotEmpty, IsString, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTenantDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Matches(/^[a-z0-9-]+$/, {
|
||||||
|
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
||||||
|
})
|
||||||
|
slug!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||||
|
import { TenantService } from './tenant.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant CRUD controller.
|
||||||
|
* D-10: Only Super-Admin can manage tenants.
|
||||||
|
* T-02-09: Tenant deletion blocked if active users exist.
|
||||||
|
*/
|
||||||
|
@Controller('tenants')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles(Role.SUPER_ADMIN)
|
||||||
|
export class TenantController {
|
||||||
|
constructor(
|
||||||
|
private readonly tenantService: TenantService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /tenants
|
||||||
|
* Returns all tenants with user count.
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async findAll() {
|
||||||
|
const tenants = await this.prisma.tenant.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { users: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return tenants.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
slug: t.slug,
|
||||||
|
isActive: t.isActive,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
userCount: t._count.users,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /tenants/:id
|
||||||
|
* Returns single tenant with user count.
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(@Param('id') id: string) {
|
||||||
|
const tenant = await this.prisma.tenant.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { users: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new NotFoundException('Tenant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
isActive: tenant.isActive,
|
||||||
|
createdAt: tenant.createdAt,
|
||||||
|
userCount: tenant._count.users,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tenants
|
||||||
|
* Create a new tenant.
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
async create(@Body() dto: CreateTenantDto) {
|
||||||
|
return this.tenantService.create({
|
||||||
|
name: dto.name,
|
||||||
|
slug: dto.slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /tenants/:id
|
||||||
|
* Update tenant name or isActive.
|
||||||
|
*/
|
||||||
|
@Patch(':id')
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: { name?: string; isActive?: boolean },
|
||||||
|
) {
|
||||||
|
const existing = await this.tenantService.findById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Tenant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tenantService.update(id, {
|
||||||
|
name: dto.name,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /tenants/:id
|
||||||
|
* T-02-09: Only delete if no active users exist.
|
||||||
|
*/
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(@Param('id') id: string) {
|
||||||
|
const tenant = await this.prisma.tenant.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
users: { where: { isActive: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new NotFoundException('Tenant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenant._count.users > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot delete tenant with active users. Deactivate or reassign users first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.tenant.delete({ where: { id } });
|
||||||
|
return { message: 'Tenant deleted' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TenantController } from './tenant.controller';
|
||||||
import { TenantService } from './tenant.service';
|
import { TenantService } from './tenant.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [TenantController],
|
||||||
providers: [TenantService],
|
providers: [TenantService],
|
||||||
exports: [TenantService],
|
exports: [TenantService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
displayName?: string;
|
||||||
|
|
||||||
|
@IsEnum(Role)
|
||||||
|
@IsOptional()
|
||||||
|
role?: Role;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
import { CreateUserDto } from './create-user.dto';
|
||||||
|
|
||||||
|
export class UpdateUserDto extends PartialType(CreateUserDto) {
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
export class UserController {
|
||||||
|
constructor(
|
||||||
|
private readonly userService: UserService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /users
|
||||||
|
* ADMIN sees own-tenant users only. SUPER_ADMIN sees all users.
|
||||||
|
* T-02-10: ADMIN filtered by tenant at controller level.
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async findAll(@CurrentUser() currentUser: any) {
|
||||||
|
if (currentUser.role === Role.SUPER_ADMIN) {
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
displayName: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
tenantId: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { username: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADMIN: filter by own tenant
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
where: { tenantId: currentUser.tenantId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
displayName: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
tenantId: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { username: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /users/:id
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) {
|
||||||
|
const user = await this.userService.findById(id);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADMIN can only view users in own tenant
|
||||||
|
if (
|
||||||
|
currentUser.role !== Role.SUPER_ADMIN &&
|
||||||
|
user.tenantId !== currentUser.tenantId
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Cannot access users from other tenants');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { passwordHash, ...result } = user;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /users
|
||||||
|
* T-02-08: ADMIN can only create users in own tenant; cannot set SUPER_ADMIN role.
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: any) {
|
||||||
|
// ADMIN can only create users in own tenant
|
||||||
|
const tenantId =
|
||||||
|
currentUser.role === Role.SUPER_ADMIN && dto.tenantId
|
||||||
|
? dto.tenantId
|
||||||
|
: currentUser.tenantId;
|
||||||
|
|
||||||
|
// T-02-08: ADMIN cannot create SUPER_ADMIN users
|
||||||
|
if (currentUser.role !== Role.SUPER_ADMIN && dto.role === Role.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Cannot assign SUPER_ADMIN role');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userService.create({
|
||||||
|
username: dto.username,
|
||||||
|
email: dto.email,
|
||||||
|
password: dto.password,
|
||||||
|
displayName: dto.displayName,
|
||||||
|
role: dto.role ?? Role.USER,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { passwordHash, ...result } = user;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /users/:id
|
||||||
|
* T-02-08: ADMIN cannot escalate to SUPER_ADMIN or modify other tenants' users.
|
||||||
|
*/
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateUserDto,
|
||||||
|
@CurrentUser() currentUser: any,
|
||||||
|
) {
|
||||||
|
const user = await this.userService.findById(id);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADMIN can only update users in own tenant
|
||||||
|
if (
|
||||||
|
currentUser.role !== Role.SUPER_ADMIN &&
|
||||||
|
user.tenantId !== currentUser.tenantId
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Cannot modify users from other tenants');
|
||||||
|
}
|
||||||
|
|
||||||
|
// T-02-08: ADMIN cannot set role to SUPER_ADMIN
|
||||||
|
if (currentUser.role !== Role.SUPER_ADMIN && dto.role === Role.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Cannot assign SUPER_ADMIN role');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.userService.update(id, {
|
||||||
|
username: dto.username,
|
||||||
|
email: dto.email,
|
||||||
|
password: dto.password,
|
||||||
|
displayName: dto.displayName,
|
||||||
|
role: dto.role,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { passwordHash, ...result } = updated;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /users/:id
|
||||||
|
* ADMIN cannot delete self or users from other tenants.
|
||||||
|
*/
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async remove(@Param('id') id: string, @CurrentUser() currentUser: any) {
|
||||||
|
const user = await this.userService.findById(id);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot delete self
|
||||||
|
if (user.id === currentUser.sub) {
|
||||||
|
throw new ForbiddenException('Cannot delete your own account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADMIN can only delete users in own tenant
|
||||||
|
if (
|
||||||
|
currentUser.role !== Role.SUPER_ADMIN &&
|
||||||
|
user.tenantId !== currentUser.tenantId
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Cannot delete users from other tenants');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userService.delete(id);
|
||||||
|
return { message: 'User deleted' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AdminSeedService } from './admin-seed.service';
|
import { AdminSeedService } from './admin-seed.service';
|
||||||
|
import { UserController } from './user.controller';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [UserController],
|
||||||
providers: [UserService, AdminSeedService],
|
providers: [UserService, AdminSeedService],
|
||||||
exports: [UserService],
|
exports: [UserService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||||
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
import { SidebarFooter } from '@/components/layout/sidebar-footer';
|
import { SidebarFooter } from '@/components/layout/sidebar-footer';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
@@ -10,6 +11,11 @@ export function Sidebar() {
|
|||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
|
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
|
||||||
const [categoriesOpen, setCategoriesOpen] = useState(false);
|
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 = (
|
const sidebarContent = (
|
||||||
<div className="flex h-full flex-col bg-sidebar">
|
<div className="flex h-full flex-col bg-sidebar">
|
||||||
@@ -100,6 +106,74 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{/* Collapse/Expand toggle */}
|
{/* Collapse/Expand toggle */}
|
||||||
|
|||||||
Generated
+24
@@ -32,6 +32,9 @@ importers:
|
|||||||
'@nestjs/jwt':
|
'@nestjs/jwt':
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.2
|
||||||
version: 11.0.2(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
version: 11.0.2(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
|
'@nestjs/mapped-types':
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
|
||||||
'@nestjs/passport':
|
'@nestjs/passport':
|
||||||
specifier: ^11.0.5
|
specifier: ^11.0.5
|
||||||
version: 11.0.5(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
version: 11.0.5(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||||
@@ -630,6 +633,19 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
|
|
||||||
|
'@nestjs/mapped-types@2.1.1':
|
||||||
|
resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||||
|
class-transformer: ^0.4.0 || ^0.5.0
|
||||||
|
class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
|
||||||
|
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
class-transformer:
|
||||||
|
optional: true
|
||||||
|
class-validator:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@nestjs/passport@11.0.5':
|
'@nestjs/passport@11.0.5':
|
||||||
resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==}
|
resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3057,6 +3073,14 @@ snapshots:
|
|||||||
'@types/jsonwebtoken': 9.0.10
|
'@types/jsonwebtoken': 9.0.10
|
||||||
jsonwebtoken: 9.0.3
|
jsonwebtoken: 9.0.3
|
||||||
|
|
||||||
|
'@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
optionalDependencies:
|
||||||
|
class-transformer: 0.5.1
|
||||||
|
class-validator: 0.15.1
|
||||||
|
|
||||||
'@nestjs/passport@11.0.5(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)':
|
'@nestjs/passport@11.0.5(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
|||||||
Reference in New Issue
Block a user