base commit
This commit is contained in:
@@ -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: {
|
||||
|
||||
Generated
+100
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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() {
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
|
||||
@@ -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 | HTMLElement>(null)
|
||||
const menuOpen = Boolean(anchorEl)
|
||||
|
||||
const onSchemeChange = (e: SelectChangeEvent<string>) => {
|
||||
setScheme(e.target.value as ColorScheme)
|
||||
}
|
||||
|
||||
const onModeChange = (e: SelectChangeEvent<string>) => {
|
||||
const v = e.target.value
|
||||
if (v === 'system' || v === 'light' || v === 'dark') setMode(v)
|
||||
}
|
||||
|
||||
const openUserMenu = (e: React.MouseEvent<HTMLElement>) => 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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
@@ -27,6 +78,107 @@ export function MainLayout({ children }: PropsWithChildren) {
|
||||
<Button component={RouterLink} to="/admin" color="inherit">
|
||||
Админка
|
||||
</Button>
|
||||
|
||||
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
|
||||
<Badge
|
||||
variant="dot"
|
||||
color="success"
|
||||
overlap="circular"
|
||||
invisible={!user}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<AccountCircleOutlinedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={menuOpen}
|
||||
onClose={closeUserMenu}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
{user ? (
|
||||
<>
|
||||
<MenuItem onClick={goToMe}>
|
||||
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onLogout}>Выход</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem onClick={goToAuth}>Войти / регистрация</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 2,
|
||||
minWidth: 160,
|
||||
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
|
||||
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
|
||||
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
|
||||
}}
|
||||
>
|
||||
<InputLabel id="scheme-label">Тема</InputLabel>
|
||||
<Select
|
||||
labelId="scheme-label"
|
||||
value={scheme}
|
||||
label="Тема"
|
||||
onChange={onSchemeChange}
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
|
||||
'.MuiSvgIcon-root': { color: 'inherit' },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="craft">Крафт</MenuItem>
|
||||
<MenuItem value="forest">Лес</MenuItem>
|
||||
<MenuItem value="ocean">Океан</MenuItem>
|
||||
<MenuItem value="berry">Ягоды</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 2,
|
||||
minWidth: 150,
|
||||
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
|
||||
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
|
||||
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
|
||||
}}
|
||||
>
|
||||
<InputLabel id="mode-label">Режим</InputLabel>
|
||||
<Select
|
||||
labelId="mode-label"
|
||||
value={mode}
|
||||
label="Режим"
|
||||
onChange={onModeChange}
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
|
||||
'.MuiSvgIcon-root': { color: 'inherit' },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="system">Авто (система)</MenuItem>
|
||||
<MenuItem value="light">Светлая</MenuItem>
|
||||
<MenuItem value="dark">Тёмная</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={cycleMode}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label="Переключить режим темы (авто/светлая/тёмная)"
|
||||
title={`Сейчас: ${mode === 'system' ? `авто (${resolvedMode})` : mode}`}
|
||||
>
|
||||
{resolvedMode === 'dark' ? <LightModeOutlinedIcon /> : <DarkModeOutlinedIcon />}
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box component="main" sx={{ flex: 1, py: 3 }}>
|
||||
|
||||
@@ -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 (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
<ThemeControllerProvider>
|
||||
<AppThemeInner>{children}</AppThemeInner>
|
||||
</ThemeControllerProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ThemeController | null>(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<ThemeSettings>(
|
||||
() => readStoredTheme() ?? { mode: 'system', scheme: 'craft' },
|
||||
)
|
||||
|
||||
const [systemMode, setSystemMode] = useState<PaletteMode>(() => 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<ThemeController>(
|
||||
() => ({
|
||||
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 <ThemeControllerContext.Provider value={controller}>{children}</ThemeControllerContext.Provider>
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
|
||||
@@ -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<string | null>(() => getAdminToken())
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Product | null>(null)
|
||||
const [form, setForm] = useState<FormState>(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<FormState>({
|
||||
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,14 +213,19 @@ export function AdminPage() {
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
|
||||
<Controller
|
||||
control={tokenForm.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Токен (Bearer)"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
{...field}
|
||||
placeholder={token ? '••••••••' : ''}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
|
||||
Сохранить
|
||||
</Button>
|
||||
@@ -267,48 +294,45 @@ export function AdminPage() {
|
||||
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Название"
|
||||
fullWidth
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="title"
|
||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Slug (URL)"
|
||||
fullWidth
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
|
||||
{...field}
|
||||
helperText="Можно оставить пустым при создании — сгенерируется из названия"
|
||||
/>
|
||||
<TextField
|
||||
label="Описание"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
label="Цена, ₽"
|
||||
fullWidth
|
||||
value={form.priceRub}
|
||||
onChange={(e) => setForm((f) => ({ ...f, priceRub: e.target.value }))}
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="description"
|
||||
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
|
||||
/>
|
||||
<TextField
|
||||
label="Ссылка на изображение"
|
||||
fullWidth
|
||||
value={form.imageUrl}
|
||||
onChange={(e) => setForm((f) => ({ ...f, imageUrl: e.target.value }))}
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="priceRub"
|
||||
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="imageUrl"
|
||||
render={({ field }) => <TextField label="Ссылка на изображение" fullWidth {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="categoryId"
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel id="cat-label">Категория</InputLabel>
|
||||
<Select
|
||||
labelId="cat-label"
|
||||
label="Категория"
|
||||
value={form.categoryId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, categoryId: String(e.target.value) }))}
|
||||
>
|
||||
<Select labelId="cat-label" label="Категория" {...field}>
|
||||
{(categoriesQuery.data ?? []).map((c) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
@@ -316,15 +340,18 @@ export function AdminPage() {
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.published}
|
||||
onChange={(e) => setForm((f) => ({ ...f, published: e.target.checked }))}
|
||||
)}
|
||||
/>
|
||||
}
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="published"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Показывать в каталоге"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@@ -332,7 +359,7 @@ export function AdminPage() {
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={!form.title.trim() || !form.categoryId || createMut.isPending || updateMut.isPending}
|
||||
disabled={!titleValue.trim() || !categoryIdValue || createMut.isPending || updateMut.isPending}
|
||||
>
|
||||
{editing ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
@@ -343,27 +370,30 @@ export function AdminPage() {
|
||||
<DialogTitle>Новая категория</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Название"
|
||||
fullWidth
|
||||
required
|
||||
value={catName}
|
||||
onChange={(e) => setCatName(e.target.value)}
|
||||
<Controller
|
||||
control={categoryForm.control}
|
||||
name="name"
|
||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={categoryForm.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Slug"
|
||||
fullWidth
|
||||
value={catSlug}
|
||||
onChange={(e) => setCatSlug(e.target.value)}
|
||||
{...field}
|
||||
helperText="Необязательно — можно сгенерировать из названия на сервере"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCatOpen(false)}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!catName.trim() || createCategoryMut.isPending}
|
||||
disabled={!categoryForm.watch('name').trim() || createCategoryMut.isPending}
|
||||
onClick={() => createCategoryMut.mutate()}
|
||||
>
|
||||
Создать
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AuthPage } from './ui/AuthPage'
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react'
|
||||
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 { useMutation } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { tokenSet } from '@/shared/model/auth'
|
||||
|
||||
type AuthResponse = { token: string; user: { id: string; email: string } }
|
||||
|
||||
function getApiErrorMessage(err: unknown): string | null {
|
||||
if (!err || typeof err !== 'object') return null
|
||||
const anyErr = err as Record<string, unknown>
|
||||
const response = anyErr.response as Record<string, unknown> | undefined
|
||||
const data = response?.data as Record<string, unknown> | undefined
|
||||
const msg = data?.error
|
||||
return typeof msg === 'string' ? msg : null
|
||||
}
|
||||
|
||||
export function AuthPage() {
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const { register, watch } = useForm<{
|
||||
email: string
|
||||
code: string
|
||||
password: string
|
||||
}>({
|
||||
defaultValues: { email: '', code: '', password: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const email = watch('email')
|
||||
const code = watch('code')
|
||||
const password = watch('password')
|
||||
|
||||
const requestCode = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('auth/request-code', { email })
|
||||
},
|
||||
onSuccess: () => setMessage('Код отправлен. Проверьте почту (в dev может быть в логах сервера).'),
|
||||
})
|
||||
|
||||
const verifyCode = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
|
||||
tokenSet(data.token)
|
||||
setMessage(`Вход выполнен: ${data.user.email}`)
|
||||
},
|
||||
})
|
||||
|
||||
const registerPassword = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/register', { email, password })
|
||||
tokenSet(data.token)
|
||||
setMessage(`Регистрация выполнена: ${data.user.email}`)
|
||||
},
|
||||
})
|
||||
|
||||
const loginPassword = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/login', { email, password })
|
||||
tokenSet(data.token)
|
||||
setMessage(`Вход выполнен: ${data.user.email}`)
|
||||
},
|
||||
})
|
||||
|
||||
const errMsg = getApiErrorMessage(
|
||||
requestCode.error || verifyCode.error || registerPassword.error || loginPassword.error,
|
||||
)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Вход / регистрация
|
||||
</Typography>
|
||||
|
||||
{message && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
{errMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{errMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack spacing={2} sx={{ maxWidth: 520 }}>
|
||||
<TextField label="Email" {...register('email')} fullWidth />
|
||||
|
||||
<Typography variant="h6">Вариант 1: Email + код</Typography>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<Button variant="outlined" onClick={() => requestCode.mutate()} disabled={!email || requestCode.isPending}>
|
||||
Отправить код
|
||||
</Button>
|
||||
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} />
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => verifyCode.mutate()}
|
||||
disabled={!email || code.length !== 6 || verifyCode.isPending}
|
||||
>
|
||||
Войти
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Typography variant="h6">Вариант 2: Email + пароль</Typography>
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
{...register('password')}
|
||||
fullWidth
|
||||
helperText="Минимум 8 символов для регистрации"
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => registerPassword.mutate()}
|
||||
disabled={!email || password.length < 8 || registerPassword.isPending}
|
||||
>
|
||||
Зарегистрироваться
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => loginPassword.mutate()}
|
||||
disabled={!email || !password || loginPassword.isPending}
|
||||
>
|
||||
Войти
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MePage } from './ui/MePage'
|
||||
|
||||
@@ -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 <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Профиль
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||
Текущая почта: <b>{user.email}</b>
|
||||
</Typography>
|
||||
|
||||
{passwordErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{passwordErrorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
{emailErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{emailErrorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
{profileErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{profileErrorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack spacing={3} sx={{ maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Имя / ник
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Имя или ник"
|
||||
helperText="До 40 символов"
|
||||
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||||
{...profileForm.register('name')}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={pendingProfile}
|
||||
onClick={() => {
|
||||
const raw = profileForm.getValues('name')
|
||||
const name = raw.trim()
|
||||
updateProfileFx({ name: name.length ? name : null })
|
||||
}}
|
||||
>
|
||||
Сохранить имя
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Смена почты
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField label="Новая почта" {...emailForm.register('newEmail')} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!emailForm.watch('newEmail') || pendingEmailReq}
|
||||
onClick={() => requestEmailChangeCodeFx(emailForm.getValues('newEmail').trim())}
|
||||
>
|
||||
Отправить код на новую почту
|
||||
</Button>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<TextField label="Код (6 цифр)" inputMode="numeric" {...emailForm.register('code')} />
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={emailForm.watch('code').trim().length !== 6 || pendingEmailVerify}
|
||||
onClick={() =>
|
||||
verifyEmailChangeFx({
|
||||
newEmail: emailForm.getValues('newEmail').trim(),
|
||||
code: emailForm.getValues('code').trim(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Подтвердить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Смена пароля
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Текущий пароль (если установлен)"
|
||||
type="password"
|
||||
{...passwordForm.register('currentPassword')}
|
||||
/>
|
||||
<TextField label="Новый пароль" type="password" {...passwordForm.register('newPassword')} />
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={passwordForm.watch('newPassword').length < 8 || pendingPassword}
|
||||
onClick={() =>
|
||||
changePasswordFx({
|
||||
currentPassword: passwordForm.getValues('currentPassword') || undefined,
|
||||
newPassword: passwordForm.getValues('newPassword'),
|
||||
})
|
||||
}
|
||||
>
|
||||
Сохранить пароль
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<string | null>()
|
||||
export const logout = createEvent()
|
||||
|
||||
export const $token = createStore<string | null>(null)
|
||||
.on(tokenSet, (_, t) => t)
|
||||
.reset(logout)
|
||||
|
||||
export const $user = createStore<AuthUser | null>(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<unknown | null>(null)
|
||||
.on(changePasswordFx.failData, (_, e) => e)
|
||||
.reset(changePasswordFx, logout)
|
||||
|
||||
export const $requestEmailChangeCodeError = createStore<unknown | null>(null)
|
||||
.on(requestEmailChangeCodeFx.failData, (_, e) => e)
|
||||
.reset(requestEmailChangeCodeFx, logout)
|
||||
|
||||
export const $verifyEmailChangeError = createStore<unknown | null>(null)
|
||||
.on(verifyEmailChangeFx.failData, (_, e) => e)
|
||||
.reset(verifyEmailChangeFx, logout)
|
||||
|
||||
export const $updateProfileError = createStore<unknown | null>(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
|
||||
}
|
||||
}
|
||||
Generated
+206
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "name" TEXT;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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Если это были не вы — просто проигнорируйте письмо.`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user