relocate special roles to api request

This commit is contained in:
rubikscraft
2022-03-24 19:56:26 +01:00
parent 25b85c00e0
commit 95c8f630f1
33 changed files with 284 additions and 156 deletions

View File

@@ -12,10 +12,11 @@
"prebuild": "rimraf dist", "prebuild": "rimraf dist",
"build": "nest build", "build": "nest build",
"start": "nest start --exec \"node --experimental-specifier-resolution=node\"", "start": "nest start --exec \"node --experimental-specifier-resolution=node\"",
"start:dev": "nest start --watch --exec \"node --experimental-specifier-resolution=node\"", "start:dev": "yarn clean && nest start --watch --exec \"node --experimental-specifier-resolution=node\"",
"start:debug": "nest start --debug --watch --exec \"node --experimental-specifier-resolution=node\"", "start:debug": "nest start --debug --watch --exec \"node --experimental-specifier-resolution=node\"",
"start:prod": "node --experimental-specifier-resolution=node dist/main", "start:prod": "node --experimental-specifier-resolution=node dist/main",
"format": "prettier --write \"src/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\"",
"clean": "rimraf dist",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix" "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
}, },
"dependencies": { "dependencies": {

View File

@@ -1,14 +1,9 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import {
ImmuteableRolesList,
SystemRoleDefaults,
SystemRoles,
SystemRolesList
} from 'picsur-shared/dist/dto/roles.dto';
import { HasFailed } from 'picsur-shared/dist/types'; import { HasFailed } from 'picsur-shared/dist/types';
import { PicsurConfigModule } from '../../config/config.module'; import { PicsurConfigModule } from '../../config/config.module';
import { HostConfigService } from '../../config/host.config.service'; import { HostConfigService } from '../../config/host.config.service';
import { ImmutableRolesList, SystemRoleDefaults, UndeletableRolesList } from '../../models/dto/roles.dto';
import { ERoleBackend } from '../../models/entities/role.entity'; import { ERoleBackend } from '../../models/entities/role.entity';
import { RolesService } from './roledb.service'; import { RolesService } from './roledb.service';
@@ -43,7 +38,7 @@ export class RolesModule implements OnModuleInit {
} }
private async ensureSystemRolesExist() { private async ensureSystemRolesExist() {
for (const systemRole of SystemRolesList as SystemRoles) { for (const systemRole of UndeletableRolesList) {
this.logger.debug(`Ensuring system role "${systemRole}" exists`); this.logger.debug(`Ensuring system role "${systemRole}" exists`);
const exists = await this.rolesService.exists(systemRole); const exists = await this.rolesService.exists(systemRole);
@@ -63,7 +58,7 @@ export class RolesModule implements OnModuleInit {
} }
private async updateImmutableRoles() { private async updateImmutableRoles() {
for (const immutableRole of ImmuteableRolesList as SystemRoles) { for (const immutableRole of ImmutableRolesList) {
this.logger.debug( this.logger.debug(
`Updating permissions for immutable role "${immutableRole}"`, `Updating permissions for immutable role "${immutableRole}"`,
); );

View File

@@ -2,11 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { plainToClass } from 'class-transformer'; import { plainToClass } from 'class-transformer';
import { Permissions } from 'picsur-shared/dist/dto/permissions'; import { Permissions } from 'picsur-shared/dist/dto/permissions';
import {
ImmuteableRolesList,
Roles,
SystemRolesList
} from 'picsur-shared/dist/dto/roles.dto';
import { import {
AsyncFailable, AsyncFailable,
Fail, Fail,
@@ -15,6 +10,7 @@ import {
} from 'picsur-shared/dist/types'; } from 'picsur-shared/dist/types';
import { strictValidate } from 'picsur-shared/dist/util/validate'; import { strictValidate } from 'picsur-shared/dist/util/validate';
import { In, Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { ImmutableRolesList, UndeletableRolesList } from '../../models/dto/roles.dto';
import { ERoleBackend } from '../../models/entities/role.entity'; import { ERoleBackend } from '../../models/entities/role.entity';
@Injectable() @Injectable()
@@ -51,7 +47,7 @@ export class RolesService {
const roleToModify = await this.resolve(role); const roleToModify = await this.resolve(role);
if (HasFailed(roleToModify)) return roleToModify; if (HasFailed(roleToModify)) return roleToModify;
if (SystemRolesList.includes(roleToModify.name)) { if (UndeletableRolesList.includes(roleToModify.name)) {
return Fail('Cannot delete system role'); return Fail('Cannot delete system role');
} }
@@ -62,7 +58,7 @@ export class RolesService {
} }
} }
public async getPermissions(roles: Roles): AsyncFailable<Permissions> { public async getPermissions(roles: string[]): AsyncFailable<Permissions> {
const permissions: Permissions = []; const permissions: Permissions = [];
const foundRoles = await Promise.all( const foundRoles = await Promise.all(
roles.map((role: string) => this.findOne(role)), roles.map((role: string) => this.findOne(role)),
@@ -113,7 +109,7 @@ export class RolesService {
const roleToModify = await this.resolve(role); const roleToModify = await this.resolve(role);
if (HasFailed(roleToModify)) return roleToModify; if (HasFailed(roleToModify)) return roleToModify;
if (!allowImmutable && ImmuteableRolesList.includes(roleToModify.name)) { if (!allowImmutable && ImmutableRolesList.includes(roleToModify.name)) {
return Fail('Cannot modify immutable role'); return Fail('Cannot modify immutable role');
} }
@@ -157,7 +153,7 @@ export class RolesService {
if (!iamsure) return Fail('Nuke aborted'); if (!iamsure) return Fail('Nuke aborted');
try { try {
await this.rolesRepository.delete({ await this.rolesRepository.delete({
name: In(SystemRolesList), name: In(UndeletableRolesList),
}); });
} catch (e: any) { } catch (e: any) {
return Fail(e?.message); return Fail(e?.message);

View File

@@ -2,11 +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 {
DefaultRolesList,
PermanentRolesList,
Roles
} from 'picsur-shared/dist/dto/roles.dto';
import { import {
LockedLoginUsersList, LockedLoginUsersList,
LockedPermsUsersList, LockedPermsUsersList,
@@ -20,6 +15,10 @@ import {
} from 'picsur-shared/dist/types'; } from 'picsur-shared/dist/types';
import { strictValidate } from 'picsur-shared/dist/util/validate'; import { strictValidate } from 'picsur-shared/dist/util/validate';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import {
DefaultRolesList,
SoulBoundRolesList
} from '../../models/dto/roles.dto';
import { EUserBackend } from '../../models/entities/user.entity'; 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';
@@ -41,7 +40,7 @@ export class UsersService {
public async create( public async create(
username: string, username: string,
password: string, password: string,
roles?: Roles, roles?: string[],
byPassRoleCheck?: boolean, byPassRoleCheck?: boolean,
): AsyncFailable<EUserBackend> { ): AsyncFailable<EUserBackend> {
if (await this.exists(username)) return Fail('User already exists'); if (await this.exists(username)) return Fail('User already exists');
@@ -90,7 +89,7 @@ export class UsersService {
public async setRoles( public async setRoles(
user: string | EUserBackend, user: string | EUserBackend,
roles: Roles, roles: string[],
): AsyncFailable<EUserBackend> { ): AsyncFailable<EUserBackend> {
const userToModify = await this.resolve(user); const userToModify = await this.resolve(user);
if (HasFailed(userToModify)) return userToModify; if (HasFailed(userToModify)) return userToModify;
@@ -101,7 +100,7 @@ export class UsersService {
} }
const rolesToKeep = userToModify.roles.filter((role) => const rolesToKeep = userToModify.roles.filter((role) =>
PermanentRolesList.includes(role), SoulBoundRolesList.includes(role),
); );
const rolesToAdd = this.filterAddedRoles(roles); const rolesToAdd = this.filterAddedRoles(roles);
@@ -216,9 +215,9 @@ export class UsersService {
} }
} }
private filterAddedRoles(roles: Roles): Roles { private filterAddedRoles(roles: string[]): string[] {
const filteredRoles = roles.filter( const filteredRoles = roles.filter(
(role) => !PermanentRolesList.includes(role), (role) => !SoulBoundRolesList.includes(role),
); );
return filteredRoles; return filteredRoles;

View File

@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Permissions } from 'picsur-shared/dist/dto/permissions'; 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 { AsyncFailable, HasFailed } from 'picsur-shared/dist/types';
import { EUserBackend } from '../../models/entities/user.entity'; import { EUserBackend } from '../../models/entities/user.entity';
import { RolesService } from '../roledb/roledb.service'; import { RolesService } from '../roledb/roledb.service';
@@ -23,7 +22,7 @@ export class UserRolesService {
public async addRoles( public async addRoles(
user: string | EUserBackend, user: string | EUserBackend,
roles: Roles, roles: string[],
): AsyncFailable<EUserBackend> { ): AsyncFailable<EUserBackend> {
const userToModify = await this.usersService.resolve(user); const userToModify = await this.usersService.resolve(user);
if (HasFailed(userToModify)) return userToModify; if (HasFailed(userToModify)) return userToModify;
@@ -35,7 +34,7 @@ export class UserRolesService {
public async removeRoles( public async removeRoles(
user: string | EUserBackend, user: string | EUserBackend,
roles: Roles, roles: string[],
): AsyncFailable<EUserBackend> { ): AsyncFailable<EUserBackend> {
const userToModify = await this.usersService.resolve(user); const userToModify = await this.usersService.resolve(user);
if (HasFailed(userToModify)) return userToModify; if (HasFailed(userToModify)) return userToModify;

View File

@@ -1,9 +1,9 @@
import { import {
BadRequestException, BadRequestException,
Injectable, Injectable,
Logger, Logger,
PipeTransform, PipeTransform,
Scope Scope
} from '@nestjs/common'; } from '@nestjs/common';
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { MultipartFields, MultipartFile } from 'fastify-multipart'; import { MultipartFields, MultipartFile } from 'fastify-multipart';
@@ -11,9 +11,9 @@ import { Newable } from 'picsur-shared/dist/types';
import { strictValidate } from 'picsur-shared/dist/util/validate'; import { strictValidate } from 'picsur-shared/dist/util/validate';
import { MultipartConfigService } from '../config/multipart.config.service'; import { MultipartConfigService } from '../config/multipart.config.service';
import { import {
MultiPartFieldDto, MultiPartFieldDto,
MultiPartFileDto MultiPartFileDto
} from '../models/dto/multipart.dto'; } from '../models/requests/multipart.dto';
@Injectable({ scope: Scope.REQUEST }) @Injectable({ scope: Scope.REQUEST })
export class MultiPartPipe implements PipeTransform { export class MultiPartPipe implements PipeTransform {

View File

@@ -2,7 +2,7 @@ import {
ArgumentsHost, Catch, ExceptionFilter, HttpException ArgumentsHost, Catch, ExceptionFilter, HttpException
} from '@nestjs/common'; } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorResponse } from 'picsur-shared/dist/dto/api'; import { ApiErrorResponse } from 'picsur-shared/dist/dto/api/api.dto';
@Catch(HttpException) @Catch(HttpException)
export class MainExceptionFilter implements ExceptionFilter { export class MainExceptionFilter implements ExceptionFilter {

View File

@@ -2,7 +2,7 @@ import {
CallHandler, ExecutionContext, Injectable, CallHandler, ExecutionContext, Injectable,
NestInterceptor NestInterceptor
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiResponse } from 'picsur-shared/dist/dto/api'; import { ApiResponse } from 'picsur-shared/dist/dto/api/api.dto';
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
@Injectable() @Injectable()

View File

@@ -0,0 +1,39 @@
import { Permission, Permissions, PermissionsList } from 'picsur-shared/dist/dto/permissions';
import tuple from 'picsur-shared/dist/types/tuple';
// Config
// These roles can never be removed or added to a user.
const SoulBoundRolesTuple = tuple('guest', 'user');
// These roles can never be modified
const ImmutableRolesTuple = tuple('admin');
// These roles can never be removed from the server
const UndeletableRolesTuple = tuple(...SoulBoundRolesTuple, ...ImmutableRolesTuple);
// These roles will be applied by default to new users
export const DefaultRolesList: string[] = ['user'];
// Derivatives
export const SoulBoundRolesList: string[] = SoulBoundRolesTuple;
export const ImmutableRolesList: string[] = ImmutableRolesTuple;
export const UndeletableRolesList: string[] = UndeletableRolesTuple;
// Defaults
type SystemRole = typeof UndeletableRolesTuple[number];
const SystemRoleDefaultsTyped: {
[key in SystemRole]: Permissions;
} = {
guest: [Permission.ImageView, Permission.UserLogin],
user: [
Permission.ImageView,
Permission.UserMe,
Permission.UserLogin,
Permission.Settings,
Permission.ImageUpload,
],
// Grant all permissions to admin
admin: PermissionsList,
};
export const SystemRoleDefaults = SystemRoleDefaultsTyped as {
[key in string]: Permissions;
};

View File

@@ -1,4 +1,3 @@
import { Roles } from 'picsur-shared/dist/dto/roles.dto';
import { EUser } from 'picsur-shared/dist/entities/user.entity'; import { EUser } from 'picsur-shared/dist/entities/user.entity';
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@@ -14,7 +13,7 @@ export class EUserBackend extends EUser {
override username: string; override username: string;
@Column('text', { nullable: false, array: true }) @Column('text', { nullable: false, array: true })
override roles: Roles; override roles: string[];
@Column({ nullable: false, select: false }) @Column({ nullable: false, select: false })
override password?: string; override password?: string;

View File

@@ -1,5 +1,5 @@
import { Controller, Get, Request } from '@nestjs/common'; import { Controller, Get, Request } from '@nestjs/common';
import AuthFasityRequest from '../../../models/dto/authrequest.dto'; import AuthFasityRequest from '../../../models/requests/authrequest.dto';
@Controller('api/experiment') @Controller('api/experiment')

View File

@@ -6,6 +6,7 @@ import {
Logger, Logger,
Post Post
} from '@nestjs/common'; } from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { import {
RoleCreateRequest, RoleCreateRequest,
RoleCreateResponse, RoleCreateResponse,
@@ -15,12 +16,19 @@ import {
RoleInfoResponse, RoleInfoResponse,
RoleListResponse, RoleListResponse,
RoleUpdateRequest, RoleUpdateRequest,
RoleUpdateResponse RoleUpdateResponse,
SpecialRolesResponse
} from 'picsur-shared/dist/dto/api/roles.dto'; } from 'picsur-shared/dist/dto/api/roles.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';
import { RolesService } from '../../../collections/roledb/roledb.service'; import { RolesService } from '../../../collections/roledb/roledb.service';
import { RequiredPermissions } from '../../../decorators/permissions.decorator'; import { RequiredPermissions } from '../../../decorators/permissions.decorator';
import {
DefaultRolesList,
ImmutableRolesList,
SoulBoundRolesList,
UndeletableRolesList
} from '../../../models/dto/roles.dto';
@Controller('api/roles') @Controller('api/roles')
@RequiredPermissions(Permission.RoleManage) @RequiredPermissions(Permission.RoleManage)
@@ -95,4 +103,16 @@ export class RolesController {
return deletedRole; return deletedRole;
} }
@Get('special')
async getSpecialRoles(): Promise<SpecialRolesResponse> {
const result: SpecialRolesResponse = {
SoulBoundRoles: SoulBoundRolesList,
ImmutableRoles: ImmutableRolesList,
UndeletableRoles: UndeletableRolesList,
DefaultRoles: DefaultRolesList,
};
return plainToClass(SpecialRolesResponse, result);
}
} }

View File

@@ -1,30 +1,30 @@
import { import {
Body, Body,
Controller, Controller,
Get, Get,
InternalServerErrorException, InternalServerErrorException,
Logger, Logger,
Post, Post,
Request Request
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
UserLoginResponse, UserLoginResponse,
UserMePermissionsResponse, UserMePermissionsResponse,
UserMeResponse, UserMeResponse,
UserRegisterRequest, UserRegisterRequest,
UserRegisterResponse UserRegisterResponse
} from 'picsur-shared/dist/dto/api/user.dto'; } from 'picsur-shared/dist/dto/api/user.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';
import { UsersService } from '../../../collections/userdb/userdb.service'; import { UsersService } from '../../../collections/userdb/userdb.service';
import { UserRolesService } from '../../../collections/userdb/userrolesdb.service'; import { UserRolesService } from '../../../collections/userdb/userrolesdb.service';
import { import {
NoPermissions, NoPermissions,
RequiredPermissions, RequiredPermissions,
UseLocalAuth UseLocalAuth
} from '../../../decorators/permissions.decorator'; } from '../../../decorators/permissions.decorator';
import { AuthManagerService } from '../../../managers/auth/auth.service'; import { AuthManagerService } from '../../../managers/auth/auth.service';
import AuthFasityRequest from '../../../models/dto/authrequest.dto'; import AuthFasityRequest from '../../../models/requests/authrequest.dto';
@Controller('api/user') @Controller('api/user')
export class UserController { export class UserController {

View File

@@ -18,7 +18,7 @@ import { HasFailed } from 'picsur-shared/dist/types';
import { MultiPart } from '../../decorators/multipart.decorator'; import { MultiPart } from '../../decorators/multipart.decorator';
import { RequiredPermissions } from '../../decorators/permissions.decorator'; import { RequiredPermissions } from '../../decorators/permissions.decorator';
import { ImageManagerService } from '../../managers/imagemanager/imagemanager.service'; import { ImageManagerService } from '../../managers/imagemanager/imagemanager.service';
import { ImageUploadDto } from '../../models/dto/imageroute.dto'; import { ImageUploadDto } from '../../models/requests/imageroute.dto';
@Controller('i') @Controller('i')
@RequiredPermissions(Permission.ImageView) @RequiredPermissions(Permission.ImageView)

View File

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

View File

@@ -1,7 +1,6 @@
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { Permission, Permissions } from 'picsur-shared/dist/dto/permissions'; import { Permission, Permissions } from 'picsur-shared/dist/dto/permissions';
import { PermanentRolesList } from 'picsur-shared/dist/dto/roles.dto';
import { BehaviorSubject, Subscription } from 'rxjs'; import { BehaviorSubject, Subscription } from 'rxjs';
import { RoleNameValidators } from './role-validators'; import { RoleNameValidators } from './role-validators';
import { RoleModel } from './role.model'; import { RoleModel } from './role.model';
@@ -58,11 +57,6 @@ export class UpdateRoleControl {
this.updateSelectablePermissions(); this.updateSelectablePermissions();
} }
public isRemovable(role: Permission) {
if (PermanentRolesList.includes(role)) return false;
return true;
}
// Data interaction // Data interaction
public putAllPermissions(permissions: Permissions) { public putAllPermissions(permissions: Permissions) {

View File

@@ -1,7 +1,6 @@
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { Permissions } from 'picsur-shared/dist/dto/permissions'; import { Permissions } from 'picsur-shared/dist/dto/permissions';
import { PermanentRolesList } from 'picsur-shared/dist/dto/roles.dto';
import { ERole } from 'picsur-shared/dist/entities/role.entity'; import { ERole } from 'picsur-shared/dist/entities/role.entity';
import { BehaviorSubject, Subscription } from 'rxjs'; import { BehaviorSubject, Subscription } from 'rxjs';
import { FullUserModel } from './fulluser.model'; import { FullUserModel } from './fulluser.model';
@@ -13,6 +12,9 @@ import {
} from './user-validators'; } from './user-validators';
export class UpdateUserControl { export class UpdateUserControl {
// Special roles
private SoulBoundRolesList: string[] = [];
// Set once // Set once
private fullRoles: ERole[] = []; private fullRoles: ERole[] = [];
private roles: string[] = []; private roles: string[] = [];
@@ -68,7 +70,7 @@ export class UpdateUserControl {
} }
public isRemovable(role: string) { public isRemovable(role: string) {
if (PermanentRolesList.includes(role)) return false; if (this.SoulBoundRolesList.includes(role)) return false;
return true; return true;
} }
@@ -106,6 +108,10 @@ export class UpdateUserControl {
this.updateSelectableRoles(); this.updateSelectableRoles();
} }
public putSoulBoundRoles(roles: string[]) {
this.SoulBoundRolesList = roles;
}
public getData(): FullUserModel { public getData(): FullUserModel {
return { return {
username: this.username.value, username: this.username.value,
@@ -119,7 +125,8 @@ export class UpdateUserControl {
private updateSelectableRoles() { private updateSelectableRoles() {
const availableRoles = this.roles.filter( const availableRoles = this.roles.filter(
// Not available if either already selected, or the role is not addable/removable // Not available if either already selected, or the role is not addable/removable
(r) => !(this.selectedRoles.includes(r) || PermanentRolesList.includes(r)) (r) =>
!(this.selectedRoles.includes(r) || this.SoulBoundRolesList.includes(r))
); );
const searchValue = this.rolesControl.value; const searchValue = this.rolesControl.value;

View File

@@ -33,12 +33,10 @@
<mat-chip-list #chipList aria-label="Permissions Selection"> <mat-chip-list #chipList aria-label="Permissions Selection">
<mat-chip <mat-chip
*ngFor="let permission of model.selectedPermissions" *ngFor="let permission of model.selectedPermissions"
[removable]="model.isRemovable(permission)"
[disabled]="!model.isRemovable(permission)"
(removed)="removePermission(permission)" (removed)="removePermission(permission)"
> >
{{ uiFriendlyPermission(permission) }} {{ uiFriendlyPermission(permission) }}
<button *ngIf="model.isRemovable(permission)" matChipRemove> <button matChipRemove>
<mat-icon>cancel</mat-icon> <mat-icon>cancel</mat-icon>
</button> </button>
</mat-chip> </mat-chip>
@@ -50,14 +48,14 @@
[matAutocomplete]="auto" [matAutocomplete]="auto"
[matChipInputFor]="chipList" [matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes" [matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="addRole($event)" (matChipInputTokenEnd)="addPermission($event)"
autocorrect="off" autocorrect="off"
autocapitalize="none" autocapitalize="none"
/> />
</mat-chip-list> </mat-chip-list>
<mat-autocomplete <mat-autocomplete
#auto="matAutocomplete" #auto="matAutocomplete"
(optionSelected)="selectedRole($event)" (optionSelected)="selectedPermission($event)"
> >
<mat-option <mat-option
*ngFor="let permission of model.selectablePermissions | async" *ngFor="let permission of model.selectablePermissions | async"

View File

@@ -77,12 +77,12 @@ export class SettingsRolesEditComponent implements OnInit {
this.model.removePermission(permission); this.model.removePermission(permission);
} }
addRole(event: MatChipInputEvent) { addPermission(event: MatChipInputEvent) {
const value = (event.value ?? '').trim(); const value = (event.value ?? '').trim();
this.model.addPermission(value as Permission); this.model.addPermission(value as Permission);
} }
selectedRole(event: MatAutocompleteSelectedEvent): void { selectedPermission(event: MatAutocompleteSelectedEvent): void {
this.model.addPermission(event.option.viewValue as Permission); this.model.addPermission(event.option.viewValue as Permission);
} }

View File

@@ -6,7 +6,6 @@ import {
Permission, Permission,
UIFriendlyPermissions UIFriendlyPermissions
} from 'picsur-shared/dist/dto/permissions'; } from 'picsur-shared/dist/dto/permissions';
import { ImmuteableRolesList, SystemRolesList } from 'picsur-shared/dist/dto/roles.dto';
import { ERole } from 'picsur-shared/dist/entities/role.entity'; import { ERole } from 'picsur-shared/dist/entities/role.entity';
import { HasFailed } from 'picsur-shared/dist/types'; import { HasFailed } from 'picsur-shared/dist/types';
import { SnackBarType } from 'src/app/models/snack-bar-type'; import { SnackBarType } from 'src/app/models/snack-bar-type';
@@ -29,6 +28,9 @@ export class SettingsRolesComponent implements OnInit, AfterViewInit {
public dataSource = new MatTableDataSource<ERole>([]); public dataSource = new MatTableDataSource<ERole>([]);
private UndeletableRolesList: string[] = [];
private ImmutableRolesList: string[] = [];
@ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatPaginator) paginator: MatPaginator;
constructor( constructor(
@@ -91,11 +93,11 @@ export class SettingsRolesComponent implements OnInit, AfterViewInit {
} }
isSystem(role: ERole) { isSystem(role: ERole) {
return SystemRolesList.includes(role.name); return this.UndeletableRolesList.includes(role.name);
} }
isImmutable(role: ERole) { isImmutable(role: ERole) {
return ImmuteableRolesList.includes(role.name); return this.ImmutableRolesList.includes(role.name);
} }
private async fetchRoles() { private async fetchRoles() {
@@ -106,5 +108,17 @@ export class SettingsRolesComponent implements OnInit, AfterViewInit {
} }
this.dataSource.data = roles; this.dataSource.data = roles;
const specialRoles = await this.rolesService.getSpecialRoles();
if (HasFailed(specialRoles)) {
this.utilService.showSnackBar(
'Failed to load special roles',
SnackBarType.Error
);
return;
}
this.UndeletableRolesList = specialRoles.UndeletableRoles;
this.ImmutableRolesList = specialRoles.ImmutableRoles;
} }
} }

View File

@@ -4,7 +4,6 @@ import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips'; import { MatChipInputEvent } from '@angular/material/chips';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { UIFriendlyPermissions } from 'picsur-shared/dist/dto/permissions'; import { UIFriendlyPermissions } from 'picsur-shared/dist/dto/permissions';
import { DefaultRolesList } from 'picsur-shared/dist/dto/roles.dto';
import { LockedPermsUsersList } from 'picsur-shared/dist/dto/specialusers.dto'; import { LockedPermsUsersList } from 'picsur-shared/dist/dto/specialusers.dto';
import { HasFailed } from 'picsur-shared/dist/types'; import { HasFailed } from 'picsur-shared/dist/types';
import { UpdateUserControl } from 'src/app/models/forms/updateuser.control'; import { UpdateUserControl } from 'src/app/models/forms/updateuser.control';
@@ -51,9 +50,15 @@ export class SettingsUsersEditComponent implements OnInit {
private async initUser() { private async initUser() {
const username = this.route.snapshot.paramMap.get('username'); const username = this.route.snapshot.paramMap.get('username');
const { DefaultRoles, SoulBoundRoles } =
await this.rolesService.getSpecialRolesOptimistic();
this.model.putSoulBoundRoles(SoulBoundRoles);
if (!username) { if (!username) {
this.mode = EditMode.add; this.mode = EditMode.add;
this.model.putRoles(DefaultRolesList);
this.model.putRoles(DefaultRoles);
return; return;
} }

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ClassConstructor, plainToClass } from 'class-transformer'; import { ClassConstructor, plainToClass } from 'class-transformer';
import { ApiResponse, ApiSuccessResponse } from 'picsur-shared/dist/dto/api'; import { ApiResponse, ApiSuccessResponse } from 'picsur-shared/dist/dto/api/api.dto';
import { AsyncFailable, Fail, HasFailed } from 'picsur-shared/dist/types'; import { AsyncFailable, Fail, HasFailed } from 'picsur-shared/dist/types';
import { strictValidate } from 'picsur-shared/dist/util/validate'; import { strictValidate } from 'picsur-shared/dist/util/validate';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
interface dataWrapper<T> {
data: T;
expires: number;
}
@Injectable({
providedIn: 'root',
})
export class CacheService {
private readonly cacheExpiresMS = 1000 * 60 * 60;
private storage: Storage;
constructor() {
if (window.sessionStorage) {
this.storage = window.sessionStorage;
} else {
throw new Error('Session storage is not supported');
}
}
public get<T>(key: string): T | null {
try {
const data: dataWrapper<T> = JSON.parse(this.storage.getItem(key) ?? '');
if (data && data.data && data.expires > Date.now()) {
return data.data;
}
return null;
} catch (e) {
return null;
}
}
public set<T>(key: string, value: T): void {
const data: dataWrapper<T> = {
data: value,
expires: Date.now() + this.cacheExpiresMS,
};
this.storage.setItem(key, JSON.stringify(data));
}
}

View File

@@ -8,18 +8,23 @@ import {
RoleInfoResponse, RoleInfoResponse,
RoleListResponse, RoleListResponse,
RoleUpdateRequest, RoleUpdateRequest,
RoleUpdateResponse RoleUpdateResponse,
SpecialRolesResponse
} from 'picsur-shared/dist/dto/api/roles.dto'; } from 'picsur-shared/dist/dto/api/roles.dto';
import { ERole } from 'picsur-shared/dist/entities/role.entity'; import { ERole } from 'picsur-shared/dist/entities/role.entity';
import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types'; import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types';
import { RoleModel } from 'src/app/models/forms/role.model'; import { RoleModel } from 'src/app/models/forms/role.model';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { CacheService } from './cache.service';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class RolesService { export class RolesService {
constructor(private apiService: ApiService) {} constructor(
private apiService: ApiService,
private cacheService: CacheService
) {}
public async getRoles(): AsyncFailable<ERole[]> { public async getRoles(): AsyncFailable<ERole[]> {
const result = await this.apiService.get( const result = await this.apiService.get(
@@ -85,4 +90,38 @@ export class RolesService {
return result; return result;
} }
public async getSpecialRoles(): AsyncFailable<SpecialRolesResponse> {
const cached = this.cacheService.get<SpecialRolesResponse>('specialRoles');
if (cached !== null) {
return cached;
}
const result = await this.apiService.get(
SpecialRolesResponse,
'/api/roles/special'
);
if (HasFailed(result)) {
return result;
}
this.cacheService.set('specialRoles', result);
return result;
}
public async getSpecialRolesOptimistic(): Promise<SpecialRolesResponse> {
const result = await this.getSpecialRoles();
if (HasFailed(result)) {
return {
DefaultRoles: [],
ImmutableRoles: [],
SoulBoundRoles: [],
UndeletableRoles: [],
};
}
return result;
}
} }

View File

@@ -18,7 +18,8 @@
"typescript": "4.5.5" "typescript": "4.5.5"
}, },
"scripts": { "scripts": {
"start": "tsc-watch", "clean": "rm -rf ./dist",
"build": "tsc" "start": "yarn clean && tsc-watch",
"build": "yarn clean && tsc"
} }
} }

View File

@@ -1,14 +1,12 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import { IsArray, IsDefined, ValidateNested } from 'class-validator';
IsArray,
IsDefined, ValidateNested
} from 'class-validator';
import { import {
ERole, ERole,
RoleNameObject, RoleNameObject,
RoleNamePermsObject RoleNamePermsObject
} from '../../entities/role.entity'; } from '../../entities/role.entity';
import { IsPosInt } from '../../validators/positive-int.validator'; import { IsPosInt } from '../../validators/positive-int.validator';
import { IsStringList } from '../../validators/string-list.validator';
// RoleInfo // RoleInfo
export class RoleInfoRequest extends RoleNameObject {} export class RoleInfoRequest extends RoleNameObject {}
@@ -37,3 +35,22 @@ export class RoleCreateResponse extends ERole {}
// RoleDelete // RoleDelete
export class RoleDeleteRequest extends RoleNameObject {} export class RoleDeleteRequest extends RoleNameObject {}
export class RoleDeleteResponse extends ERole {} export class RoleDeleteResponse extends ERole {}
// SpecialRoles
export class SpecialRolesResponse {
@IsDefined()
@IsStringList()
SoulBoundRoles: string[];
@IsDefined()
@IsStringList()
ImmutableRoles: string[];
@IsDefined()
@IsStringList()
UndeletableRoles: string[];
@IsDefined()
@IsStringList()
DefaultRoles: string[];
}

View File

@@ -1,13 +1,13 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import {
IsArray, IsArray,
IsDefined, IsOptional, IsDefined,
IsString, ValidateNested IsOptional, ValidateNested
} from 'class-validator'; } from 'class-validator';
import { EUser, NamePassUser, UsernameUser } from '../../entities/user.entity'; import { EUser, NamePassUser, UsernameUser } from '../../entities/user.entity';
import { IsPosInt } from '../../validators/positive-int.validator'; import { IsPosInt } from '../../validators/positive-int.validator';
import { IsStringList } from '../../validators/string-list.validator';
import { IsPlainTextPwd } from '../../validators/user.validators'; import { IsPlainTextPwd } from '../../validators/user.validators';
import { Roles } from '../roles.dto';
// UserList // UserList
export class UserListRequest { export class UserListRequest {
@@ -35,9 +35,8 @@ export class UserListResponse {
// UserCreate // UserCreate
export class UserCreateRequest extends NamePassUser { export class UserCreateRequest extends NamePassUser {
@IsOptional() @IsOptional()
@IsArray() @IsStringList()
@IsString({ each: true }) roles?: string[];
roles?: Roles;
} }
export class UserCreateResponse extends EUser {} export class UserCreateResponse extends EUser {}
@@ -52,9 +51,8 @@ export class UserInfoResponse extends EUser {}
// UserUpdateRoles // UserUpdateRoles
export class UserUpdateRequest extends UsernameUser { export class UserUpdateRequest extends UsernameUser {
@IsOptional() @IsOptional()
@IsArray() @IsStringList()
@IsString({ each: true }) roles?: string[];
roles?: Roles;
@IsPlainTextPwd() @IsPlainTextPwd()
@IsOptional() @IsOptional()

View File

@@ -1,47 +0,0 @@
import tuple from '../types/tuple';
import { Permission, Permissions, PermissionsList } from './permissions';
// Config
// These roles can never be removed or added to a user.
const PermanentRolesTuple = tuple('guest', 'user');
// These roles can never be modified
const ImmuteableRolesTuple = tuple('admin');
// These roles can never be removed from the server
const SystemRolesTuple = tuple(...PermanentRolesTuple, ...ImmuteableRolesTuple);
// These roles will be applied by default to new users
export const DefaultRolesList: string[] = ['user'];
// Derivatives
export const PermanentRolesList: string[] = PermanentRolesTuple;
export const ImmuteableRolesList: string[] = ImmuteableRolesTuple;
export const SystemRolesList: string[] = SystemRolesTuple;
export type SystemRole = typeof SystemRolesTuple[number];
export type SystemRoles = SystemRole[];
// Defaults
export const SystemRoleDefaults: {
[key in SystemRole]: Permissions;
} = {
guest: [Permission.ImageView, Permission.UserLogin],
user: [
Permission.ImageView,
Permission.UserMe,
Permission.UserLogin,
Permission.Settings,
Permission.ImageUpload,
],
// Grant all permissions to admin
admin: PermissionsList,
};
// Normal roles types
export type Role = SystemRole | string;
export type Roles = Role[];

View File

@@ -1,7 +1,7 @@
import { Exclude } from 'class-transformer'; import { Exclude } from 'class-transformer';
import { IsArray, IsOptional, IsString } from 'class-validator'; import { IsDefined, IsOptional, IsString } from 'class-validator';
import { Roles } from '../dto/roles.dto';
import { EntityID } from '../validators/entity-id.validator'; import { EntityID } from '../validators/entity-id.validator';
import { IsStringList } from '../validators/string-list.validator';
import { IsPlainTextPwd, IsUsername } from '../validators/user.validators'; import { IsPlainTextPwd, IsUsername } from '../validators/user.validators';
export class UsernameUser { export class UsernameUser {
@@ -17,9 +17,9 @@ export class NamePassUser extends UsernameUser {
// Add a user object with just the username and roles for jwt // Add a user object with just the username and roles for jwt
export class NameRolesUser extends UsernameUser { export class NameRolesUser extends UsernameUser {
@IsArray() @IsDefined()
@IsString({ each: true }) @IsStringList()
roles: Roles; roles: string[];
} }
// Actual entity that goes in the db // Actual entity that goes in the db

View File

@@ -0,0 +1,12 @@
import {
IsArray,
IsNotEmpty,
IsString
} from 'class-validator';
import { ComposeValidators } from './compose.validator';
export const IsStringList = ComposeValidators(
IsArray(),
IsString({ each: true }),
IsNotEmpty({ each: true }),
);