From 9c4c3b54ae94820970bbeab61775cee73ada8cd9 Mon Sep 17 00:00:00 2001 From: javier Date: Wed, 8 Jul 2026 10:00:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(login):=20campo=20de=20direcci=C3=B3n=20de?= =?UTF-8?q?l=20servidor=20configurable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.ts: BASE_URL/ORIGIN dejan de ser constantes; ahora hay setServerOrigin/getBaseUrl/getServerOrigin con normalizeServerUrl (añade http:// si falta, quita barras finales y el sufijo /api/v1). - client.ts: construye las URLs con getBaseUrl() en cada petición. - session: signIn recibe la URL, la fija antes del login y la persiste en SecureStore (clave server_url); se rehidrata al arrancar junto al token. - LoginScreen: campo "Servidor" precargado con el último valor usado. Co-Authored-By: Claude Sonnet 4.6 --- src/api/client.ts | 4 ++-- src/auth/session.tsx | 39 ++++++++++++++++++++++++------ src/config.ts | 47 +++++++++++++++++++++++++++++-------- src/screens/LoginScreen.tsx | 23 ++++++++++++++---- 4 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 240e2a8..870330d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -7,7 +7,7 @@ * - Notifica al `onUnauthorized` registrado cuando el backend responde 401 * (token caducado/revocado → la sesión debe forzar re-login). */ -import { APP_VERSION, BASE_URL } from '../config'; +import { APP_VERSION, getBaseUrl } from '../config'; export class ApiError extends Error { constructor( @@ -48,7 +48,7 @@ export interface RequestOptions { } function buildUrl(path: string, query?: RequestOptions['query']): string { - let url = BASE_URL + path; + let url = getBaseUrl() + path; if (query) { // Construcción manual (el polyfill de URL en RN es incompleto). encodeURIComponent // codifica el `+` del offset horario del `since` como %2B, según exige el protocolo. diff --git a/src/auth/session.tsx b/src/auth/session.tsx index 0f76cd1..657819c 100644 --- a/src/auth/session.tsx +++ b/src/auth/session.tsx @@ -8,17 +8,30 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useR import { configureClient } from '../api/client'; import * as api from '../api/endpoints'; import { User } from '../api/types'; -import { APP_VERSION } from '../config'; +import { + APP_VERSION, + getServerOrigin, + normalizeServerUrl, + setServerOrigin, +} from '../config'; import { wipeDatabase } from '../db/database'; const TOKEN_KEY = 'auth_token'; const USER_KEY = 'auth_user'; +const SERVER_KEY = 'server_url'; interface SessionState { ready: boolean; token: string | null; user: User | null; - signIn: (email: string, password: string, deviceName: string) => Promise; + /** Raíz del servidor vigente (configurable en el login). */ + serverUrl: string; + signIn: ( + email: string, + password: string, + deviceName: string, + serverUrl: string, + ) => Promise; signOut: () => Promise; } @@ -28,6 +41,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { const [ready, setReady] = useState(false); const [token, setToken] = useState(null); const [user, setUser] = useState(null); + const [serverUrl, setServerUrl] = useState(getServerOrigin()); // Ref para que el getToken del cliente HTTP siempre lea el valor vigente. const tokenRef = useRef(null); @@ -51,14 +65,19 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { }); }, [clear]); - // Rehidrata la sesión al arrancar. + // Rehidrata la sesión (y la URL del servidor) al arrancar. useEffect(() => { (async () => { try { - const [t, u] = await Promise.all([ + const [t, u, s] = await Promise.all([ SecureStore.getItemAsync(TOKEN_KEY), SecureStore.getItemAsync(USER_KEY), + SecureStore.getItemAsync(SERVER_KEY), ]); + if (s) { + setServerOrigin(s); + setServerUrl(getServerOrigin()); + } if (t) { tokenRef.current = t; setToken(t); @@ -71,7 +90,12 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { }, []); const signIn = useCallback( - async (email: string, password: string, deviceName: string) => { + async (email: string, password: string, deviceName: string, server: string) => { + // Fija la URL del servidor ANTES del login para que la petición vaya ahí. + const normalized = normalizeServerUrl(server); + setServerOrigin(normalized); + setServerUrl(normalized); + const res = await api.login({ email, password, @@ -81,6 +105,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { tokenRef.current = res.token; setToken(res.token); setUser(res.user); + await SecureStore.setItemAsync(SERVER_KEY, normalized); await SecureStore.setItemAsync(TOKEN_KEY, res.token); await SecureStore.setItemAsync(USER_KEY, JSON.stringify(res.user)); }, @@ -98,8 +123,8 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { }, [clear]); const value = useMemo( - () => ({ ready, token, user, signIn, signOut }), - [ready, token, user, signIn, signOut], + () => ({ ready, token, user, serverUrl, signIn, signOut }), + [ready, token, user, serverUrl, signIn, signOut], ); return {children}; diff --git a/src/config.ts b/src/config.ts index 769efd4..abec77e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,25 +1,52 @@ /** * Configuración de la app. * - * BASE_URL: raíz de la API v1. En despliegue real apunta a `https:///api/v1`. - * Ojo: desde un dispositivo físico/emulador "localhost" NO es la máquina de desarrollo: - * - Emulador Android: usa `http://10.0.2.2/...` - * - Dispositivo físico: usa la IP LAN del PC, p.ej. `http://192.168.1.50/...` - * Ajusta DEV_HOST a tu entorno o sobreescribe BASE_URL. + * La dirección del servidor es CONFIGURABLE en runtime (campo en el login, + * persistida por la sesión). DEFAULT_SERVER es solo el valor inicial. + * Ojo: desde un dispositivo físico/emulador "localhost" NO es la máquina de + * desarrollo: + * - Emulador Android: `http://10.0.2.2/...` + * - Dispositivo físico: la IP LAN del PC, p.ej. `http://192.168.1.50/...` */ -const DEV_HOST = 'http://10.0.2.2/construprogress/public'; +export const DEFAULT_SERVER = 'http://10.0.2.2/construprogress/public'; -export const BASE_URL = `${DEV_HOST}/api/v1`; +/** Raíz del host vigente (media relativa, p.ej. `/storage/...`, se resuelve contra ella). */ +let serverOrigin = DEFAULT_SERVER; -/** Raíz del host (para resolver URLs relativas de media, p.ej. `/storage/...`). */ -export const ORIGIN = DEV_HOST; +/** + * Normaliza lo que teclea el usuario a una raíz de servidor: + * añade `http://` si falta el esquema, quita barras finales y el sufijo + * `/api/v1` si lo incluyó. + */ +export function normalizeServerUrl(input: string): string { + let url = input.trim(); + if (!url) return DEFAULT_SERVER; + if (!/^https?:\/\//i.test(url)) url = `http://${url}`; + url = url.replace(/\/+$/, ''); + url = url.replace(/\/api\/v1$/i, ''); + return url; +} + +/** Fija la raíz del servidor para todas las peticiones siguientes. */ +export function setServerOrigin(url: string): void { + serverOrigin = normalizeServerUrl(url); +} + +export function getServerOrigin(): string { + return serverOrigin; +} + +/** Raíz de la API v1 vigente. */ +export function getBaseUrl(): string { + return `${serverOrigin}/api/v1`; +} /** Convierte una url de media (posiblemente relativa) en absoluta. */ export function absoluteUrl(url?: string | null): string | undefined { if (!url) return undefined; if (/^https?:\/\//i.test(url)) return url; - return `${ORIGIN}${url.startsWith('/') ? '' : '/'}${url}`; + return `${serverOrigin}${url.startsWith('/') ? '' : '/'}${url}`; } /** Se envía en cada petición como cabecera X-App-Version (ver protocolo §7). */ diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index efccb2b..a63ca63 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -15,7 +15,8 @@ import { useSession } from '../auth/session'; const DEVICE_NAME = `${Platform.OS}-avante`; export function LoginScreen() { - const { signIn } = useSession(); + const { signIn, serverUrl } = useSession(); + const [server, setServer] = useState(serverUrl); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); @@ -25,7 +26,7 @@ export function LoginScreen() { setError(null); setLoading(true); try { - await signIn(email.trim(), password, DEVICE_NAME); + await signIn(email.trim(), password, DEVICE_NAME, server); } catch (e) { if (e instanceof ApiError && e.status === 422) { setError('Credenciales inválidas.'); @@ -37,6 +38,8 @@ export function LoginScreen() { } }; + const canSubmit = !loading && !!email && !!password && !!server.trim(); + return ( Avante · Seguimiento de obra + Servidor + + {error}} {loading ? ( @@ -80,6 +94,7 @@ export function LoginScreen() { const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 }, title: { fontSize: 22, fontWeight: '700', textAlign: 'center', marginBottom: 16 }, + fieldLabel: { fontSize: 12, color: '#666', marginBottom: -6, fontWeight: '600' }, input: { borderWidth: 1, borderColor: '#ccc',