271 lines
9.0 KiB
TypeScript
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'
|
|
},
|
|
});
|