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:
@@ -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",
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
"passwordMinLength": "Пароль должен содержать минимум 8 символов",
|
||||
"usernameFormat": "Имя пользователя: 3-50 символов (буквы, цифры, _ или -)",
|
||||
"required": "Это поле обязательно"
|
||||
}
|
||||
},
|
||||
"orDivider": "или войти через",
|
||||
"oauthGoogle": "Войти через Google",
|
||||
"oauthAuthentik": "Войти через Authentik",
|
||||
"addAccount": "Добавить аккаунт",
|
||||
"switchAccount": "Сменить аккаунт"
|
||||
},
|
||||
"layout": {
|
||||
"dashboard": "Главная",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
37
frontend/src/components/auth/oauth-callback.tsx
Normal file
37
frontend/src/components/auth/oauth-callback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user