1
0
forked from baron/baron-sso

feat: 어드민 프론트엔드 로그인 및 로그아웃 기능 구현 #243

This commit is contained in:
2026-02-11 12:46:09 +09:00
parent afaac1781c
commit 8856485265
6 changed files with 277 additions and 97 deletions

View File

@@ -6,6 +6,7 @@ import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthPage from "../features/auth/AuthPage"; import AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage"; import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import LoginPage from "../features/auth/LoginPage";
import TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage"; import TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage";
import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage"; import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage";
import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage"; import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage";
@@ -22,6 +23,10 @@ import UserListPage from "../features/users/UserListPage";
export const router = createBrowserRouter( export const router = createBrowserRouter(
[ [
{
path: "/login",
element: <LoginPage />,
},
{ {
path: "/", path: "/",
element: <AppLayout />, element: <AppLayout />,

View File

@@ -4,107 +4,135 @@ import {
Key, Key,
KeyRound, KeyRound,
LayoutDashboard, LayoutDashboard,
LayoutGrid, LayoutGrid,
Moon, LogOut,
NotebookTabs, Moon,
ShieldHalf, NotebookTabs,
Sun, ShieldHalf,
Users, Sun,
} from "lucide-react"; Users,
import { useEffect, useState } from "react"; } from "lucide-react";
import { NavLink, Outlet } from "react-router-dom"; import { useEffect, useState } from "react";
import { t } from "../../lib/i18n"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
import RoleSwitcher from "./RoleSwitcher"; import { t } from "../../lib/i18n";
import RoleSwitcher from "./RoleSwitcher";
const navItems = [
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard }, const navItems = [
{ { label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
label: "ui.admin.nav.tenant_dashboard", {
to: "/dashboard", label: "ui.admin.nav.tenant_dashboard",
icon: ShieldHalf, to: "/dashboard",
}, icon: ShieldHalf,
{ label: "ui.admin.nav.tenant_groups", to: "/tenant-groups", icon: LayoutGrid }, },
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 }, { label: "ui.admin.nav.tenant_groups", to: "/tenant-groups", icon: LayoutGrid },
{ label: "ui.admin.nav.users", to: "/users", icon: Users }, { label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, { label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
]; { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
function AppLayout() {
const [theme, setTheme] = useState<"light" | "dark">(() => { function AppLayout() {
const stored = window.localStorage.getItem("admin_theme"); const navigate = useNavigate();
return stored === "dark" ? "dark" : "light"; const [theme, setTheme] = useState<"light" | "dark">(() => {
}); const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
useEffect(() => { });
const root = document.documentElement;
root.classList.remove("light", "dark"); const handleLogout = () => {
if (theme === "light") { if (window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))) {
root.classList.add("light"); window.localStorage.removeItem("admin_session");
} else { navigate("/login");
root.classList.add("dark"); }
} };
window.localStorage.setItem("admin_theme", theme);
}, [theme]); useEffect(() => {
const session = window.localStorage.getItem("admin_session");
const toggleTheme = () => { if (!session) {
setTheme((prev) => (prev === "light" ? "dark" : "light")); navigate("/login");
}; }
}, [navigate]);
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]"> useEffect(() => {
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur"> const root = document.documentElement;
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6"> root.classList.remove("light", "dark");
<div className="flex items-center gap-3 md:flex-col md:items-start"> if (theme === "light") {
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]"> root.classList.add("light");
<ShieldHalf size={20} /> } else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<ShieldHalf size={20} />
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.admin.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.admin.title", "Admin Control")}
</h1>
</div>
</div> </div>
<div> <div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"> <BadgeCheck size={14} />
{t("ui.admin.brand", "Baron 로그인")} {t("msg.admin.scope_admin", "Scoped to /admin")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.admin.title", "Admin Control")}
</h1>
</div> </div>
</div> </div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2"> <nav className="px-2 pb-4 md:px-3 md:pb-8 h-[calc(100vh-200px)] flex flex-col justify-between">
<BadgeCheck size={14} /> <div className="space-y-1">
{t("msg.admin.scope_admin", "Scoped to /admin")} <div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
</div> <span className="rounded-full border border-border px-3 py-1">
</div> {t("msg.admin.idp_env_prod", "IDP env: prod")}
<nav className="px-2 pb-4 md:px-3 md:pb-8"> </span>
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start"> <span className="rounded-full border border-border px-3 py-1">
<span className="rounded-full border border-border px-3 py-1"> {t("msg.admin.tenant_headers", "Tenant-aware headers")}
{t("msg.admin.idp_env_prod", "IDP env: prod")} </span>
</span> </div>
<span className="rounded-full border border-border px-3 py-1"> <div className="flex flex-col gap-1">
{t("msg.admin.tenant_headers", "Tenant-aware headers")} {navItems.map(({ label, to, icon: Icon }) => (
</span> <NavLink
</div> key={to}
<div className="flex flex-col gap-1"> to={to}
{navItems.map(({ label, to, icon: Icon }) => ( className={({ isActive }) =>
<NavLink [
key={to} "flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
to={to} isActive
className={({ isActive }) => ? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
[ : "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition", ].join(" ")
isActive }
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]" >
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground", <Icon size={18} />
].join(" ") <span>{t(label, label)}</span>
} </NavLink>
))}
</div>
</div>
<div className="px-3 pt-4 border-t border-border/50">
<button
type="button"
onClick={handleLogout}
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
> >
<Icon size={18} /> <LogOut size={18} />
<span>{t(label, label)}</span> <span>{t("ui.admin.nav.logout", "Logout")}</span>
</NavLink> </button>
))} </div>
</div> </nav>
</nav> <div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block"> <p>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
<p>
{t( {t(
"msg.admin.notice.scope", "msg.admin.notice.scope",
"관리 기능은 /admin 네임스페이스에서만 노출합니다.", "관리 기능은 /admin 네임스페이스에서만 노출합니다.",

View File

@@ -0,0 +1,123 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { KeyRound, Lock, Mail, ShieldHalf } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { login } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
function LoginPage() {
const navigate = useNavigate();
const [loginId, setLoginId] = useState("");
const [password, setPassword] = useState("");
const loginMutation = useMutation({
mutationFn: () => login({ loginId, password }),
onSuccess: (data) => {
window.localStorage.setItem("admin_session", data.sessionToken);
navigate("/");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
loginMutation.mutate();
};
const errorMsg = (loginMutation.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
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]">
Admin 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">
<KeyRound size={20} className="text-primary" />
Sign in
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="loginId">ID (Email)</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="loginId"
type="email"
placeholder="admin@baron.co.kr"
className="pl-10"
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
className="pl-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive animate-in fade-in zoom-in duration-200">
{errorMsg}
</div>
)}
<Button
type="submit"
className="w-full h-11 text-base font-semibold"
disabled={loginMutation.isPending}
>
{loginMutation.isPending ? "Signing in..." : "Login to Dashboard"}
</Button>
</form>
</CardContent>
</Card>
<p className="px-8 text-center text-sm text-muted-foreground">
<br />
.
</p>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -95,6 +95,26 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
return data; return data;
} }
// Authentication
export type LoginRequest = {
loginId: string;
password?: string;
};
export type LoginResponse = {
sessionToken: string;
refreshToken?: string;
userId?: string;
};
export async function login(payload: LoginRequest) {
const { data } = await apiClient.post<LoginResponse>(
"/v1/auth/password/login",
payload,
);
return data;
}
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) { export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
const { data } = await apiClient.get<TenantListResponse>( const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants", "/v1/admin/tenants",

View File

@@ -1306,3 +1306,5 @@ verify = "Verify"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "Action" action = "Action"
msg.admin.logout_confirm = "Are you sure you want to log out?"
ui.admin.nav.logout = "Logout"

View File

@@ -1306,3 +1306,5 @@ verify = "본인인증"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "로그인하기" action = "로그인하기"
msg.admin.logout_confirm = "로그아웃 하시겠습니까?"
ui.admin.nav.logout = "로그아웃"