diff --git a/client/eslint.config.js b/client/eslint.config.js index 7e3c584..7786ff6 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -185,6 +185,14 @@ export default tseslint.config( ], }, }, + { + files: ['src/app/providers/theme-controller.tsx'], + rules: { 'react-refresh/only-export-components': 'off' }, + }, + { + files: ['src/pages/**/ui/**/*.tsx'], + rules: { 'react-hooks/incompatible-library': 'off' }, + }, { files: ['eslint.config.js'], rules: { diff --git a/client/package-lock.json b/client/package-lock.json index 589b2ec..3954447 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,11 +10,15 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", "@tanstack/react-query": "^5.100.5", "axios": "^1.15.2", + "effector": "^23.4.4", + "effector-react": "^23.3.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-hook-form": "^7.74.0", "react-router-dom": "^7.14.2" }, "devDependencies": { @@ -733,6 +737,32 @@ "url": "https://opencollective.com/mui-org" } }, + "node_modules/@mui/icons-material": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.0.0.tgz", + "integrity": "sha512-oDwyvI6LgjWRC9MBcSGvLkPud9S9ELgSBQFYxa1rYcZn6Br55dn22SyvsPDMsn0G8OndFk53iMT45W5mNqrogw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^9.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.0.0.tgz", @@ -2681,6 +2711,51 @@ "node": ">= 0.4" } }, + "node_modules/effector": { + "version": "23.4.4", + "resolved": "https://registry.npmjs.org/effector/-/effector-23.4.4.tgz", + "integrity": "sha512-QkZboRN28K/iwxigDhlJcI3ux3aNbt8kYGGH/GkqWG0OlGeyuBhb7PdM89Iu+ogV8Lmz16xIlwnXR2UNWI6psg==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/zero_bias" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/effector" + } + ], + "license": "MIT", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/effector-react": { + "version": "23.3.0", + "resolved": "https://registry.npmjs.org/effector-react/-/effector-react-23.3.0.tgz", + "integrity": "sha512-QR0+x1EnbiWhO80Yc0GVF+I9xCYoxBm3t+QLB5Wg+1uY1Q1BrSWDmKvJaJJZ/+9BU4RAr25yS5J2EkdWnicu8g==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/zero_bias" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/effector" + } + ], + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.0.0" + }, + "engines": { + "node": ">=11.0.0" + }, + "peerDependencies": { + "effector": "^23.0.0", + "react": ">=16.8.0 <20.0.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.344", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", @@ -5464,6 +5539,22 @@ "react": "^19.2.5" } }, + "node_modules/react-hook-form": { + "version": "7.74.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz", + "integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", @@ -6373,6 +6464,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", diff --git a/client/package.json b/client/package.json index 8db3525..2c828ee 100644 --- a/client/package.json +++ b/client/package.json @@ -15,11 +15,15 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", "@tanstack/react-query": "^5.100.5", "axios": "^1.15.2", + "effector": "^23.4.4", + "effector-react": "^23.3.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-hook-form": "^7.74.0", "react-router-dom": "^7.14.2" }, "devDependencies": { diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index b9e28db..e850815 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -2,7 +2,9 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { MainLayout } from '@/app/layout/MainLayout' import { AppProviders } from '@/app/providers/AppProviders' import { AdminPage } from '@/pages/admin' +import { AuthPage } from '@/pages/auth' import { HomePage } from '@/pages/home' +import { MePage } from '@/pages/me/ui/MePage' export function App() { return ( @@ -12,6 +14,8 @@ export function App() { } /> } /> + } /> + } /> } /> diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx index f70a949..6fa7063 100644 --- a/client/src/app/layout/MainLayout.tsx +++ b/client/src/app/layout/MainLayout.tsx @@ -1,14 +1,65 @@ -import { type PropsWithChildren } from 'react' +import { type PropsWithChildren, useState } from 'react' +import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined' +import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined' +import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined' import AppBar from '@mui/material/AppBar' +import Badge from '@mui/material/Badge' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Container from '@mui/material/Container' +import FormControl from '@mui/material/FormControl' +import IconButton from '@mui/material/IconButton' +import InputLabel from '@mui/material/InputLabel' +import ListItemText from '@mui/material/ListItemText' +import Menu from '@mui/material/Menu' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import type { SelectChangeEvent } from '@mui/material/Select' import Toolbar from '@mui/material/Toolbar' import Typography from '@mui/material/Typography' -import { Link as RouterLink } from 'react-router-dom' +import { Link as RouterLink, useNavigate } from 'react-router-dom' +import { useUnit } from 'effector-react' +import type { ColorScheme } from '@/app/providers/theme-controller' +import { useThemeController } from '@/app/providers/theme-controller' import { STORE_NAME } from '@/shared/config' +import { $user, logout, tokenSet } from '@/shared/model/auth' export function MainLayout({ children }: PropsWithChildren) { + const { mode, resolvedMode, scheme, setMode, setScheme, cycleMode } = useThemeController() + const user = useUnit($user) + const navigate = useNavigate() + const [anchorEl, setAnchorEl] = useState(null) + const menuOpen = Boolean(anchorEl) + + const onSchemeChange = (e: SelectChangeEvent) => { + setScheme(e.target.value as ColorScheme) + } + + const onModeChange = (e: SelectChangeEvent) => { + const v = e.target.value + if (v === 'system' || v === 'light' || v === 'dark') setMode(v) + } + + const openUserMenu = (e: React.MouseEvent) => setAnchorEl(e.currentTarget) + const closeUserMenu = () => setAnchorEl(null) + + const onLogout = () => { + tokenSet(null) + logout() + closeUserMenu() + navigate('/') + } + + const goToAuth = () => { + closeUserMenu() + navigate('/auth') + } + + const goToMe = () => { + closeUserMenu() + navigate('/me') + } + return ( @@ -27,6 +78,107 @@ export function MainLayout({ children }: PropsWithChildren) { + + + + + + + + {user ? ( + <> + + + + Выход + + ) : ( + Войти / регистрация + )} + + + + Тема + + + + + Режим + + + + + {resolvedMode === 'dark' ? : } + diff --git a/client/src/app/providers/AppProviders.tsx b/client/src/app/providers/AppProviders.tsx index 979d8cc..7237b57 100644 --- a/client/src/app/providers/AppProviders.tsx +++ b/client/src/app/providers/AppProviders.tsx @@ -2,6 +2,103 @@ import { type PropsWithChildren, useMemo } from 'react' import CssBaseline from '@mui/material/CssBaseline' import { ThemeProvider, createTheme } from '@mui/material/styles' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller' + +function AppThemeInner({ children }: PropsWithChildren) { + const controller = useThemeController() + + const theme = useMemo( + () => + createTheme({ + palette: (() => { + const isDark = controller.resolvedMode === 'dark' + const common = { mode: controller.resolvedMode } + + const text = isDark + ? { primary: '#F2F2F2', secondary: 'rgba(242,242,242,0.72)', disabled: 'rgba(242,242,242,0.48)' } + : { primary: '#1F1B16', secondary: 'rgba(31,27,22,0.72)', disabled: 'rgba(31,27,22,0.48)' } + + switch (controller.scheme) { + case 'forest': + return { + ...common, + primary: { main: isDark ? '#4CAF50' : '#2E7D32' }, + secondary: { main: isDark ? '#A1887F' : '#6D4C41' }, + info: { main: isDark ? '#29B6F6' : '#0288D1' }, + success: { main: isDark ? '#66BB6A' : '#2E7D32' }, + warning: { main: isDark ? '#FFB74D' : '#ED6C02' }, + error: { main: isDark ? '#EF5350' : '#D32F2F' }, + divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)', + text, + background: isDark + ? { default: '#0E1510', paper: '#121B14' } + : { default: '#F6FAF6', paper: '#FFFFFF' }, + } + case 'ocean': + return { + ...common, + primary: { main: isDark ? '#42A5F5' : '#1565C0' }, + secondary: { main: isDark ? '#4DD0E1' : '#00838F' }, + info: { main: isDark ? '#4FC3F7' : '#0288D1' }, + success: { main: isDark ? '#26C6DA' : '#00838F' }, + warning: { main: isDark ? '#FFCC80' : '#ED6C02' }, + error: { main: isDark ? '#EF5350' : '#D32F2F' }, + divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)', + text, + background: isDark + ? { default: '#0B1220', paper: '#0F172A' } + : { default: '#F6FAFF', paper: '#FFFFFF' }, + } + case 'berry': + return { + ...common, + primary: { main: isDark ? '#BA68C8' : '#7B1FA2' }, + secondary: { main: isDark ? '#F06292' : '#C2185B' }, + info: { main: isDark ? '#64B5F6' : '#1976D2' }, + success: { main: isDark ? '#81C784' : '#2E7D32' }, + warning: { main: isDark ? '#FFB74D' : '#ED6C02' }, + error: { main: isDark ? '#EF5350' : '#D32F2F' }, + divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)', + text, + background: isDark + ? { default: '#140A17', paper: '#1B0F20' } + : { default: '#FFF7FD', paper: '#FFFFFF' }, + } + case 'craft': + default: + return { + ...common, + primary: { main: isDark ? '#BCAAA4' : '#6D4C41' }, + secondary: { main: isDark ? '#FFCCBC' : '#8D6E63' }, + info: { main: isDark ? '#90CAF9' : '#1976D2' }, + success: { main: isDark ? '#A5D6A7' : '#2E7D32' }, + warning: { main: isDark ? '#FFB74D' : '#ED6C02' }, + error: { main: isDark ? '#EF9A9A' : '#D32F2F' }, + divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)', + text, + background: isDark + ? { default: '#12100F', paper: '#191615' } + : { default: '#FAF8F5', paper: '#FFFFFF' }, + } + } + })(), + shape: { borderRadius: 12 }, + typography: { + fontFamily: '"Segoe UI", system-ui, sans-serif', + h4: { fontWeight: 700 }, + h5: { fontWeight: 600 }, + }, + }), + [controller.resolvedMode, controller.scheme], + ) + + return ( + + + {children} + + ) +} export function AppProviders({ children }: PropsWithChildren) { const queryClient = useMemo( @@ -18,31 +115,11 @@ export function AppProviders({ children }: PropsWithChildren) { [], ) - const theme = useMemo( - () => - createTheme({ - palette: { - mode: 'light', - primary: { main: '#6d4c41' }, - secondary: { main: '#8d6e63' }, - background: { default: '#faf8f5', paper: '#ffffff' }, - }, - shape: { borderRadius: 12 }, - typography: { - fontFamily: '"Segoe UI", system-ui, sans-serif', - h4: { fontWeight: 700 }, - h5: { fontWeight: 600 }, - }, - }), - [], - ) - return ( - - - {children} - + + {children} + ) } diff --git a/client/src/app/providers/theme-controller.tsx b/client/src/app/providers/theme-controller.tsx new file mode 100644 index 0000000..b0937b0 --- /dev/null +++ b/client/src/app/providers/theme-controller.tsx @@ -0,0 +1,115 @@ +import { createContext, type PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react' +import type { PaletteMode } from '@mui/material' + +export type ColorScheme = 'craft' | 'forest' | 'ocean' | 'berry' + +export type ThemeModePreference = 'system' | PaletteMode + +export type ThemeSettings = { + mode: ThemeModePreference + scheme: ColorScheme +} + +export type ThemeController = ThemeSettings & { + /** Итоговый режим, учитывая system. */ + resolvedMode: PaletteMode + setMode: (mode: ThemeModePreference) => void + toggleMode: () => void + cycleMode: () => void + setScheme: (scheme: ColorScheme) => void +} + +const THEME_STORAGE_KEY = 'craftshop_theme' + +function readStoredTheme(): ThemeSettings | null { + try { + const raw = localStorage.getItem(THEME_STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) + const mode: unknown = parsed?.mode + const scheme: unknown = parsed?.scheme + const modeOk = mode === 'light' || mode === 'dark' || mode === 'system' + const schemeOk = scheme === 'craft' || scheme === 'forest' || scheme === 'ocean' || scheme === 'berry' + if (!modeOk || !schemeOk) return null + return { mode, scheme } + } catch { + return null + } +} + +function getSystemMode(): PaletteMode { + if (typeof window === 'undefined') return 'light' + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +function resolveMode(pref: ThemeModePreference): PaletteMode { + return pref === 'system' ? getSystemMode() : pref +} + +const ThemeControllerContext = createContext(null) + +export function useThemeController(): ThemeController { + const ctx = useContext(ThemeControllerContext) + if (!ctx) throw new Error('useThemeController must be used within ThemeControllerProvider') + return ctx +} + +export function ThemeControllerProvider({ children }: PropsWithChildren) { + const [settings, setSettings] = useState( + () => readStoredTheme() ?? { mode: 'system', scheme: 'craft' }, + ) + + const [systemMode, setSystemMode] = useState(() => getSystemMode()) + + useEffect(() => { + const mql = window.matchMedia?.('(prefers-color-scheme: dark)') + if (!mql) return + + const handler = () => setSystemMode(mql.matches ? 'dark' : 'light') + + // начальное значение + handler() + + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', handler) + return () => mql.removeEventListener('change', handler) + } + + // Safari старых версий + mql.addListener(handler) + return () => mql.removeListener(handler) + }, []) + + useEffect(() => { + try { + localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(settings)) + } catch { + // ignore + } + }, [settings]) + + const resolvedMode = settings.mode === 'system' ? systemMode : settings.mode + + const controller = useMemo( + () => ({ + mode: settings.mode, + resolvedMode, + scheme: settings.scheme, + setMode: (mode) => setSettings((s) => ({ ...s, mode })), + toggleMode: () => + setSettings((s) => ({ + ...s, + mode: resolveMode(s.mode) === 'light' ? 'dark' : 'light', + })), + cycleMode: () => + setSettings((s) => ({ + ...s, + mode: s.mode === 'system' ? 'light' : s.mode === 'light' ? 'dark' : 'system', + })), + setScheme: (scheme) => setSettings((s) => ({ ...s, scheme })), + }), + [resolvedMode, settings.mode, settings.scheme], + ) + + return {children} +} diff --git a/client/src/main.tsx b/client/src/main.tsx index 32fdd50..7e48bfd 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,6 +2,9 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from '@/app/App' import '@/app/styles/global.css' +import { readStoredToken, tokenSet } from '@/shared/model/auth' + +tokenSet(readStoredToken()) createRoot(document.getElementById('root')!).render( diff --git a/client/src/pages/admin/ui/AdminPage.tsx b/client/src/pages/admin/ui/AdminPage.tsx index abfabbb..957de20 100644 --- a/client/src/pages/admin/ui/AdminPage.tsx +++ b/client/src/pages/admin/ui/AdminPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' @@ -21,6 +21,7 @@ import TableRow from '@mui/material/TableRow' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Controller, useForm } from 'react-hook-form' import { createCategory, createProduct, @@ -55,14 +56,32 @@ const emptyForm = (): FormState => ({ export function AdminPage() { const queryClient = useQueryClient() - const [tokenInput, setTokenInput] = useState('') const [token, setToken] = useState(() => getAdminToken()) const [dialogOpen, setDialogOpen] = useState(false) const [editing, setEditing] = useState(null) - const [form, setForm] = useState(emptyForm) const [catOpen, setCatOpen] = useState(false) - const [catName, setCatName] = useState('') - const [catSlug, setCatSlug] = useState('') + + const tokenForm = useForm<{ token: string }>({ + defaultValues: { token: '' }, + mode: 'onChange', + }) + + const productForm = useForm({ + defaultValues: emptyForm(), + mode: 'onChange', + }) + + const categoryForm = useForm<{ name: string; slug: string }>({ + defaultValues: { name: '', slug: '' }, + mode: 'onChange', + }) + + const titleValue = productForm.watch('title') + const categoryIdValue = productForm.watch('categoryId') + + useEffect(() => { + tokenForm.reset({ token: '' }) + }, [token, tokenForm]) const categoriesQuery = useQuery({ queryKey: ['categories'], @@ -76,7 +95,7 @@ export function AdminPage() { }) const saveToken = () => { - const t = tokenInput.trim() + const t = tokenForm.getValues('token').trim() if (!t) { clearAdminToken() setToken(null) @@ -88,13 +107,13 @@ export function AdminPage() { const openCreate = () => { setEditing(null) - setForm(emptyForm()) + productForm.reset(emptyForm()) setDialogOpen(true) } const openEdit = (p: Product) => { setEditing(p) - setForm({ + productForm.reset({ title: p.title, slug: p.slug, description: p.description ?? '', @@ -108,6 +127,7 @@ export function AdminPage() { const createMut = useMutation({ mutationFn: async () => { + const form = productForm.getValues() const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') await createProduct(token!, { @@ -129,6 +149,7 @@ export function AdminPage() { const updateMut = useMutation({ mutationFn: async () => { + const form = productForm.getValues() const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') await updateProduct(token!, editing!.id, { @@ -157,16 +178,17 @@ export function AdminPage() { }) const createCategoryMut = useMutation({ - mutationFn: () => - createCategory(token!, { - name: catName.trim(), - slug: catSlug.trim() || undefined, - }), + mutationFn: () => { + const v = categoryForm.getValues() + return createCategory(token!, { + name: v.name.trim(), + slug: v.slug.trim() || undefined, + }) + }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['categories'] }) setCatOpen(false) - setCatName('') - setCatSlug('') + categoryForm.reset({ name: '', slug: '' }) }, }) @@ -191,13 +213,18 @@ export function AdminPage() { - setTokenInput(e.target.value)} - placeholder={token ? '••••••••' : ''} + ( + + )} /> @@ -343,19 +370,22 @@ export function AdminPage() { Новая категория - setCatName(e.target.value)} + } /> - setCatSlug(e.target.value)} - helperText="Необязательно — можно сгенерировать из названия на сервере" + ( + + )} /> @@ -363,7 +393,7 @@ export function AdminPage() { + + + + + + + Вариант 2: Email + пароль + + + + + + + + ) +} diff --git a/client/src/pages/me/index.ts b/client/src/pages/me/index.ts new file mode 100644 index 0000000..69312e9 --- /dev/null +++ b/client/src/pages/me/index.ts @@ -0,0 +1,2 @@ +export { MePage } from './ui/MePage' + diff --git a/client/src/pages/me/ui/MePage.tsx b/client/src/pages/me/ui/MePage.tsx new file mode 100644 index 0000000..e535e1b --- /dev/null +++ b/client/src/pages/me/ui/MePage.tsx @@ -0,0 +1,178 @@ +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Divider from '@mui/material/Divider' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useUnit } from 'effector-react' +import { useForm } from 'react-hook-form' +import { + $changePasswordError, + $requestEmailChangeCodeError, + $updateProfileError, + $user, + $verifyEmailChangeError, + changePasswordFx, + requestEmailChangeCodeFx, + updateProfileFx, + verifyEmailChangeFx, +} from '@/shared/model/auth' + +export function MePage() { + const user = useUnit($user) + const pendingPassword = useUnit(changePasswordFx.pending) + const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending) + const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending) + const pendingProfile = useUnit(updateProfileFx.pending) + const errorPassword = useUnit($changePasswordError) + const errorEmailReq = useUnit($requestEmailChangeCodeError) + const errorProfile = useUnit($updateProfileError) + const errorEmailVerify = useUnit($verifyEmailChangeError) + + const passwordForm = useForm<{ currentPassword: string; newPassword: string }>({ + defaultValues: { currentPassword: '', newPassword: '' }, + mode: 'onChange', + }) + + const emailForm = useForm<{ newEmail: string; code: string }>({ + defaultValues: { newEmail: '', code: '' }, + mode: 'onChange', + }) + + const profileForm = useForm<{ name: string }>({ + defaultValues: { name: user?.name ? String(user.name) : '' }, + mode: 'onChange', + }) + + const passwordErrorMsg = + (errorPassword as any)?.response?.data?.error ? String((errorPassword as any).response.data.error) : null + const emailErrorMsg = + (errorEmailReq as any)?.response?.data?.error + ? String((errorEmailReq as any).response.data.error) + : (errorEmailVerify as any)?.response?.data?.error + ? String((errorEmailVerify as any).response.data.error) + : null + const profileErrorMsg = + (errorProfile as any)?.response?.data?.error ? String((errorProfile as any).response.data.error) : null + + if (!user) { + return Нужно войти. Перейдите на страницу «Вход». + } + + return ( + + + Профиль + + + Текущая почта: {user.email} + + + {passwordErrorMsg && ( + + {passwordErrorMsg} + + )} + {emailErrorMsg && ( + + {emailErrorMsg} + + )} + {profileErrorMsg && ( + + {profileErrorMsg} + + )} + + + + + Имя / ник + + + + + + + + + + + + Смена почты + + + + + + + + + + + + + + + + Смена пароля + + + + + + + + + + ) +} + diff --git a/client/src/shared/api/client.ts b/client/src/shared/api/client.ts index 16d3457..9ff70aa 100644 --- a/client/src/shared/api/client.ts +++ b/client/src/shared/api/client.ts @@ -5,3 +5,15 @@ export const apiClient = axios.create({ baseURL: apiBaseURL, headers: { 'Content-Type': 'application/json' }, }) + +apiClient.interceptors.request.use((config) => { + try { + const token = localStorage.getItem('craftshop_auth_token') + if (!token) return config + config.headers = config.headers ?? {} + config.headers.Authorization = `Bearer ${token}` + return config + } catch { + return config + } +}) diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts new file mode 100644 index 0000000..46b1bf2 --- /dev/null +++ b/client/src/shared/model/auth.ts @@ -0,0 +1,98 @@ +import { createEffect, createEvent, createStore, sample } from 'effector' +import { apiClient } from '@/shared/api/client' + +export type AuthUser = { id: string; email: string; name?: string | null } + +const TOKEN_KEY = 'craftshop_auth_token' + +export const tokenSet = createEvent() +export const logout = createEvent() + +export const $token = createStore(null) + .on(tokenSet, (_, t) => t) + .reset(logout) + +export const $user = createStore(null).reset(logout) + +export const changePasswordFx = createEffect(async (params: { currentPassword?: string; newPassword: string }) => { + const { data } = await apiClient.patch<{ user: AuthUser }>('me/password', params) + return data.user +}) + +export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => { + await apiClient.post('me/change-email/request-code', { newEmail }) +}) + +export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => { + const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params) + return data.user +}) + +export const updateProfileFx = createEffect(async (params: { name: string | null }) => { + const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params) + return data.user +}) + +export const $changePasswordError = createStore(null) + .on(changePasswordFx.failData, (_, e) => e) + .reset(changePasswordFx, logout) + +export const $requestEmailChangeCodeError = createStore(null) + .on(requestEmailChangeCodeFx.failData, (_, e) => e) + .reset(requestEmailChangeCodeFx, logout) + +export const $verifyEmailChangeError = createStore(null) + .on(verifyEmailChangeFx.failData, (_, e) => e) + .reset(verifyEmailChangeFx, logout) + +export const $updateProfileError = createStore(null) + .on(updateProfileFx.failData, (_, e) => e) + .reset(updateProfileFx, logout) + +export const meFx = createEffect(async (token: string) => { + const { data } = await apiClient.get<{ user: AuthUser | null }>('me', { + headers: { Authorization: `Bearer ${token}` }, + }) + return data.user +}) + +sample({ + clock: tokenSet, + filter: (t): t is string => Boolean(t), + target: meFx, +}) + +sample({ + clock: meFx.doneData, + target: $user, +}) + +sample({ + clock: [changePasswordFx.doneData, verifyEmailChangeFx.doneData, updateProfileFx.doneData], + target: $user, +}) + +$token.watch((t) => { + try { + if (!t) localStorage.removeItem(TOKEN_KEY) + else localStorage.setItem(TOKEN_KEY, t) + } catch { + // ignore + } +}) + +logout.watch(() => { + try { + localStorage.removeItem(TOKEN_KEY) + } catch { + // ignore + } +}) + +export function readStoredToken(): string | null { + try { + return localStorage.getItem(TOKEN_KEY) + } catch { + return null + } +} diff --git a/server/package-lock.json b/server/package-lock.json index 8a8ebe3..8829e3b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -7,15 +7,17 @@ "": { "name": "server", "version": "1.0.0", - "license": "ISC", "dependencies": { "@fastify/cors": "^11.2.0", - "@prisma/client": "^5.22.0", + "@fastify/jwt": "^10.0.0", + "@prisma/client": "5.22.0", + "bcryptjs": "^3.0.3", "dotenv": "^17.4.2", - "fastify": "^5.8.5" + "fastify": "^5.8.5", + "nodemailer": "^8.0.7" }, "devDependencies": { - "prisma": "^5.22.0" + "prisma": "5.22.0" } }, "node_modules/@fastify/ajv-compiler": { @@ -110,6 +112,29 @@ ], "license": "MIT" }, + "node_modules/@fastify/jwt": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz", + "integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.2.0", + "@lukeed/ms": "^2.0.2", + "fast-jwt": "^6.0.2", + "fastify-plugin": "^5.0.1", + "steed": "^1.1.3" + } + }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", @@ -149,6 +174,15 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -262,6 +296,18 @@ } } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -291,6 +337,21 @@ "fastq": "^1.17.1" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -325,6 +386,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -361,6 +431,22 @@ "rfdc": "^1.2.0" } }, + "node_modules/fast-jwt": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.2.3.tgz", + "integrity": "sha512-A8NqEOEO7tvUHx+ZXxc+qcbCbZXrBEvANATx5MqppygvJh1F+cHGtDuRuJjehsXDplbfCodNOFcyeWwB+ZIRyw==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0", + "safe-regex2": "^5.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -386,6 +472,18 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fastify": { "version": "5.8.5", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", @@ -435,6 +533,16 @@ ], "license": "MIT" }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -444,6 +552,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fastseries": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", + "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.0", + "xtend": "^4.0.0" + } + }, "node_modules/find-my-way": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", @@ -473,6 +591,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -544,6 +668,36 @@ ], "license": "MIT" }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/mnemonist": { + "version": "0.40.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -676,6 +830,26 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -707,6 +881,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -759,6 +939,19 @@ "node": ">= 10.x" } }, + "node_modules/steed": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", + "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", + "license": "MIT", + "dependencies": { + "fastfall": "^1.5.0", + "fastparallel": "^2.2.0", + "fastq": "^1.3.0", + "fastseries": "^1.7.0", + "reusify": "^1.0.0" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -779,6 +972,15 @@ "engines": { "node": ">=12" } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/server/package.json b/server/package.json index 4e09917..ca24dce 100644 --- a/server/package.json +++ b/server/package.json @@ -15,9 +15,12 @@ }, "dependencies": { "@fastify/cors": "^11.2.0", + "@fastify/jwt": "^10.0.0", "@prisma/client": "5.22.0", + "bcryptjs": "^3.0.3", "dotenv": "^17.4.2", - "fastify": "^5.8.5" + "fastify": "^5.8.5", + "nodemailer": "^8.0.7" }, "devDependencies": { "prisma": "5.22.0" diff --git a/server/prisma/migrations/20260428160544_auth/migration.sql b/server/prisma/migrations/20260428160544_auth/migration.sql new file mode 100644 index 0000000..e5902a4 --- /dev/null +++ b/server/prisma/migrations/20260428160544_auth/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "passwordHash" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "AuthCode" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "usedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT, + CONSTRAINT "AuthCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE INDEX "AuthCode_email_purpose_idx" ON "AuthCode"("email", "purpose"); + +-- CreateIndex +CREATE INDEX "AuthCode_expiresAt_idx" ON "AuthCode"("expiresAt"); diff --git a/server/prisma/migrations/20260428163250_add_user_name/migration.sql b/server/prisma/migrations/20260428163250_add_user_name/migration.sql new file mode 100644 index 0000000..fc3b9dd --- /dev/null +++ b/server/prisma/migrations/20260428163250_add_user_name/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "name" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 3c8a038..d2c253c 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -30,3 +30,30 @@ model Product { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model User { + id String @id @default(cuid()) + email String @unique + name String? + passwordHash String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + codes AuthCode[] +} + +model AuthCode { + id String @id @default(cuid()) + email String + codeHash String + purpose String + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String? + + @@index([email, purpose]) + @@index([expiresAt]) +} diff --git a/server/src/index.js b/server/src/index.js index df6c690..e665b50 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1,8 +1,10 @@ import 'dotenv/config' import Fastify from 'fastify' import cors from '@fastify/cors' +import jwt from '@fastify/jwt' import { registerAuth } from './plugins/auth.js' import { registerApiRoutes } from './routes/api.js' +import { registerAuthRoutes } from './routes/auth.js' const port = Number(process.env.PORT) || 3333 const origin = (process.env.CORS_ORIGIN ?? '') @@ -17,7 +19,20 @@ await fastify.register(cors, { credentials: true, }) +await fastify.register(jwt, { + secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me', +}) + +fastify.decorate('authenticate', async function authenticate(request, reply) { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Не авторизован' }) + } +}) + registerAuth(fastify) +await registerAuthRoutes(fastify) await registerApiRoutes(fastify) fastify.get('/health', async () => ({ ok: true })) diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js new file mode 100644 index 0000000..e49103d --- /dev/null +++ b/server/src/lib/auth.js @@ -0,0 +1,64 @@ +import crypto from 'node:crypto' +import bcrypt from 'bcryptjs' +import { prisma } from './prisma.js' +import { sendLoginCodeEmail } from './email.js' + +export function normalizeEmail(email) { + return String(email || '').trim().toLowerCase() +} + +export function randomCode6() { + return String(Math.floor(100000 + Math.random() * 900000)) +} + +export function sha256(input) { + return crypto.createHash('sha256').update(input).digest('hex') +} + +export async function issueEmailCode({ email, purpose, userId = null }) { + const code = randomCode6() + const expiresAt = new Date(Date.now() + 10 * 60 * 1000) + await prisma.authCode.create({ + data: { + email, + purpose, + userId, + codeHash: sha256(`${email}:${purpose}:${code}:${userId ?? ''}`), + expiresAt, + }, + }) + await sendLoginCodeEmail({ to: email, code }) +} + +export async function verifyEmailCode({ email, purpose, code, userId = null }) { + const now = new Date() + const codeHash = sha256(`${email}:${purpose}:${code}:${userId ?? ''}`) + + const found = await prisma.authCode.findFirst({ + where: { + email, + purpose, + userId, + codeHash, + usedAt: null, + expiresAt: { gt: now }, + }, + orderBy: { createdAt: 'desc' }, + }) + if (!found) return false + + await prisma.authCode.update({ + where: { id: found.id }, + data: { usedAt: now }, + }) + return true +} + +export async function hashPassword(password) { + return bcrypt.hash(password, 10) +} + +export async function verifyPassword(password, passwordHash) { + return bcrypt.compare(password, passwordHash) +} + diff --git a/server/src/lib/email.js b/server/src/lib/email.js new file mode 100644 index 0000000..97b4c9d --- /dev/null +++ b/server/src/lib/email.js @@ -0,0 +1,33 @@ +import nodemailer from 'nodemailer' + +function hasSmtpEnv() { + return Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS) +} + +export async function sendLoginCodeEmail({ to, code }) { + if (!hasSmtpEnv()) { + // dev fallback + console.log(`[DEV] login code for ${to}: ${code}`) + return + } + + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }) + + const from = process.env.MAIL_FROM || process.env.SMTP_USER + + await transporter.sendMail({ + from, + to, + subject: 'Код входа', + text: `Ваш код: ${code}\n\nЕсли это были не вы — просто проигнорируйте письмо.`, + }) +} + diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100644 index 0000000..0220410 --- /dev/null +++ b/server/src/routes/auth.js @@ -0,0 +1,158 @@ +import { prisma } from '../lib/prisma.js' +import { hashPassword, issueEmailCode, normalizeEmail, verifyEmailCode, verifyPassword } from '../lib/auth.js' + +export async function registerAuthRoutes(fastify) { + fastify.post('/api/auth/request-code', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + + // purpose: login (включает и регистрацию — пользователь создастся при verify) + await issueEmailCode({ email, purpose: 'login' }) + return { ok: true } + }) + + fastify.post('/api/auth/verify-code', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const code = String(request.body?.code || '').trim() + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) + + const ok = await verifyEmailCode({ email, purpose: 'login', code }) + if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) + + const user = await prisma.user.upsert({ + where: { email }, + update: {}, + create: { email }, + }) + + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return { token, user: { id: user.id, email: user.email, name: user.name } } + }) + + fastify.post('/api/auth/register', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const password = String(request.body?.password || '') + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (password.length < 8) return reply.code(400).send({ error: 'Пароль минимум 8 символов' }) + + const existing = await prisma.user.findUnique({ where: { email } }) + if (existing) return reply.code(409).send({ error: 'Пользователь уже существует' }) + + const passwordHash = await hashPassword(password) + const user = await prisma.user.create({ data: { email, passwordHash } }) + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return reply.code(201).send({ token, user: { id: user.id, email: user.email, name: user.name } }) + }) + + fastify.post('/api/auth/login', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const password = String(request.body?.password || '') + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (!password) return reply.code(400).send({ error: 'Укажите пароль' }) + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user?.passwordHash) return reply.code(401).send({ error: 'Неверные данные' }) + + const ok = await verifyPassword(password, user.passwordHash) + if (!ok) return reply.code(401).send({ error: 'Неверные данные' }) + + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return { token, user: { id: user.id, email: user.email, name: user.name } } + }) + + fastify.get( + '/api/me', + { preHandler: [fastify.authenticate] }, + async (request) => { + const userId = request.user.sub + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return { user: null } + return { user: { id: user.id, email: user.email, name: user.name } } + }, + ) + + fastify.post( + '/api/me/change-email/request-code', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const newEmail = normalizeEmail(request.body?.newEmail) + if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + + const exists = await prisma.user.findUnique({ where: { email: newEmail } }) + if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) + + await issueEmailCode({ email: newEmail, purpose: 'change_email', userId }) + return { ok: true } + }, + ) + + fastify.post( + '/api/me/change-email/verify', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const newEmail = normalizeEmail(request.body?.newEmail) + const code = String(request.body?.code || '').trim() + if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) + + const exists = await prisma.user.findUnique({ where: { email: newEmail } }) + if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) + + const ok = await verifyEmailCode({ email: newEmail, purpose: 'change_email', code, userId }) + if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) + + const user = await prisma.user.update({ + where: { id: userId }, + data: { email: newEmail }, + }) + return { user: { id: user.id, email: user.email, name: user.name } } + }, + ) + + fastify.patch( + '/api/me/password', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const currentPassword = request.body?.currentPassword ? String(request.body.currentPassword) : '' + const newPassword = String(request.body?.newPassword || '') + + if (newPassword.length < 8) return reply.code(400).send({ error: 'Новый пароль минимум 8 символов' }) + + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) + + if (user.passwordHash) { + if (!currentPassword) return reply.code(400).send({ error: 'Укажите текущий пароль' }) + const ok = await verifyPassword(currentPassword, user.passwordHash) + if (!ok) return reply.code(401).send({ error: 'Текущий пароль неверный' }) + } + + const passwordHash = await hashPassword(newPassword) + const updated = await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) + return { user: { id: updated.id, email: updated.email, name: updated.name } } + }, + ) + + fastify.patch( + '/api/me/profile', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const nameRaw = request.body?.name + const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + + if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + + const updated = await prisma.user.update({ + where: { id: userId }, + data: { name: name && name.length ? name : null }, + }) + return { user: { id: updated.id, email: updated.email, name: updated.name } } + }, + ) +} +