From ba0cd3ae09c3203848314429509eae35012a4bed Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 22 Jul 2024 17:40:39 +0200 Subject: [PATCH 1/2] fix: improve auth fix: verify email params issue in update email flow fix: send email if email already used fix: auth flows --- .env.example | 1 - CODE_OF_CONDUCT.md | 2 +- e2e/admin.spec.ts | 4 - e2e/register.spec.ts | 16 ++-- e2e/utils/pageUtils.ts | 17 ++-- pnpm-lock.yaml | 6 +- prisma/schema/user.prisma | 27 ++++--- src/app/{app => }/(public-only)/layout.tsx | 0 .../(public-only)/login/[token]/page.tsx | 0 .../{app => }/(public-only)/login/page.tsx | 0 .../(public-only)/register/[token]/page.tsx | 0 .../{app => }/(public-only)/register/page.tsx | 0 src/app/admin/(authenticated)/layout.tsx | 2 +- src/app/admin/(public-only)/layout.tsx | 14 ---- .../(public-only)/login/[token]/page.tsx | 13 --- src/app/admin/(public-only)/login/page.tsx | 13 --- src/app/app/(authenticated)/layout.tsx | 2 +- .../templates/email-update-already-used.tsx | 47 +++++++++++ ...dress-change.tsx => email-update-code.tsx} | 23 +++--- .../templates/register-email-already-used.tsx | 47 +++++++++++ src/env.mjs | 9 +-- src/features/account/AccountEmailForm.tsx | 17 ++-- .../account/EmailVerificationCodeModal.tsx | 7 +- src/features/account/PageAccount.tsx | 2 +- src/features/account/schemas.ts | 18 ++++- src/features/admin/AdminNavBar.tsx | 13 ++- src/features/admin/AdminPublicOnlyLayout.tsx | 26 ------ src/features/app/AppNavBarDesktop.tsx | 2 +- src/features/auth/PageAdminLogin.tsx | 37 --------- src/features/auth/PageAdminLoginValidate.tsx | 80 ------------------- src/features/auth/PageLogin.tsx | 60 ++++++-------- src/features/auth/PageLoginValidate.tsx | 30 +++++-- src/features/auth/PageLogout.tsx | 23 +++--- src/features/auth/PageRegister.tsx | 7 +- src/features/auth/PageRegisterValidate.tsx | 19 +++-- src/features/auth/VerificationCodeForm.tsx | 24 ------ src/features/auth/routes.ts | 22 ++--- src/features/auth/schemas.ts | 6 +- src/features/users/schemas.ts | 14 +++- src/locales/ar/auth.json | 7 +- src/locales/ar/emails.json | 18 ++++- src/locales/en/auth.json | 9 +-- src/locales/en/emails.json | 18 ++++- src/locales/fr/auth.json | 7 +- src/locales/fr/emails.json | 18 ++++- src/locales/sw/auth.json | 7 +- src/locales/sw/emails.json | 18 ++++- src/server/config/trpc.ts | 2 +- src/server/routers/account.tsx | 61 +++++++------- src/server/routers/auth.tsx | 56 +++++++++++-- src/server/routers/users.ts | 6 +- 51 files changed, 451 insertions(+), 426 deletions(-) rename src/app/{app => }/(public-only)/layout.tsx (100%) rename src/app/{app => }/(public-only)/login/[token]/page.tsx (100%) rename src/app/{app => }/(public-only)/login/page.tsx (100%) rename src/app/{app => }/(public-only)/register/[token]/page.tsx (100%) rename src/app/{app => }/(public-only)/register/page.tsx (100%) delete mode 100644 src/app/admin/(public-only)/layout.tsx delete mode 100644 src/app/admin/(public-only)/login/[token]/page.tsx delete mode 100644 src/app/admin/(public-only)/login/page.tsx create mode 100644 src/emails/templates/email-update-already-used.tsx rename src/emails/templates/{email-address-change.tsx => email-update-code.tsx} (63%) create mode 100644 src/emails/templates/register-email-already-used.tsx delete mode 100644 src/features/admin/AdminPublicOnlyLayout.tsx delete mode 100644 src/features/auth/PageAdminLogin.tsx delete mode 100644 src/features/auth/PageAdminLoginValidate.tsx diff --git a/.env.example b/.env.example index 14566e2ac..612184e9e 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,6 @@ NEXT_PUBLIC_IS_DEMO="false" # DATABASE DATABASE_URL="postgres://${DOCKER_DATABASE_USERNAME}:${DOCKER_DATABASE_PASSWORD}@localhost:${DOCKER_DATABASE_PORT}/${DOCKER_DATABASE_NAME}" - # EMAILS EMAIL_SERVER="smtp://username:password@0.0.0.0:1025" EMAIL_FROM="Start UI " diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index f24b10e1e..16544caa5 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -52,7 +52,7 @@ decisions when appropriate. This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, +Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts index f901b877f..19d9299cf 100644 --- a/e2e/admin.spec.ts +++ b/e2e/admin.spec.ts @@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test'; import { pageUtils } from 'e2e/utils/pageUtils'; import { ADMIN_EMAIL } from 'e2e/utils/users'; -import { env } from '@/env.mjs'; import { ROUTES_ADMIN } from '@/features/admin/routes'; test.describe('Admin access', () => { @@ -10,9 +9,6 @@ test.describe('Admin access', () => { const utils = pageUtils(page); await utils.loginAdmin({ email: ADMIN_EMAIL }); - await page.waitForURL( - `${env.NEXT_PUBLIC_BASE_URL}${ROUTES_ADMIN.root()}**` - ); await expect(page.getByTestId('admin-layout')).toBeVisible(); }); diff --git a/e2e/register.spec.ts b/e2e/register.spec.ts index 2a9b9b8b0..0fd4e8cae 100644 --- a/e2e/register.spec.ts +++ b/e2e/register.spec.ts @@ -8,8 +8,8 @@ import locales from '@/locales'; test.describe('Register flow', () => { test('Success flow', async ({ page }) => { - await page.goto(ROUTES_AUTH.app.register()); - await page.waitForURL(`**${ROUTES_AUTH.app.register()}`); + await page.goto(ROUTES_AUTH.register()); + await page.waitForURL(`**${ROUTES_AUTH.register()}`); await page.getByLabel('Name').fill('Test user'); const email = await getRandomEmail(); @@ -18,7 +18,7 @@ test.describe('Register flow', () => { .getByRole('button', { name: locales.en.auth.register.actions.create }) .click(); - await page.waitForURL(`**${ROUTES_AUTH.app.register()}/**`); + await page.waitForURL(`**${ROUTES_AUTH.register()}/**`); await page.getByText('Verification code').fill(VALIDATION_CODE_MOCKED); await expect( page.getByText(locales.en.auth.data.verificationCode.unknown) @@ -26,8 +26,8 @@ test.describe('Register flow', () => { }); test('Register with existing email', async ({ page }) => { - await page.goto(ROUTES_AUTH.app.register()); - await page.waitForURL(`**${ROUTES_AUTH.app.register()}`); + await page.goto(ROUTES_AUTH.register()); + await page.waitForURL(`**${ROUTES_AUTH.register()}`); await page.getByLabel('Name').fill('Test user'); await page.getByLabel('Email').fill(USER_EMAIL); @@ -35,7 +35,7 @@ test.describe('Register flow', () => { .getByRole('button', { name: locales.en.auth.register.actions.create }) .click(); - await page.waitForURL(`**${ROUTES_AUTH.app.register()}/**`); + await page.waitForURL(`**${ROUTES_AUTH.register()}/**`); await page.getByText('Verification code').fill(VALIDATION_CODE_MOCKED); await expect( page.getByText(locales.en.auth.data.verificationCode.unknown) @@ -44,8 +44,8 @@ test.describe('Register flow', () => { test('Login with a not verified account', async ({ page }) => { const utils = pageUtils(page); - await page.goto(ROUTES_AUTH.app.register()); - await page.waitForURL(`**${ROUTES_AUTH.app.register()}`); + await page.goto(ROUTES_AUTH.register()); + await page.waitForURL(`**${ROUTES_AUTH.register()}`); const email = await getRandomEmail(); diff --git a/e2e/utils/pageUtils.ts b/e2e/utils/pageUtils.ts index 78c9a8e21..746c8f94f 100644 --- a/e2e/utils/pageUtils.ts +++ b/e2e/utils/pageUtils.ts @@ -2,7 +2,6 @@ import { Page } from '@playwright/test'; import { ROUTES_AUTH } from '@/features/auth/routes'; import { VALIDATION_CODE_MOCKED } from '@/features/auth/utils'; -import type { RouterInputs } from '@/lib/trpc/types'; import locales from '@/locales'; /** @@ -25,9 +24,9 @@ export const pageUtils = (page: Page) => { /** * Utility used to authenticate a user on the app */ - async loginApp(input: RouterInputs['auth']['login'] & { code?: string }) { - await page.goto(ROUTES_AUTH.app.login()); - await page.waitForURL(`**${ROUTES_AUTH.app.login()}`); + async loginApp(input: { email: string; code?: string }) { + await page.goto(ROUTES_AUTH.login()); + await page.waitForURL(`**${ROUTES_AUTH.login()}`); await page .getByPlaceholder(locales.en.auth.data.email.label) @@ -36,7 +35,7 @@ export const pageUtils = (page: Page) => { .getByRole('button', { name: locales.en.auth.login.actions.login }) .click(); - await page.waitForURL(`**${ROUTES_AUTH.app.login()}/**`); + await page.waitForURL(`**${ROUTES_AUTH.login()}/**`); await page .getByText('Verification code') .fill(input.code ?? VALIDATION_CODE_MOCKED); @@ -45,9 +44,9 @@ export const pageUtils = (page: Page) => { /** * Utility used to authenticate an admin on the app */ - async loginAdmin(input: RouterInputs['auth']['login'] & { code?: string }) { - await page.goto(ROUTES_AUTH.admin.login()); - await page.waitForURL(`**${ROUTES_AUTH.admin.login()}`); + async loginAdmin(input: { email: string; code?: string }) { + await page.goto(ROUTES_AUTH.login()); + await page.waitForURL(`**${ROUTES_AUTH.login()}`); await page .getByPlaceholder(locales.en.auth.data.email.label) @@ -56,7 +55,7 @@ export const pageUtils = (page: Page) => { .getByRole('button', { name: locales.en.auth.login.actions.login }) .click(); - await page.waitForURL(`**${ROUTES_AUTH.admin.login()}/**`); + await page.waitForURL(`**${ROUTES_AUTH.login()}/**`); await page .getByText('Verification code') .fill(input.code ?? VALIDATION_CODE_MOCKED); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc26dce87..5c3e37b90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,7 +79,7 @@ importers: version: 5.1.1(encoding@0.1.13) chakra-react-select: specifier: 4.9.1 - version: 4.9.1(jg2uf7jai25oxrvhvqjn25d64i) + version: 4.9.1(@chakra-ui/form-control@2.2.0(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/icon@3.2.0(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/layout@2.3.1(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/media-query@3.3.0(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/menu@2.2.1(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(framer-motion@11.2.14(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/spinner@2.1.0(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) colorette: specifier: 2.0.20 version: 2.0.20 @@ -16228,8 +16228,8 @@ snapshots: loupe: 3.1.1 pathval: 2.0.0 - chakra-react-select@4.9.1(jg2uf7jai25oxrvhvqjn25d64i): - dependencies: + ? chakra-react-select@4.9.1(@chakra-ui/form-control@2.2.0(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/icon@3.2.0(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/layout@2.3.1(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/media-query@3.3.0(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/menu@2.2.1(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(framer-motion@11.2.14(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/spinner@2.1.0(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1))(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + : dependencies: '@chakra-ui/form-control': 2.2.0(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1) '@chakra-ui/icon': 3.2.0(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1) '@chakra-ui/layout': 2.3.1(@chakra-ui/system@2.6.2(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1) diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma index 99278bb0b..04d7031f2 100644 --- a/prisma/schema/user.prisma +++ b/prisma/schema/user.prisma @@ -1,9 +1,9 @@ enum AccountStatus { - // User has registered and verified their email + // User has registered and verified ENABLED // User has been disabled and can't login anymore DISABLED - // User did register, but has not verified their email yet. + // User did register, but has not been verified NOT_VERIFIED } @@ -13,15 +13,16 @@ enum UserRole { } model User { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - name String? - email String @unique - accountStatus AccountStatus @default(NOT_VERIFIED) - image String? - authorizations UserRole[] @default([APP]) - language String @default("en") - lastLoginAt DateTime? - session Session[] + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String? + email String? @unique + isEmailVerified Boolean @default(false) + accountStatus AccountStatus @default(NOT_VERIFIED) + image String? + authorizations UserRole[] @default([APP]) + language String @default("en") + lastLoginAt DateTime? + session Session[] } diff --git a/src/app/app/(public-only)/layout.tsx b/src/app/(public-only)/layout.tsx similarity index 100% rename from src/app/app/(public-only)/layout.tsx rename to src/app/(public-only)/layout.tsx diff --git a/src/app/app/(public-only)/login/[token]/page.tsx b/src/app/(public-only)/login/[token]/page.tsx similarity index 100% rename from src/app/app/(public-only)/login/[token]/page.tsx rename to src/app/(public-only)/login/[token]/page.tsx diff --git a/src/app/app/(public-only)/login/page.tsx b/src/app/(public-only)/login/page.tsx similarity index 100% rename from src/app/app/(public-only)/login/page.tsx rename to src/app/(public-only)/login/page.tsx diff --git a/src/app/app/(public-only)/register/[token]/page.tsx b/src/app/(public-only)/register/[token]/page.tsx similarity index 100% rename from src/app/app/(public-only)/register/[token]/page.tsx rename to src/app/(public-only)/register/[token]/page.tsx diff --git a/src/app/app/(public-only)/register/page.tsx b/src/app/(public-only)/register/page.tsx similarity index 100% rename from src/app/app/(public-only)/register/page.tsx rename to src/app/(public-only)/register/page.tsx diff --git a/src/app/admin/(authenticated)/layout.tsx b/src/app/admin/(authenticated)/layout.tsx index b8c020f01..ebf64b802 100644 --- a/src/app/admin/(authenticated)/layout.tsx +++ b/src/app/admin/(authenticated)/layout.tsx @@ -13,7 +13,7 @@ export default function AuthenticatedLayout({ {children} diff --git a/src/app/admin/(public-only)/layout.tsx b/src/app/admin/(public-only)/layout.tsx deleted file mode 100644 index 04b1a9f23..000000000 --- a/src/app/admin/(public-only)/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { ReactNode, Suspense } from 'react'; - -import { AdminPublicOnlyLayout } from '@/features/admin/AdminPublicOnlyLayout'; -import { GuardPublicOnly } from '@/features/auth/GuardPublicOnly'; - -export default function PublicLayout({ children }: { children: ReactNode }) { - return ( - - - {children} - - - ); -} diff --git a/src/app/admin/(public-only)/login/[token]/page.tsx b/src/app/admin/(public-only)/login/[token]/page.tsx deleted file mode 100644 index ba8ffc644..000000000 --- a/src/app/admin/(public-only)/login/[token]/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -'use client'; - -import { Suspense } from 'react'; - -import PageAdminLoginValidate from '@/features/auth/PageAdminLoginValidate'; - -export default function Page() { - return ( - - - - ); -} diff --git a/src/app/admin/(public-only)/login/page.tsx b/src/app/admin/(public-only)/login/page.tsx deleted file mode 100644 index 68699a9c4..000000000 --- a/src/app/admin/(public-only)/login/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -'use client'; - -import { Suspense } from 'react'; - -import PageAdminLogin from '@/features/auth/PageAdminLogin'; - -export default function Page() { - return ( - - - - ); -} diff --git a/src/app/app/(authenticated)/layout.tsx b/src/app/app/(authenticated)/layout.tsx index 9067ab9ae..4f6eac9b1 100644 --- a/src/app/app/(authenticated)/layout.tsx +++ b/src/app/app/(authenticated)/layout.tsx @@ -13,7 +13,7 @@ export default function AuthenticatedLayout({ {children} diff --git a/src/emails/templates/email-update-already-used.tsx b/src/emails/templates/email-update-already-used.tsx new file mode 100644 index 000000000..7162cc1a3 --- /dev/null +++ b/src/emails/templates/email-update-already-used.tsx @@ -0,0 +1,47 @@ +import { Container, Heading, Section, Text } from '@react-email/components'; + +import { Footer } from '@/emails/components/Footer'; +import { Layout } from '@/emails/components/Layout'; +import { styles } from '@/emails/styles'; +import i18n from '@/lib/i18n/server'; + +type EmailUpdateAlreadyUsedProps = { + language: string; + name: string; + email: string; +}; + +export const EmailUpdateAlreadyUsed = ({ + language, + name, + email, +}: EmailUpdateAlreadyUsedProps) => { + i18n.changeLanguage(language); + return ( + + + + {i18n.t('emails:emailUpdateAlreadyUsed.title')} + +
+ + {i18n.t('emails:emailUpdateAlreadyUsed.hello', { + name, + })} +
+ {i18n.t('emails:emailUpdateAlreadyUsed.intro', { email })} +
+ + {i18n.t('emails:emailUpdateAlreadyUsed.ignoreHelper')} + +
+