diff --git a/apps/api/package.json b/apps/api/package.json index 439f19c..6bc210e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,7 @@ "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", "@nestjs/jwt": "^11.0.2", + "@nestjs/mapped-types": "^2.1.1", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.0", "@prisma/client": "^6.0.0", diff --git a/apps/api/src/tenant/dto/create-tenant.dto.ts b/apps/api/src/tenant/dto/create-tenant.dto.ts new file mode 100644 index 0000000..d9b5c11 --- /dev/null +++ b/apps/api/src/tenant/dto/create-tenant.dto.ts @@ -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; +} diff --git a/apps/api/src/tenant/tenant.controller.ts b/apps/api/src/tenant/tenant.controller.ts new file mode 100644 index 0000000..edf2761 --- /dev/null +++ b/apps/api/src/tenant/tenant.controller.ts @@ -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' }; + } +} diff --git a/apps/api/src/tenant/tenant.module.ts b/apps/api/src/tenant/tenant.module.ts index aaf2737..094a6ee 100644 --- a/apps/api/src/tenant/tenant.module.ts +++ b/apps/api/src/tenant/tenant.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; +import { TenantController } from './tenant.controller'; import { TenantService } from './tenant.service'; @Module({ + controllers: [TenantController], providers: [TenantService], exports: [TenantService], }) diff --git a/apps/api/src/user/dto/create-user.dto.ts b/apps/api/src/user/dto/create-user.dto.ts new file mode 100644 index 0000000..c94fbcd --- /dev/null +++ b/apps/api/src/user/dto/create-user.dto.ts @@ -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; +} diff --git a/apps/api/src/user/dto/update-user.dto.ts b/apps/api/src/user/dto/update-user.dto.ts new file mode 100644 index 0000000..e8dd6b7 --- /dev/null +++ b/apps/api/src/user/dto/update-user.dto.ts @@ -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; +} diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts new file mode 100644 index 0000000..4c0d0e3 --- /dev/null +++ b/apps/api/src/user/user.controller.ts @@ -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' }; + } +} diff --git a/apps/api/src/user/user.module.ts b/apps/api/src/user/user.module.ts index 41e88ae..a4c0f33 100644 --- a/apps/api/src/user/user.module.ts +++ b/apps/api/src/user/user.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { AdminSeedService } from './admin-seed.service'; +import { UserController } from './user.controller'; import { UserService } from './user.service'; @Module({ + controllers: [UserController], providers: [UserService, AdminSeedService], exports: [UserService], }) diff --git a/apps/web/src/app/(portal)/admin/tenants/page.tsx b/apps/web/src/app/(portal)/admin/tenants/page.tsx new file mode 100644 index 0000000..e8ec93d --- /dev/null +++ b/apps/web/src/app/(portal)/admin/tenants/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingTenant, setEditingTenant] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [formData, setFormData] = useState({ + 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 = 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 ( +
+

{tCommon('accessDenied')}

+
+ ); + } + + return ( +
+ {/* Page header */} +
+

{t('title')}

+ +
+ + {/* Tenants table */} + {loading ? ( +

{tCommon('loading')}

+ ) : tenants.length === 0 ? ( +

{t('noTenants')}

+ ) : ( +
+ + + + + + + + + + + + {tenants.map((tenant) => ( + + + + + + + + ))} + +
+ {t('name')} + + {t('slug')} + + {t('status')} + + {t('userCount')} + + {t('actions')} +
+ {tenant.name} + + {tenant.slug} + + + {tenant.isActive ? tCommon('active') : tCommon('inactive')} + + + {tenant.userCount} + +
+ + + +
+
+
+ )} + + {/* Create/Edit modal */} + {showForm && ( +
+
+

+ {editingTenant ? t('edit') : t('create')} +

+
+
+ + + 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" + /> +
+ {!editingTenant && ( +
+ + + 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" + /> +
+ )} +
+ + +
+
+
+
+ )} + + {/* Delete confirmation modal */} + {deleteConfirm && ( +
+
+

+ {t('deleteConfirm')} +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/admin/users/page.tsx b/apps/web/src/app/(portal)/admin/users/page.tsx new file mode 100644 index 0000000..5fbe6ff --- /dev/null +++ b/apps/web/src/app/(portal)/admin/users/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [formData, setFormData] = useState({ + 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 = { + 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 ( +
+

{tCommon('accessDenied')}

+
+ ); + } + + 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 ( +
+ {/* Page header */} +
+

{t('title')}

+ +
+ + {/* Users table */} + {loading ? ( +

{tCommon('loading')}

+ ) : users.length === 0 ? ( +

{t('noUsers')}

+ ) : ( +
+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + +
+ {t('username')} + + {t('email')} + + {t('displayName')} + + {t('role')} + + {t('status')} + + {t('actions')} +
+ {user.username} + + {user.email} + + {user.displayName ?? '-'} + + + {tHeader(`role.${user.role}`)} + + + + {user.isActive ? tCommon('active') : tCommon('inactive')} + + +
+ + +
+
+
+ )} + + {/* Create/Edit modal */} + {showForm && ( +
+
+

+ {editingUser ? t('edit') : t('create')} +

+
+
+ + + 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" + /> +
+
+ + + 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" + /> +
+
+ + + 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" + /> +
+
+ + + 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" + /> +
+
+ + +
+
+ + +
+
+
+
+ )} + + {/* Delete confirmation modal */} + {deleteConfirm && ( +
+
+

+ {t('deleteConfirm')} +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 230cdf1..e1a1aa0 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -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 = (
@@ -100,6 +106,74 @@ export function Sidebar() { )}
)} + + {/* Admin section -- Verwaltung (D-12) */} + {isAdmin && ( +
+ {!isCollapsed && ( +
+ {t('admin')} +
+ )} + +
+ )} {/* Collapse/Expand toggle */} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bad8856..cba04b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@nestjs/jwt': 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)) + '@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': 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) @@ -630,6 +633,19 @@ packages: peerDependencies: '@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': resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} peerDependencies: @@ -3057,6 +3073,14 @@ snapshots: '@types/jsonwebtoken': 9.0.10 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)': 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)