diff --git a/.env.example b/.env.example index 6ae991c0..b303d5e4 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,7 @@ CORE_DB_PASSWORD=postgres # ----- Frontend ----- NODE_ENV=development -PUBLIC_API_URL=http://localhost:8000/api -PUBLIC_KEYCLOAK_REALM=master -PUBLIC_KEYCLOAK_URL=http://localhost:8080 +VITE_API_URL=http://localhost:8000/api +VITE_KEYCLOAK_REALM=master +VITE_KEYCLOAK_URL=http://localhost:8080 +VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend diff --git a/.gitignore b/.gitignore index b11dae51..471d46fd 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/backend/api/v1/modules/a76/auth/dto.py b/backend/api/v1/modules/a76/auth/dto.py index 9107c03a..23f97533 100644 --- a/backend/api/v1/modules/a76/auth/dto.py +++ b/backend/api/v1/modules/a76/auth/dto.py @@ -125,3 +125,17 @@ class ExchangeCodeRequestDTO(BaseModel): "tenant_slug": "empresa-abc" } } + + +class SetCookieRequestDTO(BaseModel): + """DTO para establecer cookies de autenticación""" + access_token: str = Field(..., description="Access token JWT") + refresh_token: str = Field(..., description="Refresh token JWT") + + class Config: + json_schema_extra = { + "example": { + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." + } + } diff --git a/backend/api/v1/modules/a76/auth/routes.py b/backend/api/v1/modules/a76/auth/routes.py index ab604f6e..d0bf28ff 100644 --- a/backend/api/v1/modules/a76/auth/routes.py +++ b/backend/api/v1/modules/a76/auth/routes.py @@ -1,7 +1,7 @@ """ Endpoints API para autenticación """ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Response from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.orm import Session @@ -15,7 +15,8 @@ from .dto import ( LogoutRequestDTO, RegisterRequestDTO, RegisterResponseDTO, - ExchangeCodeRequestDTO + ExchangeCodeRequestDTO, + SetCookieRequestDTO ) from .service import AuthService @@ -120,13 +121,62 @@ async def exchange_code( return service.exchange_code(exchange_data) -@router.get("/health") -async def auth_health(): +@router.post("/set-cookie") +async def set_cookie( + cookie_data: SetCookieRequestDTO, + response: Response, + db: Session = Depends(get_core_db) +): """ - Health check del módulo de autenticación + Establece cookies HttpOnly con los tokens de autenticación + + Este endpoint se llama desde el frontend después de una autenticación + SSO exitosa para establecer las cookies de sesión necesarias para + la validación server-side en los layouts protegidos. + + Las cookies se configuran como: + - HttpOnly: No accesibles desde JavaScript (mayor seguridad) + - Secure: Solo se envían por HTTPS (en producción) + - SameSite=Lax: Protección contra CSRF + - Max-Age: Tiempo de vida del token """ - return { - "status": "ok", - "module": "authentication", - "provider": "keycloak" - } + # Validar que los tokens sean válidos decodificándolos + service = AuthService(db) + try: + # Validar el access token + user_info = service.get_user_info(cookie_data.access_token) + + # Establecer las cookies + # Access token cookie + response.set_cookie( + key="access_token", + value=cookie_data.access_token, + httponly=True, # No accesible desde JavaScript + secure=False, # TODO: Cambiar a True en producción con HTTPS + samesite="lax", # Protección CSRF + max_age=3600, # 1 hora (ajustar según configuración del token) + path="/" + ) + + # Refresh token cookie + response.set_cookie( + key="refresh_token", + value=cookie_data.refresh_token, + httponly=True, + secure=False, # TODO: Cambiar a True en producción con HTTPS + samesite="lax", + max_age=86400, # 24 horas (ajustar según configuración del token) + path="/" + ) + + return { + "success": True, + "message": "Cookies establecidas correctamente", + "user": user_info + } + + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Error validando tokens: {str(e)}" + ) \ No newline at end of file diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 459c1fc7..6d8839ff 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -59,15 +59,21 @@ class TenantMiddleware(BaseHTTPMiddleware): user_info = verify_token(token) tenant_id = get_tenant_from_token(user_info) + # ⚠️ NOTA: tenant_id puede ser None para usuarios SSO que aún no tienen tenant asignado + # En ese caso, el endpoint específico deberá manejarlo if not tenant_id: - raise HTTPException(status_code=400, detail="Tenant ID not found in token") + logger.warning(f"⚠️ Token sin tenant_id para usuario: {user_info.get('sub', 'unknown')}") + # No lanzamos error aquí, dejamos que el endpoint decida qué hacer - # Agregar tenant_id al state del request + # Agregar tenant_id al state del request (puede ser None) request.state.tenant_id = tenant_id request.state.user_info = user_info + except HTTPException: + # Re-lanzar HTTPException directamente + raise except Exception as e: - logger.error(f"Tenant validation error: {str(e)}") + logger.error(f"❌ Tenant validation error: {str(e)}") raise HTTPException(status_code=401, detail="Invalid authentication") response = await call_next(request) @@ -85,15 +91,15 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): # Rutas que no requieren validación de licencia exempt_paths = [ - "/docs", - "/redoc", + "/api/docs", + "/api/redoc", "/openapi.json", "/api/v1/auth", - "/v1/auth", + "/api/v1/auth", "/api/v1/status", - "/v1/status", - "/health", - "/" + "/api/v1/status", + "/api/health", + "/api/" ] # Verificar si la ruta está exenta (comparación exacta o prefijo) diff --git a/docker-compose.yml b/docker-compose.yml index 47218aca..3d54d692 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -209,11 +209,15 @@ services: container_name: anexo76-frontend environment: - NODE_ENV=${NODE_ENV:-development} - - PUBLIC_API_URL=${PUBLIC_API_URL:-http://localhost:8000} - - PUBLIC_KEYCLOAK_URL=${PUBLIC_KEYCLOAK_URL:-http://localhost:8080} - - PUBLIC_KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} - - PUBLIC_KEYCLOAK_CLIENT_ID=${KEYCLOAK_FRONTEND_CLIENT_ID:-anexo76-frontend} - - VITE_HMR_HOST=localhost + - VITE_API_URL=${VITE_API_URL:-http://localhost:8000/api/} + - INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/api/} + - VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-http://localhost:8080} + - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master} + - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend} + - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9} ports: - "5180:5180" depends_on: @@ -226,6 +230,7 @@ services: - ./scripts/frontend-entrypoint.sh:/frontend-entrypoint.sh:ro networks: - frontend-net + - auth-net restart: unless-stopped command: ["pnpm", "run", "dev", "--", "--host", "0.0.0.0"] healthcheck: diff --git a/docs/VERIFICAR_MICROSOFT_CONFIG.md b/docs/VERIFICAR_MICROSOFT_CONFIG.md index f4518102..14a43959 100644 --- a/docs/VERIFICAR_MICROSOFT_CONFIG.md +++ b/docs/VERIFICAR_MICROSOFT_CONFIG.md @@ -109,7 +109,6 @@ Target: BROKER_USERNAME --- - ## Paso 4: Verificar en tu App 1. Asegúrate de que el frontend esté corriendo: `http://localhost:5173` @@ -197,9 +196,9 @@ curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/realms/master/brok 3. ✅ Redirect URI en Azure: `http://localhost:8080/realms/master/broker/microsoft/endpoint` 4. ✅ Variables de entorno en frontend (.env): ``` - PUBLIC_KEYCLOAK_URL=http://localhost:8080 - PUBLIC_KEYCLOAK_REALM=master - PUBLIC_KEYCLOAK_CLIENT_ID=anexo76-frontend + VITE_KEYCLOAK_URL=http://localhost:8080 + VITE_KEYCLOAK_REALM=master + VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend ``` **El flujo correcto es:** diff --git a/frontend/.env.example b/frontend/.env.example index 5fa0be78..000f9014 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,10 +1,10 @@ # Environment variables para frontend -PUBLIC_API_URL=http://localhost:8000/api/ +VITE_API_URL=http://localhost:8000/api/ # Configuración de Keycloak para SSO -PUBLIC_KEYCLOAK_URL=http://localhost:8080 -PUBLIC_KEYCLOAK_REALM=master -PUBLIC_KEYCLOAK_CLIENT_ID=anexo76-frontend +VITE_KEYCLOAK_URL=http://localhost:8080 +VITE_KEYCLOAK_REALM=master +VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend # Opcional: Habilitar/deshabilitar proveedores SSO PUBLIC_ENABLE_MICROSOFT_SSO=true diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 8855237a..8103a0b5 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -3,10 +3,7 @@ "singleQuote": true, "trailingComma": "none", "printWidth": 100, - "plugins": [ - "prettier-plugin-svelte", - "prettier-plugin-tailwindcss" - ], + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "overrides": [ { "files": "*.svelte", diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 00000000..f258682d --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/app.css", + "baseColor": "zinc" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/frontend/package.json b/frontend/package.json index 580a7dd2..53787930 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,15 +20,19 @@ "@eslint/compat": "^1.4.0", "@eslint/js": "^9.36.0", "@inlang/paraglide-js": "^2.3.2", + "@internationalized/date": "^3.10.0", + "@lucide/svelte": "^0.544.0", "@playwright/test": "^1.55.1", "@sveltejs/adapter-node": "^5.3.2", "@sveltejs/kit": "^2.43.2", "@sveltejs/vite-plugin-svelte": "^6.2.0", "@tailwindcss/forms": "^0.5.10", - "@tailwindcss/typography": "^0.5.18", - "@tailwindcss/vite": "^4.1.13", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.1.14", "@types/node": "^20", "@vitest/browser": "^3.2.4", + "bits-ui": "^2.14.2", + "clsx": "^2.1.1", "eslint": "^9.36.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", @@ -36,10 +40,13 @@ "playwright": "^1.55.1", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", - "prettier-plugin-tailwindcss": "^0.6.14", + "prettier-plugin-tailwindcss": "^0.7.1", "svelte": "^5.39.5", "svelte-check": "^4.3.2", - "tailwindcss": "^4.1.13", + "tailwind-merge": "^3.3.1", + "tailwind-variants": "^3.1.1", + "tailwindcss": "^4.1.14", + "tw-animate-css": "^1.4.0", "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 65d3d4f4..eca68e3c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -21,6 +21,12 @@ importers: '@inlang/paraglide-js': specifier: ^2.3.2 version: 2.4.0 + '@internationalized/date': + specifier: ^3.10.0 + version: 3.10.0 + '@lucide/svelte': + specifier: ^0.544.0 + version: 0.544.0(svelte@5.40.2) '@playwright/test': specifier: ^1.55.1 version: 1.56.1 @@ -37,10 +43,10 @@ importers: specifier: ^0.5.10 version: 0.5.10(tailwindcss@4.1.14) '@tailwindcss/typography': - specifier: ^0.5.18 + specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.1.14) '@tailwindcss/vite': - specifier: ^4.1.13 + specifier: ^4.1.14 version: 4.1.14(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) '@types/node': specifier: ^20 @@ -48,6 +54,12 @@ importers: '@vitest/browser': specifier: ^3.2.4 version: 3.2.4(playwright@1.56.1)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))(vitest@3.2.4) + bits-ui: + specifier: ^2.14.2 + version: 2.14.2(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2) + clsx: + specifier: ^2.1.1 + version: 2.1.1 eslint: specifier: ^9.36.0 version: 9.38.0(jiti@2.6.1) @@ -70,17 +82,26 @@ importers: specifier: ^3.4.0 version: 3.4.0(prettier@3.6.2)(svelte@5.40.2) prettier-plugin-tailwindcss: - specifier: ^0.6.14 - version: 0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.40.2))(prettier@3.6.2) + specifier: ^0.7.1 + version: 0.7.1(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.40.2))(prettier@3.6.2) svelte: specifier: ^5.39.5 version: 5.40.2 svelte-check: specifier: ^4.3.2 version: 4.3.3(picomatch@4.0.3)(svelte@5.40.2)(typescript@5.9.3) + tailwind-merge: + specifier: ^3.3.1 + version: 3.3.1 + tailwind-variants: + specifier: ^3.1.1 + version: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.14) tailwindcss: - specifier: ^4.1.13 + specifier: ^4.1.14 version: 4.1.14 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 typescript: specifier: ^5.9.2 version: 5.9.3 @@ -314,6 +335,15 @@ packages: resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -341,6 +371,9 @@ packages: resolution: {integrity: sha512-cvz/C1rF5WBxzHbEoiBoI6Sz6q6M+TdxfWkEGBYTD77opY8i8WN01prUWXEM87GPF4SZcyIySez9U0Ccm12oFQ==} engines: {node: '>=18.0.0'} + '@internationalized/date@3.10.0': + resolution: {integrity: sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -368,6 +401,11 @@ packages: '@lix-js/server-protocol-schema@0.1.1': resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} + '@lucide/svelte@0.544.0': + resolution: {integrity: sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==} + peerDependencies: + svelte: ^5 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -582,6 +620,9 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || ^7.0.0 + '@swc/helpers@0.5.17': + resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@tailwindcss/forms@0.5.10': resolution: {integrity: sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==} peerDependencies: @@ -868,6 +909,13 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bits-ui@2.14.2: + resolution: {integrity: sha512-YqpAJj/nRTZjf7IlgUC3QlepVZ7YFiAQWpZaYUOAZFW5Py+g5DYkhEDTdNFI5SReo7l1rct/nRpMK4pfL9Xffw==} + engines: {node: '>=20'} + peerDependencies: + '@internationalized/date': ^3.8.1 + svelte: ^5.33.0 + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1195,6 +1243,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inline-style-parser@0.2.6: + resolution: {integrity: sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg==} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -1510,9 +1561,9 @@ packages: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 - prettier-plugin-tailwindcss@0.6.14: - resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==} - engines: {node: '>=14.21.3'} + prettier-plugin-tailwindcss@0.7.1: + resolution: {integrity: sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==} + engines: {node: '>=20.19'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' '@prettier/plugin-hermes': '*' @@ -1524,14 +1575,12 @@ packages: prettier: ^3.0 prettier-plugin-astro: '*' prettier-plugin-css-order: '*' - prettier-plugin-import-sort: '*' prettier-plugin-jsdoc: '*' prettier-plugin-marko: '*' prettier-plugin-multiline-arrays: '*' prettier-plugin-organize-attributes: '*' prettier-plugin-organize-imports: '*' prettier-plugin-sort-imports: '*' - prettier-plugin-style-order: '*' prettier-plugin-svelte: '*' peerDependenciesMeta: '@ianvs/prettier-plugin-sort-imports': @@ -1552,8 +1601,6 @@ packages: optional: true prettier-plugin-css-order: optional: true - prettier-plugin-import-sort: - optional: true prettier-plugin-jsdoc: optional: true prettier-plugin-marko: @@ -1566,8 +1613,6 @@ packages: optional: true prettier-plugin-sort-imports: optional: true - prettier-plugin-style-order: - optional: true prettier-plugin-svelte: optional: true @@ -1615,6 +1660,15 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + runed@0.35.1: + resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} + peerDependencies: + '@sveltejs/kit': ^2.21.0 + svelte: ^5.7.0 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -1664,6 +1718,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-object@1.0.12: + resolution: {integrity: sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1689,10 +1746,32 @@ packages: svelte: optional: true + svelte-toolbelt@0.10.6: + resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.30.2 + svelte@5.40.2: resolution: {integrity: sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==} engines: {node: '>=18'} + tabbable@6.3.0: + resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==} + + tailwind-merge@3.3.1: + resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + + tailwind-variants@3.1.1: + resolution: {integrity: sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ==} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwind-merge: '>=3.0.0' + tailwindcss: '*' + peerDependenciesMeta: + tailwind-merge: + optional: true + tailwindcss@4.1.14: resolution: {integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==} @@ -1740,6 +1819,12 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2051,6 +2136,17 @@ snapshots: '@eslint/core': 0.16.0 levn: 0.4.1 + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2088,6 +2184,10 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros + '@internationalized/date@3.10.0': + dependencies: + '@swc/helpers': 0.5.17 + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 @@ -2125,6 +2225,10 @@ snapshots: '@lix-js/server-protocol-schema@0.1.1': {} + '@lucide/svelte@0.544.0(svelte@5.40.2)': + dependencies: + svelte: 5.40.2 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2303,6 +2407,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@swc/helpers@0.5.17': + dependencies: + tslib: 2.8.1 + '@tailwindcss/forms@0.5.10(tailwindcss@4.1.14)': dependencies: mini-svg-data-uri: 1.4.4 @@ -2610,6 +2718,19 @@ snapshots: balanced-match@1.0.2: {} + bits-ui@2.14.2(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2): + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/dom': 1.7.4 + '@internationalized/date': 3.10.0 + esm-env: 1.2.2 + runed: 0.35.1(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2) + svelte: 5.40.2 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2) + tabbable: 6.3.0 + transitivePeerDependencies: + - '@sveltejs/kit' + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -2931,6 +3052,8 @@ snapshots: imurmurhash@0.1.4: {} + inline-style-parser@0.2.6: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -3171,7 +3294,7 @@ snapshots: prettier: 3.6.2 svelte: 5.40.2 - prettier-plugin-tailwindcss@0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.40.2))(prettier@3.6.2): + prettier-plugin-tailwindcss@0.7.1(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.40.2))(prettier@3.6.2): dependencies: prettier: 3.6.2 optionalDependencies: @@ -3235,6 +3358,15 @@ snapshots: dependencies: queue-microtask: 1.2.3 + runed@0.35.1(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2): + dependencies: + dequal: 2.0.3 + esm-env: 1.2.2 + lz-string: 1.5.0 + svelte: 5.40.2 + optionalDependencies: + '@sveltejs/kit': 2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)) + sade@1.8.1: dependencies: mri: 1.2.0 @@ -3274,6 +3406,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + style-to-object@1.0.12: + dependencies: + inline-style-parser: 0.2.6 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3303,6 +3439,15 @@ snapshots: optionalDependencies: svelte: 5.40.2 + svelte-toolbelt@0.10.6(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2): + dependencies: + clsx: 2.1.1 + runed: 0.35.1(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2) + style-to-object: 1.0.12 + svelte: 5.40.2 + transitivePeerDependencies: + - '@sveltejs/kit' + svelte@5.40.2: dependencies: '@jridgewell/remapping': 2.3.5 @@ -3320,6 +3465,16 @@ snapshots: magic-string: 0.30.19 zimmerframe: 1.1.4 + tabbable@6.3.0: {} + + tailwind-merge@3.3.1: {} + + tailwind-variants@3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.14): + dependencies: + tailwindcss: 4.1.14 + optionalDependencies: + tailwind-merge: 3.3.1 + tailwindcss@4.1.14: {} tapable@2.3.0: {} @@ -3357,6 +3512,10 @@ snapshots: dependencies: typescript: 5.9.3 + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/frontend/src/app.css b/frontend/src/app.css index cd670237..236c256b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,3 +1,123 @@ @import 'tailwindcss'; + @plugin '@tailwindcss/forms'; @plugin '@tailwindcss/typography'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.623 0.214 259.815); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.623 0.214 259.815); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.623 0.214 259.815); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.623 0.214 259.815); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.546 0.245 262.881); + --primary-foreground: oklch(0.379 0.146 265.522); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.488 0.243 264.376); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.379 0.146 265.522); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.488 0.243 264.376); +} + + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index da08e6da..7e328ea8 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -3,7 +3,10 @@ declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + token: string | null; + isAuthenticated: boolean; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 51822109..794de026 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,5 +1,6 @@ import type { Handle } from '@sveltejs/kit'; import { paraglideMiddleware } from '$lib/paraglide/server'; +import { sequence } from '@sveltejs/kit/hooks'; const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { event.request = request; @@ -9,4 +10,15 @@ const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(even }); }); -export const handle: Handle = handleParaglide; +const handleAuth: Handle = async ({ event, resolve }) => { + // Obtener el token de las cookies + const token = event.cookies.get('access_token'); + + // Agregar el token a los locals para que esté disponible en toda la app + event.locals.token = token || null; + event.locals.isAuthenticated = !!token; + + return resolve(event); +}; + +export const handle: Handle = sequence(handleAuth, handleParaglide); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 00000000..4ae6cdf7 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,100 @@ +/** + * Cliente API para comunicación con el backend + */ +import { getToken } from './auth'; + +const API_BASE_URL = import.meta.env.VITE_API_URL; + +export interface ApiResponse { + data?: T; + error?: string; + status: number; +} + +/** + * Realiza una petición al API + */ +async function fetchApi( + endpoint: string, + options: RequestInit = {} +): Promise> { + const token = getToken(); + + const headers: Record = { + 'Content-Type': 'application/json', + ...((options.headers as Record) || {}) + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers + }); + + const data = await response.json(); + + if (!response.ok) { + return { + error: data.detail || 'Error en la petición', + status: response.status + }; + } + + return { + data, + status: response.status + }; + } catch (error) { + return { + error: 'Error de conexión con el servidor', + status: 0 + }; + } +} + +// Métodos HTTP +export const api = { + get: (endpoint: string) => fetchApi(endpoint, { method: 'GET' }), + + post: (endpoint: string, body: any) => + fetchApi(endpoint, { + method: 'POST', + body: JSON.stringify(body) + }), + + put: (endpoint: string, body: any) => + fetchApi(endpoint, { + method: 'PUT', + body: JSON.stringify(body) + }), + + delete: (endpoint: string) => fetchApi(endpoint, { method: 'DELETE' }), + + // Endpoints específicos + auth: { + login: (credentials: { username: string; password: string; tenant_slug: string }) => + api.post('/v1/auth/login', credentials), + logout: (data: { refresh_token: string }) => api.post('/v1/auth/logout', data), + me: () => api.get('/v1/auth/me'), + health: () => api.get('/health') + }, + + tenants: { + list: (page = 1, pageSize = 50) => + api.get(`/v1/tenants?page=${page}&page_size=${pageSize}`), + get: (id: number) => api.get(`/v1/tenants/${id}`), + create: (data: any) => api.post('/v1/tenants', data), + update: (id: number, data: any) => api.put(`/v1/tenants/${id}`, data) + }, + + licenses: { + get: (tenantId: number) => api.get(`/v1/licenses/tenant/${tenantId}`), + myLicense: () => api.get('/v1/licenses/my-license'), + usage: (tenantId: number) => api.get(`/v1/licenses/usage/${tenantId}`), + validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}`) + } +}; diff --git a/frontend/src/lib/api/dashboard/refrence_data/code_pedimento_regimens.ts b/frontend/src/lib/api/dashboard/refrence_data/code_pedimento_regimens.ts new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 00000000..cc5dc66a --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 00000000..ff0b5933 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,419 @@ +/** + * Servicio de autenticación con Keycloak + */ +import Keycloak from 'keycloak-js'; +import { writable, derived } from 'svelte/store'; +import { browser } from '$app/environment'; + +// Tipos +export interface User { + id: string; + username: string; + email?: string; + name?: string; + tenantId?: number; + roles: string[]; +} + +export interface AuthState { + isAuthenticated: boolean; + isLoading: boolean; + user: User | null; + token: string | null; +} + +// Configuración de Keycloak +const keycloakConfig = { + url: import.meta.env.VITE_KEYCLOAK_URL, + realm: import.meta.env.VITE_KEYCLOAK_REALM, + clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID +}; + +// Instancia de Keycloak +let keycloakInstance: Keycloak | null = null; + +// Helper para obtener cookies +const getCookie = (name: string): string | null => { + if (!browser) return null; + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(';').shift() || null; + return null; +}; + +// Helper para establecer cookies con las opciones correctas según el entorno +const setCookie = (name: string, value: string, days: number = 7) => { + if (!browser) return; + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + days); + + // En desarrollo (localhost), no usar Secure flag + const isSecure = window.location.protocol === 'https:'; + const secureFlag = isSecure ? '; Secure' : ''; + + const cookieString = `${name}=${value}; path=/; expires=${expirationDate.toUTCString()}; SameSite=Lax${secureFlag}`; + document.cookie = cookieString; + + // Verificar que se estableció + const verification = getCookie(name); +}; + +// Helper para eliminar cookies +const deleteCookie = (name: string) => { + if (!browser) return; + const isSecure = window.location.protocol === 'https:'; + const secureFlag = isSecure ? '; Secure' : ''; + document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`; +}; + +// Store de autenticación +const createAuthStore = () => { + const { subscribe, set, update } = writable({ + isAuthenticated: false, + isLoading: true, + user: null, + token: null + }); + + return { + subscribe, + setAuthenticated: (authenticated: boolean) => + update((state) => ({ ...state, isAuthenticated: authenticated })), + setLoading: (loading: boolean) => + update((state) => ({ ...state, isLoading: loading })), + setUser: (user: User | null) => update((state) => ({ ...state, user })), + setToken: (token: string | null) => update((state) => ({ ...state, token })), + setTokens: (accessToken: string, refreshToken?: string) => { + update((state) => ({ ...state, token: accessToken })); + if (browser && refreshToken) { + localStorage.setItem('refresh_token', refreshToken); + } + }, + reset: () => + set({ + isAuthenticated: false, + isLoading: false, + user: null, + token: null + }) + }; +}; + +export const authStore = createAuthStore(); + +// Derived store para verificar si está autenticado +export const isAuthenticated = derived(authStore, ($auth) => $auth.isAuthenticated); + +// Derived store para obtener el usuario +export const currentUser = derived(authStore, ($auth) => $auth.user); + +/** + * Inicializa la autenticación (Keycloak o token-based) + */ +export const initAuth = async (): Promise => { + if (!browser) return false; + + try { + authStore.setLoading(true); + + // Primero intentar restaurar sesión desde localStorage + const token = localStorage.getItem('access_token'); + if (token) { + authStore.setToken(token); + authStore.setAuthenticated(true); + + // Sincronizar con cookies si no existe + const cookieToken = getCookie('access_token'); + if (!cookieToken) { + setCookie('access_token', token); + } + + await loadUserInfo(token); + authStore.setLoading(false); + return true; + } + + // Si no hay token local, intentar con Keycloak + await initKeycloak(); + + authStore.setLoading(false); + return false; + } catch (error) { + console.error('Error inicializando autenticación:', error); + authStore.setLoading(false); + return false; + } +}; + +/** + * Inicializa Keycloak + */ +export const initKeycloak = async (): Promise => { + if (!browser) return false; + + try { + keycloakInstance = new Keycloak(keycloakConfig); + + const authenticated = await keycloakInstance.init({ + onLoad: 'check-sso', + silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html', + pkceMethod: 'S256', + checkLoginIframe: false + }); + + if (authenticated) { + await updateAuthState(); + setupTokenRefresh(); + } + + return authenticated; + } catch (error) { + console.error('Error inicializando Keycloak:', error); + return false; + } +}; + +/** + * Actualiza el estado de autenticación + */ +const updateAuthState = async () => { + if (!keycloakInstance?.authenticated) { + authStore.reset(); + return; + } + + try { + const profile = await keycloakInstance.loadUserProfile(); + const token = keycloakInstance.token || null; + const tokenParsed = keycloakInstance.tokenParsed as any; + + const roles = tokenParsed?.realm_access?.roles || []; + const tenantId = tokenParsed?.tenant_id || tokenParsed?.attributes?.tenant_id; + + const user: User = { + id: profile.id || '', + username: profile.username || '', + email: profile.email, + name: `${profile.firstName || ''} ${profile.lastName || ''}`.trim(), + tenantId: tenantId ? parseInt(tenantId) : undefined, + roles + }; + + authStore.setAuthenticated(true); + authStore.setUser(user); + authStore.setToken(token); + } catch (error) { + console.error('Error actualizando estado de autenticación:', error); + authStore.reset(); + } +}; + +/** + * Configura el refresh automático del token + */ +const setupTokenRefresh = () => { + if (!keycloakInstance) return; + + // Refrescar token cada 60 segundos si está cerca de expirar + keycloakInstance.onTokenExpired = () => { + keycloakInstance + ?.updateToken(70) + .then((refreshed) => { + if (refreshed) { + authStore.setToken(keycloakInstance?.token || null); + } + }) + .catch(() => { + console.error('Error refrescando token'); + logout(); + }); + }; +}; + +/** + * Inicia sesión con Keycloak (OAuth flow) + */ +export const loginWithKeycloak = async (tenantSlug?: string) => { + if (!keycloakInstance) { + console.error('Keycloak no está inicializado'); + return; + } + + const options: any = { + redirectUri: window.location.origin + '/callback' + }; + + if (tenantSlug) { + options.loginHint = tenantSlug; + } + + await keycloakInstance.login(options); +}; + +/** + * Inicia sesión con credenciales (username/password) + * Nota: Esta función ya no se usa directamente desde el login form, + * el login ahora se hace mediante form actions del servidor. + * Se mantiene para compatibilidad con SSO y otros flujos. + */ +export const login = async (credentials: { + username: string; + password: string; + tenant_slug: string; +}): Promise<{ success: boolean; error?: string; data?: any }> => { + try { + // Usar la API centralizada + const { api } = await import('./api'); + const response = await api.auth.login(credentials); + + // Si hay error en la respuesta + if (response.error) { + return { + success: false, + error: response.error + }; + } + + // Guardar tokens y actualizar estado + const loginData = response.data; + if (loginData?.access_token) { + authStore.setToken(loginData.access_token); + authStore.setAuthenticated(true); + + // Guardar también en localStorage para persistencia + if (browser) { + localStorage.setItem('access_token', loginData.access_token); + if (loginData.refresh_token) { + localStorage.setItem('refresh_token', loginData.refresh_token); + } + + // Guardar en cookies para que el servidor pueda acceder + setCookie('access_token', loginData.access_token); + } + + // Cargar información del usuario + await loadUserInfo(loginData.access_token); + } + + return { + success: true, + data: loginData + }; + } catch (error) { + console.error('Error en login:', error); + return { + success: false, + error: 'Error de conexión con el servidor' + }; + } +}; + +/** + * Carga la información del usuario desde el token + */ +const loadUserInfo = async (token: string) => { + try { + // Guardar temporalmente el token para que api.ts lo use + authStore.setToken(token); + + // Usar la API centralizada + const { api } = await import('./api'); + const response = await api.auth.me(); + + if (response.data) { + const data = response.data; + const user: User = { + id: data.sub || '', + username: data.preferred_username || data.username || '', + email: data.email, + name: data.name, + tenantId: data.tenant_id, + roles: data.realm_access?.roles || [] + }; + authStore.setUser(user); + } + } catch (error) { + console.error('Error cargando información del usuario:', error); + } +}; + +/** + * Cierra sesión + */ +export const logout = async () => { + if (!browser) return; + + try { + // Obtener el refresh token si existe + const refreshToken = localStorage.getItem('refresh_token'); + + // Si hay refresh token, intentar invalidarlo en el backend + if (refreshToken) { + try { + const { api } = await import('./api'); + await api.auth.logout({ refresh_token: refreshToken }); + } catch (error) { + console.error('Error al invalidar refresh token:', error); + // Continuar con el logout aunque falle + } + } + + // Limpiar estado local + authStore.reset(); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + + // Si hay instancia de Keycloak, hacer logout de Keycloak + if (keycloakInstance?.authenticated) { + await keycloakInstance.logout({ + redirectUri: window.location.origin + '/login' + }); + return; + } + + // Llamar al endpoint del servidor para limpiar cookies de SvelteKit + // Usar un formulario para hacer POST y permitir la redirección + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '/logout'; + document.body.appendChild(form); + form.submit(); + + } catch (error) { + console.error('Error durante logout:', error); + // Asegurar que se redirija al login aunque haya error + window.location.href = '/login'; + } +}; + +/** + * Verifica si el usuario tiene un rol específico + */ +export const hasRole = (role: string): boolean => { + if (!keycloakInstance?.authenticated) return false; + return keycloakInstance.hasRealmRole(role); +}; + +/** + * Obtiene el token de acceso actual + */ +export const getToken = (): string | null => { + // Intentar obtener de Keycloak primero + if (keycloakInstance?.token) { + return keycloakInstance.token; + } + + // Si no, intentar de localStorage + if (browser) { + return localStorage.getItem('access_token'); + } + + return null; +}; + +/** + * Obtiene la instancia de Keycloak + */ +export const getKeycloakInstance = (): Keycloak | null => { + return keycloakInstance; +}; diff --git a/frontend/src/lib/components/login-form.svelte b/frontend/src/lib/components/login-form.svelte new file mode 100644 index 00000000..afd9ac75 --- /dev/null +++ b/frontend/src/lib/components/login-form.svelte @@ -0,0 +1,219 @@ + + +
+ + +
{ + loading = true; + return async ({ update, result }) => { + await update(); + loading = false; + + // Si hay un error, limpiar cookies del cliente + if (result.type === 'failure') { + clearClientCookies(); + } + }; + }} + > + +
+

Anexo 76

+

+ Sistema de Cumplimiento Fiscal y Aduanal +

+
+ + {#if error} +
+ {error} +
+ {/if} + + + Tenant + + + + + Usuario + + + + + + + + + + + O continua con + + + + + + + + ¿No tienes una cuenta? Regístrate + +
+
+ +
+
+ + Al continuar, aceptas nuestros Términos de Servicio y + Política de Privacidad. + +
diff --git a/frontend/src/lib/components/sidebar/app-sidebar.svelte b/frontend/src/lib/components/sidebar/app-sidebar.svelte new file mode 100644 index 00000000..817f1f44 --- /dev/null +++ b/frontend/src/lib/components/sidebar/app-sidebar.svelte @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts new file mode 100644 index 00000000..829156eb --- /dev/null +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -0,0 +1,147 @@ +import AudioWaveformIcon from "@lucide/svelte/icons/audio-waveform"; +import BookOpenIcon from "@lucide/svelte/icons/book-open"; +import BotIcon from "@lucide/svelte/icons/bot"; +import ChartPieIcon from "@lucide/svelte/icons/chart-pie"; +import CommandIcon from "@lucide/svelte/icons/command"; +import FrameIcon from "@lucide/svelte/icons/frame"; +import GalleryVerticalEndIcon from "@lucide/svelte/icons/gallery-vertical-end"; +import MapIcon from "@lucide/svelte/icons/map"; +import Settings2Icon from "@lucide/svelte/icons/settings-2"; +import SquareTerminalIcon from "@lucide/svelte/icons/square-terminal"; + +export interface NavItem { + title: string; + url: string; +} + +export interface NavMainItem { + title: string; + url: string; + icon: any; + isActive?: boolean; + items?: NavItem[]; +} + +export interface Project { + name: string; + url: string; + icon: any; +} + +export interface Team { + name: string; + logo: any; + plan: string; +} + +export interface User { + name: string; + email: string; + avatar: string; +} + +export interface SidebarData { + user: User; + teams: Team[]; + navMain: NavMainItem[]; + projects: Project[]; +} + +// Datos estáticos del sidebar (los datos de usuario vienen de Keycloak) +export const sidebarData: SidebarData = { + user: { + name: "", // Se llena dinámicamente desde Keycloak + email: "", // Se llena dinámicamente desde Keycloak + avatar: "/avatars/default.jpg", // Avatar por defecto + }, + teams: [ + { + name: "Anexo76", + logo: GalleryVerticalEndIcon, + plan: "Enterprise", + }, + ], + navMain: [ + { + title: "Catalogos Generales", + url: "/dashboard", + icon: SquareTerminalIcon, + items: [ + { + title: "", + url: "/dashboard/reference_data/code_pedimento_regimens", + }, + { + title: "Reportes", + url: "#", + }, + ], + isActive: true, + }, + { + title: "Inventarios", + url: "#", + icon: BotIcon, + items: [ + { + title: "Gestionar Inventarios", + url: "#", + }, + { + title: "Reportes", + url: "#", + }, + ], + }, + { + title: "Pedimentos", + url: "#", + icon: BookOpenIcon, + items: [ + { + title: "Nuevo Pedimento", + url: "#", + }, + { + title: "Consultar", + url: "#", + }, + { + title: "Historial", + url: "#", + }, + ], + }, + { + title: "Configuración", + url: "#", + icon: Settings2Icon, + items: [ + { + title: "General", + url: "#", + }, + { + title: "Licencia", + url: "#", + }, + { + title: "Usuarios", + url: "#", + }, + ], + }, + ], + projects: [ + { + name: "Reportes", + url: "#", + icon: ChartPieIcon, + }, + { + name: "Ayuda", + url: "#", + icon: FrameIcon, + }, + ], +}; diff --git a/frontend/src/lib/components/sidebar/nav-main.svelte b/frontend/src/lib/components/sidebar/nav-main.svelte new file mode 100644 index 00000000..bd491e96 --- /dev/null +++ b/frontend/src/lib/components/sidebar/nav-main.svelte @@ -0,0 +1,64 @@ + + + + Platform + + {#each items as item (item.title)} + + {#snippet child({ props })} + + + {#snippet child({ props })} + + {#if item.icon} + + {/if} + {item.title} + + + {/snippet} + + + + {#each item.items ?? [] as subItem (subItem.title)} + + + {#snippet child({ props })} + + {subItem.title} + + {/snippet} + + + {/each} + + + + {/snippet} + + {/each} + + diff --git a/frontend/src/lib/components/sidebar/nav-projects.svelte b/frontend/src/lib/components/sidebar/nav-projects.svelte new file mode 100644 index 00000000..75c5085f --- /dev/null +++ b/frontend/src/lib/components/sidebar/nav-projects.svelte @@ -0,0 +1,76 @@ + + + + Projects + + {#each projects as item (item.name)} + + + {#snippet child({ props })} + + + {item.name} + + {/snippet} + + + + {#snippet child({ props })} + + + More + + {/snippet} + + + + + View Project + + + + Share Project + + + + + Delete Project + + + + + {/each} + + + + More + + + + diff --git a/frontend/src/lib/components/sidebar/nav-user.svelte b/frontend/src/lib/components/sidebar/nav-user.svelte new file mode 100644 index 00000000..5ee9452b --- /dev/null +++ b/frontend/src/lib/components/sidebar/nav-user.svelte @@ -0,0 +1,92 @@ + + + + + + + {#snippet child({ props })} + + + + CN + +
+ {user.name} + {user.email} +
+ +
+ {/snippet} +
+ + +
+ + + CN + +
+ {user.name} + {user.email} +
+
+
+ + + + + Upgrade to Pro + + + + + + + Account + + + + Billing + + + + Notifications + + + + + + Log out + +
+
+
+
diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte new file mode 100644 index 00000000..73d1f174 --- /dev/null +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -0,0 +1,69 @@ + + + + + + + {#snippet child({ props })} + +
+ +
+
+ + {activeTeam.name} + + {activeTeam.plan} +
+ +
+ {/snippet} +
+ + Teams + {#each teams as team, index (team.name)} + (activeTeam = team)} class="gap-2 p-2"> +
+ +
+ {team.name} + ⌘{index + 1} +
+ {/each} + + +
+ +
+
Add team
+
+
+
+
+
diff --git a/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte new file mode 100644 index 00000000..249d4a4a --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/avatar-image.svelte b/frontend/src/lib/components/ui/avatar/avatar-image.svelte new file mode 100644 index 00000000..2bb9db4d --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-image.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/avatar.svelte b/frontend/src/lib/components/ui/avatar/avatar.svelte new file mode 100644 index 00000000..e37214d5 --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/index.ts b/frontend/src/lib/components/ui/avatar/index.ts new file mode 100644 index 00000000..d06457be --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/index.ts @@ -0,0 +1,13 @@ +import Root from "./avatar.svelte"; +import Image from "./avatar-image.svelte"; +import Fallback from "./avatar-fallback.svelte"; + +export { + Root, + Image, + Fallback, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback, +}; diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte new file mode 100644 index 00000000..a178cf55 --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte new file mode 100644 index 00000000..1a84c4c4 --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte @@ -0,0 +1,20 @@ + + +
  • + {@render children?.()} +
  • diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte new file mode 100644 index 00000000..e6bc17d3 --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: attrs })} +{:else} + + {@render children?.()} + +{/if} diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte new file mode 100644 index 00000000..b5458fab --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte @@ -0,0 +1,23 @@ + + +
      + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte new file mode 100644 index 00000000..5fb69794 --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte new file mode 100644 index 00000000..84106a1c --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte b/frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte new file mode 100644 index 00000000..8f8a3e64 --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/ui/breadcrumb/index.ts b/frontend/src/lib/components/ui/breadcrumb/index.ts new file mode 100644 index 00000000..dc914ec3 --- /dev/null +++ b/frontend/src/lib/components/ui/breadcrumb/index.ts @@ -0,0 +1,25 @@ +import Root from "./breadcrumb.svelte"; +import Ellipsis from "./breadcrumb-ellipsis.svelte"; +import Item from "./breadcrumb-item.svelte"; +import Separator from "./breadcrumb-separator.svelte"; +import Link from "./breadcrumb-link.svelte"; +import List from "./breadcrumb-list.svelte"; +import Page from "./breadcrumb-page.svelte"; + +export { + Root, + Ellipsis, + Item, + Separator, + Link, + List, + Page, + // + Root as Breadcrumb, + Ellipsis as BreadcrumbEllipsis, + Item as BreadcrumbItem, + Separator as BreadcrumbSeparator, + Link as BreadcrumbLink, + List as BreadcrumbList, + Page as BreadcrumbPage, +}; diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte new file mode 100644 index 00000000..21054748 --- /dev/null +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/button/index.ts b/frontend/src/lib/components/ui/button/index.ts new file mode 100644 index 00000000..fb585d76 --- /dev/null +++ b/frontend/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/frontend/src/lib/components/ui/card/card-action.svelte b/frontend/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 00000000..cc36c566 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/card-content.svelte b/frontend/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 00000000..bc90b837 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/card-description.svelte b/frontend/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 00000000..9b20ac70 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

    + {@render children?.()} +

    diff --git a/frontend/src/lib/components/ui/card/card-footer.svelte b/frontend/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 00000000..cf433539 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 00000000..8a91abbf --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/card-title.svelte b/frontend/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 00000000..22586e61 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte new file mode 100644 index 00000000..99448cc9 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card.svelte @@ -0,0 +1,23 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/card/index.ts b/frontend/src/lib/components/ui/card/index.ts new file mode 100644 index 00000000..4d3fce48 --- /dev/null +++ b/frontend/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Action from "./card-action.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, +}; diff --git a/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte b/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte new file mode 100644 index 00000000..bdabb559 --- /dev/null +++ b/frontend/src/lib/components/ui/collapsible/collapsible-content.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte b/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte new file mode 100644 index 00000000..ece7ad68 --- /dev/null +++ b/frontend/src/lib/components/ui/collapsible/collapsible-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/collapsible/collapsible.svelte b/frontend/src/lib/components/ui/collapsible/collapsible.svelte new file mode 100644 index 00000000..39cdd4e4 --- /dev/null +++ b/frontend/src/lib/components/ui/collapsible/collapsible.svelte @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/lib/components/ui/collapsible/index.ts b/frontend/src/lib/components/ui/collapsible/index.ts new file mode 100644 index 00000000..169b4791 --- /dev/null +++ b/frontend/src/lib/components/ui/collapsible/index.ts @@ -0,0 +1,13 @@ +import Root from "./collapsible.svelte"; +import Trigger from "./collapsible-trigger.svelte"; +import Content from "./collapsible-content.svelte"; + +export { + Root, + Content, + Trigger, + // + Root as Collapsible, + Content as CollapsibleContent, + Trigger as CollapsibleTrigger, +}; diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 00000000..e03f9491 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,41 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 00000000..907ef737 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 00000000..48d14a91 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 00000000..aca1f7bd --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 00000000..64bb2831 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 00000000..f72e477e --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 00000000..189aef40 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 00000000..513170aa --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 00000000..90f1b6f1 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 00000000..69749477 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 00000000..10e14ca6 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 00000000..f9b286a2 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 00000000..cb053444 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/dropdown-menu/index.ts b/frontend/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 00000000..1cf9f701 --- /dev/null +++ b/frontend/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,49 @@ +import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import Group from "./dropdown-menu-group.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Label from "./dropdown-menu-label.svelte"; +import RadioGroup from "./dropdown-menu-radio-group.svelte"; +import RadioItem from "./dropdown-menu-radio-item.svelte"; +import Separator from "./dropdown-menu-separator.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; +import Trigger from "./dropdown-menu-trigger.svelte"; +import SubContent from "./dropdown-menu-sub-content.svelte"; +import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; +const Sub = DropdownMenuPrimitive.Sub; +const Root = DropdownMenuPrimitive.Root; + +export { + CheckboxItem, + Content, + Root as DropdownMenu, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +}; diff --git a/frontend/src/lib/components/ui/field/field-content.svelte b/frontend/src/lib/components/ui/field/field-content.svelte new file mode 100644 index 00000000..1b6535b4 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-content.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/field/field-description.svelte b/frontend/src/lib/components/ui/field/field-description.svelte new file mode 100644 index 00000000..4c147fd0 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-description.svelte @@ -0,0 +1,25 @@ + + +

    a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...restProps} +> + {@render children?.()} +

    diff --git a/frontend/src/lib/components/ui/field/field-error.svelte b/frontend/src/lib/components/ui/field/field-error.svelte new file mode 100644 index 00000000..68928119 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-error.svelte @@ -0,0 +1,58 @@ + + +{#if hasContent} + +{/if} diff --git a/frontend/src/lib/components/ui/field/field-group.svelte b/frontend/src/lib/components/ui/field/field-group.svelte new file mode 100644 index 00000000..e6854279 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-group.svelte @@ -0,0 +1,23 @@ + + +
    [data-slot=field-group]]:gap-4", + className + )} + {...restProps} +> + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/field/field-label.svelte b/frontend/src/lib/components/ui/field/field-label.svelte new file mode 100644 index 00000000..2ee431a7 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-label.svelte @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/lib/components/ui/field/field-legend.svelte b/frontend/src/lib/components/ui/field/field-legend.svelte new file mode 100644 index 00000000..3f1c50fb --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-legend.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/field/field-separator.svelte b/frontend/src/lib/components/ui/field/field-separator.svelte new file mode 100644 index 00000000..12bcb77e --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-separator.svelte @@ -0,0 +1,38 @@ + + +
    + + {#if children} + + {@render children()} + + {/if} +
    diff --git a/frontend/src/lib/components/ui/field/field-set.svelte b/frontend/src/lib/components/ui/field/field-set.svelte new file mode 100644 index 00000000..1d8e2339 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-set.svelte @@ -0,0 +1,24 @@ + + +
    [data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...restProps} +> + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/field/field-title.svelte b/frontend/src/lib/components/ui/field/field-title.svelte new file mode 100644 index 00000000..4230536f --- /dev/null +++ b/frontend/src/lib/components/ui/field/field-title.svelte @@ -0,0 +1,23 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/field/field.svelte b/frontend/src/lib/components/ui/field/field.svelte new file mode 100644 index 00000000..981cb701 --- /dev/null +++ b/frontend/src/lib/components/ui/field/field.svelte @@ -0,0 +1,53 @@ + + + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/field/index.ts b/frontend/src/lib/components/ui/field/index.ts new file mode 100644 index 00000000..a644a956 --- /dev/null +++ b/frontend/src/lib/components/ui/field/index.ts @@ -0,0 +1,33 @@ +import Field from "./field.svelte"; +import Set from "./field-set.svelte"; +import Legend from "./field-legend.svelte"; +import Group from "./field-group.svelte"; +import Content from "./field-content.svelte"; +import Label from "./field-label.svelte"; +import Title from "./field-title.svelte"; +import Description from "./field-description.svelte"; +import Separator from "./field-separator.svelte"; +import Error from "./field-error.svelte"; + +export { + Field, + Set, + Legend, + Group, + Content, + Label, + Title, + Description, + Separator, + Error, + // + Set as FieldSet, + Legend as FieldLegend, + Group as FieldGroup, + Content as FieldContent, + Label as FieldLabel, + Title as FieldTitle, + Description as FieldDescription, + Separator as FieldSeparator, + Error as FieldError, +}; diff --git a/frontend/src/lib/components/ui/input/index.ts b/frontend/src/lib/components/ui/input/index.ts new file mode 100644 index 00000000..f47b6d3f --- /dev/null +++ b/frontend/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte new file mode 100644 index 00000000..960167d7 --- /dev/null +++ b/frontend/src/lib/components/ui/input/input.svelte @@ -0,0 +1,52 @@ + + +{#if type === "file"} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/label/index.ts b/frontend/src/lib/components/ui/label/index.ts new file mode 100644 index 00000000..8bfca0b3 --- /dev/null +++ b/frontend/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/frontend/src/lib/components/ui/label/label.svelte b/frontend/src/lib/components/ui/label/label.svelte new file mode 100644 index 00000000..d0afda3d --- /dev/null +++ b/frontend/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/separator/index.ts b/frontend/src/lib/components/ui/separator/index.ts new file mode 100644 index 00000000..82442d2c --- /dev/null +++ b/frontend/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/frontend/src/lib/components/ui/separator/separator.svelte b/frontend/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 00000000..89b2695b --- /dev/null +++ b/frontend/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/ui/sheet/index.ts b/frontend/src/lib/components/ui/sheet/index.ts new file mode 100644 index 00000000..01d40c80 --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,36 @@ +import { Dialog as SheetPrimitive } from "bits-ui"; +import Trigger from "./sheet-trigger.svelte"; +import Close from "./sheet-close.svelte"; +import Overlay from "./sheet-overlay.svelte"; +import Content from "./sheet-content.svelte"; +import Header from "./sheet-header.svelte"; +import Footer from "./sheet-footer.svelte"; +import Title from "./sheet-title.svelte"; +import Description from "./sheet-description.svelte"; + +const Root = SheetPrimitive.Root; +const Portal = SheetPrimitive.Portal; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, +}; diff --git a/frontend/src/lib/components/ui/sheet/sheet-close.svelte b/frontend/src/lib/components/ui/sheet/sheet-close.svelte new file mode 100644 index 00000000..ae382c12 --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/sheet/sheet-content.svelte b/frontend/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 00000000..856922ea --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,58 @@ + + + + + + + + {@render children?.()} + + + Close + + + diff --git a/frontend/src/lib/components/ui/sheet/sheet-description.svelte b/frontend/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 00000000..333b17a7 --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/sheet/sheet-footer.svelte b/frontend/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 00000000..dd9ed84b --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sheet/sheet-header.svelte b/frontend/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 00000000..757a6a56 --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sheet/sheet-overlay.svelte b/frontend/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 00000000..345e197b --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/sheet/sheet-title.svelte b/frontend/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 00000000..9fda327e --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/sheet/sheet-trigger.svelte b/frontend/src/lib/components/ui/sheet/sheet-trigger.svelte new file mode 100644 index 00000000..e266975f --- /dev/null +++ b/frontend/src/lib/components/ui/sheet/sheet-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/sidebar/constants.ts b/frontend/src/lib/components/ui/sidebar/constants.ts new file mode 100644 index 00000000..4de44351 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/constants.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_COOKIE_NAME = "sidebar:state"; +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +export const SIDEBAR_WIDTH = "16rem"; +export const SIDEBAR_WIDTH_MOBILE = "18rem"; +export const SIDEBAR_WIDTH_ICON = "3rem"; +export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; diff --git a/frontend/src/lib/components/ui/sidebar/context.svelte.ts b/frontend/src/lib/components/ui/sidebar/context.svelte.ts new file mode 100644 index 00000000..15248ada --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/context.svelte.ts @@ -0,0 +1,81 @@ +import { IsMobile } from "$lib/hooks/is-mobile.svelte.js"; +import { getContext, setContext } from "svelte"; +import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js"; + +type Getter = () => T; + +export type SidebarStateProps = { + /** + * A getter function that returns the current open state of the sidebar. + * We use a getter function here to support `bind:open` on the `Sidebar.Provider` + * component. + */ + open: Getter; + + /** + * A function that sets the open state of the sidebar. To support `bind:open`, we need + * a source of truth for changing the open state to ensure it will be synced throughout + * the sub-components and any `bind:` references. + */ + setOpen: (open: boolean) => void; +}; + +class SidebarState { + readonly props: SidebarStateProps; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen: SidebarStateProps["setOpen"]; + #isMobile: IsMobile; + state = $derived.by(() => (this.open ? "expanded" : "collapsed")); + + constructor(props: SidebarStateProps) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } + + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } + + // Event handler to apply to the `` + handleShortcutKeydown = (e: KeyboardEvent) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; + + setOpenMobile = (value: boolean) => { + this.openMobile = value; + }; + + toggle = () => { + return this.#isMobile.current + ? (this.openMobile = !this.openMobile) + : this.setOpen(!this.open); + }; +} + +const SYMBOL_KEY = "scn-sidebar"; + +/** + * Instantiates a new `SidebarState` instance and sets it in the context. + * + * @param props The constructor props for the `SidebarState` class. + * @returns The `SidebarState` instance. + */ +export function setSidebar(props: SidebarStateProps): SidebarState { + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); +} + +/** + * Retrieves the `SidebarState` instance from the context. This is a class instance, + * so you cannot destructure it. + * @returns The `SidebarState` instance. + */ +export function useSidebar(): SidebarState { + return getContext(Symbol.for(SYMBOL_KEY)); +} diff --git a/frontend/src/lib/components/ui/sidebar/index.ts b/frontend/src/lib/components/ui/sidebar/index.ts new file mode 100644 index 00000000..318a3417 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/index.ts @@ -0,0 +1,75 @@ +import { useSidebar } from "./context.svelte.js"; +import Content from "./sidebar-content.svelte"; +import Footer from "./sidebar-footer.svelte"; +import GroupAction from "./sidebar-group-action.svelte"; +import GroupContent from "./sidebar-group-content.svelte"; +import GroupLabel from "./sidebar-group-label.svelte"; +import Group from "./sidebar-group.svelte"; +import Header from "./sidebar-header.svelte"; +import Input from "./sidebar-input.svelte"; +import Inset from "./sidebar-inset.svelte"; +import MenuAction from "./sidebar-menu-action.svelte"; +import MenuBadge from "./sidebar-menu-badge.svelte"; +import MenuButton from "./sidebar-menu-button.svelte"; +import MenuItem from "./sidebar-menu-item.svelte"; +import MenuSkeleton from "./sidebar-menu-skeleton.svelte"; +import MenuSubButton from "./sidebar-menu-sub-button.svelte"; +import MenuSubItem from "./sidebar-menu-sub-item.svelte"; +import MenuSub from "./sidebar-menu-sub.svelte"; +import Menu from "./sidebar-menu.svelte"; +import Provider from "./sidebar-provider.svelte"; +import Rail from "./sidebar-rail.svelte"; +import Separator from "./sidebar-separator.svelte"; +import Trigger from "./sidebar-trigger.svelte"; +import Root from "./sidebar.svelte"; + +export { + Content, + Footer, + Group, + GroupAction, + GroupContent, + GroupLabel, + Header, + Input, + Inset, + Menu, + MenuAction, + MenuBadge, + MenuButton, + MenuItem, + MenuSkeleton, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + Separator, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupAction as SidebarGroupAction, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Input as SidebarInput, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuAction as SidebarMenuAction, + MenuBadge as SidebarMenuBadge, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSkeleton as SidebarMenuSkeleton, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + Separator as SidebarSeparator, + Trigger as SidebarTrigger, + Trigger, + useSidebar, +}; diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-content.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-content.svelte new file mode 100644 index 00000000..f1218002 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-content.svelte @@ -0,0 +1,24 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte new file mode 100644 index 00000000..6259cb95 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-group-action.svelte new file mode 100644 index 00000000..fb84e4a2 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-group-action.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-group-content.svelte new file mode 100644 index 00000000..415255f1 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-group-label.svelte new file mode 100644 index 00000000..e292945b --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
    + {@render children?.()} +
    +{/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-group.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-group.svelte new file mode 100644 index 00000000..ec18a697 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-group.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-header.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-header.svelte new file mode 100644 index 00000000..a1b2db15 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-header.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-input.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-input.svelte new file mode 100644 index 00000000..19b36660 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-input.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte new file mode 100644 index 00000000..d862761d --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -0,0 +1,24 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-action.svelte new file mode 100644 index 00000000..fa3fb0cd --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-action.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte new file mode 100644 index 00000000..69e5a3cb --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte @@ -0,0 +1,29 @@ + + +
    + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte new file mode 100644 index 00000000..4bef683f --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -0,0 +1,103 @@ + + + + +{#snippet Button({ props }: { props?: Record })} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} +{/snippet} + +{#if !tooltipContent} + {@render Button({})} +{:else} + + + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + + + +{/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-item.svelte new file mode 100644 index 00000000..4db44532 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte new file mode 100644 index 00000000..cc63b04d --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte @@ -0,0 +1,36 @@ + + +
    + {#if showIcon} + + {/if} + + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte new file mode 100644 index 00000000..987f104d --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte new file mode 100644 index 00000000..681d0f1d --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte new file mode 100644 index 00000000..8ab11110 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -0,0 +1,25 @@ + + +
      + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte new file mode 100644 index 00000000..946cccef --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte @@ -0,0 +1,21 @@ + + +
      + {@render children?.()} +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-provider.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-provider.svelte new file mode 100644 index 00000000..5b0d0aa2 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-provider.svelte @@ -0,0 +1,53 @@ + + + + + +
    + {@render children?.()} +
    +
    diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte new file mode 100644 index 00000000..c180cf59 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-separator.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-separator.svelte new file mode 100644 index 00000000..5a7dedac --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-separator.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-trigger.svelte new file mode 100644 index 00000000..18251827 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/lib/components/ui/sidebar/sidebar.svelte b/frontend/src/lib/components/ui/sidebar/sidebar.svelte new file mode 100644 index 00000000..3e9eba99 --- /dev/null +++ b/frontend/src/lib/components/ui/sidebar/sidebar.svelte @@ -0,0 +1,104 @@ + + +{#if collapsible === "none"} +
    + {@render children?.()} +
    +{:else if sidebar.isMobile} + sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} + {...restProps} + > + + + Sidebar + Displays the mobile sidebar. + +
    + {@render children?.()} +
    +
    +
    +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/skeleton/index.ts b/frontend/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 00000000..186db219 --- /dev/null +++ b/frontend/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/frontend/src/lib/components/ui/skeleton/skeleton.svelte b/frontend/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 00000000..c7e3d26c --- /dev/null +++ b/frontend/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
    diff --git a/frontend/src/lib/components/ui/tooltip/index.ts b/frontend/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 00000000..313a7f06 --- /dev/null +++ b/frontend/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,21 @@ +import { Tooltip as TooltipPrimitive } from "bits-ui"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; + +const Root = TooltipPrimitive.Root; +const Provider = TooltipPrimitive.Provider; +const Portal = TooltipPrimitive.Portal; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte b/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 00000000..e495efe5 --- /dev/null +++ b/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,47 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
    + {/snippet} +
    +
    +
    diff --git a/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 00000000..1acdaa47 --- /dev/null +++ b/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/hooks/is-mobile.svelte.ts b/frontend/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 00000000..4829c00b --- /dev/null +++ b/frontend/src/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,9 @@ +import { MediaQuery } from "svelte/reactivity"; + +const DEFAULT_MOBILE_BREAKPOINT = 768; + +export class IsMobile extends MediaQuery { + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`); + } +} diff --git a/frontend/src/lib/sso.ts b/frontend/src/lib/sso.ts new file mode 100644 index 00000000..15b5c132 --- /dev/null +++ b/frontend/src/lib/sso.ts @@ -0,0 +1,145 @@ +/** + * Servicio de Single Sign-On (SSO) con proveedores externos + */ +import { browser } from '$app/environment'; + +// Tipos de proveedores SSO soportados +export type SSOProvider = 'microsoft' | 'google' | 'github'; + +/** + * Inicia el flujo de autenticación con un proveedor SSO + * @param provider - El proveedor SSO a utilizar + */ +export const loginWithProvider = async (provider: SSOProvider): Promise => { + if (!browser) { + console.warn('loginWithProvider solo funciona en el navegador'); + return; + } + + try { + // Construir la URL de redirección al proveedor SSO + const keycloakUrl = import.meta.env.VITE_KEYCLOAK_URL; + const realm = import.meta.env.VITE_KEYCLOAK_REALM; + const clientId = import.meta.env.VITE_KEYCLOAK_CLIENT_ID; + + // Validar que las variables de entorno estén configuradas + if (!keycloakUrl || !realm || !clientId) { + const missing = []; + if (!keycloakUrl) missing.push('VITE_KEYCLOAK_URL'); + if (!realm) missing.push('VITE_KEYCLOAK_REALM'); + if (!clientId) missing.push('VITE_KEYCLOAK_CLIENT_ID'); + + const errorMsg = `Configuración de Keycloak incompleta. Faltan las siguientes variables de entorno: ${missing.join(', ')}. Por favor, verifica tu archivo .env y reinicia el servidor de desarrollo.`; + console.error(errorMsg); + alert(errorMsg); + throw new Error(errorMsg); + } + + const redirectUri = encodeURIComponent(window.location.origin + '/auth/callback'); + + // URL de login de Keycloak con el provider específico + const loginUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid&kc_idp_hint=${provider}`; + + console.log('🔐 Iniciando login con', provider); + console.log('📍 URL de Keycloak:', keycloakUrl); + console.log('🏰 Realm:', realm); + console.log('🔑 Client ID:', clientId); + + // Redirigir al usuario al proveedor SSO + window.location.href = loginUrl; + } catch (error) { + console.error(`Error al iniciar login con ${provider}:`, error); + throw error; + } +}; + +/** + * Obtiene la lista de proveedores SSO disponibles + * Esta función podría consultar a Keycloak para obtener los providers configurados + */ +export const getAvailableProviders = async (): Promise => { + // Por ahora retornamos una lista estática + // En producción, esto debería consultarse desde Keycloak + return ['microsoft', 'google', 'github']; +}; + +/** + * Obtiene la configuración de visualización para un proveedor + */ +export const getProviderConfig = (provider: SSOProvider) => { + const configs = { + microsoft: { + name: 'Microsoft', + icon: '🪟', + color: 'bg-blue-600 hover:bg-blue-700' + }, + google: { + name: 'Google', + icon: '🔍', + color: 'bg-red-600 hover:bg-red-700' + }, + github: { + name: 'GitHub', + icon: '🐙', + color: 'bg-gray-800 hover:bg-gray-900' + } + }; + + return configs[provider]; +}; + +/** + * Intercambia el código de autorización por tokens + */ +export const exchangeCodeForTokens = async ( + code: string, + redirectUri: string +): Promise<{ access_token: string; refresh_token: string; id_token?: string }> => { + try { + const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/'; + const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL : `${API_BASE_URL}/`; + + const response = await fetch(`${baseUrl}v1/auth/exchange-code`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + code, + redirect_uri: redirectUri + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Error intercambiando código por tokens'); + } + + return await response.json(); + } catch (error) { + console.error('Error en exchangeCodeForTokens:', error); + throw error; + } +}; + +/** + * Decodifica un JWT (sin verificar la firma) + * NOTA: Esta es una decodificación simple para obtener los claims. + * La verificación de la firma debe hacerse en el backend. + */ +export const decodeJWT = (token: string): any => { + try { + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('Token JWT inválido'); + } + + // Decodificar la parte del payload (segunda parte) + const payload = parts[1]; + const decodedPayload = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + return JSON.parse(decodedPayload); + } catch (error) { + console.error('Error decodificando JWT:', error); + return null; + } +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 00000000..55b3a918 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 489c6762..d77165d0 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,15 +1,13 @@ @@ -23,7 +21,9 @@ {:else}
    -
    +

    Cargando Anexo76...

    diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts new file mode 100644 index 00000000..d237e4e6 --- /dev/null +++ b/frontend/src/routes/+page.server.ts @@ -0,0 +1,14 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies }) => { + const token = cookies.get('access_token'); + + // Si está autenticado, redirigir al dashboard + if (token) { + throw redirect(303, '/dashboard'); + } + + // Si no está autenticado, redirigir al login + throw redirect(303, '/login'); +}; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 2e82cdf7..773d54ad 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -70,7 +70,6 @@
    - {#if !$isAuthenticated}
    @@ -122,108 +121,6 @@
    - {:else} - -
    -
    -

    Dashboard

    -

    - Bienvenido, {$currentUser?.name || $currentUser?.username} -

    -
    - - - {#if licenseInfo} -
    -

    Información de Licencia

    -
    -
    -

    Plan

    -

    - {licenseInfo.plan} -

    -
    -
    -

    Estado

    -

    - - {licenseInfo.status} - -

    -
    -
    -

    Usuarios máximos

    -

    - {licenseInfo.max_users} -

    -
    -
    -

    Expira

    -

    - {new Date(licenseInfo.expires_at).toLocaleDateString('es-MX')} -

    -
    -
    -
    - {:else if loadingLicense} -
    -

    Cargando información de licencia...

    -
    - {/if} - - -
    -

    Acciones Rápidas

    -
    - - - -
    -
    - - -
    -

    Información de Usuario

    -
    -
    -
    ID de Usuario:
    -
    {$currentUser?.id}
    -
    -
    -
    Usuario:
    -
    {$currentUser?.username}
    -
    -
    -
    Email:
    -
    {$currentUser?.email || 'N/A'}
    -
    -
    -
    Tenant ID:
    -
    {$currentUser?.tenantId || 'N/A'}
    -
    -
    -
    Roles:
    -
    - {#if $currentUser?.roles && $currentUser.roles.length > 0} - {$currentUser.roles.join(', ')} - {:else} - N/A - {/if} -
    -
    -
    -
    -
    - {/if}
    diff --git a/frontend/src/routes/auth/callback/+page.server.ts b/frontend/src/routes/auth/callback/+page.server.ts new file mode 100644 index 00000000..a0b8b00d --- /dev/null +++ b/frontend/src/routes/auth/callback/+page.server.ts @@ -0,0 +1,112 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url, cookies }) => { + // Obtener el código y state de los query params + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const errorParam = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + console.log('🔄 [Callback Server] Procesando callback de autenticación'); + console.log('📝 [Callback Server] Código recibido:', code ? 'Sí' : 'No'); + console.log('📝 [Callback Server] State recibido:', state); + + if (errorParam) { + console.error('❌ [Callback Server] Error en autenticación:', errorParam, errorDescription); + throw redirect(303, `/login?error=${encodeURIComponent(errorDescription || errorParam)}`); + } + + if (!code) { + console.error('❌ [Callback Server] No se recibió código de autorización'); + throw redirect(303, '/login?error=No se recibió código de autorización'); + } + + try { + // Intercambiar código por tokens usando el backend de Keycloak + // En el servidor (SSR), usar KEYCLOAK_URL que apunta a http://keycloak:8080 + // En producción o fuera de Docker, usar VITE_KEYCLOAK_URL como fallback + const KEYCLOAK_URL = process.env.KEYCLOAK_URL || process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080'; + const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || process.env.VITE_KEYCLOAK_REALM || 'master'; + const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'anexo76-backend'; + const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || ''; + + // La redirect_uri debe coincidir exactamente con la registrada en Keycloak + const redirectUri = `${url.origin}/auth/callback`; + + console.log('🔄 [Callback Server] Intercambiando código por tokens...'); + console.log('📍 [Callback Server] Keycloak URL:', KEYCLOAK_URL); + console.log('📍 [Callback Server] Redirect URI:', redirectUri); + + const tokenEndpoint = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`; + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + redirect_uri: redirectUri, + client_id: KEYCLOAK_CLIENT_ID, + ...(KEYCLOAK_CLIENT_SECRET && { client_secret: KEYCLOAK_CLIENT_SECRET }) + }); + + const tokenResponse = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: body.toString() + }); + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.text(); + console.error('❌ [Callback Server] Error al intercambiar código:', errorData); + throw new Error('Error al obtener tokens'); + } + + const tokens = await tokenResponse.json(); + console.log('✅ [Callback Server] Tokens recibidos exitosamente'); + + // Establecer las cookies en el servidor (esto es lo importante) + // Las cookies deben ser HttpOnly y Secure en producción + const isProduction = process.env.NODE_ENV === 'production'; + + cookies.set('access_token', tokens.access_token, { + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7 // 7 días + }); + + if (tokens.refresh_token) { + cookies.set('refresh_token', tokens.refresh_token, { + path: '/', + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30 // 30 días + }); + } + + console.log('✅ [Callback Server] Cookies establecidas exitosamente'); + + // Obtener la URL de redirección del state o ir al dashboard + let redirectTo = '/dashboard'; + if (state) { + try { + const stateObj = JSON.parse(state); + redirectTo = stateObj.redirect_url || '/dashboard'; + } catch (e) { + console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state'); + } + } + + console.log('🚀 [Callback Server] Redirigiendo a:', redirectTo); + + // Redirigir a la página de destino + throw redirect(303, redirectTo); + + } catch (err: any) { + console.error('❌ [Callback Server] Error procesando autenticación:', err); + throw redirect(303, `/login?error=${encodeURIComponent(err.message || 'Error procesando autenticación')}`); + } +}; diff --git a/frontend/src/routes/auth/callback/+page.svelte b/frontend/src/routes/auth/callback/+page.svelte index 775c674b..9a6a0d98 100644 --- a/frontend/src/routes/auth/callback/+page.svelte +++ b/frontend/src/routes/auth/callback/+page.svelte @@ -1,140 +1,27 @@
    -
    -

    - {#if processing} - Procesando autenticación... - {:else} - Error de autenticación - {/if} +
    +

    + Procesando autenticación...

    -
    - - {#if processing} +
    -

    - Espera un momento mientras completamos tu inicio de sesión con Microsoft... +

    + Espera un momento mientras completamos tu inicio de sesión...

    - {:else if error} -
    -
    -
    - - - -
    -
    -

    - {error} -

    -
    -
    -
    - -
    - -
    - {/if} +

    diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts new file mode 100644 index 00000000..b95e0ad3 --- /dev/null +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -0,0 +1,59 @@ +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { + // Verificar si existe el token en las cookies + const token = cookies.get('access_token'); + + // Si no hay token, redirigir al login + if (!token) { + // Guardar la URL a la que intentaba acceder para redirigir después del login + throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`); + } + + // Validar el token con el backend para asegurar que sea válido + try { + // En Docker, el servidor debe usar el nombre del servicio 'backend' en lugar de 'localhost' + // VITE_API_URL ya incluye '/api/' al final (ej: http://localhost:8000/api/) + let apiUrl = process.env.INTERNAL_API_URL; + if (!apiUrl) { + apiUrl = import.meta.env.VITE_API_URL; + // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); + } + + // Normalizar la URL: asegurar que termine con '/' + const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; + + console.log('🔐 [Dashboard] Validando token con:', `${baseUrl}v1/auth/me`); + + const response = await fetch(`${baseUrl}v1/auth/me`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + // Token inválido, limpiar y redirigir + cookies.delete('access_token', { path: '/' }); + throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`); + } + + const userData = await response.json(); + + return { + authenticated: true, + user: userData + }; + } catch (error) { + // Si es un redirect, re-lanzarlo sin tocar las cookies + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + + // Para cualquier otro error (conexión, etc), limpiar token y redirigir + console.error('🔐 [Dashboard] Error validando token:', error); + cookies.delete('access_token', { path: '/' }); + throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`); + } +}; diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte new file mode 100644 index 00000000..8b96419d --- /dev/null +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -0,0 +1,11 @@ + + +{@render children()} diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte new file mode 100644 index 00000000..a0031a88 --- /dev/null +++ b/frontend/src/routes/dashboard/+page.svelte @@ -0,0 +1,39 @@ + + + + + +
    +
    + + + + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    diff --git a/frontend/src/routes/dashboard/reference_data/+page.svelte b/frontend/src/routes/dashboard/reference_data/+page.svelte new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/routes/dashboard/reference_data/code_pedimento_regimens/+page.svelte b/frontend/src/routes/dashboard/reference_data/code_pedimento_regimens/+page.svelte new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts new file mode 100644 index 00000000..4b880e08 --- /dev/null +++ b/frontend/src/routes/login/+page.server.ts @@ -0,0 +1,100 @@ +import { redirect, fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies, url }) => { + const token = cookies.get('access_token'); + + // Si hay un parámetro 'logout' en la URL, limpiar las cookies + if (url.searchParams.has('logout')) { + cookies.delete('access_token', { path: '/' }); + return {}; + } + + // Si está autenticado, redirigir al dashboard + if (token) { + throw redirect(303, '/dashboard'); + } + + // Si no está autenticado, permitir acceso al login + return {}; +}; + +export const actions = { + default: async ({ request, cookies, url }) => { + const data = await request.formData(); + const username = data.get('username')?.toString(); + const password = data.get('password')?.toString(); + const tenant_slug = data.get('tenant_slug')?.toString(); + + if (!username || !password || !tenant_slug) { + return fail(400, { error: 'Todos los campos son requeridos' }); + } + + try { + // En Docker, el servidor debe usar el nombre del servicio 'backend' en lugar de 'localhost' + // Intentar primero con INTERNAL_API_URL, luego VITE_API_URL modificado + let apiUrl = process.env.INTERNAL_API_URL; + + if (!apiUrl) { + // Si no hay INTERNAL_API_URL, modificar VITE_API_URL para Docker + apiUrl = import.meta.env.VITE_API_URL; + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); + } + + const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; + const loginUrl = `${baseUrl}v1/auth/login`; + + const requestBody = { + username, + password, + tenant_slug + }; + + const response = await fetch(loginUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }); + + const result = await response.json(); + + if (!response.ok) { + return fail(response.status, { + error: result.detail || 'Error de autenticación', + username, + tenant_slug + }); + } + + if (result.access_token) { + // Establecer cookie en el servidor + cookies.set('access_token', result.access_token, { + path: '/', + httpOnly: false, // Permitir acceso desde JavaScript + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7 // 7 días + }); + + // Redirigir al dashboard o a la URL original + const redirectUrl = url.searchParams.get('redirect') || '/dashboard'; + throw redirect(303, redirectUrl); + } + + return fail(500, { error: 'No se recibió token de autenticación' }); + } catch (error) { + // Si es un redirect de SvelteKit, re-lanzarlo + if (error && typeof error === 'object' && 'status' in error && 'location' in error) { + throw error; + } + + return fail(500, { + error: 'Error de conexión con el servidor: ' + (error instanceof Error ? error.message : String(error)), + username, + tenant_slug + }); + } + } +} satisfies Actions; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 9ece5850..889456cb 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -1,165 +1,9 @@ -
    -
    -
    -

    - Anexo76 -

    -

    - Inicia sesión en tu cuenta -

    -
    - -
    - {#if error} -
    -
    -
    -

    - {error} -

    -
    -
    -
    - {/if} - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - -
    - -
    - -
    -

    - Credenciales de prueba:
    - Usuario: demo | Contraseña: demo123 | Tenant: aduanasoft -

    -
    -
    - - {#if showSSOProviders} -
    -
    -
    -
    -
    -
    - O continúa con -
    -
    - -
    - - - {#if !tenantSlug} -

    - Selecciona un tenant antes de usar login social -

    - {/if} -
    -
    - {/if} +
    +
    +
    diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts new file mode 100644 index 00000000..c18a53d2 --- /dev/null +++ b/frontend/src/routes/logout/+server.ts @@ -0,0 +1,11 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ cookies }) => { + // Eliminar todas las cookies de autenticación + cookies.delete('access_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + + // Redirigir al login + throw redirect(303, '/login'); +}; diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt deleted file mode 100644 index b6dd6670..00000000 --- a/frontend/static/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# allow crawling everything by default -User-agent: * -Disallow: diff --git a/frontend/static/silent-check-sso.html b/frontend/static/silent-check-sso.html deleted file mode 100644 index efe8698a..00000000 --- a/frontend/static/silent-check-sso.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - Silent SSO Check - - - - - diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f1e73556..13f94b7a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,8 +5,8 @@ import { sveltekit } from '@sveltejs/kit/vite'; export default defineConfig({ server: { - port: 5180, // fija el puerto - host: true, // escucha en 0.0.0.0 + port: 5180, // fija el puerto + host: true // escucha en 0.0.0.0 }, plugins: [ tailwindcss(), diff --git a/scripts/frontend-entrypoint.sh b/scripts/frontend-entrypoint.sh index 5b0da3ef..bb2f0a33 100755 --- a/scripts/frontend-entrypoint.sh +++ b/scripts/frontend-entrypoint.sh @@ -32,7 +32,7 @@ wait_for_backend() { } # Esperar al backend -# En Docker, usamos el nombre del servicio. En desarrollo local, PUBLIC_API_URL apunta a localhost +# En Docker, usamos el nombre del servicio. En desarrollo local, VITE_API_URL apunta a localhost BACKEND_HEALTH_URL="${BACKEND_INTERNAL_URL:-http://backend:8000/api}" wait_for_backend "${BACKEND_HEALTH_URL}" diff --git a/start.sh b/start.sh index bae90b4f..61308fda 100755 --- a/start.sh +++ b/start.sh @@ -80,8 +80,8 @@ KEYCLOAK_FRONTEND_CLIENT_ID=anexo76-frontend DEBUG=True ENVIRONMENT=development NODE_ENV=development -PUBLIC_API_URL=http://localhost:8000/api -PUBLIC_KEYCLOAK_URL=http://localhost:8080 +VITE_API_URL=http://localhost:8000/api +VITE_KEYCLOAK_URL=http://localhost:8080 EOF echo -e "${GREEN}✓ Archivo .env creado con valores por defecto${NC}" fi