diff --git a/.env b/.env index 644eec4..136c492 100644 --- a/.env +++ b/.env @@ -1,3 +1,7 @@ +NEXT_PUBLIC_API_BASE_URL= NEXT_PUBLIC_DOMAIN= -NEXT_PUBLIC_GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +NEXTAUTH_SECRET= +NEXTAUTH_URL= NEXT_ANALYZE=false diff --git a/package.json b/package.json index f5ca674..c80e4f8 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ "pnpm": "^8.7.0" }, "dependencies": { + "@hookform/resolvers": "^3.3.2", "@phosphor-icons/react": "^2.0.14", - "@react-oauth/google": "^0.11.1", "@tw-classed/react": "^1.6.1", "@types/node": "18.15.11", "@types/react": "18.0.32", @@ -33,11 +33,15 @@ "axios": "^1.6.1", "clsx": "^2.0.0", "next": "13.4.19", + "next-auth": "^4.24.5", "polished": "^4.2.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.48.2", + "swr": "^2.2.4", "tailwindcss": "0.0.0-insiders.803d7b5", "typescript": "5.0.3", + "yup": "^1.3.2", "zustand": "^4.4.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e41143..eadb5bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,12 +5,12 @@ settings: excludeLinksFromLockfile: false dependencies: + '@hookform/resolvers': + specifier: ^3.3.2 + version: 3.3.2(react-hook-form@7.48.2) '@phosphor-icons/react': specifier: ^2.0.14 version: 2.0.14(react-dom@18.2.0)(react@18.2.0) - '@react-oauth/google': - specifier: ^0.11.1 - version: 0.11.1(react-dom@18.2.0)(react@18.2.0) '@tw-classed/react': specifier: ^1.6.1 version: 1.6.1(react@18.2.0) @@ -32,6 +32,9 @@ dependencies: next: specifier: 13.4.19 version: 13.4.19(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0) + next-auth: + specifier: ^4.24.5 + version: 4.24.5(next@13.4.19)(react-dom@18.2.0)(react@18.2.0) polished: specifier: ^4.2.2 version: 4.2.2 @@ -41,12 +44,21 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.48.2 + version: 7.48.2(react@18.2.0) + swr: + specifier: ^2.2.4 + version: 2.2.4(react@18.2.0) tailwindcss: specifier: 0.0.0-insiders.803d7b5 version: 0.0.0-insiders.803d7b5 typescript: specifier: 5.0.3 version: 5.0.3 + yup: + specifier: ^1.3.2 + version: 1.3.2 zustand: specifier: ^4.4.6 version: 4.4.6(@types/react@18.0.32)(react@18.2.0) @@ -1838,6 +1850,14 @@ packages: resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} dev: true + /@hookform/resolvers@3.3.2(react-hook-form@7.48.2): + resolution: {integrity: sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==} + peerDependencies: + react-hook-form: ^7.0.0 + dependencies: + react-hook-form: 7.48.2(react@18.2.0) + dev: false + /@humanwhocodes/config-array@0.11.13: resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} engines: {node: '>=10.10.0'} @@ -2114,6 +2134,10 @@ packages: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 + /@panva/hkdf@1.1.1: + resolution: {integrity: sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==} + dev: false + /@phosphor-icons/react@2.0.14(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-VaZ7/JEQ7dW+Up23l7t6lqJ3dPJupM03916Pat+ZOLX1vex9OeX9t8RZLJWt0oVrdc/GcrAyRD5FESDeP+M4tQ==} engines: {node: '>=10'} @@ -2731,16 +2755,6 @@ packages: '@babel/runtime': 7.23.2 dev: true - /@react-oauth/google@0.11.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-tywZisXbsdaRBVbEu0VX6dRbOSL2I6DgY97woq5NMOOOz+xtDsm418vqq+Vx10KMtra3kdHMRMf0hXLWrk2RMg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - dependencies: - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - /@rushstack/eslint-patch@1.5.1: resolution: {integrity: sha512-6i/8UoL0P5y4leBIGzvkZdS85RDMG9y1ihZzmTZQ5LdHUYmZ7pKFoj8X0236s3lusPs1Fa5HTQUpwI+UfTcmeA==} dev: true @@ -5870,7 +5884,6 @@ packages: /cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} - dev: true /core-js-compat@3.33.2: resolution: {integrity: sha512-axfo+wxFVxnqf8RvxTzoAlzW4gRoacrHeoFlc9n0x50+7BEyZL/Rt3hicaED1/CEd7I6tPCPVUYcJwCMO5XUYw==} @@ -8330,6 +8343,10 @@ packages: resolution: {integrity: sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==} hasBin: true + /jose@4.15.4: + resolution: {integrity: sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==} + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -8739,7 +8756,6 @@ packages: engines: {node: '>=10'} dependencies: yallist: 4.0.0 - dev: true /lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} @@ -9019,6 +9035,31 @@ packages: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} dev: true + /next-auth@4.24.5(next@13.4.19)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-3RafV3XbfIKk6rF6GlLE4/KxjTcuMCifqrmD+98ejFq73SRoj2rmzoca8u764977lH/Q7jo6Xu6yM+Re1Mz/Og==} + peerDependencies: + next: ^12.2.5 || ^13 || ^14 + nodemailer: ^6.6.5 + react: ^17.0.2 || ^18 + react-dom: ^17.0.2 || ^18 + peerDependenciesMeta: + nodemailer: + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@panva/hkdf': 1.1.1 + cookie: 0.5.0 + jose: 4.15.4 + next: 13.4.19(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0) + oauth: 0.9.15 + openid-client: 5.6.1 + preact: 10.19.2 + preact-render-to-string: 5.2.6(preact@10.19.2) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + uuid: 8.3.2 + dev: false + /next@13.4.19(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-HuPSzzAbJ1T4BD8e0bs6B9C1kWQ6gv8ykZoRWs5AQoiIuqbGHHdQO7Ljuvg05Q0Z24E2ABozHe6FxDvI6HfyAw==} engines: {node: '>=16.8.0'} @@ -9171,10 +9212,19 @@ packages: boolbase: 1.0.0 dev: true + /oauth@0.9.15: + resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} + dev: false + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + /object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + dev: false + /object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -9254,6 +9304,11 @@ packages: resolution: {integrity: sha512-eJJDYkhJFFbBBAxeh8xW+weHlkI28n2ZdQV/J/DNfWfSKlGEf2xcfAbZTv3riEXHAhL9SVOTs2pRmXiSTf78xg==} dev: true + /oidc-token-hash@5.0.3: + resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} + engines: {node: ^10.13.0 || >=12.0.0} + dev: false + /on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -9294,6 +9349,15 @@ packages: is-wsl: 2.2.0 dev: true + /openid-client@5.6.1: + resolution: {integrity: sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==} + dependencies: + jose: 4.15.4 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.0.3 + dev: false + /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -9722,6 +9786,19 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 + /preact-render-to-string@5.2.6(preact@10.19.2): + resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} + peerDependencies: + preact: '>=10' + dependencies: + preact: 10.19.2 + pretty-format: 3.8.0 + dev: false + + /preact@10.19.2: + resolution: {integrity: sha512-UA9DX/OJwv6YwP9Vn7Ti/vF80XL+YA5H2l7BpCtUr3ya8LWHFzpiO5R+N7dN16ujpIxhekRFuOOF82bXX7K/lg==} + dev: false + /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -9810,6 +9887,10 @@ packages: react-is: 17.0.2 dev: true + /pretty-format@3.8.0: + resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + dev: false + /pretty-hrtime@1.0.3: resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==} engines: {node: '>= 0.8'} @@ -9845,6 +9926,10 @@ packages: react-is: 16.13.1 dev: true + /property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + dev: false + /proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -10047,6 +10132,15 @@ packages: react-is: 18.1.0 dev: true + /react-hook-form@7.48.2(react@18.2.0): + resolution: {integrity: sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + /react-inspector@6.0.2(react@18.2.0): resolution: {integrity: sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==} peerDependencies: @@ -10983,6 +11077,16 @@ packages: webpack: 5.89.0(@swc/core@1.3.95)(esbuild@0.18.20) dev: true + /swr@2.2.4(react@18.2.0): + resolution: {integrity: sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + dependencies: + client-only: 0.0.1 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false + /synchronous-promise@2.0.17: resolution: {integrity: sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==} dev: true @@ -11165,6 +11269,10 @@ packages: setimmediate: 1.0.5 dev: true + /tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + dev: false + /tiny-invariant@1.3.1: resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} dev: true @@ -11199,6 +11307,10 @@ packages: engines: {node: '>=0.6'} dev: true + /toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + dev: false + /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: true @@ -11311,7 +11423,6 @@ packages: /type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - dev: true /type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} @@ -11553,6 +11664,11 @@ packages: engines: {node: '>= 0.4.0'} dev: true + /uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + dev: false + /uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -11826,7 +11942,6 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - dev: true /yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} @@ -11872,6 +11987,15 @@ packages: engines: {node: '>=12.20'} dev: true + /yup@1.3.2: + resolution: {integrity: sha512-6KCM971iQtJ+/KUaHdrhVr2LDkfhBtFPRnsG1P8F4q3uUVQ2RfEM9xekpha9aA4GXWJevjM10eDcPQ1FfWlmaQ==} + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + dev: false + /zod@3.21.4: resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} diff --git a/src/app/(authorized)/layout.tsx b/src/app/(authorized)/layout.tsx index a3acbd7..57354c4 100644 --- a/src/app/(authorized)/layout.tsx +++ b/src/app/(authorized)/layout.tsx @@ -1,8 +1,12 @@ +'use client'; + import { PropsWithChildren } from 'react'; +import RoleEnum from 'domain/entity/RoleEnum'; import Sidebar from 'presentation/component/layout/Sidebar'; import AuthorizedHeader from 'presentation/component/layout/AuthorizedHeader'; +import createAuthorizedLayout from 'presentation/component/layout/AuthorizedLayout'; -export default function AuthorizedLayout(props: PropsWithChildren) { +const BaseAuthorizedLayout = (props: PropsWithChildren) => { const { children } = props; return ( @@ -14,4 +18,8 @@ export default function AuthorizedLayout(props: PropsWithChildren) { ); -} +}; + +export default createAuthorizedLayout(BaseAuthorizedLayout, { + roles: [RoleEnum.User], +}); diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..8656176 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,13 @@ +import NextAuth from 'next-auth'; +import GoogleProvider from 'next-auth/providers/google'; + +const handler = NextAuth({ + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID ?? '', + clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '', + }), + ], +}); + +export { handler as GET, handler as POST }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 927cc0f..5df0715 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren } from 'react'; import { Inter, Kodchasan } from 'next/font/google'; import clsx from 'clsx'; -import GoogleOAuthProvider from './providers/GoogleOAuthProvider'; +import AuthProvider from './providers/AuthProvider'; import './globals.css'; const kodchasan = Kodchasan({ subsets: ['latin'], variable: '--font-title', weight: ['600'] }); @@ -18,9 +18,9 @@ export default function RootLayout(props: PropsWithChildren) { return ( - +
{children}
-
+ ); diff --git a/src/app/providers/AuthProvider/index.tsx b/src/app/providers/AuthProvider/index.tsx new file mode 100644 index 0000000..6451c1f --- /dev/null +++ b/src/app/providers/AuthProvider/index.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { ReactNode } from 'react'; +import { SessionProvider } from 'next-auth/react'; + +export default function AuthProvider({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/app/providers/GoogleOAuthProvider/index.tsx b/src/app/providers/GoogleOAuthProvider/index.tsx deleted file mode 100644 index 546395a..0000000 --- a/src/app/providers/GoogleOAuthProvider/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -'use client'; - -import { GoogleOAuthProvider as BaseGoogleOAuthProvider } from '@react-oauth/google'; -import { GOOGLE_CLIENT_ID } from 'constant/env'; - -export default function GoogleOAuthProvider({ children }: { children: React.ReactNode }) { - return ( - {children} - ); -} diff --git a/src/app/(login)/login/page.tsx b/src/app/sign-in/page.tsx similarity index 100% rename from src/app/(login)/login/page.tsx rename to src/app/sign-in/page.tsx diff --git a/src/constant/env.ts b/src/constant/env.ts index d8fe56b..9d562d7 100644 --- a/src/constant/env.ts +++ b/src/constant/env.ts @@ -1,2 +1,3 @@ export const IS_PRODUCTION = process.env.NODE_ENV === 'production'; +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; export const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? ''; diff --git a/src/constant/httpCode.ts b/src/constant/httpCode.ts new file mode 100644 index 0000000..b236365 --- /dev/null +++ b/src/constant/httpCode.ts @@ -0,0 +1,5 @@ +export const enum HttpCode { + Ok = 200, + Unauthorized = 401, + Forbidden = 403, +} diff --git a/src/constant/route.ts b/src/constant/route.ts index ffa0db1..1c909ef 100644 --- a/src/constant/route.ts +++ b/src/constant/route.ts @@ -1,12 +1,12 @@ +export const SIGN_UP = '/sign-up'; +export const SIGN_IN = '/sign-in'; + export const HOME = '/'; export const NEWS = '/news'; export const DASHBOARD = '/dashboard'; export const STUDY = '/study'; export const SETTINGS = '/settings'; -export const SIGN_UP = '/sign-up'; -export const SIGN_IN = '/sign-in'; - /** * Dashboard sidebar items */ @@ -26,3 +26,11 @@ export const DICTIONARY = `${DASHBOARD}/dictionary`; export const PROFILE = `${SETTINGS}/profile`; export const SECURITY = `${SETTINGS}/security`; export const ADVANCED = `${SETTINGS}/advanced`; + +/** + * API routes + */ +const USER = (url: string) => `/user/${url}`; +export const GET_USER = USER(''); +export const USER_LOGIN = USER('login'); +export const USER_REFRESH = USER('refresh'); diff --git a/src/data/driver/ApiClient/AbstractApiClient.ts b/src/data/driver/ApiClient/AbstractApiClient.ts new file mode 100644 index 0000000..39bd827 --- /dev/null +++ b/src/data/driver/ApiClient/AbstractApiClient.ts @@ -0,0 +1,26 @@ +import type { AxiosInstance } from 'axios'; +import REST from './REST'; + +export default abstract class AbstractApiClient { + public abstract rest: REST; + + private accessToken?: string; + + public setAccessToken(token?: string) { + this.accessToken = token; + } + + public getAccessToken() { + return this.accessToken; + } + + protected useCredentialsInterceptor(client: AxiosInstance) { + client.interceptors.request.use((request) => { + if (this.accessToken && request.headers) { + request.headers.Authorization = `Bearer ${this.accessToken}`; + } + + return request; + }, Promise.reject); + } +} diff --git a/src/data/driver/ApiClient/FrontendApiClient.ts b/src/data/driver/ApiClient/FrontendApiClient.ts new file mode 100644 index 0000000..8d96d36 --- /dev/null +++ b/src/data/driver/ApiClient/FrontendApiClient.ts @@ -0,0 +1,14 @@ +import { API_BASE_URL } from 'constant/env'; +import AbstractApiClient from './AbstractApiClient'; +import REST from './REST'; + +export default class FrontendApiClient extends AbstractApiClient { + public readonly rest: REST; + + constructor() { + super(); + this.rest = new REST(`${API_BASE_URL}/api`); + + this.useCredentialsInterceptor(this.rest.client); + } +} diff --git a/src/data/driver/ApiClient/REST.ts b/src/data/driver/ApiClient/REST.ts new file mode 100644 index 0000000..e6bb33f --- /dev/null +++ b/src/data/driver/ApiClient/REST.ts @@ -0,0 +1,13 @@ +import AbstractAxiosClient from './axios'; + +export default class REST extends AbstractAxiosClient { + public post = this._client.post; + + public postForm = this._client.postForm; + + public get = this._client.get; + + public delete = this._client.delete; + + public patch = this._client.patch; +} diff --git a/src/data/driver/ApiClient/axios.ts b/src/data/driver/ApiClient/axios.ts new file mode 100644 index 0000000..d4acaf5 --- /dev/null +++ b/src/data/driver/ApiClient/axios.ts @@ -0,0 +1,32 @@ +import axios, { AxiosInstance } from 'axios'; + +const TIMEOUT_ERROR_MESSAGE = 'TimeoutError'; + +export default abstract class AbstractAxiosClient { + protected readonly _client: AxiosInstance; + + public constructor(apiBaseURL: string) { + this._client = axios.create({ + baseURL: apiBaseURL, + timeout: 30000, + timeoutErrorMessage: TIMEOUT_ERROR_MESSAGE, + }); + + this.useTimeoutErrorInterceptor(); + } + + private useTimeoutErrorInterceptor(): void { + this._client.interceptors.response.use(undefined, (error) => { + if (error?.message === TIMEOUT_ERROR_MESSAGE) { + // eslint-disable-next-line no-console + console.log(JSON.stringify({ TIMEOUT_ERROR_MESSAGE, error }, null, 2)); + } + + throw error; + }); + } + + public get client(): AxiosInstance { + return this._client; + } +} diff --git a/src/data/driver/ApiClient/frontend.ts b/src/data/driver/ApiClient/frontend.ts new file mode 100644 index 0000000..712839d --- /dev/null +++ b/src/data/driver/ApiClient/frontend.ts @@ -0,0 +1,3 @@ +import FrontendApiClient from './FrontendApiClient'; + +export const frontendApiClient = new FrontendApiClient(); diff --git a/src/data/driver/validation/auth/messages.ts b/src/data/driver/validation/auth/messages.ts new file mode 100644 index 0000000..1faa1c4 --- /dev/null +++ b/src/data/driver/validation/auth/messages.ts @@ -0,0 +1,7 @@ +import getStringByNumber from 'helper/string/getStringByNumber'; + +export const requiredField = 'Поле обязательно для заполнения'; +export const minSymbols = ({ min }: { min: number }) => + `Минимум ${min} ${getStringByNumber(min, ['символ', 'символа', 'символов'])}`; +export const maxSymbols = ({ max }: { max: number }) => + `Максимум ${max} ${getStringByNumber(max, ['символ', 'символа', 'символов'])}`; diff --git a/src/data/driver/validation/auth/schema.ts b/src/data/driver/validation/auth/schema.ts new file mode 100644 index 0000000..8d5b205 --- /dev/null +++ b/src/data/driver/validation/auth/schema.ts @@ -0,0 +1,24 @@ +import { object, ObjectSchema, string } from 'yup'; +import { LoginForm } from './types'; +import { requiredField } from './messages'; + +const emailValidationSchema = string().required(requiredField); +const passwordOptionalValidationSchema = string() + .matches( + /^[\w#?!@$%^&*-]+$|^$/i, + 'Password can only contain Latin letters, numbers and special characters', + ) + .test('min-password-length', 'The password must contain at least 4 characters', (value) => { + if (!value) { + return true; + } + + return value.length >= 4 || value.length === 0; + }) + .defined(); +const passwordValidationSchema = passwordOptionalValidationSchema.required(requiredField); + +export const loginFormValidationSchema: ObjectSchema = object({ + email: emailValidationSchema, + password: passwordValidationSchema, +}); diff --git a/src/data/driver/validation/auth/types.ts b/src/data/driver/validation/auth/types.ts new file mode 100644 index 0000000..85a202f --- /dev/null +++ b/src/data/driver/validation/auth/types.ts @@ -0,0 +1,4 @@ +export interface LoginForm { + email: string; + password: string; +} diff --git a/src/domain/entity/RoleEnum.ts b/src/domain/entity/RoleEnum.ts new file mode 100644 index 0000000..52e3fff --- /dev/null +++ b/src/domain/entity/RoleEnum.ts @@ -0,0 +1,7 @@ +const enum RoleEnum { + Visitor = 'visitor', + User = 'user', + Admin = 'admin', +} + +export default RoleEnum; diff --git a/src/domain/entity/User.ts b/src/domain/entity/User.ts index 9375e1d..c390fd8 100644 --- a/src/domain/entity/User.ts +++ b/src/domain/entity/User.ts @@ -1,11 +1,14 @@ +import RoleEnum from './RoleEnum'; + export default class User { // eslint-disable-next-line @typescript-eslint/no-explicit-any public static Hydrate(data: any): User { try { return new User( - data.id ?? NaN, + data.id ?? '', data.name ?? '', data.avatar ?? '', + data.visitor ?? [RoleEnum.Visitor], data.lvl ?? '', data.location ?? '', data.email ?? '', @@ -19,13 +22,14 @@ export default class User { } public static CreateEmpty(): User { - return new User(NaN, '', '', '', '', '', NaN, NaN, NaN); + return new User('', '', '', [RoleEnum.Visitor], '', '', '', NaN, NaN, NaN); } constructor( - public readonly id: number, + public readonly id: string, public readonly name: string, public readonly avatar: string, + public readonly roles: RoleEnum[], public readonly lvl: string, public readonly location: string, public readonly email: string, diff --git a/src/domain/service/auth/useLoginSWRMutation.ts b/src/domain/service/auth/useLoginSWRMutation.ts new file mode 100644 index 0000000..6d2317a --- /dev/null +++ b/src/domain/service/auth/useLoginSWRMutation.ts @@ -0,0 +1,40 @@ +import { GET_USER, USER_LOGIN } from 'constant/route'; +import type User from 'domain/entity/User'; +import { frontendApiClient } from 'data/driver/ApiClient/frontend'; +import useUserStore from 'domain/store/user/useUserStore'; +import useCustomSWRMutation from 'domain/service/useCustomSWRMutation'; + +interface LoginPayload { + email: string; + password: string; +} + +interface LoginResponse { + token: string; +} + +const useLoginSWRMutation = () => { + const setUser = useUserStore((state) => state.setUser); + const { trigger, ...restProps } = useCustomSWRMutation(USER_LOGIN); + + return { + ...restProps, + trigger: async (loginPayload: LoginPayload) => { + const loginResponse = await trigger(loginPayload); + const { headers } = loginResponse; + const { authorization } = headers; + frontendApiClient.setAccessToken(authorization); + const { data: user } = await frontendApiClient.rest.get(GET_USER); + + if (user) { + setUser(user); + + return; + } + + frontendApiClient.setAccessToken(undefined); + }, + }; +}; + +export default useLoginSWRMutation; diff --git a/src/domain/service/auth/useUserRefreshTokenSWR.ts b/src/domain/service/auth/useUserRefreshTokenSWR.ts new file mode 100644 index 0000000..c9c7e0d --- /dev/null +++ b/src/domain/service/auth/useUserRefreshTokenSWR.ts @@ -0,0 +1,31 @@ +import useSWRMutation from 'swr/mutation'; +import { GET_USER, USER_REFRESH } from 'constant/route'; +import User from 'domain/entity/User'; +import useUserStore from 'domain/store/user/useUserStore'; +import { frontendApiClient } from 'data/driver/ApiClient/frontend'; + +interface RefreshResponse { + token: string; +} + +export function useUserRefreshTokenSWR() { + const { setUser, getIsAuthorized } = useUserStore((state) => state); + const isAuthorized = getIsAuthorized(); + + return useSWRMutation( + () => (isAuthorized ? null : USER_REFRESH), + async (baseUrl) => { + const response = await frontendApiClient.rest.post(baseUrl); + + const { headers } = response; + const { authorization } = headers; + + frontendApiClient.setAccessToken(authorization); + + const { data } = await frontendApiClient.rest.get(GET_USER); + + setUser(data); + }, + // TODO: error handling + ); +} diff --git a/src/domain/service/useCustomSWRMutation.ts b/src/domain/service/useCustomSWRMutation.ts new file mode 100644 index 0000000..0133a73 --- /dev/null +++ b/src/domain/service/useCustomSWRMutation.ts @@ -0,0 +1,14 @@ +import { AxiosResponse } from 'axios'; +import useSWRMutation from 'swr/mutation'; +import { frontendApiClient } from 'data/driver/ApiClient/frontend'; + +const useCustomSWRMutation = (url: string) => { + return useSWRMutation, unknown, string, Input>( + url, + async (baseUrl, options) => { + return frontendApiClient.rest.post(baseUrl, options.arg); + }, + ); +}; + +export default useCustomSWRMutation; diff --git a/src/domain/store/user/mock.ts b/src/domain/store/user/mock.ts index 86bb5b2..10dcb04 100644 --- a/src/domain/store/user/mock.ts +++ b/src/domain/store/user/mock.ts @@ -1,10 +1,12 @@ +import RoleEnum from 'domain/entity/RoleEnum'; import User from 'domain/entity/User'; import avatar from 'presentation/image/account/avatar.jpg'; export const MOCK_USER = new User( - 0, + '', 'Username', avatar.src, + [RoleEnum.Visitor], 'N2', 'AZ, Tucson', 'ewing-meghan79@outlook.com', diff --git a/src/domain/store/user/useUserStore.ts b/src/domain/store/user/useUserStore.ts index 1d3ff3b..36522af 100644 --- a/src/domain/store/user/useUserStore.ts +++ b/src/domain/store/user/useUserStore.ts @@ -1,21 +1,23 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import User from 'domain/entity/User'; +import RoleEnum from 'domain/entity/RoleEnum'; import { MOCK_USER } from './mock'; type UserState = { user: User; setUser: (user: User) => void; + getIsAuthorized: () => boolean; }; const useUserStore = create()( - devtools( - (set) => ({ - user: MOCK_USER, - setUser: (user) => set(() => ({ user })), - }), - { name: 'user-storage' }, - ), + devtools((set, getState) => ({ + user: MOCK_USER, + setUser: (user) => set(() => ({ user })), + getIsAuthorized: () => { + return getState().user.id !== '' || !getState().user.roles.includes(RoleEnum.Visitor); + }, + })), ); export default useUserStore; diff --git a/src/helper/string/getStringByNumber.ts b/src/helper/string/getStringByNumber.ts new file mode 100644 index 0000000..9577af0 --- /dev/null +++ b/src/helper/string/getStringByNumber.ts @@ -0,0 +1,11 @@ +type StringTuple = [one: string, few: string, many: string]; + +export default function getStringByNumber( + number: number, + strings: StringTuple | Readonly, +): string { + const cases = [2, 0, 1, 1, 1, 2]; + const stringIndex = number % 100 > 4 && number % 100 < 20 ? 2 : cases[Math.min(number % 10, 5)]; + + return strings[stringIndex]; +} diff --git a/src/presentation/component/layout/AuthorizedLayout/index.tsx b/src/presentation/component/layout/AuthorizedLayout/index.tsx new file mode 100644 index 0000000..3389103 --- /dev/null +++ b/src/presentation/component/layout/AuthorizedLayout/index.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { FC, PropsWithChildren, useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { SIGN_IN } from 'constant/route'; +import RoleEnum from 'domain/entity/RoleEnum'; +import { useUserRefreshTokenSWR } from 'domain/service/auth/useUserRefreshTokenSWR'; +import useUserStore from 'domain/store/user/useUserStore'; + +type OptionsT = { + roles: RoleEnum[]; +}; + +type PropsT = PropsWithChildren & OptionsT; + +const AuthorizedLayout: FC = (props) => { + const { children, roles } = props; + const { user, getIsAuthorized } = useUserStore((state) => state); + const isAuthorized = getIsAuthorized(); + const isPageAllowedForCurrentUser = user.roles.some((role) => roles.includes(role)); + const { isMutating, trigger } = useUserRefreshTokenSWR(); + const isFirstRender = useRef(true); + const [isLoading, setIsLoading] = useState(!isAuthorized); + + useEffect(() => { + if (!isFirstRender.current || isAuthorized) { + return; + } + + isFirstRender.current = false; + + trigger().finally(() => setIsLoading(false)); + }, []); + + if (isLoading || isMutating) { + return <>Loading...; + } + + if (isPageAllowedForCurrentUser) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{children}; + } + + if (isAuthorized) { + return <>Unavailable; + } + + return Log in; +}; + +const createAuthorizedLayout = (Component: FC, options: OptionsT) => { + return (props: T) => ( + + + + ); +}; + +export default createAuthorizedLayout; diff --git a/src/presentation/component/layout/Sidebar/Account/index.tsx b/src/presentation/component/layout/Sidebar/Account/index.tsx index 23d4b63..463805c 100644 --- a/src/presentation/component/layout/Sidebar/Account/index.tsx +++ b/src/presentation/component/layout/Sidebar/Account/index.tsx @@ -1,10 +1,9 @@ import { FC } from 'react'; -import useUserStore from 'domain/store/user/useUserStore'; +import { MOCK_USER } from 'domain/store/user/mock'; import CircleImage from 'presentation/component/common/block/CircleImage'; const Account: FC = () => { - const user = useUserStore((state) => state.user); - const { avatar, name } = user; + const { avatar, name } = MOCK_USER; return (
diff --git a/src/presentation/component/page/Login/Form/GoogleAuthButton/index.tsx b/src/presentation/component/page/Login/Form/GoogleAuthButton/index.tsx index 7f334be..f870531 100644 --- a/src/presentation/component/page/Login/Form/GoogleAuthButton/index.tsx +++ b/src/presentation/component/page/Login/Form/GoogleAuthButton/index.tsx @@ -1,60 +1,13 @@ -import { FC, useEffect, useState } from 'react'; -import { TokenResponse, useGoogleLogin } from '@react-oauth/google'; -import axios from 'axios'; -import { useRouter } from 'next/navigation'; +import { FC } from 'react'; +import { signIn } from 'next-auth/react'; import { OVERVIEW } from 'constant/route'; -import User from 'domain/entity/User'; -import useUserStore from 'domain/store/user/useUserStore'; -import { MOCK_USER } from 'domain/store/user/mock'; import GoogleIcon from 'presentation/svg/google.svg'; import Button from 'presentation/component/common/control/Button'; -type AuthResponse = Omit; - const GoogleAuthButton: FC = () => { - const [authResponse, setAuthResponse] = useState(null); - const setUser = useUserStore((state) => state.setUser); - const { push } = useRouter(); - - const login = useGoogleLogin({ - onSuccess: (response) => setAuthResponse(response), - }); - - useEffect(() => { - if (authResponse) { - axios - .get( - `https://www.googleapis.com/oauth2/v1/userinfo?access_token=${authResponse.access_token}`, - { - headers: { - Authorization: `Bearer ${authResponse.access_token}`, - Accept: 'application/json', - }, - }, - ) - .then((res) => { - const { id, name, picture } = res.data; - const { lvl, location, email, todayReviews, totalReviews, successRate } = - MOCK_USER; - - setUser({ - ...MOCK_USER, - ...new User( - id, - name, - picture, - lvl, - location, - email, - todayReviews, - totalReviews, - successRate, - ), - }); - push(OVERVIEW); - }); - } - }, [authResponse]); + const handleClick = () => { + signIn('google', { callbackUrl: OVERVIEW }); + }; return ( diff --git a/src/presentation/component/page/Login/Form/Input/index.tsx b/src/presentation/component/page/Login/Form/Input/index.tsx index d0eeb0f..29c1b9f 100644 --- a/src/presentation/component/page/Login/Form/Input/index.tsx +++ b/src/presentation/component/page/Login/Form/Input/index.tsx @@ -1,14 +1,15 @@ -import { FC, InputHTMLAttributes } from 'react'; +import { FC, InputHTMLAttributes, forwardRef } from 'react'; -type InputProps = InputHTMLAttributes; +export type InputProps = InputHTMLAttributes; -const Input: FC = (props) => { +const Input: FC = forwardRef((props, ref) => { return ( ); -}; +}); export default Input; diff --git a/src/presentation/component/page/Login/Form/index.tsx b/src/presentation/component/page/Login/Form/index.tsx index 71382a4..a0599ec 100644 --- a/src/presentation/component/page/Login/Form/index.tsx +++ b/src/presentation/component/page/Login/Form/index.tsx @@ -1,6 +1,13 @@ 'use client'; import { FC } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useRouter } from 'next/navigation'; +import { OVERVIEW } from 'constant/route'; +import { LoginForm } from 'data/driver/validation/auth/types'; +import { loginFormValidationSchema } from 'data/driver/validation/auth/schema'; +import useLoginSWRMutation from 'domain/service/auth/useLoginSWRMutation'; import Button from 'presentation/component/common/control/Button'; import { Container } from 'presentation/component/common/block/Container'; import GoogleAuthButton from './GoogleAuthButton'; @@ -8,9 +15,31 @@ import Input from './Input'; import { Delimiter } from './styles'; const Form: FC = () => { + const { register, handleSubmit } = useForm({ + resolver: yupResolver(loginFormValidationSchema), + }); + const { trigger } = useLoginSWRMutation(); + const router = useRouter(); + + const onSubmit: SubmitHandler = async (data) => { + try { + const { email, password } = data; + + await trigger({ + email, + password, + }); + + router.push(OVERVIEW); + } catch (e) {} + }; + return ( -
+
Sign In Your Social Campaigns @@ -27,13 +56,16 @@ const Form: FC = () => { inputMode="email" autoComplete="email" placeholder="Email" + {...register('email')} /> - +
- +
Not a Member yet? diff --git a/src/presentation/component/page/dashboard/Overview/Intro/index.tsx b/src/presentation/component/page/dashboard/Overview/Intro/index.tsx index e11b9ab..73f830e 100644 --- a/src/presentation/component/page/dashboard/Overview/Intro/index.tsx +++ b/src/presentation/component/page/dashboard/Overview/Intro/index.tsx @@ -3,6 +3,7 @@ import { FC } from 'react'; import { EnvelopeSimple, GraduationCap, MapPin } from '@phosphor-icons/react'; import useUserStore from 'domain/store/user/useUserStore'; +import { MOCK_USER } from 'domain/store/user/mock'; import SurfaceCard from 'presentation/component/common/block/SurfaceCard'; import CircleImage from 'presentation/component/common/block/CircleImage'; import Detail from './Detail'; @@ -11,7 +12,8 @@ import ProgressBar from './ProgressBar'; const Intro: FC = () => { const user = useUserStore((state) => state.user); - const { name, avatar, lvl, location, email, todayReviews, totalReviews, successRate } = user; + const { email } = user; + const { name, avatar, lvl, location, todayReviews, totalReviews, successRate } = MOCK_USER; return ( diff --git a/src/presentation/hook/.gitkeep b/src/presentation/hook/.gitkeep deleted file mode 100644 index e69de29..0000000