diff --git a/admin/src/app/app.routes.ts b/admin/src/app/app.routes.ts index 2b9ed87..aca0b8f 100644 --- a/admin/src/app/app.routes.ts +++ b/admin/src/app/app.routes.ts @@ -17,8 +17,53 @@ import { EventFormComponent } from './features/events/components/event-form/even import { EventDetailsComponent } from './features/events/components/event-details/event-details.component'; import { EventTableComponent } from './features/events/components/event-table/event-table.component'; import { EventListComponent } from './features/events/components/event-list/event-list.component'; +import { UserFormComponent } from './features/user/components/user-form/user-form.component'; +import { UserDetailsComponent } from './features/user/components/user-details/user-details.component'; +import { UserTableComponent } from './features/user/components/user-table/user-table.component'; +import { UserListComponent } from './features/user/components/user-list/user-list.component'; export const routes: Routes = [ + { + path: 'user/new', + component: UserFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'user', + component: UserListComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'user/table', + component: UserTableComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'user/:id', + component: UserDetailsComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'user/:id/edit', + component: UserFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { path: 'events/new', component: EventFormComponent, diff --git a/admin/src/app/app.ts b/admin/src/app/app.ts index 8aec2f2..6773b62 100644 --- a/admin/src/app/app.ts +++ b/admin/src/app/app.ts @@ -35,6 +35,14 @@ export class App { svgIcon: ` +` + }, + { + menuText: 'Felhasználók', + targetUrl: '/user/table', + svgIcon: ` + + ` } diff --git a/admin/src/app/features/user/components/user-details/user-details.component.html b/admin/src/app/features/user/components/user-details/user-details.component.html new file mode 100644 index 0000000..4a46f14 --- /dev/null +++ b/admin/src/app/features/user/components/user-details/user-details.component.html @@ -0,0 +1,46 @@ + + + +
+ +
+
+

User Details

+ +
+ + + + + + + + + + + + + + + + + + + +
id{{ user.id }}
username{{ user.username }}
email{{ user.email }}
password{{ user.password }}
+
+ + +
+
+
+ + +
+ +
+
+
diff --git a/admin/src/app/features/user/components/user-details/user-details.component.ts b/admin/src/app/features/user/components/user-details/user-details.component.ts new file mode 100644 index 0000000..e2b9cd4 --- /dev/null +++ b/admin/src/app/features/user/components/user-details/user-details.component.ts @@ -0,0 +1,34 @@ +// dvbooking-cli/src/templates/angular/details.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { User } from '../../models/user.model'; +import { UserService } from '../../services/user.service'; + +@Component({ + selector: 'app-user-details', + templateUrl: './user-details.component.html', + standalone: true, + imports: [CommonModule, RouterModule], +}) +export class UserDetailsComponent implements OnInit { + user$!: Observable; + + constructor( + private route: ActivatedRoute, + private userService: UserService + ) {} + + ngOnInit(): void { + this.user$ = this.route.params.pipe( + switchMap(params => { + const id = params['id']; + return this.userService.findOne(id); + }) + ); + } +} \ No newline at end of file diff --git a/admin/src/app/features/user/components/user-filter/user-filter.component.html b/admin/src/app/features/user/components/user-filter/user-filter.component.html new file mode 100644 index 0000000..3da4913 --- /dev/null +++ b/admin/src/app/features/user/components/user-filter/user-filter.component.html @@ -0,0 +1,18 @@ + + +
+
+
+
+ +
+
+ +
+
+ +
+ +
+
+
diff --git a/admin/src/app/features/user/components/user-filter/user-filter.component.ts b/admin/src/app/features/user/components/user-filter/user-filter.component.ts new file mode 100644 index 0000000..afb7d70 --- /dev/null +++ b/admin/src/app/features/user/components/user-filter/user-filter.component.ts @@ -0,0 +1,40 @@ +// dvbooking-cli/src/templates/angular/filter.component.ts.tpl + +// Generated by the CLI +import { Component, EventEmitter, Output } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +@Component({ + selector: 'app-user-filter', + templateUrl: './user-filter.component.html', + standalone: true, + imports: [ReactiveFormsModule] +}) +export class UserFilterComponent { + @Output() filterChanged = new EventEmitter(); + filterForm: FormGroup; + + constructor(private fb: FormBuilder) { + this.filterForm = this.fb.group({ + username: [''], + email: [''], + password: [''], + hashedRefreshToken: [''] + }); + + this.filterForm.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged() + ).subscribe(values => { + const cleanFilter = Object.fromEntries( + Object.entries(values).filter(([_, v]) => v != null && v !== '') + ); + this.filterChanged.emit(cleanFilter); + }); + } + + reset() { + this.filterForm.reset(); + } +} \ No newline at end of file diff --git a/admin/src/app/features/user/components/user-form/user-form.component.html b/admin/src/app/features/user/components/user-form/user-form.component.html new file mode 100644 index 0000000..ade414a --- /dev/null +++ b/admin/src/app/features/user/components/user-form/user-form.component.html @@ -0,0 +1,34 @@ + + + +
+
+
+

+ {{ isEditMode ? 'Edit' : 'Create' }} User +

+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ Cancel + +
+
+
+
+
\ No newline at end of file diff --git a/admin/src/app/features/user/components/user-form/user-form.component.ts b/admin/src/app/features/user/components/user-form/user-form.component.ts new file mode 100644 index 0000000..712c6c1 --- /dev/null +++ b/admin/src/app/features/user/components/user-form/user-form.component.ts @@ -0,0 +1,88 @@ +// dvbooking-cli/src/templates/angular/form.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; +import { User } from '../../models/user.model'; +import { UserService } from '../../services/user.service'; + +@Component({ + selector: 'app-user-form', + templateUrl: './user-form.component.html', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterModule], +}) +export class UserFormComponent implements OnInit { + form: FormGroup; + isEditMode = false; + id: number | null = null; + + private numericFields = []; + + constructor( + private fb: FormBuilder, + private route: ActivatedRoute, + private router: Router, + private userService: UserService + ) { + this.form = this.fb.group({ + username: [null], + email: [null], + password: [null], + hashedRefreshToken: [null] + }); + } + + ngOnInit(): void { + this.route.params.pipe( + tap(params => { + if (params['id']) { + this.isEditMode = true; + this.id = +params['id']; + } + }), + switchMap(() => { + if (this.isEditMode && this.id) { + return this.userService.findOne(this.id); + } + return of(null); + }) + ).subscribe(user => { + if (user) { + this.form.patchValue(user); + } + }); + } + + onSubmit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + const payload = { ...this.form.value }; + + for (const field of this.numericFields) { + if (payload[field] != null && payload[field] !== '') { + payload[field] = parseFloat(payload[field]); + } + } + + let action$: Observable; + + if (this.isEditMode && this.id) { + action$ = this.userService.update(this.id, payload); + } else { + action$ = this.userService.create(payload); + } + + action$.subscribe({ + next: () => this.router.navigate(['/user']), + error: (err) => console.error('Failed to save user', err) + }); + } +} \ No newline at end of file diff --git a/admin/src/app/features/user/components/user-list/user-list.component.html b/admin/src/app/features/user/components/user-list/user-list.component.html new file mode 100644 index 0000000..e7ba5c8 --- /dev/null +++ b/admin/src/app/features/user/components/user-list/user-list.component.html @@ -0,0 +1,62 @@ + + + +
+
+

Users

+ Create New +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + +
idusernameemailActions
{{ item.id }}{{ item.username }}{{ item.email }} + View + Edit + +
No user found.
+
+ + +
+
+ + + +
+
+
+ + +
+ +
+
+
diff --git a/admin/src/app/features/user/components/user-list/user-list.component.ts b/admin/src/app/features/user/components/user-list/user-list.component.ts new file mode 100644 index 0000000..bd0f757 --- /dev/null +++ b/admin/src/app/features/user/components/user-list/user-list.component.ts @@ -0,0 +1,70 @@ +// dvbooking-cli/src/templates/angular/list.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { BehaviorSubject, Observable, combineLatest } from 'rxjs'; +import { switchMap, startWith } from 'rxjs/operators'; +import { User } from '../../models/user.model'; +import { UserService } from '../../services/user.service'; +import { UserFilterComponent } from '../user-filter/user-filter.component'; +import { PaginatedResponse } from '../../../../../types'; + + +@Component({ + selector: 'app-user-list', + templateUrl: './user-list.component.html', + standalone: true, + imports: [CommonModule,RouterModule, UserFilterComponent], +}) +export class UserListComponent implements OnInit { + + private refresh$ = new BehaviorSubject(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + + paginatedResponse$!: Observable>; + + constructor(private userService: UserService) { } + + ngOnInit(): void { + this.paginatedResponse$ = combineLatest([ + this.refresh$, + this.filter$.pipe(startWith({})), + this.page$.pipe(startWith(1)) + ]).pipe( + switchMap(([_, filter, page]) => { + const query = { ...filter, page, limit: 10 }; + return this.userService.find(query); + }) + ); + } + + onFilterChanged(filter: any): void { + this.page$.next(1); + this.filter$.next(filter); + } + + changePage(newPage: number): void { + if (newPage > 0) { + this.page$.next(newPage); + } + } + + deleteItem(id: number): void { + if (confirm('Are you sure you want to delete this item?')) { + this.userService.remove(id).subscribe({ + next: () => { + console.log(`Item with ID ${id} deleted successfully.`); + this.refresh$.next(); + }, + // --- THIS IS THE FIX --- + // Explicitly type 'err' to satisfy strict TypeScript rules. + error: (err: any) => { + console.error(`Error deleting item with ID ${id}:`, err); + } + }); + } + } +} \ No newline at end of file diff --git a/admin/src/app/features/user/components/user-table/user-data-provider.service.ts b/admin/src/app/features/user/components/user-table/user-data-provider.service.ts new file mode 100644 index 0000000..52ea152 --- /dev/null +++ b/admin/src/app/features/user/components/user-table/user-data-provider.service.ts @@ -0,0 +1,26 @@ +// dvbooking-cli/src/templates/angular-generic/data-provider.service.ts.tpl + +// Generated by the CLI +import { inject, Injectable } from '@angular/core'; +import { DataProvider, GetDataOptions, GetDataResponse } from '../../../../components/generic-table/data-provider.interface'; +import { User } from '../../models/user.model'; +import { map, Observable } from 'rxjs'; +import { UserService } from '../../services/user.service'; + +@Injectable({ + providedIn: 'root', +}) +export class UserDataProvider implements DataProvider { + private userService = inject(UserService); + + getData(options?: GetDataOptions): Observable> { + const {q,page,limit} = options?.params ?? {}; + // The generic table's params are compatible with our NestJS Query DTO + return this.userService.search(q ?? '',page,limit, ).pipe( + map((res) => { + // Adapt the paginated response to the GetDataResponse format + return { data: res }; + }) + ); + } +} \ No newline at end of file diff --git a/admin/src/app/features/user/components/user-table/user-table.component.html b/admin/src/app/features/user/components/user-table/user-table.component.html new file mode 100644 index 0000000..0bf1d83 --- /dev/null +++ b/admin/src/app/features/user/components/user-table/user-table.component.html @@ -0,0 +1,11 @@ + + + +
+
+

Users (Generic Table)

+ Create New +
+ + +
\ No newline at end of file diff --git a/admin/src/app/features/user/components/user-table/user-table.component.ts b/admin/src/app/features/user/components/user-table/user-table.component.ts new file mode 100644 index 0000000..4131e48 --- /dev/null +++ b/admin/src/app/features/user/components/user-table/user-table.component.ts @@ -0,0 +1,105 @@ +// dvbooking-cli/src/templates/angular-generic/table.component.ts.tpl + +// Generated by the CLI +import { Component, inject, OnInit } from '@angular/core'; +import { Router, RouterModule } from '@angular/router'; +import { User } from '../../models/user.model'; +import { UserDataProvider } from './user-data-provider.service'; +import { ColumnDefinition } from '../../../../components/generic-table/column-definition.interface'; +import { GenericTable } from '../../../../components/generic-table/generic-table'; +import { GenericTableConfig } from '../../../../components/generic-table/generic-table.config'; +import { + ActionDefinition, + GenericActionColumn, +} from '../../../../components/generic-action-column/generic-action-column'; +import { UserService } from '../../services/user.service'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'app-user-table', + standalone: true, + imports: [GenericTable, RouterModule], + templateUrl: './user-table.component.html', +}) +export class UserTableComponent implements OnInit { + + private refresh$ = new BehaviorSubject(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + private limit$ = new BehaviorSubject(10); + + router = inject(Router); + tableConfig!: GenericTableConfig; + + userDataProvider = inject(UserDataProvider); + userService = inject(UserService); + + ngOnInit(): void { + const actionHandler = (action: ActionDefinition, item: User) => { + switch (action.action) { + case 'view': + this.router.navigate(['/user', item?.id]); + break; + case 'edit': + this.router.navigate(['/user', item?.id, 'edit']); + break; + case 'delete': + this.deleteItem(item.id); + break; + } + }; + + this.tableConfig = { + refresh$: this.refresh$, + filter$: this.filter$, + page$: this.page$, + limit$: this.limit$, + dataProvider: this.userDataProvider, + columns: [ + { + attribute: 'username', + headerCell: true, + valueCell: true, + }, + { + attribute: 'email', + headerCell: true, + valueCell: true, + }, + { + attribute: 'actions', + headerCell: { value: 'Actions' }, + valueCell: { + component: GenericActionColumn, + componentInputs: item => ({ + item: item, + actions: [ + { action: 'view', handler: actionHandler }, + { action: 'edit', handler: actionHandler }, + { action: 'delete', handler: actionHandler }, + ] as ActionDefinition[], + }), + }, + }, + ] as ColumnDefinition[], + tableCssClass: 'user-table-container', + }; + } + + deleteItem(id: number): void { + if (confirm('Are you sure you want to delete this item?')) { + this.userService.remove(id).subscribe({ + next: () => { + console.log(`Item with ID ${id} deleted successfully.`); + this.refresh$.next(); + }, + // --- THIS IS THE FIX --- + // Explicitly type 'err' to satisfy strict TypeScript rules. + error: (err: any) => { + console.error(`Error deleting item with ID ${id}:`, err); + } + }); + } + } + +} diff --git a/admin/src/app/features/user/models/user.model.ts b/admin/src/app/features/user/models/user.model.ts new file mode 100644 index 0000000..b299b02 --- /dev/null +++ b/admin/src/app/features/user/models/user.model.ts @@ -0,0 +1,9 @@ +// dvbooking-cli/src/templates/angular/model.ts.tpl + +// Generated by the CLI +export interface User { + id: number; + username: string; + email: string; + password: string; +} diff --git a/admin/src/app/features/user/services/user.service.ts b/admin/src/app/features/user/services/user.service.ts new file mode 100644 index 0000000..6be4fc6 --- /dev/null +++ b/admin/src/app/features/user/services/user.service.ts @@ -0,0 +1,85 @@ +// dvbooking-cli/src/templates/angular/service.ts.tpl + +// Generated by the CLI +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { User } from '../models/user.model'; +import { ConfigurationService } from '../../../services/configuration.service'; +import { PaginatedResponse } from '../../../../types'; + + +export interface SearchResponse { + data: T[]; + total: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class UserService { + private readonly apiUrl: string; + + constructor( + private http: HttpClient, + private configService: ConfigurationService + ) { + this.apiUrl = `${this.configService.getApiUrl()}/user`; + } + + /** + * Find records with pagination and filtering. + */ + public find(filter: Record): Observable> { + // --- THIS IS THE FIX --- + // The incorrect line: .filter(([_, v]) for v != null) + // is now correctly written with an arrow function. + const cleanFilter = Object.fromEntries( + Object.entries(filter).filter(([_, v]) => v != null) + ); + // --- END OF FIX --- + + const params = new HttpParams({ fromObject: cleanFilter }); + return this.http.get>(this.apiUrl, { params }); + } + + /** + * Search across multiple fields with a single term. + * @param term The search term (q). + */ + public search(term: string, page: number = 1, limit: number = 10): Observable> { + const params = new HttpParams() + .set('q', term) + .set('page', page.toString()) + .set('limit', limit.toString()); + return this.http.get>(`${this.apiUrl}/search`, { params }); + } + + /** + * Find a single record by its ID. + */ + public findOne(id: number): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + /** + * Create a new record. + */ + public create(data: Omit): Observable { + return this.http.post(this.apiUrl, data); + } + + /** + * Update an existing record. + */ + public update(id: number, data: Partial>): Observable { + return this.http.patch(`${this.apiUrl}/${id}`, data); + } + + /** + * Remove a record by its ID. + */ + public remove(id: number): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } +} \ No newline at end of file diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 0e35f1f..2618fea 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -5,7 +5,6 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { UserModule } from './user/user.module'; import { AuthModule } from './auth/auth.module'; -import { User } from './entity/user'; import { UserGroup } from './entity/user-group'; import { UserRole } from './entity/user-role'; import { LoggerModule } from './logger/logger.module'; @@ -13,8 +12,9 @@ import { EventType } from './entity/event-type.entity'; import { EventTypesModule } from './event-type/event-type.module'; import { Product } from './entity/product.entity'; import { ProductsModule } from './product/products.module'; -import { Event } from "./entity/event.entity"; -import { EventsModule } from "./event/events.module"; +import { Event } from './entity/event.entity'; +import { EventsModule } from './event/events.module'; +import { User } from './entity/user'; const moduleTypeOrm = TypeOrmModule.forRootAsync({ imports: [ConfigModule], @@ -43,8 +43,8 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ LoggerModule, EventTypesModule, ProductsModule, - EventsModule - ], + EventsModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/server/src/user/dto/query-user.dto.ts b/server/src/user/dto/query-user.dto.ts new file mode 100644 index 0000000..a872493 --- /dev/null +++ b/server/src/user/dto/query-user.dto.ts @@ -0,0 +1,14 @@ +import { + IsOptional, + IsString, + IsNumber, + IsIn, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryUserDto { + @IsOptional() @Type(() => Number) @IsNumber() page?: number; + @IsOptional() @Type(() => Number) @IsNumber() limit?: number; + @IsOptional() @IsString() sortBy?: string; + @IsOptional() @IsIn(['ASC', 'DESC']) order?: 'ASC' | 'DESC'; +} diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 7413471..77e2723 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -8,6 +8,9 @@ import { Delete, UseGuards, ValidationPipe, + Query, + DefaultValuePipe, + ParseIntPipe, } from '@nestjs/common'; import { UserService } from './user.service'; import { CreateUserDto } from './dto/create-user.dto'; @@ -18,7 +21,7 @@ import { Roles } from '../auth/roles.decorator'; import { Role } from '../auth/role.enum'; import { RolesGuard } from '../auth/roles.guard'; -@Controller('users') +@Controller('user') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(Role.Admin) export class UserController { @@ -36,6 +39,15 @@ export class UserController { return this.userService.findAll(); } + @Get('search') + search( + @Query('q') term: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ) { + return this.userService.search(term, { page, limit }); + } + @Get(':id') findOne(@Param('id') id: string): Promise { return this.userService.findOne(+id); diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 7f3528b..c9630a9 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { ILike, Repository } from 'typeorm'; import { User } from '../entity/user'; import * as bcrypt from 'bcrypt'; import { FindOptionsRelations } from 'typeorm/find-options/FindOptionsRelations'; @@ -8,6 +8,8 @@ import { DvbookingLoggerService } from '../logger/dvbooking-logger.service'; @Injectable() export class UserService { + private readonly searchableFields: (keyof User)[] = ['username', 'email']; + constructor( @InjectRepository(User) private usersRepository: Repository, @@ -55,6 +57,40 @@ export class UserService { await this.usersRepository.delete(id); } + async search(term: string, options: { page: number; limit: number }) { + if (this.searchableFields.length === 0) { + console.warn('Search is not configured for this entity.'); + return { + data: [], + meta: { + totalItems: 0, + itemCount: 0, + itemsPerPage: options.limit, + totalPages: 0, + currentPage: options.page, + }, + }; + } + const whereConditions = this.searchableFields.map((field) => ({ + [field]: ILike(`%${term}%`), + })); + const [data, totalItems] = await this.usersRepository.findAndCount({ + where: whereConditions, + skip: (options.page - 1) * options.limit, + take: options.limit, + }); + return { + data, + meta: { + totalItems, + itemCount: data.length, + itemsPerPage: options.limit, + totalPages: Math.ceil(totalItems / options.limit), + currentPage: options.page, + }, + }; + } + async setRefreshToken( id: number, refreshToken: string | null,