From 1e41168adf10269be2824d069653c045d318aecd Mon Sep 17 00:00:00 2001 From: Bruno Date: Wed, 23 Oct 2024 15:35:02 +0200 Subject: [PATCH 01/11] feat: display api doc on prod env --- packages/core/manifest/src/app.module.ts | 3 +-- packages/core/manifest/src/main.ts | 14 ++++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/core/manifest/src/app.module.ts b/packages/core/manifest/src/app.module.ts index 5c46f8b5..c12e0fef 100644 --- a/packages/core/manifest/src/app.module.ts +++ b/packages/core/manifest/src/app.module.ts @@ -66,9 +66,8 @@ export class AppModule { private async init() { const isSeed: boolean = process.argv[1].includes('seed') const isTest: boolean = process.env.NODE_ENV === 'test' - const isProduction: boolean = process.env.NODE_ENV === 'production' - if (!isSeed && !isTest && !isProduction) { + if (!isSeed && !isTest) { this.loggerService.initMessage() } } diff --git a/packages/core/manifest/src/main.ts b/packages/core/manifest/src/main.ts index 02f1d37d..0def0a9c 100644 --- a/packages/core/manifest/src/main.ts +++ b/packages/core/manifest/src/main.ts @@ -51,14 +51,13 @@ async function bootstrap() { } }) - if (!isProduction) { - const openApiService: OpenApiService = app.get(OpenApiService) + const openApiService: OpenApiService = app.get(OpenApiService) - SwaggerModule.setup('api', app, openApiService.generateOpenApiObject(), { - customfavIcon: 'assets/images/open-api/favicon.ico', - customSiteTitle: 'Manifest API Doc', + SwaggerModule.setup('api', app, openApiService.generateOpenApiObject(), { + customfavIcon: 'assets/images/open-api/favicon.ico', + customSiteTitle: 'Manifest API Doc', - customCss: ` + customCss: ` .swagger-ui html { box-sizing: border-box; @@ -1791,8 +1790,7 @@ background: #ce107c; fill: #535356; } ` - }) - } + }) await app.listen(configService.get('PORT') || DEFAULT_PORT) } From 5fd6fec209f6e7dc7f334a80bc28a34e0b076b81 Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 31 Oct 2024 15:16:30 +0100 Subject: [PATCH 02/11] feat(addManifest): adds README and gitignore folders --- README.md | 2 +- packages/add-manifest/README.md | 4 +- packages/add-manifest/assets/README.md | 58 +++++++++++++++++++++ packages/add-manifest/src/commands/index.ts | 41 +++++++++++---- packages/core/manifest/package.json | 2 +- 5 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 packages/add-manifest/assets/README.md diff --git a/README.md b/README.md index c831ee95..fec5ff0c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

-A backend so simple that it fits in a YAML file +A backend so simple that it fits into 1 YAML file

npm CodeFactor Grade diff --git a/packages/add-manifest/README.md b/packages/add-manifest/README.md index e6067011..31290f2d 100644 --- a/packages/add-manifest/README.md +++ b/packages/add-manifest/README.md @@ -14,9 +14,11 @@ npm install # Run from a test folder to prevent messing with project files. mkdir test-folder cd test-folder -../bin/dev.js create +../bin/dev.js ``` +However due to the monorepo workspace structure, the launch script will fail as the path to the node modules folder is different than when served. + ## Publish ```bash diff --git a/packages/add-manifest/assets/README.md b/packages/add-manifest/assets/README.md new file mode 100644 index 00000000..0cd97613 --- /dev/null +++ b/packages/add-manifest/assets/README.md @@ -0,0 +1,58 @@ +
+

+ + manifest + + + manifest + +

+ +

+A backend so simple that it fits into 1 YAML file +

+ npm + CodeFactor Grade + Discord + Support us + CodeTriage + License MIT +
+

+ +## Description + +This project was made with [Manifest](https://github.com/mnfst/manifest). + +## Installation + +```bash +$ npm install +``` + +## Running the app + +To run the app in the development mode: + +```bash +npm run manifest +``` + +- Open [http://localhost:1111](http://localhost:1111) to open your admin UI it in your browser +- Open [http://localhost:1111/api](http://localhost:111/api) to view your REST API documentation + +The page will reload when you make changes. + +## Seed dummy data + +Seeds some dummy data for your entities: + +```bash +npm run manifest:seed +``` + +## Community & Resources + +- [Docs](https://manifest.build/docs) - Get started with Manifest +- [Discord](https://discord.gg/FepAked3W7) - Come chat with the community +- [Github](https://github.com/mnfst/manifest/issues) - Report bugs and share ideas to improve the product. diff --git a/packages/add-manifest/src/commands/index.ts b/packages/add-manifest/src/commands/index.ts index c9eba975..96764de4 100644 --- a/packages/add-manifest/src/commands/index.ts +++ b/packages/add-manifest/src/commands/index.ts @@ -28,11 +28,12 @@ export class MyCommand extends Command { * 5. Update the .vscode/settings.json file with the recommended settings. * 6. Update the .gitignore file with the recommended settings. * 7. Update the .env file with the environment variables. - * 8. Install the new packages. - * 9. Serve the new app. - * 10. Wait for the server to start. - * 11. Seed the database. - * 12. Open the browser. + * 8. If no README.md file exists, create one. + * 9. Install the new packages. + * 10. Serve the new app. + * 11. Wait for the server to start. + * 12. Seed the database. + * 13. Open the browser. */ async run(): Promise { const folderName = 'manifest' @@ -42,6 +43,7 @@ export class MyCommand extends Command { const spinner = ora('Add Manifest to your project...').start() + // * 1. Create a folder with the name `manifest`. // Construct the folder path. This example creates the folder in the current working directory. const folderPath = path.join(process.cwd(), folderName) @@ -56,6 +58,7 @@ export class MyCommand extends Command { // Create the folder fs.mkdirSync(folderPath) + // * 2. Create a file inside the folder with the name `manifest.yml`. // Path where the new file should be created const newFilePath = path.join(folderPath, initialFileName) @@ -155,7 +158,7 @@ export class MyCommand extends Command { }) ) - // Update the .gitignore file with the recommended settings. + // * 7. Update the .env file with the environment variables. const gitignorePath = path.join(process.cwd(), '.gitignore') let gitignoreContent = '' @@ -163,14 +166,32 @@ export class MyCommand extends Command { gitignoreContent = fs.readFileSync(gitignorePath, 'utf8') } - if (!gitignoreContent.includes('node_modules')) { - gitignoreContent += '\nnode_modules' - gitignoreContent += '\n.env' - } + const newGitignoreLines: string[] = [ + 'node_modules', + '.env', + 'public', + 'manifest/backend.db' + ] + newGitignoreLines.forEach((line) => { + if (!gitignoreContent.includes(line)) { + gitignoreContent += `\n${line}` + } + }) fs.writeFileSync(gitignorePath, gitignoreContent) spinner.succeed() + + // * 8. Add a README.md file if it doesn't exist. + const readmeFilePath = path.join(process.cwd(), 'README.md') + if (!fs.existsSync(readmeFilePath)) { + fs.writeFileSync( + readmeFilePath, + fs.readFileSync(path.join(assetFolderPath, 'README.md'), 'utf8') + ) + } + + // * 9. Install the new packages. spinner.start('Install dependencies...') // Install deps. diff --git a/packages/core/manifest/package.json b/packages/core/manifest/package.json index dd27cb55..d2fb44df 100644 --- a/packages/core/manifest/package.json +++ b/packages/core/manifest/package.json @@ -16,7 +16,7 @@ "manifest", "backend", "backend-as-a-service", - "bass", + "baas", "api", "rest", "fullstack", From e7ecff863505f8ad79ef8bfec8dff70d5370e5c5 Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 4 Nov 2024 11:01:56 +0100 Subject: [PATCH 03/11] feat(onboarding): signup first admin endpoint (if db empty) --- .../core/manifest/src/auth/auth.controller.ts | 11 ++++ .../core/manifest/src/auth/auth.service.ts | 6 +- .../dtos/signup-authenticable-entity.dto.ts | 4 +- .../src/auth/guards/is-db-empty.guard.ts | 43 +++++++++++++ .../src/auth/tests/is-db-empty.guard.spec.ts | 62 +++++++++++++++++++ 5 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 packages/core/manifest/src/auth/guards/is-db-empty.guard.ts create mode 100644 packages/core/manifest/src/auth/tests/is-db-empty.guard.spec.ts diff --git a/packages/core/manifest/src/auth/auth.controller.ts b/packages/core/manifest/src/auth/auth.controller.ts index 1d51e5b2..ee98c7ae 100644 --- a/packages/core/manifest/src/auth/auth.controller.ts +++ b/packages/core/manifest/src/auth/auth.controller.ts @@ -14,6 +14,7 @@ import { AuthService } from './auth.service' import { SignupAuthenticableEntityDto } from './dtos/signup-authenticable-entity.dto' import { Rule } from './decorators/rule.decorator' import { AuthorizationGuard } from './guards/authorization.guard' +import { IsDbEmptyGuard } from './guards/is-db-empty.guard' @Controller('auth') @UseGuards(AuthorizationGuard) @@ -30,6 +31,16 @@ export class AuthController { return this.authService.createToken(entity, signupUserDto) } + @Post(':admins/signup') + @UseGuards(IsDbEmptyGuard) + public async signupAdmin( + @Body() signupUserDto: SignupAuthenticableEntityDto + ): Promise<{ + token: string + }> { + return this.authService.signup('admins', signupUserDto, true) + } + @Post(':entity/signup') @Rule('signup') public async signup( diff --git a/packages/core/manifest/src/auth/auth.service.ts b/packages/core/manifest/src/auth/auth.service.ts index f3cd53cc..528600dd 100644 --- a/packages/core/manifest/src/auth/auth.service.ts +++ b/packages/core/manifest/src/auth/auth.service.ts @@ -78,15 +78,17 @@ export class AuthService { * @param entitySlug The slug of the AuthenticableEntity where the user is going to be created * @param email The email of the user * @param password The password of the user + * @param byPassAdminCheck If true, the method will not check if the entity is an admin * * @returns A JWT token of the created user * */ async signup( entitySlug: string, - signupUserDto: SignupAuthenticableEntityDto + signupUserDto: SignupAuthenticableEntityDto, + byPassAdminCheck = false ): Promise<{ token: string }> { - if (entitySlug === ADMIN_ENTITY_MANIFEST.slug) { + if (entitySlug === ADMIN_ENTITY_MANIFEST.slug && !byPassAdminCheck) { throw new HttpException( 'Admins cannot be created with this method.', HttpStatus.BAD_REQUEST diff --git a/packages/core/manifest/src/auth/dtos/signup-authenticable-entity.dto.ts b/packages/core/manifest/src/auth/dtos/signup-authenticable-entity.dto.ts index 9bba208f..a7aa2130 100644 --- a/packages/core/manifest/src/auth/dtos/signup-authenticable-entity.dto.ts +++ b/packages/core/manifest/src/auth/dtos/signup-authenticable-entity.dto.ts @@ -1,9 +1,11 @@ -import { IsEmail, IsNotEmpty } from 'class-validator' +import { IsEmail, IsNotEmpty, IsString } from 'class-validator' export class SignupAuthenticableEntityDto { @IsEmail() + @IsNotEmpty() public email: string + @IsString() @IsNotEmpty() public password: string } diff --git a/packages/core/manifest/src/auth/guards/is-db-empty.guard.ts b/packages/core/manifest/src/auth/guards/is-db-empty.guard.ts new file mode 100644 index 00000000..39c3cfe3 --- /dev/null +++ b/packages/core/manifest/src/auth/guards/is-db-empty.guard.ts @@ -0,0 +1,43 @@ +import { CanActivate, Injectable } from '@nestjs/common' +import { ManifestService } from '../../manifest/services/manifest.service' +import { AppManifest, EntityManifest } from '@repo/types' +import { EntityService } from '../../entity/services/entity.service' +import { ADMIN_ENTITY_MANIFEST } from '../../constants' + +@Injectable() +export class IsDbEmptyGuard implements CanActivate { + constructor( + private readonly manifestService: ManifestService, + private readonly entityService: EntityService + ) {} + + /** + * Check if the database is empty (no items in any entity, even admin). + * + * @returns True if the database is empty, false otherwise. + * */ + async canActivate(): Promise { + const appManifest: AppManifest = this.manifestService.getAppManifest() + + const entities = [ + ...Object.values(appManifest.entities), + ADMIN_ENTITY_MANIFEST + ] + let totalItems = 0 + + await Promise.all( + Object.values(entities).map(async (entityManifest: EntityManifest) => { + return this.entityService + .getEntityRepository({ + entitySlug: entityManifest.slug + }) + .createQueryBuilder('entity') + .getCount() + }) + ).then((counts: number[]) => { + totalItems = counts.reduce((acc, count) => acc + count, 0) + }) + + return totalItems === 0 + } +} diff --git a/packages/core/manifest/src/auth/tests/is-db-empty.guard.spec.ts b/packages/core/manifest/src/auth/tests/is-db-empty.guard.spec.ts new file mode 100644 index 00000000..f6671415 --- /dev/null +++ b/packages/core/manifest/src/auth/tests/is-db-empty.guard.spec.ts @@ -0,0 +1,62 @@ +import { Test } from '@nestjs/testing' +import { IsDbEmptyGuard } from '../guards/is-db-empty.guard' +import { ManifestService } from '../../manifest/services/manifest.service' +import { EntityService } from '../../entity/services/entity.service' + +describe('IsDbEmptyGuard', () => { + let manifestService: ManifestService + let entityService: EntityService + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + IsDbEmptyGuard, + { + provide: ManifestService, + useValue: { + getAppManifest: jest.fn().mockReturnValue({ + entities: {} + }) + } + }, + { + provide: EntityService, + useValue: { + getEntityRepository: jest.fn().mockReturnValue({ + createQueryBuilder: jest.fn().mockReturnValue({ + getCount: jest.fn().mockReturnValue(0) + }) + }) + } + } + ] + }).compile() + + manifestService = module.get(ManifestService) + entityService = module.get(EntityService) + }) + + it('should be defined', () => { + expect(new IsDbEmptyGuard(manifestService, entityService)).toBeDefined() + }) + + it('should return true if the database is empty', async () => { + const isDbEmptyGuard = new IsDbEmptyGuard(manifestService, entityService) + const res = await isDbEmptyGuard.canActivate() + + expect(res).toBe(true) + }) + + it('should return false if the database is not empty', async () => { + jest.spyOn(entityService, 'getEntityRepository').mockReturnValue({ + createQueryBuilder: jest.fn().mockReturnValue({ + getCount: jest.fn().mockReturnValue(1) + }) + } as any) + + const isDbEmptyGuard = new IsDbEmptyGuard(manifestService, entityService) + const res = await isDbEmptyGuard.canActivate() + + expect(res).toBe(false) + }) +}) From 9f05a6cce4b3b1a5d3ecf9036d1803d9888bd483 Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 4 Nov 2024 11:45:04 +0100 Subject: [PATCH 04/11] feat(onboarding): isDbEmpty endpoint Refactor: Extracted isDbEmpty guard logic in new database service --- .../core/manifest/src/auth/auth.module.ts | 3 +- .../src/auth/guards/is-db-empty.guard.ts | 33 +--------- .../src/auth/tests/is-db-empty.guard.spec.ts | 39 ++++-------- .../crud/controllers/database.controller.ts | 16 +++++ .../core/manifest/src/crud/crud.module.ts | 7 ++- .../src/crud/services/database.service.ts | 43 +++++++++++++ .../crud/tests/database.controller.spec.ts | 18 ++++++ .../src/crud/tests/database.service.spec.ts | 62 +++++++++++++++++++ 8 files changed, 160 insertions(+), 61 deletions(-) create mode 100644 packages/core/manifest/src/crud/controllers/database.controller.ts create mode 100644 packages/core/manifest/src/crud/services/database.service.ts create mode 100644 packages/core/manifest/src/crud/tests/database.controller.spec.ts create mode 100644 packages/core/manifest/src/crud/tests/database.service.spec.ts diff --git a/packages/core/manifest/src/auth/auth.module.ts b/packages/core/manifest/src/auth/auth.module.ts index 6afc6c91..1df89597 100644 --- a/packages/core/manifest/src/auth/auth.module.ts +++ b/packages/core/manifest/src/auth/auth.module.ts @@ -4,11 +4,12 @@ import { EntityModule } from '../entity/entity.module' import { AuthController } from './auth.controller' import { AuthService } from './auth.service' import { ManifestModule } from '../manifest/manifest.module' +import { DatabaseService } from '../crud/services/database.service' @Module({ imports: [EntityModule, forwardRef(() => ManifestModule)], controllers: [AuthController], - providers: [AuthService], + providers: [AuthService, DatabaseService], exports: [AuthService] }) export class AuthModule {} diff --git a/packages/core/manifest/src/auth/guards/is-db-empty.guard.ts b/packages/core/manifest/src/auth/guards/is-db-empty.guard.ts index 39c3cfe3..80fc483f 100644 --- a/packages/core/manifest/src/auth/guards/is-db-empty.guard.ts +++ b/packages/core/manifest/src/auth/guards/is-db-empty.guard.ts @@ -1,15 +1,9 @@ import { CanActivate, Injectable } from '@nestjs/common' -import { ManifestService } from '../../manifest/services/manifest.service' -import { AppManifest, EntityManifest } from '@repo/types' -import { EntityService } from '../../entity/services/entity.service' -import { ADMIN_ENTITY_MANIFEST } from '../../constants' +import { DatabaseService } from '../../crud/services/database.service' @Injectable() export class IsDbEmptyGuard implements CanActivate { - constructor( - private readonly manifestService: ManifestService, - private readonly entityService: EntityService - ) {} + constructor(private readonly databaseService: DatabaseService) {} /** * Check if the database is empty (no items in any entity, even admin). @@ -17,27 +11,6 @@ export class IsDbEmptyGuard implements CanActivate { * @returns True if the database is empty, false otherwise. * */ async canActivate(): Promise { - const appManifest: AppManifest = this.manifestService.getAppManifest() - - const entities = [ - ...Object.values(appManifest.entities), - ADMIN_ENTITY_MANIFEST - ] - let totalItems = 0 - - await Promise.all( - Object.values(entities).map(async (entityManifest: EntityManifest) => { - return this.entityService - .getEntityRepository({ - entitySlug: entityManifest.slug - }) - .createQueryBuilder('entity') - .getCount() - }) - ).then((counts: number[]) => { - totalItems = counts.reduce((acc, count) => acc + count, 0) - }) - - return totalItems === 0 + return this.databaseService.isDbEmpty() } } diff --git a/packages/core/manifest/src/auth/tests/is-db-empty.guard.spec.ts b/packages/core/manifest/src/auth/tests/is-db-empty.guard.spec.ts index f6671415..341a9cb0 100644 --- a/packages/core/manifest/src/auth/tests/is-db-empty.guard.spec.ts +++ b/packages/core/manifest/src/auth/tests/is-db-empty.guard.spec.ts @@ -1,60 +1,43 @@ import { Test } from '@nestjs/testing' import { IsDbEmptyGuard } from '../guards/is-db-empty.guard' -import { ManifestService } from '../../manifest/services/manifest.service' -import { EntityService } from '../../entity/services/entity.service' +import { DatabaseService } from '../../crud/services/database.service' describe('IsDbEmptyGuard', () => { - let manifestService: ManifestService - let entityService: EntityService + let databaseService: DatabaseService beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ IsDbEmptyGuard, { - provide: ManifestService, + provide: DatabaseService, useValue: { - getAppManifest: jest.fn().mockReturnValue({ - entities: {} - }) - } - }, - { - provide: EntityService, - useValue: { - getEntityRepository: jest.fn().mockReturnValue({ - createQueryBuilder: jest.fn().mockReturnValue({ - getCount: jest.fn().mockReturnValue(0) - }) - }) + isDbEmpty: jest.fn().mockReturnValue(Promise.resolve(true)) } } ] }).compile() - manifestService = module.get(ManifestService) - entityService = module.get(EntityService) + databaseService = module.get(DatabaseService) }) it('should be defined', () => { - expect(new IsDbEmptyGuard(manifestService, entityService)).toBeDefined() + expect(new IsDbEmptyGuard(databaseService)).toBeDefined() }) it('should return true if the database is empty', async () => { - const isDbEmptyGuard = new IsDbEmptyGuard(manifestService, entityService) + const isDbEmptyGuard = new IsDbEmptyGuard(databaseService) const res = await isDbEmptyGuard.canActivate() expect(res).toBe(true) }) it('should return false if the database is not empty', async () => { - jest.spyOn(entityService, 'getEntityRepository').mockReturnValue({ - createQueryBuilder: jest.fn().mockReturnValue({ - getCount: jest.fn().mockReturnValue(1) - }) - } as any) + jest + .spyOn(databaseService, 'isDbEmpty') + .mockReturnValue(Promise.resolve(false)) - const isDbEmptyGuard = new IsDbEmptyGuard(manifestService, entityService) + const isDbEmptyGuard = new IsDbEmptyGuard(databaseService) const res = await isDbEmptyGuard.canActivate() expect(res).toBe(false) diff --git a/packages/core/manifest/src/crud/controllers/database.controller.ts b/packages/core/manifest/src/crud/controllers/database.controller.ts new file mode 100644 index 00000000..8eaae478 --- /dev/null +++ b/packages/core/manifest/src/crud/controllers/database.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get } from '@nestjs/common' +import { DatabaseService } from '../services/database.service' + +@Controller('db') +export class DatabaseController { + constructor(private readonly databaseService: DatabaseService) {} + + @Get('is-db-empty') + public async isDbEmpty(): Promise<{ + empty: boolean + }> { + const empty = await this.databaseService.isDbEmpty() + + return { empty } + } +} diff --git a/packages/core/manifest/src/crud/crud.module.ts b/packages/core/manifest/src/crud/crud.module.ts index 6d1a06d1..7e8daee7 100644 --- a/packages/core/manifest/src/crud/crud.module.ts +++ b/packages/core/manifest/src/crud/crud.module.ts @@ -8,10 +8,13 @@ import { CrudService } from './services/crud.service' import { PaginationService } from './services/pagination.service' import { ValidationModule } from '../validation/validation.module' import { AuthService } from '../auth/auth.service' +import { DatabaseService } from './services/database.service' +import { DatabaseController } from './controllers/database.controller' @Module({ imports: [EntityModule, ManifestModule, ValidationModule], - controllers: [CrudController], - providers: [CrudService, PaginationService, AuthService] + controllers: [CrudController, DatabaseController], + providers: [CrudService, PaginationService, AuthService, DatabaseService], + exports: [DatabaseService] }) export class CrudModule {} diff --git a/packages/core/manifest/src/crud/services/database.service.ts b/packages/core/manifest/src/crud/services/database.service.ts new file mode 100644 index 00000000..639c1a70 --- /dev/null +++ b/packages/core/manifest/src/crud/services/database.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common' +import { ManifestService } from '../../manifest/services/manifest.service' +import { EntityService } from '../../entity/services/entity.service' +import { AppManifest, EntityManifest } from '../../../../types/src' +import { ADMIN_ENTITY_MANIFEST } from '../../constants' + +@Injectable() +export class DatabaseService { + constructor( + private manifestService: ManifestService, + private entityService: EntityService + ) {} + + /** + * Check if the database is empty (no items in any entity, even admin). + * + * @returns true if the database is empty, false otherwise. + * */ + async isDbEmpty(): Promise { + const appManifest: AppManifest = this.manifestService.getAppManifest() + + const entities = [ + ...Object.values(appManifest.entities), + ADMIN_ENTITY_MANIFEST + ] + let totalItems = 0 + + await Promise.all( + Object.values(entities).map(async (entityManifest: EntityManifest) => { + return this.entityService + .getEntityRepository({ + entitySlug: entityManifest.slug + }) + .createQueryBuilder('entity') + .getCount() + }) + ).then((counts: number[]) => { + totalItems = counts.reduce((acc, count) => acc + count, 0) + }) + + return totalItems === 0 + } +} diff --git a/packages/core/manifest/src/crud/tests/database.controller.spec.ts b/packages/core/manifest/src/crud/tests/database.controller.spec.ts new file mode 100644 index 00000000..55a602ab --- /dev/null +++ b/packages/core/manifest/src/crud/tests/database.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { DatabaseController } from '../controllers/database.controller' + +describe('DatabaseController', () => { + let controller: DatabaseController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DatabaseController] + }).compile() + + controller = module.get(DatabaseController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) +}) diff --git a/packages/core/manifest/src/crud/tests/database.service.spec.ts b/packages/core/manifest/src/crud/tests/database.service.spec.ts new file mode 100644 index 00000000..6c1a7c5b --- /dev/null +++ b/packages/core/manifest/src/crud/tests/database.service.spec.ts @@ -0,0 +1,62 @@ +import { Test } from '@nestjs/testing' +import { DatabaseService } from '../services/database.service' +import { ManifestService } from '../../manifest/services/manifest.service' +import { EntityService } from '../../entity/services/entity.service' + +describe('DatabaseService', () => { + let manifestService: ManifestService + let entityService: EntityService + let service: DatabaseService + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + DatabaseService, + { + provide: ManifestService, + useValue: { + getAppManifest: jest.fn().mockReturnValue({ + entities: {} + }) + } + }, + { + provide: EntityService, + useValue: { + getEntityRepository: jest.fn().mockReturnValue({ + createQueryBuilder: jest.fn().mockReturnValue({ + getCount: jest.fn().mockReturnValue(0) + }) + }) + } + } + ] + }).compile() + + manifestService = module.get(ManifestService) + entityService = module.get(EntityService) + service = module.get(DatabaseService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should return true if the database is empty', async () => { + const res = await service.isDbEmpty() + + expect(res).toBe(true) + }) + + it('should return false if the database is not empty', async () => { + jest.spyOn(entityService, 'getEntityRepository').mockReturnValue({ + createQueryBuilder: jest.fn().mockReturnValue({ + getCount: jest.fn().mockReturnValue(1) + }) + } as any) + + const res = await service.isDbEmpty() + + expect(res).toBe(false) + }) +}) From 05723aa9e76509c185da9ee337a26c9acbc9dfd5 Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 4 Nov 2024 15:06:00 +0100 Subject: [PATCH 05/11] feat(admin): register first admin page --- packages/core/admin/src/app/app.component.ts | 9 ++- .../app/modules/auth/auth-routing.module.ts | 7 ++ .../admin/src/app/modules/auth/auth.module.ts | 3 +- .../src/app/modules/auth/auth.service.ts | 44 ++++++++++++ .../modules/auth/guards/is-db-empty.guard.ts | 23 ++++++ .../auth/utlis/confirm-password-validator.ts | 22 ++++++ .../auth/views/login/login.component.ts | 10 ++- .../register-first-admin.component.html | 71 +++++++++++++++++++ .../register-first-admin.component.scss | 0 .../register-first-admin.component.ts | 63 ++++++++++++++++ .../email-input/email-input.component.ts | 2 +- .../password-input.component.ts | 1 + .../crud/tests/database.controller.spec.ts | 11 ++- 13 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 packages/core/admin/src/app/modules/auth/guards/is-db-empty.guard.ts create mode 100644 packages/core/admin/src/app/modules/auth/utlis/confirm-password-validator.ts create mode 100644 packages/core/admin/src/app/modules/auth/views/register-first-admin/register-first-admin.component.html create mode 100644 packages/core/admin/src/app/modules/auth/views/register-first-admin/register-first-admin.component.scss create mode 100644 packages/core/admin/src/app/modules/auth/views/register-first-admin/register-first-admin.component.ts diff --git a/packages/core/admin/src/app/app.component.ts b/packages/core/admin/src/app/app.component.ts index e3f3c932..be2098ca 100644 --- a/packages/core/admin/src/app/app.component.ts +++ b/packages/core/admin/src/app/app.component.ts @@ -11,13 +11,18 @@ export class AppComponent implements OnInit { currentUser: Admin isLogin = true - constructor(private authService: AuthService, private router: Router) {} + constructor( + private authService: AuthService, + private router: Router + ) {} ngOnInit() { this.router.events.subscribe((routeChanged) => { if (routeChanged instanceof NavigationEnd) { window.scrollTo(0, 0) - this.isLogin = routeChanged.url.includes('/auth/login') + this.isLogin = + routeChanged.url.includes('/auth/login') || + routeChanged.url.includes('/auth/welcome') if (this.isLogin) { this.currentUser = null diff --git a/packages/core/admin/src/app/modules/auth/auth-routing.module.ts b/packages/core/admin/src/app/modules/auth/auth-routing.module.ts index 81cfd67b..85a4ce0e 100644 --- a/packages/core/admin/src/app/modules/auth/auth-routing.module.ts +++ b/packages/core/admin/src/app/modules/auth/auth-routing.module.ts @@ -4,6 +4,8 @@ import { RouterModule, Routes } from '@angular/router' import { NotLoggedInGuard } from './guards/not-logged-in.guard' import { LoginComponent } from './views/login/login.component' import { LogoutComponent } from './views/logout/logout.component' +import { RegisterFirstAdminComponent } from './views/register-first-admin/register-first-admin.component' +import { IsDbEmptyGuard } from './guards/is-db-empty.guard' export const authRoutes: Routes = [ { @@ -14,6 +16,11 @@ export const authRoutes: Routes = [ { path: 'logout', component: LogoutComponent + }, + { + path: 'welcome', + component: RegisterFirstAdminComponent, + canActivate: [IsDbEmptyGuard] } ] diff --git a/packages/core/admin/src/app/modules/auth/auth.module.ts b/packages/core/admin/src/app/modules/auth/auth.module.ts index 8d6c1ccc..8ca69a11 100644 --- a/packages/core/admin/src/app/modules/auth/auth.module.ts +++ b/packages/core/admin/src/app/modules/auth/auth.module.ts @@ -5,9 +5,10 @@ import { SharedModule } from '../shared/shared.module' import { AuthRoutingModule } from './auth-routing.module' import { LoginComponent } from './views/login/login.component' import { LogoutComponent } from './views/logout/logout.component' +import { RegisterFirstAdminComponent } from './views/register-first-admin/register-first-admin.component' @NgModule({ - declarations: [LoginComponent, LogoutComponent], + declarations: [LoginComponent, LogoutComponent, RegisterFirstAdminComponent], imports: [CommonModule, AuthRoutingModule, SharedModule] }) export class AuthModule {} diff --git a/packages/core/admin/src/app/modules/auth/auth.service.ts b/packages/core/admin/src/app/modules/auth/auth.service.ts index e74bc049..a06dfb5b 100644 --- a/packages/core/admin/src/app/modules/auth/auth.service.ts +++ b/packages/core/admin/src/app/modules/auth/auth.service.ts @@ -41,6 +41,37 @@ export class AuthService { }) } + /** + * Signs up a new admin and logs them in. + * + * @param {Object} credentials - The credentials of the new admin + * @param {string} credentials.email - The email of the new admin + * @param {string} credentials.password - The password of the new admin + * + * @returns {Promise} The token of the new admin + */ + async signup(credentials: { + email: string + password: string + }): Promise { + return ( + firstValueFrom( + this.http.post( + `${environment.apiBaseUrl}/auth/admins/signup`, + credentials + ) + ) as Promise<{ + token: string + }> + ).then((res: { token: string }) => { + const token = res?.token + if (token) { + localStorage.setItem(TOKEN_KEY, token) + } + return token + }) + } + logout(): void { delete this.currentUserPromise localStorage.removeItem(TOKEN_KEY) @@ -76,4 +107,17 @@ export class AuthService { ) as Promise<{ exists: boolean }> ).then((res) => res.exists) } + + /** + * Returns true if the database is empty (no items, even admins), false otherwise. + * + * @returns {Promise} true if the database is empty, false otherwise + */ + async isDbEmpty(): Promise { + return ( + firstValueFrom( + this.http.get(`${environment.apiBaseUrl}/db/is-db-empty`) + ) as Promise<{ empty: boolean }> + ).then((res) => res.empty) + } } diff --git a/packages/core/admin/src/app/modules/auth/guards/is-db-empty.guard.ts b/packages/core/admin/src/app/modules/auth/guards/is-db-empty.guard.ts new file mode 100644 index 00000000..4635f38d --- /dev/null +++ b/packages/core/admin/src/app/modules/auth/guards/is-db-empty.guard.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@angular/core' +import { Router } from '@angular/router' +import { AuthService } from '../auth.service' + +@Injectable({ + providedIn: 'root' +}) +export class IsDbEmptyGuard { + constructor( + private authService: AuthService, + private router: Router + ) {} + async canActivate(): Promise { + const isDbEmpty = await this.authService.isDbEmpty() + + if (isDbEmpty) { + return true + } + + this.router.navigate(['/auth/login']) + return false + } +} diff --git a/packages/core/admin/src/app/modules/auth/utlis/confirm-password-validator.ts b/packages/core/admin/src/app/modules/auth/utlis/confirm-password-validator.ts new file mode 100644 index 00000000..765747f5 --- /dev/null +++ b/packages/core/admin/src/app/modules/auth/utlis/confirm-password-validator.ts @@ -0,0 +1,22 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms' + +export function confirmPasswordValidator( + passwordControlName: string +): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const formGroup = control.parent + if (!formGroup) return null + + const passwordControl = formGroup.get(passwordControlName) + if (!passwordControl) return null + + const password = passwordControl.value + const confirmPassword = control.value + + if (!confirmPassword || password !== confirmPassword) { + return { confirmPasswordMismatch: true } + } + + return null + } +} diff --git a/packages/core/admin/src/app/modules/auth/views/login/login.component.ts b/packages/core/admin/src/app/modules/auth/views/login/login.component.ts index 2a411167..f91d5ca0 100644 --- a/packages/core/admin/src/app/modules/auth/views/login/login.component.ts +++ b/packages/core/admin/src/app/modules/auth/views/login/login.component.ts @@ -31,6 +31,7 @@ export class LoginComponent implements OnInit { ngOnInit(): void { this.activatedRoute.queryParams.subscribe(async (queryParams: Params) => { + // Set suggested email and password from query params or default admin credentials. if (queryParams['email'] && queryParams['password']) { this.suggestedEmail = queryParams['email'] this.suggestedPassword = queryParams['password'] @@ -40,15 +41,20 @@ export class LoginComponent implements OnInit { this.suggestedPassword = DEFAULT_ADMIN_CREDENTIALS.password } } - this.form = new FormGroup({ email: new FormControl(this.suggestedEmail || '', [ - Validators.required + Validators.required, + Validators.email ]), password: new FormControl(this.suggestedPassword || '', [ Validators.required ]) }) + + // Redirect to register first admin if the database is empty. + if (await this.authService.isDbEmpty()) { + this.router.navigate(['/auth/welcome']) + } }) } diff --git a/packages/core/admin/src/app/modules/auth/views/register-first-admin/register-first-admin.component.html b/packages/core/admin/src/app/modules/auth/views/register-first-admin/register-first-admin.component.html new file mode 100644 index 00000000..16098386 --- /dev/null +++ b/packages/core/admin/src/app/modules/auth/views/register-first-admin/register-first-admin.component.html @@ -0,0 +1,71 @@ +
+
+
+
+ +
+
+
+
+
diff --git a/packages/core/admin/src/app/modules/auth/views/register-first-admin/register-first-admin.component.scss b/packages/core/admin/src/app/modules/auth/views/register-first-admin/register-first-admin.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/packages/core/admin/src/app/modules/auth/views/register-first-admin/register-first-admin.component.ts b/packages/core/admin/src/app/modules/auth/views/register-first-admin/register-first-admin.component.ts new file mode 100644 index 00000000..2755f27e --- /dev/null +++ b/packages/core/admin/src/app/modules/auth/views/register-first-admin/register-first-admin.component.ts @@ -0,0 +1,63 @@ +import { Component, OnInit } from '@angular/core' +import { FormControl, FormGroup, Validators } from '@angular/forms' +import { PropType } from '@repo/types' +import { confirmPasswordValidator } from '../../utlis/confirm-password-validator' +import { AuthService } from '../../auth.service' +import { Router } from '@angular/router' +import { FlashMessageService } from '../../../shared/services/flash-message.service' + +@Component({ + selector: 'app-register-first-admin', + templateUrl: './register-first-admin.component.html', + styleUrl: './register-first-admin.component.scss' +}) +export class RegisterFirstAdminComponent implements OnInit { + form: FormGroup + PropType = PropType + + constructor( + private authService: AuthService, + private router: Router, + private flashMessageService: FlashMessageService + ) {} + + ngOnInit(): void { + this.form = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + password: new FormControl('', [Validators.required]), + confirmPassword: new FormControl('', [ + Validators.required, + confirmPasswordValidator('password') + ]) + }) + } + + /** + * Patch value to the form + * + * @param controlName + * @param value + * + * @returns void + */ + patchValue(controlName: string, value: string) { + this.form.get(controlName)?.patchValue(value) + } + + /** + * Submit the form + */ + async submit(): Promise { + const token: string = await this.authService.signup(this.form.value) + + if (!token) { + return this.flashMessageService.error('Error: Failed to register') + } + + this.flashMessageService.success( + 'Welcome! You have successfully registered as an admin.' + ) + + this.router.navigate(['/']) + } +} diff --git a/packages/core/admin/src/app/modules/shared/inputs/email-input/email-input.component.ts b/packages/core/admin/src/app/modules/shared/inputs/email-input/email-input.component.ts index c613dfed..a1350192 100644 --- a/packages/core/admin/src/app/modules/shared/inputs/email-input/email-input.component.ts +++ b/packages/core/admin/src/app/modules/shared/inputs/email-input/email-input.component.ts @@ -20,7 +20,7 @@ import { PropertyManifest } from '@repo/types' class="input" [ngClass]="{ 'is-danger': isError }" type="email" - placeholder="Email" + placeholder="Email..." autocomplete="email" (change)="onChange($event)" #input diff --git a/packages/core/admin/src/app/modules/shared/inputs/password-input/password-input.component.ts b/packages/core/admin/src/app/modules/shared/inputs/password-input/password-input.component.ts index 7f9c2f42..95e6ae68 100644 --- a/packages/core/admin/src/app/modules/shared/inputs/password-input/password-input.component.ts +++ b/packages/core/admin/src/app/modules/shared/inputs/password-input/password-input.component.ts @@ -18,6 +18,7 @@ import { PropertyManifest } from '@repo/types' { let controller: DatabaseController beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - controllers: [DatabaseController] + controllers: [DatabaseController], + providers: [ + { + provide: DatabaseService, + useValue: { + isDbEmpty: jest.fn().mockReturnValue(Promise.resolve(true)) + } + } + ] }).compile() controller = module.get(DatabaseController) From 9183c15dc840be388f62c2bf410503e42fb85688 Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 4 Nov 2024 15:20:13 +0100 Subject: [PATCH 06/11] workflow: add "add-manifest" package in PR template --- .github/pull_request_template.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 15147794..de3d2ae9 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,6 +9,7 @@ Check the NPM packages that require a new publication or release: - [ ] [manifest](https://www.npmjs.com/package/manifest) +- [] [add-manifest](https://www.npmjs.com/package/add-manifest) - [ ] [@mnfst/sdk](https://www.npmjs.com/package/@mnfst/sdk) ## Check list before submitting From 6fe42752ec067aebf095e78018c1ce2048c91ca0 Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 4 Nov 2024 15:35:19 +0100 Subject: [PATCH 07/11] fix: cannot signup with authenticable entities --- packages/core/manifest/src/auth/auth.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/manifest/src/auth/auth.controller.ts b/packages/core/manifest/src/auth/auth.controller.ts index ee98c7ae..e22e3b17 100644 --- a/packages/core/manifest/src/auth/auth.controller.ts +++ b/packages/core/manifest/src/auth/auth.controller.ts @@ -31,7 +31,7 @@ export class AuthController { return this.authService.createToken(entity, signupUserDto) } - @Post(':admins/signup') + @Post('admins/signup') @UseGuards(IsDbEmptyGuard) public async signupAdmin( @Body() signupUserDto: SignupAuthenticableEntityDto From abec404fe1f24f5718101722197dc539ed00a22f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Conejo?= Date: Mon, 4 Nov 2024 16:18:25 +0100 Subject: [PATCH 08/11] fix: fields were nested into the first field --- .../auth/views/login/login.component.html | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/core/admin/src/app/modules/auth/views/login/login.component.html b/packages/core/admin/src/app/modules/auth/views/login/login.component.html index fa175774..7ac5945a 100644 --- a/packages/core/admin/src/app/modules/auth/views/login/login.component.html +++ b/packages/core/admin/src/app/modules/auth/views/login/login.component.html @@ -25,32 +25,32 @@

Sign in

(valueChanged)="patchValue('email', $event)" > -
-
- -
+
+
+
+
- - + + -
+
From 78ecb37d02e6b5c27f7abbf0e8360545de89c4f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Conejo?= Date: Mon, 4 Nov 2024 16:19:26 +0100 Subject: [PATCH 09/11] style: set register style and update box-shadow global style --- .../core/admin/src/app/app.component.html | 5 +- .../register-first-admin.component.html | 98 ++++++++++--------- .../register-first-admin.component.scss | 61 ++++++++++++ .../admin/src/styles/variables/_cards.scss | 8 +- .../admin/src/styles/variables/_generic.scss | 6 +- 5 files changed, 125 insertions(+), 53 deletions(-) diff --git a/packages/core/admin/src/app/app.component.html b/packages/core/admin/src/app/app.component.html index d2e65e82..3577b25c 100644 --- a/packages/core/admin/src/app/app.component.html +++ b/packages/core/admin/src/app/app.component.html @@ -1,11 +1,10 @@ - - + -
+