Spaces:
Runtime error
Runtime error
Add HF Spaces deployment config and passkey auth
Browse files- Update Dockerfile to use $PORT env var (default 7860 for HF Spaces)
- Add HF Spaces README.md with Docker SDK metadata
- Add passkey authentication gate (backend + frontend)
- Update frontend components
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Dockerfile +3 -2
- README.md +8 -0
- backend/api/auth.py +59 -0
- backend/main.py +28 -3
- frontend/.gitignore +24 -0
- frontend/README.md +73 -0
- frontend/src/App.tsx +19 -0
- frontend/src/components/results/FlightCard.tsx +27 -28
- frontend/src/components/shared/PasskeyGate.tsx +73 -0
Dockerfile
CHANGED
|
@@ -21,6 +21,7 @@ COPY airline_routes.json ./
|
|
| 21 |
# Copy built frontend
|
| 22 |
COPY --from=frontend-build /app/frontend/dist ./frontend/dist
|
| 23 |
|
| 24 |
-
|
|
|
|
| 25 |
|
| 26 |
-
CMD
|
|
|
|
| 21 |
# Copy built frontend
|
| 22 |
COPY --from=frontend-build /app/frontend/dist ./frontend/dist
|
| 23 |
|
| 24 |
+
ENV PORT=7860
|
| 25 |
+
EXPOSE $PORT
|
| 26 |
|
| 27 |
+
CMD uvicorn backend.main:app --host 0.0.0.0 --port $PORT
|
README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Flight Search
|
| 3 |
+
emoji: ✈️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
backend/api/auth.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication endpoints for passkey gate."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import secrets
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Request, Response
|
| 7 |
+
from fastapi.responses import JSONResponse
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
| 11 |
+
|
| 12 |
+
PASSKEY = "flyingagents"
|
| 13 |
+
# Token written into the HTTP-only cookie after successful verification.
|
| 14 |
+
AUTH_TOKEN = secrets.token_hex(32)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _require_passkey() -> bool:
|
| 18 |
+
"""Return True unless REQUIRE_PASSKEY is explicitly set to 'false'."""
|
| 19 |
+
return os.environ.get("REQUIRE_PASSKEY", "true").lower() != "false"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PasskeyBody(BaseModel):
|
| 23 |
+
passkey: str
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.post("/verify")
|
| 27 |
+
async def verify(body: PasskeyBody, response: Response):
|
| 28 |
+
if not _require_passkey():
|
| 29 |
+
response.set_cookie(
|
| 30 |
+
key="flight_auth",
|
| 31 |
+
value=AUTH_TOKEN,
|
| 32 |
+
httponly=True,
|
| 33 |
+
samesite="lax",
|
| 34 |
+
max_age=60 * 60 * 24 * 30, # 30 days
|
| 35 |
+
)
|
| 36 |
+
return {"ok": True}
|
| 37 |
+
|
| 38 |
+
if body.passkey != PASSKEY:
|
| 39 |
+
return JSONResponse(status_code=401, content={"detail": "Invalid passkey"})
|
| 40 |
+
|
| 41 |
+
response.set_cookie(
|
| 42 |
+
key="flight_auth",
|
| 43 |
+
value=AUTH_TOKEN,
|
| 44 |
+
httponly=True,
|
| 45 |
+
samesite="lax",
|
| 46 |
+
max_age=60 * 60 * 24 * 30, # 30 days
|
| 47 |
+
)
|
| 48 |
+
return {"ok": True}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.get("/check")
|
| 52 |
+
async def check(request: Request):
|
| 53 |
+
if not _require_passkey():
|
| 54 |
+
return {"ok": True}
|
| 55 |
+
|
| 56 |
+
token = request.cookies.get("flight_auth")
|
| 57 |
+
if token != AUTH_TOKEN:
|
| 58 |
+
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
| 59 |
+
return {"ok": True}
|
backend/main.py
CHANGED
|
@@ -3,12 +3,13 @@
|
|
| 3 |
import os
|
| 4 |
import time
|
| 5 |
|
| 6 |
-
from fastapi import FastAPI
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from fastapi.staticfiles import StaticFiles
|
| 9 |
-
from fastapi.responses import FileResponse
|
|
|
|
| 10 |
|
| 11 |
-
from .api import airports, calendar, search
|
| 12 |
from .data_loader import get_route_graph
|
| 13 |
from .hub_detector import compute_hub_scores
|
| 14 |
|
|
@@ -23,7 +24,31 @@ app.add_middleware(
|
|
| 23 |
allow_headers=["*"],
|
| 24 |
)
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
# Register API routers
|
|
|
|
| 27 |
app.include_router(airports.router)
|
| 28 |
app.include_router(search.router)
|
| 29 |
app.include_router(calendar.router)
|
|
|
|
| 3 |
import os
|
| 4 |
import time
|
| 5 |
|
| 6 |
+
from fastapi import FastAPI, Request
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from fastapi.staticfiles import StaticFiles
|
| 9 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 10 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 11 |
|
| 12 |
+
from .api import airports, auth, calendar, search
|
| 13 |
from .data_loader import get_route_graph
|
| 14 |
from .hub_detector import compute_hub_scores
|
| 15 |
|
|
|
|
| 24 |
allow_headers=["*"],
|
| 25 |
)
|
| 26 |
|
| 27 |
+
# Passkey middleware — protects /api/* routes when REQUIRE_PASSKEY is enabled
|
| 28 |
+
class PasskeyMiddleware(BaseHTTPMiddleware):
|
| 29 |
+
EXEMPT_PREFIXES = ("/api/auth/", "/api/health")
|
| 30 |
+
|
| 31 |
+
async def dispatch(self, request: Request, call_next):
|
| 32 |
+
if not auth._require_passkey():
|
| 33 |
+
return await call_next(request)
|
| 34 |
+
|
| 35 |
+
path = request.url.path
|
| 36 |
+
if path.startswith("/api/") and not any(
|
| 37 |
+
path.startswith(p) for p in self.EXEMPT_PREFIXES
|
| 38 |
+
):
|
| 39 |
+
token = request.cookies.get("flight_auth")
|
| 40 |
+
if token != auth.AUTH_TOKEN:
|
| 41 |
+
return JSONResponse(
|
| 42 |
+
status_code=401, content={"detail": "Not authenticated"}
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
return await call_next(request)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
app.add_middleware(PasskeyMiddleware)
|
| 49 |
+
|
| 50 |
# Register API routers
|
| 51 |
+
app.include_router(auth.router)
|
| 52 |
app.include_router(airports.router)
|
| 53 |
app.include_router(search.router)
|
| 54 |
app.include_router(calendar.router)
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
+
|
| 18 |
+
```js
|
| 19 |
+
export default defineConfig([
|
| 20 |
+
globalIgnores(['dist']),
|
| 21 |
+
{
|
| 22 |
+
files: ['**/*.{ts,tsx}'],
|
| 23 |
+
extends: [
|
| 24 |
+
// Other configs...
|
| 25 |
+
|
| 26 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
+
tseslint.configs.recommendedTypeChecked,
|
| 28 |
+
// Alternatively, use this for stricter rules
|
| 29 |
+
tseslint.configs.strictTypeChecked,
|
| 30 |
+
// Optionally, add this for stylistic rules
|
| 31 |
+
tseslint.configs.stylisticTypeChecked,
|
| 32 |
+
|
| 33 |
+
// Other configs...
|
| 34 |
+
],
|
| 35 |
+
languageOptions: {
|
| 36 |
+
parserOptions: {
|
| 37 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
+
tsconfigRootDir: import.meta.dirname,
|
| 39 |
+
},
|
| 40 |
+
// other options...
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
])
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
+
|
| 48 |
+
```js
|
| 49 |
+
// eslint.config.js
|
| 50 |
+
import reactX from 'eslint-plugin-react-x'
|
| 51 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
+
|
| 53 |
+
export default defineConfig([
|
| 54 |
+
globalIgnores(['dist']),
|
| 55 |
+
{
|
| 56 |
+
files: ['**/*.{ts,tsx}'],
|
| 57 |
+
extends: [
|
| 58 |
+
// Other configs...
|
| 59 |
+
// Enable lint rules for React
|
| 60 |
+
reactX.configs['recommended-typescript'],
|
| 61 |
+
// Enable lint rules for React DOM
|
| 62 |
+
reactDom.configs.recommended,
|
| 63 |
+
],
|
| 64 |
+
languageOptions: {
|
| 65 |
+
parserOptions: {
|
| 66 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
+
tsconfigRootDir: import.meta.dirname,
|
| 68 |
+
},
|
| 69 |
+
// other options...
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
])
|
| 73 |
+
```
|
frontend/src/App.tsx
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
|
|
| 1 |
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
| 2 |
import Header from './components/shared/Header';
|
|
|
|
| 3 |
import SearchPage from './pages/SearchPage';
|
| 4 |
import ResultsPage from './pages/ResultsPage';
|
| 5 |
|
| 6 |
export default function App() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
return (
|
| 8 |
<BrowserRouter>
|
| 9 |
<Header />
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
| 3 |
import Header from './components/shared/Header';
|
| 4 |
+
import PasskeyGate from './components/shared/PasskeyGate';
|
| 5 |
import SearchPage from './pages/SearchPage';
|
| 6 |
import ResultsPage from './pages/ResultsPage';
|
| 7 |
|
| 8 |
export default function App() {
|
| 9 |
+
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
| 10 |
+
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
fetch('/api/auth/check')
|
| 13 |
+
.then((res) => setAuthenticated(res.ok))
|
| 14 |
+
.catch(() => setAuthenticated(false));
|
| 15 |
+
}, []);
|
| 16 |
+
|
| 17 |
+
// Still checking auth status
|
| 18 |
+
if (authenticated === null) {
|
| 19 |
+
return null;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
if (!authenticated) {
|
| 23 |
+
return <PasskeyGate onAuthenticated={() => setAuthenticated(true)} />;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
return (
|
| 27 |
<BrowserRouter>
|
| 28 |
<Header />
|
frontend/src/components/results/FlightCard.tsx
CHANGED
|
@@ -78,22 +78,33 @@ export default function FlightCard({ flight, roundTripPrice, priceLabel, onSelec
|
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
|
| 81 |
-
{/* Price column */}
|
| 82 |
-
<div className="
|
| 83 |
-
{
|
| 84 |
-
<
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
<div className="text-[11px] text-gray-500">{flight.cabin_class.replace('_', ' ')}</div>
|
| 92 |
-
</>
|
| 93 |
-
)}
|
| 94 |
-
{discountApplied && (
|
| 95 |
-
<div className="text-[10px] text-green-600 mt-0.5">Airline discount</div>
|
| 96 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
</div>
|
| 98 |
|
| 99 |
{/* Chevron */}
|
|
@@ -105,19 +116,6 @@ export default function FlightCard({ flight, roundTripPrice, priceLabel, onSelec
|
|
| 105 |
</svg>
|
| 106 |
</div>
|
| 107 |
|
| 108 |
-
{/* Select flight button */}
|
| 109 |
-
{onSelect && (
|
| 110 |
-
<div className="px-4 pb-3 -mt-1">
|
| 111 |
-
<button
|
| 112 |
-
onClick={(e) => { e.stopPropagation(); onSelect(flight); }}
|
| 113 |
-
className="w-full rounded-full bg-[#1a73e8] px-4 py-2 text-sm font-medium text-white hover:bg-[#1557b0] cursor-pointer"
|
| 114 |
-
data-testid="select-flight-btn"
|
| 115 |
-
>
|
| 116 |
-
Select flight
|
| 117 |
-
</button>
|
| 118 |
-
</div>
|
| 119 |
-
)}
|
| 120 |
-
|
| 121 |
{/* Expanded detail view */}
|
| 122 |
{expanded && (
|
| 123 |
<div className="border-t border-gray-100 px-4 pt-3 pb-4" data-testid="segments-detail">
|
|
@@ -262,6 +260,7 @@ export default function FlightCard({ flight, roundTripPrice, priceLabel, onSelec
|
|
| 262 |
</>
|
| 263 |
)}
|
| 264 |
</div>
|
|
|
|
| 265 |
</div>
|
| 266 |
)}
|
| 267 |
</div>
|
|
|
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
|
| 81 |
+
{/* Select button + Price column */}
|
| 82 |
+
<div className="flex items-center gap-3 pl-3">
|
| 83 |
+
{onSelect && expanded && (
|
| 84 |
+
<button
|
| 85 |
+
onClick={(e) => { e.stopPropagation(); onSelect(flight); }}
|
| 86 |
+
className="rounded-full bg-[#1a73e8] px-4 py-1.5 text-xs font-medium text-white hover:bg-[#1557b0] cursor-pointer whitespace-nowrap"
|
| 87 |
+
data-testid="select-flight-btn"
|
| 88 |
+
>
|
| 89 |
+
Select flight
|
| 90 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
)}
|
| 92 |
+
<div className="text-right min-w-[80px]" data-testid="price">
|
| 93 |
+
{roundTripPrice != null ? (
|
| 94 |
+
<>
|
| 95 |
+
<div className="text-[15px] font-medium text-gray-900">{formatPrice(roundTripPrice)}</div>
|
| 96 |
+
<div className="text-[11px] text-gray-500">{priceLabel || 'round trip'}</div>
|
| 97 |
+
</>
|
| 98 |
+
) : (
|
| 99 |
+
<>
|
| 100 |
+
<div className="text-[15px] font-medium text-gray-900">{formatPrice(flight.price_usd)}</div>
|
| 101 |
+
<div className="text-[11px] text-gray-500">{flight.cabin_class.replace('_', ' ')}</div>
|
| 102 |
+
</>
|
| 103 |
+
)}
|
| 104 |
+
{discountApplied && (
|
| 105 |
+
<div className="text-[10px] text-green-600 mt-0.5">Airline discount</div>
|
| 106 |
+
)}
|
| 107 |
+
</div>
|
| 108 |
</div>
|
| 109 |
|
| 110 |
{/* Chevron */}
|
|
|
|
| 116 |
</svg>
|
| 117 |
</div>
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
{/* Expanded detail view */}
|
| 120 |
{expanded && (
|
| 121 |
<div className="border-t border-gray-100 px-4 pt-3 pb-4" data-testid="segments-detail">
|
|
|
|
| 260 |
</>
|
| 261 |
)}
|
| 262 |
</div>
|
| 263 |
+
|
| 264 |
</div>
|
| 265 |
)}
|
| 266 |
</div>
|
frontend/src/components/shared/PasskeyGate.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, type FormEvent } from 'react';
|
| 2 |
+
|
| 3 |
+
interface Props {
|
| 4 |
+
onAuthenticated: () => void;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export default function PasskeyGate({ onAuthenticated }: Props) {
|
| 8 |
+
const [passkey, setPasskey] = useState('');
|
| 9 |
+
const [error, setError] = useState('');
|
| 10 |
+
const [loading, setLoading] = useState(false);
|
| 11 |
+
|
| 12 |
+
async function handleSubmit(e: FormEvent) {
|
| 13 |
+
e.preventDefault();
|
| 14 |
+
setError('');
|
| 15 |
+
setLoading(true);
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
const res = await fetch('/api/auth/verify', {
|
| 19 |
+
method: 'POST',
|
| 20 |
+
headers: { 'Content-Type': 'application/json' },
|
| 21 |
+
body: JSON.stringify({ passkey }),
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
if (res.ok) {
|
| 25 |
+
onAuthenticated();
|
| 26 |
+
} else {
|
| 27 |
+
setError('Invalid passkey');
|
| 28 |
+
setPasskey('');
|
| 29 |
+
}
|
| 30 |
+
} catch {
|
| 31 |
+
setError('Connection error. Please try again.');
|
| 32 |
+
} finally {
|
| 33 |
+
setLoading(false);
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
| 39 |
+
<div className="w-full max-w-sm mx-4">
|
| 40 |
+
<div className="text-center mb-8">
|
| 41 |
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" className="mx-auto mb-4 text-[#1a73e8]">
|
| 42 |
+
<path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" fill="currentColor"/>
|
| 43 |
+
</svg>
|
| 44 |
+
<h1 className="text-2xl font-medium text-gray-900">Flights</h1>
|
| 45 |
+
<p className="text-sm text-gray-500 mt-1">Enter passkey to continue</p>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
| 49 |
+
<input
|
| 50 |
+
type="password"
|
| 51 |
+
value={passkey}
|
| 52 |
+
onChange={(e) => setPasskey(e.target.value)}
|
| 53 |
+
placeholder="Passkey"
|
| 54 |
+
autoFocus
|
| 55 |
+
className="w-full px-4 py-3 rounded-lg border border-gray-300 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#1a73e8] focus:border-transparent"
|
| 56 |
+
/>
|
| 57 |
+
|
| 58 |
+
{error && (
|
| 59 |
+
<p className="mt-3 text-sm text-red-600">{error}</p>
|
| 60 |
+
)}
|
| 61 |
+
|
| 62 |
+
<button
|
| 63 |
+
type="submit"
|
| 64 |
+
disabled={loading || !passkey}
|
| 65 |
+
className="mt-4 w-full py-3 rounded-lg bg-[#1a73e8] text-white font-medium hover:bg-[#1557b0] disabled:opacity-50 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
| 66 |
+
>
|
| 67 |
+
{loading ? 'Verifying...' : 'Continue'}
|
| 68 |
+
</button>
|
| 69 |
+
</form>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
}
|