add user edit page

This commit is contained in:
rubikscraft
2022-03-23 17:17:39 +01:00
parent 036f4d4e4d
commit 3e23681847
34 changed files with 674 additions and 118 deletions

View File

@@ -6,6 +6,7 @@ import { PicsurConfigModule } from '../../config/config.module';
import { EUserBackend } from '../../models/entities/user.entity'; import { EUserBackend } from '../../models/entities/user.entity';
import { RolesModule } from '../roledb/roledb.module'; import { RolesModule } from '../roledb/roledb.module';
import { UsersService } from './userdb.service'; import { UsersService } from './userdb.service';
import { UserRolesService } from './userrolesdb.service';
@Module({ @Module({
imports: [ imports: [
@@ -13,14 +14,15 @@ import { UsersService } from './userdb.service';
RolesModule, RolesModule,
TypeOrmModule.forFeature([EUserBackend]), TypeOrmModule.forFeature([EUserBackend]),
], ],
providers: [UsersService], providers: [UsersService, UserRolesService],
exports: [UsersService], exports: [UsersService, UserRolesService],
}) })
export class UsersModule implements OnModuleInit { export class UsersModule implements OnModuleInit {
private readonly logger = new Logger('UsersModule'); private readonly logger = new Logger('UsersModule');
constructor( constructor(
private usersService: UsersService, private usersService: UsersService,
private userRolesService: UserRolesService,
private authConfigService: AuthConfigService, private authConfigService: AuthConfigService,
) {} ) {}
@@ -44,7 +46,7 @@ export class UsersModule implements OnModuleInit {
return; return;
} }
const result = await this.usersService.addRoles(newUser, ['admin']); const result = await this.userRolesService.addRoles(newUser, ['admin']);
if (HasFailed(result)) { if (HasFailed(result)) {
this.logger.error( this.logger.error(
`Failed to make admin user "${username}" because: ${result.getReason()}`, `Failed to make admin user "${username}" because: ${result.getReason()}`,

View File

@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { plainToClass } from 'class-transformer'; import { plainToClass } from 'class-transformer';
import { Permissions } from 'picsur-shared/dist/dto/permissions';
import { PermanentRolesList, Roles } from 'picsur-shared/dist/dto/roles.dto'; import { PermanentRolesList, Roles } from 'picsur-shared/dist/dto/roles.dto';
import { import {
AsyncFailable, AsyncFailable,
@@ -16,6 +15,8 @@ import { EUserBackend } from '../../models/entities/user.entity';
import { GetCols } from '../collectionutils'; import { GetCols } from '../collectionutils';
import { RolesService } from '../roledb/roledb.service'; import { RolesService } from '../roledb/roledb.service';
const BCryptStrength = 12;
@Injectable() @Injectable()
export class UsersService { export class UsersService {
private readonly logger = new Logger('UsersService'); private readonly logger = new Logger('UsersService');
@@ -35,7 +36,7 @@ export class UsersService {
): AsyncFailable<EUserBackend> { ): AsyncFailable<EUserBackend> {
if (await this.exists(username)) return Fail('User already exists'); 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(); let user = new EUserBackend();
user.username = username; user.username = username;
@@ -65,6 +66,52 @@ export class UsersService {
} }
} }
// Updating
public async setRoles(
user: string | EUserBackend,
roles: Roles,
): AsyncFailable<EUserBackend> {
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<EUserBackend> {
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 // Authentication
async authenticate( async authenticate(
@@ -80,62 +127,6 @@ export class UsersService {
return await this.findOne(username); return await this.findOne(username);
} }
// Permissions and roles
public async getPermissions(
user: string | EUserBackend,
): AsyncFailable<Permissions> {
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<EUserBackend> {
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<EUserBackend> {
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<EUserBackend> {
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 // Listing
public async findOne<B extends true | undefined = undefined>( public async findOne<B extends true | undefined = undefined>(
@@ -182,7 +173,7 @@ export class UsersService {
// Internal resolver // Internal resolver
private async resolve( public async resolve(
user: string | EUserBackend, user: string | EUserBackend,
): AsyncFailable<EUserBackend> { ): AsyncFailable<EUserBackend> {
if (typeof user === 'string') { if (typeof user === 'string') {

View File

@@ -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<Permissions> {
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<EUserBackend> {
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<EUserBackend> {
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);
}
}

View File

@@ -8,6 +8,7 @@ import * as multipart from 'fastify-multipart';
import { ValidateOptions } from 'picsur-shared/dist/util/validate'; import { ValidateOptions } from 'picsur-shared/dist/util/validate';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { UsersService } from './collections/userdb/userdb.service'; import { UsersService } from './collections/userdb/userdb.service';
import { UserRolesService } from './collections/userdb/userrolesdb.service';
import { HostConfigService } from './config/host.config.service'; import { HostConfigService } from './config/host.config.service';
import { MainExceptionFilter } from './layers/httpexception/httpexception.filter'; import { MainExceptionFilter } from './layers/httpexception/httpexception.filter';
import { SuccessInterceptor } from './layers/success/success.interceptor'; 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'; import { MainAuthGuard } from './managers/auth/guards/main.guard';
async function bootstrap() { async function bootstrap() {
// Create fasify
const fastifyAdapter = new FastifyAdapter(); const fastifyAdapter = new FastifyAdapter();
// TODO: generic error messages // TODO: generic error messages
fastifyAdapter.register(multipart as any); fastifyAdapter.register(multipart as any);
// Create nest app
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
AppModule, AppModule,
fastifyAdapter, fastifyAdapter,
@@ -27,15 +29,23 @@ async function bootstrap() {
bufferLogs: true, bufferLogs: true,
}, },
); );
// Configure nest app
app.useGlobalFilters(new MainExceptionFilter()); app.useGlobalFilters(new MainExceptionFilter());
app.useGlobalInterceptors(new SuccessInterceptor()); app.useGlobalInterceptors(new SuccessInterceptor());
app.useGlobalPipes(new ValidationPipe(ValidateOptions)); app.useGlobalPipes(new ValidationPipe(ValidateOptions));
app.useGlobalGuards( 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)); app.useLogger(app.get(PicsurLoggerService));
// Start app
const hostConfigService = app.get(HostConfigService); const hostConfigService = app.get(HostConfigService);
await app.listen(hostConfigService.getPort(), hostConfigService.getHost()); await app.listen(hostConfigService.getPort(), hostConfigService.getHost());
} }

View File

@@ -15,6 +15,7 @@ import { Fail, Failable, HasFailed } from 'picsur-shared/dist/types';
import { isPermissionsArray } from 'picsur-shared/dist/util/permissions'; import { isPermissionsArray } from 'picsur-shared/dist/util/permissions';
import { strictValidate } from 'picsur-shared/dist/util/validate'; import { strictValidate } from 'picsur-shared/dist/util/validate';
import { UsersService } from '../../../collections/userdb/userdb.service'; import { UsersService } from '../../../collections/userdb/userdb.service';
import { UserRolesService } from '../../../collections/userdb/userrolesdb.service';
import { EUserBackend } from '../../../models/entities/user.entity'; import { EUserBackend } from '../../../models/entities/user.entity';
@Injectable() @Injectable()
@@ -24,6 +25,7 @@ export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) {
constructor( constructor(
private reflector: Reflector, private reflector: Reflector,
private usersService: UsersService, private usersService: UsersService,
private userRolesService: UserRolesService,
) { ) {
super(); super();
} }
@@ -45,7 +47,7 @@ export class MainAuthGuard extends AuthGuard(['jwt', 'guest']) {
throw new InternalServerErrorException(); throw new InternalServerErrorException();
} }
const userPermissions = await this.usersService.getPermissions(user); const userPermissions = await this.userRolesService.getPermissions(user);
if (HasFailed(userPermissions)) { if (HasFailed(userPermissions)) {
this.logger.warn('111' + userPermissions.getReason()); this.logger.warn('111' + userPermissions.getReason());
throw new InternalServerErrorException(); throw new InternalServerErrorException();

View File

@@ -17,6 +17,7 @@ import {
import { Permission } from 'picsur-shared/dist/dto/permissions'; import { Permission } from 'picsur-shared/dist/dto/permissions';
import { HasFailed } from 'picsur-shared/dist/types'; import { HasFailed } from 'picsur-shared/dist/types';
import { UsersService } from '../../../collections/userdb/userdb.service'; import { UsersService } from '../../../collections/userdb/userdb.service';
import { UserRolesService } from '../../../collections/userdb/userrolesdb.service';
import { import {
NoPermissions, NoPermissions,
RequiredPermissions, RequiredPermissions,
@@ -31,6 +32,7 @@ export class UserController {
constructor( constructor(
private usersService: UsersService, private usersService: UsersService,
private userRolesSerivce: UserRolesService,
private authService: AuthManagerService, private authService: AuthManagerService,
) {} ) {}
@@ -74,7 +76,7 @@ export class UserController {
async refresh( async refresh(
@Request() req: AuthFasityRequest, @Request() req: AuthFasityRequest,
): Promise<UserMePermissionsResponse> { ): Promise<UserMePermissionsResponse> {
const permissions = await this.usersService.getPermissions(req.user); const permissions = await this.userRolesSerivce.getPermissions(req.user);
if (HasFailed(permissions)) { if (HasFailed(permissions)) {
this.logger.warn(permissions.getReason()); this.logger.warn(permissions.getReason());
throw new InternalServerErrorException('Could not get permissions'); throw new InternalServerErrorException('Could not get permissions');

View File

@@ -15,8 +15,8 @@ import {
UserInfoResponse, UserInfoResponse,
UserListRequest, UserListRequest,
UserListResponse, UserListResponse,
UserUpdateRolesRequest, UserUpdateRequest,
UserUpdateRolesResponse UserUpdateResponse
} from 'picsur-shared/dist/dto/api/usermanage.dto'; } from 'picsur-shared/dist/dto/api/usermanage.dto';
import { Permission } from 'picsur-shared/dist/dto/permissions'; import { Permission } from 'picsur-shared/dist/dto/permissions';
import { HasFailed } from 'picsur-shared/dist/types'; import { HasFailed } from 'picsur-shared/dist/types';
@@ -63,6 +63,7 @@ export class UserManageController {
const user = await this.usersService.create( const user = await this.usersService.create(
create.username, create.username,
create.password, create.password,
create.roles,
); );
if (HasFailed(user)) { if (HasFailed(user)) {
this.logger.warn(user.getReason()); this.logger.warn(user.getReason());
@@ -96,20 +97,32 @@ export class UserManageController {
return user; return user;
} }
@Post('roles') @Post('update')
async setPermissions( async setPermissions(
@Body() body: UserUpdateRolesRequest, @Body() body: UserUpdateRequest,
): Promise<UserUpdateRolesResponse> { ): Promise<UserUpdateResponse> {
const updatedUser = await this.usersService.setRoles( let user = await this.usersService.findOne(body.username);
body.username, if (HasFailed(user)) {
body.roles, this.logger.warn(user.getReason());
); throw new InternalServerErrorException('Could not find user');
if (HasFailed(updatedUser)) {
this.logger.warn(updatedUser.getReason());
throw new InternalServerErrorException('Could not update 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;
} }
} }

View File

@@ -26,6 +26,7 @@
"bootstrap": "^5.1.3", "bootstrap": "^5.1.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"fuse.js": "^6.5.3",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"ngx-auto-unsubscribe-decorator": "^1.0.0", "ngx-auto-unsubscribe-decorator": "^1.0.0",
"ngx-dropzone": "^3.1.0", "ngx-dropzone": "^3.1.0",

View File

@@ -69,8 +69,8 @@ export class HeaderComponent implements OnInit {
this.router.navigate(['/user/login']); this.router.navigate(['/user/login']);
} }
doLogout() { async doLogout() {
const user = this.userService.logout(); const user = await this.userService.logout();
if (HasFailed(user)) { if (HasFailed(user)) {
this.utilService.showSnackBar(user.getReason(), SnackBarType.Error); this.utilService.showSnackBar(user.getReason(), SnackBarType.Error);
return; return;

View File

@@ -12,7 +12,6 @@ function errorsToError(errors: ValidationErrors | null): string {
} }
export const UsernameValidators = [ export const UsernameValidators = [
Validators.required,
Validators.minLength(4), Validators.minLength(4),
Validators.maxLength(32), Validators.maxLength(32),
Validators.pattern('^[a-zA-Z0-9]+$'), Validators.pattern('^[a-zA-Z0-9]+$'),
@@ -37,7 +36,6 @@ export const CreateUsernameError = (
}; };
export const PasswordValidators = [ export const PasswordValidators = [
Validators.required,
Validators.minLength(4), Validators.minLength(4),
Validators.maxLength(1024), Validators.maxLength(1024),
]; ];

View File

@@ -0,0 +1,7 @@
import { Roles } from 'picsur-shared/dist/dto/roles.dto';
export interface FullUserModel {
username: string;
password: string;
roles: Roles;
}

View File

@@ -6,7 +6,7 @@ import {
PasswordValidators, PasswordValidators,
UsernameValidators UsernameValidators
} from './default-validators'; } from './default-validators';
import { UserPassModel } from './userpass'; import { UserPassModel } from './userpass.model';
export class LoginControl { export class LoginControl {
public username = new FormControl('', UsernameValidators); public username = new FormControl('', UsernameValidators);

View File

@@ -7,7 +7,7 @@ import {
PasswordValidators, PasswordValidators,
UsernameValidators UsernameValidators
} from './default-validators'; } from './default-validators';
import { UserPassModel } from './userpass'; import { UserPassModel } from './userpass.model';
export class RegisterControl { export class RegisterControl {
public username = new FormControl('', UsernameValidators); public username = new FormControl('', UsernameValidators);

View File

@@ -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<string[]>([]);
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('');
}
}

View File

@@ -1 +1,100 @@
<p>settings-users-edit works!</p> <ng-container *ngIf="editing">
<h1>Editing {{ model.usernameValue }}</h1>
</ng-container>
<ng-container *ngIf="adding">
<h1>Add new user</h1>
</ng-container>
<form (ngSubmit)="updateUser()">
<div class="row">
<div class="col-12 py-2" *ngIf="updateFail">
<mat-error *ngIf="adding"> Failed to add user </mat-error>
<mat-error *ngIf="editing"> Failed to update user </mat-error>
</div>
</div>
<div class="row" *ngIf="adding">
<div class="col-lg-6 col-12">
<mat-form-field appearance="outline" color="accent">
<mat-label>Username</mat-label>
<input
matInput
type="text"
[formControl]="model.username"
name="username"
required
/>
<mat-error *ngIf="model.username.errors">{{
model.usernameError
}}</mat-error>
</mat-form-field>
</div>
</div>
<div class="row">
<div class="col-lg-6 col-12">
<mat-form-field appearance="outline" color="accent">
<mat-label>{{ editing ? "New Password" : "Password" }}</mat-label>
<input
matInput
type="password"
[formControl]="model.password"
name="password"
[required]="adding"
/>
<mat-error *ngIf="model.password.errors">{{
model.passwordError
}}</mat-error>
</mat-form-field>
</div>
</div>
<div class="row">
<div class="col-lg-6 col-12">
<mat-form-field appearance="outline" color="accent">
<mat-label>Roles</mat-label>
<mat-chip-list #chipList aria-label="Roles Selection">
<mat-chip
*ngFor="let role of model.selectedRoles"
[removable]="model.isRemovable(role)"
(removed)="removeRole(role)"
>
{{ role }}
<button *ngIf="model.isRemovable(role)" matChipRemove>
<mat-icon>cancel</mat-icon>
</button>
</mat-chip>
<input
placeholder="Add role..."
#fruitInput
[formControl]="model.rolesControl"
[value]="model.rolesControl.value"
[matAutocomplete]="auto"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="addRole($event)"
/>
</mat-chip-list>
<mat-autocomplete
#auto="matAutocomplete"
(optionSelected)="selectedRole($event)"
>
<mat-option
*ngFor="let role of model.selectableRoles | async"
[value]="role"
>
{{ role }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</div>
</div>
<div class="row">
<div class="col-12 py-2">
<button mat-raised-button color="accent" type="submit">
{{ editing ? "Update" : "Add" }}
</button>
</div>
</div>
</form>

View File

@@ -0,0 +1,3 @@
mat-form-field {
width: 100%;
}

View File

@@ -1,15 +1,128 @@
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import { Component, OnInit } from '@angular/core'; 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({ @Component({
selector: 'app-settings-users-edit', selector: 'app-settings-users-edit',
templateUrl: './settings-users-edit.component.html', templateUrl: './settings-users-edit.component.html',
styleUrls: ['./settings-users-edit.component.scss'] styleUrls: ['./settings-users-edit.component.scss'],
}) })
export class SettingsUsersEditComponent implements OnInit { 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']);
}
} }

View File

@@ -1,10 +1,10 @@
<h1>Users</h1> <h1>Users</h1>
<mat-table [dataSource]="dataSubject" class="mat-elevation-z2"> <mat-table [dataSource]="dataSubject" class="mat-elevation-z2">
<ng-container matColumnDef="id"> <!-- <ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef>ID</mat-header-cell> <mat-header-cell *matHeaderCellDef>ID</mat-header-cell>
<mat-cell *matCellDef="let user">{{ user.id }}</mat-cell> <mat-cell *matCellDef="let user">{{ user.id }}</mat-cell>
</ng-container> </ng-container> -->
<ng-container matColumnDef="username"> <ng-container matColumnDef="username">
<mat-header-cell *matHeaderCellDef>Username</mat-header-cell> <mat-header-cell *matHeaderCellDef>Username</mat-header-cell>
@@ -14,7 +14,7 @@
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef>Actions</mat-header-cell> <mat-header-cell *matHeaderCellDef>Actions</mat-header-cell>
<mat-cell *matCellDef="let user"> <mat-cell *matCellDef="let user">
<button mat-icon-button> <button mat-icon-button (click)="editUser(user)">
<mat-icon aria-label="Edit">edit</mat-icon> <mat-icon aria-label="Edit">edit</mat-icon>
</button> </button>
</mat-cell> </mat-cell>
@@ -24,8 +24,23 @@
<mat-row *matRowDef="let row; columns: displayedColumns"> pog </mat-row> <mat-row *matRowDef="let row; columns: displayedColumns"> pog </mat-row>
</mat-table> </mat-table>
<mat-paginator
color="accent"
[pageSizeOptions]="pageSizeOptions"
[pageSize]="startingPageSize"
length="Infinity"
aria-label="Select page of users"
(page)="updateSubject.next($event)"
>
</mat-paginator>
<div class="fabholder"> <div class="fabholder">
<button mat-fab color="accent" class="fabbutton fullanimate mat-elevation-z6"> <button
mat-fab
color="accent"
class="fabbutton fullanimate mat-elevation-z6"
(click)="addUser()"
>
<mat-icon>add</mat-icon> <mat-icon>add</mat-icon>
</button> </button>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { Component, OnInit, ViewChild } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { Router } from '@angular/router';
import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator'; import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator';
import { EUser } from 'picsur-shared/dist/entities/user.entity'; import { EUser } from 'picsur-shared/dist/entities/user.entity';
import { HasFailed } from 'picsur-shared/dist/types'; import { HasFailed } from 'picsur-shared/dist/types';
@@ -13,7 +14,7 @@ import { UtilService } from 'src/app/util/util.service';
styleUrls: ['./settings-users.component.scss'], styleUrls: ['./settings-users.component.scss'],
}) })
export class SettingsUsersComponent implements OnInit { 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 pageSizeOptions: number[] = [5, 10, 25, 100];
public readonly startingPageSize = this.pageSizeOptions[2]; public readonly startingPageSize = this.pageSizeOptions[2];
@@ -24,7 +25,8 @@ export class SettingsUsersComponent implements OnInit {
constructor( constructor(
private userManageService: UserManageService, private userManageService: UserManageService,
private utilService: UtilService private utilService: UtilService,
private router: Router
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -32,6 +34,14 @@ export class SettingsUsersComponent implements OnInit {
this.fetchUsers(this.startingPageSize, 0); 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() @AutoUnsubscribe()
private subscribeToUpdate() { private subscribeToUpdate() {
return this.updateSubject return this.updateSubject

View File

@@ -1,8 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatPaginatorModule } from '@angular/material/paginator'; import { MatPaginatorModule } from '@angular/material/paginator';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { SettingsUsersEditComponent } from './settings-users-edit/settings-users-edit.component'; import { SettingsUsersEditComponent } from './settings-users-edit/settings-users-edit.component';
@@ -19,6 +23,11 @@ import { SettingsUsersRoutingModule } from './settings-users.routing.module';
MatTableModule, MatTableModule,
MatPaginatorModule, MatPaginatorModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule,
MatChipsModule,
MatAutocompleteModule,
FormsModule,
ReactiveFormsModule,
], ],
}) })
export class SettingsUsersRouteModule {} export class SettingsUsersRouteModule {}

View File

@@ -10,7 +10,11 @@ const routes: PRoutes = [
component: SettingsUsersComponent, component: SettingsUsersComponent,
}, },
{ {
path: 'edit/:id', path: 'edit/:username',
component: SettingsUsersEditComponent,
},
{
path: 'add',
component: SettingsUsersEditComponent, component: SettingsUsersEditComponent,
} }
]; ];

View File

@@ -0,0 +1,4 @@
mat-form-field {
max-width: 40rem;
width: inherit;
}

View File

@@ -7,11 +7,12 @@ import { SnackBarType } from 'src/app/models/snack-bar-type';
import { PermissionService } from 'src/app/services/api/permission.service'; import { PermissionService } from 'src/app/services/api/permission.service';
import { UserService } from 'src/app/services/api/user.service'; import { UserService } from 'src/app/services/api/user.service';
import { UtilService } from 'src/app/util/util.service'; import { UtilService } from 'src/app/util/util.service';
import { LoginControl } from '../../../models/forms/login.model'; import { LoginControl } from '../../../models/forms/login.control';
import { UserPassModel } from '../../../models/forms/userpass'; import { UserPassModel } from '../../../models/forms/userpass.model';
@Component({ @Component({
templateUrl: './login.component.html', templateUrl: './login.component.html',
styleUrls: ['./login.component.scss'],
}) })
export class LoginComponent implements OnInit { export class LoginComponent implements OnInit {
private readonly logger = console; private readonly logger = console;

View File

@@ -0,0 +1,4 @@
mat-form-field {
max-width: 40rem;
width: inherit;
}

View File

@@ -3,15 +3,16 @@ import { Router } from '@angular/router';
import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator'; import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator';
import { Permission, Permissions } from 'picsur-shared/dist/dto/permissions'; import { Permission, Permissions } from 'picsur-shared/dist/dto/permissions';
import { HasFailed } from 'picsur-shared/dist/types'; 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 { SnackBarType } from 'src/app/models/snack-bar-type';
import { PermissionService } from 'src/app/services/api/permission.service'; import { PermissionService } from 'src/app/services/api/permission.service';
import { UserService } from 'src/app/services/api/user.service'; import { UserService } from 'src/app/services/api/user.service';
import { UtilService } from 'src/app/util/util.service'; import { UtilService } from 'src/app/util/util.service';
import { RegisterControl } from '../../../models/forms/register.model'; import { RegisterControl } from '../../../models/forms/register.control';
@Component({ @Component({
templateUrl: './register.component.html', templateUrl: './register.component.html',
styleUrls: ['./register.component.scss'],
}) })
export class RegisterComponent implements OnInit { export class RegisterComponent implements OnInit {
private readonly logger = console; private readonly logger = console;

View File

@@ -1,10 +1,11 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import Fuse from 'fuse.js';
import { CopyFieldModule } from 'src/app/components/copyfield/copyfield.module'; import { CopyFieldModule } from 'src/app/components/copyfield/copyfield.module';
import { ViewComponent } from './view.component'; import { ViewComponent } from './view.component';
import { ViewRoutingModule } from './view.routing.module'; import { ViewRoutingModule } from './view.routing.module';
const a = Fuse;
@NgModule({ @NgModule({
declarations: [ViewComponent], declarations: [ViewComponent],
imports: [CommonModule, CopyFieldModule, ViewRoutingModule, MatButtonModule], imports: [CommonModule, CopyFieldModule, ViewRoutingModule, MatButtonModule],

View File

@@ -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<ERole[]> {
const result = await this.apiService.get(
RoleListResponse,
'/api/roles/list'
);
if (HasFailed(result)) {
return result;
}
return result.roles;
}
}

View File

@@ -1,10 +1,17 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { import {
UserCreateRequest,
UserCreateResponse,
UserInfoRequest,
UserInfoResponse,
UserListRequest, UserListRequest,
UserListResponse UserListResponse,
UserUpdateRequest,
UserUpdateResponse
} from 'picsur-shared/dist/dto/api/usermanage.dto'; } from 'picsur-shared/dist/dto/api/usermanage.dto';
import { EUser } from 'picsur-shared/dist/entities/user.entity'; import { EUser } from 'picsur-shared/dist/entities/user.entity';
import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types'; import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types';
import { FullUserModel } from 'src/app/models/forms/fulluser.model';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
@Injectable({ @Injectable({
@@ -13,6 +20,21 @@ import { ApiService } from './api.service';
export class UserManageService { export class UserManageService {
constructor(private apiService: ApiService) {} constructor(private apiService: ApiService) {}
public async getUser(username: string): AsyncFailable<EUser> {
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<EUser[]> { public async getUsers(count: number, page: number): AsyncFailable<EUser[]> {
const body = { const body = {
count, count,
@@ -32,4 +54,26 @@ export class UserManageService {
return result.users; return result.users;
} }
public async createUser(user: FullUserModel): AsyncFailable<EUser> {
const result = await this.apiService.post(
UserCreateRequest,
UserCreateResponse,
'/api/user/create',
user
);
return result;
}
public async updateUser(user: FullUserModel): AsyncFailable<EUser> {
const result = await this.apiService.post(
UserUpdateRequest,
UserUpdateResponse,
'/api/user/update',
user
);
return result;
}
} }

View File

@@ -36,9 +36,3 @@ html {
width: initial !important; width: initial !important;
} }
// Fix small form inputs
form mat-form-field {
width: inherit;
max-width: 40rem;
}

View File

@@ -5,6 +5,8 @@
border-style: solid; border-style: solid;
border-width: 5px; border-width: 5px;
transition: all 0.2s ease-in-out;
} }
// Easily center content // Easily center content
@@ -63,6 +65,11 @@
// Anim // Anim
.fullanimate, .fullanimate * { .container, .row > div {
transition: ease-in-out all 0.2s;
}
.fullanimate,
.fullanimate * {
transition: ease-in-out all 0.2s !important; transition: ease-in-out all 0.2s !important;
} }

View File

@@ -3,11 +3,13 @@ import {
IsArray, IsArray,
IsDefined, IsDefined,
IsInt, IsInt,
IsOptional,
IsString, IsString,
Min, Min,
ValidateNested ValidateNested
} from 'class-validator'; } from 'class-validator';
import { EUser, NamePassUser, UsernameUser } from '../../entities/user.entity'; import { EUser, NamePassUser, UsernameUser } from '../../entities/user.entity';
import { IsPlainTextPwd } from '../../validators/user.validators';
import { Roles } from '../roles.dto'; import { Roles } from '../roles.dto';
// UserList // UserList
@@ -42,7 +44,12 @@ export class UserListResponse {
} }
// UserCreate // UserCreate
export class UserCreateRequest extends NamePassUser {} export class UserCreateRequest extends NamePassUser {
@IsOptional()
@IsArray()
@IsString({ each: true })
roles?: Roles;
}
export class UserCreateResponse extends EUser {} export class UserCreateResponse extends EUser {}
// UserDelete // UserDelete
@@ -54,11 +61,15 @@ export class UserInfoRequest extends UsernameUser {}
export class UserInfoResponse extends EUser {} export class UserInfoResponse extends EUser {}
// UserUpdateRoles // UserUpdateRoles
export class UserUpdateRolesRequest extends UsernameUser { export class UserUpdateRequest extends UsernameUser {
@IsOptional()
@IsArray() @IsArray()
@IsDefined()
@IsString({ each: true }) @IsString({ each: true })
roles: Roles; roles?: Roles;
@IsPlainTextPwd()
@IsOptional()
password?: string;
} }
export class UserUpdateRolesResponse extends EUser {} export class UserUpdateResponse extends EUser {}

View File

@@ -3,9 +3,9 @@ import { Permission, Permissions, PermissionsList } from './permissions';
// Config // 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'); const PermanentRolesTuple = tuple('guest', 'user');
// These reles can never be modified // These roles can never be modified
const ImmuteableRolesTuple = tuple('admin'); const ImmuteableRolesTuple = tuple('admin');
// These roles can never be removed from the server // These roles can never be removed from the server
const SystemRolesTuple = tuple(...PermanentRolesTuple, ...ImmuteableRolesTuple); const SystemRolesTuple = tuple(...PermanentRolesTuple, ...ImmuteableRolesTuple);

View File

@@ -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" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= 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: gauge@^3.0.0:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395"
@@ -5494,7 +5499,17 @@ minimatch@^3.0.4:
dependencies: dependencies:
brace-expansion "^1.1.7" 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" version "2.2.1"
resolved "https://registry.yarnpkg.com/minimist-lite/-/minimist-lite-2.2.1.tgz#abb71db2c9b454d7cf4496868c03e9802de9934d" resolved "https://registry.yarnpkg.com/minimist-lite/-/minimist-lite-2.2.1.tgz#abb71db2c9b454d7cf4496868c03e9802de9934d"
integrity sha512-RSrWIRWGYoM2TDe102s7aIyeSipXMIXKb1fSHYx1tAbxAV0z4g2xR6ra3oPzkTqFb0EIUz1H3A/qvYYeDd+/qQ== integrity sha512-RSrWIRWGYoM2TDe102s7aIyeSipXMIXKb1fSHYx1tAbxAV0z4g2xR6ra3oPzkTqFb0EIUz1H3A/qvYYeDd+/qQ==