Se configura SDK de firebase

Juan José Pelaez 2 weeks ago
parent cf616c1ed8
commit 5eea1e2d70

3
.gitignore vendored

@ -11,6 +11,9 @@ node_modules
dist
dist-ssr
*.local
.env
.env.*
!.env.example
# Editor directories and files
.vscode/*

@ -6,7 +6,7 @@ Aplicacion web interna para el **Sistema de Equipos como Servicio (ECS)** de Sap
El proyecto esta enfocado de momento solo en frontend local. La interfaz actual incluye:
- Login local con credenciales hardcodeadas.
- Login con Firebase Authentication (correo/contrasena y Microsoft).
- Router con rutas base de autenticacion y dashboard.
- Dashboard ECS con menu hamburguesa tipo drawer.
- Busqueda rapida por placa Sapian o serial.
@ -19,17 +19,31 @@ El proyecto esta enfocado de momento solo en frontend local. La interfaz actual
- TypeScript
- Vite
- React Router DOM
- Firebase (Authentication, Firestore)
- ESLint
## Estructura principal
```text
src/
features/
auth/
AuthProvider.tsx
useAuth.ts
lib/
firebase/
app.ts
auth.ts
collections.ts
config.ts
firestore.ts
pages/
DashboardPage.tsx
LoginPage.tsx
routes/
AppRouter.tsx
GuestRoute.tsx
ProtectedRoute.tsx
paths.ts
ui/
AuthShell.tsx
@ -50,15 +64,100 @@ src/
- `/dashboard`: panel principal ECS.
- `*`: redirige a `/login`.
## Credenciales temporales
## Firebase
Mientras no exista backend ni proveedor real de autenticacion, el login usa credenciales hardcodeadas:
### 1. Crear proyecto en Firebase
```text
Usuario: admin@nexus.local
Contrasena: admin123
1. Entra a [Firebase Console](https://console.firebase.google.com/) y crea un proyecto (o usa uno existente).
2. Agrega una app web y copia la configuracion del SDK.
3. En **Authentication** habilita:
- **Correo/contrasena** (Email/Password).
- **Microsoft** (opcional, para el boton de la pantalla de login).
### 2. Variables de entorno locales
Copia el ejemplo y completa los valores de tu app web:
```bash
cp .env.example .env
```
```env
VITE_FIREBASE_API_KEY=tu_api_key
VITE_FIREBASE_AUTH_DOMAIN=tu_proyecto.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=tu_proyecto
VITE_FIREBASE_STORAGE_BUCKET=tu_proyecto.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=123456789
VITE_FIREBASE_APP_ID=1:123456789:web:abcdef
```
Reinicia `npm run dev` despues de crear o modificar `.env`.
### 3. Usuarios de prueba
En Firebase Console → **Authentication****Users**, crea un usuario con correo y contrasena para probar el login interno.
Para Microsoft, configura el proveedor en Firebase y registra la URI de redireccion que indique la consola.
### 4. Firestore
Firestore usa la misma configuracion de `.env` que Authentication; no requiere variables adicionales.
#### Crear la base de datos
1. En Firebase Console → **Build****Firestore Database****Create database**.
2. Elige la region mas cercana a tus usuarios (ej. `southamerica-east1`).
3. Para produccion, inicia en **modo de produccion** y aplica reglas restrictivas desde el inicio.
#### Reglas de seguridad
El repositorio incluye `firestore.rules` con acceso solo para usuarios autenticados en las colecciones base del dominio ECS:
- `equipos`
- `bitacora` (solo lectura y creacion; sin borrado ni edicion)
- `contratos`
Publica las reglas de una de estas formas:
**Opcion A — Consola web**
1. Firestore → **Rules**.
2. Copia el contenido de `firestore.rules` y publica.
**Opcion B — Firebase CLI**
```bash
npm install -g firebase-tools
firebase login
firebase use tu_proyecto
firebase deploy --only firestore:rules,firestore:indexes
```
#### Uso en el codigo
```typescript
import { collection } from 'firebase/firestore'
import { firestoreCollections } from './lib/firebase/collections'
import { getFirestoreDb } from './lib/firebase/firestore'
const equiposRef = collection(getFirestoreDb(), firestoreCollections.equipos)
```
#### Emulador local (opcional)
```bash
firebase init emulators # selecciona Firestore
firebase emulators:start --only firestore
```
En `.env`:
```env
VITE_FIREBASE_FIRESTORE_EMULATOR_HOST=127.0.0.1:8080
```
Reinicia `npm run dev` para que el cliente apunte al emulador.
## Desarrollo local
Instalar dependencias:

@ -0,0 +1,6 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
}
}

@ -0,0 +1,4 @@
{
"indexes": [],
"fieldOverrides": []
}

@ -0,0 +1,23 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isSignedIn() {
return request.auth != null;
}
match /equipos/{equipoId} {
allow read, write: if isSignedIn();
}
match /bitacora/{eventoId} {
allow read: if isSignedIn();
allow create: if isSignedIn();
allow update, delete: if false;
}
match /contratos/{contratoId} {
allow read, write: if isSignedIn();
}
}
}

1062
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"firebase": "^12.14.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.16.0"

@ -439,6 +439,18 @@
color: var(--text-primary);
}
.dashboard-sign-out {
margin-top: 8px;
}
.route-loading {
display: grid;
place-items: center;
min-height: 100vh;
margin: 0;
color: var(--text-secondary);
}
.dashboard-main {
display: grid;
height: 100vh;

@ -1,8 +1,13 @@
import './App.css'
import { AuthProvider } from './features/auth/AuthProvider'
import { AppRouter } from './routes/AppRouter'
function App() {
return <AppRouter />
return (
<AuthProvider>
<AppRouter />
</AuthProvider>
)
}
export default App

@ -0,0 +1,13 @@
import { createContext } from 'react'
import type { User } from 'firebase/auth'
export type AuthContextValue = {
isConfigured: boolean
loading: boolean
signInWithEmail: (email: string, password: string) => Promise<void>
signInWithMicrosoft: () => Promise<void>
signOut: () => Promise<void>
user: User | null
}
export const AuthContext = createContext<AuthContextValue | null>(null)

@ -0,0 +1,55 @@
import { onAuthStateChanged } from 'firebase/auth'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import type { User } from 'firebase/auth'
import { isFirebaseConfigured } from '../../lib/firebase/config'
import {
getFirebaseAuth,
signInWithEmail as firebaseSignInWithEmail,
signInWithMicrosoft as firebaseSignInWithMicrosoft,
signOutUser,
} from '../../lib/firebase/auth'
import { AuthContext, type AuthContextValue } from './AuthContext'
type AuthProviderProps = {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const isConfigured = isFirebaseConfigured()
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(() => isFirebaseConfigured())
useEffect(() => {
if (!isConfigured) {
return
}
const unsubscribe = onAuthStateChanged(getFirebaseAuth(), (nextUser) => {
setUser(nextUser)
setLoading(false)
})
return unsubscribe
}, [isConfigured])
const value = useMemo<AuthContextValue>(
() => ({
isConfigured,
loading,
async signInWithEmail(email, password) {
await firebaseSignInWithEmail(email, password)
},
async signInWithMicrosoft() {
await firebaseSignInWithMicrosoft()
},
async signOut() {
await signOutUser()
},
user,
}),
[isConfigured, loading, user],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

@ -0,0 +1,27 @@
import { FirebaseError } from 'firebase/app'
const AUTH_ERROR_MESSAGES: Record<string, string> = {
'auth/configuration-not-found':
'El proveedor de inicio de sesion no esta habilitado en Firebase.',
'auth/invalid-credential': 'Usuario o contrasena incorrectos.',
'auth/invalid-email': 'El correo no tiene un formato valido.',
'auth/network-request-failed': 'No hay conexion. Revisa tu red e intenta de nuevo.',
'auth/popup-blocked': 'El navegador bloqueo la ventana emergente. Permite popups e intenta de nuevo.',
'auth/popup-closed-by-user': 'Se cancelo el inicio de sesion con Microsoft.',
'auth/too-many-requests': 'Demasiados intentos fallidos. Espera un momento e intenta de nuevo.',
'auth/user-disabled': 'Esta cuenta esta deshabilitada.',
'auth/user-not-found': 'Usuario o contrasena incorrectos.',
'auth/wrong-password': 'Usuario o contrasena incorrectos.',
}
export function getFirebaseAuthErrorMessage(error: unknown): string {
if (error instanceof FirebaseError) {
return AUTH_ERROR_MESSAGES[error.code] ?? 'No se pudo completar el inicio de sesion.'
}
if (error instanceof Error) {
return error.message
}
return 'Ocurrio un error inesperado.'
}

@ -0,0 +1,13 @@
import { useContext } from 'react'
import { AuthContext } from './AuthContext'
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth debe usarse dentro de AuthProvider.')
}
return context
}

@ -0,0 +1,13 @@
import type { FirebaseApp } from 'firebase/app'
export declare const firebaseConfig: {
readonly apiKey: string
readonly appId: string
readonly authDomain: string
readonly messagingSenderId: string
readonly projectId: string
readonly storageBucket: string
}
export declare function isFirebaseConfigured(): boolean
export declare function getFirebaseApp(): FirebaseApp

@ -0,0 +1,34 @@
import { getApp, getApps, initializeApp } from 'firebase/app'
/**
* En Vite solo se exponen variables con prefijo VITE_ desde .env
* @see https://vite.dev/guide/env-and-mode
*/
export const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
}
export function isFirebaseConfigured() {
return Object.values(firebaseConfig).every((value) => Boolean(value))
}
let firebaseApp
export function getFirebaseApp() {
if (!isFirebaseConfigured()) {
throw new Error(
'Firebase no esta configurado. Copia .env.example a .env y completa las variables VITE_FIREBASE_*.',
)
}
if (!firebaseApp) {
firebaseApp = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig)
}
return firebaseApp
}

@ -0,0 +1 @@
export { getFirebaseApp } from '../../firebase/firebase.js'

@ -0,0 +1,43 @@
import {
OAuthProvider,
getAuth,
signInWithEmailAndPassword,
signInWithPopup,
signOut,
type Auth,
type User,
} from 'firebase/auth'
import { getFirebaseApp } from './app'
import { isFirebaseConfigured } from './config'
let authInstance: Auth | undefined
export function getFirebaseAuth(): Auth {
if (!isFirebaseConfigured()) {
throw new Error('Firebase Auth no esta disponible sin configuracion.')
}
if (!authInstance) {
authInstance = getAuth(getFirebaseApp())
}
return authInstance
}
export async function signInWithEmail(email: string, password: string): Promise<User> {
const credential = await signInWithEmailAndPassword(getFirebaseAuth(), email, password)
return credential.user
}
export async function signInWithMicrosoft(): Promise<User> {
const provider = new OAuthProvider('microsoft.com')
provider.setCustomParameters({ prompt: 'select_account' })
const credential = await signInWithPopup(getFirebaseAuth(), provider)
return credential.user
}
export async function signOutUser(): Promise<void> {
await signOut(getFirebaseAuth())
}

@ -0,0 +1,9 @@
/** Nombres de colecciones raiz en Firestore para Nexus ECS. */
export const firestoreCollections = {
equipos: 'equipos',
bitacora: 'bitacora',
contratos: 'contratos',
} as const
export type FirestoreCollectionName =
(typeof firestoreCollections)[keyof typeof firestoreCollections]

@ -0,0 +1 @@
export { firebaseConfig, isFirebaseConfigured } from '../../firebase/firebase.js'

@ -0,0 +1,42 @@
import { connectFirestoreEmulator, getFirestore, type Firestore } from 'firebase/firestore'
import { getFirebaseApp } from './app'
import { isFirebaseConfigured } from './config'
let firestoreInstance: Firestore | undefined
let isEmulatorConnected = false
export function getFirestoreDb(): Firestore {
if (!isFirebaseConfigured()) {
throw new Error(
'Firestore no esta disponible. Define las variables VITE_FIREBASE_* en un archivo .env.',
)
}
if (!firestoreInstance) {
firestoreInstance = getFirestore(getFirebaseApp())
connectFirestoreEmulatorIfConfigured(firestoreInstance)
}
return firestoreInstance
}
function connectFirestoreEmulatorIfConfigured(firestore: Firestore) {
const emulatorHost = import.meta.env.VITE_FIREBASE_FIRESTORE_EMULATOR_HOST
if (!import.meta.env.DEV || !emulatorHost || isEmulatorConnected) {
return
}
const [hostname, portValue] = emulatorHost.split(':')
const port = Number(portValue)
if (!hostname || !Number.isFinite(port)) {
throw new Error(
'VITE_FIREBASE_FIRESTORE_EMULATOR_HOST debe tener el formato host:puerto (ej. 127.0.0.1:8080).',
)
}
connectFirestoreEmulator(firestore, hostname, port)
isEmulatorConnected = true
}

@ -1,5 +1,8 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../features/auth/useAuth'
import { routes } from '../routes/paths'
import { Button } from '../ui/Button'
import { MetricCard } from '../ui/MetricCard'
import { SectionCard } from '../ui/SectionCard'
@ -143,9 +146,16 @@ const dashboardTabs: Array<{ id: DashboardTab; label: string }> = [
]
export function DashboardPage() {
const navigate = useNavigate()
const { signOut, user } = useAuth()
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [activeTab, setActiveTab] = useState<DashboardTab>('summary')
async function handleSignOut() {
await signOut()
navigate(routes.login)
}
return (
<main className={`dashboard-page ${isMenuOpen ? 'dashboard-page--menu-open' : ''}`}>
<button
@ -190,8 +200,11 @@ export function DashboardPage() {
</nav>
<div className="dashboard-sidebar__footer">
<span>Entorno</span>
<strong>Local</strong>
<span>Sesion</span>
<strong>{user?.email ?? 'Usuario activo'}</strong>
<Button className="dashboard-sign-out" variant="secondary" onClick={() => void handleSignOut()}>
Cerrar sesion
</Button>
</div>
</aside>

@ -1,17 +1,14 @@
import { useMemo, useState } from 'react'
import { useState } from 'react'
import type { FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { getFirebaseAuthErrorMessage } from '../features/auth/firebaseAuthErrors'
import { useAuth } from '../features/auth/useAuth'
import { AuthShell } from '../ui/AuthShell'
import { Button } from '../ui/Button'
import { TextField } from '../ui/TextField'
import { routes } from '../routes/paths'
const AUTH_CREDENTIALS = {
password: 'admin123',
username: 'admin@nexus.local',
} as const
type LoginFormState = {
password: string
username: string
@ -40,23 +37,26 @@ function validateLoginForm(form: LoginFormState): LoginFormErrors {
export function LoginPage() {
const navigate = useNavigate()
const { isConfigured, signInWithEmail, signInWithMicrosoft } = useAuth()
const [form, setForm] = useState<LoginFormState>(initialFormState)
const [errors, setErrors] = useState<LoginFormErrors>({})
const [isAuthenticated, setIsAuthenticated] = useState(false)
const demoCredentials = useMemo(
() => `${AUTH_CREDENTIALS.username} / ${AUTH_CREDENTIALS.password}`,
[],
)
const [isSubmitting, setIsSubmitting] = useState(false)
function updateField(field: keyof LoginFormState, value: string) {
setForm((currentForm) => ({ ...currentForm, [field]: value }))
setErrors((currentErrors) => ({ ...currentErrors, [field]: undefined, form: undefined }))
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
async function handleEmailSignIn(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!isConfigured) {
setErrors({
form: 'Firebase no esta configurado. Copia .env.example a .env y completa las variables.',
})
return
}
const validationErrors = validateLoginForm(form)
if (Object.keys(validationErrors).length > 0) {
@ -64,19 +64,38 @@ export function LoginPage() {
return
}
const canAccess =
form.username.trim() === AUTH_CREDENTIALS.username &&
form.password === AUTH_CREDENTIALS.password
setIsSubmitting(true)
try {
await signInWithEmail(form.username.trim(), form.password)
setErrors({})
navigate(routes.dashboard)
} catch (error) {
setErrors({ form: getFirebaseAuthErrorMessage(error) })
} finally {
setIsSubmitting(false)
}
}
if (!canAccess) {
setIsAuthenticated(false)
setErrors({ form: 'Usuario o contrasena incorrectos.' })
async function handleMicrosoftSignIn() {
if (!isConfigured) {
setErrors({
form: 'Firebase no esta configurado. Copia .env.example a .env y completa las variables.',
})
return
}
setIsSubmitting(true)
setErrors({})
setIsAuthenticated(true)
navigate(routes.dashboard)
try {
await signInWithMicrosoft()
navigate(routes.dashboard)
} catch (error) {
setErrors({ form: getFirebaseAuthErrorMessage(error) })
} finally {
setIsSubmitting(false)
}
}
return (
@ -85,10 +104,15 @@ export function LoginPage() {
<div className="login-card__header">
<p className="eyebrow">Acceso privado</p>
<h2>Inicia sesion</h2>
<p>Usa tus credenciales internas para entrar al panel local.</p>
<p>Autenticate con Firebase para entrar al panel ECS.</p>
</div>
<Button className="microsoft-login-button" variant="secondary">
<Button
className="microsoft-login-button"
disabled={isSubmitting}
variant="secondary"
onClick={() => void handleMicrosoftSignIn()}
>
<span className="microsoft-mark" aria-hidden="true">
<span />
<span />
@ -99,28 +123,30 @@ export function LoginPage() {
</Button>
<div className="login-divider">
<span>O usa credenciales internas</span>
<span>O usa correo y contrasena</span>
</div>
<form className="login-form" onSubmit={handleSubmit} noValidate>
<form className="login-form" onSubmit={(event) => void handleEmailSignIn(event)} noValidate>
<TextField
autoComplete="username"
disabled={isSubmitting}
error={errors.username}
label="Usuario"
label="Correo"
name="username"
onChange={(event) => updateField('username', event.target.value)}
placeholder="admin@nexus.local"
placeholder="usuario@sapian.com"
type="email"
value={form.username}
/>
<TextField
autoComplete="current-password"
disabled={isSubmitting}
error={errors.password}
label="Contrasena"
name="password"
onChange={(event) => updateField('password', event.target.value)}
placeholder="admin123"
placeholder="********"
type="password"
value={form.password}
/>
@ -131,17 +157,21 @@ export function LoginPage() {
</div>
) : null}
{isAuthenticated ? (
<div className="form-success" role="status">
Acceso concedido. Listo para conectar el flujo real cuando lo indiquemos.
</div>
) : null}
<Button type="submit">Entrar al panel</Button>
<Button disabled={isSubmitting} type="submit">
{isSubmitting ? 'Validando acceso...' : 'Entrar al panel'}
</Button>
</form>
<p className="login-card__hint">
Credenciales temporales: <strong>{demoCredentials}</strong>
{isConfigured ? (
<>
Usuario gestionado en <strong>Firebase Authentication</strong>.
</>
) : (
<>
Crea un archivo <strong>.env</strong> a partir de <strong>.env.example</strong>.
</>
)}
</p>
</div>
</AuthShell>

@ -2,6 +2,8 @@ import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom'
import { DashboardPage } from '../pages/DashboardPage'
import { LoginPage } from '../pages/LoginPage'
import { GuestRoute } from './GuestRoute'
import { ProtectedRoute } from './ProtectedRoute'
import { routes } from './paths'
const router = createBrowserRouter([
@ -10,11 +12,19 @@ const router = createBrowserRouter([
path: '/',
},
{
element: <LoginPage />,
element: (
<GuestRoute>
<LoginPage />
</GuestRoute>
),
path: routes.login,
},
{
element: <DashboardPage />,
element: (
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
),
path: routes.dashboard,
},
{

@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../features/auth/useAuth'
import { routes } from './paths'
type GuestRouteProps = {
children: ReactNode
}
export function GuestRoute({ children }: GuestRouteProps) {
const { loading, user } = useAuth()
if (loading) {
return (
<main className="route-loading" aria-live="polite">
<p>Cargando sesion...</p>
</main>
)
}
if (user) {
return <Navigate to={routes.dashboard} replace />
}
return children
}

@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../features/auth/useAuth'
import { routes } from './paths'
type ProtectedRouteProps = {
children: ReactNode
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { loading, user } = useAuth()
if (loading) {
return (
<main className="route-loading" aria-live="polite">
<p>Cargando sesion...</p>
</main>
)
}
if (!user) {
return <Navigate to={routes.login} replace />
}
return children
}

16
src/vite-env.d.ts vendored

@ -0,0 +1,16 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_FIREBASE_API_KEY: string
readonly VITE_FIREBASE_AUTH_DOMAIN: string
readonly VITE_FIREBASE_PROJECT_ID: string
readonly VITE_FIREBASE_STORAGE_BUCKET: string
readonly VITE_FIREBASE_MESSAGING_SENDER_ID: string
readonly VITE_FIREBASE_APP_ID: string
/** Solo desarrollo local. Formato: host:puerto (ej. 127.0.0.1:8080). */
readonly VITE_FIREBASE_FIRESTORE_EMULATOR_HOST?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
Loading…
Cancel
Save