diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 4e69b2a..d52d9d6 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -248,6 +248,15 @@ const resources = { verifyFailed: 'Verification failed', resendFailed: 'Failed to resend code', genericError: 'An error occurred. Please try again later.', + showPassword: 'Show password', + hidePassword: 'Hide password', + passwordTooShort: 'Password must be at least 8 characters.', + passwordNumericOnly: 'Password cannot be entirely numeric.', + passwordStrength: 'Strength', + strengthWeak: 'Weak', + strengthFair: 'Fair', + strengthGood: 'Good', + strengthStrong: 'Strong', }, community: { title: 'Community Activity', @@ -633,6 +642,15 @@ const resources = { verifyFailed: 'Verifizierung fehlgeschlagen', resendFailed: 'Code konnte nicht erneut gesendet werden', genericError: 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.', + showPassword: 'Passwort anzeigen', + hidePassword: 'Passwort verbergen', + passwordTooShort: 'Passwort muss mindestens 8 Zeichen lang sein.', + passwordNumericOnly: 'Passwort darf nicht nur aus Zahlen bestehen.', + passwordStrength: 'Stärke', + strengthWeak: 'Schwach', + strengthFair: 'Mittel', + strengthGood: 'Gut', + strengthStrong: 'Stark', }, community: { title: 'Community-Aktivität', diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx index 2bcc0c6..b9016d5 100644 --- a/frontend/src/pages/CommunityFeed.tsx +++ b/frontend/src/pages/CommunityFeed.tsx @@ -9,29 +9,22 @@ import { Button, Grid, List, - ListItemAvatar, ListItemText, ListItemButton, Pagination, - TextField, IconButton, Collapse, Link as MuiLink, } from '@mui/material'; -import SearchIcon from '@mui/icons-material/Search'; import CommentIcon from '@mui/icons-material/Comment'; import SendIcon from '@mui/icons-material/Send'; import WhatshotIcon from '@mui/icons-material/Whatshot'; -import NotificationsIcon from '@mui/icons-material/Notifications'; -import PeopleIcon from '@mui/icons-material/People'; import { Link, useNavigate } from 'react-router-dom'; import { useCommunityFeed, useFollowedTopFlavors, - useFollowingList, type FeedRating, } from '../api/queries/useCommunityFeed'; -import { useNotificationsQuery } from '../api/queries/useNotifications'; import { useCreateReply } from '../api/mutations/useRatingMutations'; import { useTitle } from '../hooks/useTitle'; import { useAuth } from '../hooks/useAuth'; @@ -81,27 +74,19 @@ const CommunityFeed: React.FC = () => { const navigate = useNavigate(); const { notify } = useToast(); const [page, setPage] = useState(1); - const [followingSearch, setFollowingSearch] = useState(''); const [replyInputs, setReplyInputs] = useState>({}); const [expandedReplies, setExpandedReplies] = useState>({}); const { user } = useAuth(); const isAuthed = !!user; const { data: feed, isLoading: feedLoading, error: feedError } = useCommunityFeed(page); - const { data: following = [] } = useFollowingList(); - const { data: notificationsAll = [] } = useNotificationsQuery(isAuthed); const { data: topFollowed = [] } = useFollowedTopFlavors(); const createReply = useCreateReply(); const ratings: FeedRating[] = feed?.ratings ?? []; const totalPages = feed?.totalPages ?? 1; - const notifications = notificationsAll.slice(0, 5); const loading = feedLoading; - const filteredFollowing = following.filter((user) => - user.username.toLowerCase().includes(followingSearch.toLowerCase()), - ); - useEffect(() => { if (!isAuthed) navigate('/login'); }, [isAuthed, navigate]); @@ -322,12 +307,14 @@ const CommunityFeed: React.FC = () => { setReplyInputs((prev) => ({ ...prev, [rating.id]: val })) } onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { handleReplySubmit(rating.id); } }} @@ -361,98 +348,6 @@ const CommunityFeed: React.FC = () => { - } - title={t('community.notifications')} - > - - {notifications.length === 0 ? ( - - - {t('community.noNotifications')} - - - ) : ( - notifications.map((n) => { - const handleClick = () => { - if (n.notification_type === 'follow') { - navigate(`/profile/${n.actor_username}`); - } else if (n.flavor_id) { - navigate(`/flavor/${n.flavor_id}`); - } - }; - return ( - - - - {n.actor_username.charAt(0)} - - - - {n.actor_username}{' '} - {n.notification_type === 'reply' - ? t('community.notifReply') - : n.notification_type === 'mention' - ? t('community.notifMention') - : n.notification_type === 'follow' - ? t('community.notifFollow') - : n.notification_type} - - } - secondary={formatDate(n.created_at)} - secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }} - /> - - ); - }) - )} - - - - } - title={t('nav.following')} - > - - setFollowingSearch(e.target.value)} - slotProps={{ - input: { - startAdornment: ( - - ), - }, - }} - sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 } }} - /> - - - {filteredFollowing.map((user) => ( - - - {user.username.charAt(0)} - - - - ))} - - - } title={t('community.topRated')} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 1d016af..c0211a0 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,5 +1,19 @@ import React, { useState } from 'react'; -import { Typography, TextField, Button, Tab, Tabs, Alert, Stack, Box } from '@mui/material'; +import { + Typography, + TextField, + Button, + Tab, + Tabs, + Alert, + Stack, + Box, + IconButton, + InputAdornment, + LinearProgress, +} from '@mui/material'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; import { useNavigate } from 'react-router-dom'; import api from '../lib/api'; import { useTitle } from '../hooks/useTitle'; @@ -13,6 +27,93 @@ interface ApiError { const readError = (err: unknown): ApiError => err as ApiError; +// Mirror of the backend password validators (password_policy.rs): min 8 chars, +// not entirely numeric. Returns an i18n key for the first failure, or null. +const validatePassword = (pw: string): string | null => { + if (pw.length === 0) return null; + if (pw.length < 8) return 'auth.passwordTooShort'; + if (/^\d+$/.test(pw)) return 'auth.passwordNumericOnly'; + return null; +}; + +// Heuristic 0–4 strength score (length + character-class variety). +const scorePassword = (pw: string): number => { + let score = 0; + if (pw.length >= 8) score++; + if (pw.length >= 12) score++; + if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++; + if (/\d/.test(pw)) score++; + if (/[^A-Za-z0-9]/.test(pw)) score++; + return Math.min(score, 4); +}; + +const PasswordStrengthMeter: React.FC<{ password: string }> = ({ password }) => { + const { t } = useTranslation(); + const score = scorePassword(password); + const levels = [ + { label: t('auth.strengthWeak'), color: 'error.main' }, + { label: t('auth.strengthWeak'), color: 'error.main' }, + { label: t('auth.strengthFair'), color: 'warning.main' }, + { label: t('auth.strengthGood'), color: 'info.main' }, + { label: t('auth.strengthStrong'), color: 'success.main' }, + ]; + const level = levels[score]; + return ( + + + + {t('auth.passwordStrength')}: {level.label} + + + ); +}; + +const PasswordField: React.FC<{ + label: string; + value: string; + onChange: (v: string) => void; + error?: boolean; + helperText?: string; + showStrength?: boolean; +}> = ({ label, value, onChange, error, helperText, showStrength }) => { + const { t } = useTranslation(); + const [show, setShow] = useState(false); + return ( + + onChange(e.target.value)} + error={error} + helperText={helperText} + slotProps={{ + input: { + endAdornment: ( + + setShow((s) => !s)} + edge="end" + size="small" + aria-label={show ? t('auth.hidePassword') : t('auth.showPassword')} + > + {show ? : } + + + ), + }, + }} + /> + {showStrength && value.length > 0 && } + + ); +}; + const Login: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -185,6 +286,7 @@ const Login: React.FC = () => { } if (isResetting) { + const resetPwError = validatePassword(password); return ( { > {t('auth.backToLogin')} - @@ -221,12 +327,13 @@ const Login: React.FC = () => { value={verificationCode} onChange={(e) => setVerificationCode(e.target.value)} /> - setPassword(e.target.value)} + onChange={setPassword} + error={!!resetPwError} + helperText={resetPwError ? t(resetPwError) : undefined} + showStrength /> )} @@ -263,23 +370,22 @@ const Login: React.FC = () => { value={username} onChange={(e) => setUsername(e.target.value)} /> - setPassword(e.target.value)} - /> + ); } + const signupPwError = validatePassword(password); return ( + } @@ -297,12 +403,13 @@ const Login: React.FC = () => { value={email} onChange={(e) => setEmail(e.target.value)} /> - setPassword(e.target.value)} + onChange={setPassword} + error={!!signupPwError} + helperText={signupPwError ? t(signupPwError) : undefined} + showStrength /> ); diff --git a/frontend/src/pages/dashboard/MyReviewCard.tsx b/frontend/src/pages/dashboard/MyReviewCard.tsx index 2caaca5..9740f9f 100644 --- a/frontend/src/pages/dashboard/MyReviewCard.tsx +++ b/frontend/src/pages/dashboard/MyReviewCard.tsx @@ -287,9 +287,13 @@ const MyReviewCard: React.FC = ({ rating, currentUsername }) e.key === 'Enter' && !e.shiftKey && handleReplySubmit()} + onKeyDown={(e) => + (e.metaKey || e.ctrlKey) && e.key === 'Enter' && handleReplySubmit() + } />