Initial LinkedSign platform
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { Alert, FlatList, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { useState } from 'react';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import ScreenContainer from '../../components/ScreenContainer';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import { colors } from '../../theme/colors';
|
||||
import { useSync } from '../../context/SyncContext';
|
||||
import { uploadCertificate } from '../../api/certificates';
|
||||
|
||||
export default function CertificatesScreen() {
|
||||
const { certificates, syncNow } = useSync();
|
||||
const [alias, setAlias] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSync = async () => {
|
||||
try {
|
||||
await syncNow();
|
||||
Alert.alert('Certificados', 'Certificados sincronizados.');
|
||||
} catch (error) {
|
||||
Alert.alert('Certificados', error.response?.data?.error || 'No se pudo sincronizar.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const picked = await DocumentPicker.getDocumentAsync({
|
||||
type: [
|
||||
'application/x-pkcs12',
|
||||
'application/octet-stream',
|
||||
],
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (picked.canceled) return;
|
||||
const file = picked.assets[0];
|
||||
const formData = new FormData();
|
||||
formData.append('file', {
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
type: file.mimeType || 'application/octet-stream',
|
||||
});
|
||||
formData.append('alias', alias);
|
||||
formData.append('password', password);
|
||||
|
||||
try {
|
||||
await uploadCertificate(formData);
|
||||
setAlias('');
|
||||
setPassword('');
|
||||
await syncNow();
|
||||
Alert.alert('Certificados', 'Certificado subido correctamente.');
|
||||
} catch (error) {
|
||||
Alert.alert('Certificados', error.response?.data?.error || 'No se pudo subir el certificado.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Subir certificado</Text>
|
||||
<TextInput style={styles.input} placeholder="Alias" value={alias} onChangeText={setAlias} />
|
||||
<TextInput style={styles.input} placeholder="Contraseña del P12/PFX (opcional)" secureTextEntry value={password} onChangeText={setPassword} />
|
||||
<PrimaryButton title="Seleccionar y subir" onPress={handleUpload} />
|
||||
</View>
|
||||
|
||||
<PrimaryButton title="Sincronizar certificados" onPress={handleSync} variant="secondary" />
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Certificados sincronizados</Text>
|
||||
<FlatList
|
||||
data={certificates}
|
||||
keyExtractor={(item) => `${item.id}`}
|
||||
scrollEnabled={false}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.item}>
|
||||
<Text style={styles.itemTitle}>{item.alias}</Text>
|
||||
<Text style={styles.itemMeta}>{item.subject_cn || 'Sin sujeto'}</Text>
|
||||
<Text style={styles.itemMeta}>Válido hasta: {item.valid_to || 'N/D'}</Text>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={<Text style={styles.empty}>No hay certificados sincronizados.</Text>}
|
||||
/>
|
||||
</View>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
item: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
gap: 4,
|
||||
},
|
||||
itemTitle: {
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
itemMeta: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
},
|
||||
empty: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Alert, StyleSheet, Text, View } from 'react-native';
|
||||
import ScreenContainer from '../../components/ScreenContainer';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useSync } from '../../context/SyncContext';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const { user } = useAuth();
|
||||
const { documents, certificates, signedDocuments, syncNow, syncing, lastSyncAt } = useSync();
|
||||
|
||||
const handleSync = async () => {
|
||||
try {
|
||||
await syncNow();
|
||||
Alert.alert('Sincronización', 'Los datos del teléfono fueron sincronizados con el backend.');
|
||||
} catch (error) {
|
||||
Alert.alert('Sincronización', error.response?.data?.error || 'No se pudo sincronizar.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.greeting}>Hola, {user?.name || user?.email}</Text>
|
||||
<Text style={styles.subtext}>LinkedSign móvil comparte autenticación con la app web y sincroniza datos bajo petición.</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsGrid}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{documents.length}</Text>
|
||||
<Text style={styles.statLabel}>Documentos</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{certificates.length}</Text>
|
||||
<Text style={styles.statLabel}>Certificados</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{signedDocuments.length}</Text>
|
||||
<Text style={styles.statLabel}>Firmados</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Sincronización local</Text>
|
||||
<Text style={styles.cardText}>
|
||||
La base local del teléfono se actualiza solo cuando tú lo pides. La sesión permanece guardada de forma segura.
|
||||
</Text>
|
||||
<Text style={styles.syncMeta}>
|
||||
Última sincronización: {lastSyncAt ? new Date(lastSyncAt).toLocaleString('es-EC') : 'Sin sincronizar'}
|
||||
</Text>
|
||||
<PrimaryButton title={syncing ? 'Sincronizando...' : 'Sincronizar ahora'} onPress={handleSync} disabled={syncing} />
|
||||
</View>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
hero: {
|
||||
backgroundColor: colors.dark,
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
gap: 8,
|
||||
},
|
||||
greeting: {
|
||||
color: '#fff',
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
},
|
||||
subtext: {
|
||||
color: '#cbd5e1',
|
||||
lineHeight: 20,
|
||||
},
|
||||
statsGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: colors.primary,
|
||||
},
|
||||
statLabel: {
|
||||
color: colors.textMuted,
|
||||
marginTop: 4,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 18,
|
||||
padding: 18,
|
||||
gap: 10,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
cardText: {
|
||||
color: colors.textMuted,
|
||||
lineHeight: 20,
|
||||
},
|
||||
syncMeta: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Alert, FlatList, StyleSheet, Text, View } from 'react-native';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import ScreenContainer from '../../components/ScreenContainer';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import { colors } from '../../theme/colors';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useSync } from '../../context/SyncContext';
|
||||
import { uploadDocument } from '../../api/documents';
|
||||
import { downloadAndShareFile } from '../../utils/files';
|
||||
import { API_BASE_URL } from '../../config/env';
|
||||
|
||||
function SectionCard({ title, rows, emptyText, actionLabel, onActionPress, onDownload, token, type }) {
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
{actionLabel ? <PrimaryButton title={actionLabel} onPress={onActionPress} /> : null}
|
||||
{rows.length === 0 ? (
|
||||
<Text style={styles.emptyText}>{emptyText}</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
data={rows}
|
||||
keyExtractor={(item) => `${type}-${item.id}`}
|
||||
scrollEnabled={false}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.itemCard}>
|
||||
<Text style={styles.itemTitle}>{item.title || item.signed_filename}</Text>
|
||||
<Text style={styles.itemMeta}>
|
||||
{item.status || item.document_title || 'Documento'}
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
title="Descargar / Compartir"
|
||||
variant="secondary"
|
||||
onPress={() => onDownload(item, token)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentsScreen() {
|
||||
const { tokens } = useAuth();
|
||||
const { documents, signedDocuments, syncNow, refreshLocalCache } = useSync();
|
||||
|
||||
const handleUpload = async () => {
|
||||
const picked = await DocumentPicker.getDocumentAsync({
|
||||
type: 'application/pdf',
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (picked.canceled) return;
|
||||
const file = picked.assets[0];
|
||||
const formData = new FormData();
|
||||
formData.append('file', {
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadDocument(formData);
|
||||
await syncNow();
|
||||
refreshLocalCache();
|
||||
Alert.alert('Documentos', 'PDF subido correctamente.');
|
||||
} catch (error) {
|
||||
Alert.alert('Documentos', error.response?.data?.error || 'No se pudo subir el PDF.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
try {
|
||||
await syncNow();
|
||||
Alert.alert('Documentos', 'Documentos sincronizados.');
|
||||
} catch (error) {
|
||||
Alert.alert('Documentos', error.response?.data?.error || 'No se pudo sincronizar.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadOriginal = async (item) => {
|
||||
await downloadAndShareFile({
|
||||
url: `${API_BASE_URL}/documents/${item.id}/download`,
|
||||
token: tokens.accessToken,
|
||||
filename: item.filename || `${item.title}.pdf`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadSigned = async (item) => {
|
||||
await downloadAndShareFile({
|
||||
url: `${API_BASE_URL}/signatures/${item.id}/download`,
|
||||
token: tokens.accessToken,
|
||||
filename: item.signed_filename,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<PrimaryButton title="Sincronizar documentos" onPress={handleSync} variant="secondary" />
|
||||
<SectionCard
|
||||
title="Originales"
|
||||
rows={documents}
|
||||
emptyText="No hay documentos sincronizados en el teléfono."
|
||||
actionLabel="Subir PDF"
|
||||
onActionPress={handleUpload}
|
||||
onDownload={handleDownloadOriginal}
|
||||
token={tokens.accessToken}
|
||||
type="original"
|
||||
/>
|
||||
<SectionCard
|
||||
title="Firmados"
|
||||
rows={signedDocuments}
|
||||
emptyText="No hay documentos firmados sincronizados."
|
||||
onDownload={handleDownloadSigned}
|
||||
token={tokens.accessToken}
|
||||
type="signed"
|
||||
/>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
itemCard: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
gap: 8,
|
||||
},
|
||||
itemTitle: {
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
},
|
||||
itemMeta: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Alert, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { useMemo, useState } from 'react';
|
||||
import ScreenContainer from '../../components/ScreenContainer';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import PasswordRules from '../../components/PasswordRules';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useSync } from '../../context/SyncContext';
|
||||
import { isPasswordStrong } from '../../utils/passwordRules';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, logout, requestPasswordChange } = useAuth();
|
||||
const { lastSyncAt, syncNow, syncing } = useSync();
|
||||
const [form, setForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const syncLabel = useMemo(
|
||||
() => (lastSyncAt ? new Date(lastSyncAt).toLocaleString('es-EC') : 'Sin sincronizar'),
|
||||
[lastSyncAt]
|
||||
);
|
||||
|
||||
const updateField = (name, value) => setForm((current) => ({ ...current, [name]: value }));
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
if (!isPasswordStrong(form.newPassword)) {
|
||||
Alert.alert('Seguridad', 'La nueva contraseña no cumple con los requisitos.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
Alert.alert('Seguridad', 'Las nuevas contraseñas no coinciden.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await requestPasswordChange({
|
||||
currentPassword: form.currentPassword,
|
||||
newPassword: form.newPassword,
|
||||
});
|
||||
setForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
Alert.alert('Perfil', 'Te enviamos un correo para confirmar el cambio de contraseña.');
|
||||
} catch (error) {
|
||||
Alert.alert('Perfil', error.response?.data?.error || 'No se pudo solicitar el cambio de contraseña.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Perfil</Text>
|
||||
<Text style={styles.item}><Text style={styles.label}>Correo:</Text> {user?.email}</Text>
|
||||
<Text style={styles.item}><Text style={styles.label}>Rol:</Text> {user?.role}</Text>
|
||||
<Text style={styles.item}><Text style={styles.label}>Última sincronización:</Text> {syncLabel}</Text>
|
||||
<PrimaryButton title={syncing ? 'Sincronizando...' : 'Sincronizar ahora'} onPress={syncNow} disabled={syncing} variant="secondary" />
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Solicitar cambio de contraseña</Text>
|
||||
<TextInput style={styles.input} placeholder="Contraseña actual" secureTextEntry value={form.currentPassword} onChangeText={(value) => updateField('currentPassword', value)} />
|
||||
<TextInput style={styles.input} placeholder="Nueva contraseña" secureTextEntry value={form.newPassword} onChangeText={(value) => updateField('newPassword', value)} />
|
||||
<PasswordRules password={form.newPassword} />
|
||||
<TextInput style={styles.input} placeholder="Confirmar nueva contraseña" secureTextEntry value={form.confirmPassword} onChangeText={(value) => updateField('confirmPassword', value)} />
|
||||
<PrimaryButton title={saving ? 'Enviando...' : 'Enviar solicitud'} onPress={handlePasswordChange} disabled={saving} />
|
||||
</View>
|
||||
|
||||
<PrimaryButton title="Cerrar sesión" onPress={logout} variant="secondary" />
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
gap: 10,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
label: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
item: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Alert, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import ScreenContainer from '../../components/ScreenContainer';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import { colors } from '../../theme/colors';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useSync } from '../../context/SyncContext';
|
||||
import { signDocument } from '../../api/signatures';
|
||||
|
||||
const selectFirst = (rows) => (rows.length > 0 ? String(rows[0].id) : '');
|
||||
|
||||
export default function SignScreen() {
|
||||
const { user } = useAuth();
|
||||
const { documents, certificates, syncNow } = useSync();
|
||||
const [form, setForm] = useState({
|
||||
documentId: selectFirst(documents),
|
||||
certificateId: selectFirst(certificates),
|
||||
certPassword: '',
|
||||
reason: 'Firmado digitalmente',
|
||||
location: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const activeCertificates = useMemo(
|
||||
() => certificates.filter((certificate) => certificate.is_active === 1 || certificate.is_active === true),
|
||||
[certificates]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.documentId && documents.length > 0) {
|
||||
setForm((current) => ({ ...current, documentId: String(documents[0].id) }));
|
||||
}
|
||||
}, [documents, form.documentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.certificateId && activeCertificates.length > 0) {
|
||||
setForm((current) => ({ ...current, certificateId: String(activeCertificates[0].id) }));
|
||||
}
|
||||
}, [activeCertificates, form.certificateId]);
|
||||
|
||||
const handleSign = async () => {
|
||||
if (!form.documentId || !form.certificateId) {
|
||||
Alert.alert('Firmar', 'Selecciona un documento y un certificado sincronizados.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await signDocument({
|
||||
documentId: Number(form.documentId),
|
||||
certificateId: Number(form.certificateId),
|
||||
certPassword: form.certPassword,
|
||||
reason: form.reason,
|
||||
location: form.location,
|
||||
signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr',
|
||||
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
|
||||
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
|
||||
qrBorder: user?.signaturePreferences?.qrBorder || 'none',
|
||||
signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null,
|
||||
});
|
||||
await syncNow();
|
||||
Alert.alert('Firmar', 'Documento firmado. Se usó la posición visible por defecto del backend.');
|
||||
} catch (error) {
|
||||
Alert.alert('Firmar', error.response?.data?.error || 'No se pudo firmar el documento.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Firma básica desde Android</Text>
|
||||
<Text style={styles.subtitle}>Esta versión usa la configuración visual guardada y la posición por defecto del backend.</Text>
|
||||
|
||||
<Text style={styles.label}>Documento</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={form.documentId}
|
||||
onChangeText={(value) => setForm((current) => ({ ...current, documentId: value }))}
|
||||
placeholder="ID de documento sincronizado"
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Disponibles: {documents.map((item) => `${item.id}`).join(', ') || 'sin documentos'}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.label}>Certificado</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={form.certificateId}
|
||||
onChangeText={(value) => setForm((current) => ({ ...current, certificateId: value }))}
|
||||
placeholder="ID de certificado"
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Disponibles: {activeCertificates.map((item) => `${item.id}`).join(', ') || 'sin certificados'}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={form.certPassword}
|
||||
onChangeText={(value) => setForm((current) => ({ ...current, certPassword: value }))}
|
||||
placeholder="Contraseña del certificado (si aplica)"
|
||||
secureTextEntry
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={form.reason}
|
||||
onChangeText={(value) => setForm((current) => ({ ...current, reason: value }))}
|
||||
placeholder="Razón"
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={form.location}
|
||||
onChangeText={(value) => setForm((current) => ({ ...current, location: value }))}
|
||||
placeholder="Ubicación"
|
||||
/>
|
||||
<PrimaryButton title={loading ? 'Firmando...' : 'Firmar documento'} onPress={handleSign} disabled={loading} />
|
||||
</View>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
gap: 10,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textMuted,
|
||||
lineHeight: 20,
|
||||
},
|
||||
label: {
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginTop: 4,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
hint: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import ScreenContainer from '../../components/ScreenContainer';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import { colors } from '../../theme/colors';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
export default function LoginScreen({ navigation }) {
|
||||
const { login } = useAuth();
|
||||
const [form, setForm] = useState({ email: '', password: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateField = (name, value) => setForm((current) => ({ ...current, [name]: value }));
|
||||
|
||||
const handleLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(form.email, form.password);
|
||||
} catch (error) {
|
||||
const code = error.response?.data?.code;
|
||||
const message = error.response?.data?.error || 'No se pudo iniciar sesión';
|
||||
if (code === 'EMAIL_NOT_VERIFIED') {
|
||||
Alert.alert('Correo pendiente', 'Tu cuenta aún no está validada. Te enviamos un nuevo correo de confirmación.');
|
||||
} else if (code === 'MAIL_SEND_FAILED') {
|
||||
Alert.alert('Correo no enviado', message);
|
||||
} else {
|
||||
Alert.alert('Iniciar sesión', message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.brand}>LinkedSign</Text>
|
||||
<Text style={styles.subtitle}>Firma digital segura desde Android</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Iniciar sesión</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Correo electrónico"
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={form.email}
|
||||
onChangeText={(value) => updateField('email', value)}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Contraseña"
|
||||
secureTextEntry
|
||||
value={form.password}
|
||||
onChangeText={(value) => updateField('password', value)}
|
||||
/>
|
||||
<PrimaryButton title={loading ? 'Ingresando...' : 'Iniciar sesión'} onPress={handleLogin} disabled={loading} />
|
||||
</View>
|
||||
|
||||
<Pressable onPress={() => navigation.navigate('Register')}>
|
||||
<Text style={styles.link}>¿No tienes cuenta? Regístrate</Text>
|
||||
</Pressable>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingTop: 24,
|
||||
gap: 4,
|
||||
},
|
||||
brand: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: colors.text,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
link: {
|
||||
textAlign: 'center',
|
||||
color: colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { useState } from 'react';
|
||||
import ScreenContainer from '../../components/ScreenContainer';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import PasswordRules from '../../components/PasswordRules';
|
||||
import { isPasswordStrong } from '../../utils/passwordRules';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
export default function RegisterScreen({ navigation }) {
|
||||
const { register } = useAuth();
|
||||
const [form, setForm] = useState({ email: '', password: '', confirmPassword: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateField = (name, value) => setForm((current) => ({ ...current, [name]: value }));
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!isPasswordStrong(form.password)) {
|
||||
Alert.alert('Contraseña insegura', 'Tu contraseña no cumple con los requisitos mínimos.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
Alert.alert('Confirmación', 'Las contraseñas no coinciden.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await register({ email: form.email, password: form.password });
|
||||
navigation.replace('VerifyNotice', { email: form.email });
|
||||
} catch (error) {
|
||||
Alert.alert('Registro', error.response?.data?.error || 'No se pudo registrar la cuenta.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Crear cuenta</Text>
|
||||
<Text style={styles.subtitle}>Regístrate con correo y contraseña segura.</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Correo electrónico"
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={form.email}
|
||||
onChangeText={(value) => updateField('email', value)}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Contraseña"
|
||||
secureTextEntry
|
||||
value={form.password}
|
||||
onChangeText={(value) => updateField('password', value)}
|
||||
/>
|
||||
<PasswordRules password={form.password} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Confirmar contraseña"
|
||||
secureTextEntry
|
||||
value={form.confirmPassword}
|
||||
onChangeText={(value) => updateField('confirmPassword', value)}
|
||||
/>
|
||||
<PrimaryButton title={loading ? 'Creando...' : 'Crear cuenta'} onPress={handleRegister} disabled={loading} />
|
||||
</View>
|
||||
|
||||
<Pressable onPress={() => navigation.goBack()}>
|
||||
<Text style={styles.link}>Volver al inicio de sesión</Text>
|
||||
</Pressable>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
link: {
|
||||
textAlign: 'center',
|
||||
color: colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import ScreenContainer from '../../components/ScreenContainer';
|
||||
import PrimaryButton from '../../components/PrimaryButton';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
export default function VerifyNoticeScreen({ navigation, route }) {
|
||||
const email = route.params?.email || '';
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Confirma tu cuenta</Text>
|
||||
<Text style={styles.text}>
|
||||
Enviamos un enlace de validación a {email || 'tu correo'}. Debes confirmarlo antes de iniciar sesión.
|
||||
</Text>
|
||||
<PrimaryButton title="Volver al login" onPress={() => navigation.navigate('Login')} />
|
||||
</View>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
text: {
|
||||
color: colors.textMuted,
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user