Initial LinkedSign platform
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.expo
|
||||
dist
|
||||
android
|
||||
ios
|
||||
*.log
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user