From d56c20f8f373a1d17a5c2b0c663d36743c7373aa Mon Sep 17 00:00:00 2001 From: Dima Bulavenko Date: Fri, 30 Jan 2026 19:33:06 +0000 Subject: [PATCH 01/10] feat: Add is_password_set field to UserRead and User pydantic models. --- backend/app/core/domain/user.py | 7 ++++++- backend/app/core/dto/user.py | 1 + .../app/infrastructure/repositories/slq_alchemy/user.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/app/core/domain/user.py b/backend/app/core/domain/user.py index da62850..80241db 100644 --- a/backend/app/core/domain/user.py +++ b/backend/app/core/domain/user.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime from enum import Enum -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, computed_field class OAuthProvider(str, Enum): @@ -23,3 +23,8 @@ class User(BaseModel): time_create: datetime = Field(default_factory=lambda: datetime.now(UTC)) time_update: datetime = Field(default_factory=lambda: datetime.now(UTC)) is_active: bool = False + + @computed_field # type: ignore[prop-decorator] + @property + def is_password_set(self) -> bool: + return self.password is not None diff --git a/backend/app/core/dto/user.py b/backend/app/core/dto/user.py index 6b2e85b..0d8c519 100644 --- a/backend/app/core/dto/user.py +++ b/backend/app/core/dto/user.py @@ -39,6 +39,7 @@ class UserRead(BaseModelDTO): time_create: datetime time_update: datetime is_active: bool = True + is_password_set: bool class UserLogin(BaseModelDTO): diff --git a/backend/app/infrastructure/repositories/slq_alchemy/user.py b/backend/app/infrastructure/repositories/slq_alchemy/user.py index ac6349e..70f1a7e 100644 --- a/backend/app/infrastructure/repositories/slq_alchemy/user.py +++ b/backend/app/infrastructure/repositories/slq_alchemy/user.py @@ -43,7 +43,7 @@ async def update(self, user_id: int, **update_data) -> User | None: return User.model_validate(updated_user, from_attributes=True) if updated_user else None async def create(self, user: User) -> User: - user_model = self.model(**user.model_dump()) + user_model = self.model(**user.model_dump(exclude_computed_fields=True)) self.session.add(user_model) await self.session.flush() return User.model_validate(user_model, from_attributes=True) From 7f160e9978a2e97fd28917b093e4de7b5f3bee2a Mon Sep 17 00:00:00 2001 From: Dima Bulavenko Date: Fri, 30 Jan 2026 21:57:11 +0000 Subject: [PATCH 02/10] feat(PasswordField): allow custom helper text in password input --- frontend/src/entities/user/ui/PasswordField.tsx | 8 ++++++-- frontend/src/shared/types/form.ts | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/entities/user/ui/PasswordField.tsx b/frontend/src/entities/user/ui/PasswordField.tsx index 12a7935..c8e96fe 100644 --- a/frontend/src/entities/user/ui/PasswordField.tsx +++ b/frontend/src/entities/user/ui/PasswordField.tsx @@ -10,14 +10,18 @@ import { zUserCreate } from 'shared/api/gen/zod.gen'; const passwordHelp = zUserCreate.shape.password.description; -const PasswordField: FieldComponent = ({ label = 'Password', ...props }) => { +const PasswordField: FieldComponent = ({ + label = 'Password', + helperText, + ...props +}) => { const [showPassword, setShowPassword] = useState(false); const handleClickShowPassword = () => setShowPassword((show) => !show); const controller = useController(props); return ( = Omit, 'control'> & { control: Control; label?: string; + helperText?: string; }; export type FieldComponent = < From 53ca918e651ea8407be8364738335442dd64b663 Mon Sep 17 00:00:00 2001 From: Dima Bulavenko Date: Sat, 31 Jan 2026 18:40:47 +0000 Subject: [PATCH 03/10] fix: Update RHF resolver to eliminate throwing error during zod fiedl validation. --- frontend/package-lock.json | 59 ++++++++++++++++++++++---------------- frontend/package.json | 2 +- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d05469..9876126 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@hey-api/client-axios": "^0.9.1", - "@hookform/resolvers": "^3.9.1", + "@hookform/resolvers": "^5.2.2", "@mui/icons-material": "^6.4.7", "@mui/material": "^6.4.7", "@mui/x-date-pickers": "^8.10.2", @@ -1375,12 +1375,15 @@ } }, "node_modules/@hookform/resolvers": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", - "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, "peerDependencies": { - "react-hook-form": "^7.0.0" + "react-hook-form": "^7.55.0" } }, "node_modules/@humanfs/core": { @@ -2216,6 +2219,12 @@ "win32" ] }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tanstack/devtools-event-client": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.2.3.tgz", @@ -3856,9 +3865,9 @@ "license": "MIT" }, "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5018,9 +5027,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -5610,9 +5619,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.54.2", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", - "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "version": "7.71.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", + "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -5924,18 +5933,18 @@ } }, "node_modules/seroval": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", - "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", + "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/seroval-plugins": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz", - "integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.0.tgz", + "integrity": "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==", "license": "MIT", "engines": { "node": ">=10" @@ -5968,15 +5977,15 @@ } }, "node_modules/solid-js": { - "version": "1.9.9", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.9.tgz", - "integrity": "sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA==", + "version": "1.9.11", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz", + "integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==", "license": "MIT", "peer": true, "dependencies": { "csstype": "^3.1.0", - "seroval": "~1.3.0", - "seroval-plugins": "~1.3.0" + "seroval": "~1.5.0", + "seroval-plugins": "~1.5.0" } }, "node_modules/source-map": { diff --git a/frontend/package.json b/frontend/package.json index e93232b..a4276eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@hey-api/client-axios": "^0.9.1", - "@hookform/resolvers": "^3.9.1", + "@hookform/resolvers": "^5.2.2", "@mui/icons-material": "^6.4.7", "@mui/material": "^6.4.7", "@mui/x-date-pickers": "^8.10.2", From bae79224d79216c2aa7a0939a519f6a888f1e0b4 Mon Sep 17 00:00:00 2001 From: Dima Bulavenko Date: Sat, 31 Jan 2026 19:32:06 +0000 Subject: [PATCH 04/10] fix: Install latest version of RHF devtools to make it work properly --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9876126..80c5fce 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,7 +29,7 @@ "@apidevtools/json-schema-ref-parser": "^11.9.3", "@eslint/js": "^9.15.0", "@hey-api/openapi-ts": "0.80.18", - "@hookform/devtools": "^4.3.3", + "@hookform/devtools": "^4.4.0", "@tanstack/router-plugin": "^1.133.13", "@types/node": "^24.2.1", "@types/react": "^18.3.12", @@ -1354,9 +1354,9 @@ } }, "node_modules/@hookform/devtools": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@hookform/devtools/-/devtools-4.3.3.tgz", - "integrity": "sha512-W9MipDe6P5y2XLos9coN4/fZhbt0YE2c+PaUx7tiKdc9XNQ2UOWCYTtysCnbj7fipZWEJht8J/UyQmXprWEhgw==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@hookform/devtools/-/devtools-4.4.0.tgz", + "integrity": "sha512-Mtlic+uigoYBPXlfvPBfiYYUZuyMrD3pTjDpVIhL6eCZTvQkHsKBSKeZCvXWUZr8fqrkzDg27N+ZuazLKq6Vmg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index a4276eb..87917f2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,7 +35,7 @@ "@apidevtools/json-schema-ref-parser": "^11.9.3", "@eslint/js": "^9.15.0", "@hey-api/openapi-ts": "0.80.18", - "@hookform/devtools": "^4.3.3", + "@hookform/devtools": "^4.4.0", "@tanstack/router-plugin": "^1.133.13", "@types/node": "^24.2.1", "@types/react": "^18.3.12", From 9643f2030357d67a3b6a0430a0ac7152d34a478a Mon Sep 17 00:00:00 2001 From: Dima Bulavenko Date: Sun, 1 Feb 2026 22:46:14 +0000 Subject: [PATCH 05/10] feat(ChangePasswordForm): implement password change form with validation --- .../features/user/ui/ChangePasswordForm.tsx | 101 ++++++++++++++++++ frontend/src/shared/ui/TextInput.tsx | 6 +- 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 frontend/src/features/user/ui/ChangePasswordForm.tsx diff --git a/frontend/src/features/user/ui/ChangePasswordForm.tsx b/frontend/src/features/user/ui/ChangePasswordForm.tsx new file mode 100644 index 0000000..d374c3c --- /dev/null +++ b/frontend/src/features/user/ui/ChangePasswordForm.tsx @@ -0,0 +1,101 @@ +import PasswordField from 'entities/user/ui/PasswordField'; +import { type SubmitHandler, useForm } from 'react-hook-form'; +import { changePassword } from 'shared/api/gen'; +import { zUserChangePassword } from 'shared/api/gen/zod.gen'; +import { Form } from 'shared/ui/Form'; +import z from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import Stack from '@mui/material/Stack'; +import SubmitButton from 'shared/ui/SubmitButton'; +import { FormError } from 'shared/ui/FormError'; + +type FormType = z.infer; + +// Extend schema to validate password confirmation +const passwordHelperText = + 'Password must be 8 characters long, contain at least one uppercase letter and one number.'; +const zPasswordFiled = z + .string(passwordHelperText) + .regex(/^(?=.*[A-Z])(?=.*\d).{8,}$/, passwordHelperText); + +const validationSchema = z + .object({ + old_password: zPasswordFiled, + new_password: zPasswordFiled, + confirm_new_password: zPasswordFiled, + }) + .refine((data) => data.new_password === data.confirm_new_password, { + message: "Passwords don't match", + path: ['confirm_new_password'], + }); + +export function ChangePasswordForm() { + const { + control, + handleSubmit, + setError, + formState: { isSubmitting, errors }, + } = useForm({ + resolver: zodResolver(validationSchema), + defaultValues: { + old_password: '', + new_password: '', + confirm_new_password: '', + }, + }); + + const onSubmit: SubmitHandler = async (data, event) => { + event?.preventDefault(); + const response = await changePassword({ + body: data, + throwOnError: false, + }); + if (response.status === 200) return; + if (response.status === 400) + return setError('old_password', { + message: 'Current password is incorrect', + }); + if (response.status === 422 && Array.isArray(response.error?.detail)) { + response.error.detail.forEach((err) => { + const fieldName = err.loc[err.loc.length - 1]; + if (typeof fieldName === 'string' && fieldName in data) { + setError(fieldName as keyof FormType, { message: err.msg }); + } + }); + return; + } + setError('root', { + message: + 'An unexpected error occurred. Please reload page or try again later.', + }); + }; + + return ( +
+ + + + + + + + Create Application + + + ); +} diff --git a/frontend/src/shared/ui/TextInput.tsx b/frontend/src/shared/ui/TextInput.tsx index 247ab4d..610a1e6 100644 --- a/frontend/src/shared/ui/TextInput.tsx +++ b/frontend/src/shared/ui/TextInput.tsx @@ -5,7 +5,7 @@ import { TextInputProps } from 'shared/types/form'; export function TextInput< V extends FieldValues = FieldValues, N extends FieldPath = FieldPath, ->({ label, controller, children, ...props }: TextInputProps) { +>({ label, controller, children, helperText, ...props }: TextInputProps) { const { field, fieldState, formState } = controller; return ( Date: Mon, 2 Feb 2026 16:43:23 +0000 Subject: [PATCH 06/10] feat(ChangePassword): implement ChangePasswordDrawer and ChangePasswordSection components --- .../features/user/ui/ChangePasswordForm.tsx | 8 +++- .../pages/profile/ui/ChangePasswordDrawer.tsx | 47 +++++++++++++++++++ .../profile/ui/ChangePasswordSection.tsx | 24 ++++++++++ frontend/src/pages/profile/ui/ProfilePage.tsx | 9 ++++ 4 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/profile/ui/ChangePasswordDrawer.tsx create mode 100644 frontend/src/pages/profile/ui/ChangePasswordSection.tsx diff --git a/frontend/src/features/user/ui/ChangePasswordForm.tsx b/frontend/src/features/user/ui/ChangePasswordForm.tsx index d374c3c..0d7b2d9 100644 --- a/frontend/src/features/user/ui/ChangePasswordForm.tsx +++ b/frontend/src/features/user/ui/ChangePasswordForm.tsx @@ -29,7 +29,11 @@ const validationSchema = z path: ['confirm_new_password'], }); -export function ChangePasswordForm() { +type ChangePasswordForm = { + onSuccess?: () => void; +}; + +export function ChangePasswordForm({ onSuccess }: ChangePasswordForm) { const { control, handleSubmit, @@ -50,7 +54,7 @@ export function ChangePasswordForm() { body: data, throwOnError: false, }); - if (response.status === 200) return; + if (response.status === 200) return onSuccess?.(); if (response.status === 400) return setError('old_password', { message: 'Current password is incorrect', diff --git a/frontend/src/pages/profile/ui/ChangePasswordDrawer.tsx b/frontend/src/pages/profile/ui/ChangePasswordDrawer.tsx new file mode 100644 index 0000000..5cc7cfa --- /dev/null +++ b/frontend/src/pages/profile/ui/ChangePasswordDrawer.tsx @@ -0,0 +1,47 @@ +import { Suspense } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Drawer from '@mui/material/Drawer'; +import Divider from '@mui/material/Divider'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useTheme } from '@mui/material/styles'; +import { SuspenseFallback } from 'shared/ui/SuspenseFallback'; +import { ChangePasswordForm } from 'features/user/ui/ChangePasswordForm'; + +interface ChangePasswordProps { + open: boolean; + onClose: () => void; +} + +export function ChangePasswordDrawer({ open, onClose }: ChangePasswordProps) { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + return ( + + + + Change Password + + + }> + {open && } + + + + ); +} diff --git a/frontend/src/pages/profile/ui/ChangePasswordSection.tsx b/frontend/src/pages/profile/ui/ChangePasswordSection.tsx new file mode 100644 index 0000000..2a556c9 --- /dev/null +++ b/frontend/src/pages/profile/ui/ChangePasswordSection.tsx @@ -0,0 +1,24 @@ +import { useState } from 'react'; +import Paper from '@mui/material/Paper'; +import { SectionHeader } from './SectionHeader'; +import { ChangePasswordDrawer } from './ChangePasswordDrawer'; + +export function ChangePasswordSection() { + const [drawerOpen, setDrawerOpen] = useState(false); + + const handleOpenDrawer = () => setDrawerOpen(true); + const handleCloseDrawer = () => setDrawerOpen(false); + + return ( + <> + + + + + + ); +} diff --git a/frontend/src/pages/profile/ui/ProfilePage.tsx b/frontend/src/pages/profile/ui/ProfilePage.tsx index eabb881..d8ea950 100644 --- a/frontend/src/pages/profile/ui/ProfilePage.tsx +++ b/frontend/src/pages/profile/ui/ProfilePage.tsx @@ -3,13 +3,22 @@ import Container from '@mui/material/Container'; import { ProfileHeader } from './ProfileHeader'; import { PersonalInfoSection } from './PersonalInfoSection'; import { DangerZoneSection } from './DangerZoneSection'; +import { ChangePasswordSection } from './ChangePasswordSection'; +import { getRouteApi } from '@tanstack/react-router'; + +const routeApi = getRouteApi('/_authenticated'); export function ProfilePage() { + const { + auth: { user }, + } = routeApi.useRouteContext(); + return ( + {user.is_password_set && } From c86b64e5be9e6431800c5f5c289b405a8242d976 Mon Sep 17 00:00:00 2001 From: Dima Bulavenko Date: Mon, 2 Feb 2026 17:23:01 +0000 Subject: [PATCH 07/10] feat(ProfilePage): add SetPasswordSection and integrate SetPasswordDrawer feat(SetPasswordForm): implement form for setting a new password --- .../features/user/ui/ChangePasswordForm.tsx | 4 +- frontend/src/pages/profile/ui/ProfilePage.tsx | 2 + .../pages/profile/ui/SetPasswordDrawer.tsx | 60 ++++++++++++ .../src/pages/profile/ui/SetPasswordForm.tsx | 91 +++++++++++++++++++ .../pages/profile/ui/SetPasswordSection.tsx | 24 +++++ 5 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/profile/ui/SetPasswordDrawer.tsx create mode 100644 frontend/src/pages/profile/ui/SetPasswordForm.tsx create mode 100644 frontend/src/pages/profile/ui/SetPasswordSection.tsx diff --git a/frontend/src/features/user/ui/ChangePasswordForm.tsx b/frontend/src/features/user/ui/ChangePasswordForm.tsx index 0d7b2d9..797c487 100644 --- a/frontend/src/features/user/ui/ChangePasswordForm.tsx +++ b/frontend/src/features/user/ui/ChangePasswordForm.tsx @@ -97,9 +97,7 @@ export function ChangePasswordForm({ onSuccess }: ChangePasswordForm) { /> - - Create Application - + Change Password ); } diff --git a/frontend/src/pages/profile/ui/ProfilePage.tsx b/frontend/src/pages/profile/ui/ProfilePage.tsx index d8ea950..a21dd9e 100644 --- a/frontend/src/pages/profile/ui/ProfilePage.tsx +++ b/frontend/src/pages/profile/ui/ProfilePage.tsx @@ -5,6 +5,7 @@ import { PersonalInfoSection } from './PersonalInfoSection'; import { DangerZoneSection } from './DangerZoneSection'; import { ChangePasswordSection } from './ChangePasswordSection'; import { getRouteApi } from '@tanstack/react-router'; +import { SetPasswordSection } from './SetPasswordSection'; const routeApi = getRouteApi('/_authenticated'); @@ -19,6 +20,7 @@ export function ProfilePage() { {user.is_password_set && } + {!user.is_password_set && } diff --git a/frontend/src/pages/profile/ui/SetPasswordDrawer.tsx b/frontend/src/pages/profile/ui/SetPasswordDrawer.tsx new file mode 100644 index 0000000..a8f39fa --- /dev/null +++ b/frontend/src/pages/profile/ui/SetPasswordDrawer.tsx @@ -0,0 +1,60 @@ +import { Suspense } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Drawer from '@mui/material/Drawer'; +import Divider from '@mui/material/Divider'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useTheme } from '@mui/material/styles'; +import { SuspenseFallback } from 'shared/ui/SuspenseFallback'; +import { SetPasswordForm } from './SetPasswordForm'; +import { getCurrentUser } from 'shared/api/gen'; +import { getRouteApi } from '@tanstack/react-router'; + +interface SetPasswordProps { + open: boolean; + onClose: () => void; +} + +const routeApi = getRouteApi('/_authenticated'); + +export function SetPasswordDrawer({ open, onClose }: SetPasswordProps) { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + const { + auth: { setUser }, + } = routeApi.useRouteContext(); + const onSuccess = async () => { + const { data } = await getCurrentUser(); + setUser(data); + onClose(); + }; + + return ( + + + + Set Password + + + }> + {open && } + + + + ); +} diff --git a/frontend/src/pages/profile/ui/SetPasswordForm.tsx b/frontend/src/pages/profile/ui/SetPasswordForm.tsx new file mode 100644 index 0000000..56bd8a7 --- /dev/null +++ b/frontend/src/pages/profile/ui/SetPasswordForm.tsx @@ -0,0 +1,91 @@ +import PasswordField from 'entities/user/ui/PasswordField'; +import { type SubmitHandler, useForm } from 'react-hook-form'; +import { setPassword } from 'shared/api/gen'; +import { zUserSetPassword } from 'shared/api/gen/zod.gen'; +import { Form } from 'shared/ui/Form'; +import z from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import Stack from '@mui/material/Stack'; +import SubmitButton from 'shared/ui/SubmitButton'; +import { FormError } from 'shared/ui/FormError'; + +type FormType = z.infer; + +// Extend schema to validate password confirmation +const passwordHelperText = + 'Password must be 8 characters long, contain at least one uppercase letter and one number.'; +const zPasswordFiled = z + .string(passwordHelperText) + .regex(/^(?=.*[A-Z])(?=.*\d).{8,}$/, passwordHelperText); + +const validationSchema = z + .object({ + new_password: zPasswordFiled, + confirm_new_password: zPasswordFiled, + }) + .refine((data) => data.new_password === data.confirm_new_password, { + message: "Passwords don't match", + path: ['confirm_new_password'], + }); + +type SetPasswordForm = { + onSuccess?: () => void; +}; + +export function SetPasswordForm({ onSuccess }: SetPasswordForm) { + const { + control, + handleSubmit, + setError, + formState: { isSubmitting, errors }, + } = useForm({ + resolver: zodResolver(validationSchema), + defaultValues: { + new_password: '', + confirm_new_password: '', + }, + }); + + const onSubmit: SubmitHandler = async (data, event) => { + event?.preventDefault(); + const response = await setPassword({ + body: data, + throwOnError: false, + }); + if (response.status === 200) return onSuccess?.(); + if (response.status === 422 && Array.isArray(response.error?.detail)) { + response.error.detail.forEach((err) => { + const fieldName = err.loc[err.loc.length - 1]; + if (typeof fieldName === 'string' && fieldName in data) { + setError(fieldName as keyof FormType, { message: err.msg }); + } + }); + return; + } + setError('root', { + message: + 'An unexpected error occurred. Please reload page or try again later.', + }); + }; + + return ( +
+ + + + + + Set Password + + ); +} diff --git a/frontend/src/pages/profile/ui/SetPasswordSection.tsx b/frontend/src/pages/profile/ui/SetPasswordSection.tsx new file mode 100644 index 0000000..b5fb456 --- /dev/null +++ b/frontend/src/pages/profile/ui/SetPasswordSection.tsx @@ -0,0 +1,24 @@ +import { useState } from 'react'; +import Paper from '@mui/material/Paper'; +import { SectionHeader } from './SectionHeader'; +import { SetPasswordDrawer } from './SetPasswordDrawer'; + +export function SetPasswordSection() { + const [drawerOpen, setDrawerOpen] = useState(false); + + const handleOpenDrawer = () => setDrawerOpen(true); + const handleCloseDrawer = () => setDrawerOpen(false); + + return ( + <> + + + + + + ); +} From 35508bf83cb01c9faae39f406a7dc93f8efc2e1f Mon Sep 17 00:00:00 2001 From: Dima Bulavenko Date: Wed, 4 Feb 2026 20:17:18 +0000 Subject: [PATCH 08/10] feat(Link): create router-compatible MUI Link with type safety --- frontend/src/shared/ui/Link.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 frontend/src/shared/ui/Link.tsx diff --git a/frontend/src/shared/ui/Link.tsx b/frontend/src/shared/ui/Link.tsx new file mode 100644 index 0000000..d079620 --- /dev/null +++ b/frontend/src/shared/ui/Link.tsx @@ -0,0 +1,12 @@ +// src/components/ui/mui-router-link.tsx +import { createLink } from '@tanstack/react-router'; +import { type LinkProps } from '@mui/material/Link'; +import MuiLink from '@mui/material/Link'; +import { forwardRef } from 'react'; + +// Create a router-compatible MUI Link with full type safety +export const Link = createLink( + forwardRef((props, ref) => { + return ; + }) +); From c02535801ff33be799bd9d1ab9db45e613427b81 Mon Sep 17 00:00:00 2001 From: Dima Bulavenko Date: Wed, 4 Feb 2026 20:18:26 +0000 Subject: [PATCH 09/10] feat(AccountMenu, Header, AuthPage): replace MUI Link with LinkButton for consistency --- frontend/src/features/user/ui/AccountMenu.tsx | 18 ++++++----- frontend/src/pages/auth/ui/AuthPage.tsx | 5 ++- frontend/src/widgets/header/ui/Header.tsx | 31 ++++++------------- 3 files changed, 22 insertions(+), 32 deletions(-) diff --git a/frontend/src/features/user/ui/AccountMenu.tsx b/frontend/src/features/user/ui/AccountMenu.tsx index 6f3465c..6e728fe 100644 --- a/frontend/src/features/user/ui/AccountMenu.tsx +++ b/frontend/src/features/user/ui/AccountMenu.tsx @@ -7,8 +7,8 @@ import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Tooltip from '@mui/material/Tooltip'; import { useState } from 'react'; -import Link from '@mui/material/Link'; -import { getRouteApi, Link as RouterLink } from '@tanstack/react-router'; +import { getRouteApi } from '@tanstack/react-router'; +import { LinkButton } from 'shared/ui/LinkButton'; const MenuStyle = { paper: { @@ -77,14 +77,18 @@ export function AccountMenu() { slotProps={MenuStyle} transformOrigin={{ horizontal: 'center', vertical: 'top' }} anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}> + - + sx={{ + display: 'flex', + alignItems: 'center', + color: 'inherit', + padding: 0, + }}> Profile - + {footerText}{' '} {footerLinkText && footerTo && ( - + {footerLinkText} )} diff --git a/frontend/src/widgets/header/ui/Header.tsx b/frontend/src/widgets/header/ui/Header.tsx index b02648a..d2f27e0 100644 --- a/frontend/src/widgets/header/ui/Header.tsx +++ b/frontend/src/widgets/header/ui/Header.tsx @@ -1,12 +1,11 @@ import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; import Stack from '@mui/material/Stack'; -import { Link as RouterLink } from '@tanstack/react-router'; import { ColorModeToggler } from 'shared/ui/ColorModeToggler'; import { AccountMenu } from 'features/user/ui/AccountMenu'; import { getRouteApi } from '@tanstack/react-router'; +import { LinkButton } from 'shared/ui/LinkButton'; const routeApi = getRouteApi('__root__'); @@ -17,18 +16,9 @@ export function Header() { return ( - + - - + @@ -47,22 +36,20 @@ export function Header() { ) : ( <> - - + )} From 443d93150677b2d271a998197c12e9fdae616fbb Mon Sep 17 00:00:00 2001 From: Dima Bulavenko Date: Thu, 5 Feb 2026 23:46:29 +0000 Subject: [PATCH 10/10] feat(ci): enhance frontend deployment workflow with OpenAPI checksum validation --- .github/workflows/cd-frontend.yml | 71 +++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cd-frontend.yml b/.github/workflows/cd-frontend.yml index 19ff208..8fe0abd 100644 --- a/.github/workflows/cd-frontend.yml +++ b/.github/workflows/cd-frontend.yml @@ -6,43 +6,96 @@ on: paths: - "frontend/**" - ".github/workflows/cd-frontend.yml" + - "!backend/**" + + workflow_run: + workflows: ["Deploy Backend"] + types: + - completed jobs: deploy-frontend: runs-on: ubuntu-latest + if: | + github.event_name == 'push' || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success') + + defaults: + run: + working-directory: ./frontend + steps: - name: Checkout code uses: actions/checkout@v4 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Download OpenAPI schema from deployed backend + run: | + curl -f -sS ${{ vars.API_URL }}/openapi.json -o openapi.json + + - name: Compute checksum of OpenAPI schema + run: | + sha256sum openapi.json | awk '{print $1}' > openapi.sha256 + + - name: Download last OpenAPI checksum artifact + id: download + continue-on-error: true + uses: actions/download-artifact@v4 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} + name: openapi-checksum + path: frontend/openapi-checksum - - name: Download OpenAPI schema from deployed backend + - name: Decide whether frontend deploy is needed + id: should-deploy run: | - curl -s ${{ vars.API_URL }}/openapi.json -o frontend/openapi.json + if [ "${{ github.event_name }}" = "push" ]; then + echo "deploy=true" >> $GITHUB_OUTPUT + exit 0 + fi + + if [ ! -f openapi-checksum/openapi.sha256 ]; then + echo "deploy=true" >> $GITHUB_OUTPUT + elif cmp -s openapi.sha256 openapi-checksum/openapi.sha256; then + echo "deploy=false" >> $GITHUB_OUTPUT + else + echo "deploy=true" >> $GITHUB_OUTPUT + fi - name: Build React frontend id: build-frontend + if: steps.should-deploy.outputs.deploy == 'true' env: VITE_API_URL: ${{ vars.API_URL }} - working-directory: ./frontend run: | npm ci npm run generate-client npm run build + - name: Configure AWS credentials + if: steps.should-deploy.outputs.deploy == 'true' + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + - name: Sync frontend build to S3 - working-directory: ./frontend + if: steps.should-deploy.outputs.deploy == 'true' run: | aws s3 sync dist/ s3://${{ secrets.S3_BUCKET_NAME }} --delete - name: Invalidate CloudFront cache + if: steps.should-deploy.outputs.deploy == 'true' run: | aws cloudfront create-invalidation \ --distribution-id ${{ secrets.CLOUDFRONT_DIST_ID }} \ --paths "/*" + + - name: Upload OpenAPI checksum artifact + if: steps.should-deploy.outputs.deploy == 'true' + uses: actions/upload-artifact@v4 + with: + name: openapi-checksum + path: frontend/openapi.sha256 + retention-days: 90