mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
updated email verification and page layout
This commit is contained in:
1 parent
2befe3eb7d
commit
0ed8fc9ac0
9 files changed
+619
-106
No files matched your search
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-15 19:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0003_flavor_shop_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='email_confirmation_code',
|
||||
field=models.CharField(blank=True, max_length=6, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='pending_email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True),
|
||||
),
|
||||
]
|
||||
@@ -10,6 +10,8 @@ class User(AbstractUser):
|
||||
('mocha', 'Mocha'),
|
||||
]
|
||||
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='mocha')
|
||||
pending_email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
+135
-12
@@ -41,35 +41,100 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == 'signup':
|
||||
if self.action in ['signup', 'verify_signup', 'request_password_reset', 'complete_password_reset']:
|
||||
return [permissions.AllowAny()]
|
||||
return super().get_permissions()
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def signup(self, request):
|
||||
username = request.data.get('username')
|
||||
email = request.data.get('email')
|
||||
if not username or not email:
|
||||
return Response({'error': 'Username and email required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
password = request.data.get('password')
|
||||
if not username or not email or not password:
|
||||
return Response({'error': 'Username, email and password required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
return Response({'error': 'Username already exists'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if User.objects.filter(email=email).exists():
|
||||
return Response({'error': 'Email already exists'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Create user with random password
|
||||
import secrets
|
||||
password = secrets.token_urlsafe(10)
|
||||
user = User.objects.create_user(username=username, email=email, password=password)
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
email=email,
|
||||
password=password,
|
||||
is_active=False,
|
||||
email_confirmation_code=code
|
||||
)
|
||||
|
||||
# Send "Password Reset" / Welcome Email
|
||||
send_mail(
|
||||
'Welcome to Holy Flavors!',
|
||||
f'Hi {username},\n\nYour account has been created. Your temporary password is: {password}\n\nPlease login and change it.',
|
||||
'from@holyflavors.com',
|
||||
'Verify your Holy Flavors account',
|
||||
f'Hi {username},\n\nYour verification code is: {code}',
|
||||
'noreply@holyflavors.com',
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
return Response({'status': 'User created, check console/email for temporary password'}, status=status.HTTP_201_CREATED)
|
||||
return Response({'status': 'User created, please verify your email'}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def verify_signup(self, request):
|
||||
username = request.data.get('username')
|
||||
code = request.data.get('code')
|
||||
try:
|
||||
user = User.objects.get(username=username, is_active=False)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if user.email_confirmation_code == code:
|
||||
user.is_active = True
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({'status': 'Account verified successfully!'})
|
||||
else:
|
||||
return Response({'error': 'Invalid verification code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def request_password_reset(self, request):
|
||||
email = request.data.get('email')
|
||||
try:
|
||||
user = User.objects.get(email=email, is_active=True)
|
||||
except User.DoesNotExist:
|
||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
||||
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
send_mail(
|
||||
'Password Reset Request',
|
||||
f'Hi {user.username},\n\nYour password reset code is: {code}',
|
||||
'noreply@holyflavors.com',
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def complete_password_reset(self, request):
|
||||
email = request.data.get('email')
|
||||
code = request.data.get('code')
|
||||
new_password = request.data.get('password')
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email, email_confirmation_code=code)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'Invalid email or code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({'status': 'Password reset successful!'})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def me(self, request):
|
||||
@@ -86,6 +151,64 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
return Response({'status': 'theme updated', 'theme': theme})
|
||||
return Response({'error': 'Invalid theme'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def change_password(self, request):
|
||||
user = request.user
|
||||
old_password = request.data.get('old_password')
|
||||
new_password = request.data.get('new_password')
|
||||
if not user.check_password(old_password):
|
||||
return Response({'error': 'Wrong old password'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
return Response({'status': 'password changed'})
|
||||
|
||||
@action(detail=False, methods=['patch'])
|
||||
def update_profile(self, request):
|
||||
user = request.user
|
||||
username = request.data.get('username')
|
||||
email = request.data.get('email')
|
||||
|
||||
message = ""
|
||||
if username and username != user.username:
|
||||
if User.objects.filter(username=username).exists():
|
||||
return Response({'error': 'Username already taken'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.username = username
|
||||
|
||||
if email and email != user.email:
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
user.pending_email = email
|
||||
user.email_confirmation_code = code
|
||||
send_mail(
|
||||
'Confirm your new email',
|
||||
f'Hi {user.username},\n\nYour confirmation code is: {code}',
|
||||
'noreply@holyflavors.com',
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
message = " Confirmation code sent to new email."
|
||||
|
||||
user.save()
|
||||
serializer = self.get_serializer(user)
|
||||
data = serializer.data
|
||||
if message:
|
||||
data['message'] = message
|
||||
return Response(data)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def confirm_email(self, request):
|
||||
user = request.user
|
||||
code = request.data.get('code')
|
||||
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
||||
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.email = user.pending_email
|
||||
user.pending_email = None
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({'status': 'Email confirmed', 'email': user.email})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def dashboard(self, request):
|
||||
user = request.user
|
||||
|
||||
+108
-32
@@ -11,20 +11,32 @@ import {
|
||||
Box,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem
|
||||
MenuItem,
|
||||
Drawer,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import PaletteIcon from '@mui/icons-material/Palette';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import { getTheme, type CatppuccinTheme } from './theme';
|
||||
import CategoryList from './pages/CategoryList';
|
||||
import CategoryFlavors from './pages/categories/CategoryFlavors';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Login from './pages/Login';
|
||||
import Settings from './pages/Settings';
|
||||
import api from './api';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [themeName, setThemeName] = useState<CatppuccinTheme>((localStorage.getItem('theme') as CatppuccinTheme) || 'mocha');
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [user, setUser] = useState<{username: string} | null>(null);
|
||||
const [loadingUser, setLoadingUser] = useState(true);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width:600px)');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
@@ -36,10 +48,14 @@ const App: React.FC = () => {
|
||||
}
|
||||
} catch (err) {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoadingUser(false);
|
||||
}
|
||||
};
|
||||
if (localStorage.getItem('token')) {
|
||||
fetchUser();
|
||||
} else {
|
||||
setLoadingUser(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -64,49 +80,109 @@ const App: React.FC = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
const navItems = user ? [
|
||||
{ label: 'Categories', path: '/' },
|
||||
{ label: 'Dashboard', path: '/dashboard' },
|
||||
{ label: 'Settings', path: '/settings' },
|
||||
] : [
|
||||
{ label: 'Categories', path: '/' },
|
||||
{ label: 'Login', path: '/login' },
|
||||
];
|
||||
|
||||
const drawer = (
|
||||
<Box onClick={() => setDrawerOpen(false)} sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ my: 2 }}>Holy Flavors</Typography>
|
||||
<Divider />
|
||||
<List>
|
||||
{navItems.map((item) => (
|
||||
<ListItem key={item.label} disablePadding>
|
||||
<ListItemButton component={Link} to={item.path} sx={{ textAlign: 'center' }}>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
{user && (
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={handleLogout} sx={{ textAlign: 'center' }}>
|
||||
<ListItemText primary="Logout" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
<Link to="/" style={{ color: 'inherit', textDecoration: 'none' }}>Holy Flavors</Link>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Button color="inherit" component={Link} to="/">Categories</Button>
|
||||
{user ? (
|
||||
<>
|
||||
<Button color="inherit" component={Link} to="/dashboard">Dashboard</Button>
|
||||
<Button color="inherit" onClick={handleLogout}>Logout ({user.username})</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button color="inherit" component={Link} to="/login">Login</Button>
|
||||
<AppBar position="sticky" elevation={1}>
|
||||
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 } }}>
|
||||
<Toolbar disableGutters sx={{ minHeight: { xs: 56, sm: 64 } }}>
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
|
||||
<Link to="/" style={{ color: 'inherit', textDecoration: 'none' }}>Holy Flavors</Link>
|
||||
</Typography>
|
||||
|
||||
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)}>
|
||||
<PaletteIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
>
|
||||
<MenuItem onClick={() => handleThemeChange('latte')}>Latte</MenuItem>
|
||||
<MenuItem onClick={() => handleThemeChange('frappe')}>Frappé</MenuItem>
|
||||
<MenuItem onClick={() => handleThemeChange('macchiato')}>Macchiato</MenuItem>
|
||||
<MenuItem onClick={() => handleThemeChange('mocha')}>Mocha</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{!isMobile && !loadingUser && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{navItems.map((item) => (
|
||||
<Button key={item.label} color="inherit" component={Link} to={item.path}>{item.label}</Button>
|
||||
))}
|
||||
{user && (
|
||||
<Button color="inherit" onClick={handleLogout} sx={{ ml: 1 }}>Logout</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)} sx={{ ml: 1 }}>
|
||||
<PaletteIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
>
|
||||
<MenuItem onClick={() => handleThemeChange('latte')}>Latte</MenuItem>
|
||||
<MenuItem onClick={() => handleThemeChange('frappe')}>Frappé</MenuItem>
|
||||
<MenuItem onClick={() => handleThemeChange('macchiato')}>Macchiato</MenuItem>
|
||||
<MenuItem onClick={() => handleThemeChange('mocha')}>Mocha</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</Container>
|
||||
</AppBar>
|
||||
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: 240 },
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
|
||||
<Container maxWidth={false} sx={{ mt: 4, pb: 4, px: { xs: 2, sm: 4, md: 6 } }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<CategoryList />} />
|
||||
<Route path="/category/:slug" element={<CategoryFlavors />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
</Routes>
|
||||
</Container>
|
||||
|
||||
@@ -66,7 +66,7 @@ const CategoryList: React.FC = () => {
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{filteredCategories.map(category => (
|
||||
<Box key={category.id} sx={{ flex: '1 1 45%', minWidth: 280 }}>
|
||||
<Box key={category.id} sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', lg: '1 1 23%' }, minWidth: 280 }}>
|
||||
<Link to={`/category/${category.slug}`} style={{ textDecoration: 'none' }}>
|
||||
<Card sx={{
|
||||
height: 150,
|
||||
|
||||
@@ -36,7 +36,7 @@ const Dashboard: React.FC = () => {
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Personal Dashboard</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
<Box sx={{ flex: '1 1 45%', minWidth: 300 }}>
|
||||
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 45%' }, minWidth: 300 }}>
|
||||
<Typography variant="h5" color="secondary" gutterBottom>My Ratings ({data.rated_count})</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{data.my_ratings.map((rating: any) => (
|
||||
@@ -66,7 +66,7 @@ const Dashboard: React.FC = () => {
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: '1 1 45%', minWidth: 300 }}>
|
||||
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 45%' }, minWidth: 300 }}>
|
||||
<Typography variant="h5" color="primary" gutterBottom>Missing Flavors ({data.missing_count})</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{data.missing_flavors.map((flavor: any) => (
|
||||
|
||||
+187
-58
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Typography, TextField, Button, Box, Paper, Tab, Tabs } from '@mui/material';
|
||||
import { Typography, TextField, Button, Box, Paper, Tab, Tabs, Alert } from '@mui/material';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
|
||||
@@ -12,9 +12,15 @@ const Login: React.FC<LoginProps> = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [resetStep, setResetStep] = useState(1); // 1: email, 2: code/new pass
|
||||
const [message, setMessage] = useState<{type: 'success' | 'error', text: string} | null>(null);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await api.post('token/', {
|
||||
username,
|
||||
@@ -22,76 +28,199 @@ const Login: React.FC<LoginProps> = () => {
|
||||
});
|
||||
localStorage.setItem('token', res.data.token);
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
alert('Login failed');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: 'Login failed. Please check your credentials or verify your account.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/signup/', { username, email });
|
||||
alert('Signup request sent! Check your email (or the backend console) for your temporary password.');
|
||||
setTab(0);
|
||||
await api.post('users/signup/', { username, email, password });
|
||||
setIsVerifying(true);
|
||||
setMessage({ type: 'success', text: 'Signup successful! Please enter the verification code sent to your email.' });
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Signup failed');
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Signup failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/verify_signup/', { username, code: verificationCode });
|
||||
setMessage({ type: 'success', text: 'Account verified! You can now login.' });
|
||||
setIsVerifying(false);
|
||||
setTab(0);
|
||||
setPassword('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Verification failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/request_password_reset/', { email });
|
||||
setResetStep(2);
|
||||
setMessage({ type: 'success', text: 'If an account exists, a reset code has been sent.' });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: 'An error occurred. Please try again later.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/complete_password_reset/', { email, code: verificationCode, password });
|
||||
setMessage({ type: 'success', text: 'Password reset successful! You can now login.' });
|
||||
setIsResetting(false);
|
||||
setResetStep(1);
|
||||
setTab(0);
|
||||
setPassword('');
|
||||
setVerificationCode('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Reset failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isVerifying) {
|
||||
return (
|
||||
<form onSubmit={handleVerify}>
|
||||
<Typography variant="h6" gutterBottom>Verify Account</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>Enter the code sent to {email}</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Verification Code"
|
||||
margin="normal"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
Verify
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => setIsVerifying(false)} sx={{ mt: 1 }}>
|
||||
Back to Signup
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
if (isResetting) {
|
||||
return (
|
||||
<form onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}>
|
||||
<Typography variant="h6" gutterBottom>Reset Password</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={resetStep === 2}
|
||||
/>
|
||||
{resetStep === 2 && (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Reset Code"
|
||||
margin="normal"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="New Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{resetStep === 1 ? 'Send Reset Code' : 'Reset Password'}
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => { setIsResetting(false); setResetStep(1); setMessage(null); }} sx={{ mt: 1 }}>
|
||||
Back to Login
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setMessage(null); }} sx={{ mb: 2 }}>
|
||||
<Tab label="Login" />
|
||||
<Tab label="Signup" />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 ? (
|
||||
<form onSubmit={handleLogin}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
Login
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => { setIsResetting(true); setMessage(null); }} sx={{ mt: 1, textTransform: 'none' }} size="small">
|
||||
Forgot Password?
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSignup}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
type="email"
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
Signup
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 400, mx: 'auto', mt: 8 }}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label="Login" />
|
||||
<Tab label="Signup" />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 ? (
|
||||
<form onSubmit={handleLogin}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSignup}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
type="email"
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
A password reset link will be sent to your email to set your initial password.
|
||||
</Typography>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
Signup
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{message && <Alert severity={message.type} sx={{ mb: 2 }}>{message.text}</Alert>}
|
||||
{renderContent()}
|
||||
</Paper>
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Paper,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
useTitle('Account Settings');
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [currentEmail, setCurrentEmail] = useState('');
|
||||
const [confirmationCode, setConfirmationCode] = useState('');
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState(false);
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const res = await api.get('users/me/');
|
||||
setUsername(res.data.username);
|
||||
setEmail(res.data.email);
|
||||
setCurrentEmail(res.data.email);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await api.patch('users/update_profile/', { username, email });
|
||||
if (res.data.message) {
|
||||
setPendingConfirmation(true);
|
||||
setMessage({ type: 'success', text: `Profile updated.${res.data.message}` });
|
||||
} else {
|
||||
setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
||||
setCurrentEmail(email);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to update profile' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmEmail = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await api.post('users/confirm_email/', { code: confirmationCode });
|
||||
setMessage({ type: 'success', text: 'Email confirmed and updated!' });
|
||||
setCurrentEmail(res.data.email);
|
||||
setPendingConfirmation(false);
|
||||
setConfirmationCode('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Confirmation failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post('users/change_password/', { old_password: oldPassword, new_password: newPassword });
|
||||
setMessage({ type: 'success', text: 'Password changed successfully!' });
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to change password' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 600, mx: 'auto' }}>
|
||||
<Typography variant="h4" gutterBottom>Account Settings</Typography>
|
||||
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 3 }} onClose={() => setMessage(null)}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ p: 3, mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>Profile Information</Typography>
|
||||
<form onSubmit={handleUpdateProfile}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
helperText={email !== currentEmail ? "Change will require confirmation" : ""}
|
||||
/>
|
||||
<Button variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
Update Profile
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{pendingConfirmation && (
|
||||
<Box sx={{ mt: 4, pt: 3, borderTop: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="h6" color="primary" gutterBottom>Confirm Email Change</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>Enter the 6-digit code sent to {email}</Typography>
|
||||
<form onSubmit={handleConfirmEmail}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Confirmation Code"
|
||||
margin="normal"
|
||||
value={confirmationCode}
|
||||
onChange={(e) => setConfirmationCode(e.target.value)}
|
||||
/>
|
||||
<Button variant="contained" type="submit" sx={{ mt: 1 }} color="secondary">
|
||||
Verify & Update Email
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Change Password</Typography>
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Old Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="New Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<Button variant="contained" type="submit" sx={{ mt: 2 }} color="warning">
|
||||
Change Password
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -107,7 +107,7 @@ const CategoryFlavors: React.FC = () => {
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{filteredFlavors.map(flavor => (
|
||||
<Box sx={{ flex: '1 1 30%', minWidth: 280 }} key={flavor.id}>
|
||||
<Box sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 30%', lg: '1 1 23%', xl: '1 1 18%' }, minWidth: 280 }} key={flavor.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}>
|
||||
{!flavor.is_available && (
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, bgcolor: 'error.main', color: 'white', px: 1, borderRadius: 1, fontSize: '0.75rem', zIndex: 1 }}>
|
||||
|
||||
Reference in new issue
Block a user