diff --git a/backend/package.json b/backend/package.json index 0e85b3e..b664e18 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,10 +12,11 @@ "prebuild": "rimraf dist", "build": "nest build", "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:prod": "node --experimental-specifier-resolution=node dist/main", "format": "prettier --write \"src/**/*.ts\"", + "clean": "rimraf dist", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix" }, "dependencies": { diff --git a/backend/src/collections/roledb/roledb.module.ts b/backend/src/collections/roledb/roledb.module.ts index 518e97e..b4b0d4b 100644 --- a/backend/src/collections/roledb/roledb.module.ts +++ b/backend/src/collections/roledb/roledb.module.ts @@ -1,14 +1,9 @@ import { Logger, Module, OnModuleInit } from '@nestjs/common'; 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 { PicsurConfigModule } from '../../config/config.module'; import { HostConfigService } from '../../config/host.config.service'; +import { ImmutableRolesList, SystemRoleDefaults, UndeletableRolesList } from '../../models/dto/roles.dto'; import { ERoleBackend } from '../../models/entities/role.entity'; import { RolesService } from './roledb.service'; @@ -43,7 +38,7 @@ export class RolesModule implements OnModuleInit { } private async ensureSystemRolesExist() { - for (const systemRole of SystemRolesList as SystemRoles) { + for (const systemRole of UndeletableRolesList) { this.logger.debug(`Ensuring system role "${systemRole}" exists`); const exists = await this.rolesService.exists(systemRole); @@ -63,7 +58,7 @@ export class RolesModule implements OnModuleInit { } private async updateImmutableRoles() { - for (const immutableRole of ImmuteableRolesList as SystemRoles) { + for (const immutableRole of ImmutableRolesList) { this.logger.debug( `Updating permissions for immutable role "${immutableRole}"`, ); diff --git a/backend/src/collections/roledb/roledb.service.ts b/backend/src/collections/roledb/roledb.service.ts index 33f1562..d678648 100644 --- a/backend/src/collections/roledb/roledb.service.ts +++ b/backend/src/collections/roledb/roledb.service.ts @@ -2,11 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { plainToClass } from 'class-transformer'; import { Permissions } from 'picsur-shared/dist/dto/permissions'; -import { - ImmuteableRolesList, - Roles, - SystemRolesList -} from 'picsur-shared/dist/dto/roles.dto'; import { AsyncFailable, Fail, @@ -15,6 +10,7 @@ import { } from 'picsur-shared/dist/types'; import { strictValidate } from 'picsur-shared/dist/util/validate'; import { In, Repository } from 'typeorm'; +import { ImmutableRolesList, UndeletableRolesList } from '../../models/dto/roles.dto'; import { ERoleBackend } from '../../models/entities/role.entity'; @Injectable() @@ -51,7 +47,7 @@ export class RolesService { const roleToModify = await this.resolve(role); if (HasFailed(roleToModify)) return roleToModify; - if (SystemRolesList.includes(roleToModify.name)) { + if (UndeletableRolesList.includes(roleToModify.name)) { return Fail('Cannot delete system role'); } @@ -62,7 +58,7 @@ export class RolesService { } } - public async getPermissions(roles: Roles): AsyncFailable { + public async getPermissions(roles: string[]): AsyncFailable { const permissions: Permissions = []; const foundRoles = await Promise.all( roles.map((role: string) => this.findOne(role)), @@ -113,7 +109,7 @@ export class RolesService { const roleToModify = await this.resolve(role); if (HasFailed(roleToModify)) return roleToModify; - if (!allowImmutable && ImmuteableRolesList.includes(roleToModify.name)) { + if (!allowImmutable && ImmutableRolesList.includes(roleToModify.name)) { return Fail('Cannot modify immutable role'); } @@ -157,7 +153,7 @@ export class RolesService { if (!iamsure) return Fail('Nuke aborted'); try { await this.rolesRepository.delete({ - name: In(SystemRolesList), + name: In(UndeletableRolesList), }); } catch (e: any) { return Fail(e?.message); diff --git a/backend/src/collections/userdb/userdb.service.ts b/backend/src/collections/userdb/userdb.service.ts index 63fdcc7..588af20 100644 --- a/backend/src/collections/userdb/userdb.service.ts +++ b/backend/src/collections/userdb/userdb.service.ts @@ -2,11 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import * as bcrypt from 'bcrypt'; import { plainToClass } from 'class-transformer'; -import { - DefaultRolesList, - PermanentRolesList, - Roles -} from 'picsur-shared/dist/dto/roles.dto'; import { LockedLoginUsersList, LockedPermsUsersList, @@ -20,6 +15,10 @@ import { } from 'picsur-shared/dist/types'; import { strictValidate } from 'picsur-shared/dist/util/validate'; import { Repository } from 'typeorm'; +import { + DefaultRolesList, + SoulBoundRolesList +} from '../../models/dto/roles.dto'; import { EUserBackend } from '../../models/entities/user.entity'; import { GetCols } from '../collectionutils'; import { RolesService } from '../roledb/roledb.service'; @@ -41,7 +40,7 @@ export class UsersService { public async create( username: string, password: string, - roles?: Roles, + roles?: string[], byPassRoleCheck?: boolean, ): AsyncFailable { if (await this.exists(username)) return Fail('User already exists'); @@ -90,7 +89,7 @@ export class UsersService { public async setRoles( user: string | EUserBackend, - roles: Roles, + roles: string[], ): AsyncFailable { const userToModify = await this.resolve(user); if (HasFailed(userToModify)) return userToModify; @@ -101,7 +100,7 @@ export class UsersService { } const rolesToKeep = userToModify.roles.filter((role) => - PermanentRolesList.includes(role), + SoulBoundRolesList.includes(role), ); 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( - (role) => !PermanentRolesList.includes(role), + (role) => !SoulBoundRolesList.includes(role), ); return filteredRoles; diff --git a/backend/src/collections/userdb/userrolesdb.service.ts b/backend/src/collections/userdb/userrolesdb.service.ts index 4bdb0c6..6a883b9 100644 --- a/backend/src/collections/userdb/userrolesdb.service.ts +++ b/backend/src/collections/userdb/userrolesdb.service.ts @@ -1,6 +1,5 @@ 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'; @@ -23,7 +22,7 @@ export class UserRolesService { public async addRoles( user: string | EUserBackend, - roles: Roles, + roles: string[], ): AsyncFailable { const userToModify = await this.usersService.resolve(user); if (HasFailed(userToModify)) return userToModify; @@ -35,7 +34,7 @@ export class UserRolesService { public async removeRoles( user: string | EUserBackend, - roles: Roles, + roles: string[], ): AsyncFailable { const userToModify = await this.usersService.resolve(user); if (HasFailed(userToModify)) return userToModify; diff --git a/backend/src/decorators/multipart.pipe.ts b/backend/src/decorators/multipart.pipe.ts index c9b42a2..38d8c62 100644 --- a/backend/src/decorators/multipart.pipe.ts +++ b/backend/src/decorators/multipart.pipe.ts @@ -1,9 +1,9 @@ import { - BadRequestException, - Injectable, - Logger, - PipeTransform, - Scope + BadRequestException, + Injectable, + Logger, + PipeTransform, + Scope } from '@nestjs/common'; import { FastifyRequest } from 'fastify'; 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 { MultipartConfigService } from '../config/multipart.config.service'; import { - MultiPartFieldDto, - MultiPartFileDto -} from '../models/dto/multipart.dto'; + MultiPartFieldDto, + MultiPartFileDto +} from '../models/requests/multipart.dto'; @Injectable({ scope: Scope.REQUEST }) export class MultiPartPipe implements PipeTransform { diff --git a/backend/src/layers/httpexception/httpexception.filter.ts b/backend/src/layers/httpexception/httpexception.filter.ts index 63ea06a..6c12f21 100644 --- a/backend/src/layers/httpexception/httpexception.filter.ts +++ b/backend/src/layers/httpexception/httpexception.filter.ts @@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; 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) export class MainExceptionFilter implements ExceptionFilter { diff --git a/backend/src/layers/success/success.interceptor.ts b/backend/src/layers/success/success.interceptor.ts index 0f11fb0..9d76d50 100644 --- a/backend/src/layers/success/success.interceptor.ts +++ b/backend/src/layers/success/success.interceptor.ts @@ -2,7 +2,7 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } 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'; @Injectable() diff --git a/backend/src/models/dto/roles.dto.ts b/backend/src/models/dto/roles.dto.ts new file mode 100644 index 0000000..cc0d45d --- /dev/null +++ b/backend/src/models/dto/roles.dto.ts @@ -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; +}; diff --git a/backend/src/models/entities/user.entity.ts b/backend/src/models/entities/user.entity.ts index df7e5c4..6a030ea 100644 --- a/backend/src/models/entities/user.entity.ts +++ b/backend/src/models/entities/user.entity.ts @@ -1,4 +1,3 @@ -import { Roles } from 'picsur-shared/dist/dto/roles.dto'; import { EUser } from 'picsur-shared/dist/entities/user.entity'; import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; @@ -14,7 +13,7 @@ export class EUserBackend extends EUser { override username: string; @Column('text', { nullable: false, array: true }) - override roles: Roles; + override roles: string[]; @Column({ nullable: false, select: false }) override password?: string; diff --git a/backend/src/models/dto/authrequest.dto.ts b/backend/src/models/requests/authrequest.dto.ts similarity index 100% rename from backend/src/models/dto/authrequest.dto.ts rename to backend/src/models/requests/authrequest.dto.ts diff --git a/backend/src/models/dto/imageroute.dto.ts b/backend/src/models/requests/imageroute.dto.ts similarity index 100% rename from backend/src/models/dto/imageroute.dto.ts rename to backend/src/models/requests/imageroute.dto.ts diff --git a/backend/src/models/dto/multipart.dto.ts b/backend/src/models/requests/multipart.dto.ts similarity index 100% rename from backend/src/models/dto/multipart.dto.ts rename to backend/src/models/requests/multipart.dto.ts diff --git a/backend/src/routes/api/experiment/experiment.controller.ts b/backend/src/routes/api/experiment/experiment.controller.ts index 777052b..e99a310 100644 --- a/backend/src/routes/api/experiment/experiment.controller.ts +++ b/backend/src/routes/api/experiment/experiment.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Request } from '@nestjs/common'; -import AuthFasityRequest from '../../../models/dto/authrequest.dto'; +import AuthFasityRequest from '../../../models/requests/authrequest.dto'; @Controller('api/experiment') diff --git a/backend/src/routes/api/roles/roles.controller.ts b/backend/src/routes/api/roles/roles.controller.ts index 62c79f5..8d33c4c 100644 --- a/backend/src/routes/api/roles/roles.controller.ts +++ b/backend/src/routes/api/roles/roles.controller.ts @@ -6,6 +6,7 @@ import { Logger, Post } from '@nestjs/common'; +import { plainToClass } from 'class-transformer'; import { RoleCreateRequest, RoleCreateResponse, @@ -15,12 +16,19 @@ import { RoleInfoResponse, RoleListResponse, RoleUpdateRequest, - RoleUpdateResponse + RoleUpdateResponse, + SpecialRolesResponse } from 'picsur-shared/dist/dto/api/roles.dto'; import { Permission } from 'picsur-shared/dist/dto/permissions'; import { HasFailed } from 'picsur-shared/dist/types'; import { RolesService } from '../../../collections/roledb/roledb.service'; import { RequiredPermissions } from '../../../decorators/permissions.decorator'; +import { + DefaultRolesList, + ImmutableRolesList, + SoulBoundRolesList, + UndeletableRolesList +} from '../../../models/dto/roles.dto'; @Controller('api/roles') @RequiredPermissions(Permission.RoleManage) @@ -95,4 +103,16 @@ export class RolesController { return deletedRole; } + + @Get('special') + async getSpecialRoles(): Promise { + const result: SpecialRolesResponse = { + SoulBoundRoles: SoulBoundRolesList, + ImmutableRoles: ImmutableRolesList, + UndeletableRoles: UndeletableRolesList, + DefaultRoles: DefaultRolesList, + }; + + return plainToClass(SpecialRolesResponse, result); + } } diff --git a/backend/src/routes/api/user/user.controller.ts b/backend/src/routes/api/user/user.controller.ts index dd2341b..82e9a55 100644 --- a/backend/src/routes/api/user/user.controller.ts +++ b/backend/src/routes/api/user/user.controller.ts @@ -1,30 +1,30 @@ import { - Body, - Controller, - Get, - InternalServerErrorException, - Logger, - Post, - Request + Body, + Controller, + Get, + InternalServerErrorException, + Logger, + Post, + Request } from '@nestjs/common'; import { - UserLoginResponse, - UserMePermissionsResponse, - UserMeResponse, - UserRegisterRequest, - UserRegisterResponse + UserLoginResponse, + UserMePermissionsResponse, + UserMeResponse, + UserRegisterRequest, + UserRegisterResponse } from 'picsur-shared/dist/dto/api/user.dto'; 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, - UseLocalAuth + NoPermissions, + RequiredPermissions, + UseLocalAuth } from '../../../decorators/permissions.decorator'; import { AuthManagerService } from '../../../managers/auth/auth.service'; -import AuthFasityRequest from '../../../models/dto/authrequest.dto'; +import AuthFasityRequest from '../../../models/requests/authrequest.dto'; @Controller('api/user') export class UserController { diff --git a/backend/src/routes/image/imageroute.controller.ts b/backend/src/routes/image/imageroute.controller.ts index 0449d5c..7107e4f 100644 --- a/backend/src/routes/image/imageroute.controller.ts +++ b/backend/src/routes/image/imageroute.controller.ts @@ -18,7 +18,7 @@ import { HasFailed } from 'picsur-shared/dist/types'; import { MultiPart } from '../../decorators/multipart.decorator'; import { RequiredPermissions } from '../../decorators/permissions.decorator'; import { ImageManagerService } from '../../managers/imagemanager/imagemanager.service'; -import { ImageUploadDto } from '../../models/dto/imageroute.dto'; +import { ImageUploadDto } from '../../models/requests/imageroute.dto'; @Controller('i') @RequiredPermissions(Permission.ImageView) diff --git a/frontend/src/app/models/forms/fulluser.model.ts b/frontend/src/app/models/forms/fulluser.model.ts index df4cb47..273f5c1 100644 --- a/frontend/src/app/models/forms/fulluser.model.ts +++ b/frontend/src/app/models/forms/fulluser.model.ts @@ -1,7 +1,5 @@ -import { Roles } from 'picsur-shared/dist/dto/roles.dto'; - export interface FullUserModel { username: string; password: string; - roles: Roles; + roles: string[]; } diff --git a/frontend/src/app/models/forms/updaterole.control.ts b/frontend/src/app/models/forms/updaterole.control.ts index ced8dc7..ab46e69 100644 --- a/frontend/src/app/models/forms/updaterole.control.ts +++ b/frontend/src/app/models/forms/updaterole.control.ts @@ -1,7 +1,6 @@ import { FormControl } from '@angular/forms'; import Fuse from 'fuse.js'; import { Permission, Permissions } from 'picsur-shared/dist/dto/permissions'; -import { PermanentRolesList } from 'picsur-shared/dist/dto/roles.dto'; import { BehaviorSubject, Subscription } from 'rxjs'; import { RoleNameValidators } from './role-validators'; import { RoleModel } from './role.model'; @@ -58,11 +57,6 @@ export class UpdateRoleControl { this.updateSelectablePermissions(); } - public isRemovable(role: Permission) { - if (PermanentRolesList.includes(role)) return false; - return true; - } - // Data interaction public putAllPermissions(permissions: Permissions) { diff --git a/frontend/src/app/models/forms/updateuser.control.ts b/frontend/src/app/models/forms/updateuser.control.ts index 559b33d..1ca8508 100644 --- a/frontend/src/app/models/forms/updateuser.control.ts +++ b/frontend/src/app/models/forms/updateuser.control.ts @@ -1,7 +1,6 @@ import { FormControl } from '@angular/forms'; import Fuse from 'fuse.js'; 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 { BehaviorSubject, Subscription } from 'rxjs'; import { FullUserModel } from './fulluser.model'; @@ -13,6 +12,9 @@ import { } from './user-validators'; export class UpdateUserControl { + // Special roles + private SoulBoundRolesList: string[] = []; + // Set once private fullRoles: ERole[] = []; private roles: string[] = []; @@ -68,7 +70,7 @@ export class UpdateUserControl { } public isRemovable(role: string) { - if (PermanentRolesList.includes(role)) return false; + if (this.SoulBoundRolesList.includes(role)) return false; return true; } @@ -106,6 +108,10 @@ export class UpdateUserControl { this.updateSelectableRoles(); } + public putSoulBoundRoles(roles: string[]) { + this.SoulBoundRolesList = roles; + } + public getData(): FullUserModel { return { username: this.username.value, @@ -119,7 +125,8 @@ export class UpdateUserControl { 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)) + (r) => + !(this.selectedRoles.includes(r) || this.SoulBoundRolesList.includes(r)) ); const searchValue = this.rolesControl.value; diff --git a/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.html b/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.html index 471b18b..57f70e8 100644 --- a/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.html +++ b/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.html @@ -33,12 +33,10 @@ {{ uiFriendlyPermission(permission) }} - @@ -50,14 +48,14 @@ [matAutocomplete]="auto" [matChipInputFor]="chipList" [matChipInputSeparatorKeyCodes]="separatorKeysCodes" - (matChipInputTokenEnd)="addRole($event)" + (matChipInputTokenEnd)="addPermission($event)" autocorrect="off" autocapitalize="none" /> ([]); + private UndeletableRolesList: string[] = []; + private ImmutableRolesList: string[] = []; + @ViewChild(MatPaginator) paginator: MatPaginator; constructor( @@ -91,11 +93,11 @@ export class SettingsRolesComponent implements OnInit, AfterViewInit { } isSystem(role: ERole) { - return SystemRolesList.includes(role.name); + return this.UndeletableRolesList.includes(role.name); } isImmutable(role: ERole) { - return ImmuteableRolesList.includes(role.name); + return this.ImmutableRolesList.includes(role.name); } private async fetchRoles() { @@ -106,5 +108,17 @@ export class SettingsRolesComponent implements OnInit, AfterViewInit { } 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; } } 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 507aff5..eec92f6 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 @@ -4,7 +4,6 @@ import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatChipInputEvent } from '@angular/material/chips'; import { ActivatedRoute, Router } from '@angular/router'; 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 { HasFailed } from 'picsur-shared/dist/types'; import { UpdateUserControl } from 'src/app/models/forms/updateuser.control'; @@ -51,9 +50,15 @@ export class SettingsUsersEditComponent implements OnInit { private async initUser() { const username = this.route.snapshot.paramMap.get('username'); + + const { DefaultRoles, SoulBoundRoles } = + await this.rolesService.getSpecialRolesOptimistic(); + this.model.putSoulBoundRoles(SoulBoundRoles); + if (!username) { this.mode = EditMode.add; - this.model.putRoles(DefaultRolesList); + + this.model.putRoles(DefaultRoles); return; } diff --git a/frontend/src/app/services/api/api.service.ts b/frontend/src/app/services/api/api.service.ts index 8b35cd6..100710d 100644 --- a/frontend/src/app/services/api/api.service.ts +++ b/frontend/src/app/services/api/api.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; 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 { strictValidate } from 'picsur-shared/dist/util/validate'; import { Subject } from 'rxjs'; diff --git a/frontend/src/app/services/api/cache.service.ts b/frontend/src/app/services/api/cache.service.ts new file mode 100644 index 0000000..64aed30 --- /dev/null +++ b/frontend/src/app/services/api/cache.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; + +interface dataWrapper { + 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(key: string): T | null { + try { + const data: dataWrapper = 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(key: string, value: T): void { + const data: dataWrapper = { + data: value, + expires: Date.now() + this.cacheExpiresMS, + }; + + this.storage.setItem(key, JSON.stringify(data)); + } +} diff --git a/frontend/src/app/services/api/roles.service.ts b/frontend/src/app/services/api/roles.service.ts index 926e769..fe69170 100644 --- a/frontend/src/app/services/api/roles.service.ts +++ b/frontend/src/app/services/api/roles.service.ts @@ -8,18 +8,23 @@ import { RoleInfoResponse, RoleListResponse, RoleUpdateRequest, - RoleUpdateResponse + RoleUpdateResponse, + SpecialRolesResponse } 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 { RoleModel } from 'src/app/models/forms/role.model'; import { ApiService } from './api.service'; +import { CacheService } from './cache.service'; @Injectable({ providedIn: 'root', }) export class RolesService { - constructor(private apiService: ApiService) {} + constructor( + private apiService: ApiService, + private cacheService: CacheService + ) {} public async getRoles(): AsyncFailable { const result = await this.apiService.get( @@ -85,4 +90,38 @@ export class RolesService { return result; } + + public async getSpecialRoles(): AsyncFailable { + const cached = this.cacheService.get('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 { + const result = await this.getSpecialRoles(); + if (HasFailed(result)) { + return { + DefaultRoles: [], + ImmutableRoles: [], + SoulBoundRoles: [], + UndeletableRoles: [], + }; + } + + return result; + } } diff --git a/shared/package.json b/shared/package.json index 55c2072..6361dd3 100644 --- a/shared/package.json +++ b/shared/package.json @@ -18,7 +18,8 @@ "typescript": "4.5.5" }, "scripts": { - "start": "tsc-watch", - "build": "tsc" + "clean": "rm -rf ./dist", + "start": "yarn clean && tsc-watch", + "build": "yarn clean && tsc" } } diff --git a/shared/src/dto/api/roles.dto.ts b/shared/src/dto/api/roles.dto.ts index ce2b254..97ed9bd 100644 --- a/shared/src/dto/api/roles.dto.ts +++ b/shared/src/dto/api/roles.dto.ts @@ -1,14 +1,12 @@ import { Type } from 'class-transformer'; -import { - IsArray, - IsDefined, ValidateNested -} from 'class-validator'; +import { IsArray, IsDefined, ValidateNested } from 'class-validator'; import { ERole, RoleNameObject, RoleNamePermsObject } from '../../entities/role.entity'; import { IsPosInt } from '../../validators/positive-int.validator'; +import { IsStringList } from '../../validators/string-list.validator'; // RoleInfo export class RoleInfoRequest extends RoleNameObject {} @@ -37,3 +35,22 @@ export class RoleCreateResponse extends ERole {} // RoleDelete export class RoleDeleteRequest extends RoleNameObject {} 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[]; +} diff --git a/shared/src/dto/api/usermanage.dto.ts b/shared/src/dto/api/usermanage.dto.ts index 961ed85..a020198 100644 --- a/shared/src/dto/api/usermanage.dto.ts +++ b/shared/src/dto/api/usermanage.dto.ts @@ -1,13 +1,13 @@ import { Type } from 'class-transformer'; import { IsArray, - IsDefined, IsOptional, - IsString, ValidateNested + IsDefined, + IsOptional, ValidateNested } from 'class-validator'; import { EUser, NamePassUser, UsernameUser } from '../../entities/user.entity'; import { IsPosInt } from '../../validators/positive-int.validator'; +import { IsStringList } from '../../validators/string-list.validator'; import { IsPlainTextPwd } from '../../validators/user.validators'; -import { Roles } from '../roles.dto'; // UserList export class UserListRequest { @@ -35,9 +35,8 @@ export class UserListResponse { // UserCreate export class UserCreateRequest extends NamePassUser { @IsOptional() - @IsArray() - @IsString({ each: true }) - roles?: Roles; + @IsStringList() + roles?: string[]; } export class UserCreateResponse extends EUser {} @@ -52,9 +51,8 @@ export class UserInfoResponse extends EUser {} // UserUpdateRoles export class UserUpdateRequest extends UsernameUser { @IsOptional() - @IsArray() - @IsString({ each: true }) - roles?: Roles; + @IsStringList() + roles?: string[]; @IsPlainTextPwd() @IsOptional() diff --git a/shared/src/dto/roles.dto.ts b/shared/src/dto/roles.dto.ts deleted file mode 100644 index 7e5153a..0000000 --- a/shared/src/dto/roles.dto.ts +++ /dev/null @@ -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[]; diff --git a/shared/src/entities/user.entity.ts b/shared/src/entities/user.entity.ts index a4075f8..fe177e5 100644 --- a/shared/src/entities/user.entity.ts +++ b/shared/src/entities/user.entity.ts @@ -1,7 +1,7 @@ import { Exclude } from 'class-transformer'; -import { IsArray, IsOptional, IsString } from 'class-validator'; -import { Roles } from '../dto/roles.dto'; +import { IsDefined, IsOptional, IsString } from 'class-validator'; import { EntityID } from '../validators/entity-id.validator'; +import { IsStringList } from '../validators/string-list.validator'; import { IsPlainTextPwd, IsUsername } from '../validators/user.validators'; export class UsernameUser { @@ -17,9 +17,9 @@ export class NamePassUser extends UsernameUser { // Add a user object with just the username and roles for jwt export class NameRolesUser extends UsernameUser { - @IsArray() - @IsString({ each: true }) - roles: Roles; + @IsDefined() + @IsStringList() + roles: string[]; } // Actual entity that goes in the db diff --git a/shared/src/validators/string-list.validator.ts b/shared/src/validators/string-list.validator.ts new file mode 100644 index 0000000..478f691 --- /dev/null +++ b/shared/src/validators/string-list.validator.ts @@ -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 }), +);