mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
password redesign
This commit is contained in:
1 parent
a39c28d807
commit
97a17e516d
6 files changed
+159
-131
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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<Record<number, string>>({});
|
||||
const [expandedReplies, setExpandedReplies] = useState<Record<number, boolean>>({});
|
||||
|
||||
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 = () => {
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
||||
<MentionTextField
|
||||
placeholder={t('community.writeReply')}
|
||||
multiline
|
||||
rows={2}
|
||||
value={replyInputs[rating.id] || ''}
|
||||
onChange={(val) =>
|
||||
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 = () => {
|
||||
|
||||
<Grid size={{ xs: 12, lg: 4 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<SidebarCard
|
||||
icon={<NotificationsIcon color="primary" fontSize="small" />}
|
||||
title={t('community.notifications')}
|
||||
>
|
||||
<List disablePadding>
|
||||
{notifications.length === 0 ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('community.noNotifications')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
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 (
|
||||
<ListItemButton key={n.id} onClick={handleClick} sx={{ py: 1.5 }}>
|
||||
<ListItemAvatar sx={{ minWidth: 40 }}>
|
||||
<Avatar
|
||||
src={n.actor_avatar || undefined}
|
||||
sx={{ width: 30, height: 30, fontSize: '0.8rem' }}
|
||||
>
|
||||
{n.actor_username.charAt(0)}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
<strong>{n.actor_username}</strong>{' '}
|
||||
{n.notification_type === 'reply'
|
||||
? t('community.notifReply')
|
||||
: n.notification_type === 'mention'
|
||||
? t('community.notifMention')
|
||||
: n.notification_type === 'follow'
|
||||
? t('community.notifFollow')
|
||||
: n.notification_type}
|
||||
</Typography>
|
||||
}
|
||||
secondary={formatDate(n.created_at)}
|
||||
secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</List>
|
||||
</SidebarCard>
|
||||
|
||||
<SidebarCard
|
||||
icon={<PeopleIcon color="primary" fontSize="small" />}
|
||||
title={t('nav.following')}
|
||||
>
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder={t('community.searchFriends')}
|
||||
value={followingSearch}
|
||||
onChange={(e) => setFollowingSearch(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<SearchIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 } }}
|
||||
/>
|
||||
</Box>
|
||||
<List disablePadding sx={{ maxHeight: 250, overflowY: 'auto' }}>
|
||||
{filteredFollowing.map((user) => (
|
||||
<ListItemButton key={user.id} component={Link} to={`/profile/${user.username}`}>
|
||||
<Avatar
|
||||
src={user.avatar || undefined}
|
||||
sx={{ width: 28, height: 28, mr: 2, fontSize: '0.8rem' }}
|
||||
>
|
||||
{user.username.charAt(0)}
|
||||
</Avatar>
|
||||
<ListItemText
|
||||
primary={user.username}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</SidebarCard>
|
||||
|
||||
<SidebarCard
|
||||
icon={<WhatshotIcon color="error" fontSize="small" />}
|
||||
title={t('community.topRated')}
|
||||
|
||||
+125
-18
@@ -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 (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(score / 4) * 100}
|
||||
sx={{ height: 6, borderRadius: 3, '& .MuiLinearProgress-bar': { bgcolor: level.color } }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: level.color, fontWeight: 600 }}>
|
||||
{t('auth.passwordStrength')}: {level.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type={show ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShow((s) => !s)}
|
||||
edge="end"
|
||||
size="small"
|
||||
aria-label={show ? t('auth.hidePassword') : t('auth.showPassword')}
|
||||
>
|
||||
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{showStrength && value.length > 0 && <PasswordStrengthMeter password={value} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<FormCard
|
||||
title={t('auth.resetTitle')}
|
||||
@@ -200,7 +302,11 @@ const Login: React.FC = () => {
|
||||
>
|
||||
{t('auth.backToLogin')}
|
||||
</Button>
|
||||
<Button variant="contained" type="submit">
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={resetStep === 2 && (password.length === 0 || !!resetPwError)}
|
||||
>
|
||||
{resetStep === 1 ? t('auth.sendResetCode') : t('auth.resetButton')}
|
||||
</Button>
|
||||
</>
|
||||
@@ -221,12 +327,13 @@ const Login: React.FC = () => {
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
<PasswordField
|
||||
label={t('auth.newPasswordLabel')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('auth.passwordLabel')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<PasswordField label={t('auth.passwordLabel')} value={password} onChange={setPassword} />
|
||||
</FormCard>
|
||||
);
|
||||
}
|
||||
|
||||
const signupPwError = validatePassword(password);
|
||||
return (
|
||||
<FormCard
|
||||
title={t('auth.signupTitle')}
|
||||
onSubmit={handleSignup}
|
||||
actions={
|
||||
<Button variant="contained" type="submit">
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={password.length === 0 || !!signupPwError}
|
||||
>
|
||||
{t('auth.signupTitle')}
|
||||
</Button>
|
||||
}
|
||||
@@ -297,12 +403,13 @@ const Login: React.FC = () => {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
<PasswordField
|
||||
label={t('auth.passwordLabel')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={setPassword}
|
||||
error={!!signupPwError}
|
||||
helperText={signupPwError ? t(signupPwError) : undefined}
|
||||
showStrength
|
||||
/>
|
||||
</FormCard>
|
||||
);
|
||||
|
||||
@@ -287,9 +287,13 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<MentionTextField
|
||||
placeholder={t('community.writeReply')}
|
||||
multiline
|
||||
rows={2}
|
||||
value={replyInput}
|
||||
onChange={setReplyInput}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleReplySubmit()}
|
||||
onKeyDown={(e) =>
|
||||
(e.metaKey || e.ctrlKey) && e.key === 'Enter' && handleReplySubmit()
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
|
||||
@@ -572,12 +572,16 @@ const FlavorDetail: React.FC = () => {
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
||||
<MentionTextField
|
||||
placeholder={t('community.writeReply')}
|
||||
multiline
|
||||
rows={2}
|
||||
value={replyInputs[rating.id] || ''}
|
||||
onChange={(val) =>
|
||||
setReplyInputs({ ...replyInputs, [rating.id]: val })
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id)
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.key === 'Enter' &&
|
||||
handleReplySubmit(rating.id)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -21,9 +21,9 @@ Tracking doc from the frontend/UX audit. Items grouped by priority. Check off as
|
||||
|
||||
- [x] **#5 Hall of Fame carousel** (`MainPage.tsx`) — added 6s auto-advance (stops on manual control, pauses on hover, off for reduced-motion), touch swipe, on-card overlay arrows on mobile (header arrows desktop-only), and featured quote now picks the highest-scored comment instead of the first.
|
||||
- [x] **#6 Admin edit leaks into product card** (`FlavorDetail.tsx`) — extracted to `components/AdminFlavorEditDialog.tsx`; card keeps a clean read view, edit opens a focused dialog.
|
||||
- [ ] **#7 CommunityFeed duplication** — in-page notifications + following duplicate the nav/global menus.
|
||||
- [ ] **#8 Reply Enter-to-submit on multiline** — surprising; blocks newlines.
|
||||
- [ ] **#9 Login password UX** — no show/hide toggle, no strength hint, no inline validation.
|
||||
- [x] **#7 CommunityFeed duplication** (`CommunityFeed.tsx`) — dropped the in-page Notifications + Following sidebar cards (NavSidebar already provides both: NotificationMenu popup + Following collapse). Kept the unique "Circle's Top Rated" card. Removed now-dead queries/state/imports.
|
||||
- [x] **#8 Reply Enter-to-submit on multiline** (`CommunityFeed.tsx`, `FlavorDetail.tsx`, `MyReviewCard.tsx`) — reply fields are now `multiline rows={2}`; plain Enter inserts a newline, submit moved to Ctrl/Cmd+Enter (no longer hijacks Enter, no longer blocks newlines).
|
||||
- [x] **#9 Login password UX** (`Login.tsx`) — added a `PasswordField` with show/hide toggle (all password inputs), a 0–4 strength meter on signup + reset-new-password, and inline validation mirroring the backend (min 8 chars, not entirely numeric) that gates the submit button.
|
||||
|
||||
## 🟢 Design-system cleanup
|
||||
|
||||
|
||||
Reference in new issue
Block a user