PortainerManager/app/components/LoginForm.tsx

271 lines
9.0 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Text, View, TextInput, StyleSheet, Animated, Pressable, ActivityIndicator } from "react-native";
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../context/AuthContext';
/**
* LoginForm component handles user authentication with a Portainer instance.
* It includes a multi-step form that first validates the domain URL,
* then allows entering username/password credentials.
*/
export default function LoginForm() {
// Form state and animation values
const [formData, setFormData] = useState({ username: '', password: '', domain: '' });
const [isDomainValid, setIsDomainValid] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const buttonScale = useState(new Animated.Value(1))[0];
const buttonRotation = useState(new Animated.Value(0))[0];
const fadeAnim = useState(new Animated.Value(0))[0];
const { setAuthState } = useAuth();
/**
* Validates domain input and animates the login form appearance
* when a valid domain is entered
*/
useEffect(() => {
// Basic FQDN validation
const fqdnRegex = /^(?:https?:\/\/)?(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?::\d+)?(?:\/\S*)?$/;
const isValid = fqdnRegex.test(formData.domain);
setIsDomainValid(isValid);
// Fade animation
Animated.timing(fadeAnim, {
toValue: isValid ? 1 : 0,
duration: 500,
useNativeDriver: true
}).start();
}, [formData.domain]);
/**
* Handles the login form submission and authentication process
* Includes success/failure animations for visual feedback
*/
const handleLogin = async () => {
setIsLoading(true);
try {
const response = await fetch(`${formData.domain}/api/auth`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
credentials: 'include', // Include cookies if needed
body: JSON.stringify({
username: formData.username,
password: formData.password,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Store the domain and auth data
await setAuthState(formData.domain, formData.username, data);
// Success animation sequence: rotate -> scale up -> scale back
Animated.sequence([
Animated.timing(buttonRotation, {
toValue: 1,
duration: 400,
useNativeDriver: true
}),
Animated.spring(buttonScale, {
toValue: 1.1,
useNativeDriver: true
}),
Animated.spring(buttonScale, {
toValue: 1,
useNativeDriver: true
})
]).start();
} catch (error) {
console.error('Login error:', error);
} finally {
setIsLoading(false);
}
};
return (
<View style={styles.container}>
{/* Domain input section */}
<Text style={styles.title}>Please enter your Portainer domain</Text>
<Text style={styles.infoText}>Domain must be HTTPS</Text>
<TextInput
style={styles.domainInput}
placeholder="https://your-portainer-domain.com"
placeholderTextColor="#9EA0A4"
onChangeText={text => setFormData({ ...formData, domain: text })}
/>
{/* Login credentials section - appears after valid domain */}
<Animated.View
style={[
styles.loginSection,
{
opacity: fadeAnim,
transform: [{
translateY: fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: [20, 0]
})
}]
}
]}
>
<Text style={styles.title}>Please enter your username and password</Text>
<TextInput
style={styles.input}
placeholder="Username"
placeholderTextColor="#9EA0A4"
onChangeText={text => setFormData({ ...formData, username: text })}
/>
<TextInput
style={styles.input}
secureTextEntry
placeholder="Password"
placeholderTextColor="#9EA0A4"
onChangeText={text => setFormData({ ...formData, password: text })}
/>
{/* Animated login button */}
<Animated.View style={{
transform: [
{ scale: buttonScale },
{
rotate: buttonRotation.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg']
})
}
]
}}>
<Pressable
onPress={() => {
if (!isLoading) {
// Button press animation
Animated.sequence([
Animated.spring(buttonScale, {
toValue: 0.95,
useNativeDriver: true
}),
Animated.spring(buttonScale, {
toValue: 1,
useNativeDriver: true
})
]).start();
handleLogin();
}
}}
style={styles.loginButton}
>
<View style={styles.buttonContent}>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<>
<Ionicons name="log-in-outline" size={24} color="#fff" style={styles.buttonIcon} />
<Text style={styles.buttonText}>Login</Text>
</>
)}
</View>
</Pressable>
</Animated.View>
</Animated.View>
</View>
);
}
/**
* Component styles
* Includes styling for container, form inputs, buttons, and animations
*/
const styles = StyleSheet.create({
container: {
width: '100%',
padding: 10,
backgroundColor: '#ffffff',
borderRadius: 10,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
loginSection: {
marginTop: 20,
paddingTop: 20,
borderTopWidth: 1,
borderTopColor: '#E9ECEF'
},
domainInput: {
backgroundColor: '#EDF2F7',
borderRadius: 8,
padding: 15,
borderWidth: 1,
borderColor: '#CBD5E0',
fontSize: 16,
color: '#4A5568',
fontWeight: '500'
},
title: {
fontSize: 18,
fontWeight: '600',
color: '#2C3E50',
marginBottom: 20,
textAlign: 'center'
},
input: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
padding: 15,
marginBottom: 15,
borderWidth: 1,
borderColor: '#E9ECEF',
fontSize: 16,
color: '#2C3E50'
},
loginButton: {
backgroundColor: '#4299E1',
borderRadius: 8,
padding: 15,
marginTop: 10,
shadowColor: "#4299E1",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
},
buttonContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
buttonIcon: {
marginRight: 8,
},
buttonText: {
color: '#fff',
fontSize: 16,
marginLeft: 8,
fontWeight: '600'
},
infoText: {
fontSize: 14,
color: '#718096',
marginBottom: 15,
textAlign: 'center',
fontStyle: 'italic'
},
});