mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
- Fixed authentication race condition where `isAuthenticated` was set before data loading completed, causing routes to render and tables to mount prematurely - Added `isInitialized` flag to delay route rendering until after all initialization data is loaded via `initData()` - Consolidated version and environment settings fetching into centralized settings store with caching to prevent redundant calls - Implemented stale fetch prevention in ChannelsTable and StreamsTable using fetch version tracking to ignore outdated responses - Fixed filter handling in tables to use `debouncedFilters` consistently, preventing unnecessary refetches - Added initialization guards using refs to prevent double-execution of auth and superuser checks during React StrictMode's intentional double-rendering in development - Removed duplicate version/environment fetch calls from Sidebar, LoginForm, and SuperuserForm by using centralized store
310 lines
8.5 KiB
JavaScript
310 lines
8.5 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import useAuthStore from '../../store/auth';
|
|
import useSettingsStore from '../../store/settings';
|
|
import {
|
|
Paper,
|
|
Title,
|
|
TextInput,
|
|
Button,
|
|
Center,
|
|
Stack,
|
|
Text,
|
|
Image,
|
|
Group,
|
|
Divider,
|
|
Modal,
|
|
Anchor,
|
|
Code,
|
|
Checkbox,
|
|
} from '@mantine/core';
|
|
import logo from '../../assets/logo.png';
|
|
|
|
const LoginForm = () => {
|
|
const login = useAuthStore((s) => s.login);
|
|
const logout = useAuthStore((s) => s.logout);
|
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
const initData = useAuthStore((s) => s.initData);
|
|
const fetchVersion = useSettingsStore((s) => s.fetchVersion);
|
|
const storedVersion = useSettingsStore((s) => s.version);
|
|
|
|
const navigate = useNavigate(); // Hook to navigate to other routes
|
|
const [formData, setFormData] = useState({ username: '', password: '' });
|
|
const [rememberMe, setRememberMe] = useState(false);
|
|
const [savePassword, setSavePassword] = useState(false);
|
|
const [forgotPasswordOpened, setForgotPasswordOpened] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
// Simple base64 encoding/decoding for localStorage
|
|
// Note: This is obfuscation, not encryption. Use browser's password manager for real security.
|
|
const encodePassword = (password) => {
|
|
try {
|
|
return btoa(password);
|
|
} catch (error) {
|
|
console.error('Encoding error:', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const decodePassword = (encoded) => {
|
|
try {
|
|
return atob(encoded);
|
|
} catch (error) {
|
|
console.error('Decoding error:', error);
|
|
return '';
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
// Fetch version info using the settings store (will skip if already loaded)
|
|
fetchVersion();
|
|
}, [fetchVersion]);
|
|
|
|
useEffect(() => {
|
|
// Load saved username if it exists
|
|
const savedUsername = localStorage.getItem(
|
|
'dispatcharr_remembered_username'
|
|
);
|
|
const savedPassword = localStorage.getItem('dispatcharr_saved_password');
|
|
|
|
if (savedUsername) {
|
|
setFormData((prev) => ({ ...prev, username: savedUsername }));
|
|
setRememberMe(true);
|
|
|
|
if (savedPassword) {
|
|
try {
|
|
const decrypted = decodePassword(savedPassword);
|
|
if (decrypted) {
|
|
setFormData((prev) => ({ ...prev, password: decrypted }));
|
|
setSavePassword(true);
|
|
}
|
|
} catch {
|
|
// If decoding fails, just skip
|
|
}
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isAuthenticated) {
|
|
navigate('/channels');
|
|
}
|
|
}, [isAuthenticated, navigate]);
|
|
|
|
const handleInputChange = (e) => {
|
|
setFormData({
|
|
...formData,
|
|
[e.target.name]: e.target.value,
|
|
});
|
|
};
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
await login(formData);
|
|
|
|
// Save username if remember me is checked
|
|
if (rememberMe) {
|
|
localStorage.setItem(
|
|
'dispatcharr_remembered_username',
|
|
formData.username
|
|
);
|
|
|
|
// Save password if save password is checked
|
|
if (savePassword) {
|
|
const encoded = encodePassword(formData.password);
|
|
if (encoded) {
|
|
localStorage.setItem('dispatcharr_saved_password', encoded);
|
|
}
|
|
} else {
|
|
localStorage.removeItem('dispatcharr_saved_password');
|
|
}
|
|
} else {
|
|
localStorage.removeItem('dispatcharr_remembered_username');
|
|
localStorage.removeItem('dispatcharr_saved_password');
|
|
}
|
|
|
|
await initData();
|
|
// Navigation will happen automatically via the useEffect or route protection
|
|
} catch (e) {
|
|
console.log(`Failed to login: ${e}`);
|
|
await logout();
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Center
|
|
style={{
|
|
height: '100vh',
|
|
}}
|
|
>
|
|
<Paper
|
|
elevation={3}
|
|
style={{
|
|
padding: 30,
|
|
width: '100%',
|
|
maxWidth: 500,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Stack align="center" spacing="lg">
|
|
<Image
|
|
src={logo}
|
|
alt="Dispatcharr Logo"
|
|
width={120}
|
|
height={120}
|
|
fit="contain"
|
|
/>
|
|
<Title order={2} align="center">
|
|
Dispatcharr
|
|
</Title>
|
|
<Text size="sm" color="dimmed" align="center">
|
|
Welcome back! Please log in to continue.
|
|
</Text>
|
|
<Divider style={{ width: '100%' }} />
|
|
</Stack>
|
|
<form onSubmit={handleSubmit}>
|
|
<Stack>
|
|
<TextInput
|
|
label="Username"
|
|
name="username"
|
|
value={formData.username}
|
|
onChange={handleInputChange}
|
|
required
|
|
/>
|
|
|
|
<TextInput
|
|
label="Password"
|
|
type="password"
|
|
name="password"
|
|
value={formData.password}
|
|
onChange={handleInputChange}
|
|
// required
|
|
/>
|
|
|
|
<Group justify="space-between" align="center">
|
|
<Group align="center" spacing="xs">
|
|
<Checkbox
|
|
label="Remember me"
|
|
checked={rememberMe}
|
|
onChange={(e) => setRememberMe(e.currentTarget.checked)}
|
|
size="sm"
|
|
/>
|
|
{rememberMe && (
|
|
<Checkbox
|
|
label="Save password"
|
|
checked={savePassword}
|
|
onChange={(e) => setSavePassword(e.currentTarget.checked)}
|
|
size="sm"
|
|
/>
|
|
)}
|
|
</Group>
|
|
<Anchor
|
|
size="sm"
|
|
component="button"
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setForgotPasswordOpened(true);
|
|
}}
|
|
>
|
|
Forgot password?
|
|
</Anchor>
|
|
</Group>
|
|
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
height: '0',
|
|
overflow: 'visible',
|
|
marginBottom: '-4px',
|
|
}}
|
|
>
|
|
{savePassword && (
|
|
<Text
|
|
size="xs"
|
|
color="red"
|
|
style={{
|
|
marginTop: '-10px',
|
|
marginBottom: '0',
|
|
lineHeight: '1.2',
|
|
}}
|
|
>
|
|
⚠ Password will be stored locally without encryption. Only use
|
|
on trusted devices.
|
|
</Text>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
type="submit"
|
|
fullWidth
|
|
loading={isLoading}
|
|
disabled={isLoading}
|
|
loaderProps={{ type: 'dots' }}
|
|
>
|
|
{isLoading ? 'Logging you in...' : 'Login'}
|
|
</Button>
|
|
</Stack>
|
|
</form>
|
|
|
|
{storedVersion.version && (
|
|
<Text
|
|
size="xs"
|
|
color="dimmed"
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: 6,
|
|
right: 30,
|
|
}}
|
|
>
|
|
v{storedVersion.version}
|
|
</Text>
|
|
)}
|
|
</Paper>
|
|
|
|
<Modal
|
|
opened={forgotPasswordOpened}
|
|
onClose={() => setForgotPasswordOpened(false)}
|
|
title="Reset Your Password"
|
|
centered
|
|
>
|
|
<Stack spacing="md">
|
|
<Text>
|
|
To reset your password, your administrator needs to run a Django
|
|
management command:
|
|
</Text>
|
|
<div>
|
|
<Text weight={500} size="sm" mb={8}>
|
|
If running with Docker:
|
|
</Text>
|
|
<Code block>
|
|
docker exec <container_name> python manage.py changepassword
|
|
<username>
|
|
</Code>
|
|
</div>
|
|
<div>
|
|
<Text weight={500} size="sm" mb={8}>
|
|
If running locally:
|
|
</Text>
|
|
<Code block>python manage.py changepassword <username></Code>
|
|
</div>
|
|
<Text size="sm" color="dimmed">
|
|
The command will prompt for a new password. Replace
|
|
<code><container_name></code> with your Docker container name
|
|
and <code><username></code> with the account username.
|
|
</Text>
|
|
<Text size="sm" color="dimmed" italic>
|
|
Please contact your system administrator to perform a password
|
|
reset.
|
|
</Text>
|
|
</Stack>
|
|
</Modal>
|
|
</Center>
|
|
);
|
|
};
|
|
|
|
export default LoginForm;
|