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:
2026-06-12 21:37:45 +08:00
parent dc4ed7c48d
commit 58a41093c6
10 changed files with 792 additions and 240 deletions
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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}>"
+192
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -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",
]
Generated
+68
View File
@@ -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"