feat: enhance visitor mode

This commit is contained in:
Peifan Li
2026-01-03 22:07:04 -05:00
parent 76d4269164
commit 13de853a54
20 changed files with 1828 additions and 355 deletions

View File

@@ -148,16 +148,15 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
return (
<Box>
<FormControlLabel
control={
<Switch
checked={settings.loginEnabled}
onChange={(e) => onChange('loginEnabled', e.target.checked)}
disabled={settings.visitorMode} // Locked enabled if visitor mode is on
<FormControlLabel
control={
<Switch
checked={settings.loginEnabled}
onChange={(e) => onChange('loginEnabled', e.target.checked)}
/>
}
label={t('enableLogin')}
/>
}
label={t('enableLogin')}
/>
{settings.loginEnabled && (
<Box sx={{ mt: 2, maxWidth: 400 }}>
@@ -194,53 +193,27 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
</Typography>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.visitorMode === true}
onChange={(e) => {
const enabled = e.target.checked;
onChange('visitorMode', enabled);
// Lock loginEnabled to true if visitor mode is enabled
if (enabled) {
if (!settings.loginEnabled) {
onChange('loginEnabled', true);
}
if (!settings.visitorPassword) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
const array = new Uint32Array(12);
window.crypto.getRandomValues(array);
const newPassword = Array.from(array, x => chars[x % chars.length]).join('');
onChange('visitorPassword', newPassword);
}
}
}}
disabled={!settings.loginEnabled && settings.visitorMode} // Unlock only if login is enabled? Actually user said "loginEnabled should be locked enabled". So if visitor mode is ON, loginEnabled switch (above) should be disabled or force checked.
/>
}
label={t('visitorUser') || 'Visitor User'}
/>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
{t('visitorUser') || 'Visitor User'}
</Typography>
<Box sx={{ mt: 1, mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{t('visitorUserHelper') || 'Enable a restricted Visitor User role. Visitors have read-only access and cannot change settings.'}
{t('visitorUserHelper') || 'Set a password for the Visitor User role. Users logging in with this password will have read-only access and cannot change settings.'}
</Typography>
</Box>
{settings.visitorMode && (
<TextField
fullWidth
sx={{ mb: 2 }}
label={t('visitorPassword') || 'Visitor Password'}
type="text" // User said "It should be visible" - wait, "show a input ... It should be visible". Does it mean the input is visible, or the password text is visible? "let admin setup visior password. It should be visible." Usually setup inputs are passwords but maybe they want it visible to see what it is? let's stick to type="text" or "password" with show toggle. "It should be visible" likely means the input field itself appears. I will use standard password field for security but maybe default show? Or just text if implied. "It should be visible" logically refers to the input field appearing. Safe bet is standard password field behavior.
value={settings.visitorPassword || ''}
onChange={(e) => onChange('visitorPassword', e.target.value)}
helperText={
settings.isVisitorPasswordSet
? (t('visitorPasswordSetHelper') || 'Password is set. Leave empty to keep it.')
: (t('visitorPasswordHelper') || 'Password for the Visitor User to log in.')
}
/>
)}
<TextField
fullWidth
sx={{ mb: 2 }}
label={t('visitorPassword') || 'Visitor Password'}
type="text"
value={settings.visitorPassword || ''}
onChange={(e) => onChange('visitorPassword', e.target.value)}
helperText={
settings.isVisitorPasswordSet
? (t('visitorPasswordSetHelper') || 'Password is set. Leave empty to keep it.')
: (t('visitorPasswordHelper') || 'Password for the Visitor User to log in.')
}
/>
<FormControlLabel
control={

View File

@@ -1,12 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import React, { createContext, ReactNode, useContext } from 'react';
import { useAuth } from './AuthContext';
const API_URL = import.meta.env.VITE_API_URL;
/**
* @deprecated This context is kept for backward compatibility.
* Permission control is now based on userRole from AuthContext.
* Use `useAuth().userRole === 'visitor'` instead of `useVisitorMode().visitorMode`.
*/
interface VisitorModeContextType {
visitorMode: boolean;
isLoading: boolean;
@@ -14,25 +13,19 @@ interface VisitorModeContextType {
const VisitorModeContext = createContext<VisitorModeContextType>({
visitorMode: false,
isLoading: true,
isLoading: false,
});
/**
* @deprecated Use useAuth().userRole === 'visitor' instead
*/
export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { userRole } = useAuth();
const { data: settingsData, isLoading } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings`);
return response.data;
},
refetchInterval: 30000, // Refetch every 30 seconds (reduced frequency)
staleTime: 10000, // Consider data fresh for 10 seconds
gcTime: 10 * 60 * 1000, // Garbage collect after 10 minutes
});
const { userRole, checkingAuth } = useAuth();
// Visitor mode is active if enabled in settings AND user is not an admin
const visitorMode = settingsData?.visitorMode === true && userRole !== 'admin';
// Visitor mode is now based solely on userRole
// No longer depends on settings.visitorMode
const visitorMode = userRole === 'visitor';
const isLoading = checkingAuth;
return (
<VisitorModeContext.Provider value={{ visitorMode, isLoading }}>
@@ -41,6 +34,9 @@ export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ childre
);
};
/**
* @deprecated Use useAuth().userRole === 'visitor' instead
*/
export const useVisitorMode = () => {
const context = useContext(VisitorModeContext);
if (!context) {

View File

@@ -26,7 +26,6 @@ import AlertModal from '../components/AlertModal';
import ConfirmationModal from '../components/ConfirmationModal';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
import getTheme from '../theme';
import { getWebAuthnErrorTranslationKey } from '../utils/translations';
@@ -48,7 +47,6 @@ const LoginPage: React.FC = () => {
const [resetPasswordCooldown, setResetPasswordCooldown] = useState(0); // in milliseconds
const { t } = useLanguage();
const { login } = useAuth();
const { visitorMode } = useVisitorMode();
const queryClient = useQueryClient();
// Fetch website name and settings from settings
@@ -68,6 +66,8 @@ const LoginPage: React.FC = () => {
const passwordLoginAllowed = settingsData?.passwordLoginAllowed !== false;
const allowResetPassword = settingsData?.allowResetPassword !== false;
// Show visitor tab if visitorPassword is set (no longer depends on visitorMode setting)
const showVisitorTab = !!settingsData?.isVisitorPasswordSet;
// Update website name when settings are loaded
useEffect(() => {
@@ -194,9 +194,9 @@ const LoginPage: React.FC = () => {
setAlertOpen(true);
};
const loginMutation = useMutation({
const adminLoginMutation = useMutation({
mutationFn: async (passwordToVerify: string) => {
const response = await axios.post(`${API_URL}/settings/verify-password`, { password: passwordToVerify });
const response = await axios.post(`${API_URL}/settings/verify-admin-password`, { password: passwordToVerify });
return response.data;
},
onSuccess: (data) => {
@@ -241,13 +241,55 @@ const LoginPage: React.FC = () => {
const visitorLoginMutation = useMutation({
mutationFn: async (passwordToVerify: string) => {
const response = await axios.post(`${API_URL}/settings/verify-visitor-password`, { password: passwordToVerify });
return response.data;
},
onSuccess: (data) => {
if (data.success) {
setWaitTime(0); // Reset wait time on success
login(data.token, data.role);
} else {
// Handle failures (incorrect password or too many attempts)
const statusCode = data.statusCode || 401;
const responseData = data;
if (statusCode === 429) {
// Too many attempts - wait time required
const waitTimeMs = responseData.waitTime || 0;
setWaitTime(waitTimeMs);
const formattedTime = formatWaitTime(waitTimeMs);
showAlert(t('error'), `${t('tooManyAttempts')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`);
} else if (statusCode === 401) {
// Incorrect password - check if wait time is returned
const waitTimeMs = responseData.waitTime || 0;
if (waitTimeMs > 0) {
setWaitTime(waitTimeMs);
const formattedTime = formatWaitTime(waitTimeMs);
showAlert(t('error'), `${t('incorrectPassword')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`);
} else {
showAlert(t('error'), t('incorrectPassword'));
}
} else {
showAlert(t('error'), t('loginFailed'));
}
}
},
onError: (err: any) => {
console.error('Login error:', err);
// Handle actual network errors or unexpected 500s
showAlert(t('error'), t('loginFailed'));
}
});
const handleVisitorSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (waitTime > 0) {
return;
}
setError('');
loginMutation.mutate(visitorPassword);
visitorLoginMutation.mutate(visitorPassword);
}
const resetPasswordMutation = useMutation({
@@ -341,7 +383,7 @@ const LoginPage: React.FC = () => {
return; // Don't allow submission if wait time is active
}
setError('');
loginMutation.mutate(password);
adminLoginMutation.mutate(password);
};
const handleResetPassword = () => {
@@ -423,12 +465,7 @@ const LoginPage: React.FC = () => {
<Typography variant="h4" sx={{ fontWeight: 'bold', lineHeight: 1 }}>
{websiteName}
</Typography>
{visitorMode && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.75rem', lineHeight: 1.2, mt: 0.5 }}>
{t('visitorMode')}
</Typography>
)}
{websiteName !== 'MyTube' && !visitorMode && (
{websiteName !== 'MyTube' && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', lineHeight: 1.2, mt: 0.25 }}>
Powered by MyTube
</Typography>
@@ -442,7 +479,7 @@ const LoginPage: React.FC = () => {
</Typography>
</Box>
<Box sx={{ mt: 1, width: '100%' }}>
{visitorMode && (
{showVisitorTab && (
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={activeTab} onChange={(_: React.SyntheticEvent, newValue: number) => setActiveTab(newValue)} aria-label="login tabs" variant="fullWidth">
<Tab label={t('admin') || 'Admin'} id="login-tab-0" aria-controls="login-tabpanel-0" />
@@ -451,14 +488,14 @@ const LoginPage: React.FC = () => {
</Box>
)}
{/* Admin Tab Panel (and default view when visitor mode is off) */}
{/* Admin Tab Panel (and default view when visitor tab is not shown) */}
<div
role="tabpanel"
hidden={visitorMode && activeTab !== 0}
hidden={showVisitorTab && activeTab !== 0}
id="login-tabpanel-0"
aria-labelledby="login-tab-0"
>
{(visitorMode ? activeTab === 0 : true) && (
{(showVisitorTab ? activeTab === 0 : true) && (
<>
{passwordLoginAllowed && (
<Box component="form" onSubmit={handleSubmit} noValidate>
@@ -473,8 +510,8 @@ const LoginPage: React.FC = () => {
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus={!visitorMode || activeTab === 0}
disabled={waitTime > 0 || loginMutation.isPending}
autoFocus={!showVisitorTab || activeTab === 0}
disabled={waitTime > 0 || adminLoginMutation.isPending}
helperText={t('defaultPasswordHint') || "Default password: 123"}
slotProps={{
input: {
@@ -497,9 +534,9 @@ const LoginPage: React.FC = () => {
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
disabled={adminLoginMutation.isPending || waitTime > 0}
>
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('signIn') || 'Admin Sign In')}
{adminLoginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('signIn') || 'Admin Sign In')}
</Button>
</Box>
)}
@@ -577,7 +614,7 @@ const LoginPage: React.FC = () => {
</div>
{/* Visitor Tab Panel */}
{visitorMode && (
{showVisitorTab && (
<div
role="tabpanel"
hidden={activeTab !== 1}
@@ -597,7 +634,7 @@ const LoginPage: React.FC = () => {
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
autoFocus={activeTab === 1}
disabled={waitTime > 0 || loginMutation.isPending}
disabled={waitTime > 0 || visitorLoginMutation.isPending}
slotProps={{
input: {
endAdornment: (
@@ -619,9 +656,9 @@ const LoginPage: React.FC = () => {
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
disabled={visitorLoginMutation.isPending || waitTime > 0}
>
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('visitorSignIn') || 'Visitor Sign In')}
{visitorLoginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('visitorSignIn') || 'Visitor Sign In')}
</Button>
</Box>
)}