forked from baron/baron-sso
feat: 어드민 프론트엔드 로그인 및 로그아웃 기능 구현 #243
This commit is contained in:
@@ -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 />,
|
||||||
|
|||||||
@@ -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 = [
|
const navItems = [
|
||||||
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
||||||
{
|
{
|
||||||
label: "ui.admin.nav.tenant_dashboard",
|
label: "ui.admin.nav.tenant_dashboard",
|
||||||
to: "/dashboard",
|
to: "/dashboard",
|
||||||
icon: ShieldHalf,
|
icon: ShieldHalf,
|
||||||
},
|
},
|
||||||
{ label: "ui.admin.nav.tenant_groups", to: "/tenant-groups", icon: LayoutGrid },
|
{ label: "ui.admin.nav.tenant_groups", to: "/tenant-groups", icon: LayoutGrid },
|
||||||
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
|
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
|
||||||
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
||||||
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
||||||
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
||||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
||||||
];
|
];
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
const navigate = useNavigate();
|
||||||
const stored = window.localStorage.getItem("admin_theme");
|
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||||
return stored === "dark" ? "dark" : "light";
|
const stored = window.localStorage.getItem("admin_theme");
|
||||||
});
|
return stored === "dark" ? "dark" : "light";
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const handleLogout = () => {
|
||||||
const root = document.documentElement;
|
if (window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))) {
|
||||||
root.classList.remove("light", "dark");
|
window.localStorage.removeItem("admin_session");
|
||||||
if (theme === "light") {
|
navigate("/login");
|
||||||
root.classList.add("light");
|
}
|
||||||
} else {
|
};
|
||||||
root.classList.add("dark");
|
|
||||||
}
|
|
||||||
window.localStorage.setItem("admin_theme", theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
useEffect(() => {
|
||||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
const session = window.localStorage.getItem("admin_session");
|
||||||
};
|
if (!session) {
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
const root = document.documentElement;
|
||||||
<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">
|
root.classList.remove("light", "dark");
|
||||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
if (theme === "light") {
|
||||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
root.classList.add("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)]">
|
} else {
|
||||||
<ShieldHalf size={20} />
|
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 네임스페이스에서만 노출합니다.",
|
||||||
|
|||||||
123
adminfront/src/features/auth/LoginPage.tsx
Normal file
123
adminfront/src/features/auth/LoginPage.tsx
Normal 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;
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1306,3 +1306,5 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
action = "로그인하기"
|
||||||
|
msg.admin.logout_confirm = "로그아웃 하시겠습니까?"
|
||||||
|
ui.admin.nav.logout = "로그아웃"
|
||||||
|
|||||||
Reference in New Issue
Block a user