From cdbe5a0f5bad87e773ac127948382be7a2ea527b Mon Sep 17 00:00:00 2001 From: tal-rofe Date: Sat, 17 Sep 2022 19:09:34 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=F0=9F=94=A5=20[EXL-77]=20support?= =?UTF-8?q?=20secret=20management=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit support secret management page --- .../modules/database/client-secret.service.ts | 31 ++++-- .../src/modules/database/group.service.ts | 2 +- .../secrets/available-label.controller.ts | 53 ++++++++++ .../secrets/classes/create-secret.dto.ts | 23 +++-- .../user/modules/secrets/classes/responses.ts | 21 ++-- .../user/modules/secrets/create.controller.ts | 6 +- .../decorators/valid-expiration.decorator.ts | 5 +- .../user/modules/secrets/delete.controller.ts | 2 +- .../secrets/interfaces/user-secrets.ts | 3 +- .../contracts/available-label.contract.ts | 3 + .../handlers/available-label.handler.ts | 14 +++ .../queries/handlers/create-secret.handler.ts | 4 +- .../handlers/get-all-secrets.handler.ts | 1 - .../modules/secrets/queries/handlers/index.ts | 8 +- .../user/modules/secrets/secrets.module.ts | 2 + .../user/modules/secrets/secrets.routes.ts | 1 + apps/frontend/.eslintrc.cjs | 2 +- .../component/new-ui/component.scss.ejs.t | 2 +- .../component/new-ui/component.test.tsx.t | 15 --- .../component/new-ui/component.tsx.ejs.t | 14 +-- .../component/new-ui/component.view.tsx.ejs.t | 12 +-- .../component/new-ui/index.tsx.ejs.t | 6 +- apps/frontend/package.json | 2 + apps/frontend/src/assets/icons.ts | 18 ++++ .../DeleteAccountModal.module.scss | 4 + .../AccountSettings/AccountSettings.view.tsx | 10 +- .../AccountSettings/Header/Header.module.scss | 4 + .../LabelInput/LabelInput.module.scss | 72 ++++++++++++++ .../NewSecret/LabelInput/LabelInput.tsx | 24 +++++ .../NewSecret/LabelInput/LabelInput.view.tsx | 60 ++++++++++++ .../NewSecret/LabelInput/index.ts | 3 + .../NewSecret/NewSecret.module.scss | 73 ++++++++++++++ .../AccountSettings/NewSecret/NewSecret.tsx | 91 +++++++++++++++++ .../NewSecret/NewSecret.view.tsx | 88 +++++++++++++++++ .../AccountSettings/NewSecret/index.ts | 3 + .../NewSecret/interfaces/responses.ts | 8 ++ .../AccountSettings/NewSecret/models/time.ts | 9 ++ .../PostSecretCreation.module.scss | 63 ++++++++++++ .../PostSecretCreation/PostSecretCreation.tsx | 58 +++++++++++ .../PostSecretCreation.view.tsx | 59 +++++++++++ .../PostSecretCreation/index.ts | 3 + .../SecretManagement.module.scss | 75 ++++++++++++++ .../SecretManagement/SecretManagement.tsx | 64 ++++++++++++ .../SecretManagement.view.tsx | 90 +++++++++++++++++ .../SecretsList/SecretsList.module.scss | 59 +++++++++++ .../SecretsList/SecretsList.tsx | 31 ++++++ .../SecretsList/SecretsList.view.tsx | 71 ++++++++++++++ .../SecretManagement/SecretsList/index.ts | 3 + .../AccountSettings/SecretManagement/index.ts | 3 + .../SecretManagement/interfaces/secret.ts | 14 +++ .../AccountSettings/SideBar/SideBar.view.tsx | 4 +- .../EDNotification/EDNotification.module.scss | 60 ++++++++---- .../ui/EDNotification/EDNotification.view.tsx | 34 +++++-- .../ui/EDSelectDate/EDSelectDate.module.scss | 97 +++++++++++++++++++ .../ui/EDSelectDate/EDSelectDate.tsx | 48 +++++++++ .../ui/EDSelectDate/EDSelectDate.view.tsx | 86 ++++++++++++++++ .../src/components/ui/EDSelectDate/index.ts | 3 + .../ui/EDSelectDate/interfaces/option.ts | 5 + .../ui/EDSelectDate/utils/format-date.ts | 6 ++ apps/frontend/src/hooks/backend-api.ts | 82 ---------------- apps/frontend/src/hooks/use-debounce.ts | 11 +++ apps/frontend/src/i18n/en.ts | 33 ++++++- apps/frontend/src/styles/custom.scss | 1 + apps/frontend/stylelint.config.cjs | 6 ++ pnpm-lock.yaml | 77 ++++++++++++++- prisma/schema.prisma | 48 +++------ 66 files changed, 1669 insertions(+), 224 deletions(-) create mode 100644 apps/backend/src/modules/user/modules/secrets/available-label.controller.ts create mode 100644 apps/backend/src/modules/user/modules/secrets/queries/contracts/available-label.contract.ts create mode 100644 apps/backend/src/modules/user/modules/secrets/queries/handlers/available-label.handler.ts delete mode 100644 apps/frontend/_templates/component/new-ui/component.test.tsx.t create mode 100644 apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.module.scss create mode 100644 apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.tsx create mode 100644 apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.view.tsx create mode 100644 apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/index.ts create mode 100644 apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.module.scss create mode 100644 apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.tsx create mode 100644 apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.view.tsx create mode 100644 apps/frontend/src/components/containers/AccountSettings/NewSecret/index.ts create mode 100644 apps/frontend/src/components/containers/AccountSettings/NewSecret/interfaces/responses.ts create mode 100644 apps/frontend/src/components/containers/AccountSettings/NewSecret/models/time.ts create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.module.scss create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.tsx create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.view.tsx create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/index.ts create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.module.scss create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.tsx create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.view.tsx create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.module.scss create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.tsx create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.view.tsx create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/index.ts create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/index.ts create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/interfaces/secret.ts create mode 100644 apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.module.scss create mode 100644 apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.tsx create mode 100644 apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.view.tsx create mode 100644 apps/frontend/src/components/ui/EDSelectDate/index.ts create mode 100644 apps/frontend/src/components/ui/EDSelectDate/interfaces/option.ts create mode 100644 apps/frontend/src/components/ui/EDSelectDate/utils/format-date.ts delete mode 100644 apps/frontend/src/hooks/backend-api.ts create mode 100644 apps/frontend/src/hooks/use-debounce.ts diff --git a/apps/backend/src/modules/database/client-secret.service.ts b/apps/backend/src/modules/database/client-secret.service.ts index 230ddd055..9aa565bf3 100644 --- a/apps/backend/src/modules/database/client-secret.service.ts +++ b/apps/backend/src/modules/database/client-secret.service.ts @@ -7,49 +7,60 @@ export class DBClientSecretService { constructor(private prisma: PrismaService) {} public async doesSecretBelongUser(userId: string, secretId: string) { - const secretDB = await this.prisma.clientSecret.findFirst({ where: { userId, id: secretId } }); + const secretDB = await this.prisma.secret.findFirst({ where: { userId, id: secretId } }); return secretDB !== null; } public async deleteSecret(secretId: string) { - await this.prisma.clientSecret.delete({ where: { id: secretId } }); + await this.prisma.secret.delete({ where: { id: secretId } }); } public async revokeAllSecrets(userId: string) { - await this.prisma.clientSecret.deleteMany({ where: { userId } }); + await this.prisma.secret.deleteMany({ where: { userId } }); } public async refreshSecret(secretId: string, newSecret: string) { - await this.prisma.clientSecret.update({ where: { id: secretId }, data: { secret: newSecret } }); + await this.prisma.secret.update({ where: { id: secretId }, data: { secret: newSecret } }); } public async editSecretLabel(secretId: string, newLabel: string) { - await this.prisma.clientSecret.update({ where: { id: secretId }, data: { label: newLabel } }); + await this.prisma.secret.update({ where: { id: secretId }, data: { label: newLabel } }); } - public async createSecret(userId: string, secret: string, label: string, expiration: Date | null) { - await this.prisma.clientSecret.create({ data: { secret, userId, label, expiration } }); + public createSecret(userId: string, secret: string, label: string, expiration: Date | null) { + return this.prisma.secret.create({ + data: { secret, userId, label, expiration }, + select: { id: true }, + }); } public getSecrets(userId: string) { - return this.prisma.clientSecret.findMany({ + return this.prisma.secret.findMany({ where: { userId }, select: { id: true, label: true, - createdAt: true, expiration: true, }, + orderBy: { createdAt: 'asc' }, }); } public async getSecretExpiration(secretId: string) { - const secret = await this.prisma.clientSecret.findUniqueOrThrow({ + const secret = await this.prisma.secret.findUniqueOrThrow({ where: { id: secretId }, select: { expiration: true }, }); return secret.expiration; } + + public async isLabelAvailable(userId: string, label: string) { + const record = await this.prisma.secret.findFirst({ + where: { userId, label }, + }); + + return record === null; + } } diff --git a/apps/backend/src/modules/database/group.service.ts b/apps/backend/src/modules/database/group.service.ts index 26d5be974..9fd95bdbe 100644 --- a/apps/backend/src/modules/database/group.service.ts +++ b/apps/backend/src/modules/database/group.service.ts @@ -8,7 +8,7 @@ export class DBGroupService { public async createGroup(userId: string) { const createdGroup = await this.prisma.group.create({ - data: { userId, policyIDs: [] }, + data: { userId }, select: { id: true }, }); diff --git a/apps/backend/src/modules/user/modules/secrets/available-label.controller.ts b/apps/backend/src/modules/user/modules/secrets/available-label.controller.ts new file mode 100644 index 000000000..f4c7f16d0 --- /dev/null +++ b/apps/backend/src/modules/user/modules/secrets/available-label.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Get, HttpCode, HttpStatus, Logger, Param } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; +import { + ApiBearerAuth, + ApiInternalServerErrorResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; + +import { CurrentUserId } from '@/decorators/current-user-id.decorator'; + +import Routes from './secrets.routes'; +import { AvailableLabelResponse } from './classes/responses'; +import { AvailableLabelContract } from './queries/contracts/available-label.contract'; + +@ApiTags('Secrets') +@Controller(Routes.CONTROLLER) +export class AvailableLabelController { + private readonly logger = new Logger(AvailableLabelController.name); + + constructor(private readonly queryBus: QueryBus) {} + + @ApiOperation({ description: 'Check whether a provided label is availble' }) + @ApiBearerAuth('access-token') + @ApiOkResponse({ + description: 'Returns whether the provided label is available', + type: AvailableLabelResponse, + }) + @ApiUnauthorizedResponse({ + description: 'If access token is invalid or missing', + }) + @ApiInternalServerErrorResponse({ description: 'If get availability status of the label' }) + @Get(Routes.AVAILABLE_LABEL) + @HttpCode(HttpStatus.OK) + public async availableLabel( + @CurrentUserId() userId: string, + @Param('label') label: string, + ): Promise { + this.logger.log(`Will try to get availability status of label: "${label}" with an Id: "${userId}"`); + + const isAvailable = await this.queryBus.execute( + new AvailableLabelContract(userId, label), + ); + + this.logger.log(`Successfully got availability status of label: "${label}" with an Id: "${userId}"`); + + return { + isAvailable, + }; + } +} diff --git a/apps/backend/src/modules/user/modules/secrets/classes/create-secret.dto.ts b/apps/backend/src/modules/user/modules/secrets/classes/create-secret.dto.ts index eebc07d5a..9d9506d79 100644 --- a/apps/backend/src/modules/user/modules/secrets/classes/create-secret.dto.ts +++ b/apps/backend/src/modules/user/modules/secrets/classes/create-secret.dto.ts @@ -1,4 +1,5 @@ -import { IsISO8601, IsString, MinLength } from 'class-validator'; +import { IsString, MaxLength, MinLength } from 'class-validator'; +import { Transform } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { IsNullable } from '@/decorators/is-nullable.decorator'; @@ -9,16 +10,26 @@ export class CreateSecretDto { @ApiProperty({ type: String, description: 'The label of the new secret', example: 'Yazif Secret' }) @IsString() @MinLength(1) + @MaxLength(30) readonly label!: string; @ApiProperty({ - type: String, - description: 'The expiration date of the secret in ISO8601 format. Null for no expiration', + type: Number, + description: 'The expiration date of the secret (in ms)', nullable: true, - example: '2019-02-11', + example: 111122222, }) - @IsISO8601() @IsFutureDate() @IsNullable() - readonly expiration!: string | null; + @Transform(({ value }: { value: number | null }) => { + if (!value) { + return null; + } + + const date = new Date(value); + const endOfDate = new Date(date.setHours(23, 59, 59, 999)); + + return endOfDate.getTime(); + }) + readonly expiration!: number | null; } diff --git a/apps/backend/src/modules/user/modules/secrets/classes/responses.ts b/apps/backend/src/modules/user/modules/secrets/classes/responses.ts index fae815017..5007261ea 100644 --- a/apps/backend/src/modules/user/modules/secrets/classes/responses.ts +++ b/apps/backend/src/modules/user/modules/secrets/classes/responses.ts @@ -19,22 +19,22 @@ class UserSecretGetAll implements IUserSecretsGetAll { type: Number, example: 12345678, }) - public expiration!: number; + public expiration!: number | null; +} +export class CreateClientSecretResponse { @ApiResponseProperty({ - type: Number, - example: 12345678, + type: String, + example: '62e5362119bea07115434f4a', }) - public createdAt!: number; -} + public secretId!: string; -export class CreateClientSecretResponse { @ApiResponseProperty({ type: String, example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', }) - public clientSecret!: string; + public secretValue!: string; } export class RefreshClientSecretResponse { @@ -52,3 +52,10 @@ export class GetAllSecretsResponse { }) public secrets!: IUserSecretsGetAll[]; } + +export class AvailableLabelResponse { + @ApiResponseProperty({ + type: Boolean, + }) + public isAvailable!: boolean; +} diff --git a/apps/backend/src/modules/user/modules/secrets/create.controller.ts b/apps/backend/src/modules/user/modules/secrets/create.controller.ts index 8448b2f41..2c05b1fd7 100644 --- a/apps/backend/src/modules/user/modules/secrets/create.controller.ts +++ b/apps/backend/src/modules/user/modules/secrets/create.controller.ts @@ -43,7 +43,7 @@ export class CreateController { ): Promise { this.logger.log(`Will try to create a client secret with to user with an Id: "${userId}"`); - const secret = await this.queryBus.execute( + const secret = await this.queryBus.execute( new CreateSecretContract( userId, userEmail, @@ -52,8 +52,8 @@ export class CreateController { ), ); - this.logger.log('Successfully deleted a client secret'); + this.logger.log('Successfully created a client secret'); - return { clientSecret: secret }; + return secret; } } diff --git a/apps/backend/src/modules/user/modules/secrets/decorators/valid-expiration.decorator.ts b/apps/backend/src/modules/user/modules/secrets/decorators/valid-expiration.decorator.ts index 533eb4a99..1897bd463 100644 --- a/apps/backend/src/modules/user/modules/secrets/decorators/valid-expiration.decorator.ts +++ b/apps/backend/src/modules/user/modules/secrets/decorators/valid-expiration.decorator.ts @@ -2,13 +2,12 @@ import { ValidateIf, type ValidationOptions } from 'class-validator'; export function IsFutureDate(validationOptions?: ValidationOptions) { return ValidateIf((_object, value) => { - if (typeof value !== 'string') { + if (typeof value !== 'number') { return false; } const currentDate = new Date(); - const inputDate = new Date(value); - return !isNaN(inputDate.getTime()) && inputDate >= currentDate; + return value >= currentDate.getTime(); }, validationOptions); } diff --git a/apps/backend/src/modules/user/modules/secrets/delete.controller.ts b/apps/backend/src/modules/user/modules/secrets/delete.controller.ts index 9f44dd55a..4de338d3f 100644 --- a/apps/backend/src/modules/user/modules/secrets/delete.controller.ts +++ b/apps/backend/src/modules/user/modules/secrets/delete.controller.ts @@ -59,6 +59,6 @@ export class DeleteController { await this.commandBus.execute(new RevokeSecretsContract(userId)); - this.logger.log('Successfully deleted a client secret'); + this.logger.log("Successfully deleted revoked user's secrets"); } } diff --git a/apps/backend/src/modules/user/modules/secrets/interfaces/user-secrets.ts b/apps/backend/src/modules/user/modules/secrets/interfaces/user-secrets.ts index 5f5d8b9bd..758039fec 100644 --- a/apps/backend/src/modules/user/modules/secrets/interfaces/user-secrets.ts +++ b/apps/backend/src/modules/user/modules/secrets/interfaces/user-secrets.ts @@ -1,6 +1,5 @@ export interface IUserSecretsGetAll { readonly id: string; readonly label: string; - readonly expiration: number; - readonly createdAt: number; + readonly expiration: number | null; } diff --git a/apps/backend/src/modules/user/modules/secrets/queries/contracts/available-label.contract.ts b/apps/backend/src/modules/user/modules/secrets/queries/contracts/available-label.contract.ts new file mode 100644 index 000000000..25998701a --- /dev/null +++ b/apps/backend/src/modules/user/modules/secrets/queries/contracts/available-label.contract.ts @@ -0,0 +1,3 @@ +export class AvailableLabelContract { + constructor(public readonly userId: string, public readonly label: string) {} +} diff --git a/apps/backend/src/modules/user/modules/secrets/queries/handlers/available-label.handler.ts b/apps/backend/src/modules/user/modules/secrets/queries/handlers/available-label.handler.ts new file mode 100644 index 000000000..5ddccd84c --- /dev/null +++ b/apps/backend/src/modules/user/modules/secrets/queries/handlers/available-label.handler.ts @@ -0,0 +1,14 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; + +import { DBClientSecretService } from '@/modules/database/client-secret.service'; + +import { AvailableLabelContract } from '../contracts/available-label.contract'; + +@QueryHandler(AvailableLabelContract) +export class AvailableLabelHandler implements IQueryHandler { + constructor(private readonly dbClientSecretsService: DBClientSecretService) {} + + execute(contract: AvailableLabelContract) { + return this.dbClientSecretsService.isLabelAvailable(contract.userId, contract.label); + } +} diff --git a/apps/backend/src/modules/user/modules/secrets/queries/handlers/create-secret.handler.ts b/apps/backend/src/modules/user/modules/secrets/queries/handlers/create-secret.handler.ts index a6db8c5a3..4b33926fd 100644 --- a/apps/backend/src/modules/user/modules/secrets/queries/handlers/create-secret.handler.ts +++ b/apps/backend/src/modules/user/modules/secrets/queries/handlers/create-secret.handler.ts @@ -21,13 +21,13 @@ export class CreateSecretHandler implements IQueryHandler const expirationDate = contract.expiration ? new Date(contract.expiration) : null; - await this.dbClientSecretService.createSecret( + const createdSecret = await this.dbClientSecretService.createSecret( contract.userId, secret, contract.label, expirationDate, ); - return secret; + return { secretId: createdSecret.id, secretValue: secret }; } } diff --git a/apps/backend/src/modules/user/modules/secrets/queries/handlers/get-all-secrets.handler.ts b/apps/backend/src/modules/user/modules/secrets/queries/handlers/get-all-secrets.handler.ts index 175c4dd8f..f275a0d75 100644 --- a/apps/backend/src/modules/user/modules/secrets/queries/handlers/get-all-secrets.handler.ts +++ b/apps/backend/src/modules/user/modules/secrets/queries/handlers/get-all-secrets.handler.ts @@ -13,7 +13,6 @@ export class GetAllSecretsHandler implements IQueryHandler ({ ...secret, - createdAt: secret.createdAt.getTime(), expiration: secret.expiration ? secret.expiration.getTime() : null, })); } diff --git a/apps/backend/src/modules/user/modules/secrets/queries/handlers/index.ts b/apps/backend/src/modules/user/modules/secrets/queries/handlers/index.ts index d0c02d596..a54cebe61 100644 --- a/apps/backend/src/modules/user/modules/secrets/queries/handlers/index.ts +++ b/apps/backend/src/modules/user/modules/secrets/queries/handlers/index.ts @@ -1,5 +1,11 @@ +import { AvailableLabelHandler } from './available-label.handler'; import { CreateSecretHandler } from './create-secret.handler'; import { GetAllSecretsHandler } from './get-all-secrets.handler'; import { RefreshSecretHandler } from './refresh-secret.handler'; -export const QueryHandlers = [CreateSecretHandler, RefreshSecretHandler, GetAllSecretsHandler]; +export const QueryHandlers = [ + CreateSecretHandler, + RefreshSecretHandler, + GetAllSecretsHandler, + AvailableLabelHandler, +]; diff --git a/apps/backend/src/modules/user/modules/secrets/secrets.module.ts b/apps/backend/src/modules/user/modules/secrets/secrets.module.ts index 1ce236166..243c2037c 100644 --- a/apps/backend/src/modules/user/modules/secrets/secrets.module.ts +++ b/apps/backend/src/modules/user/modules/secrets/secrets.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { JwtModule } from '@nestjs/jwt'; +import { AvailableLabelController } from './available-label.controller'; import { CommandHandlers } from './commands/handlers'; import { CreateController } from './create.controller'; import { DeleteController } from './delete.controller'; @@ -20,6 +21,7 @@ import { SecretsService } from './secrets.service'; CreateController, EditSecretController, GetAllController, + AvailableLabelController, ], providers: [...CommandHandlers, ...QueryHandlers, BelongingSecretGuard, SecretsService], }) diff --git a/apps/backend/src/modules/user/modules/secrets/secrets.routes.ts b/apps/backend/src/modules/user/modules/secrets/secrets.routes.ts index 4de49abde..067884e1b 100644 --- a/apps/backend/src/modules/user/modules/secrets/secrets.routes.ts +++ b/apps/backend/src/modules/user/modules/secrets/secrets.routes.ts @@ -6,6 +6,7 @@ const Routes = { REFRSH_SECRET: 'refresh-secret/:secret_id', EDIT_LABEL: 'edit-label/:secret_id', GET_ALL: '', + AVAILABLE_LABEL: ':label', }; export default Routes; diff --git a/apps/frontend/.eslintrc.cjs b/apps/frontend/.eslintrc.cjs index 23f4abb33..14892258b 100644 --- a/apps/frontend/.eslintrc.cjs +++ b/apps/frontend/.eslintrc.cjs @@ -91,7 +91,7 @@ module.exports = { }, overrides: [ { - files: ['./src/assets/icons.ts', './src/data/**/*.ts'], + files: ['./src/assets/icons.ts', './src/data/**/*.ts', './src/i18n/en.ts'], rules: { 'max-lines': 'off', }, diff --git a/apps/frontend/_templates/component/new-ui/component.scss.ejs.t b/apps/frontend/_templates/component/new-ui/component.scss.ejs.t index e3fd4d543..8363cf33d 100644 --- a/apps/frontend/_templates/component/new-ui/component.scss.ejs.t +++ b/apps/frontend/_templates/component/new-ui/component.scss.ejs.t @@ -1,3 +1,3 @@ --- -to: src/components/ui/V<%= h.changeCase.pascalCase(name.toLowerCase()) %>/V<%= h.changeCase.pascalCase(name.toLowerCase()) %>.module.scss +to: src/components/ui/ED<%= h.changeCase.pascalCase(name.toLowerCase()) %>/ED<%= h.changeCase.pascalCase(name.toLowerCase()) %>.module.scss --- diff --git a/apps/frontend/_templates/component/new-ui/component.test.tsx.t b/apps/frontend/_templates/component/new-ui/component.test.tsx.t deleted file mode 100644 index 49b1dc04e..000000000 --- a/apps/frontend/_templates/component/new-ui/component.test.tsx.t +++ /dev/null @@ -1,15 +0,0 @@ ---- -to: src/components/ui/V<%= h.changeCase.pascalCase(name.toLowerCase()) %>/V<%= h.changeCase.pascalCase(name.toLowerCase()) %>.test.tsx ---- -<% name = name.toLowerCase() %>import React from 'react'; -import { render } from '@testing-library/react'; - -import V<%= h.changeCase.pascalCase(name) %> from './V<%= h.changeCase.pascalCase(name) %>'; - -describe('>', () => { - it('Should render the component unchanged', () => { - const { container } = render( />); - - expect(container).toMatchSnapshot(); - }); -}); diff --git a/apps/frontend/_templates/component/new-ui/component.tsx.ejs.t b/apps/frontend/_templates/component/new-ui/component.tsx.ejs.t index 9bd429633..98351863c 100644 --- a/apps/frontend/_templates/component/new-ui/component.tsx.ejs.t +++ b/apps/frontend/_templates/component/new-ui/component.tsx.ejs.t @@ -1,17 +1,17 @@ --- -to: src/components/ui/V<%= h.changeCase.pascalCase(name.toLowerCase()) %>/V<%= h.changeCase.pascalCase(name.toLowerCase()) %>.tsx +to: src/components/ui/ED<%= h.changeCase.pascalCase(name.toLowerCase()) %>/ED<%= h.changeCase.pascalCase(name.toLowerCase()) %>.tsx --- <% name = name.toLowerCase() %>import React from 'react'; -import V<%= h.changeCase.pascalCase(name) %>View from './V<%= h.changeCase.pascalCase(name) %>.view'; +import ED<%= h.changeCase.pascalCase(name) %>View from './ED<%= h.changeCase.pascalCase(name) %>.view'; interface IProps {} -const V<%= h.changeCase.pascalCase(name) %>: React.FC = (props: React.PropsWithChildren) => { - return View />; +const ED<%= h.changeCase.pascalCase(name) %>: React.FC = (props: React.PropsWithChildren) => { + return View />; }; -V<%= h.changeCase.pascalCase(name) %>.displayName = 'V<%= h.changeCase.pascalCase(name) %>'; -V<%= h.changeCase.pascalCase(name) %>.defaultProps = {}; +ED<%= h.changeCase.pascalCase(name) %>.displayName = 'ED<%= h.changeCase.pascalCase(name) %>'; +ED<%= h.changeCase.pascalCase(name) %>.defaultProps = {}; -export default React.memo(V<%= h.changeCase.pascalCase(name) %>); +export default React.memo(ED<%= h.changeCase.pascalCase(name) %>); diff --git a/apps/frontend/_templates/component/new-ui/component.view.tsx.ejs.t b/apps/frontend/_templates/component/new-ui/component.view.tsx.ejs.t index b3b16a0e1..4a16e4b55 100644 --- a/apps/frontend/_templates/component/new-ui/component.view.tsx.ejs.t +++ b/apps/frontend/_templates/component/new-ui/component.view.tsx.ejs.t @@ -1,17 +1,17 @@ --- -to: src/components/ui/V<%= h.changeCase.pascalCase(name.toLowerCase()) %>/V<%= h.changeCase.pascalCase(name.toLowerCase()) %>.view.tsx +to: src/components/ui/ED<%= h.changeCase.pascalCase(name.toLowerCase()) %>/ED<%= h.changeCase.pascalCase(name.toLowerCase()) %>.view.tsx --- <% name = name.toLowerCase() %>import React from 'react'; -import classes from './V<%= h.changeCase.pascalCase(name) %>.module.scss'; +import classes from './ED<%= h.changeCase.pascalCase(name) %>.module.scss'; interface IProps {} -const V<%= h.changeCase.pascalCase(name) %>View: React.FC = (props: React.PropsWithChildren) => { +const ED<%= h.changeCase.pascalCase(name) %>View: React.FC = (props: React.PropsWithChildren) => { return ; }; -V<%= h.changeCase.pascalCase(name) %>View.displayName = 'V<%= h.changeCase.pascalCase(name) %>View'; -V<%= h.changeCase.pascalCase(name) %>View.defaultProps = {}; +ED<%= h.changeCase.pascalCase(name) %>View.displayName = 'ED<%= h.changeCase.pascalCase(name) %>View'; +ED<%= h.changeCase.pascalCase(name) %>View.defaultProps = {}; -export default React.memo(V<%= h.changeCase.pascalCase(name) %>View); +export default React.memo(ED<%= h.changeCase.pascalCase(name) %>View); diff --git a/apps/frontend/_templates/component/new-ui/index.tsx.ejs.t b/apps/frontend/_templates/component/new-ui/index.tsx.ejs.t index 1b66646c7..1d0aebf05 100644 --- a/apps/frontend/_templates/component/new-ui/index.tsx.ejs.t +++ b/apps/frontend/_templates/component/new-ui/index.tsx.ejs.t @@ -1,6 +1,6 @@ --- -to: src/components/ui/V<%= h.changeCase.pascalCase(name.toLowerCase()) %>/index.ts +to: src/components/ui/ED<%= h.changeCase.pascalCase(name.toLowerCase()) %>/index.ts --- -<% name = name.toLowerCase() %>import V<%= h.changeCase.pascalCase(name) %> from './V<%= h.changeCase.pascalCase(name) %>'; +<% name = name.toLowerCase() %>import ED<%= h.changeCase.pascalCase(name) %> from './ED<%= h.changeCase.pascalCase(name) %>'; -export default V<%= h.changeCase.pascalCase(name) %>; +export default ED<%= h.changeCase.pascalCase(name) %>; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 0833608e1..f9cb2705d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -24,6 +24,7 @@ "axios": "0.27.2", "i18next": "21.9.1", "react": "18.2.0", + "react-datepicker": "4.8.0", "react-dom": "18.2.0", "react-i18next": "11.18.6", "react-redux": "8.0.2", @@ -38,6 +39,7 @@ "@types/craco__craco": "6.4.0", "@types/node": "17.0.35", "@types/react": "18.0.19", + "@types/react-datepicker": "4.4.2", "@types/react-dom": "18.0.6", "@types/react-router-dom": "5.3.3", "@typescript-eslint/eslint-plugin": "5.36.2", diff --git a/apps/frontend/src/assets/icons.ts b/apps/frontend/src/assets/icons.ts index ab4f11201..2ade63444 100644 --- a/apps/frontend/src/assets/icons.ts +++ b/apps/frontend/src/assets/icons.ts @@ -69,6 +69,24 @@ const icons = { `, ], + strokeArrowDown: [ + '10 6', + ` + + `, + ], + vStroke: [ + '12 8', + ` + + `, + ], + xFill: [ + '10 10', + ` + + `, + ], }; export default icons; diff --git a/apps/frontend/src/components/containers/AccountSettings/Account/DeleteAccountModal/DeleteAccountModal.module.scss b/apps/frontend/src/components/containers/AccountSettings/Account/DeleteAccountModal/DeleteAccountModal.module.scss index 18172887b..0522f5981 100644 --- a/apps/frontend/src/components/containers/AccountSettings/Account/DeleteAccountModal/DeleteAccountModal.module.scss +++ b/apps/frontend/src/components/containers/AccountSettings/Account/DeleteAccountModal/DeleteAccountModal.module.scss @@ -84,6 +84,10 @@ color: map.get($colors, greys-independence-grey); border: 2px solid map.get($colors, greys-platinum); border-radius: 10px; + + &::placeholder { + opacity: 1; + } } &__button { diff --git a/apps/frontend/src/components/containers/AccountSettings/AccountSettings.view.tsx b/apps/frontend/src/components/containers/AccountSettings/AccountSettings.view.tsx index 3e8046235..d5cb83c3b 100644 --- a/apps/frontend/src/components/containers/AccountSettings/AccountSettings.view.tsx +++ b/apps/frontend/src/components/containers/AccountSettings/AccountSettings.view.tsx @@ -6,6 +6,8 @@ import Nav from '@/layout/Nav'; import Header from './Header'; import SideBar from './SideBar'; import Account from './Account'; +import SecretManagement from './SecretManagement'; +import NewSecret from './NewSecret'; import classes from './AccountSettings.module.scss'; @@ -21,7 +23,13 @@ const AccountSettingsView: React.FC = () => { } /> } /> -  } /> + } /> + } /> + } + /> + } /> diff --git a/apps/frontend/src/components/containers/AccountSettings/Header/Header.module.scss b/apps/frontend/src/components/containers/AccountSettings/Header/Header.module.scss index 8e9566bb1..3662953ea 100644 --- a/apps/frontend/src/components/containers/AccountSettings/Header/Header.module.scss +++ b/apps/frontend/src/components/containers/AccountSettings/Header/Header.module.scss @@ -41,6 +41,10 @@ border-color: map.get($colors, greys-philippine-gray); } + &:active { + border-color: map.get($colors, greys-onyx); + } + &__value { margin-inline-end: map.get($sizes, spacing-m); font-size: 1.5rem; diff --git a/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.module.scss b/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.module.scss new file mode 100644 index 000000000..01bb70f5c --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.module.scss @@ -0,0 +1,72 @@ +@use 'sass:map'; + +@import '../../../../../styles/variables.scss'; + +.label { + font-size: 1.7rem; + font-weight: 500; + color: map.get($colors, greys-onyx); +} + +.inputContainer { + display: flex; + align-items: center; + + &__input { + width: 354px; + padding: map.get($sizes, spacing-m) map.get($sizes, spacing-l); + margin-block: map.get($sizes, spacing-s); + font-size: 1.5rem; + color: map.get($colors, greys-philippine-gray); + background-color: map.get($colors, whites-ghost-white); + border: 2px solid map.get($colors, greys-platinum); + border-radius: 6px; + transition: 0.3s ease-in border-color; + + &::placeholder { + opacity: 1; + } + + &:hover, + &:focus { + border-color: map.get($colors, greys-silver-sand); + } + } + + .tooltip { + display: flex; + align-items: center; + padding: map.get($sizes, spacing-s) map.get($sizes, spacing-m); + margin-inline-start: map.get($sizes, spacing-m); + background-color: map.get($colors, whites-white); + border: 2px solid map.get($colors, greys-platinum); + border-radius: 6px; + + &__icon { + width: auto; + height: 10px; + margin-inline-end: map.get($sizes, spacing-m); + + &--available { + fill: transparent; + stroke: map.get($colors, greens-japanese-laurel); + } + + &--unavailable { + fill: map.get($colors, reds-persian-plum); + } + } + + &__text { + font-size: 1.5rem; + color: map.get($colors, greys-onyx); + } + } +} + +.hint { + margin-bottom: map.get($sizes, spacing-l); + font-size: 1.5rem; + font-weight: 300; + color: map.get($colors, greys-independence-grey); +} diff --git a/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.tsx b/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.tsx new file mode 100644 index 000000000..63fe315ef --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import LabelInputView from './LabelInput.view'; + +interface IProps { + readonly secretLabelInput: string | null; + readonly isSecretLabelAvailable: boolean | null; + readonly onSecretLabelInputChange: (value: string) => void; +} + +const LabelInput: React.FC = (props: React.PropsWithChildren) => { + return ( + + ); +}; + +LabelInput.displayName = 'LabelInput'; +LabelInput.defaultProps = {}; + +export default React.memo(LabelInput); diff --git a/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.view.tsx b/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.view.tsx new file mode 100644 index 000000000..6c6f62c1e --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/LabelInput.view.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { concatClasses } from '@/utils/component'; +import EDSvg from '@/ui/EDSvg'; + +import classes from './LabelInput.module.scss'; + +interface IProps { + readonly secretLabelInput: string | null; + readonly isSecretLabelAvailable: boolean | null; + readonly onSecretLabelInputChange: (value: string) => void; +} + +const LabelInputView: React.FC = (props: React.PropsWithChildren) => { + const { t } = useTranslation(); + + return ( + <> + +
+ props.onSecretLabelInputChange(value)} + /> + {props.isSecretLabelAvailable !== null && ( +
+ + + {props.isSecretLabelAvailable + ? t('accountSettings.newSecret.labelIsAvailable') + : t('accountSettings.newSecret.labelIsUnavailable')} + +
+ )} +
+ {t('accountSettings.newSecret.secretUsageHint')} + + ); +}; + +LabelInputView.displayName = 'LabelInputView'; +LabelInputView.defaultProps = {}; + +export default React.memo(LabelInputView); diff --git a/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/index.ts b/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/index.ts new file mode 100644 index 000000000..ffc42b8ed --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/NewSecret/LabelInput/index.ts @@ -0,0 +1,3 @@ +import LabelInput from './LabelInput'; + +export default LabelInput; diff --git a/apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.module.scss b/apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.module.scss new file mode 100644 index 000000000..f2b858198 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.module.scss @@ -0,0 +1,73 @@ +@use 'sass:map'; + +@import '../../../../styles/variables.scss'; + +.container { + display: flex; + flex-direction: column; + width: 708px; +} + +.actionSection { + display: flex; + flex-direction: column; + align-items: flex-start; + + &__header { + font-size: 2.4rem; + font-weight: normal; + color: map.get($colors, greys-onyx); + + &--delete { + color: map.get($colors, reds-persian-plum); + } + } + + &__divider { + width: 100%; + height: 2px; + margin-block: map.get($sizes, spacing-l); + background-color: map.get($colors, greys-platinum); + } +} + +.generateSecretForm { + display: flex; + flex-direction: column; + width: 100%; + + &__divider { + width: 100%; + height: 2px; + margin-block: map.get($sizes, spacing-l); + background-color: map.get($colors, greys-platinum); + } + + .formActions { + display: flex; + align-items: center; + + &__submit { + padding: map.get($sizes, spacing-m) map.get($sizes, spacing-l); + margin-inline-end: map.get($sizes, spacing-m); + font-size: 1.5rem; + font-weight: 600; + color: map.get($colors, greys-anti-flash-grey-white); + background-color: map.get($colors, greens-slimy-green); + border: 1px solid map.get($colors, greens-japanese-laurel); + border-radius: 6px; + + &:disabled { + color: map.get($colors, greys-philippine-gray); + background-color: map.get($colors, whites-ghost-white); + border-color: map.get($colors, greys-platinum); + } + } + + &__cancel { + font-size: 1.5rem; + color: map.get($colors, purples-purple); + text-decoration: none; + } + } +} diff --git a/apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.tsx b/apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.tsx new file mode 100644 index 000000000..d7767be9a --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.tsx @@ -0,0 +1,91 @@ +import React, { type FormEvent, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useDebounce } from '@/hooks/use-debounce'; +import { backendApi } from '@/utils/http'; + +import type { IAvailableLabelResponse, ICreateSecretResponse } from './interfaces/responses'; +import { WEEK_INTERVAL } from './models/time'; + +import NewSecretView from './NewSecret.view'; + +interface IProps {} + +const NewSecret: React.FC = () => { + const currentDate = new Date(); + const nextWeekDate = new Date(currentDate.getTime() + WEEK_INTERVAL); + + const navigate = useNavigate(); + + const [secretLabelInputState, setSecretLabelInputState] = useState(null); + const [isSecretLabelValidState, setIsSecretLabelValidState] = useState(false); + const [isSecretLabelAvailableState, setIsSecretLabelAvailableState] = useState(null); + const [selectedOptionIndexState, setSelectedOptionIndexState] = useState(0); + const [selectedDateState, setSelectedDateState] = useState(nextWeekDate); + + useEffect(() => { + if (secretLabelInputState === '' || secretLabelInputState === null) { + setIsSecretLabelValidState(() => false); + } + }, [secretLabelInputState]); + + useDebounce( + () => { + if (secretLabelInputState === '' || secretLabelInputState === null) { + setIsSecretLabelValidState(() => false); + } else { + backendApi + .get(`/user/secrets/${secretLabelInputState}`) + .then((response) => { + setIsSecretLabelValidState(response.data.isAvailable); + setIsSecretLabelAvailableState(response.data.isAvailable); + }); + } + }, + [secretLabelInputState], + 400, + ); + + const onSecretLabelInputChange = (value: string) => { + setSecretLabelInputState(() => value); + setIsSecretLabelAvailableState(() => null); + setIsSecretLabelValidState(() => false); + }; + + const onSelectExpirationDate = (index: number, value: Date | null) => { + setSelectedOptionIndexState(() => index); + setSelectedDateState(() => value); + }; + + const onGenerateSecret = (e: FormEvent) => { + e.preventDefault(); + + backendApi + .post('/user/secrets', { + label: secretLabelInputState, + expiration: selectedDateState ? selectedDateState.getTime() : null, + }) + .then((response) => { + navigate('/account-settings/secret-management', { + state: response.data, + }); + }); + }; + + return ( + + ); +}; + +NewSecret.displayName = 'NewSecret'; +NewSecret.defaultProps = {}; + +export default React.memo(NewSecret); diff --git a/apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.view.tsx b/apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.view.tsx new file mode 100644 index 000000000..8acfc86e8 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/NewSecret/NewSecret.view.tsx @@ -0,0 +1,88 @@ +import React, { type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import EDSelectDate from '@/ui/EDSelectDate'; +import type { IOption } from '@/ui/EDSelectDate/interfaces/option'; + +import { MONTH_INTERVAL, THREE_MONTHS_INTERVAL, WEEK_INTERVAL } from './models/time'; +import LabelInput from './LabelInput'; + +import classes from './NewSecret.module.scss'; + +interface IProps { + readonly secretLabelInput: string | null; + readonly isSecretLabelValid: boolean; + readonly selectedOptionIndex: number; + readonly isSecretLabelAvailable: boolean | null; + readonly onSecretLabelInputChange: (value: string) => void; + readonly onSelectExpirationDate: (index: number, value: Date | null) => void; + readonly onGenerateSecret: (e: FormEvent) => void; +} + +const NewSecretView: React.FC = (props: React.PropsWithChildren) => { + const { t } = useTranslation(); + + const currentDate = new Date(); + const nextWeekDate = new Date(currentDate.getTime() + WEEK_INTERVAL); + const nextMonthDate = new Date(currentDate.getTime() + MONTH_INTERVAL); + const nextThreeMonthsDate = new Date(currentDate.getTime() + THREE_MONTHS_INTERVAL); + + const dateSelectOptions: IOption[] = [ + { value: nextWeekDate, label: `7 ${t('accountSettings.newSecret.dateSelect.days')}` }, + { value: nextMonthDate, label: `30 ${t('accountSettings.newSecret.dateSelect.days')}` }, + { value: nextThreeMonthsDate, label: `90 ${t('accountSettings.newSecret.dateSelect.days')}` }, + { value: null, label: t('accountSettings.newSecret.dateSelect.custom'), withPicker: true }, + { value: null, label: t('accountSettings.newSecret.dateSelect.noExpiration') }, + ]; + + return ( +
+
+

+ {t('accountSettings.newSecret.actionHeader')} +

+ +
+ +
+ + + + +
+ +
+ + + {t('accountSettings.newSecret.cancelButton')} + +
+ +
+
+ ); +}; + +NewSecretView.displayName = 'NewSecretView'; +NewSecretView.defaultProps = {}; + +export default React.memo(NewSecretView); diff --git a/apps/frontend/src/components/containers/AccountSettings/NewSecret/index.ts b/apps/frontend/src/components/containers/AccountSettings/NewSecret/index.ts new file mode 100644 index 000000000..97481a110 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/NewSecret/index.ts @@ -0,0 +1,3 @@ +import NewSecret from './NewSecret'; + +export default NewSecret; diff --git a/apps/frontend/src/components/containers/AccountSettings/NewSecret/interfaces/responses.ts b/apps/frontend/src/components/containers/AccountSettings/NewSecret/interfaces/responses.ts new file mode 100644 index 000000000..8635abe01 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/NewSecret/interfaces/responses.ts @@ -0,0 +1,8 @@ +export interface IAvailableLabelResponse { + readonly isAvailable: boolean; +} + +export interface ICreateSecretResponse { + readonly secretId: string; + readonly secretValue: string; +} diff --git a/apps/frontend/src/components/containers/AccountSettings/NewSecret/models/time.ts b/apps/frontend/src/components/containers/AccountSettings/NewSecret/models/time.ts new file mode 100644 index 000000000..b3aadc215 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/NewSecret/models/time.ts @@ -0,0 +1,9 @@ +const SECOND = 60 * 1000; +const MINUTE = 60 * SECOND; +const DAY = 24 * MINUTE; + +export const WEEK_INTERVAL = 7 * DAY; + +export const MONTH_INTERVAL = 30 * DAY; + +export const THREE_MONTHS_INTERVAL = 3 * MONTH_INTERVAL; diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.module.scss b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.module.scss new file mode 100644 index 000000000..fc0681bde --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.module.scss @@ -0,0 +1,63 @@ +@use 'sass:map'; + +@import '../../../../../styles/variables.scss'; + +.container { + display: flex; + flex-direction: column; + margin-bottom: map.get($sizes, spacing-xxl); + + &__disclaimer { + padding: map.get($sizes, spacing-l); + margin-bottom: map.get($sizes, spacing-l); + font-size: 1.5rem; + color: map.get($colors, greys-onyx); + background-color: map.get($colors, blues-light-cyan); + border: 2px solid map.get($colors, blues-weldon-blue); + border-radius: 6px; + } +} + +.createdSecretContainer { + display: flex; + align-items: center; + justify-content: space-between; + padding: map.get($sizes, spacing-m) map.get($sizes, spacing-l); + background-color: map.get($colors, greens-nyanza); + border: 2px solid map.get($colors, greys-platinum); + border-radius: 6px; + + .createdSecretDetails { + display: flex; + align-items: center; + + &__successIcon { + width: auto; + height: 9px; + fill: transparent; + stroke: map.get($colors, greens-japanese-laurel); + } + + &__value { + margin-inline: map.get($sizes, spacing-s); + font-size: 1.5rem; + color: map.get($colors, greys-independence-grey); + } + + &__copyIcon { + width: auto; + height: 18px; + cursor: pointer; + fill: map.get($colors, greys-independence-grey); + } + } + + &__deleteAction { + padding: map.get($sizes, spacing-m) map.get($sizes, spacing-l); + font-size: 1.5rem; + color: map.get($colors, reds-persian-plum); + background-color: map.get($colors, whites-ghost-white); + border: 2px solid map.get($colors, greys-platinum); + border-radius: 6px; + } +} diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.tsx b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.tsx new file mode 100644 index 000000000..6dd98e660 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { connect } from 'react-redux'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import { useTranslation } from 'react-i18next'; + +import { backendApi } from '@/utils/http'; +import type { IUiShowNotificationPayload } from '@/store/interfaces/ui'; +import { uiActions } from '@/store/reducers/ui'; + +import type { ISecretDetails } from '../interfaces/secret'; + +import PostSecretCreationView from './PostSecretCreation.view'; + +interface IPropsFromDispatch { + readonly showNotification: ( + showNotificationPayload: IUiShowNotificationPayload, + ) => PayloadAction; +} + +interface IProps extends IPropsFromDispatch { + readonly createdSecretDetails: ISecretDetails; +} + +const PostSecretCreation: React.FC = (props: React.PropsWithChildren) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const onDelete = () => { + backendApi.delete(`/user/secrets/${props.createdSecretDetails.secretId}`).then(() => { + navigate('', { replace: true }); + }); + }; + + const onCopySecretValue = () => { + navigator.clipboard.writeText(props.createdSecretDetails.secretValue); + props.showNotification({ + notificationType: 'info', + notificationTitle: t('accountSettings.secretManagement.copyNotificationTitle'), + notificationMessage: t('accountSettings.secretManagement.copyNotificationMessage'), + }); + }; + + return ( + + ); +}; + +PostSecretCreation.displayName = 'PostSecretCreation'; +PostSecretCreation.defaultProps = {}; + +export default connect(null, { + showNotification: uiActions.showNotification, +})(React.memo(PostSecretCreation)); diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.view.tsx b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.view.tsx new file mode 100644 index 000000000..a7ac0a719 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/PostSecretCreation.view.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import EDSvg from '@/ui/EDSvg'; + +import classes from './PostSecretCreation.module.scss'; + +interface IProps { + readonly createdSecretValue: string; + readonly onDelete: VoidFunction; + readonly onCopySecretValue: VoidFunction; +} + +const PostSecretCreationView: React.FC = (props: React.PropsWithChildren) => { + const { t } = useTranslation(); + + let createdSecretValueText: string; + + if (props.createdSecretValue.length > 18) { + const prefixText = props.createdSecretValue.substring(0, 12); + const postfixText = props.createdSecretValue.slice(-6); + + createdSecretValueText = `${prefixText}...${postfixText}`; + } else { + createdSecretValueText = props.createdSecretValue; + } + + return ( +
+ + {t('accountSettings.secretManagement.postSecretCreationDisclaimer')} + + +
+
+ + {createdSecretValueText} + +
+ +
+
+ ); +}; + +PostSecretCreationView.displayName = 'SecretsListView'; +PostSecretCreationView.defaultProps = {}; + +export default React.memo(PostSecretCreationView); diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/index.ts b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/index.ts new file mode 100644 index 000000000..04ec28ad1 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/PostSecretCreation/index.ts @@ -0,0 +1,3 @@ +import PostSecretCreation from './PostSecretCreation'; + +export default PostSecretCreation; diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.module.scss b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.module.scss new file mode 100644 index 000000000..e1523ffb3 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.module.scss @@ -0,0 +1,75 @@ +@use 'sass:map'; + +@import '../../../../styles/variables.scss'; + +.container { + display: flex; + flex-direction: column; + width: 708px; + + &__noSecrets { + font-size: 1.5rem; + color: map.get($colors, greys-onyx); + text-align: center; + } +} + +.actionSection { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: map.get($sizes, spacing-xxl); + + .actionHeader { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + &__header { + font-size: 2.4rem; + font-weight: normal; + color: map.get($colors, greys-onyx); + } + + .headerActionsContainer { + display: flex; + align-items: center; + + &__button { + padding: map.get($sizes, spacing-m) map.get($sizes, spacing-l); + font-size: 1.5rem; + font-weight: 500; + background-color: map.get($colors, whites-ghost-white); + border: 2px solid map.get($colors, greys-platinum); + border-radius: 6px; + + &--generate { + color: map.get($colors, greys-onyx); + text-decoration: none; + } + + &--revokeAll { + margin-inline-start: map.get($sizes, spacing-m); + color: map.get($colors, reds-persian-plum); + } + } + } + } + + &__divider { + width: 100%; + height: 2px; + margin-block: map.get($sizes, spacing-l); + background-color: map.get($colors, greys-platinum); + } + + .actionSubHeader { + font-size: 1.7rem; + color: map.get($colors, greys-independence-grey); + + &__postfix { + color: map.get($colors, purples-purple); + } + } +} diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.tsx b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.tsx new file mode 100644 index 000000000..04cbc6897 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { backendApi } from '@/utils/http'; + +import type { IGetAllSecretsResponse, ISecret, ISecretDetails } from './interfaces/secret'; + +import SecretManagementView from './SecretManagement.view'; + +interface IProps {} + +const SecretManagement: React.FC = () => { + const [secretsListState, setSecretsListState] = useState([]); + + const navigate = useNavigate(); + const location = useLocation(); + const createdSecretDetails = location.state as ISecretDetails | null; + + useEffect(() => { + backendApi + .get('/user/secrets') + .then((response) => { + if (createdSecretDetails) { + setSecretsListState(() => + response.data.secrets.filter((secret) => secret.id !== createdSecretDetails.secretId), + ); + + return; + } + + setSecretsListState(() => response.data.secrets); + }) + .catch(() => { + return; + }); + }, [backendApi, createdSecretDetails]); + + const onDeleteSecret = (secretId: string) => { + backendApi.delete(`/user/secrets/${secretId}`).then(() => { + setSecretsListState((prev) => prev.filter((secret) => secret.id !== secretId)); + }); + }; + + const onRevokeAllSecrets = () => { + backendApi.delete('/user/secrets').then(() => { + setSecretsListState(() => []); + navigate('', { replace: true }); + }); + }; + + return ( + + ); +}; + +SecretManagement.displayName = 'SecretManagement'; +SecretManagement.defaultProps = {}; + +export default React.memo(SecretManagement); diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.view.tsx b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.view.tsx new file mode 100644 index 000000000..519d2d984 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretManagement.view.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { concatClasses } from '@/utils/component'; + +import type { ISecret, ISecretDetails } from './interfaces/secret'; +import SecretsList from './SecretsList'; + +import classes from './SecretManagement.module.scss'; +import PostSecretCreation from './PostSecretCreation'; + +interface IProps { + readonly secretsList: ISecret[]; + readonly createdSecretDetails: ISecretDetails | null; + readonly onDeleteSecret: (secretId: string) => void; + readonly onRevokeAllSecrets: VoidFunction; +} + +const SecretManagementView: React.FC = (props: React.PropsWithChildren) => { + const { t } = useTranslation(); + + return ( +
+
+
+

+ {t('accountSettings.secretManagement.actionHeader')} +

+
+ + {t('accountSettings.secretManagement.generateNewSecretButton')} + + {props.secretsList.length > 0 && ( + + )} +
+
+
+ + {t('accountSettings.secretManagement.actionSubHeaderPrefix')} +   + + {t('accountSettings.secretManagement.actionSubHeaderPostfix')} + + +
+ + {props.createdSecretDetails && ( + + )} + + {props.secretsList.length === 0 && props.createdSecretDetails === null && ( + + {t('accountSettings.secretManagement.noSecrets')} + + )} + + {props.secretsList.length > 0 && ( + + )} +
+ ); +}; + +SecretManagementView.displayName = 'SecretManagementView'; +SecretManagementView.defaultProps = {}; + +export default React.memo(SecretManagementView); diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.module.scss b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.module.scss new file mode 100644 index 000000000..b7804a432 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.module.scss @@ -0,0 +1,59 @@ +@use 'sass:map'; + +@import '../../../../../styles/variables.scss'; + +.secretsList { + display: flex; + flex-direction: column; + + .secretContainer { + display: flex; + align-items: center; + justify-content: space-between; + padding: map.get($sizes, spacing-l); + border: 2px solid map.get($colors, greys-platinum); + + &:not(:last-child) { + border-bottom: 0; + } + + .secretDetails { + display: flex; + flex-direction: column; + + &__label { + margin-bottom: map.get($sizes, spacing-s); + font-size: 1.5rem; + font-weight: 500; + color: map.get($colors, purples-purple); + } + + &__expiration { + font-size: 1.5rem; + color: map.get($colors, greys-onyx); + } + } + + .secretActions { + display: flex; + align-items: center; + + &__button { + padding: map.get($sizes, spacing-m) map.get($sizes, spacing-l); + font-size: 1.5rem; + background-color: map.get($colors, whites-ghost-white); + border: 2px solid map.get($colors, greys-platinum); + border-radius: 6px; + + &--refresh { + margin-inline-end: map.get($sizes, spacing-m); + color: map.get($colors, greys-onyx); + } + + &--delete { + color: map.get($colors, reds-persian-plum); + } + } + } + } +} diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.tsx b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.tsx new file mode 100644 index 000000000..e1e625687 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { backendApi } from '@/utils/http'; + +import type { ISecret } from '../interfaces/secret'; + +import SecretsListView from './SecretsList.view'; + +interface IProps { + readonly secretsList: ISecret[]; + readonly onDeleteSecret: (secretId: string) => void; +} + +const SecretsList: React.FC = (props: React.PropsWithChildren) => { + const onRefreshSecret = (secretId: string) => { + backendApi.patch(`/user/secrets/refresh-secret/${secretId}`); + }; + + return ( + + ); +}; + +SecretsList.displayName = 'SecretsList'; +SecretsList.defaultProps = {}; + +export default React.memo(SecretsList); diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.view.tsx b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.view.tsx new file mode 100644 index 000000000..184b4e0da --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.view.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { concatClasses } from '@/utils/component'; + +import type { ISecret } from '../interfaces/secret'; + +import classes from './SecretsList.module.scss'; + +interface IProps { + readonly secretsList: ISecret[]; + readonly onRefreshSecret: (secretId: string) => void; + readonly onDeleteSecret: (secretId: string) => void; +} + +const SecretsListView: React.FC = (props: React.PropsWithChildren) => { + const { t } = useTranslation(); + + return ( +
+ {props.secretsList.map((secret) => ( +
+
+
{secret.label}
+ + {secret.expiration ? ( + <> + {t('accountSettings.secretManagement.secretExpirationPrefix')} +   + {new Intl.DateTimeFormat('en-US').format(secret.expiration)} + + ) : ( + t('accountSettings.secretManagement.secretExpirationNeverExpires') + )} + + +
+
+ + +
+
+ ))} +
+ ); +}; + +SecretsListView.displayName = 'SecretsListView'; +SecretsListView.defaultProps = {}; + +export default React.memo(SecretsListView); diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/index.ts b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/index.ts new file mode 100644 index 000000000..2ebdb2383 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/index.ts @@ -0,0 +1,3 @@ +import SecretsList from './SecretsList'; + +export default SecretsList; diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/index.ts b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/index.ts new file mode 100644 index 000000000..2aa8cb6b9 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/index.ts @@ -0,0 +1,3 @@ +import SecretManagement from './SecretManagement'; + +export default SecretManagement; diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/interfaces/secret.ts b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/interfaces/secret.ts new file mode 100644 index 000000000..11b9fc0a8 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/interfaces/secret.ts @@ -0,0 +1,14 @@ +export interface ISecret { + readonly id: string; + readonly label: string; + readonly expiration: number | null; +} + +export interface IGetAllSecretsResponse { + readonly secrets: ISecret[]; +} + +export interface ISecretDetails { + readonly secretId: string; + readonly secretValue: string; +} diff --git a/apps/frontend/src/components/containers/AccountSettings/SideBar/SideBar.view.tsx b/apps/frontend/src/components/containers/AccountSettings/SideBar/SideBar.view.tsx index c2400be22..de89dbb92 100644 --- a/apps/frontend/src/components/containers/AccountSettings/SideBar/SideBar.view.tsx +++ b/apps/frontend/src/components/containers/AccountSettings/SideBar/SideBar.view.tsx @@ -28,12 +28,12 @@ const SideBarView: React.FC = () => { className={({ isActive }) => concatClasses(classes, 'innerLink', isActive ? 'innerLink--active' : null) } - to="token-management" + to="secret-management" >
- {t('accountSettings.sideBar.tokenManagement')} + {t('accountSettings.sideBar.secretManagement')} diff --git a/apps/frontend/src/components/ui/EDNotification/EDNotification.module.scss b/apps/frontend/src/components/ui/EDNotification/EDNotification.module.scss index 3fd1cd638..f2045c3aa 100644 --- a/apps/frontend/src/components/ui/EDNotification/EDNotification.module.scss +++ b/apps/frontend/src/components/ui/EDNotification/EDNotification.module.scss @@ -6,9 +6,6 @@ position: fixed; display: flex; width: 258px; - padding: map.get($sizes, spacing-m) map.get($sizes, spacing-l); - border: 2px solid; - border-inline-start: 3px solid; opacity: 0; inset-block-end: map.get($sizes, spacing-xxl); inset-inline-end: map.get($sizes, spacing-xxl); @@ -17,29 +14,52 @@ &--fullOpacity { opacity: 1; } +} + +.notificationBorder { + width: 3px; + + &--info { + background-color: map.get($colors, greys-onyx); + } + + &--checkmark { + background-color: map.get($colors, greens-japanese-laurel); + } + + &--warning { + background-color: map.get($colors, yellows-deep-lemon); + } + + &--error { + background-color: map.get($colors, reds-persian-plum); + } +} + +.notificationContent { + display: flex; + padding: map.get($sizes, spacing-m) map.get($sizes, spacing-l); + border: 2px solid; + border-inline-start: none; &--info { background-color: map.get($colors, whites-white); border-color: map.get($colors, greys-platinum); - border-inline-start-color: map.get($colors, greys-onyx); } &--checkmark { background-color: map.get($colors, greens-nyanza); border-color: map.get($colors, greens-sea-green); - border-inline-start-color: map.get($colors, greens-japanese-laurel); } &--warning { background-color: map.get($colors, yellows-papaya-whip); border-color: map.get($colors, yellows-deep-lemon-30-p); - border-inline-start-color: map.get($colors, yellows-deep-lemon); } &--error { background-color: map.get($colors, reds-lavender-blush); border-color: map.get($colors, reds-maximum-red); - border-inline-start-color: map.get($colors, reds-persian-plum); } &__icon { @@ -54,21 +74,21 @@ cursor: pointer; fill: map.get($colors, greys-onyx); } -} -.textContainer { - display: flex; - flex-direction: column; + .textContainer { + display: flex; + flex-direction: column; - &__title { - margin-bottom: map.get($sizes, spacing-xs); - font-size: 1.5rem; - font-weight: 600; - color: map.get($colors, greys-onyx); - } + &__title { + margin-bottom: map.get($sizes, spacing-xs); + font-size: 1.5rem; + font-weight: 600; + color: map.get($colors, greys-onyx); + } - &__message { - font-size: 1.5rem; - color: map.get($colors, greys-onyx); + &__message { + font-size: 1.5rem; + color: map.get($colors, greys-onyx); + } } } diff --git a/apps/frontend/src/components/ui/EDNotification/EDNotification.view.tsx b/apps/frontend/src/components/ui/EDNotification/EDNotification.view.tsx index 4f487766b..10188ecd4 100644 --- a/apps/frontend/src/components/ui/EDNotification/EDNotification.view.tsx +++ b/apps/frontend/src/components/ui/EDNotification/EDNotification.view.tsx @@ -45,20 +45,34 @@ const EDNotificationView: React.FC = (props: React.PropsWithChildren - -
-
{props.notificationTitle}
- {props.notificationMessage} -
- +
+ +
+
{props.notificationTitle}
+ {props.notificationMessage} +
+ +
); }; diff --git a/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.module.scss b/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.module.scss new file mode 100644 index 000000000..06b4b8164 --- /dev/null +++ b/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.module.scss @@ -0,0 +1,97 @@ +@use 'sass:map'; + +@import '../../../styles/variables.scss'; + +.container { + display: flex; + align-items: center; + + &__hintText { + font-size: 1.5rem; + color: map.get($colors, greys-independence-grey); + white-space: nowrap; + } +} + +.select { + position: relative; + min-width: 215px; + margin-inline-end: map.get($sizes, spacing-m); + + .selectedOptionContainer { + display: flex; + align-items: center; + justify-content: space-between; + padding: map.get($sizes, spacing-m) map.get($sizes, spacing-l); + background-color: map.get($colors, whites-ghost-white); + border: 2px solid map.get($colors, greys-platinum); + border-radius: 6px; + transition: 0.3s ease-in background-color; + + &:hover { + background-color: map.get($colors, greys-anti-flash-grey-white); + } + + &--open { + border-block-end-color: transparent; + border-end-start-radius: 0; + border-end-end-radius: 0; + } + + &__text { + font-size: 1.5rem; + color: map.get($colors, greys-independence-grey); + cursor: default; + } + + &__icon { + width: auto; + height: 6px; + fill: transparent; + stroke: map.get($colors, greys-independence-grey); + } + } + + .optionsContainer { + position: absolute; + display: flex; + flex-direction: column; + width: 100%; + padding-inline: map.get($sizes, spacing-l); + background-color: map.get($colors, whites-ghost-white); + border: 2px solid map.get($colors, greys-platinum); + + &__option { + padding-block: map.get($sizes, spacing-m); + font-size: 1.5rem; + color: map.get($colors, greys-independence-grey); + cursor: default; + + &:not(:last-child) { + border-bottom: 2px solid map.get($colors, greys-platinum); + } + } + } +} + +/* stylelint-disable-next-line selector-class-pattern */ +:global(.react-datepicker-wrapper) { + display: flex; + justify-content: center; + width: 132px; + padding: map.get($sizes, spacing-m) map.get($sizes, spacing-l); + cursor: pointer; + background-color: map.get($colors, whites-ghost-white); + border: 2px solid map.get($colors, greys-platinum); + border-radius: 6px; + + input { + width: 100%; + padding: 0; + font-size: 1.5rem; + color: map.get($colors, greys-independence-grey); + text-align: center; + cursor: pointer; + background-color: transparent; + } +} diff --git a/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.tsx b/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.tsx new file mode 100644 index 000000000..49fd2ead5 --- /dev/null +++ b/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; + +import { useClickOutside } from '@/hooks/click-outside'; + +import type { IOption } from './interfaces/option'; + +import EDSelectDateView from './EDSelectDate.view'; + +interface IProps { + readonly options: IOption[]; + readonly selectedIndex: number; + readonly hintText: string; + readonly onSelect: (index: number, value: Date | null) => void; +} + +const EDSelectDate: React.FC = (props: React.PropsWithChildren) => { + const { + ref: selectRef, + isVisible: isSelectVisible, + toggleVisibility: toggleSelectVisibility, + } = useClickOutside(false); + + const [datePickerDateState, setDatePickerDateState] = useState(new Date()); + + const onPickDate = (index: number, value: Date) => { + setDatePickerDateState(() => value); + props.onSelect(index, value); + }; + + return ( + + ); +}; + +EDSelectDate.displayName = 'EDSelectDate'; +EDSelectDate.defaultProps = {}; + +export default React.memo(EDSelectDate); diff --git a/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.view.tsx b/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.view.tsx new file mode 100644 index 000000000..11cb07b44 --- /dev/null +++ b/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.view.tsx @@ -0,0 +1,86 @@ +import React, { type RefObject } from 'react'; +import DatePicker from 'react-datepicker'; + +import { concatClasses } from '@/utils/component'; + +import EDSvg from '../EDSvg'; +import type { IOption } from './interfaces/option'; +import { formatDate } from './utils/format-date'; + +import classes from './EDSelectDate.module.scss'; + +interface IProps { + readonly selectRef: RefObject; + readonly isSelectVisible: boolean; + readonly options: IOption[]; + readonly selectedIndex: number; + readonly hintText: string; + readonly datePickerDate: Date; + readonly toggleSelectVisibility: VoidFunction; + readonly onSelect: (index: number, value: Date | null) => void; + readonly onPickDate: (index: number, value: Date) => void; +} + +const EDSelectDateView: React.FC = (props: React.PropsWithChildren) => { + const selectedOption = props.options[props.selectedIndex]!; + + const selectOptionClickHandler = (index: number, value: Date | null) => { + props.onSelect(index, value); + props.toggleSelectVisibility(); + }; + + return ( +
+
+
+ {selectedOption.label} + +
+ + {props.isSelectVisible && ( +
+ {props.options.map((option, index) => ( + selectOptionClickHandler(index, option.value)} + > + {option.label} + + ))} +
+ )} +
+ + {selectedOption.withPicker && ( + props.onPickDate(props.selectedIndex, date)} + /> + )} + + {selectedOption.value !== null && ( + + {props.hintText} +   + {formatDate(selectedOption.value)} + + )} +
+ ); +}; + +EDSelectDateView.displayName = 'EDSelectDateView'; +EDSelectDateView.defaultProps = {}; + +export default React.memo(EDSelectDateView); diff --git a/apps/frontend/src/components/ui/EDSelectDate/index.ts b/apps/frontend/src/components/ui/EDSelectDate/index.ts new file mode 100644 index 000000000..5d2265c43 --- /dev/null +++ b/apps/frontend/src/components/ui/EDSelectDate/index.ts @@ -0,0 +1,3 @@ +import EDSelectDate from './EDSelectDate'; + +export default EDSelectDate; diff --git a/apps/frontend/src/components/ui/EDSelectDate/interfaces/option.ts b/apps/frontend/src/components/ui/EDSelectDate/interfaces/option.ts new file mode 100644 index 000000000..5250d50c7 --- /dev/null +++ b/apps/frontend/src/components/ui/EDSelectDate/interfaces/option.ts @@ -0,0 +1,5 @@ +export interface IOption { + readonly value: Date | null; + readonly label: string; + readonly withPicker?: boolean; +} diff --git a/apps/frontend/src/components/ui/EDSelectDate/utils/format-date.ts b/apps/frontend/src/components/ui/EDSelectDate/utils/format-date.ts new file mode 100644 index 000000000..e91f427f2 --- /dev/null +++ b/apps/frontend/src/components/ui/EDSelectDate/utils/format-date.ts @@ -0,0 +1,6 @@ +export const formatDate = (value: Date) => { + const formattedDate = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(value); + const formattedDay = new Intl.DateTimeFormat('en-US', { weekday: 'short' }).format(value); + + return `${formattedDay}, ${formattedDate.replace(',', '')}`; +}; diff --git a/apps/frontend/src/hooks/backend-api.ts b/apps/frontend/src/hooks/backend-api.ts deleted file mode 100644 index 692c99fc8..000000000 --- a/apps/frontend/src/hooks/backend-api.ts +++ /dev/null @@ -1,82 +0,0 @@ -// * Hook logic is inspired by https://stackoverflow.com/questions/63118511/custom-http-hook-using-axios - -import { useState } from 'react'; -import axios, { type AxiosResponse, type AxiosError, type AxiosRequestHeaders, type Method } from 'axios'; - -interface IHTTPResponeData {} - -interface IHookResponse { - response: AxiosResponse | null; - error: AxiosError | null; - loading: boolean; - request: () => Promise; -} - -const backendInstance = axios.create({ - baseURL: process.env.REACT_APP_BACKEND_URL, -}); - -/** - * The hook receives the request configuration and returns an object to use a request against the backend - * @param url the url segment to send the request to - * @param method the method to use - * @param body the request body - * @param headers the request headers - * @param timeout the request timeout - * @returns an object of response, error, loading state and request to use - */ -const useBackend = ( - url: string, - method: Extract, - body?: object, - headers?: AxiosRequestHeaders, - timeout?: number, -): IHookResponse => { - const HTTPTimeout = timeout ?? 8000; - - const [responseState, setResponseState] = useState | null>(null); - const [errorState, setErrorState] = useState(null); - const [loadingState, setLoadingState] = useState(false); - - const request = async () => { - setLoadingState(() => true); - - // Get a source in-order to cancel the request when hook is being unmounted - const axiosSource = axios.CancelToken.source(); - - try { - if (method === 'GET') { - const response = await backendInstance.get(url, { - cancelToken: axiosSource.token, - timeout: HTTPTimeout, - headers, - }); - - setResponseState(() => response); - setLoadingState(() => false); - } else if (method === 'POST') { - const response = await backendInstance.post(url, body, { - cancelToken: axiosSource.token, - timeout: HTTPTimeout, - headers, - }); - - setResponseState(() => response); - setLoadingState(() => false); - } - } catch (error) { - // Update the state only if the hook isn't being unmounted - if (!axios.isCancel(error)) { - setErrorState(() => error as AxiosError); - setLoadingState(() => false); - } - } - - // In case the hook is being unmounted - cancel the request - return () => axiosSource.cancel(); - }; - - return { response: responseState, error: errorState, loading: loadingState, request }; -}; - -export default useBackend; diff --git a/apps/frontend/src/hooks/use-debounce.ts b/apps/frontend/src/hooks/use-debounce.ts new file mode 100644 index 000000000..d00424cc5 --- /dev/null +++ b/apps/frontend/src/hooks/use-debounce.ts @@ -0,0 +1,11 @@ +import { useEffect, useCallback, type DependencyList } from 'react'; + +export const useDebounce = (effect: VoidFunction, dependencies: DependencyList, delay: number) => { + const callback = useCallback(effect, dependencies); + + useEffect(() => { + const timeout = setTimeout(callback, delay); + + return () => clearTimeout(timeout); + }, [callback, delay]); +}; diff --git a/apps/frontend/src/i18n/en.ts b/apps/frontend/src/i18n/en.ts index 677b838a0..22d730b84 100644 --- a/apps/frontend/src/i18n/en.ts +++ b/apps/frontend/src/i18n/en.ts @@ -30,7 +30,7 @@ const en = { }, sideBar: { account: 'Account', - tokenManagement: 'Token Management', + secretManagement: 'Secret Management', }, account: { signOutHeader: 'Sign Out', @@ -54,6 +54,37 @@ const en = { title: 'ID Copied to clipboard', message: 'Paste it wherever you want.', }, + secretManagement: { + actionHeader: 'Secret Management', + actionSubHeaderPrefix: 'Create secrets for', + actionSubHeaderPostfix: 'our GitHub action', + generateNewSecretButton: 'Generate new secret', + noSecrets: 'No Secrets Created!', + secretExpirationPrefix: 'Expires on', + secretExpirationNeverExpires: 'Never expires', + refreshSecretAction: 'Refresh Secret', + deleteSecretAction: 'Delete', + revokeAllSecretAction: 'Revoke All', + postSecretCreationDisclaimer: + "Make sure to copy the secret now. You won't be able to see it again!", + copyNotificationTitle: 'Secret copied to clipboard', + copyNotificationMessage: 'Paste it wherever you want.', + }, + newSecret: { + actionHeader: 'New Secret', + nameInputLabel: 'Secret name', + secretUsageHint: "What's this secret for?", + secretExpirationHint: 'This secret will expire on', + generateButton: 'Generate Secret', + cancelButton: 'Cancel', + dateSelect: { + days: 'days', + custom: 'Custom', + noExpiration: 'No expiration', + }, + labelIsAvailable: 'Name available!', + labelIsUnavailable: 'Name unavailable. Try another?', + }, }, cliAuth: { header: 'Authenticate for CLI', diff --git a/apps/frontend/src/styles/custom.scss b/apps/frontend/src/styles/custom.scss index 59066ad27..8710ed6d9 100644 --- a/apps/frontend/src/styles/custom.scss +++ b/apps/frontend/src/styles/custom.scss @@ -2,6 +2,7 @@ @import 'https://fonts.googleapis.com/css2?family=Poppins:wght@100;200;300;400;500;600;700;800;900&display=swap'; @import 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap'; +@import 'react-datepicker/dist/react-datepicker.css'; @import './variables.scss'; diff --git a/apps/frontend/stylelint.config.cjs b/apps/frontend/stylelint.config.cjs index 7a4094967..34973360e 100644 --- a/apps/frontend/stylelint.config.cjs +++ b/apps/frontend/stylelint.config.cjs @@ -16,6 +16,12 @@ module.exports = { 'declaration-block-no-duplicate-properties': [true], 'function-disallowed-list': ['rgb', 'rgba', 'hsl', 'hwb'], 'property-disallowed-list': ['/.*(right|left).*/'], + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: ['global'], + }, + ], 'scss/at-import-partial-extension': null, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90374ac82..0b029d8b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,7 @@ importers: '@types/craco__craco': 6.4.0 '@types/node': 17.0.35 '@types/react': 18.0.19 + '@types/react-datepicker': 4.4.2 '@types/react-dom': 18.0.6 '@types/react-router-dom': 5.3.3 '@typescript-eslint/eslint-plugin': 5.36.2 @@ -263,6 +264,7 @@ importers: postcss-flexbugs-fixes: 5.0.2 prettier: 2.6.2 react: 18.2.0 + react-datepicker: 4.8.0 react-dom: 18.2.0 react-i18next: 11.18.6 react-redux: 8.0.2 @@ -284,6 +286,7 @@ importers: axios: 0.27.2 i18next: 21.9.1 react: 18.2.0 + react-datepicker: 4.8.0_biqbaboplfbrettd7655fr4n2y react-dom: 18.2.0_react@18.2.0 react-i18next: 11.18.6_4sidbwfhen5r7txudrvpua6nty react-redux: 8.0.2_uyfbmxhdwfup574gfu3al6lriq @@ -296,6 +299,7 @@ importers: '@craco/craco': 6.4.5_se27jcntkekojcq73coyaxt45q '@types/craco__craco': 6.4.0 '@types/node': 17.0.35 + '@types/react-datepicker': 4.4.2_biqbaboplfbrettd7655fr4n2y '@types/react-router-dom': 5.3.3 '@typescript-eslint/eslint-plugin': 5.36.2_wxqvmnl3i4rbvz4ixyoiufmx3e '@typescript-eslint/parser': 5.36.2_irgkl5vooow2ydyo6aokmferha @@ -4229,6 +4233,9 @@ packages: webpack-dev-server: 4.9.3_webpack@5.73.0 dev: true + /@popperjs/core/2.11.6: + resolution: {integrity: sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==} + /@prisma/client/4.3.1_prisma@4.3.1: resolution: {integrity: sha512-FA0/d1VMJNWqzU7WVWTNWJ+lGOLR9JUBnF73GdIPAEVo/6dWk4gHx0EmgeU+SMv4MZoxgOeTBJF2azhg7x0hMw==} engines: {node: '>=14.17'} @@ -4828,6 +4835,18 @@ packages: resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==} dev: true + /@types/react-datepicker/4.4.2_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-g8DhWvYmaIMLzVrIEVLXncylyImyBaoPsEUr3yR13JDaaHoebhDorqnVv4tLkNGa8SjBB8SAOQvxD5jaPNBX8A==} + dependencies: + '@popperjs/core': 2.11.6 + '@types/react': 18.0.19 + date-fns: 2.29.3 + react-popper: 2.3.0_r6q5zrenym2zg7je7hgi674bti + transitivePeerDependencies: + - react + - react-dom + dev: true + /@types/react-dom/18.0.6: resolution: {integrity: sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA==} dependencies: @@ -6441,6 +6460,10 @@ packages: libphonenumber-js: 1.10.9 validator: 13.7.0 + /classnames/2.3.2: + resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} + dev: false + /clean-css/5.3.1: resolution: {integrity: sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg==} engines: {node: '>= 10.0'} @@ -7251,6 +7274,10 @@ packages: whatwg-url: 8.7.0 dev: true + /date-fns/2.29.3: + resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==} + engines: {node: '>=0.11'} + /debug/2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -13399,7 +13426,6 @@ packages: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 - dev: true /proxy-addr/2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} @@ -13512,6 +13538,22 @@ packages: whatwg-fetch: 3.6.2 dev: true + /react-datepicker/4.8.0_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-u69zXGHMpxAa4LeYR83vucQoUCJQ6m/WBsSxmUMu/M8ahTSVMMyiyQzauHgZA2NUr9y0FUgOAix71hGYUb6tvg==} + peerDependencies: + react: ^16.9.0 || ^17 || ^18 + react-dom: ^16.9.0 || ^17 || ^18 + dependencies: + '@popperjs/core': 2.11.6 + classnames: 2.3.2 + date-fns: 2.29.3 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + react-onclickoutside: 6.12.2_biqbaboplfbrettd7655fr4n2y + react-popper: 2.3.0_r6q5zrenym2zg7je7hgi674bti + dev: false + /react-dev-utils/12.0.1_xfgjaxsro6snajw6lbbxg7qhpq: resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==} engines: {node: '>=14'} @@ -13562,12 +13604,14 @@ packages: loose-envify: 1.4.0 react: 18.2.0 scheduler: 0.23.0 - dev: false /react-error-overlay/6.0.11: resolution: {integrity: sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==} dev: true + /react-fast-compare/3.2.0: + resolution: {integrity: sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==} + /react-i18next/11.18.6_4sidbwfhen5r7txudrvpua6nty: resolution: {integrity: sha512-yHb2F9BiT0lqoQDt8loZ5gWP331GwctHz9tYQ8A2EIEUu+CcEdjBLQWli1USG3RdWQt3W+jqQLg/d4rrQR96LA==} peerDependencies: @@ -13598,6 +13642,29 @@ packages: /react-is/18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + /react-onclickoutside/6.12.2_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-NMXGa223OnsrGVp5dJHkuKxQ4czdLmXSp5jSV9OqiCky9LOpPATn3vLldc+q5fK3gKbEHvr7J1u0yhBh/xYkpA==} + peerDependencies: + react: ^15.5.x || ^16.x || ^17.x || ^18.x + react-dom: ^15.5.x || ^16.x || ^17.x || ^18.x + dependencies: + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + dev: false + + /react-popper/2.3.0_r6q5zrenym2zg7je7hgi674bti: + resolution: {integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==} + peerDependencies: + '@popperjs/core': ^2.0.0 + react: ^16.8.0 || ^17 || ^18 + react-dom: ^16.8.0 || ^17 || ^18 + dependencies: + '@popperjs/core': 2.11.6 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + react-fast-compare: 3.2.0 + warning: 4.0.3 + /react-redux/8.0.2_uyfbmxhdwfup574gfu3al6lriq: resolution: {integrity: sha512-nBwiscMw3NoP59NFCXFf02f8xdo+vSHT/uZ1ldDwF7XaTpzm+Phk97VT4urYBl5TYAPNVaFm12UHAEyzkpNzRA==} peerDependencies: @@ -14244,7 +14311,6 @@ packages: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} dependencies: loose-envify: 1.4.0 - dev: false /schema-utils/2.7.0: resolution: {integrity: sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==} @@ -15965,6 +16031,11 @@ packages: makeerror: 1.0.12 dev: true + /warning/4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + dependencies: + loose-envify: 1.4.0 + /watchpack/2.4.0: resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} engines: {node: '>=10.13.0'} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78a49419f..458cb1737 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,36 +8,33 @@ datasource db { } enum AuthType { - LOCAL GOOGLE GITHUB } enum PolicyLibrary { - ESLINT - PRETTIER - INFLINT - STYLELINT - DEPCHECK + eslint + prettier + inflint + stylelint + depcheck } model User { id String @id @default(auto()) @map("_id") @db.ObjectId - name String - email String @unique - passwordHash String? + name String + email String @unique authType AuthType - externalToken String? + externalToken String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt refreshTokens RefreshToken[] - clientSecrets ClientSecret[] + secrets Secret[] groups Group[] - policies Policy[] } model RefreshToken { @@ -52,7 +49,7 @@ model RefreshToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } -model ClientSecret { +model Secret { id String @id @default(auto()) @map("_id") @db.ObjectId secret String @@ -87,34 +84,15 @@ model InlinePolicy { model Group { id String @id @default(auto()) @map("_id") @db.ObjectId - userId String @db.ObjectId - label String? - policyIDs String[] @db.ObjectId + userId String @db.ObjectId + label String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt inlinePolicies InlinePolicy[] - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - policies Policy[] @relation(fields: [policyIDs], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique(fields: [userId, label], name: "unique_user_labels") } - -model Policy { - id String @id @default(auto()) @map("_id") @db.ObjectId - - userId String @db.ObjectId - label String - library PolicyLibrary - configuration Json? - rules Json? - groupsIDs String[] @db.ObjectId - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - groups Group[] @relation(fields: [groupsIDs], references: [id]) -} From 64d749251cb1fcb2012aa68e6932f3cae900cba6 Mon Sep 17 00:00:00 2001 From: tal-rofe Date: Sat, 17 Sep 2022 19:16:53 +0300 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20=F0=9F=A4=96=20[EXL-77]=20improve?= =?UTF-8?q?=20Inflint=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit improve Inflint configuration --- inflint.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inflint.config.ts b/inflint.config.ts index 8335d79b0..3773edd3c 100644 --- a/inflint.config.ts +++ b/inflint.config.ts @@ -13,7 +13,7 @@ const inflintConfig: Config = { 'apps/frontend/src/pages/**/*': [2, 'PascalCase.Point'], 'apps/frontend/src/components/{containers,layout}/**/*.{tsx,scss}': [2, 'PascalCase.Point'], 'apps/frontend/src/components/ui/**/*.{tsx,scss}': [2, '[UIComponent]'], - 'apps/frontend/src/components/ui/**/*': [2, '[UIComponent]', { onlyDirectories: true }], + 'apps/frontend/src/components/ui/*': [2, '[UIComponent]', { onlyDirectories: true }], }, }; From d751fccd7277646eec863ef28fcb7f790ba1f854 Mon Sep 17 00:00:00 2001 From: tal-rofe Date: Sat, 17 Sep 2022 20:23:12 +0300 Subject: [PATCH 3/3] =?UTF-8?q?style:=20=F0=9F=8E=A8=20[EXL-77]=20more=20s?= =?UTF-8?q?tyles=20to=20datepicker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit more styles to datepicker --- .../user/modules/secrets/classes/responses.ts | 2 +- .../secrets/refresh-secret.controller.ts | 2 +- .../SecretsList/SecretsList.tsx | 15 +++++- .../SecretsList/interfaces/responses.ts | 3 ++ .../ui/EDSelectDate/EDSelectDate.module.scss | 46 ++++++++++++++----- .../ui/EDSelectDate/EDSelectDate.view.tsx | 4 ++ 6 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/interfaces/responses.ts diff --git a/apps/backend/src/modules/user/modules/secrets/classes/responses.ts b/apps/backend/src/modules/user/modules/secrets/classes/responses.ts index 5007261ea..4c1a150b3 100644 --- a/apps/backend/src/modules/user/modules/secrets/classes/responses.ts +++ b/apps/backend/src/modules/user/modules/secrets/classes/responses.ts @@ -43,7 +43,7 @@ export class RefreshClientSecretResponse { example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', }) - public clientSecret!: string; + public secretValue!: string; } export class GetAllSecretsResponse { diff --git a/apps/backend/src/modules/user/modules/secrets/refresh-secret.controller.ts b/apps/backend/src/modules/user/modules/secrets/refresh-secret.controller.ts index c10f3da58..f3948bd7b 100644 --- a/apps/backend/src/modules/user/modules/secrets/refresh-secret.controller.ts +++ b/apps/backend/src/modules/user/modules/secrets/refresh-secret.controller.ts @@ -53,7 +53,7 @@ export class RefreshSecretController { this.logger.log('Successfully refreshed a client secret'); return { - clientSecret: secret, + secretValue: secret, }; } } diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.tsx b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.tsx index e1e625687..6a0f9ce20 100644 --- a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.tsx +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/SecretsList.tsx @@ -1,8 +1,10 @@ import React from 'react'; +import { useNavigate } from 'react-router-dom'; import { backendApi } from '@/utils/http'; import type { ISecret } from '../interfaces/secret'; +import type { IRefreshSecretResponse } from './interfaces/responses'; import SecretsListView from './SecretsList.view'; @@ -12,8 +14,19 @@ interface IProps { } const SecretsList: React.FC = (props: React.PropsWithChildren) => { + const navigate = useNavigate(); + const onRefreshSecret = (secretId: string) => { - backendApi.patch(`/user/secrets/refresh-secret/${secretId}`); + backendApi + .patch(`/user/secrets/refresh-secret/${secretId}`) + .then((response) => { + navigate('/account-settings/secret-management', { + state: { + secretId, + secretValue: response.data.secretValue, + }, + }); + }); }; return ( diff --git a/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/interfaces/responses.ts b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/interfaces/responses.ts new file mode 100644 index 000000000..480cbd202 --- /dev/null +++ b/apps/frontend/src/components/containers/AccountSettings/SecretManagement/SecretsList/interfaces/responses.ts @@ -0,0 +1,3 @@ +export interface IRefreshSecretResponse { + readonly secretValue: string; +} diff --git a/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.module.scss b/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.module.scss index 06b4b8164..951c78db7 100644 --- a/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.module.scss +++ b/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.module.scss @@ -74,8 +74,24 @@ } } -/* stylelint-disable-next-line selector-class-pattern */ -:global(.react-datepicker-wrapper) { +.datePickerValueText { + width: 100%; + padding: 0; + font-size: 1.5rem; + color: map.get($colors, greys-independence-grey); + text-align: center; + cursor: pointer; + background-color: transparent; +} + +.datePickerDayText { + width: 25px; + padding: map.get($sizes, spacing-s); + font-size: 1.5rem; + text-align: center; +} + +.datePickerWrapper { display: flex; justify-content: center; width: 132px; @@ -84,14 +100,22 @@ background-color: map.get($colors, whites-ghost-white); border: 2px solid map.get($colors, greys-platinum); border-radius: 6px; +} - input { - width: 100%; - padding: 0; - font-size: 1.5rem; - color: map.get($colors, greys-independence-grey); - text-align: center; - cursor: pointer; - background-color: transparent; - } +.datePickerWeekDay { + width: 25px; + font-size: 1.5rem; +} + +/* stylelint-disable-next-line selector-class-pattern */ +:global(.react-datepicker__current-month) { + margin-bottom: map.get($sizes, spacing-s); + font-size: 1.5rem; +} + +/* stylelint-disable-next-line selector-class-pattern */ +:global(.react-datepicker__navigation) { + width: 18px; + height: 18px; + margin-top: map.get($sizes, spacing-m); } diff --git a/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.view.tsx b/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.view.tsx index 11cb07b44..5ca6e5d50 100644 --- a/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.view.tsx +++ b/apps/frontend/src/components/ui/EDSelectDate/EDSelectDate.view.tsx @@ -61,6 +61,10 @@ const EDSelectDateView: React.FC = (props: React.PropsWithChildren classes['datePickerDayText']!} + wrapperClassName={classes['datePickerWrapper']} + weekDayClassName={() => classes['datePickerWeekDay']!} selected={props.datePickerDate || new Date()} locale="en-US" adjustDateOnChange