diff --git a/backend/api/migrations/0004_user_email_confirmation_code_user_pending_email.py b/backend/api/migrations/0004_user_email_confirmation_code_user_pending_email.py new file mode 100644 index 0000000..df329f8 --- /dev/null +++ b/backend/api/migrations/0004_user_email_confirmation_code_user_pending_email.py @@ -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), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 422d445..e83354e 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -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 diff --git a/backend/api/views.py b/backend/api/views.py index bcf9de2..296455e 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5263f44..0174959 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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((localStorage.getItem('theme') as CatppuccinTheme) || 'mocha'); const [anchorEl, setAnchorEl] = useState(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 = ( + setDrawerOpen(false)} sx={{ textAlign: 'center' }}> + Holy Flavors + + + {navItems.map((item) => ( + + + + + + ))} + {user && ( + + + + + + )} + + + ); + return ( - - - - Holy Flavors - - - - - {user ? ( - <> - - - - ) : ( - + + + + {isMobile && ( + setDrawerOpen(true)} + sx={{ mr: 2 }} + > + + )} + + Holy Flavors + - setAnchorEl(e.currentTarget)}> - - - setAnchorEl(null)} - > - handleThemeChange('latte')}>Latte - handleThemeChange('frappe')}>Frappé - handleThemeChange('macchiato')}>Macchiato - handleThemeChange('mocha')}>Mocha - - - + + {!isMobile && !loadingUser && ( + + {navItems.map((item) => ( + + ))} + {user && ( + + )} + + )} + + setAnchorEl(e.currentTarget)} sx={{ ml: 1 }}> + + + setAnchorEl(null)} + > + handleThemeChange('latte')}>Latte + handleThemeChange('frappe')}>Frappé + handleThemeChange('macchiato')}>Macchiato + handleThemeChange('mocha')}>Mocha + + + + - + setDrawerOpen(false)} + ModalProps={{ keepMounted: true }} + sx={{ + display: { xs: 'block', sm: 'none' }, + '& .MuiDrawer-paper': { boxSizing: 'border-box', width: 240 }, + }} + > + {drawer} + + + } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/CategoryList.tsx b/frontend/src/pages/CategoryList.tsx index 1c00a9d..b087e20 100644 --- a/frontend/src/pages/CategoryList.tsx +++ b/frontend/src/pages/CategoryList.tsx @@ -66,7 +66,7 @@ const CategoryList: React.FC = () => { {filteredCategories.map(category => ( - + { Personal Dashboard - + My Ratings ({data.rated_count}) {data.my_ratings.map((rating: any) => ( @@ -66,7 +66,7 @@ const Dashboard: React.FC = () => { ))} - + Missing Flavors ({data.missing_count}) {data.missing_flavors.map((flavor: any) => ( diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 684b5c4..59fe5c2 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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 = () => { 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 = () => { }); 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 ( +
+ Verify Account + Enter the code sent to {email} + setVerificationCode(e.target.value)} + /> + + + + ); + } + + if (isResetting) { + return ( +
+ Reset Password + setEmail(e.target.value)} + disabled={resetStep === 2} + /> + {resetStep === 2 && ( + <> + setVerificationCode(e.target.value)} + /> + setPassword(e.target.value)} + /> + + )} + + + + ); + } + + return ( + <> + { setTab(v); setMessage(null); }} sx={{ mb: 2 }}> + + + + + {tab === 0 ? ( +
+ setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + + ) : ( +
+ setUsername(e.target.value)} + /> + setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + )} + + ); + }; + return ( - setTab(v)} sx={{ mb: 2 }}> - - - - - {tab === 0 ? ( -
- setUsername(e.target.value)} - /> - setPassword(e.target.value)} - /> - - - ) : ( -
- setUsername(e.target.value)} - /> - setEmail(e.target.value)} - /> - - A password reset link will be sent to your email to set your initial password. - - - - )} + {message && {message.text}} + {renderContent()}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..e73d00c --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -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 ( + + Account Settings + + {message && ( + setMessage(null)}> + {message.text} + + )} + + + Profile Information +
+ setUsername(e.target.value)} + /> + setEmail(e.target.value)} + helperText={email !== currentEmail ? "Change will require confirmation" : ""} + /> + + + + {pendingConfirmation && ( + + Confirm Email Change + Enter the 6-digit code sent to {email} +
+ setConfirmationCode(e.target.value)} + /> + + +
+ )} +
+ + + Change Password +
+ setOldPassword(e.target.value)} + /> + setNewPassword(e.target.value)} + /> + + +
+
+ ); +}; + +export default Settings; diff --git a/frontend/src/pages/categories/CategoryFlavors.tsx b/frontend/src/pages/categories/CategoryFlavors.tsx index 54381e8..3dc7388 100644 --- a/frontend/src/pages/categories/CategoryFlavors.tsx +++ b/frontend/src/pages/categories/CategoryFlavors.tsx @@ -107,7 +107,7 @@ const CategoryFlavors: React.FC = () => { {filteredFlavors.map(flavor => ( - + {!flavor.is_available && (