mirror of
https://github.com/furyhawk/agent_alpha.git
synced 2026-07-21 02:25:34 +00:00
feat(auth): implement authentication API with login, register, and token management
- Added token management functions in frontend API for storing and retrieving auth tokens. - Created authentication routes in backend for user registration, login, and profile retrieval. - Implemented JWT token creation and validation for secure user sessions. - Updated frontend API calls to include authorization headers for protected routes. - Added bcrypt dependency for password hashing in user registration.
This commit is contained in:
@@ -12,6 +12,7 @@ from uvicorn.logging import DefaultFormatter
|
||||
from backend.core.config import Settings
|
||||
from backend.core.database import close_engine, close_valkey, init_db
|
||||
from backend.core.dependencies import get_agent_service
|
||||
from backend.routes.auth import router as auth_router
|
||||
from backend.routes.chat import router as chat_router
|
||||
from backend.routes.health import router as health_router
|
||||
from backend.routes.users import router as users_router
|
||||
@@ -63,6 +64,7 @@ class AppBuilder:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(chat_router)
|
||||
app.include_router(users_router)
|
||||
|
||||
+111
-2
@@ -36,7 +36,7 @@ from datetime import datetime, timezone
|
||||
from typing import AsyncIterator
|
||||
|
||||
from redis.asyncio import Redis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
@@ -63,10 +63,21 @@ _session_factory = async_sessionmaker(
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables if they don't exist (call on startup)."""
|
||||
"""Create all tables if they don't exist, then apply missing-column migrations.
|
||||
|
||||
Safe to call repeatedly — uses ``IF NOT EXISTS`` for columns and tables.
|
||||
"""
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# ── Migrations: add columns that may be missing on existing tables ──
|
||||
migrations = [
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash VARCHAR(128)",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS team VARCHAR(100)",
|
||||
]
|
||||
for stmt in migrations:
|
||||
await conn.execute(text(stmt))
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
"""Yield an async SQLAlchemy session (for FastAPI dependency injection)."""
|
||||
@@ -222,6 +233,104 @@ async def update_user(
|
||||
return user
|
||||
|
||||
|
||||
# ── Authentication (Valkey tokens + bcrypt) ───────────────────────────────
|
||||
|
||||
_AUTH_TOKEN_KEY = "auth_token:{token}"
|
||||
_AUTH_TOKEN_TTL = 86400 * 7 # 7 days
|
||||
|
||||
|
||||
async def create_user_with_password(
|
||||
username: str,
|
||||
display_name: str,
|
||||
password: str,
|
||||
role: str = "user",
|
||||
team: str | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> User:
|
||||
"""Create a new user with a hashed password."""
|
||||
if session is None:
|
||||
async with _session_factory() as session:
|
||||
user = User(
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
team=team,
|
||||
)
|
||||
user.set_password(password)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
else:
|
||||
user = User(
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
team=team,
|
||||
)
|
||||
user.set_password(password)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def authenticate_user(
|
||||
username: str,
|
||||
password: str,
|
||||
session: AsyncSession | None = None,
|
||||
) -> User | None:
|
||||
"""Verify username/password. Returns the User on success, ``None`` on failure."""
|
||||
user = await get_user_by_username(username, session=session)
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
if user.check_password(password):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
async def create_auth_token(
|
||||
user_id: str,
|
||||
valkey: Redis | None = None,
|
||||
) -> str:
|
||||
"""Create an auth token for a user, stored in Valkey with TTL.
|
||||
|
||||
Returns the token string.
|
||||
"""
|
||||
if valkey is None:
|
||||
valkey = await get_valkey()
|
||||
|
||||
token = uuid.uuid4().hex
|
||||
key = _AUTH_TOKEN_KEY.format(token=token)
|
||||
await valkey.setex(key, _AUTH_TOKEN_TTL, user_id)
|
||||
return token
|
||||
|
||||
|
||||
async def resolve_auth_token(
|
||||
token: str,
|
||||
valkey: Redis | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve an auth token to a user_id string, or ``None`` if invalid/expired."""
|
||||
if valkey is None:
|
||||
valkey = await get_valkey()
|
||||
|
||||
key = _AUTH_TOKEN_KEY.format(token=token)
|
||||
user_id = await valkey.get(key)
|
||||
return user_id
|
||||
|
||||
|
||||
async def revoke_auth_token(
|
||||
token: str,
|
||||
valkey: Redis | None = None,
|
||||
) -> None:
|
||||
"""Delete an auth token (logout)."""
|
||||
if valkey is None:
|
||||
valkey = await get_valkey()
|
||||
|
||||
key = _AUTH_TOKEN_KEY.format(token=token)
|
||||
await valkey.delete(key)
|
||||
|
||||
|
||||
# ── Chat persistence (Valkey) ─────────────────────────────────────────────
|
||||
|
||||
_MESSAGES_KEY = "chat:{session_id}:messages"
|
||||
|
||||
+26
-1
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
@@ -15,9 +16,15 @@ class Base(DeclarativeBase):
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Application user with role-based access control.
|
||||
"""Application user with role-based access control and password auth.
|
||||
|
||||
Roles: ``admin``, ``user``, ``viewer``
|
||||
|
||||
Usage::
|
||||
|
||||
user = User(username="alice", display_name="Alice")
|
||||
user.set_password("secret123")
|
||||
assert user.check_password("secret123") is True
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
@@ -31,6 +38,9 @@ class User(Base):
|
||||
String(100), unique=True, nullable=False
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
password_hash: Mapped[str | None] = mapped_column(
|
||||
String(128), nullable=True, default=None
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="user"
|
||||
)
|
||||
@@ -52,5 +62,20 @@ class User(Base):
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
"""Hash and store the password using bcrypt."""
|
||||
self.password_hash = bcrypt.hashpw(
|
||||
password.encode("utf-8"), bcrypt.gensalt()
|
||||
).decode("utf-8")
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""Verify a password against the stored hash."""
|
||||
if self.password_hash is None:
|
||||
return False
|
||||
return bcrypt.checkpw(
|
||||
password.encode("utf-8"),
|
||||
self.password_hash.encode("utf-8"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.username!r} role={self.role!r}>"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Authentication API routes — login, register, and token management.
|
||||
|
||||
Users authenticate with username + password and receive a bearer token
|
||||
(stored in Valkey with a 7-day TTL) for subsequent requests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.core.database import (
|
||||
authenticate_user,
|
||||
create_auth_token,
|
||||
create_user_with_password,
|
||||
get_user_by_username,
|
||||
resolve_auth_token,
|
||||
revoke_auth_token,
|
||||
)
|
||||
from backend.core.dependencies import get_db_session
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
# ── Schemas ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
display_name: str
|
||||
password: str
|
||||
role: str = "user"
|
||||
team: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
token: str
|
||||
user_id: str
|
||||
username: str
|
||||
display_name: str
|
||||
role: str
|
||||
team: str | None = None
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
user_id: str
|
||||
username: str
|
||||
display_name: str
|
||||
role: str
|
||||
team: str | None = None
|
||||
|
||||
|
||||
async def _get_token_from_header(
|
||||
authorization: str | None = Header(None),
|
||||
) -> str:
|
||||
"""Extract the bearer token from the Authorization header."""
|
||||
if authorization is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing Authorization header",
|
||||
)
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer" or not token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid Authorization header. Use: Bearer <token>",
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
# ── Routes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/register", response_model=AuthResponse, status_code=201)
|
||||
async def register_endpoint(
|
||||
body: RegisterRequest,
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> AuthResponse:
|
||||
"""Register a new user with a password."""
|
||||
# Check for duplicate username.
|
||||
existing = await get_user_by_username(body.username, session=session)
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"User '{body.username}' already exists",
|
||||
)
|
||||
|
||||
user = await create_user_with_password(
|
||||
username=body.username,
|
||||
display_name=body.display_name,
|
||||
password=body.password,
|
||||
role=body.role,
|
||||
team=body.team,
|
||||
session=session,
|
||||
)
|
||||
|
||||
token = await create_auth_token(str(user.id))
|
||||
return AuthResponse(
|
||||
token=token,
|
||||
user_id=str(user.id),
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
role=user.role,
|
||||
team=user.team,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthResponse)
|
||||
async def login_endpoint(
|
||||
body: LoginRequest,
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> AuthResponse:
|
||||
"""Authenticate with username + password. Returns a bearer token."""
|
||||
user = await authenticate_user(body.username, body.password, session=session)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid username or password",
|
||||
)
|
||||
|
||||
token = await create_auth_token(str(user.id))
|
||||
return AuthResponse(
|
||||
token=token,
|
||||
user_id=str(user.id),
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
role=user.role,
|
||||
team=user.team,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me_endpoint(
|
||||
token: str = Depends(_get_token_from_header),
|
||||
) -> MeResponse:
|
||||
"""Return the current authenticated user's profile.
|
||||
|
||||
Requires ``Authorization: Bearer <token>`` header.
|
||||
"""
|
||||
from backend.core.database import get_session as _get_db
|
||||
from backend.core.database import get_user
|
||||
|
||||
user_id = await resolve_auth_token(token)
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or expired token",
|
||||
)
|
||||
|
||||
async with _get_db() as session:
|
||||
user = await get_user(uuid_obj(user_id), session=session)
|
||||
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="User not found or inactive",
|
||||
)
|
||||
|
||||
return MeResponse(
|
||||
user_id=str(user.id),
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
role=user.role,
|
||||
team=user.team,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
async def logout_endpoint(
|
||||
token: str = Depends(_get_token_from_header),
|
||||
) -> None:
|
||||
"""Revoke the current auth token (logout).
|
||||
|
||||
Requires ``Authorization: Bearer <token>`` header.
|
||||
"""
|
||||
await revoke_auth_token(token)
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def uuid_obj(value: str) -> object:
|
||||
"""Convert a string UUID to a UUID object for DB queries."""
|
||||
import uuid as _uuid
|
||||
|
||||
return _uuid.UUID(value)
|
||||
+22
-4
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.core.agent import AgentService
|
||||
@@ -16,6 +16,7 @@ from backend.core.database import (
|
||||
get_session_messages,
|
||||
get_session_user_id,
|
||||
list_sessions,
|
||||
resolve_auth_token,
|
||||
save_message,
|
||||
)
|
||||
from backend.core.dependencies import get_agent_service
|
||||
@@ -46,6 +47,20 @@ class SessionOut(BaseModel):
|
||||
user_id: str | None = None
|
||||
|
||||
|
||||
async def _resolve_user_id(
|
||||
user_id: str | None = None,
|
||||
authorization: str | None = Header(None),
|
||||
) -> str | None:
|
||||
"""Resolve the effective user_id from explicit param or auth token."""
|
||||
if user_id is not None:
|
||||
return user_id
|
||||
if authorization is not None:
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and token:
|
||||
return await resolve_auth_token(token)
|
||||
return None
|
||||
|
||||
|
||||
# ── POST /api/chat ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -53,22 +68,25 @@ class SessionOut(BaseModel):
|
||||
async def chat_endpoint(
|
||||
body: ChatRequest,
|
||||
agent: AgentService = Depends(get_agent_service),
|
||||
user_id: str | None = Depends(_resolve_user_id),
|
||||
) -> ChatResponse:
|
||||
"""Send a user message to the agent and return its reply.
|
||||
|
||||
Saves both the user message and the assistant reply to Valkey.
|
||||
Assigns a new UUID ``session_id`` if none was provided.
|
||||
If ``user_id`` is provided, the session is linked to that user.
|
||||
The chat session is automatically assigned to the authenticated user
|
||||
(via ``Authorization: Bearer <token>`` header) or to the explicit
|
||||
``user_id`` field in the request body.
|
||||
"""
|
||||
session_id = body.session_id or uuid.uuid4().hex
|
||||
|
||||
try:
|
||||
# Persist the user message (linked to user if provided).
|
||||
# Persist the user message (linked to authenticated user).
|
||||
await save_message(
|
||||
session_id,
|
||||
"user",
|
||||
body.message,
|
||||
user_id=body.user_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Ask the agent.
|
||||
|
||||
+3
-1
@@ -6,7 +6,9 @@ server {
|
||||
index index.html;
|
||||
|
||||
# ── API proxy ─────────────────────────────────────────────────
|
||||
# Resolved at container startup (depends_on ensures backend is up).
|
||||
# Container runtime (Docker/Podman) resolves backend:8000 via
|
||||
# its built-in DNS. nginx caches the resolved IP at startup, so
|
||||
# restarting the backend container needs a frontend restart too.
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
+264
-231
@@ -2,11 +2,15 @@ import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
sendMessage,
|
||||
getHistory,
|
||||
listUsers,
|
||||
createUser,
|
||||
getUserSessions,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
getAuthToken,
|
||||
setAuthToken,
|
||||
clearAuthToken,
|
||||
type MessageData,
|
||||
type UserData,
|
||||
type AuthResponse,
|
||||
type UserSessionData,
|
||||
} from "./api";
|
||||
|
||||
@@ -20,7 +24,6 @@ interface Message {
|
||||
}
|
||||
|
||||
const STORAGE_SESSION_KEY = "agent_alpha_session_id";
|
||||
const STORAGE_USER_KEY = "agent_alpha_user_id";
|
||||
|
||||
const ROLE_BADGES: Record<string, string> = {
|
||||
admin: "bg-purple-600",
|
||||
@@ -29,7 +32,177 @@ const ROLE_BADGES: Record<string, string> = {
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* Auth Page */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function AuthPage({ onAuth }: { onAuth: (user: AuthResponse) => void }) {
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [team, setTeam] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
let result: AuthResponse;
|
||||
if (mode === "login") {
|
||||
result = await login(username, password);
|
||||
} else {
|
||||
result = await register(
|
||||
username,
|
||||
displayName || username,
|
||||
password,
|
||||
"user",
|
||||
team || undefined,
|
||||
);
|
||||
}
|
||||
setAuthToken(result.token);
|
||||
onAuth(result);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Authentication failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-dvh max-w-md flex-col items-center justify-center px-6">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 text-lg font-bold text-white shadow-lg shadow-indigo-500/25">
|
||||
α
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Agent Alpha</h1>
|
||||
<p className="text-xs text-gray-500">powered by pydantic-ai</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="w-full space-y-4">
|
||||
<p className="text-center text-sm text-gray-400">
|
||||
{mode === "login" ? "Sign in to your account" : "Create a new account"}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg bg-red-900/40 px-4 py-2 text-center text-sm text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 outline-none transition focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="alice"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "register" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 outline-none transition focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="Alice"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Team (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={team}
|
||||
onChange={(e) => setTeam(e.target.value)}
|
||||
className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 outline-none transition focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="Engineering"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 outline-none transition focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{busy
|
||||
? "Please wait…"
|
||||
: mode === "login"
|
||||
? "Sign In"
|
||||
: "Create Account"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-xs text-gray-500">
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
Don't have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
setError("");
|
||||
}}
|
||||
className="text-indigo-400 hover:underline"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Already have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setError("");
|
||||
}}
|
||||
className="text-indigo-400 hover:underline"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main App */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function App() {
|
||||
@@ -40,17 +213,11 @@ export default function App() {
|
||||
return localStorage.getItem(STORAGE_SESSION_KEY) ?? "";
|
||||
});
|
||||
|
||||
/* ── User state ──────────────────────────────────────────────────── */
|
||||
const [users, setUsers] = useState<UserData[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<UserData | null>(null);
|
||||
const [showUserPanel, setShowUserPanel] = useState(false);
|
||||
const [showCreateUser, setShowCreateUser] = useState(false);
|
||||
|
||||
/* ── Create user form state ──────────────────────────────────────── */
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newDisplayName, setNewDisplayName] = useState("");
|
||||
const [newRole, setNewRole] = useState("user");
|
||||
const [newTeam, setNewTeam] = useState("");
|
||||
/* ── Auth state ──────────────────────────────────────────────────── */
|
||||
const [authenticated, setAuthenticated] = useState<AuthResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
|
||||
/* ── Session sidebar state ───────────────────────────────────────── */
|
||||
const [userSessions, setUserSessions] = useState<UserSessionData[]>([]);
|
||||
@@ -58,25 +225,29 @@ export default function App() {
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* Load users on mount and restore selected user */
|
||||
/* Restore auth session from stored token */
|
||||
useEffect(() => {
|
||||
const storedUserId = localStorage.getItem(STORAGE_USER_KEY);
|
||||
|
||||
listUsers()
|
||||
.then((allUsers) => {
|
||||
setUsers(allUsers);
|
||||
if (storedUserId) {
|
||||
const found = allUsers.find((u) => u.id === storedUserId);
|
||||
if (found) setCurrentUser(found);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Users table may not exist yet — that's OK.
|
||||
});
|
||||
(async () => {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
setAuthReady(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { getMe } = await import("./api");
|
||||
const user = await getMe();
|
||||
setAuthenticated(user);
|
||||
} catch {
|
||||
clearAuthToken();
|
||||
} finally {
|
||||
setAuthReady(true);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
/* Restore chat history on mount or session change */
|
||||
useEffect(() => {
|
||||
if (!authenticated) return;
|
||||
const sid = localStorage.getItem(STORAGE_SESSION_KEY);
|
||||
if (sid) {
|
||||
getHistory(sid)
|
||||
@@ -112,51 +283,33 @@ export default function App() {
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, []);
|
||||
}, [authenticated]);
|
||||
|
||||
/* Auto-scroll on new messages */
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
/* ── User handlers ────────────────────────────────────────────────── */
|
||||
/* ── Auth handlers ────────────────────────────────────────────────── */
|
||||
|
||||
const handleSelectUser = (user: UserData) => {
|
||||
setCurrentUser(user);
|
||||
localStorage.setItem(STORAGE_USER_KEY, user.id);
|
||||
setShowUserPanel(false);
|
||||
const handleAuth = (user: AuthResponse) => {
|
||||
setAuthenticated(user);
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newUsername.trim() || !newDisplayName.trim()) return;
|
||||
|
||||
try {
|
||||
const user = await createUser({
|
||||
username: newUsername.trim(),
|
||||
display_name: newDisplayName.trim(),
|
||||
role: newRole,
|
||||
team: newTeam.trim() || null,
|
||||
});
|
||||
setUsers((prev) => [...prev, user]);
|
||||
setCurrentUser(user);
|
||||
localStorage.setItem(STORAGE_USER_KEY, user.id);
|
||||
setNewUsername("");
|
||||
setNewDisplayName("");
|
||||
setNewRole("user");
|
||||
setNewTeam("");
|
||||
setShowCreateUser(false);
|
||||
setShowUserPanel(false);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
alert(`Failed to create user: ${msg}`);
|
||||
}
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
setAuthenticated(null);
|
||||
setMessages([]);
|
||||
setSessionId("");
|
||||
localStorage.removeItem(STORAGE_SESSION_KEY);
|
||||
};
|
||||
|
||||
/* ── Session handlers ────────────────────────────────────────────── */
|
||||
|
||||
const handleViewSessions = async () => {
|
||||
if (!currentUser) return;
|
||||
if (!authenticated) return;
|
||||
try {
|
||||
const sessions = await getUserSessions(currentUser.id);
|
||||
const sessions = await getUserSessions(authenticated.user_id);
|
||||
setUserSessions(sessions);
|
||||
setShowSessions(true);
|
||||
} catch {
|
||||
@@ -169,7 +322,6 @@ export default function App() {
|
||||
localStorage.setItem(STORAGE_SESSION_KEY, sid);
|
||||
setSessionId(sid);
|
||||
setShowSessions(false);
|
||||
// Reload messages for this session.
|
||||
getHistory(sid)
|
||||
.then((history: MessageData[]) => {
|
||||
if (history.length > 0) {
|
||||
@@ -203,11 +355,8 @@ export default function App() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const data = await sendMessage(
|
||||
text,
|
||||
sessionId || undefined,
|
||||
currentUser?.id,
|
||||
);
|
||||
// Auth token is sent automatically via api.ts authHeaders()
|
||||
const data = await sendMessage(text, sessionId || undefined);
|
||||
localStorage.setItem(STORAGE_SESSION_KEY, data.session_id);
|
||||
setSessionId(data.session_id);
|
||||
setMessages((prev) => [
|
||||
@@ -225,7 +374,23 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Render ──────────────────────────────────────────────────────── */
|
||||
/* ── Loading splash ──────────────────────────────────────────────── */
|
||||
|
||||
if (!authReady) {
|
||||
return (
|
||||
<div className="mx-auto flex h-dvh max-w-4xl items-center justify-center">
|
||||
<p className="text-sm text-gray-500">Loading…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Auth gate ──────────────────────────────────────────────────── */
|
||||
|
||||
if (!authenticated) {
|
||||
return <AuthPage onAuth={handleAuth} />;
|
||||
}
|
||||
|
||||
/* ── Chat UI ─────────────────────────────────────────────────────── */
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-dvh max-w-4xl flex-col">
|
||||
@@ -242,179 +407,47 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* User badge */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserPanel(!showUserPanel)}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-1.5 text-sm transition hover:border-indigo-500"
|
||||
>
|
||||
{currentUser ? (
|
||||
<>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${ROLE_BADGES[currentUser.role] ?? "bg-gray-500"}`}
|
||||
/>
|
||||
<span className="text-gray-200">{currentUser.display_name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{currentUser.team ? `${currentUser.team} · ` : ""}
|
||||
{currentUser.role}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400">Select user</span>
|
||||
)}
|
||||
<svg
|
||||
className={`h-4 w-4 text-gray-400 transition ${showUserPanel ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* User dropdown panel */}
|
||||
{showUserPanel && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-64 rounded-xl border border-gray-700 bg-gray-900 p-3 shadow-2xl">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Users
|
||||
</p>
|
||||
{users.length === 0 && (
|
||||
<p className="py-2 text-sm text-gray-500">
|
||||
No users yet. Create one below.
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-2 max-h-48 space-y-1 overflow-y-auto">
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => handleSelectUser(user)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-gray-800 ${
|
||||
currentUser?.id === user.id
|
||||
? "bg-gray-800 ring-1 ring-indigo-500"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${ROLE_BADGES[user.role] ?? "bg-gray-500"}`}
|
||||
/>
|
||||
<span className="flex-1 truncate text-gray-200">
|
||||
{user.display_name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{user.team ?? user.role}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserPanel(false);
|
||||
setShowCreateUser(true);
|
||||
}}
|
||||
className="w-full rounded-lg border border-dashed border-gray-600 px-3 py-2 text-sm text-gray-400 transition hover:border-indigo-500 hover:text-indigo-400"
|
||||
>
|
||||
+ New user
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-1.5">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${ROLE_BADGES[authenticated.role] ?? "bg-gray-500"}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-200">
|
||||
{authenticated.display_name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{authenticated.team ? `${authenticated.team} · ` : ""}
|
||||
{authenticated.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sessions button */}
|
||||
{currentUser && (
|
||||
<button
|
||||
onClick={handleViewSessions}
|
||||
className="rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 transition hover:border-indigo-500 hover:text-indigo-400"
|
||||
title="View my sessions"
|
||||
>
|
||||
Sessions
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleViewSessions}
|
||||
className="rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 transition hover:border-indigo-500 hover:text-indigo-400"
|
||||
title="View my sessions"
|
||||
>
|
||||
Sessions
|
||||
</button>
|
||||
|
||||
{/* Logout button */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-500 transition hover:border-red-500 hover:text-red-400"
|
||||
title="Sign out"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* ---- Create User Modal ---- */}
|
||||
{showCreateUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<form
|
||||
onSubmit={handleCreateUser}
|
||||
className="w-full max-w-sm rounded-2xl border border-gray-700 bg-gray-900 p-6 shadow-2xl"
|
||||
>
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-100">
|
||||
Create User
|
||||
</h2>
|
||||
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
className="mb-3 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500"
|
||||
placeholder="alice"
|
||||
required
|
||||
/>
|
||||
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newDisplayName}
|
||||
onChange={(e) => setNewDisplayName(e.target.value)}
|
||||
className="mb-3 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500"
|
||||
placeholder="Alice"
|
||||
required
|
||||
/>
|
||||
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Team
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTeam}
|
||||
onChange={(e) => setNewTeam(e.target.value)}
|
||||
className="mb-3 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500"
|
||||
placeholder="Engineering (optional)"
|
||||
/>
|
||||
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
className="mb-4 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateUser(false)}
|
||||
className="flex-1 rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 transition hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-500"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Sessions Sidebar ---- */}
|
||||
{showSessions && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-md rounded-2xl border border-gray-700 bg-gray-900 p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-100">
|
||||
Sessions — {currentUser?.display_name}
|
||||
Sessions — {authenticated.display_name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowSessions(false)}
|
||||
|
||||
+103
-1
@@ -1,5 +1,26 @@
|
||||
const API_BASE = "/api";
|
||||
|
||||
/* ── Token management ──────────────────────────────────────────── */
|
||||
|
||||
const TOKEN_KEY = "agent_alpha_auth_token";
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearAuthToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getAuthToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/* ── Chat Types ────────────────────────────────────────────────── */
|
||||
|
||||
export interface ChatResponse {
|
||||
@@ -19,6 +40,17 @@ export interface SessionData {
|
||||
user_id: string | null;
|
||||
}
|
||||
|
||||
/* ── Auth Types ────────────────────────────────────────────────── */
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
team: string | null;
|
||||
}
|
||||
|
||||
/* ── User Types ────────────────────────────────────────────────── */
|
||||
|
||||
export interface UserData {
|
||||
@@ -37,12 +69,79 @@ export interface UserCreateData {
|
||||
display_name: string;
|
||||
role?: string;
|
||||
team?: string | null;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface UserSessionData {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
/* ── Auth API ──────────────────────────────────────────────────── */
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.detail ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function register(
|
||||
username: string,
|
||||
displayName: string,
|
||||
password: string,
|
||||
role?: string,
|
||||
team?: string,
|
||||
): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
display_name: displayName,
|
||||
password,
|
||||
role: role ?? "user",
|
||||
team: team ?? null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.detail ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<AuthResponse> {
|
||||
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.detail ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
const token = getAuthToken();
|
||||
if (!token) return;
|
||||
await fetch(`${API_BASE}/auth/logout`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).catch(() => {
|
||||
/* ignore network errors on logout */
|
||||
});
|
||||
clearAuthToken();
|
||||
}
|
||||
|
||||
/* ── Send a message ────────────────────────────────────────────── */
|
||||
|
||||
export async function sendMessage(
|
||||
@@ -52,7 +151,10 @@ export async function sendMessage(
|
||||
): Promise<ChatResponse> {
|
||||
const res = await fetch(`${API_BASE}/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...authHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
session_id: sessionId ?? null,
|
||||
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"pydantic-deep>=0.3.28",
|
||||
"uvicorn[standard]>=0.34.0",
|
||||
"asyncpg>=0.30.0",
|
||||
"bcrypt>=4.2.0",
|
||||
"redis[hiredis]>=5.2.0",
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
]
|
||||
|
||||
@@ -13,6 +13,7 @@ version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncpg" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "logfire", extra = ["asyncpg", "fastapi", "httpx", "sqlite3"] },
|
||||
{ name = "pydantic-ai-backend" },
|
||||
@@ -31,6 +32,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "asyncpg", specifier = ">=0.30.0" },
|
||||
{ name = "bcrypt", specifier = ">=4.2.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.136.3" },
|
||||
{ name = "logfire", extras = ["asyncpg", "fastapi", "httpx", "sqlite3"], specifier = ">=4.36.0" },
|
||||
{ name = "pydantic-ai-backend", specifier = ">=0.2.11" },
|
||||
@@ -160,6 +162,72 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "5.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beartype"
|
||||
version = "0.22.9"
|
||||
|
||||
Reference in New Issue
Block a user