From 510d53305ba106c8fcdb53a112856a813a8cbd5a Mon Sep 17 00:00:00 2001 From: Edward Runkel Date: Thu, 16 Nov 2023 00:05:25 -0500 Subject: [PATCH 01/11] Update version requirements in `package` --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c62cb342..bf769a1a 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "private": true, "engines": { - "node": ">=20.0.0", - "pnpm": "8.7.5" + "node": ">=18.0.0", + "pnpm": ">=8.7.5" }, "engineStrict": true, "packageManager": "pnpm@8.7.5", From 61a61cd7fcc440bc9ff5b15654ee8d2ce93b7b16 Mon Sep 17 00:00:00 2001 From: Edward Runkel Date: Thu, 16 Nov 2023 00:07:15 -0500 Subject: [PATCH 02/11] Update API library --- lib/api/Account.ts | 92 ++++++++++++++++++++++++++++++++++ lib/api/AccountRoute.ts | 102 ++++++++++++++++++++++++++++++++++++++ lib/api/data-contracts.ts | 18 +++++++ 3 files changed, 212 insertions(+) diff --git a/lib/api/Account.ts b/lib/api/Account.ts index fd2f91be..8568d7f3 100644 --- a/lib/api/Account.ts +++ b/lib/api/Account.ts @@ -10,9 +10,11 @@ */ import { + ChangePasswordRequest, LoginRequest, LoginResponse, ProblemDetails, + RecoverAccountRequest, RegisterRequest, UserViewModel, ValidationProblemDetails, @@ -34,6 +36,7 @@ export class Account< * @response `400` `void` The request was malformed. * @response `409` `ValidationProblemDetails` A `User` with the specified username or email already exists.

Validation error codes by property: - **Username**: - **UsernameTaken**: the username is already in use - **Email**: - **EmailAlreadyUsed**: the email is already in use * @response `422` `void` The request contains errors.

Validation error codes by property: - **Username**: - **UsernameFormat**: Invalid username format - **Password**: - **PasswordFormat**: Invalid password format - **Email**: - **EmailValidator**: Invalid email format + * @response `500` `void` Server Error */ registerCreate = (data: RegisterRequest, params: RequestParams = {}) => this.request({ @@ -92,4 +95,93 @@ export class Account< secure: true, ...params, }) + /** + * No description + * + * @tags Account + * @name RecoverCreate + * @summary Sends an account recovery email. + * @request POST:/Account/recover + * @secure + * @response `200` `void` This endpoint returns 200 OK regardless of whether the email was sent successfully or not. + * @response `400` `ProblemDetails` The request object was malformed. + */ + recoverCreate = (data: RecoverAccountRequest, params: RequestParams = {}) => + this.request({ + path: `/Account/recover`, + method: 'POST', + body: data, + secure: true, + type: ContentType.Json, + ...params, + }) + /** + * No description + * + * @tags Account + * @name ConfirmUpdate + * @summary Confirms a user account. + * @request PUT:/Account/confirm/{id} + * @secure + * @response `200` `void` The account was confirmed successfully. + * @response `400` `ProblemDetails` Bad Request + * @response `404` `ProblemDetails` The token provided was invalid or expired. + * @response `409` `ProblemDetails` The user's account was either already confirmed or banned. + */ + confirmUpdate = (id: string, params: RequestParams = {}) => + this.request({ + path: `/Account/confirm/${id}`, + method: 'PUT', + secure: true, + ...params, + }) + /** + * No description + * + * @tags Account + * @name RecoverDetail + * @summary Tests an account recovery token for validity. + * @request GET:/Account/recover/{id} + * @secure + * @response `200` `void` The token provided is valid. + * @response `400` `ProblemDetails` Bad Request + * @response `404` `ProblemDetails` The token provided is invalid or expired, or the user is banned. + */ + recoverDetail = (id: string, params: RequestParams = {}) => + this.request({ + path: `/Account/recover/${id}`, + method: 'GET', + secure: true, + ...params, + }) + /** + * No description + * + * @tags Account + * @name RecoverCreate2 + * @summary Recover the user's account by resetting their password to a new value. + * @request POST:/Account/recover/{id} + * @originalName recoverCreate + * @duplicate + * @secure + * @response `200` `void` The user's password was reset successfully. + * @response `400` `ProblemDetails` Bad Request + * @response `403` `ProblemDetails` The user is banned. + * @response `404` `ProblemDetails` The token provided is invalid or expired. + * @response `409` `ProblemDetails` The new password is the same as the user's existing password. + * @response `422` `ValidationProblemDetails` The request body contains errors.
A **PasswordFormat** Validation error on the Password field indicates that the password format is invalid. + */ + recoverCreate2 = ( + id: string, + data: ChangePasswordRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/Account/recover/${id}`, + method: 'POST', + body: data, + secure: true, + type: ContentType.Json, + ...params, + }) } diff --git a/lib/api/AccountRoute.ts b/lib/api/AccountRoute.ts index b90b9893..f27622b8 100644 --- a/lib/api/AccountRoute.ts +++ b/lib/api/AccountRoute.ts @@ -10,8 +10,10 @@ */ import { + ChangePasswordRequest, LoginRequest, LoginResponse, + RecoverAccountRequest, RegisterRequest, UserViewModel, } from './data-contracts' @@ -28,6 +30,7 @@ export namespace Account { * @response `400` `void` The request was malformed. * @response `409` `ValidationProblemDetails` A `User` with the specified username or email already exists.

Validation error codes by property: - **Username**: - **UsernameTaken**: the username is already in use - **Email**: - **EmailAlreadyUsed**: the email is already in use * @response `422` `void` The request contains errors.

Validation error codes by property: - **Username**: - **UsernameFormat**: Invalid username format - **Password**: - **PasswordFormat**: Invalid password format - **Email**: - **EmailValidator**: Invalid email format + * @response `500` `void` Server Error */ export namespace RegisterCreate { export type RequestParams = {} @@ -80,4 +83,103 @@ export namespace Account { export type RequestHeaders = {} export type ResponseBody = void } + + /** + * No description + * @tags Account + * @name RecoverCreate + * @summary Sends an account recovery email. + * @request POST:/Account/recover + * @secure + * @response `200` `void` This endpoint returns 200 OK regardless of whether the email was sent successfully or not. + * @response `400` `ProblemDetails` The request object was malformed. + */ + export namespace RecoverCreate { + export type RequestParams = {} + export type RequestQuery = {} + export type RequestBody = RecoverAccountRequest + export type RequestHeaders = {} + export type ResponseBody = void + } + + /** + * No description + * @tags Account + * @name ConfirmUpdate + * @summary Confirms a user account. + * @request PUT:/Account/confirm/{id} + * @secure + * @response `200` `void` The account was confirmed successfully. + * @response `400` `ProblemDetails` Bad Request + * @response `404` `ProblemDetails` The token provided was invalid or expired. + * @response `409` `ProblemDetails` The user's account was either already confirmed or banned. + */ + export namespace ConfirmUpdate { + export type RequestParams = { + /** + * The confirmation token. + * @pattern ^[a-zA-Z0-9-_]{22}$ + */ + id: string + } + export type RequestQuery = {} + export type RequestBody = never + export type RequestHeaders = {} + export type ResponseBody = void + } + + /** + * No description + * @tags Account + * @name RecoverDetail + * @summary Tests an account recovery token for validity. + * @request GET:/Account/recover/{id} + * @secure + * @response `200` `void` The token provided is valid. + * @response `400` `ProblemDetails` Bad Request + * @response `404` `ProblemDetails` The token provided is invalid or expired, or the user is banned. + */ + export namespace RecoverDetail { + export type RequestParams = { + /** + * The recovery token. + * @pattern ^[a-zA-Z0-9-_]{22}$ + */ + id: string + } + export type RequestQuery = {} + export type RequestBody = never + export type RequestHeaders = {} + export type ResponseBody = void + } + + /** + * No description + * @tags Account + * @name RecoverCreate2 + * @summary Recover the user's account by resetting their password to a new value. + * @request POST:/Account/recover/{id} + * @originalName recoverCreate + * @duplicate + * @secure + * @response `200` `void` The user's password was reset successfully. + * @response `400` `ProblemDetails` Bad Request + * @response `403` `ProblemDetails` The user is banned. + * @response `404` `ProblemDetails` The token provided is invalid or expired. + * @response `409` `ProblemDetails` The new password is the same as the user's existing password. + * @response `422` `ValidationProblemDetails` The request body contains errors.
A **PasswordFormat** Validation error on the Password field indicates that the password format is invalid. + */ + export namespace RecoverCreate2 { + export type RequestParams = { + /** + * The recovery token. + * @pattern ^[a-zA-Z0-9-_]{22}$ + */ + id: string + } + export type RequestQuery = {} + export type RequestBody = ChangePasswordRequest + export type RequestHeaders = {} + export type ResponseBody = void + } } diff --git a/lib/api/data-contracts.ts b/lib/api/data-contracts.ts index 94e2afa5..ffad3b27 100644 --- a/lib/api/data-contracts.ts +++ b/lib/api/data-contracts.ts @@ -43,6 +43,10 @@ export interface CategoryViewModel { rules?: string | null } +export interface ChangePasswordRequest { + password: string +} + /** This request object is sent when creating a `Category`. */ export interface CreateCategoryRequest { /** @@ -186,6 +190,20 @@ export interface ProblemDetails { [key: string]: any } +export interface RecoverAccountRequest { + /** + * The user's name. + * @minLength 1 + */ + username: string + /** + * The user's email address. + * @format email + * @minLength 1 + */ + email: string +} + /** This request object is sent when a `User` is attempting to register. */ export interface RegisterRequest { /** From 84c4a56eb3924f656f8f657b3f602d2132e321f8 Mon Sep 17 00:00:00 2001 From: Edward Runkel Date: Wed, 22 Nov 2023 09:52:34 -0500 Subject: [PATCH 03/11] Create `useConfirmAccount` composable --- composables/api/index.ts | 1 + composables/api/useConfirmAccount/index.ts | 20 ++++++++++++++++ .../useConfirmAccount.test.ts | 24 +++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 composables/api/useConfirmAccount/index.ts create mode 100644 composables/api/useConfirmAccount/useConfirmAccount.test.ts diff --git a/composables/api/index.ts b/composables/api/index.ts index 9a2bba31..1010c161 100644 --- a/composables/api/index.ts +++ b/composables/api/index.ts @@ -1,3 +1,4 @@ +export { default as useConfirmAccount } from './useConfirmAccount' export { default as useGetLeaderboardDetail } from './useGetLeaderboardDetail' export { default as useGetUserDetail } from './useGetUserDetail' export { default as useLoginUser } from './useLoginUser' diff --git a/composables/api/useConfirmAccount/index.ts b/composables/api/useConfirmAccount/index.ts new file mode 100644 index 00000000..e16a79c6 --- /dev/null +++ b/composables/api/useConfirmAccount/index.ts @@ -0,0 +1,20 @@ +import { ApiResponse, optionalParameters, useApi } from 'composables/useApi' +import { Account } from 'lib/api/Account' + +export const useConfirmAccount = async ( + confirmationToken: string, + opts: optionalParameters = {}, +): Promise> => { + const { onError, onOkay } = opts + + const account = new Account({ + baseUrl: useRuntimeConfig().public.BACKEND_BASE_URL, + }) + + return await useApi( + async () => await account.confirmUpdate(confirmationToken), + { onError, onOkay }, + ) +} + +export default useConfirmAccount diff --git a/composables/api/useConfirmAccount/useConfirmAccount.test.ts b/composables/api/useConfirmAccount/useConfirmAccount.test.ts new file mode 100644 index 00000000..1c26163a --- /dev/null +++ b/composables/api/useConfirmAccount/useConfirmAccount.test.ts @@ -0,0 +1,24 @@ +import { useConfirmAccount } from '.' + +const mockSuccessAccountConfirmation = vi.fn(() => + Promise.resolve({ ok: true }), +) + +describe('useConfirmUser', () => { + describe('when everything is successful', () => { + const confirmationCode = '123' + + it('creates a PUT request to confirm the account', async () => { + vi.mock('lib/api/Account', () => ({ + Account: function Account() { + this.confirmUpdate = mockSuccessAccountConfirmation + }, + })) + + await useConfirmAccount(confirmationCode) + + expect(mockSuccessAccountConfirmation).toBeCalledTimes(1) + expect(mockSuccessAccountConfirmation).toBeCalledWith(confirmationCode) + }) + }) +}) From 8086eed38e462ee70d352995b9c4069b8500924f Mon Sep 17 00:00:00 2001 From: Edward Runkel Date: Wed, 22 Nov 2023 11:07:56 -0500 Subject: [PATCH 04/11] Create `BasicAlert` component, and add it to the default layout --- assets/sprite/svg/circle-check.svg | 1 + assets/sprite/svg/circle-exclamation.svg | 1 + assets/sprite/svg/circle-info.svg | 1 + assets/sprite/svg/triangle-exclamation.svg | 1 + components.d.ts | 4 + .../cards/BasicAlert/BasicAlert.test.ts | 60 +++++++++++ .../blocks/cards/BasicAlert/BasicAlert.vue | 99 +++++++++++++++++++ composables/useModalAlert.ts | 16 +++ layouts/default.vue | 2 + testUtils.ts | 7 ++ 10 files changed, 192 insertions(+) create mode 100644 assets/sprite/svg/circle-check.svg create mode 100644 assets/sprite/svg/circle-exclamation.svg create mode 100644 assets/sprite/svg/circle-info.svg create mode 100644 assets/sprite/svg/triangle-exclamation.svg create mode 100644 components/blocks/cards/BasicAlert/BasicAlert.test.ts create mode 100644 components/blocks/cards/BasicAlert/BasicAlert.vue create mode 100644 composables/useModalAlert.ts diff --git a/assets/sprite/svg/circle-check.svg b/assets/sprite/svg/circle-check.svg new file mode 100644 index 00000000..9ba4259b --- /dev/null +++ b/assets/sprite/svg/circle-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/sprite/svg/circle-exclamation.svg b/assets/sprite/svg/circle-exclamation.svg new file mode 100644 index 00000000..81b3768a --- /dev/null +++ b/assets/sprite/svg/circle-exclamation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/sprite/svg/circle-info.svg b/assets/sprite/svg/circle-info.svg new file mode 100644 index 00000000..652acbee --- /dev/null +++ b/assets/sprite/svg/circle-info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/sprite/svg/triangle-exclamation.svg b/assets/sprite/svg/triangle-exclamation.svg new file mode 100644 index 00000000..0a695fd2 --- /dev/null +++ b/assets/sprite/svg/triangle-exclamation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components.d.ts b/components.d.ts index f4d661fe..8bae323b 100644 --- a/components.d.ts +++ b/components.d.ts @@ -7,12 +7,16 @@ export {} declare module 'vue' { export interface GlobalComponents { + ISvgCircleCheck: typeof import('~icons/svg/circle-check')['default'] + ISvgCircleExclamation: typeof import('~icons/svg/circle-exclamation')['default'] + ISvgCircleInfo: typeof import('~icons/svg/circle-info')['default'] ISvgClock: typeof import('~icons/svg/clock')['default'] ISvgClose: typeof import('~icons/svg/close')['default'] ISvgEyeHidden: typeof import('~icons/svg/eye-hidden')['default'] ISvgEyeVisible: typeof import('~icons/svg/eye-visible')['default'] ISvgMenu: typeof import('~icons/svg/menu')['default'] ISvgSearch: typeof import('~icons/svg/search')['default'] + ISvgTriangleExclamation: typeof import('~icons/svg/triangle-exclamation')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/components/blocks/cards/BasicAlert/BasicAlert.test.ts b/components/blocks/cards/BasicAlert/BasicAlert.test.ts new file mode 100644 index 00000000..ad18ac93 --- /dev/null +++ b/components/blocks/cards/BasicAlert/BasicAlert.test.ts @@ -0,0 +1,60 @@ +import { mount, enableAutoUnmount } from '@vue/test-utils' +import { useModalAlert } from 'composables/useModalAlert' +import { getByClass, getByTestId, getHTMLElement } from 'root/testUtils' +import BasicAlert from './BasicAlert.vue' + +function getBasicAlertWrapper() { + return mount(BasicAlert) +} + +beforeEach(() => { + const modalAlertState = useModalAlert() + modalAlertState.value = { + body: 'This is a test', + show: true, + title: 'A test alert?', + type: 'info', + } +}) + +enableAutoUnmount(afterEach) + +afterEach(() => { + fetchMock.resetMocks() + vi.restoreAllMocks() +}) + +describe('', () => { + it('should render without crashing', () => { + const wrapper = getBasicAlertWrapper() + + expect(wrapper.isVisible()).toBe(true) + }) + + it('renders with the correct information', () => { + const wrapper = getBasicAlertWrapper() + + expect( + getHTMLElement(getByClass(wrapper, 'basic-modal-alert__header')) + .childElementCount, + ).toEqual(3) + expect(getByClass(wrapper, 'basic-modal-alert__title').text()).toEqual( + 'A test alert?', + ) + expect(getByClass(wrapper, 'basic-modal-alert__body').text()).toEqual( + 'This is a test', + ) + }) + + describe('when the close button is clicked', () => { + it('should emit the close event', async () => { + const wrapper = getBasicAlertWrapper() + + await getByTestId(wrapper, 'basic-modal-alert-close-button').trigger( + 'click', + ) + + expect(wrapper.isVisible()).toBe(false) + }) + }) +}) diff --git a/components/blocks/cards/BasicAlert/BasicAlert.vue b/components/blocks/cards/BasicAlert/BasicAlert.vue new file mode 100644 index 00000000..c4220729 --- /dev/null +++ b/components/blocks/cards/BasicAlert/BasicAlert.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/composables/useModalAlert.ts b/composables/useModalAlert.ts new file mode 100644 index 00000000..45dae5a0 --- /dev/null +++ b/composables/useModalAlert.ts @@ -0,0 +1,16 @@ +interface ModalAlertState { + body: string + show: boolean + title: string + type: string +} + +export const useModalAlert = () => + useState('modal_alert', () => ({ + body: '', + show: false, + title: '', + type: '', + })) + +export default useModalAlert diff --git a/layouts/default.vue b/layouts/default.vue index db30e2c9..ecf23152 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -1,10 +1,12 @@