feat(login): campo de dirección del servidor configurable
- 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 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -7,7 +7,7 @@
|
|||||||
* - Notifica al `onUnauthorized` registrado cuando el backend responde 401
|
* - Notifica al `onUnauthorized` registrado cuando el backend responde 401
|
||||||
* (token caducado/revocado → la sesión debe forzar re-login).
|
* (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 {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -48,7 +48,7 @@ export interface RequestOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(path: string, query?: RequestOptions['query']): string {
|
function buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||||
let url = BASE_URL + path;
|
let url = getBaseUrl() + path;
|
||||||
if (query) {
|
if (query) {
|
||||||
// Construcción manual (el polyfill de URL en RN es incompleto). encodeURIComponent
|
// 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.
|
// codifica el `+` del offset horario del `since` como %2B, según exige el protocolo.
|
||||||
|
|||||||
+32
-7
@@ -8,17 +8,30 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useR
|
|||||||
import { configureClient } from '../api/client';
|
import { configureClient } from '../api/client';
|
||||||
import * as api from '../api/endpoints';
|
import * as api from '../api/endpoints';
|
||||||
import { User } from '../api/types';
|
import { User } from '../api/types';
|
||||||
import { APP_VERSION } from '../config';
|
import {
|
||||||
|
APP_VERSION,
|
||||||
|
getServerOrigin,
|
||||||
|
normalizeServerUrl,
|
||||||
|
setServerOrigin,
|
||||||
|
} from '../config';
|
||||||
import { wipeDatabase } from '../db/database';
|
import { wipeDatabase } from '../db/database';
|
||||||
|
|
||||||
const TOKEN_KEY = 'auth_token';
|
const TOKEN_KEY = 'auth_token';
|
||||||
const USER_KEY = 'auth_user';
|
const USER_KEY = 'auth_user';
|
||||||
|
const SERVER_KEY = 'server_url';
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
signIn: (email: string, password: string, deviceName: string) => Promise<void>;
|
/** Raíz del servidor vigente (configurable en el login). */
|
||||||
|
serverUrl: string;
|
||||||
|
signIn: (
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
deviceName: string,
|
||||||
|
serverUrl: string,
|
||||||
|
) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +41,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [serverUrl, setServerUrl] = useState<string>(getServerOrigin());
|
||||||
|
|
||||||
// Ref para que el getToken del cliente HTTP siempre lea el valor vigente.
|
// Ref para que el getToken del cliente HTTP siempre lea el valor vigente.
|
||||||
const tokenRef = useRef<string | null>(null);
|
const tokenRef = useRef<string | null>(null);
|
||||||
@@ -51,14 +65,19 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, [clear]);
|
}, [clear]);
|
||||||
|
|
||||||
// Rehidrata la sesión al arrancar.
|
// Rehidrata la sesión (y la URL del servidor) al arrancar.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [t, u] = await Promise.all([
|
const [t, u, s] = await Promise.all([
|
||||||
SecureStore.getItemAsync(TOKEN_KEY),
|
SecureStore.getItemAsync(TOKEN_KEY),
|
||||||
SecureStore.getItemAsync(USER_KEY),
|
SecureStore.getItemAsync(USER_KEY),
|
||||||
|
SecureStore.getItemAsync(SERVER_KEY),
|
||||||
]);
|
]);
|
||||||
|
if (s) {
|
||||||
|
setServerOrigin(s);
|
||||||
|
setServerUrl(getServerOrigin());
|
||||||
|
}
|
||||||
if (t) {
|
if (t) {
|
||||||
tokenRef.current = t;
|
tokenRef.current = t;
|
||||||
setToken(t);
|
setToken(t);
|
||||||
@@ -71,7 +90,12 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signIn = useCallback(
|
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({
|
const res = await api.login({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@@ -81,6 +105,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|||||||
tokenRef.current = res.token;
|
tokenRef.current = res.token;
|
||||||
setToken(res.token);
|
setToken(res.token);
|
||||||
setUser(res.user);
|
setUser(res.user);
|
||||||
|
await SecureStore.setItemAsync(SERVER_KEY, normalized);
|
||||||
await SecureStore.setItemAsync(TOKEN_KEY, res.token);
|
await SecureStore.setItemAsync(TOKEN_KEY, res.token);
|
||||||
await SecureStore.setItemAsync(USER_KEY, JSON.stringify(res.user));
|
await SecureStore.setItemAsync(USER_KEY, JSON.stringify(res.user));
|
||||||
},
|
},
|
||||||
@@ -98,8 +123,8 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, [clear]);
|
}, [clear]);
|
||||||
|
|
||||||
const value = useMemo<SessionState>(
|
const value = useMemo<SessionState>(
|
||||||
() => ({ ready, token, user, signIn, signOut }),
|
() => ({ ready, token, user, serverUrl, signIn, signOut }),
|
||||||
[ready, token, user, signIn, signOut],
|
[ready, token, user, serverUrl, signIn, signOut],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
|
|||||||
+37
-10
@@ -1,25 +1,52 @@
|
|||||||
/**
|
/**
|
||||||
* Configuración de la app.
|
* Configuración de la app.
|
||||||
*
|
*
|
||||||
* BASE_URL: raíz de la API v1. En despliegue real apunta a `https://<host>/api/v1`.
|
* La dirección del servidor es CONFIGURABLE en runtime (campo en el login,
|
||||||
* Ojo: desde un dispositivo físico/emulador "localhost" NO es la máquina de desarrollo:
|
* persistida por la sesión). DEFAULT_SERVER es solo el valor inicial.
|
||||||
* - Emulador Android: usa `http://10.0.2.2/...`
|
* Ojo: desde un dispositivo físico/emulador "localhost" NO es la máquina de
|
||||||
* - Dispositivo físico: usa la IP LAN del PC, p.ej. `http://192.168.1.50/...`
|
* desarrollo:
|
||||||
* Ajusta DEV_HOST a tu entorno o sobreescribe BASE_URL.
|
* - 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. */
|
/** Convierte una url de media (posiblemente relativa) en absoluta. */
|
||||||
export function absoluteUrl(url?: string | null): string | undefined {
|
export function absoluteUrl(url?: string | null): string | undefined {
|
||||||
if (!url) return undefined;
|
if (!url) return undefined;
|
||||||
if (/^https?:\/\//i.test(url)) return url;
|
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). */
|
/** Se envía en cada petición como cabecera X-App-Version (ver protocolo §7). */
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { useSession } from '../auth/session';
|
|||||||
const DEVICE_NAME = `${Platform.OS}-avante`;
|
const DEVICE_NAME = `${Platform.OS}-avante`;
|
||||||
|
|
||||||
export function LoginScreen() {
|
export function LoginScreen() {
|
||||||
const { signIn } = useSession();
|
const { signIn, serverUrl } = useSession();
|
||||||
|
const [server, setServer] = useState(serverUrl);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -25,7 +26,7 @@ export function LoginScreen() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await signIn(email.trim(), password, DEVICE_NAME);
|
await signIn(email.trim(), password, DEVICE_NAME, server);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 422) {
|
if (e instanceof ApiError && e.status === 422) {
|
||||||
setError('Credenciales inválidas.');
|
setError('Credenciales inválidas.');
|
||||||
@@ -37,6 +38,8 @@ export function LoginScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canSubmit = !loading && !!email && !!password && !!server.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
@@ -44,6 +47,17 @@ export function LoginScreen() {
|
|||||||
>
|
>
|
||||||
<Text style={styles.title}>Avante · Seguimiento de obra</Text>
|
<Text style={styles.title}>Avante · Seguimiento de obra</Text>
|
||||||
|
|
||||||
|
<Text style={styles.fieldLabel}>Servidor</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="https://servidor o IP (p.ej. 192.168.1.50/construprogress/public)"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
value={server}
|
||||||
|
onChangeText={setServer}
|
||||||
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
@@ -63,9 +77,9 @@ export function LoginScreen() {
|
|||||||
{error && <Text style={styles.error}>{error}</Text>}
|
{error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.button, (loading || !email || !password) && styles.buttonDisabled]}
|
style={[styles.button, !canSubmit && styles.buttonDisabled]}
|
||||||
onPress={onSubmit}
|
onPress={onSubmit}
|
||||||
disabled={loading || !email || !password}
|
disabled={!canSubmit}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator color="#fff" />
|
||||||
@@ -80,6 +94,7 @@ export function LoginScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 },
|
container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 },
|
||||||
title: { fontSize: 22, fontWeight: '700', textAlign: 'center', marginBottom: 16 },
|
title: { fontSize: 22, fontWeight: '700', textAlign: 'center', marginBottom: 16 },
|
||||||
|
fieldLabel: { fontSize: 12, color: '#666', marginBottom: -6, fontWeight: '600' },
|
||||||
input: {
|
input: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#ccc',
|
borderColor: '#ccc',
|
||||||
|
|||||||
Reference in New Issue
Block a user