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) => {
|
||||
|
||||
19
devfront/src/features/auth/AuthCallbackPage.tsx
Normal file
19
devfront/src/features/auth/AuthCallbackPage.tsx
Normal file
@@ -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>;
|
||||
}
|
||||
20
devfront/src/features/auth/AuthGuard.tsx
Normal file
20
devfront/src/features/auth/AuthGuard.tsx
Normal file
@@ -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 />;
|
||||
}
|
||||
87
devfront/src/features/auth/LoginPage.tsx
Normal file
87
devfront/src/features/auth/LoginPage.tsx
Normal file
@@ -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;
|
||||
22
devfront/src/features/auth/authApi.ts
Normal file
22
devfront/src/features/auth/authApi.ts
Normal file
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
20
devfront/src/lib/auth.ts
Normal file
20
devfront/src/lib/auth.ts
Normal file
@@ -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[];
|
||||
};
|
||||
|
||||
1316
devfront/src/locales/en.toml
Normal file
1316
devfront/src/locales/en.toml
Normal file
File diff suppressed because one or more lines are too long
1316
devfront/src/locales/ko.toml
Normal file
1316
devfront/src/locales/ko.toml
Normal file
File diff suppressed because one or more lines are too long
1316
devfront/src/locales/template.toml
Normal file
1316
devfront/src/locales/template.toml
Normal file
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