Initial LinkedSign platform

This commit is contained in:
2026-04-06 20:33:59 -05:00
commit 1074c7c9d2
159 changed files with 19072 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.expo
dist
android
ios
*.log
+18
View File
@@ -0,0 +1,18 @@
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AuthProvider } from './src/context/AuthContext';
import { SyncProvider } from './src/context/SyncContext';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
return (
<SafeAreaProvider>
<AuthProvider>
<SyncProvider>
<StatusBar style="light" />
<AppNavigator />
</SyncProvider>
</AuthProvider>
</SafeAreaProvider>
);
}
+101
View File
@@ -0,0 +1,101 @@
# LinkedSign Android
Aplicación móvil Android de LinkedSign construida con Expo + React Native. Esta app complementa a la versión web y usa el mismo backend.
## Objetivo
- compartir autenticación con la app web
- guardar la sesión localmente en el teléfono
- mantener cache local para trabajo móvil
- sincronizar documentos, certificados y firmados solo cuando el usuario lo solicite
## Alcance actual
- login
- registro
- aviso de verificación por correo
- dashboard con estado de sincronización
- documentos originales y firmados
- subida de PDF original
- descarga y compartir documentos firmados
- certificados
- subida de `.p12` / `.pfx`
- firmado básico usando la configuración visual ya guardada por el usuario
- perfil y solicitud de cambio de contraseña
- base local SQLite para cache
## Cómo se complementa con la web
- la web sigue siendo el cliente principal para flujos avanzados
- Android usa el mismo usuario, misma contraseña y mismo backend
- la sesión se guarda en el teléfono con `expo-secure-store`
- los datos de negocio no se sincronizan solos: el usuario decide cuándo sincronizar
- el estilo visual de firma se toma del backend, por lo que si lo cambias en la web también se usa al firmar desde Android
## Estructura
```text
android/
App.js
app.json
package.json
src/
api/
components/
config/
context/
navigation/
screens/
storage/
theme/
utils/
```
## Configuración
La URL del backend se define en [app.json](/home/mgonzalez/desarrollo/react/linkedsign/android/app.json) y puede sobreescribirse con `EXPO_PUBLIC_API_BASE_URL`.
Valor actual:
```json
{
"expo": {
"extra": {
"apiBaseUrl": "http://192.168.222.72:4000"
}
}
}
```
## Ejecutar
```bash
cd /home/mgonzalez/desarrollo/react/linkedsign/android
npm install
npm run start
```
Para compilar o abrir en Android:
```bash
npm run android
```
## Sincronización
La app guarda cache local en SQLite para:
- perfil
- documentos originales
- certificados
- documentos firmados
- fecha de última sincronización
La sincronización se ejecuta manualmente desde la app. Eso evita sobreescribir datos locales o consumir red sin que el usuario lo pida.
## Notas
- La confirmación de correo y el cambio de contraseña se validan contra el mismo backend de LinkedSign.
- La validación del enlace de correo puede abrirse en navegador si el enlace apunta al frontend web.
- Para una prueba real en dispositivo, el teléfono debe poder alcanzar la IP del backend.
- Este cliente no reemplaza la web; la complementa con acceso móvil, cache local y sincronización bajo demanda.
+22
View File
@@ -0,0 +1,22 @@
{
"expo": {
"name": "LinkedSign",
"slug": "linkedsign-android",
"scheme": "linkedsign",
"version": "0.1.0",
"orientation": "portrait",
"userInterfaceStyle": "light",
"splash": {
"backgroundColor": "#111827"
},
"assetBundlePatterns": [
"**/*"
],
"android": {
"package": "net.linkedcorp.linkedsign"
},
"extra": {
"apiBaseUrl": "http://192.168.222.72:4000"
}
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
+29
View File
@@ -0,0 +1,29 @@
{
"name": "linkedsign-android",
"version": "0.1.0",
"private": true,
"main": "expo/AppEntry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"web": "expo start --web"
},
"dependencies": {
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/native": "^7.1.8",
"@react-navigation/native-stack": "^7.3.12",
"axios": "^1.9.0",
"expo": "~53.0.0",
"expo-document-picker": "~14.0.0",
"expo-file-system": "~18.0.0",
"expo-linking": "~7.0.0",
"expo-secure-store": "~14.0.0",
"expo-sharing": "~13.0.0",
"expo-sqlite": "~15.0.0",
"expo-status-bar": "~2.2.3",
"react": "19.0.0",
"react-native": "0.79.0",
"react-native-safe-area-context": "^5.4.0",
"react-native-screens": "^4.11.1"
}
}
+7
View File
@@ -0,0 +1,7 @@
import client from './client';
export const login = (payload) => client.post('/auth/login', payload);
export const register = (payload) => client.post('/auth/register', payload);
export const getMe = () => client.get('/auth/me');
export const logout = (refreshToken) => client.post('/auth/logout', { refreshToken });
export const changePassword = (payload) => client.patch('/auth/change-password', payload);
+7
View File
@@ -0,0 +1,7 @@
import client from './client';
export const listCertificates = () => client.get('/certificates');
export const uploadCertificate = (formData) =>
client.post('/certificates', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
+78
View File
@@ -0,0 +1,78 @@
import axios from 'axios';
import { API_BASE_URL } from '../config/env';
import { loadSession, saveSession, clearSession } from '../storage/session';
const client = axios.create({
baseURL: API_BASE_URL,
timeout: 15000,
});
let isRefreshing = false;
let pendingQueue = [];
const resolveQueue = (error, token = null) => {
pendingQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token);
});
pendingQueue = [];
};
client.interceptors.request.use(async (config) => {
const { accessToken } = await loadSession();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status !== 401 || originalRequest?._retry) {
throw error;
}
const session = await loadSession();
if (!session.refreshToken) {
await clearSession();
throw error;
}
if (isRefreshing) {
const token = await new Promise((resolve, reject) => {
pendingQueue.push({ resolve, reject });
});
originalRequest.headers.Authorization = `Bearer ${token}`;
return client(originalRequest);
}
isRefreshing = true;
originalRequest._retry = true;
try {
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
refreshToken: session.refreshToken,
});
const { accessToken, refreshToken } = response.data.data;
await saveSession({
accessToken,
refreshToken,
user: session.user,
});
resolveQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return client(originalRequest);
} catch (refreshError) {
resolveQueue(refreshError, null);
await clearSession();
throw refreshError;
} finally {
isRefreshing = false;
}
}
);
export default client;
+7
View File
@@ -0,0 +1,7 @@
import client from './client';
export const listDocuments = () => client.get('/documents?limit=100');
export const uploadDocument = (formData) =>
client.post('/documents', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
+4
View File
@@ -0,0 +1,4 @@
import client from './client';
export const listSignedDocuments = () => client.get('/signatures/mine');
export const signDocument = (payload) => client.post('/signatures/sign', payload);
@@ -0,0 +1,47 @@
import { StyleSheet, Text, View } from 'react-native';
import { getPasswordChecks } from '../utils/passwordRules';
import { colors } from '../theme/colors';
export default function PasswordRules({ password }) {
const checks = getPasswordChecks(password);
return (
<View style={styles.wrapper}>
{checks.map((check) => (
<View key={check.key} style={styles.row}>
<Text style={[styles.icon, check.valid ? styles.ok : styles.pending]}>
{check.valid ? '✓' : '•'}
</Text>
<Text style={[styles.label, check.valid ? styles.ok : styles.pending]}>
{check.label}
</Text>
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: 4,
marginTop: 8,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
icon: {
fontSize: 12,
fontWeight: '700',
},
label: {
fontSize: 12,
},
ok: {
color: colors.success,
},
pending: {
color: colors.textMuted,
},
});
@@ -0,0 +1,47 @@
import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native';
import { colors } from '../theme/colors';
export default function PrimaryButton({ title, onPress, variant = 'primary', disabled = false }) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
style={[
styles.button,
variant === 'secondary' && styles.secondary,
disabled && styles.disabled,
]}
>
{disabled ? <ActivityIndicator color="#fff" /> : (
<Text style={[styles.label, variant === 'secondary' && styles.secondaryLabel]}>{title}</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
minHeight: 48,
borderRadius: 12,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
},
secondary: {
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
disabled: {
opacity: 0.65,
},
label: {
color: '#fff',
fontWeight: '700',
fontSize: 15,
},
secondaryLabel: {
color: colors.text,
},
});
@@ -0,0 +1,26 @@
import { ScrollView, StyleSheet, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../theme/colors';
export default function ScreenContainer({ children, scroll = true, contentContainerStyle }) {
const content = scroll ? (
<ScrollView contentContainerStyle={[styles.scrollContent, contentContainerStyle]}>
{children}
</ScrollView>
) : (
<View style={[styles.scrollContent, contentContainerStyle]}>{children}</View>
);
return <SafeAreaView style={styles.safe}>{content}</SafeAreaView>;
}
const styles = StyleSheet.create({
safe: {
flex: 1,
backgroundColor: colors.background,
},
scrollContent: {
padding: 16,
gap: 16,
},
});
+8
View File
@@ -0,0 +1,8 @@
import Constants from 'expo-constants';
const extra = Constants.expoConfig?.extra || {};
export const API_BASE_URL =
process.env.EXPO_PUBLIC_API_BASE_URL ||
extra.apiBaseUrl ||
'http://192.168.222.72:4000';
+86
View File
@@ -0,0 +1,86 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Alert } from 'react-native';
import { changePassword as apiChangePassword, getMe, login as apiLogin, logout as apiLogout, register as apiRegister } from '../api/auth';
import { initLocalDb, saveProfileCache } from '../storage/localDb';
import { clearSession, loadSession, saveSession } from '../storage/session';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [tokens, setTokens] = useState({ accessToken: null, refreshToken: null });
const [loading, setLoading] = useState(true);
useEffect(() => {
const bootstrap = async () => {
initLocalDb();
const session = await loadSession();
if (!session.accessToken || !session.refreshToken) {
setLoading(false);
return;
}
setTokens({ accessToken: session.accessToken, refreshToken: session.refreshToken });
try {
const { data } = await getMe();
setUser(data.data.user);
saveProfileCache(data.data.user);
} catch (error) {
await clearSession();
} finally {
setLoading(false);
}
};
bootstrap();
}, []);
const login = useCallback(async (email, password) => {
const { data } = await apiLogin({ email, password });
const payload = data.data;
await saveSession(payload);
saveProfileCache(payload.user);
setUser(payload.user);
setTokens({ accessToken: payload.accessToken, refreshToken: payload.refreshToken });
return payload.user;
}, []);
const register = useCallback(async ({ email, password }) => {
return apiRegister({ email, password });
}, []);
const logout = useCallback(async () => {
try {
const session = await loadSession();
if (session.refreshToken) {
await apiLogout(session.refreshToken);
}
} catch (error) {
Alert.alert('Cerrar sesión', 'No se pudo notificar el cierre al backend. La sesión local se cerrará igual.');
} finally {
await clearSession();
setUser(null);
setTokens({ accessToken: null, refreshToken: null });
}
}, []);
const requestPasswordChange = useCallback(async ({ currentPassword, newPassword }) => {
return apiChangePassword({ currentPassword, newPassword });
}, []);
const value = useMemo(() => ({
user,
tokens,
loading,
isAuthenticated: Boolean(user && tokens.accessToken),
login,
register,
logout,
requestPasswordChange,
}), [user, tokens, loading, login, register, logout, requestPasswordChange]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => useContext(AuthContext);
+53
View File
@@ -0,0 +1,53 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { getCertificatesCache, getDocumentsCache, getSignedDocumentsCache, getSyncMeta, initLocalDb } from '../storage/localDb';
import { syncAllData } from '../storage/syncService';
import { useAuth } from './AuthContext';
const SyncContext = createContext(null);
export const SyncProvider = ({ children }) => {
const { user, isAuthenticated } = useAuth();
const [syncing, setSyncing] = useState(false);
const [documents, setDocuments] = useState([]);
const [certificates, setCertificates] = useState([]);
const [signedDocuments, setSignedDocuments] = useState([]);
const [lastSyncAt, setLastSyncAt] = useState(null);
const loadCache = useCallback(() => {
initLocalDb();
setDocuments(getDocumentsCache());
setCertificates(getCertificatesCache());
setSignedDocuments(getSignedDocumentsCache());
setLastSyncAt(getSyncMeta('last_sync_at')?.value || null);
}, []);
useEffect(() => {
loadCache();
}, [loadCache, isAuthenticated]);
const syncNow = useCallback(async () => {
if (!isAuthenticated || !user) return null;
setSyncing(true);
try {
const result = await syncAllData({ user });
loadCache();
return result;
} finally {
setSyncing(false);
}
}, [isAuthenticated, user, loadCache]);
const value = useMemo(() => ({
syncing,
documents,
certificates,
signedDocuments,
lastSyncAt,
syncNow,
refreshLocalCache: loadCache,
}), [syncing, documents, certificates, signedDocuments, lastSyncAt, syncNow, loadCache]);
return <SyncContext.Provider value={value}>{children}</SyncContext.Provider>;
};
export const useSync = () => useContext(SyncContext);
@@ -0,0 +1,74 @@
import { ActivityIndicator, Text, View } from 'react-native';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useAuth } from '../context/AuthContext';
import { colors } from '../theme/colors';
import LoginScreen from '../screens/auth/LoginScreen';
import RegisterScreen from '../screens/auth/RegisterScreen';
import VerifyNoticeScreen from '../screens/auth/VerifyNoticeScreen';
import DashboardScreen from '../screens/app/DashboardScreen';
import DocumentsScreen from '../screens/app/DocumentsScreen';
import CertificatesScreen from '../screens/app/CertificatesScreen';
import SignScreen from '../screens/app/SignScreen';
import ProfileScreen from '../screens/app/ProfileScreen';
const AuthStack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();
const navTheme = {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
background: colors.background,
card: colors.surface,
text: colors.text,
primary: colors.primary,
border: colors.border,
},
};
function AppTabs() {
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textMuted,
}}
>
<Tab.Screen name="Inicio" component={DashboardScreen} />
<Tab.Screen name="Documentos" component={DocumentsScreen} />
<Tab.Screen name="Firmar" component={SignScreen} />
<Tab.Screen name="Certificados" component={CertificatesScreen} />
<Tab.Screen name="Perfil" component={ProfileScreen} />
</Tab.Navigator>
);
}
export default function AppNavigator() {
const { loading, isAuthenticated } = useAuth();
if (loading) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.background }}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={{ marginTop: 12, color: colors.textMuted }}>Cargando LinkedSign...</Text>
</View>
);
}
return (
<NavigationContainer theme={navTheme}>
{isAuthenticated ? (
<AppTabs />
) : (
<AuthStack.Navigator screenOptions={{ headerShown: false }}>
<AuthStack.Screen name="Login" component={LoginScreen} />
<AuthStack.Screen name="Register" component={RegisterScreen} />
<AuthStack.Screen name="VerifyNotice" component={VerifyNoticeScreen} />
</AuthStack.Navigator>
)}
</NavigationContainer>
);
}
@@ -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',
},
});
+156
View File
@@ -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,
},
});
+13
View File
@@ -0,0 +1,13 @@
export const colors = {
primary: '#4f46e5',
primaryDark: '#3730a3',
success: '#16a34a',
danger: '#dc2626',
warning: '#d97706',
text: '#111827',
textMuted: '#6b7280',
border: '#e5e7eb',
surface: '#ffffff',
background: '#f3f4f6',
dark: '#111827',
};
+17
View File
@@ -0,0 +1,17 @@
import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing';
export const downloadAndShareFile = async ({ url, token, filename }) => {
const targetUri = `${FileSystem.cacheDirectory}${filename}`;
const result = await FileSystem.downloadAsync(url, targetUri, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(result.uri);
}
return result.uri;
};
+10
View File
@@ -0,0 +1,10 @@
export const getPasswordChecks = (password = '') => ([
{ key: 'length', label: 'Al menos 8 caracteres', valid: password.length >= 8 },
{ key: 'upper', label: 'Una mayúscula', valid: /[A-Z]/.test(password) },
{ key: 'lower', label: 'Una minúscula', valid: /[a-z]/.test(password) },
{ key: 'number', label: 'Un número', valid: /\d/.test(password) },
{ key: 'special', label: 'Un carácter especial', valid: /[^A-Za-z0-9]/.test(password) },
]);
export const isPasswordStrong = (password = '') =>
getPasswordChecks(password).every((rule) => rule.valid);