diff --git a/backend/src/collections/userdb/userdb.module.ts b/backend/src/collections/userdb/userdb.module.ts index c0fc80b..911ab38 100644 --- a/backend/src/collections/userdb/userdb.module.ts +++ b/backend/src/collections/userdb/userdb.module.ts @@ -6,6 +6,7 @@ import { PicsurConfigModule } from '../../config/config.module'; import { EUserBackend } from '../../models/entities/user.entity'; import { RolesModule } from '../roledb/roledb.module'; import { UsersService } from './userdb.service'; +import { UserRolesService } from './userrolesdb.service'; @Module({ imports: [ @@ -13,14 +14,15 @@ import { UsersService } from './userdb.service'; RolesModule, TypeOrmModule.forFeature([EUserBackend]), ], - providers: [UsersService], - exports: [UsersService], + providers: [UsersService, UserRolesService], + exports: [UsersService, UserRolesService], }) export class UsersModule implements OnModuleInit { private readonly logger = new Logger('UsersModule'); constructor( private usersService: UsersService, + private userRolesService: UserRolesService, private authConfigService: AuthConfigService, ) {} @@ -44,7 +46,7 @@ export class UsersModule implements OnModuleInit { return; } - const result = await this.usersService.addRoles(newUser, ['admin']); + const result = await this.userRolesService.addRoles(newUser, ['admin']); if (HasFailed(result)) { this.logger.error( `Failed to make admin user "${username}" because: ${result.getReason()}`, diff --git a/backend/src/collections/userdb/userdb.service.ts b/backend/src/collections/userdb/userdb.service.ts index 66c91a0..33ff0e1 100644 --- a/backend/src/collections/userdb/userdb.service.ts +++ b/backend/src/collections/userdb/userdb.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import * as bcrypt from 'bcrypt'; import { plainToClass } from 'class-transformer'; -import { Permissions } from 'picsur-shared/dist/dto/permissions'; import { PermanentRolesList, Roles } from 'picsur-shared/dist/dto/roles.dto'; import { AsyncFailable, @@ -16,6 +15,8 @@ import { EUserBackend } from '../../models/entities/user.entity'; import { GetCols } from '../collectionutils'; import { RolesService } from '../roledb/roledb.service'; +const BCryptStrength = 12; + @Injectable() export class UsersService { private readonly logger = new Logger('UsersService'); @@ -35,7 +36,7 @@ export class UsersService { ): AsyncFailable { if (await this.exists(username)) return Fail('User already exists'); - const hashedPassword = await bcrypt.hash(password, 12); + const hashedPassword = await bcrypt.hash(password, BCryptStrength); let user = new EUserBackend(); user.username = username; @@ -65,6 +66,52 @@ export class UsersService { } } + // Updating + + public async setRoles( + user: string | EUserBackend, + roles: Roles, + ): AsyncFailable { + const userToModify = await this.resolve(user); + if (HasFailed(userToModify)) return userToModify; + + const rolesToKeep = userToModify.roles.filter((role) => + PermanentRolesList.includes(role), + ); + const rolesToAdd = roles.filter( + (role) => !PermanentRolesList.includes(role), + ); + + const newRoles = [...new Set([...rolesToKeep, ...rolesToAdd])]; + + userToModify.roles = newRoles; + + try { + return await this.usersRepository.save(userToModify); + } catch (e: any) { + return Fail(e?.message); + } + } + + public async updatePassword( + user: string | EUserBackend, + password: string, + ): AsyncFailable { + const userToModify = await this.resolve(user); + if (HasFailed(userToModify)) return userToModify; + + const hashedPassword = await bcrypt.hash(password, BCryptStrength); + + userToModify.password = hashedPassword; + + try { + const fullUser = await this.usersRepository.save(userToModify); + return plainToClass(EUserBackend, fullUser); + } catch (e: any) { + return Fail(e?.message); + } + } + // Authentication async authenticate( @@ -80,62 +127,6 @@ export class UsersService { return await this.findOne(username); } - // Permissions and roles - - public async getPermissions( - user: string | EUserBackend, - ): AsyncFailable { - const userToModify = await this.resolve(user); - if (HasFailed(userToModify)) return userToModify; - - return await this.rolesService.getPermissions(userToModify.roles); - } - - public async addRoles( - user: string | EUserBackend, - roles: Roles, - ): AsyncFailable { - const userToModify = await this.resolve(user); - if (HasFailed(userToModify)) return userToModify; - - const newRoles = [...new Set([...userToModify.roles, ...roles])]; - - return this.setRoles(userToModify, newRoles); - } - - public async removeRoles( - user: string | EUserBackend, - roles: Roles, - ): AsyncFailable { - const userToModify = await this.resolve(user); - if (HasFailed(userToModify)) return userToModify; - - const newRoles = userToModify.roles.filter((role) => !roles.includes(role)); - - return this.setRoles(userToModify, newRoles); - } - - public async setRoles( - user: string | EUserBackend, - roles: Roles, - ): AsyncFailable { - const userToModify = await this.resolve(user); - if (HasFailed(userToModify)) return userToModify; - - const rolesToKeep = userToModify.roles.filter((role) => - PermanentRolesList.includes(role), - ); - const newRoles = [...new Set([...rolesToKeep, ...roles])]; - - userToModify.roles = newRoles; - - try { - return await this.usersRepository.save(userToModify); - } catch (e: any) { - return Fail(e?.message); - } - } - // Listing public async findOne( @@ -182,7 +173,7 @@ export class UsersService { // Internal resolver - private async resolve( + public async resolve( user: string | EUserBackend, ): AsyncFailable { if (typeof user === 'string') { diff --git a/backend/src/collections/userdb/userrolesdb.service.ts b/backend/src/collections/userdb/userrolesdb.service.ts new file mode 100644 index 0000000..4bdb0c6 --- /dev/null +++ b/backend/src/collections/userdb/userrolesdb.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { Permissions } from 'picsur-shared/dist/dto/permissions'; +import { Roles } from 'picsur-shared/dist/dto/roles.dto'; +import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types'; +import { EUserBackend } from '../../models/entities/user.entity'; +import { RolesService } from '../roledb/roledb.service'; +import { UsersService } from './userdb.service'; + +@Injectable() +export class UserRolesService { + constructor(private usersService: UsersService, private rolesService: RolesService){} + + // Permissions and roles + + public async getPermissions( + user: string | EUserBackend, + ): AsyncFailable { + const userToModify = await this.usersService.resolve(user); + if (HasFailed(userToModify)) return userToModify; + + return await this.rolesService.getPermissions(userToModify.roles); + } + + public async addRoles( + user: string | EUserBackend, + roles: Roles, + ): AsyncFailable { + const userToModify = await this.usersService.resolve(user); + if (HasFailed(userToModify)) return userToModify; + + const newRoles = [...new Set([...userToModify.roles, ...roles])]; + + return this.usersService.setRoles(userToModify, newRoles); + } + + public async removeRoles( + user: string | EUserBackend, + roles: Roles, + ): AsyncFailable { + const userToModify = await this.usersService.resolve(user); + if (HasFailed(userToModify)) return userToModify; + + const newRoles = userToModify.roles.filter((role) => !roles.includes(role)); + + return this.usersService.setRoles(userToModify, newRoles); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 70c24c4..899da34 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -8,6 +8,7 @@ import * as multipart from 'fastify-multipart'; import { ValidateOptions } from 'picsur-shared/dist/util/validate'; import { AppModule } from './app.module'; import { UsersService } from './collections/userdb/userdb.service'; +import { UserRolesService } from './collections/userdb/userrolesdb.service'; import { HostConfigService } from './config/host.config.service'; import { MainExceptionFilter } from './layers/httpexception/httpexception.filter'; import { SuccessInterceptor } from './layers/success/success.interceptor'; @@ -15,11 +16,12 @@ import { PicsurLoggerService } from './logger/logger.service'; import { MainAuthGuard } from './managers/auth/guards/main.guard'; async function bootstrap() { + // Create fasify const fastifyAdapter = new FastifyAdapter(); - // TODO: generic error messages fastifyAdapter.register(multipart as any); + // Create nest app const app = await NestFactory.create( AppModule, fastifyAdapter, @@ -27,15 +29,23 @@ async function bootstrap() { bufferLogs: true, }, ); + + // Configure nest app app.useGlobalFilters(new MainExceptionFilter()); app.useGlobalInterceptors(new SuccessInterceptor()); app.useGlobalPipes(new ValidationPipe(ValidateOptions)); app.useGlobalGuards( - new MainAuthGuard(app.get(Reflector), app.get(UsersService)), + new MainAuthGuard( + app.get(Reflector), + app.get(UsersService), + app.get(UserRolesService), + ), ); + // Configure logger app.useLogger(app.get(PicsurLoggerService)); + // Start app const hostConfigService = app.get(HostConfigService); await app.listen(hostConfigService.getPort(), hostConfigService.getHost()); } diff --git a/backend/src/managers/auth/guards/main.guard.ts b/backend/src/managers/auth/guards/main.guard.ts index ec219f4..d50b328 100644 --- a/backend/src/managers/auth/guards/main.guard.ts +++ b/backend/src/managers/auth/guards/main.guard.ts @@ -15,6 +15,7 @@ import { Fail, Failable, HasFailed } from 'picsur-shared/dist/types'; import { isPermissionsArray } from 'picsur-shared/dist/util/permissions'; import { strictValidate } from 'picsur-shared/dist/util/validate'; import { UsersService } from '../../../collections/userdb/userdb.service'; +import { UserRolesService } from '../../../collections/userdb/userrolesdb.service'; import { EUserBackend } from '../../../models/entities/user.entity'; @Injectable() @@ -24,6 +25,7 @@ export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) { constructor( private reflector: Reflector, private usersService: UsersService, + private userRolesService: UserRolesService, ) { super(); } @@ -45,7 +47,7 @@ export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) { throw new InternalServerErrorException(); } - const userPermissions = await this.usersService.getPermissions(user); + const userPermissions = await this.userRolesService.getPermissions(user); if (HasFailed(userPermissions)) { this.logger.warn('111' + userPermissions.getReason()); throw new InternalServerErrorException(); diff --git a/backend/src/routes/api/user/user.controller.ts b/backend/src/routes/api/user/user.controller.ts index 4b9f709..f25f09d 100644 --- a/backend/src/routes/api/user/user.controller.ts +++ b/backend/src/routes/api/user/user.controller.ts @@ -17,6 +17,7 @@ import { import { Permission } from 'picsur-shared/dist/dto/permissions'; import { HasFailed } from 'picsur-shared/dist/types'; import { UsersService } from '../../../collections/userdb/userdb.service'; +import { UserRolesService } from '../../../collections/userdb/userrolesdb.service'; import { NoPermissions, RequiredPermissions, @@ -31,6 +32,7 @@ export class UserController { constructor( private usersService: UsersService, + private userRolesSerivce: UserRolesService, private authService: AuthManagerService, ) {} @@ -74,7 +76,7 @@ export class UserController { async refresh( @Request() req: AuthFasityRequest, ): Promise { - const permissions = await this.usersService.getPermissions(req.user); + const permissions = await this.userRolesSerivce.getPermissions(req.user); if (HasFailed(permissions)) { this.logger.warn(permissions.getReason()); throw new InternalServerErrorException('Could not get permissions'); diff --git a/backend/src/routes/api/user/usermanage.controller.ts b/backend/src/routes/api/user/usermanage.controller.ts index 75c4ed5..9e1adf7 100644 --- a/backend/src/routes/api/user/usermanage.controller.ts +++ b/backend/src/routes/api/user/usermanage.controller.ts @@ -15,8 +15,8 @@ import { UserInfoResponse, UserListRequest, UserListResponse, - UserUpdateRolesRequest, - UserUpdateRolesResponse + UserUpdateRequest, + UserUpdateResponse } from 'picsur-shared/dist/dto/api/usermanage.dto'; import { Permission } from 'picsur-shared/dist/dto/permissions'; import { HasFailed } from 'picsur-shared/dist/types'; @@ -63,6 +63,7 @@ export class UserManageController { const user = await this.usersService.create( create.username, create.password, + create.roles, ); if (HasFailed(user)) { this.logger.warn(user.getReason()); @@ -96,20 +97,32 @@ export class UserManageController { return user; } - @Post('roles') + @Post('update') async setPermissions( - @Body() body: UserUpdateRolesRequest, - ): Promise { - const updatedUser = await this.usersService.setRoles( - body.username, - body.roles, - ); - - if (HasFailed(updatedUser)) { - this.logger.warn(updatedUser.getReason()); - throw new InternalServerErrorException('Could not update user'); + @Body() body: UserUpdateRequest, + ): Promise { + let user = await this.usersService.findOne(body.username); + if (HasFailed(user)) { + this.logger.warn(user.getReason()); + throw new InternalServerErrorException('Could not find user'); } - return updatedUser; + if (body.roles) { + user = await this.usersService.setRoles(user, body.roles); + if (HasFailed(user)) { + this.logger.warn(user.getReason()); + throw new InternalServerErrorException('Could not update user'); + } + } + + if (body.password) { + user = await this.usersService.updatePassword(user, body.password); + if (HasFailed(user)) { + this.logger.warn(user.getReason()); + throw new InternalServerErrorException('Could not update user'); + } + } + + return user; } } diff --git a/frontend/package.json b/frontend/package.json index a7057e1..3237063 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "bootstrap": "^5.1.3", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", + "fuse.js": "^6.5.3", "jwt-decode": "^3.1.2", "ngx-auto-unsubscribe-decorator": "^1.0.0", "ngx-dropzone": "^3.1.0", diff --git a/frontend/src/app/components/header/header.component.ts b/frontend/src/app/components/header/header.component.ts index 34fbb86..7e479dc 100644 --- a/frontend/src/app/components/header/header.component.ts +++ b/frontend/src/app/components/header/header.component.ts @@ -69,8 +69,8 @@ export class HeaderComponent implements OnInit { this.router.navigate(['/user/login']); } - doLogout() { - const user = this.userService.logout(); + async doLogout() { + const user = await this.userService.logout(); if (HasFailed(user)) { this.utilService.showSnackBar(user.getReason(), SnackBarType.Error); return; diff --git a/frontend/src/app/models/forms/default-validators.ts b/frontend/src/app/models/forms/default-validators.ts index 8813785..314a7c9 100644 --- a/frontend/src/app/models/forms/default-validators.ts +++ b/frontend/src/app/models/forms/default-validators.ts @@ -12,7 +12,6 @@ function errorsToError(errors: ValidationErrors | null): string { } export const UsernameValidators = [ - Validators.required, Validators.minLength(4), Validators.maxLength(32), Validators.pattern('^[a-zA-Z0-9]+$'), @@ -37,7 +36,6 @@ export const CreateUsernameError = ( }; export const PasswordValidators = [ - Validators.required, Validators.minLength(4), Validators.maxLength(1024), ]; diff --git a/frontend/src/app/models/forms/fulluser.model.ts b/frontend/src/app/models/forms/fulluser.model.ts new file mode 100644 index 0000000..df4cb47 --- /dev/null +++ b/frontend/src/app/models/forms/fulluser.model.ts @@ -0,0 +1,7 @@ +import { Roles } from 'picsur-shared/dist/dto/roles.dto'; + +export interface FullUserModel { + username: string; + password: string; + roles: Roles; +} diff --git a/frontend/src/app/models/forms/login.model.ts b/frontend/src/app/models/forms/login.control.ts similarity index 95% rename from frontend/src/app/models/forms/login.model.ts rename to frontend/src/app/models/forms/login.control.ts index 0cb7946..987e421 100644 --- a/frontend/src/app/models/forms/login.model.ts +++ b/frontend/src/app/models/forms/login.control.ts @@ -6,7 +6,7 @@ import { PasswordValidators, UsernameValidators } from './default-validators'; -import { UserPassModel } from './userpass'; +import { UserPassModel } from './userpass.model'; export class LoginControl { public username = new FormControl('', UsernameValidators); diff --git a/frontend/src/app/models/forms/register.model.ts b/frontend/src/app/models/forms/register.control.ts similarity index 96% rename from frontend/src/app/models/forms/register.model.ts rename to frontend/src/app/models/forms/register.control.ts index 5397a9b..2791452 100644 --- a/frontend/src/app/models/forms/register.model.ts +++ b/frontend/src/app/models/forms/register.control.ts @@ -7,7 +7,7 @@ import { PasswordValidators, UsernameValidators } from './default-validators'; -import { UserPassModel } from './userpass'; +import { UserPassModel } from './userpass.model'; export class RegisterControl { public username = new FormControl('', UsernameValidators); diff --git a/frontend/src/app/models/forms/updateuser.control.ts b/frontend/src/app/models/forms/updateuser.control.ts new file mode 100644 index 0000000..d172bd3 --- /dev/null +++ b/frontend/src/app/models/forms/updateuser.control.ts @@ -0,0 +1,123 @@ +import { FormControl } from '@angular/forms'; +import Fuse from 'fuse.js'; +import { PermanentRolesList } from 'picsur-shared/dist/dto/roles.dto'; +import { ERole } from 'picsur-shared/dist/entities/role.entity'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { + CreatePasswordError, + CreateUsernameError, + PasswordValidators, + UsernameValidators +} from './default-validators'; +import { FullUserModel } from './fulluser.model'; + +export class UpdateUserControl { + // Set once + private fullRoles: ERole[] = []; + private roles: string[] = []; + + // Variables + private selectableRolesSubject = new BehaviorSubject([]); + private rolesInputSubscription: null | Subscription; + + public username = new FormControl('', UsernameValidators); + public password = new FormControl('', PasswordValidators); + + public rolesControl = new FormControl('', []); + public selectableRoles = this.selectableRolesSubject.asObservable(); + public selectedRoles: string[] = []; + + public get usernameValue() { + return this.username.value; + } + + public get usernameError() { + return CreateUsernameError(this.username.errors); + } + + public get passwordError() { + return CreatePasswordError(this.password.errors); + } + + constructor() { + this.rolesInputSubscription = this.rolesControl.valueChanges.subscribe( + (roles) => { + this.updateSelectableRoles(); + } + ); + } + + public destroy() { + if (this.rolesInputSubscription) { + this.rolesInputSubscription.unsubscribe(); + this.rolesInputSubscription = null; + } + } + + public addRole(role: string) { + if (!this.selectableRolesSubject.value.includes(role)) return; + + this.selectedRoles.push(role); + this.clearInput(); + } + + public removeRole(role: string) { + this.selectedRoles = this.selectedRoles.filter((r) => r !== role); + this.updateSelectableRoles(); + } + + public isRemovable(role: string) { + if (PermanentRolesList.includes(role)) return false; + return true; + } + + // Data interaction + + public putAllRoles(roles: ERole[]) { + this.fullRoles = roles; + this.roles = roles.map((role) => role.name); + this.updateSelectableRoles(); + } + + public putUsername(username: string) { + this.username.setValue(username); + } + + public putRoles(roles: string[]) { + this.selectedRoles = roles; + this.updateSelectableRoles(); + } + + public getData(): FullUserModel { + return { + username: this.username.value, + password: this.password.value, + roles: this.selectedRoles, + }; + } + + // Logic + + private updateSelectableRoles() { + const availableRoles = this.roles.filter( + // Not available if either already selected, or the role is not addable/removable + (r) => !(this.selectedRoles.includes(r) || PermanentRolesList.includes(r)) + ); + + const searchValue = this.rolesControl.value; + if (searchValue && availableRoles.length > 0) { + const fuse = new Fuse(availableRoles); + const result = fuse + .search(this.rolesControl.value ?? '') + .map((r) => r.item); + + this.selectableRolesSubject.next(result); + } else { + this.selectableRolesSubject.next(availableRoles); + } + } + + private clearInput() { + this.rolesControl.setValue(''); + } +} diff --git a/frontend/src/app/models/forms/userpass.ts b/frontend/src/app/models/forms/userpass.model.ts similarity index 100% rename from frontend/src/app/models/forms/userpass.ts rename to frontend/src/app/models/forms/userpass.model.ts diff --git a/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.html b/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.html index d905acd..4e46833 100644 --- a/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.html +++ b/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.html @@ -1 +1,100 @@ -

settings-users-edit works!

+ +

Editing {{ model.usernameValue }}

+
+ +

Add new user

+
+ +
+
+
+ Failed to add user + Failed to update user +
+
+ +
+
+ + Username + + {{ + model.usernameError + }} + +
+
+ +
+
+ + {{ editing ? "New Password" : "Password" }} + + {{ + model.passwordError + }} + +
+
+ +
+
+ + Roles + + + {{ role }} + + + + + + + {{ role }} + + + +
+
+ +
+
+ +
+
+
diff --git a/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.scss b/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.scss index e69de29..c7acb4b 100644 --- a/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.scss +++ b/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.scss @@ -0,0 +1,3 @@ +mat-form-field { + width: 100%; +} diff --git a/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.ts b/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.ts index 1ba31ea..290d1a7 100644 --- a/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.ts +++ b/frontend/src/app/routes/settings/users/settings-users-edit/settings-users-edit.component.ts @@ -1,15 +1,128 @@ +import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; import { Component, OnInit } from '@angular/core'; +import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatChipInputEvent } from '@angular/material/chips'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HasFailed } from 'picsur-shared/dist/types'; +import { UpdateUserControl } from 'src/app/models/forms/updateuser.control'; +import { SnackBarType } from 'src/app/models/snack-bar-type'; +import { RolesService } from 'src/app/services/api/roles.service'; +import { UserManageService } from 'src/app/services/api/usermanage.service'; +import { UtilService } from 'src/app/util/util.service'; + +enum EditMode { + edit = 'edit', + add = 'add', +} @Component({ selector: 'app-settings-users-edit', templateUrl: './settings-users-edit.component.html', - styleUrls: ['./settings-users-edit.component.scss'] + styleUrls: ['./settings-users-edit.component.scss'], }) export class SettingsUsersEditComponent implements OnInit { + readonly separatorKeysCodes: number[] = [ENTER, COMMA, SPACE]; - constructor() { } + private mode: EditMode = EditMode.edit; - ngOnInit(): void { + model = new UpdateUserControl(); + updateFail: boolean = false; + + get adding() { + return this.mode === EditMode.add; + } + get editing() { + return this.mode === EditMode.edit; } + constructor( + private route: ActivatedRoute, + private router: Router, + private userManageService: UserManageService, + private utilService: UtilService, + private rolesService: RolesService + ) {} + + ngOnInit() { + Promise.all([this.initUser(), this.initRoles()]).catch(console.error); + } + + private async initUser() { + const username = this.route.snapshot.paramMap.get('username'); + if (!username) { + this.mode = EditMode.add; + return; + } + + this.mode = EditMode.edit; + this.model.putUsername(username); + + const user = await this.userManageService.getUser(username); + if (HasFailed(user)) { + this.utilService.showSnackBar('Failed to get user', SnackBarType.Error); + return; + } + + this.model.putUsername(user.username); + this.model.putRoles(user.roles); + } + + private async initRoles() { + const roles = await this.rolesService.getRoles(); + if (HasFailed(roles)) { + this.utilService.showSnackBar('Failed to get roles', SnackBarType.Error); + return; + } + + this.model.putAllRoles(roles); + } + + removeRole(role: string) { + this.model.removeRole(role); + } + + addRole(event: MatChipInputEvent) { + const value = (event.value ?? '').trim(); + this.model.addRole(value); + } + + selectedRole(event: MatAutocompleteSelectedEvent): void { + this.model.addRole(event.option.viewValue); + } + + async updateUser() { + const data = this.model.getData(); + + if (this.adding) { + const resultUser = await this.userManageService.createUser(data); + if (HasFailed(resultUser)) { + this.utilService.showSnackBar( + 'Failed to create user', + SnackBarType.Error + ); + return; + } + + this.utilService.showSnackBar('User created', SnackBarType.Success); + } else { + const updateData = data.password + ? data + : { username: data.username, roles: data.roles }; + + const resultUser = await this.userManageService.updateUser( + updateData as any + ); + if (HasFailed(resultUser)) { + this.utilService.showSnackBar( + 'Failed to update user', + SnackBarType.Error + ); + return; + } + + this.utilService.showSnackBar('User updated', SnackBarType.Success); + } + + this.router.navigate(['/settings/users']); + } } diff --git a/frontend/src/app/routes/settings/users/settings-users.component.html b/frontend/src/app/routes/settings/users/settings-users.component.html index 4f86651..b19b086 100644 --- a/frontend/src/app/routes/settings/users/settings-users.component.html +++ b/frontend/src/app/routes/settings/users/settings-users.component.html @@ -1,10 +1,10 @@

Users

- + Username @@ -14,7 +14,7 @@ Actions - @@ -24,8 +24,23 @@ pog + + +
-
diff --git a/frontend/src/app/routes/settings/users/settings-users.component.ts b/frontend/src/app/routes/settings/users/settings-users.component.ts index f89ec4c..e3ee7a5 100644 --- a/frontend/src/app/routes/settings/users/settings-users.component.ts +++ b/frontend/src/app/routes/settings/users/settings-users.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { Router } from '@angular/router'; import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator'; import { EUser } from 'picsur-shared/dist/entities/user.entity'; import { HasFailed } from 'picsur-shared/dist/types'; @@ -13,7 +14,7 @@ import { UtilService } from 'src/app/util/util.service'; styleUrls: ['./settings-users.component.scss'], }) export class SettingsUsersComponent implements OnInit { - public readonly displayedColumns: string[] = ['id', 'username', 'actions']; + public readonly displayedColumns: string[] = [/*'id',*/ 'username', 'actions']; public readonly pageSizeOptions: number[] = [5, 10, 25, 100]; public readonly startingPageSize = this.pageSizeOptions[2]; @@ -24,7 +25,8 @@ export class SettingsUsersComponent implements OnInit { constructor( private userManageService: UserManageService, - private utilService: UtilService + private utilService: UtilService, + private router: Router ) {} async ngOnInit() { @@ -32,6 +34,14 @@ export class SettingsUsersComponent implements OnInit { this.fetchUsers(this.startingPageSize, 0); } + public editUser(user: EUser) { + this.router.navigate(['/settings/users/edit', user.username]); + } + + public addUser() { + this.router.navigate(['/settings/users/add']); + } + @AutoUnsubscribe() private subscribeToUpdate() { return this.updateSubject diff --git a/frontend/src/app/routes/settings/users/settings-users.module.ts b/frontend/src/app/routes/settings/users/settings-users.module.ts index ac7a4e9..63f2ad0 100644 --- a/frontend/src/app/routes/settings/users/settings-users.module.ts +++ b/frontend/src/app/routes/settings/users/settings-users.module.ts @@ -1,8 +1,12 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; +import { MatChipsModule } from '@angular/material/chips'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; import { MatPaginatorModule } from '@angular/material/paginator'; import { MatTableModule } from '@angular/material/table'; import { SettingsUsersEditComponent } from './settings-users-edit/settings-users-edit.component'; @@ -19,6 +23,11 @@ import { SettingsUsersRoutingModule } from './settings-users.routing.module'; MatTableModule, MatPaginatorModule, MatFormFieldModule, + MatInputModule, + MatChipsModule, + MatAutocompleteModule, + FormsModule, + ReactiveFormsModule, ], }) export class SettingsUsersRouteModule {} diff --git a/frontend/src/app/routes/settings/users/settings-users.routing.module.ts b/frontend/src/app/routes/settings/users/settings-users.routing.module.ts index a5bc6da..2861e9e 100644 --- a/frontend/src/app/routes/settings/users/settings-users.routing.module.ts +++ b/frontend/src/app/routes/settings/users/settings-users.routing.module.ts @@ -10,7 +10,11 @@ const routes: PRoutes = [ component: SettingsUsersComponent, }, { - path: 'edit/:id', + path: 'edit/:username', + component: SettingsUsersEditComponent, + }, + { + path: 'add', component: SettingsUsersEditComponent, } ]; diff --git a/frontend/src/app/routes/user/login/login.component.scss b/frontend/src/app/routes/user/login/login.component.scss new file mode 100644 index 0000000..399068f --- /dev/null +++ b/frontend/src/app/routes/user/login/login.component.scss @@ -0,0 +1,4 @@ +mat-form-field { + max-width: 40rem; + width: inherit; +} diff --git a/frontend/src/app/routes/user/login/login.component.ts b/frontend/src/app/routes/user/login/login.component.ts index eebc040..f92d7f9 100644 --- a/frontend/src/app/routes/user/login/login.component.ts +++ b/frontend/src/app/routes/user/login/login.component.ts @@ -7,11 +7,12 @@ import { SnackBarType } from 'src/app/models/snack-bar-type'; import { PermissionService } from 'src/app/services/api/permission.service'; import { UserService } from 'src/app/services/api/user.service'; import { UtilService } from 'src/app/util/util.service'; -import { LoginControl } from '../../../models/forms/login.model'; -import { UserPassModel } from '../../../models/forms/userpass'; +import { LoginControl } from '../../../models/forms/login.control'; +import { UserPassModel } from '../../../models/forms/userpass.model'; @Component({ templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'], }) export class LoginComponent implements OnInit { private readonly logger = console; diff --git a/frontend/src/app/routes/user/register/register.component.scss b/frontend/src/app/routes/user/register/register.component.scss new file mode 100644 index 0000000..399068f --- /dev/null +++ b/frontend/src/app/routes/user/register/register.component.scss @@ -0,0 +1,4 @@ +mat-form-field { + max-width: 40rem; + width: inherit; +} diff --git a/frontend/src/app/routes/user/register/register.component.ts b/frontend/src/app/routes/user/register/register.component.ts index 2c06009..da2fdac 100644 --- a/frontend/src/app/routes/user/register/register.component.ts +++ b/frontend/src/app/routes/user/register/register.component.ts @@ -3,15 +3,16 @@ import { Router } from '@angular/router'; import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator'; import { Permission, Permissions } from 'picsur-shared/dist/dto/permissions'; import { HasFailed } from 'picsur-shared/dist/types'; -import { UserPassModel } from 'src/app/models/forms/userpass'; +import { UserPassModel } from 'src/app/models/forms/userpass.model'; import { SnackBarType } from 'src/app/models/snack-bar-type'; import { PermissionService } from 'src/app/services/api/permission.service'; import { UserService } from 'src/app/services/api/user.service'; import { UtilService } from 'src/app/util/util.service'; -import { RegisterControl } from '../../../models/forms/register.model'; +import { RegisterControl } from '../../../models/forms/register.control'; @Component({ templateUrl: './register.component.html', + styleUrls: ['./register.component.scss'], }) export class RegisterComponent implements OnInit { private readonly logger = console; diff --git a/frontend/src/app/routes/view/view.module.ts b/frontend/src/app/routes/view/view.module.ts index 6e95bfd..20681d6 100644 --- a/frontend/src/app/routes/view/view.module.ts +++ b/frontend/src/app/routes/view/view.module.ts @@ -1,10 +1,11 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import Fuse from 'fuse.js'; import { CopyFieldModule } from 'src/app/components/copyfield/copyfield.module'; import { ViewComponent } from './view.component'; import { ViewRoutingModule } from './view.routing.module'; - +const a = Fuse; @NgModule({ declarations: [ViewComponent], imports: [CommonModule, CopyFieldModule, ViewRoutingModule, MatButtonModule], diff --git a/frontend/src/app/services/api/roles.service.ts b/frontend/src/app/services/api/roles.service.ts new file mode 100644 index 0000000..37ceaf3 --- /dev/null +++ b/frontend/src/app/services/api/roles.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { RoleListResponse } from 'picsur-shared/dist/dto/api/roles.dto'; +import { ERole } from 'picsur-shared/dist/entities/role.entity'; +import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types'; +import { ApiService } from './api.service'; + +@Injectable({ + providedIn: 'root', +}) +export class RolesService { + constructor(private apiService: ApiService) {} + + public async getRoles(): AsyncFailable { + const result = await this.apiService.get( + RoleListResponse, + '/api/roles/list' + ); + + if (HasFailed(result)) { + return result; + } + + return result.roles; + } +} diff --git a/frontend/src/app/services/api/usermanage.service.ts b/frontend/src/app/services/api/usermanage.service.ts index 373665c..22fc10b 100644 --- a/frontend/src/app/services/api/usermanage.service.ts +++ b/frontend/src/app/services/api/usermanage.service.ts @@ -1,10 +1,17 @@ import { Injectable } from '@angular/core'; import { + UserCreateRequest, + UserCreateResponse, + UserInfoRequest, + UserInfoResponse, UserListRequest, - UserListResponse + UserListResponse, + UserUpdateRequest, + UserUpdateResponse } from 'picsur-shared/dist/dto/api/usermanage.dto'; import { EUser } from 'picsur-shared/dist/entities/user.entity'; import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types'; +import { FullUserModel } from 'src/app/models/forms/fulluser.model'; import { ApiService } from './api.service'; @Injectable({ @@ -13,6 +20,21 @@ import { ApiService } from './api.service'; export class UserManageService { constructor(private apiService: ApiService) {} + public async getUser(username: string): AsyncFailable { + const body = { + username, + }; + + const result = await this.apiService.post( + UserInfoRequest, + UserInfoResponse, + 'api/user/info', + body + ); + + return result; + } + public async getUsers(count: number, page: number): AsyncFailable { const body = { count, @@ -32,4 +54,26 @@ export class UserManageService { return result.users; } + + public async createUser(user: FullUserModel): AsyncFailable { + const result = await this.apiService.post( + UserCreateRequest, + UserCreateResponse, + '/api/user/create', + user + ); + + return result; + } + + public async updateUser(user: FullUserModel): AsyncFailable { + const result = await this.apiService.post( + UserUpdateRequest, + UserUpdateResponse, + '/api/user/update', + user + ); + + return result; + } } diff --git a/frontend/src/scss/fixes.scss b/frontend/src/scss/fixes.scss index 1ce15f9..e21f020 100644 --- a/frontend/src/scss/fixes.scss +++ b/frontend/src/scss/fixes.scss @@ -36,9 +36,3 @@ html { width: initial !important; } -// Fix small form inputs - -form mat-form-field { - width: inherit; - max-width: 40rem; -} diff --git a/frontend/src/scss/personal.scss b/frontend/src/scss/personal.scss index 7d21d3b..e3196f8 100644 --- a/frontend/src/scss/personal.scss +++ b/frontend/src/scss/personal.scss @@ -5,6 +5,8 @@ border-style: solid; border-width: 5px; + + transition: all 0.2s ease-in-out; } // Easily center content @@ -63,6 +65,11 @@ // Anim -.fullanimate, .fullanimate * { +.container, .row > div { + transition: ease-in-out all 0.2s; +} + +.fullanimate, +.fullanimate * { transition: ease-in-out all 0.2s !important; } diff --git a/shared/src/dto/api/usermanage.dto.ts b/shared/src/dto/api/usermanage.dto.ts index 299d8f3..c7a3f0b 100644 --- a/shared/src/dto/api/usermanage.dto.ts +++ b/shared/src/dto/api/usermanage.dto.ts @@ -3,11 +3,13 @@ import { IsArray, IsDefined, IsInt, + IsOptional, IsString, Min, ValidateNested } from 'class-validator'; import { EUser, NamePassUser, UsernameUser } from '../../entities/user.entity'; +import { IsPlainTextPwd } from '../../validators/user.validators'; import { Roles } from '../roles.dto'; // UserList @@ -42,7 +44,12 @@ export class UserListResponse { } // UserCreate -export class UserCreateRequest extends NamePassUser {} +export class UserCreateRequest extends NamePassUser { + @IsOptional() + @IsArray() + @IsString({ each: true }) + roles?: Roles; +} export class UserCreateResponse extends EUser {} // UserDelete @@ -54,11 +61,15 @@ export class UserInfoRequest extends UsernameUser {} export class UserInfoResponse extends EUser {} // UserUpdateRoles -export class UserUpdateRolesRequest extends UsernameUser { +export class UserUpdateRequest extends UsernameUser { + @IsOptional() @IsArray() - @IsDefined() @IsString({ each: true }) - roles: Roles; + roles?: Roles; + + @IsPlainTextPwd() + @IsOptional() + password?: string; } -export class UserUpdateRolesResponse extends EUser {} +export class UserUpdateResponse extends EUser {} diff --git a/shared/src/dto/roles.dto.ts b/shared/src/dto/roles.dto.ts index b4a17b7..920f7fd 100644 --- a/shared/src/dto/roles.dto.ts +++ b/shared/src/dto/roles.dto.ts @@ -3,9 +3,9 @@ import { Permission, Permissions, PermissionsList } from './permissions'; // Config -// These roles can never be removed from a user +// These roles can never be removed or added to a user. const PermanentRolesTuple = tuple('guest', 'user'); -// These reles can never be modified +// These roles can never be modified const ImmuteableRolesTuple = tuple('admin'); // These roles can never be removed from the server const SystemRolesTuple = tuple(...PermanentRolesTuple, ...ImmuteableRolesTuple); diff --git a/yarn.lock b/yarn.lock index 556c26f..89ff422 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4252,6 +4252,11 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +fuse.js@^6.5.3: + version "6.5.3" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.5.3.tgz#7446c0acbc4ab0ab36fa602e97499bdb69452b93" + integrity sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg== + gauge@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" @@ -5494,7 +5499,17 @@ minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" -minimist@1.2.5, minimist@^1.2.0, minimist@^1.2.6, "minimist@npm:minimist-lite": +minimist@1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +minimist@^1.2.0, minimist@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== + +"minimist@npm:minimist-lite": version "2.2.1" resolved "https://registry.yarnpkg.com/minimist-lite/-/minimist-lite-2.2.1.tgz#abb71db2c9b454d7cf4496868c03e9802de9934d" integrity sha512-RSrWIRWGYoM2TDe102s7aIyeSipXMIXKb1fSHYx1tAbxAV0z4g2xR6ra3oPzkTqFb0EIUz1H3A/qvYYeDd+/qQ==