Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .idea/jsLinters/eslint.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@nestjs/swagger": "^8.1.1",
"@prisma/client": "^6.5.0",
"@radix-ui/react-dialog": "^1.1.4",
"archiver": "^7.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"nestjs-prisma": "^0.24.0",
Expand All @@ -43,6 +44,7 @@
"@faker-js/faker": "^9.3.0",
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/archiver": "^7.0.0",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.12",
Expand Down
12 changes: 11 additions & 1 deletion apps/backend/src/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import {
Controller,
Delete,
Get,
Header,
Param,
ParseIntPipe,
Patch,
Post,
Query,
StreamableFile,
UploadedFile,
UseGuards,
Expand Down Expand Up @@ -109,6 +109,16 @@ export class UserController {
return this.userService.findPendingProfilePictures();
}

@Post('profile-pictures/export')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@ApiBearerAuth()
@Roles(Role.BODY_ADMIN)
@Header('Content-Type', 'application/zip')
@Header('Content-Disposition', 'attachment; filename="profile-pictures.zip"')
async exportProfilePictures(@Body('userIds') userIds?: string[]): Promise<StreamableFile> {
return new StreamableFile(await this.userService.exportProfilePictures(userIds));
}

@Patch(':id')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@ApiBearerAuth()
Expand Down
31 changes: 31 additions & 0 deletions apps/backend/src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { Prisma, ProfilePictureStatus, User } from '@prisma/client';
import archiver from 'archiver';
import { PrismaService } from 'nestjs-prisma';
import { optimizeImage } from 'src/util';

Expand Down Expand Up @@ -242,6 +243,36 @@ export class UserService {
}
}

async exportProfilePictures(userIds?: string[]): Promise<Buffer> {
const where: Prisma.ProfilePictureWhereInput = { status: ProfilePictureStatus.ACCEPTED };
if (userIds && userIds.length > 0) {
where.userId = { in: userIds };
}
const pictures = await this.prisma.profilePicture.findMany({
where,
select: {
profileImage: true,
user: { select: { fullName: true, authSchId: true } },
},
});

return new Promise((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 0 } });

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The archiver is configured with compression level 0 (zlib: { level: 0 }), which means no compression. This will result in larger ZIP files. While this may be intentional for faster processing, consider using a moderate compression level (e.g., level 6) to balance file size and performance, especially if many profile pictures are being exported.

Suggested change
const archive = archiver('zip', { zlib: { level: 0 } });
const archive = archiver('zip', { zlib: { level: 6 } });

Copilot uses AI. Check for mistakes.
const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
archive.on('end', () => resolve(Buffer.concat(chunks)));
archive.on('error', (err: Error) => reject(err));

for (const picture of pictures) {
const imageBuffer = Buffer.from(picture.profileImage.buffer);
const safeName = picture.user.fullName.replace(/[^\w\s\-áéíóöőúüűÁÉÍÓÖŐÚÜŰ]/g, '_');

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The safeName sanitization removes potentially problematic characters, but it doesn't handle the case where the fullName might be empty or only contain special characters, which would result in an empty filename. Consider adding a fallback to use authSchId if the sanitized name is empty, for example: const safeName = picture.user.fullName.replace(/[^\w\s\-áéíóöőúüűÁÉÍÓÖŐÚÜŰ]/g, '_').trim() || picture.user.authSchId;

Suggested change
const safeName = picture.user.fullName.replace(/[^\w\s\-áéíóöőúüűÁÉÍÓÖŐÚÜŰ]/g, '_');
const safeName =
picture.user.fullName.replace(/[^\w\s\-áéíóöőúüűÁÉÍÓÖŐÚÜŰ]/g, '_').trim() ||
picture.user.authSchId;

Copilot uses AI. Check for mistakes.
archive.append(imageBuffer, { name: `${safeName}.jpg` });

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple users with the same fullName will have filename collisions in the exported ZIP file. When two users have identical names, only one file will be preserved in the archive. Consider including the authSchId in the filename to ensure uniqueness, for example: ${safeName}_${picture.user.authSchId}.jpg

Suggested change
archive.append(imageBuffer, { name: `${safeName}.jpg` });
archive.append(imageBuffer, { name: `${safeName}_${picture.user.authSchId}.jpg` });

Copilot uses AI. Check for mistakes.
}

archive.finalize();
});
}

async findProfilePicture(authSchId: string): Promise<Buffer> {
try {
const profilePic = await this.prisma.profilePicture.findUniqueOrThrow({ where: { userId: authSchId } });
Expand Down
100 changes: 100 additions & 0 deletions apps/frontend/src/app/admin/profile-picture-export/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
'use client';
import { useRouter } from 'next/navigation';
import React from 'react';
import useSWR from 'swr';

import Th1 from '@/components/typography/typography';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import LoadingCard from '@/components/ui/LoadingCard';
import { axiosGetFetcher } from '@/lib/fetchers';
import { exportProfilePictures } from '@/lib/profile-pictures';
import { ProfilePictureStatus, UserEntityPagination } from '@/types/user-entity';
import { LuDownload } from 'react-icons/lu';

export default function Page() {
const router = useRouter();
const { data, isLoading } = useSWR<UserEntityPagination>('users?page=-1&pageSize=-1', axiosGetFetcher);
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());

const usersWithPictures = React.useMemo(
() => data?.users.filter((u) => u.profilePicture?.status === ProfilePictureStatus.ACCEPTED) ?? [],
[data]
);

const allSelected = usersWithPictures.length > 0 && selectedIds.size === usersWithPictures.length;

const toggleSelectAll = () => {
if (allSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(usersWithPictures.map((u) => u.authSchId)));
}
};

const toggleUser = (authSchId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(authSchId)) {
next.delete(authSchId);
} else {
next.add(authSchId);
}
return next;
});
};

const handleExport = () => exportProfilePictures(selectedIds.size > 0 ? [...selectedIds] : undefined);

const exportLabel = selectedIds.size === 0 ? 'mind' : `${selectedIds.size} db`;

return (
<>
<div className='flex justify-between items-center flex-wrap gap-4'>
<Th1>Profilképek exportálása</Th1>
<div className='flex gap-2'>
<Button variant='outline' onClick={toggleSelectAll} disabled={usersWithPictures.length === 0}>
{allSelected ? 'Kijelölés törlése' : 'Összes kijelölése'}
</Button>
<Button onClick={handleExport}>
<LuDownload />
Exportálás ({exportLabel})
</Button>
</div>
</div>

{isLoading && <LoadingCard />}

{!isLoading && usersWithPictures.length === 0 && (
<p className='text-muted-foreground'>Nincs jóváhagyott profilkép.</p>
)}

<div className='grid max-lg:grid-cols-1 lg:grid-cols-2 gap-2'>
{usersWithPictures.map((user) => (
<Card
key={user.authSchId}
className='flex items-center gap-4 p-4 cursor-pointer select-none'
onClick={() => toggleUser(user.authSchId)}
>
<Checkbox checked={selectedIds.has(user.authSchId)} onCheckedChange={() => toggleUser(user.authSchId)} />
<img
src={`${process.env.NEXT_PUBLIC_API_URL}/users/${user.authSchId}/profile-picture`}
alt={user.fullName}
loading='lazy'
className='w-12 h-16 object-cover rounded'
/>
<div>
<p className='font-medium'>{user.fullName}</p>
<p className='text-sm text-muted-foreground'>{user.nickName}</p>
</div>
</Card>
))}
</div>

<Button variant='secondary' onClick={() => router.push('/admin')}>
Vissza
</Button>
</>
);
}
9 changes: 9 additions & 0 deletions apps/frontend/src/app/periods/[id]/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ interface DataTableProps<TData, TValue> {
onExportApplicationsClicked: (data: TData[]) => void;
onSetToManufactured: (data: TData[]) => void;
onExportToExcelClicked: (data: TData[]) => void;
onExportProfilePicturesClicked: (data: TData[]) => void;
}

export function DataTable<TData, TValue>({
Expand All @@ -62,6 +63,7 @@ export function DataTable<TData, TValue>({
onExportPassesClicked,
onSetToManufactured,
onExportToExcelClicked,
onExportProfilePicturesClicked,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([
{
Expand Down Expand Up @@ -216,6 +218,13 @@ export function DataTable<TData, TValue>({
>
Minden kiosztott jelentkezés exportálása Excel file-ba
</MenubarItem>
<Separator />
<MenubarItem
disabled={table.getFilteredSelectedRowModel().rows.length === 0}
onClick={() => onExportProfilePicturesClicked(data.filter((_, i) => rowSelection[i]))}
>
Kijelöltek profilképeinek exportálása
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/src/app/periods/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { toast } from '@/lib/use-toast';
import { ApplicationEntity, ApplicationStatus } from '@/types/application-entity';

import { generateXlsx } from '@/lib/xlsx';
import { exportProfilePictures } from '@/lib/profile-pictures';
import { saveAs } from 'file-saver';
import { ApplicationExport } from './application-export';
import { PassExport } from './pass-export';
Expand Down Expand Up @@ -194,6 +195,9 @@ export default function Page(props: { params: Promise<{ id: number }> }) {
* This function exports the selected applications which have the status {@link ApplicationStatus.DISTRIBUTED}
* to an Excel file.
*/
const onExportProfilePictures = (data: ApplicationEntity[]) =>
exportProfilePictures(data.map((a) => a.user.authSchId));

const onExportToExcel = async (data: ApplicationEntity[]) => {
const distributedApplications = data.filter((a) => a.status === getStatusKey(ApplicationStatus.DISTRIBUTED));

Expand Down Expand Up @@ -278,6 +282,7 @@ export default function Page(props: { params: Promise<{ id: number }> }) {
onExportApplicationsClicked={onApplicationsExport}
onSetToManufactured={onSetToManufactured}
onExportToExcelClicked={onExportToExcel}
onExportProfilePicturesClicked={onExportProfilePictures}
/>
)}
</div>
Expand Down
18 changes: 16 additions & 2 deletions apps/frontend/src/app/roles/page.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
'use client';
import { useRouter } from 'next/navigation';
import React from 'react';

import api from '@/components/network/apiSetup';
import Th1 from '@/components/typography/typography';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import LoadingCard from '@/components/ui/LoadingCard';
import NotFoundCard from '@/components/ui/NotFoundCard';
import OwnPagination from '@/components/ui/ownPagination';
import UserCard from '@/components/ui/UserCard';
import { useUsers } from '@/hooks/useUsers';
import { exportProfilePictures } from '@/lib/profile-pictures';
import { toast } from '@/lib/use-toast';
import { Role } from '@/types/user-entity';
import { LuDownload } from 'react-icons/lu';

export default function Page() {
const router = useRouter();

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The router constant is declared but never used in the component. This is an unused variable that should be removed to keep the code clean.

Copilot uses AI. Check for mistakes.
const [search, setSearch] = React.useState('');
const [pageIndex, setPageIndex] = React.useState(0);
const users = useUsers(search, pageIndex);

async function onChange(newRole: Role, userId: string) {
async function onRoleChange(newRole: Role, userId: string) {
try {
await api.patch(`/users/${userId}`, { role: newRole });
await users.mutate();
Expand All @@ -32,11 +37,19 @@ export default function Page() {
}
}

async function mutateUsers() {
await users.mutate();
}

return (
<>
<div className='flex justify-between md:flex-row max-md:flex-col items-center'>
<Th1>Jogosultságok kezelése</Th1>
<div className='flex gap-2'>
<Button onClick={() => exportProfilePictures()}>
<LuDownload />
Minden profilkép exportálása
</Button>
<Input
placeholder='Keresés név alapján...'
value={search}
Expand Down Expand Up @@ -68,7 +81,8 @@ export default function Page() {
<UserCard
key={user.authSchId}
user={user}
onChange={(newRole: Role) => onChange(newRole, user.authSchId)}
onChange={(newRole: Role) => onRoleChange(newRole, user.authSchId)}
mutateUsers={mutateUsers}
/>
))}
</div>
Expand Down
23 changes: 17 additions & 6 deletions apps/frontend/src/components/ui/UserCard.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,41 @@
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { RoleBadgeSelector } from '@/components/ui/RoleBadgeSelector';
import { Role, UserEntity } from '@/types/user-entity';
import { ProfilePictureStatus, Role, UserEntity } from '@/types/user-entity';
import Image from 'next/image';
import { LuPencil, LuUser, LuUserCheck, LuUserMinus, LuUserSearch } from 'react-icons/lu';
import api from '@/components/network/apiSetup';
import { toast } from '@/lib/use-toast';
import { Button } from '@/components/ui/button';

/*admin component*/
export default function UserCard(props: { user: UserEntity; onChange: (newRole: Role) => Promise<void> }) {
export default function UserCard(props: {
user: UserEntity;
onChange: (newRole: Role) => Promise<void>;
mutateUsers: () => Promise<void>;
}) {
async function sendStatusChange(string: string) {
const resp = await api.patch('/users/' + props.user.authSchId + '/profile-picture/' + string);
toast({
title: 'Profilkép státusz módosítva!',
description: resp.statusText,
});
await props.mutateUsers();
}

// @ts-ignore

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @ts-ignore comment is suppressing TypeScript errors without explanation. This should be removed and the underlying TypeScript error should be properly fixed. If there's a legitimate reason to suppress the error, it should be documented with a comment explaining why.

Suggested change
// @ts-ignore

Copilot uses AI. Check for mistakes.
return (
<Card>
<CardHeader className='flex flex-row w-full justify-between items-center p-4 overflow-auto gap-4'>
<div className='flex gap-8'>
<Image
src={`${process.env.NEXT_PUBLIC_API_URL}/users/${props.user.authSchId}/profile-picture`}
alt='KEP'
alt={`Picture of ${props.user.nickName ?? props.user.fullName}`}
className='lg:rounded-l-lg max-lg:rounded-lg aspect-auto -m-4 max-md:-my-4'
width={100}
height={100}
/>
<div className='overflow-scroll text-nowrap truncate justify-between flex flex-col h-auto'>
<p className='text-xs font-mono'>{props.user.authSchId}</p>
<CardTitle>{props.user.fullName}</CardTitle>
<CardDescription className='flex sm:gap-4 max-sm:gap-0 max-sm:flex-col sm:flex-row'>
<p className='flex items-center gap-2'>
Expand All @@ -52,20 +59,24 @@ export default function UserCard(props: { user: UserEntity; onChange: (newRole:
</div>
<div className='flex flex-col gap-2 items-center justify-center'>
<div className='flex gap-2'>
<Button onClick={() => sendStatusChange('ACCEPTED')} title='Profilkép gyors elfogadása'>
<Button
onClick={() => sendStatusChange('ACCEPTED')}
title='Profilkép gyors elfogadása'
variant={props.user.profilePicture?.status === ProfilePictureStatus.ACCEPTED ? 'default' : 'outline'}
>
<LuUserCheck />
</Button>
<Button
onClick={() => sendStatusChange('PENDING')}
title='Profilkép gyors pendingre állítása'
variant='secondary'
variant={props.user.profilePicture?.status === ProfilePictureStatus.PENDING ? 'default' : 'outline'}
>
<LuUserSearch />
</Button>
<Button
onClick={() => sendStatusChange('REJECTED')}
title='Profilkép gyors elutasítása'
variant='destructive'
variant={props.user.profilePicture?.status === ProfilePictureStatus.REJECTED ? 'default' : 'outline'}
>
<LuUserMinus />
</Button>
Expand Down
Loading
Loading