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 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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d05469..80c5fce 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", @@ -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": { @@ -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..87917f2 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", @@ -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", 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 ( + - + sx={{ + display: 'flex', + alignItems: 'center', + color: 'inherit', + padding: 0, + }}> Profile - + ; + +// 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'], + }); + +type ChangePasswordForm = { + onSuccess?: () => void; +}; + +export function ChangePasswordForm({ onSuccess }: 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 onSuccess?.(); + 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 ( +
+ + + + + + + Change Password + + ); +} diff --git a/frontend/src/pages/auth/ui/AuthPage.tsx b/frontend/src/pages/auth/ui/AuthPage.tsx index 090afaa..6c7d313 100644 --- a/frontend/src/pages/auth/ui/AuthPage.tsx +++ b/frontend/src/pages/auth/ui/AuthPage.tsx @@ -3,9 +3,8 @@ import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import Divider from '@mui/material/Divider'; -import Link from '@mui/material/Link'; -import { Link as RouterLink } from '@tanstack/react-router'; import type { SxProps, Theme } from '@mui/material/styles'; +import { Link } from 'shared/ui/Link'; export interface AuthPageProps { title: string; @@ -62,7 +61,7 @@ export function AuthPage({ textAlign='center'> {footerText}{' '} {footerLinkText && footerTo && ( - + {footerLinkText} )} 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..a21dd9e 100644 --- a/frontend/src/pages/profile/ui/ProfilePage.tsx +++ b/frontend/src/pages/profile/ui/ProfilePage.tsx @@ -3,13 +3,24 @@ 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'; +import { SetPasswordSection } from './SetPasswordSection'; + +const routeApi = getRouteApi('/_authenticated'); export function ProfilePage() { + const { + auth: { user }, + } = routeApi.useRouteContext(); + return ( + {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 ( + <> + + + + + + ); +} diff --git a/frontend/src/shared/types/form.ts b/frontend/src/shared/types/form.ts index 1c2b47a..6c33f7f 100644 --- a/frontend/src/shared/types/form.ts +++ b/frontend/src/shared/types/form.ts @@ -14,6 +14,7 @@ export type BaseInputProps< > = Omit, 'control'> & { control: Control; label?: string; + helperText?: string; }; export type FieldComponent = < 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 ; + }) +); 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 ( - + - - + @@ -47,22 +36,20 @@ export function Header() { ) : ( <> - - + )}