password redesign

This commit is contained in:
nvrl committed 2026-06-16 14:34:21 +02:00
1 parent a39c28d807
commit 97a17e516d
6 files changed
+159 -131

No files matched your search

+18
View File
@@ -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',
+3 -108
View File
@@ -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
View File
@@ -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
+5 -1
View File
@@ -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
+3 -3
View File
@@ -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