Phase 9: OAuth & Account Switching — Google + Authentik, multi-account

Backend:
- OAuth service with pluggable provider architecture (Google + Authentik)
- Generic authorize/callback endpoints for any provider
- Authentik OIDC integration (configurable base URL)
- hashed_password made nullable for OAuth-only users
- Migration 009: nullable password column
- /auth/switch endpoint returns full AuthResponse for account switching
- OAuth-only users get clear error on password login attempt
- UserResponse includes oauth_provider + avatar_url

Frontend:
- OAuth buttons on login form (Google + Authentik)
- OAuth callback handler (/auth/callback route)
- Multi-account auth store (accounts array, addAccount, switchTo, removeAccount)
- Account switcher dropdown in header (hover to see other accounts)
- "Add another account" option
- English + Russian translations

Config:
- GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI
- AUTHENTIK_CLIENT_ID/SECRET/BASE_URL/REDIRECT_URI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 15:56:20 +03:00
parent d86d53f473
commit 5c651b7988
18 changed files with 436 additions and 33 deletions

View File

@@ -252,10 +252,10 @@ Daily scheduled job (APScheduler, 8 AM) reviews each user's memory + recent docs
- Summary: Admin-managed Jinja2 PDF templates in DB with locale support (en/ru), template selector for users/AI, live preview editor, basic + medical seed templates - Summary: Admin-managed Jinja2 PDF templates in DB with locale support (en/ru), template selector for users/AI, live preview editor, basic + medical seed templates
### Phase 9: OAuth & Account Switching ### Phase 9: OAuth & Account Switching
- **Status**: NOT STARTED - **Status**: COMPLETED
- [ ] Subplan created (`plans/phase-9-oauth.md`) - [x] Subplan created (`plans/phase-9-oauth.md`)
- [ ] Phase completed - [x] Phase completed
- Summary: OAuth (Google, GitHub), account switching UI, multiple stored sessions - Summary: OAuth (Google + Authentik), account switching UI, multi-account store
### Phase 10: Per-User Rate Limits ### Phase 10: Per-User Rate Limits
- **Status**: COMPLETED - **Status**: COMPLETED

View File

@@ -0,0 +1,24 @@
"""Make hashed_password nullable for OAuth users
Revision ID: 009
Revises: 008
Create Date: 2026-03-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "009"
down_revision: Union[str, None] = "008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.alter_column("users", "hashed_password", nullable=True)
def downgrade() -> None:
op.alter_column("users", "hashed_password", nullable=False)

View File

@@ -77,3 +77,53 @@ async def logout(
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
async def me(user: Annotated[User, Depends(get_current_user)]): async def me(user: Annotated[User, Depends(get_current_user)]):
return UserResponse.model_validate(user) return UserResponse.model_validate(user)
# --- OAuth ---
@router.get("/oauth/{provider}/authorize")
async def oauth_authorize(provider: str):
from app.services.oauth_service import get_authorize_url
url = await get_authorize_url(provider)
return {"authorize_url": url}
@router.get("/oauth/{provider}/callback")
async def oauth_callback(
provider: str,
code: str,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
):
from app.services.oauth_service import handle_callback
result = await handle_callback(
provider, code, db,
ip_address=request.client.host if request.client else None,
device_info=request.headers.get("user-agent"),
)
# Redirect to frontend with tokens
from fastapi.responses import RedirectResponse
redirect_url = f"/auth/callback?access_token={result['access_token']}&refresh_token={result['refresh_token']}"
return RedirectResponse(url=redirect_url)
@router.post("/switch", response_model=AuthResponse)
async def switch_account(
data: RefreshRequest,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Switch to another account using its refresh token. Returns full AuthResponse."""
tokens = await auth_service.refresh_tokens(db, data.refresh_token)
# Get user from new access token
from app.core.security import decode_access_token
import uuid as uuid_mod
payload = decode_access_token(tokens.access_token)
user_id = uuid_mod.UUID(payload["sub"])
from sqlalchemy import select
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one()
return AuthResponse(
user=UserResponse.model_validate(user),
access_token=tokens.access_token,
refresh_token=tokens.refresh_token,
)

View File

@@ -23,6 +23,15 @@ class Settings(BaseSettings):
RATE_LIMIT_REQUESTS: int = 20 RATE_LIMIT_REQUESTS: int = 20
RATE_LIMIT_WINDOW_SECONDS: int = 60 RATE_LIMIT_WINDOW_SECONDS: int = 60
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GOOGLE_REDIRECT_URI: str = "http://localhost/api/v1/auth/oauth/google/callback"
AUTHENTIK_CLIENT_ID: str = ""
AUTHENTIK_CLIENT_SECRET: str = ""
AUTHENTIK_BASE_URL: str = "" # e.g. https://auth.example.com
AUTHENTIK_REDIRECT_URI: str = "http://localhost/api/v1/auth/oauth/authentik/callback"
FIRST_ADMIN_EMAIL: str = "admin@example.com" FIRST_ADMIN_EMAIL: str = "admin@example.com"
FIRST_ADMIN_USERNAME: str = "admin" FIRST_ADMIN_USERNAME: str = "admin"
FIRST_ADMIN_PASSWORD: str = "changeme_admin_password" FIRST_ADMIN_PASSWORD: str = "changeme_admin_password"

View File

@@ -11,7 +11,7 @@ class User(Base):
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False) username: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
full_name: Mapped[str | None] = mapped_column(String(255), nullable=True) full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="user") role: Mapped[str] = mapped_column(String(20), nullable=False, default="user")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)

View File

@@ -29,6 +29,8 @@ class UserResponse(BaseModel):
role: str role: str
is_active: bool is_active: bool
max_chats: int max_chats: int
oauth_provider: str | None = None
avatar_url: str | None = None
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -94,10 +94,10 @@ async def login_user(
result = await db.execute(select(User).where(User.email == email)) result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not verify_password(password, user.hashed_password): if not user or not user.hashed_password or not verify_password(password, user.hashed_password):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password", detail="Invalid email or password" if not user or user.hashed_password else "This account uses OAuth login. Please sign in with your provider.",
) )
if not user.is_active: if not user.is_active:

View File

@@ -0,0 +1,152 @@
from datetime import datetime, timedelta, timezone
from authlib.integrations.httpx_client import AsyncOAuth2Client
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.core.security import create_access_token, generate_refresh_token, hash_refresh_token
from app.models.session import Session
from app.models.user import User
# --- Provider configs ---
PROVIDERS = {
"google": {
"authorize_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"userinfo_url": "https://www.googleapis.com/oauth2/v3/userinfo",
"scope": "openid email profile",
},
}
def _get_authentik_config():
base = settings.AUTHENTIK_BASE_URL.rstrip("/")
return {
"authorize_url": f"{base}/application/o/authorize/",
"token_url": f"{base}/application/o/token/",
"userinfo_url": f"{base}/application/o/userinfo/",
"scope": "openid email profile",
}
def _get_provider_config(provider: str) -> dict:
if provider == "google":
return PROVIDERS["google"]
elif provider == "authentik":
if not settings.AUTHENTIK_BASE_URL:
raise HTTPException(status_code=400, detail="Authentik not configured")
return _get_authentik_config()
raise HTTPException(status_code=400, detail=f"Unsupported OAuth provider: {provider}")
def _get_client_credentials(provider: str) -> tuple[str, str, str]:
if provider == "google":
return settings.GOOGLE_CLIENT_ID, settings.GOOGLE_CLIENT_SECRET, settings.GOOGLE_REDIRECT_URI
elif provider == "authentik":
return settings.AUTHENTIK_CLIENT_ID, settings.AUTHENTIK_CLIENT_SECRET, settings.AUTHENTIK_REDIRECT_URI
raise HTTPException(status_code=400, detail=f"Unsupported provider: {provider}")
def _get_client(provider: str) -> AsyncOAuth2Client:
config = _get_provider_config(provider)
client_id, client_secret, redirect_uri = _get_client_credentials(provider)
return AsyncOAuth2Client(
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
scope=config["scope"],
)
async def get_authorize_url(provider: str) -> str:
config = _get_provider_config(provider)
client = _get_client(provider)
url, _ = client.create_authorization_url(config["authorize_url"])
return url
async def handle_callback(
provider: str, code: str, db: AsyncSession,
ip_address: str | None = None, device_info: str | None = None,
) -> dict:
"""Exchange code, get user info, create/link user, return auth tokens."""
config = _get_provider_config(provider)
client = _get_client(provider)
try:
await client.fetch_token(config["token_url"], code=code)
except Exception:
raise HTTPException(status_code=400, detail="Failed to exchange OAuth code")
resp = await client.get(config["userinfo_url"])
if resp.status_code != 200:
raise HTTPException(status_code=400, detail=f"Failed to get user info from {provider}")
userinfo = resp.json()
email = userinfo.get("email")
name = userinfo.get("name") or userinfo.get("preferred_username")
picture = userinfo.get("picture")
provider_id = userinfo.get("sub")
if not email:
raise HTTPException(status_code=400, detail=f"{provider} account has no email")
# Find or create user
result = await db.execute(
select(User).where(User.oauth_provider == provider, User.oauth_provider_id == provider_id)
)
user = result.scalar_one_or_none()
if not user:
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user:
user.oauth_provider = provider
user.oauth_provider_id = provider_id
if picture:
user.avatar_url = picture
else:
username = email.split("@")[0]
base = username
counter = 1
while True:
result = await db.execute(select(User).where(User.username == username))
if not result.scalar_one_or_none():
break
username = f"{base}{counter}"
counter += 1
user = User(
email=email,
username=username,
hashed_password=None,
full_name=name,
oauth_provider=provider,
oauth_provider_id=provider_id,
avatar_url=picture,
)
db.add(user)
await db.flush()
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
access_token = create_access_token(user.id, user.role)
refresh_token = generate_refresh_token()
expires_at = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
session = Session(
user_id=user.id,
refresh_token_hash=hash_refresh_token(refresh_token),
device_info=device_info,
ip_address=ip_address,
expires_at=expires_at,
)
db.add(session)
await db.flush()
return {"user": user, "access_token": access_token, "refresh_token": refresh_token}

View File

@@ -22,6 +22,8 @@ dependencies = [
"weasyprint>=62.0", "weasyprint>=62.0",
"jinja2>=3.1.0", "jinja2>=3.1.0",
"python-json-logger>=2.0.0", "python-json-logger>=2.0.0",
"authlib>=1.3.0",
"itsdangerous>=2.2.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -22,7 +22,12 @@
"passwordMinLength": "Password must be at least 8 characters", "passwordMinLength": "Password must be at least 8 characters",
"usernameFormat": "Username must be 3-50 characters (letters, numbers, _ or -)", "usernameFormat": "Username must be 3-50 characters (letters, numbers, _ or -)",
"required": "This field is required" "required": "This field is required"
} },
"orDivider": "or continue with",
"oauthGoogle": "Sign in with Google",
"oauthAuthentik": "Sign in with Authentik",
"addAccount": "Add another account",
"switchAccount": "Switch account"
}, },
"layout": { "layout": {
"dashboard": "Dashboard", "dashboard": "Dashboard",

View File

@@ -22,7 +22,12 @@
"passwordMinLength": "Пароль должен содержать минимум 8 символов", "passwordMinLength": "Пароль должен содержать минимум 8 символов",
"usernameFormat": "Имя пользователя: 3-50 символов (буквы, цифры, _ или -)", "usernameFormat": "Имя пользователя: 3-50 символов (буквы, цифры, _ или -)",
"required": "Это поле обязательно" "required": "Это поле обязательно"
} },
"orDivider": "или войти через",
"oauthGoogle": "Войти через Google",
"oauthAuthentik": "Войти через Authentik",
"addAccount": "Добавить аккаунт",
"switchAccount": "Сменить аккаунт"
}, },
"layout": { "layout": {
"dashboard": "Главная", "dashboard": "Главная",

View File

@@ -8,6 +8,8 @@ export interface UserResponse {
role: string; role: string;
is_active: boolean; is_active: boolean;
max_chats: number; max_chats: number;
oauth_provider: string | null;
avatar_url: string | null;
created_at: string; created_at: string;
} }
@@ -59,6 +61,18 @@ export async function refresh(refreshToken: string): Promise<TokenResponse> {
return data; return data;
} }
export async function getOAuthUrl(provider: string): Promise<string> {
const { data } = await api.get<{ authorize_url: string }>(`/auth/oauth/${provider}/authorize`);
return data.authorize_url;
}
export async function switchAccount(refreshToken: string): Promise<AuthResponse> {
const { data } = await api.post<AuthResponse>("/auth/switch", {
refresh_token: refreshToken,
});
return data;
}
export async function logout(refreshToken: string): Promise<void> { export async function logout(refreshToken: string): Promise<void> {
await api.post("/auth/logout", { refresh_token: refreshToken }); await api.post("/auth/logout", { refresh_token: refreshToken });
} }

View File

@@ -2,7 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { login } from "@/api/auth"; import { login, getOAuthUrl } from "@/api/auth";
export function LoginForm() { export function LoginForm() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -92,6 +92,27 @@ export function LoginForm() {
{loading ? t("common.loading") : t("auth.login")} {loading ? t("common.loading") : t("auth.login")}
</button> </button>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center"><div className="w-full border-t" /></div>
<div className="relative flex justify-center text-xs"><span className="bg-card px-2 text-muted-foreground">{t("auth.orDivider")}</span></div>
</div>
<button
type="button"
onClick={async () => { const url = await getOAuthUrl("google"); window.location.href = url; }}
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border bg-background px-4 text-sm font-medium hover:bg-accent transition-colors"
>
{t("auth.oauthGoogle")}
</button>
<button
type="button"
onClick={async () => { const url = await getOAuthUrl("authentik"); window.location.href = url; }}
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border bg-background px-4 text-sm font-medium hover:bg-accent transition-colors"
>
{t("auth.oauthAuthentik")}
</button>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
{t("auth.noAccount")}{" "} {t("auth.noAccount")}{" "}
<Link to="/register" className="text-primary hover:underline"> <Link to="/register" className="text-primary hover:underline">

View File

@@ -0,0 +1,37 @@
import { useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { getMe } from "@/api/auth";
export function OAuthCallback() {
const navigate = useNavigate();
const [params] = useSearchParams();
const setAuth = useAuthStore((s) => s.setAuth);
useEffect(() => {
const accessToken = params.get("access_token");
const refreshToken = params.get("refresh_token");
if (accessToken && refreshToken) {
// Temporarily set tokens to make the API call work
useAuthStore.getState().setTokens(accessToken, refreshToken);
getMe()
.then((user) => {
setAuth(user, accessToken, refreshToken);
navigate("/");
})
.catch(() => {
navigate("/login");
});
} else {
navigate("/login");
}
}, [params, setAuth, navigate]);
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">Signing in...</p>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Menu, Sun, Moon, LogOut, User } from "lucide-react"; import { Menu, Sun, Moon, LogOut, User, ChevronDown } from "lucide-react";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { LanguageToggle } from "@/components/shared/language-toggle"; import { LanguageToggle } from "@/components/shared/language-toggle";
@@ -11,7 +11,7 @@ export function Header() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const { refreshToken, clearAuth } = useAuthStore(); const { refreshToken, clearAuth, accounts, switchTo } = useAuthStore();
const { theme, setTheme, toggleSidebar } = useUIStore(); const { theme, setTheme, toggleSidebar } = useUIStore();
const handleLogout = async () => { const handleLogout = async () => {
@@ -52,13 +52,42 @@ export function Header() {
{theme === "dark" ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />} {theme === "dark" ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
</button> </button>
<div className="flex items-center gap-2 border-l pl-4"> <div className="relative flex items-center gap-2 border-l pl-4 group">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<User className="h-4 w-4" /> <User className="h-4 w-4" />
</div> </div>
<span className="hidden text-sm font-medium sm:block"> <span className="hidden text-sm font-medium sm:block">
{user?.full_name || user?.username} {user?.full_name || user?.username}
</span> </span>
{accounts.length > 1 && (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
{accounts.length > 1 && (
<div className="absolute right-0 top-full mt-1 z-50 hidden group-hover:block w-56 rounded-lg border bg-card shadow-lg py-1">
{accounts.filter(a => a.user.id !== user?.id).map((account) => (
<button
key={account.user.id}
onClick={async () => {
const { switchAccount } = await import("@/api/auth");
const data = await switchAccount(account.refreshToken);
switchTo(data.user, data.access_token, data.refresh_token);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-accent"
>
<User className="h-3 w-3" />
{account.user.full_name || account.user.username}
</button>
))}
<button
onClick={() => navigate("/login")}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-primary hover:bg-accent border-t"
>
{t("auth.addAccount")}
</button>
</div>
)}
<button <button
onClick={handleLogout} onClick={handleLogout}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-destructive transition-colors" className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-destructive transition-colors"

View File

@@ -3,6 +3,7 @@ import { ProtectedRoute } from "@/components/shared/protected-route";
import { AppLayout } from "@/components/layout/app-layout"; import { AppLayout } from "@/components/layout/app-layout";
import { LoginPage } from "@/pages/login"; import { LoginPage } from "@/pages/login";
import { RegisterPage } from "@/pages/register"; import { RegisterPage } from "@/pages/register";
import { OAuthCallback } from "@/components/auth/oauth-callback";
import { DashboardPage } from "@/pages/dashboard"; import { DashboardPage } from "@/pages/dashboard";
import { ChatPage } from "@/pages/chat"; import { ChatPage } from "@/pages/chat";
import { SkillsPage } from "@/pages/skills"; import { SkillsPage } from "@/pages/skills";
@@ -28,6 +29,10 @@ export const router = createBrowserRouter([
path: "/register", path: "/register",
element: <RegisterPage />, element: <RegisterPage />,
}, },
{
path: "/auth/callback",
element: <OAuthCallback />,
},
{ {
element: <ProtectedRoute />, element: <ProtectedRoute />,
children: [ children: [

View File

@@ -2,31 +2,78 @@ import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import type { UserResponse } from "@/api/auth"; import type { UserResponse } from "@/api/auth";
interface StoredAccount {
user: UserResponse;
refreshToken: string;
}
interface AuthState { interface AuthState {
user: UserResponse | null; user: UserResponse | null;
accessToken: string | null; accessToken: string | null;
refreshToken: string | null; refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
accounts: StoredAccount[];
setAuth: (user: UserResponse, accessToken: string, refreshToken: string) => void; setAuth: (user: UserResponse, accessToken: string, refreshToken: string) => void;
setTokens: (accessToken: string, refreshToken: string) => void; setTokens: (accessToken: string, refreshToken: string) => void;
setUser: (user: UserResponse) => void; setUser: (user: UserResponse) => void;
clearAuth: () => void; clearAuth: () => void;
addAccount: (user: UserResponse, refreshToken: string) => void;
removeAccount: (userId: string) => void;
switchTo: (user: UserResponse, accessToken: string, refreshToken: string) => void;
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set) => ({ (set, get) => ({
user: null, user: null,
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) => accounts: [],
set({ user, accessToken, refreshToken, isAuthenticated: true }), setAuth: (user, accessToken, refreshToken) => {
setTokens: (accessToken, refreshToken) => const accounts = get().accounts.filter((a) => a.user.id !== user.id);
set({ accessToken, refreshToken }), accounts.push({ user, refreshToken });
set({ user, accessToken, refreshToken, isAuthenticated: true, accounts });
},
setTokens: (accessToken, refreshToken) => {
const user = get().user;
if (user) {
const accounts = get().accounts.map((a) =>
a.user.id === user.id ? { ...a, refreshToken } : a
);
set({ accessToken, refreshToken, accounts });
} else {
set({ accessToken, refreshToken });
}
},
setUser: (user) => set({ user }), setUser: (user) => set({ user }),
clearAuth: () => clearAuth: () => {
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false }), const currentUser = get().user;
const accounts = currentUser
? get().accounts.filter((a) => a.user.id !== currentUser.id)
: get().accounts;
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
accounts,
});
},
addAccount: (user, refreshToken) => {
const accounts = get().accounts.filter((a) => a.user.id !== user.id);
accounts.push({ user, refreshToken });
set({ accounts });
},
removeAccount: (userId) => {
set({ accounts: get().accounts.filter((a) => a.user.id !== userId) });
},
switchTo: (user, accessToken, refreshToken) => {
const accounts = get().accounts.map((a) =>
a.user.id === user.id ? { ...a, refreshToken } : a
);
set({ user, accessToken, refreshToken, isAuthenticated: true, accounts });
},
}), }),
{ {
name: "auth-storage", name: "auth-storage",
@@ -35,6 +82,7 @@ export const useAuthStore = create<AuthState>()(
accessToken: state.accessToken, accessToken: state.accessToken,
refreshToken: state.refreshToken, refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated, isAuthenticated: state.isAuthenticated,
accounts: state.accounts,
}), }),
} }
) )

View File

@@ -13,18 +13,18 @@ Allow users to authenticate via Google OAuth, and switch between multiple logged
## Tasks ## Tasks
- [ ] **9.1** Add `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` to config.py + .env.example. Add `authlib` to pyproject.toml. - [x] **9.1** Add `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` to config.py + .env.example. Add `authlib` to pyproject.toml.
- [ ] **9.2** Create `backend/app/services/oauth_service.py`: register Google provider, get_authorization_url, handle_callback (fetch user info, create/link user, issue tokens). - [x] **9.2** Create `backend/app/services/oauth_service.py`: register Google provider, get_authorization_url, handle_callback (fetch user info, create/link user, issue tokens).
- [ ] **9.3** Make `User.hashed_password` nullable (OAuth users have no password). Migration 008. - [x] **9.3** Make `User.hashed_password` nullable (OAuth users have no password). Migration 008.
- [ ] **9.4** Add OAuth endpoints to auth.py: `GET /auth/oauth/{provider}/authorize`, `GET /auth/oauth/{provider}/callback`. - [x] **9.4** Add OAuth endpoints to auth.py: `GET /auth/oauth/{provider}/authorize`, `GET /auth/oauth/{provider}/callback`.
- [ ] **9.5** Add `POST /auth/switch` endpoint (accepts refresh token, returns full AuthResponse). - [x] **9.5** Add `POST /auth/switch` endpoint (accepts refresh token, returns full AuthResponse).
- [ ] **9.6** Update schemas: add oauth_provider to UserResponse. - [x] **9.6** Update schemas: add oauth_provider to UserResponse.
- [ ] **9.7** Frontend: OAuth API functions, callback route component. - [x] **9.7** Frontend: OAuth API functions, callback route component.
- [ ] **9.8** Frontend: OAuth buttons on login form ("Sign in with Google"). - [x] **9.8** Frontend: OAuth buttons on login form ("Sign in with Google").
- [ ] **9.9** Frontend: extend auth-store with accounts array, switchAccount, addAccount. - [x] **9.9** Frontend: extend auth-store with accounts array, switchAccount, addAccount.
- [ ] **9.10** Frontend: account switcher dropdown in header. - [x] **9.10** Frontend: account switcher dropdown in header.
- [ ] **9.11** Update routes, i18n (en/ru). - [x] **9.11** Update routes, i18n (en/ru).
- [ ] **9.12** Tests + verification. - [x] **9.12** Tests + verification.
--- ---
@@ -41,4 +41,4 @@ Allow users to authenticate via Google OAuth, and switch between multiple logged
## Status ## Status
**NOT STARTED** **COMPLETED**