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

@@ -22,7 +22,12 @@
"passwordMinLength": "Password must be at least 8 characters",
"usernameFormat": "Username must be 3-50 characters (letters, numbers, _ or -)",
"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": {
"dashboard": "Dashboard",

View File

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

View File

@@ -8,6 +8,8 @@ export interface UserResponse {
role: string;
is_active: boolean;
max_chats: number;
oauth_provider: string | null;
avatar_url: string | null;
created_at: string;
}
@@ -59,6 +61,18 @@ export async function refresh(refreshToken: string): Promise<TokenResponse> {
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> {
await api.post("/auth/logout", { refresh_token: refreshToken });
}

View File

@@ -2,7 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { login } from "@/api/auth";
import { login, getOAuthUrl } from "@/api/auth";
export function LoginForm() {
const { t } = useTranslation();
@@ -92,6 +92,27 @@ export function LoginForm() {
{loading ? t("common.loading") : t("auth.login")}
</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">
{t("auth.noAccount")}{" "}
<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 { 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 { useUIStore } from "@/stores/ui-store";
import { LanguageToggle } from "@/components/shared/language-toggle";
@@ -11,7 +11,7 @@ export function Header() {
const { t } = useTranslation();
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const { refreshToken, clearAuth } = useAuthStore();
const { refreshToken, clearAuth, accounts, switchTo } = useAuthStore();
const { theme, setTheme, toggleSidebar } = useUIStore();
const handleLogout = async () => {
@@ -52,13 +52,42 @@ export function Header() {
{theme === "dark" ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
</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">
<User className="h-4 w-4" />
</div>
<span className="hidden text-sm font-medium sm:block">
{user?.full_name || user?.username}
</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
onClick={handleLogout}
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 { LoginPage } from "@/pages/login";
import { RegisterPage } from "@/pages/register";
import { OAuthCallback } from "@/components/auth/oauth-callback";
import { DashboardPage } from "@/pages/dashboard";
import { ChatPage } from "@/pages/chat";
import { SkillsPage } from "@/pages/skills";
@@ -28,6 +29,10 @@ export const router = createBrowserRouter([
path: "/register",
element: <RegisterPage />,
},
{
path: "/auth/callback",
element: <OAuthCallback />,
},
{
element: <ProtectedRoute />,
children: [

View File

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