forked from baron/baron-sso
Merge branch 'dev' into feature/i18n
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { Navigate, createBrowserRouter } from "react-router-dom";
|
||||
import AppLayout from "../components/layout/AppLayout";
|
||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
import AuthGuard from "../features/auth/AuthGuard";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import ClientConsentsPage from "../features/clients/ClientConsentsPage";
|
||||
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||
@@ -7,16 +10,29 @@ import ClientsPage from "../features/clients/ClientsPage";
|
||||
|
||||
export const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: "/callback",
|
||||
element: <AuthCallbackPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AppLayout />,
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/clients" replace /> },
|
||||
{ path: "clients", element: <ClientsPage /> },
|
||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/clients" replace /> },
|
||||
{ path: "clients", element: <ClientsPage /> },
|
||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react";
|
||||
import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { t } from "../../lib/i18n";
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
|
||||
@@ -26,7 +26,8 @@ const badgeVariants = cva(
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
|
||||
@@ -34,7 +34,8 @@ const buttonVariants = cva(
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
navigate("/", { replace: true });
|
||||
} else if (auth.error) {
|
||||
console.error("Auth Error:", auth.error);
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.error, navigate]);
|
||||
|
||||
return <div>Loading Auth...</div>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
|
||||
if (auth.isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (auth.error) {
|
||||
return <div>Auth Error: {auth.error.message}</div>;
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ShieldHalf, LogIn, ExternalLink } from "lucide-react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
|
||||
function LoginPage() {
|
||||
const auth = useAuth();
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
// OIDC client-side authentication flow started here
|
||||
auth.signinRedirect();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
|
||||
<ShieldHalf size={32} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
|
||||
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
|
||||
Developer Control Plane
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
<LogIn size={20} className="text-primary" />
|
||||
개발자 포털 로그인
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 pb-8 space-y-3">
|
||||
<Button
|
||||
onClick={handleSSOLogin}
|
||||
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
|
||||
disabled={auth.isLoading}
|
||||
>
|
||||
{auth.isLoading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
로그인 진행 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldHalf size={22} />
|
||||
SSO 계정으로 로그인
|
||||
<ExternalLink size={16} className="opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||
개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.<br />
|
||||
민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
||||
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
|
||||
</div>
|
||||
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
인증 정보가 없거나 로그인이 되지 않는 경우<br />
|
||||
시스템 관리자에게 문의하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
@@ -0,0 +1,22 @@
|
||||
import apiClient from "../../lib/apiClient";
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
companyCode?: string;
|
||||
tenantId?: string;
|
||||
tenant?: Tenant;
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
const { data } = await apiClient.get<UserProfile>("/user/me");
|
||||
return data;
|
||||
}
|
||||
@@ -211,14 +211,10 @@ function ClientsPage() {
|
||||
variant={
|
||||
item.tone === "up"
|
||||
? "success"
|
||||
: item.tone === "down"
|
||||
? "warning"
|
||||
: "muted"
|
||||
: "muted"
|
||||
}
|
||||
className={cn(
|
||||
"px-2",
|
||||
item.tone === "down" &&
|
||||
"bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-200",
|
||||
item.tone === "stable" && "bg-muted/40 text-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||
background-image: radial-gradient(
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at 10% 18%,
|
||||
rgba(54, 211, 153, 0.16),
|
||||
transparent 28%
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { userManager } from "./auth";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL:
|
||||
@@ -7,15 +8,15 @@ const apiClient = axios.create({
|
||||
"/api/v1",
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
|
||||
const sessionToken = window.localStorage.getItem("admin_session");
|
||||
if (sessionToken) {
|
||||
config.headers.Authorization = `Bearer ${sessionToken}`;
|
||||
apiClient.interceptors.request.use(async (config) => {
|
||||
// OIDC Access Token 주입
|
||||
const user = await userManager.getUser();
|
||||
if (user?.access_token) {
|
||||
config.headers.Authorization = `Bearer ${user.access_token}`;
|
||||
}
|
||||
|
||||
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
|
||||
const tenantId = window.localStorage.getItem("admin_tenant");
|
||||
const tenantId = window.localStorage.getItem("dev_tenant_id"); // 키 이름을 좀 더 명확하게 변경 고려
|
||||
if (tenantId) {
|
||||
config.headers["X-Tenant-ID"] = tenantId;
|
||||
}
|
||||
@@ -26,7 +27,13 @@ apiClient.interceptors.request.use((config) => {
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
|
||||
if (error.response?.status === 401) {
|
||||
// 401 발생 시 로그인 페이지로 리다이렉트
|
||||
const isAuthPath = window.location.pathname.startsWith("/callback");
|
||||
if (!isAuthPath) {
|
||||
userManager.signinRedirect();
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||
import type { AuthProviderProps } from "react-oidc-context";
|
||||
|
||||
export const oidcConfig: AuthProviderProps = {
|
||||
authority: import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
|
||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront-client",
|
||||
redirect_uri: `${window.location.origin}/callback`,
|
||||
response_type: "code",
|
||||
scope: "openid offline_access profile email", // offline_access for refresh token
|
||||
post_logout_redirect_uri: window.location.origin,
|
||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||
automaticSilentRenew: true,
|
||||
};
|
||||
|
||||
export const userManager = new UserManager({
|
||||
...oidcConfig,
|
||||
authority: oidcConfig.authority || "",
|
||||
client_id: oidcConfig.client_id || "",
|
||||
redirect_uri: oidcConfig.redirect_uri || "",
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export type ClientSummary = {
|
||||
type: ClientType;
|
||||
status: ClientStatus;
|
||||
createdAt?: string;
|
||||
clientSecret?: string;
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { AuthProvider } from "react-oidc-context";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { router } from "./app/routes";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import "./index.css";
|
||||
import { oidcConfig } from "./lib/auth";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
@@ -15,9 +17,11 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
<AuthProvider {...oidcConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user