{error}
} +From 97929990c2213480a7cf4c8c37476e98b638568e Mon Sep 17 00:00:00 2001 From: AlinaLuch <156098118+luchali@users.noreply.github.com> Date: Thu, 28 May 2026 14:42:25 +0300 Subject: [PATCH 01/18] clone nodejs-theory_login-app-react --- .env | 1 + .eslintrc.cjs | 31 + .gitignore | 29 +- .prettierrc | 11 +- index.html | 13 + package.json | 51 +- pnpm-lock.yaml | 3417 +++++++++++++++++++++++++++ public/vite.svg | 1 + readme.md | 58 +- src/App.tsx | 113 + src/components/AuthContext.tsx | 71 + src/components/Loader.tsx | 5 + src/components/RequireAuth.tsx | 18 + src/components/RequireNonAuth.tsx | 18 + src/hooks/usePageError.ts | 15 + src/http/authClient.ts | 13 + src/http/httpClient.ts | 38 + src/index.tsx | 15 + src/pages/AccountActivationPage.tsx | 47 + src/pages/HomePage.tsx | 5 + src/pages/LoginPage.tsx | 139 ++ src/pages/RegistrationPage.tsx | 163 ++ src/pages/UsersPage.tsx | 50 + src/services/accessTokenService.ts | 7 + src/services/authService.ts | 25 + src/services/userService.ts | 6 + src/styles.scss | 20 + src/types/user.ts | 4 + src/vite-env.d.ts | 1 + tsconfig.app.json | 27 + tsconfig.json | 11 + tsconfig.node.json | 13 + vite.config.ts | 7 + 33 files changed, 4380 insertions(+), 63 deletions(-) create mode 100644 .env create mode 100644 .eslintrc.cjs create mode 100644 index.html create mode 100644 pnpm-lock.yaml create mode 100644 public/vite.svg create mode 100644 src/App.tsx create mode 100644 src/components/AuthContext.tsx create mode 100644 src/components/Loader.tsx create mode 100644 src/components/RequireAuth.tsx create mode 100644 src/components/RequireNonAuth.tsx create mode 100644 src/hooks/usePageError.ts create mode 100644 src/http/authClient.ts create mode 100644 src/http/httpClient.ts create mode 100644 src/index.tsx create mode 100644 src/pages/AccountActivationPage.tsx create mode 100644 src/pages/HomePage.tsx create mode 100644 src/pages/LoginPage.tsx create mode 100644 src/pages/RegistrationPage.tsx create mode 100644 src/pages/UsersPage.tsx create mode 100644 src/services/accessTokenService.ts create mode 100644 src/services/authService.ts create mode 100644 src/services/userService.ts create mode 100644 src/styles.scss create mode 100644 src/types/user.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.env b/.env new file mode 100644 index 00000000..cd41370f --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:3000 \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..708883b2 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,31 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended-type-checked', + 'plugin:@typescript-eslint/stylistic-type-checked', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.app.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-empty-function': 'off', + }, + settings: { + react: { version: 'detect' }, + }, +}; diff --git a/.gitignore b/.gitignore index ed48a299..a547bf36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,24 @@ -# IDE -.idea -.vscode +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* -# Node node_modules +dist +dist-ssr +*.local -# MacOS +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea .DS_Store - -# env files -*.env -.env* +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.prettierrc b/.prettierrc index 8c8d7fcb..49b905d6 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,8 +1,11 @@ { - "tabWidth": 2, - "useTabs": false, - "semi": true, + "arrowParens": "avoid", "singleQuote": true, + "tabWidth": 2, "trailingComma": "all", - "printWidth": 80 + "jsxSingleQuote": false, + "printWidth": 80, + "semi": true, + "bracketSpacing": true, + "bracketSameLine": false } diff --git a/index.html b/index.html new file mode 100644 index 00000000..6dd50838 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + +
+ + + +{error}
} +{error}
+ ) : ( ++ Your account is now active +
+ )} + > + ); +}; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 00000000..0db62e9f --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,5 @@ +export const HomePage = () => ( +{error}
} + > + ); +}; diff --git a/src/pages/RegistrationPage.tsx b/src/pages/RegistrationPage.tsx new file mode 100644 index 00000000..de1bb434 --- /dev/null +++ b/src/pages/RegistrationPage.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import { Link, Navigate } from 'react-router-dom'; +import { Formik, Form, Field } from 'formik'; +import cn from 'classnames'; + +import { authService } from '../services/authService'; +import { AxiosError } from 'axios'; +import { usePageError } from '../hooks/usePageError'; +import { useAuth } from '../components/AuthContext'; + +type RegistrationError = AxiosError<{ + errors?: { email?: string; password?: string }; + message: string; +}>; + +function validateEmail(value: string) { + const EMAIL_PATTERN = /^[\w.+-]+@([\w-]+\.){1,3}[\w-]{2,}$/; + + if (!value) return 'Email is required'; + if (!EMAIL_PATTERN.test(value)) return 'Email is not valid'; +} + +const validatePassword = (value: string) => { + if (!value) return 'Password is required'; + if (value.length < 6) return 'At least 6 characters'; +}; + +export const RegistrationPage = () => { + const [error, setError] = usePageError(''); + const [registered, setRegistered] = useState(false); + + const { isChecked, currentUser } = useAuth(); + + if (isChecked && currentUser) { + returnWe have sent you an email with the activation link
+{error}
} + > + ); +}; diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx new file mode 100644 index 00000000..780a993d --- /dev/null +++ b/src/pages/UsersPage.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import { usePageError } from '../hooks/usePageError'; +import { userService } from '../services/userService'; +import { User } from '../types/user'; +import { AxiosError } from 'axios'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from '../components/AuthContext'; + +export const UsersPage = () => { + const { logout } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const [error, setError] = usePageError(''); + const [users, setUsers] = useState{error}
} +Page not found
+ + + Go Home + +This is a test email sent from the test-mail.ts script.
'); + +console.log('Mail sent'); diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292a..253881f9 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src", "test-mail.ts"] } From 0f823e39445b49cc402775536224a3d5f25d369f Mon Sep 17 00:00:00 2001 From: AlinaLuch <156098118+luchali@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:09:43 +0300 Subject: [PATCH 06/18] add login, logout --- src/api/auth.controller.ts | 36 +++++++++++++++++++++++++++++------ src/routes/auth.route.ts | 2 ++ src/services/authService.ts | 4 +++- src/services/email.service.ts | 2 +- src/services/user.service.ts | 23 ++++++++++++++++++++++ 5 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 src/services/user.service.ts diff --git a/src/api/auth.controller.ts b/src/api/auth.controller.ts index 2e59678e..71f33cb7 100644 --- a/src/api/auth.controller.ts +++ b/src/api/auth.controller.ts @@ -2,6 +2,7 @@ import { RequestHandler } from "express"; import bcrypt from 'bcrypt'; import { usersRepository } from "../entity/users.repository"; import { mailer } from "../services/email.service"; +import { userService } from "../services/user.service"; const register: RequestHandler = async (req, res) => { const { email, password } = req.body; @@ -15,31 +16,54 @@ const register: RequestHandler = async (req, res) => { } await mailer.sendActivationLink(email, activationToken); - res.json({ user }); + res.json({ user: userService.normalize(user) }); }; const getUsers: RequestHandler = async (req, res) => { const users = await usersRepository.getAll(); - res.json(users); + res.json(users.map(userService.normalize)); }; const activate: RequestHandler = async (req, res) => { const { email, activationToken } = req.params; - const user = await usersRepository.getByEmail(email as string); + const decodedEmail = decodeURIComponent(email as string); + const decodedActivationToken = decodeURIComponent(activationToken as string); - if (!user || user.activationToken !== activationToken) { + const user = await usersRepository.getByEmail(decodedEmail); + + if (!user || user.activationToken !== decodedActivationToken) { return res.status(400).json({ message: 'Wrong activation link' }); } - const activatedUser = await usersRepository.activate(email as string); + const activatedUser = await usersRepository.activate(decodedEmail); - res.json({ user: activatedUser }); + res.json({ user: userService.normalize(activatedUser) }); }; +const login: RequestHandler = async (req, res) => { + const { email, password } = req.body; + + const user = await usersRepository.getByEmail(email); + const isPasswordValid = await bcrypt.compare(password, user?.password || ''); + + if (!user || !isPasswordValid) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + + res.json({ user: userService.normalize(user) }); +}; + +const logout: RequestHandler = async (req, res) => { + res.clearCookie('refreshToken'); + res.sendStatus(204); +} + export const authController = { + login, register, getUsers, activate, + logout, }; diff --git a/src/routes/auth.route.ts b/src/routes/auth.route.ts index 9892d45a..404fb174 100644 --- a/src/routes/auth.route.ts +++ b/src/routes/auth.route.ts @@ -6,5 +6,7 @@ const authRouter = Router(); authRouter.post('/registration', authController.register); authRouter.get('/users', authController.getUsers); authRouter.get('/activation/:email/:activationToken', authController.activate); +authRouter.post('/login', authController.login); +authRouter.post('/logout', authController.logout); export default authRouter; diff --git a/src/services/authService.ts b/src/services/authService.ts index da9fc02e..07ca4889 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -12,7 +12,9 @@ export const authService = { }, activate: (email: string, token: string): Promise+ If this email exists, password reset link was sent. +
+ + Back to login + > + ); +}; diff --git a/src/pages/ResetPasswordPage.tsx b/src/pages/ResetPasswordPage.tsx new file mode 100644 index 00000000..6dddad91 --- /dev/null +++ b/src/pages/ResetPasswordPage.tsx @@ -0,0 +1,129 @@ +import { FormEvent, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { authService } from '../services/authService'; +import { AxiosError } from 'axios'; + +export const ResetPasswordPage = () => { + const { resetToken = '' } = useParams(); + + const [password, setPassword] = useState(''); + const [confirmation, setConfirmation] = useState(''); + + const [passwordError, setPasswordError] = useState(''); + const [confirmationError, setConfirmationError] = useState(''); + const [error, setError] = useState(''); + + const navigate = useNavigate(); + + const validatePassword = (value: string) => { + if (!value) { + return 'Password is required'; + } + + if (value.length < 6) { + return 'At least 6 characters'; + } + + return ''; + }; + + const validateConfirmation = (value: string) => { + if (!value) { + return 'Confirmation is required'; + } + + if (value !== password) { + return 'Passwords do not match'; + } + + return ''; + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + setError(''); + + const passwordValidationError = validatePassword(password); + const confirmationValidationError = validateConfirmation(confirmation); + + setPasswordError(passwordValidationError); + setConfirmationError(confirmationValidationError); + + if (passwordValidationError || confirmationValidationError) { + return; + } + + try { + await authService.resetPassword(resetToken, password, confirmation); + navigate('/reset-password/success'); + } catch (error) { + const err = error as AxiosError<{ message?: string }>; + setError(err.response?.data?.message ?? 'Something went wrong'); + } + }; + + const isSubmitDisabled = + password.length < 6 || confirmation.length < 6 || password !== confirmation; + + return ( + <> +{passwordError}
+ ) : ( +At least 6 characters
+ )} +{confirmationError}
+ )} +{error}
} + > + ); +}; diff --git a/src/pages/ResetPasswordSuccessPage.tsx b/src/pages/ResetPasswordSuccessPage.tsx new file mode 100644 index 00000000..ea2944b7 --- /dev/null +++ b/src/pages/ResetPasswordSuccessPage.tsx @@ -0,0 +1,15 @@ +import { Link } from 'react-router-dom'; + +export const ResetPasswordSuccessPage = () => { + return ( + <> ++ Your password was changed successfully. +
+ + Go to login + > + ); +}; diff --git a/src/routes/auth.route.ts b/src/routes/auth.route.ts index 894acd79..5bbb9103 100644 --- a/src/routes/auth.route.ts +++ b/src/routes/auth.route.ts @@ -10,5 +10,7 @@ authRouter.get('/activation/:email/:activationToken', authController.activate); authRouter.post('/login', authController.login); authRouter.post('/logout', authController.logout); authRouter.get('/refresh', cookieParser(), authController.refresh); +authRouter.post('/password-reset', authController.requestPasswordReset); +authRouter.post('/password-reset/:resetToken', authController.resetPassword); export default authRouter; diff --git a/src/services/authService.ts b/src/services/authService.ts index 3102ab93..97503604 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -24,4 +24,19 @@ export const authService = { logout: () => client.post('/logout'), refresh: (): PromiseClick the link below to reset your password:
+ ${link} + `; + + return send(email, 'Password reset', html); +} + export const mailer = { send, sendActivationLink, + sendPasswordResetLink, }; From 17df72cfd143e472639a64b89c63a12a61e81426 Mon Sep 17 00:00:00 2001 From: AlinaLuch <156098118+luchali@users.noreply.github.com> Date: Sat, 6 Jun 2026 17:48:49 +0300 Subject: [PATCH 13/18] add profile page --- src/App.tsx | 16 ++- src/api/profile.controller.ts | 115 ++++++++++++++++ src/entity/users.repository.ts | 23 ++++ src/index.js | 2 + src/pages/ProfilePage.tsx | 239 +++++++++++++++++++++++++++++++++ src/routes/profile.route.ts | 10 ++ src/services/profileService.ts | 34 +++++ src/services/user.service.ts | 5 +- src/types/user.ts | 5 +- 9 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 src/api/profile.controller.ts create mode 100644 src/pages/ProfilePage.tsx create mode 100644 src/routes/profile.route.ts create mode 100644 src/services/profileService.ts diff --git a/src/App.tsx b/src/App.tsx index 79a01a95..f56c4ae4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { ResetPasswordSuccessPage } from './pages/ResetPasswordSuccessPage'; import { ResetPasswordPage } from './pages/ResetPasswordPage'; import { PasswordResetEmailSentPage } from './pages/PasswordResetEmailSentPage'; import { ForgotPasswordPage } from './pages/ForgotPasswordPage'; +import { ProfilePage } from './pages/ProfilePage'; export function App() { @@ -67,12 +68,18 @@ export function App() {Your account email was changed from ${oldEmail} to ${email}.
+ `, + ); + + res.json({ user: userService.normalize(updatedUser) }); +}; + +export const profileController = { + updateName, + updatePassword, + updateEmail, +}; diff --git a/src/entity/users.repository.ts b/src/entity/users.repository.ts index 0e9c6722..14b35af9 100644 --- a/src/entity/users.repository.ts +++ b/src/entity/users.repository.ts @@ -63,6 +63,26 @@ function updatePassword(userId: string, password: string) { }); } +function updateEmail(id: string, email: string) { + return client.user.update({ + where: { id }, + data: { email }, + }); +} + +function getById(id: string) { + return client.user.findUnique({ + where: { id }, + }); +} + +function updateName(id: string, name: string) { + return client.user.update({ + where: { id }, + data: { name }, + }); +} + export const usersRepository = { create, getAll, @@ -72,4 +92,7 @@ export const usersRepository = { getByResetPasswordToken, updatePassword, getAllActive, + updateEmail, + getById, + updateName, }; diff --git a/src/index.js b/src/index.js index 7f50e204..5a8f5e3a 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import express from 'express'; import cors from 'cors'; import authRouter from './routes/auth.route.ts'; import { usersRouter } from './routes/users.route.ts'; +import profileRouter from './routes/profile.route.ts'; const PORT = process.env.PORT || 3000; const app = express(); @@ -16,6 +17,7 @@ app.use(express.json()); app.use('/auth', authRouter); app.use('/users', usersRouter); +app.use('/profile', profileRouter); app.get('/', (req, res) => { res.send('Hello World!'); diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx new file mode 100644 index 00000000..c062e68f --- /dev/null +++ b/src/pages/ProfilePage.tsx @@ -0,0 +1,239 @@ +import { FormEvent, useState } from 'react'; +import { Navigate } from 'react-router-dom'; +import { AxiosError } from 'axios'; + +import { useAuth } from '../components/AuthContext'; +import { profileService } from '../services/profileService'; + +export const ProfilePage = () => { + const { currentUser } = useAuth(); + + const [name, setName] = useState(currentUser?.name ?? ''); + const [newEmail, setNewEmail] = useState(''); + + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmation, setConfirmation] = useState(''); + + const [emailPassword, setEmailPassword] = useState(''); + + const [success, setSuccess] = useState(''); + const [error, setError] = useState(''); + + if (!currentUser) { + return+ {success} +
+ )} + + {error && ( ++ {error} +
+ )} + ++ Email: {currentUser.email} +
+At least 6 characters
+