mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
Add cookie-based SPA auth and update container plumbing
Backend now exposes /api/auth/login + /api/auth/logout setting an httpOnly ws_token cookie, and get_current_user accepts either the cookie (SPA) or a Bearer token (n8n/CLI). AuthContext probes the cookie via /api/v1/auth/me. Dockerfiles and compose files updated for the new agent service deps and CopilotKit dev sidecar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,6 @@ FROM python:3.11-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir --pre -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from fastapi import Depends
|
|
||||||
|
|
||||||
from app.auth import create_access_token
|
from app.auth import create_access_token, get_current_user, get_current_user_cookie_or_bearer
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
@@ -20,3 +19,8 @@ def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
|||||||
)
|
)
|
||||||
token = create_access_token(form_data.username)
|
token = create_access_token(form_data.username)
|
||||||
return {"access_token": token, "token_type": "bearer"}
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
|
||||||
|
return {"username": username}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Cookie, Depends, Header, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -22,8 +24,8 @@ def hash_token(token: str) -> str:
|
|||||||
return hashlib.sha256(token.encode()).hexdigest()
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
def _validate_token(token: str) -> str:
|
||||||
# Try JWT first
|
"""Validate JWT and return subject, or raise 401."""
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
@@ -51,3 +53,26 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
|||||||
return f"api:{api_token.name}"
|
return f"api:{api_token.name}"
|
||||||
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_cookie_or_bearer(
|
||||||
|
authorization: Optional[str] = Header(default=None),
|
||||||
|
ws_token: Optional[str] = Cookie(default=None),
|
||||||
|
) -> str:
|
||||||
|
"""Accepts httpOnly cookie (SPA) or Bearer token (API clients / n8n)."""
|
||||||
|
token: Optional[str] = None
|
||||||
|
if authorization and authorization.lower().startswith("bearer "):
|
||||||
|
token = authorization.split(" ", 1)[1].strip()
|
||||||
|
elif ws_token:
|
||||||
|
token = ws_token
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
return _validate_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
authorization: Optional[str] = Header(default=None),
|
||||||
|
ws_token: Optional[str] = Cookie(default=None),
|
||||||
|
) -> str:
|
||||||
|
"""SPA cookie or Bearer token. Single dependency for all v1 endpoints."""
|
||||||
|
return get_current_user_cookie_or_bearer(authorization, ws_token)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class Settings(BaseSettings):
|
|||||||
VAPID_PRIVATE_KEY: str = ""
|
VAPID_PRIVATE_KEY: str = ""
|
||||||
VAPID_PUBLIC_KEY: str = ""
|
VAPID_PUBLIC_KEY: str = ""
|
||||||
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
|
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
|
||||||
|
OPENAI_API_KEY: str = ""
|
||||||
|
AGENT_MODEL: str = "gpt-5.4-mini"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|||||||
@@ -1,16 +1,63 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.agent.agent import build_agent
|
||||||
|
from app.agent.tools import reset_session, set_session
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
|
from app.auth import ALGORITHM, create_access_token
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import init_db, run_migrations
|
from app.db import get_session, init_db, run_migrations
|
||||||
from app.seed import seed_db
|
from app.seed import seed_db
|
||||||
from app.services.exchange_rate import refresh_rates_periodically
|
from app.services.exchange_rate import refresh_rates_periodically
|
||||||
|
|
||||||
|
|
||||||
|
AGENT_PATH = "/api/v1/agent/agui"
|
||||||
|
|
||||||
|
|
||||||
|
def _pair_orphan_tool_calls(messages: list) -> list:
|
||||||
|
"""Inject synthetic tool responses for any assistant tool_calls that have
|
||||||
|
no matching tool message. OpenAI rejects histories where a tool_calls
|
||||||
|
entry is not immediately followed by the corresponding tool response."""
|
||||||
|
out: list = []
|
||||||
|
pending: list[str] = []
|
||||||
|
|
||||||
|
def flush():
|
||||||
|
for call_id in pending:
|
||||||
|
out.append({"role": "tool", "tool_call_id": call_id, "content": ""})
|
||||||
|
pending.clear()
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role", "")
|
||||||
|
if role == "tool":
|
||||||
|
call_id = msg.get("tool_call_id") or msg.get("toolCallId")
|
||||||
|
if call_id and call_id in pending:
|
||||||
|
pending.remove(call_id)
|
||||||
|
out.append(msg)
|
||||||
|
continue
|
||||||
|
if role == "assistant":
|
||||||
|
flush()
|
||||||
|
out.append(msg)
|
||||||
|
for tc in msg.get("tool_calls") or msg.get("toolCalls") or []:
|
||||||
|
tc_id = tc.get("id") if isinstance(tc, dict) else None
|
||||||
|
if tc_id:
|
||||||
|
pending.append(tc_id)
|
||||||
|
continue
|
||||||
|
flush()
|
||||||
|
out.append(msg)
|
||||||
|
|
||||||
|
flush()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
@@ -37,9 +84,106 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def agent_auth_and_session(request: Request, call_next):
|
||||||
|
"""For the AG-UI route, validate the JWT, repair message history, and
|
||||||
|
bind a DB session to a ContextVar so agent tools can query without going
|
||||||
|
through Depends."""
|
||||||
|
if not request.url.path.startswith(AGENT_PATH):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
token: str | None = None
|
||||||
|
if auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
else:
|
||||||
|
cookie_header = request.headers.get("cookie", "")
|
||||||
|
m = re.search(r"(?:^|;\s*)ws_token=([^;]+)", cookie_header)
|
||||||
|
if m:
|
||||||
|
token = m.group(1)
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return Response(status_code=401, content="Missing auth")
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
if not payload.get("sub"):
|
||||||
|
return Response(status_code=401, content="Invalid token")
|
||||||
|
except JWTError:
|
||||||
|
return Response(status_code=401, content="Invalid token")
|
||||||
|
|
||||||
|
# Repair orphan tool_calls before the MAF agent sees the message history.
|
||||||
|
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
|
||||||
|
raw = await request.body()
|
||||||
|
try:
|
||||||
|
body = json.loads(raw)
|
||||||
|
if isinstance(body.get("messages"), list):
|
||||||
|
body["messages"] = _pair_orphan_tool_calls(body["messages"])
|
||||||
|
raw = json.dumps(body).encode()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Starlette caches the body; replace it so call_next sees the fixed bytes.
|
||||||
|
request._body = raw # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
session_gen = get_session()
|
||||||
|
session = next(session_gen)
|
||||||
|
token_var = set_session(session)
|
||||||
|
try:
|
||||||
|
return await call_next(request)
|
||||||
|
finally:
|
||||||
|
reset_session(token_var)
|
||||||
|
try:
|
||||||
|
next(session_gen)
|
||||||
|
except StopIteration:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Register app routes
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
# Mount the AG-UI agent endpoint.
|
||||||
|
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
return {"app": "WealthySmart", "version": "0.1.0"}
|
return {"app": "WealthySmart", "version": "0.1.0"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cookie-based auth endpoints (used by the Vite SPA) ──────────────────────
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/login")
|
||||||
|
def cookie_login(body: LoginRequest, response: Response):
|
||||||
|
if (
|
||||||
|
body.username != settings.ADMIN_USERNAME
|
||||||
|
or body.password != settings.ADMIN_PASSWORD
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
token = create_access_token(body.username)
|
||||||
|
response.set_cookie(
|
||||||
|
key="ws_token",
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||||
|
secure=False, # set True behind TLS in production via nginx
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/logout", status_code=204)
|
||||||
|
def cookie_logout(response: Response):
|
||||||
|
response.delete_cookie("ws_token")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
networks:
|
networks:
|
||||||
@@ -47,22 +49,32 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile.prod
|
dockerfile: Dockerfile
|
||||||
|
target: runner
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
container_name: wealthysmart-frontend-prod
|
container_name: wealthysmart-frontend-prod
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
BACKEND_URL: http://backend:8000
|
||||||
|
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
||||||
|
JWT_SECRET: ${SECRET_KEY}
|
||||||
|
COOKIE_DOMAIN: wealth.cescalante.dev
|
||||||
|
COOKIE_SECURE: "true"
|
||||||
VIRTUAL_HOST: wealth.cescalante.dev
|
VIRTUAL_HOST: wealth.cescalante.dev
|
||||||
|
VIRTUAL_PORT: "3000"
|
||||||
LETSENCRYPT_HOST: wealth.cescalante.dev
|
LETSENCRYPT_HOST: wealth.cescalante.dev
|
||||||
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
|
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
|
||||||
expose:
|
expose:
|
||||||
- "80"
|
- "3000"
|
||||||
networks:
|
networks:
|
||||||
- wealthysmart-network
|
- wealthysmart-network
|
||||||
- nginx-prod-network
|
- nginx-prod-network
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ services:
|
|||||||
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
|
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -32,17 +34,52 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- path: ./backend/app
|
||||||
|
action: sync
|
||||||
|
target: /app/app
|
||||||
|
- path: ./backend/requirements.txt
|
||||||
|
action: rebuild
|
||||||
|
- path: ./backend/Dockerfile
|
||||||
|
action: rebuild
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: dev
|
||||||
container_name: wealthysmart-frontend-dev
|
container_name: wealthysmart-frontend-dev
|
||||||
ports:
|
ports:
|
||||||
- "5175:5173"
|
- "5175:3000"
|
||||||
volumes:
|
environment:
|
||||||
- ./frontend:/app
|
NODE_ENV: development
|
||||||
- /app/node_modules
|
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- path: ./frontend/src
|
||||||
|
action: sync
|
||||||
|
target: /app/src
|
||||||
|
- path: ./frontend/public
|
||||||
|
action: sync
|
||||||
|
target: /app/public
|
||||||
|
- path: ./frontend/server.ts
|
||||||
|
action: sync
|
||||||
|
target: /app/server.ts
|
||||||
|
- path: ./frontend/vite.config.ts
|
||||||
|
action: sync+restart
|
||||||
|
target: /app/vite.config.ts
|
||||||
|
- path: ./frontend/tsconfig.json
|
||||||
|
action: sync+restart
|
||||||
|
target: /app/tsconfig.json
|
||||||
|
- path: ./frontend/package.json
|
||||||
|
action: rebuild
|
||||||
|
- path: ./frontend/pnpm-lock.yaml
|
||||||
|
action: rebuild
|
||||||
|
- path: ./frontend/Dockerfile
|
||||||
|
action: rebuild
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -1,33 +1,40 @@
|
|||||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
|
||||||
|
import { logout as apiLogout } from "@/lib/api";
|
||||||
|
|
||||||
interface AuthCtx {
|
interface AuthCtx {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
logout: () => void;
|
isLoading: boolean;
|
||||||
|
logout: () => Promise<void>;
|
||||||
setAuthenticated: (v: boolean) => void;
|
setAuthenticated: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthCtx>({
|
const AuthContext = createContext<AuthCtx>({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
logout: () => {},
|
isLoading: true,
|
||||||
|
logout: async () => {},
|
||||||
setAuthenticated: () => {},
|
setAuthenticated: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [isAuthenticated, setAuthenticated] = useState(!!localStorage.getItem('token'));
|
const [isAuthenticated, setAuthenticated] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => setAuthenticated(!!localStorage.getItem('token'));
|
// Probe auth state by hitting a protected endpoint.
|
||||||
window.addEventListener('storage', check);
|
// If the ws_token cookie is valid, the server returns 200; else 401.
|
||||||
return () => window.removeEventListener('storage', check);
|
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||||
|
.then((r) => setAuthenticated(r.ok))
|
||||||
|
.catch(() => setAuthenticated(false))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = () => {
|
const logout = async () => {
|
||||||
localStorage.removeItem('token');
|
await apiLogout();
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ isAuthenticated, logout, setAuthenticated }}>
|
<AuthContext.Provider value={{ isAuthenticated, isLoading, logout, setAuthenticated }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user