forked from baron/baron-sso
[#365] Devfront 프로필 메뉴 개선 및 상세 페이지 구현
This commit is contained in:
@@ -8,6 +8,7 @@ import ClientConsentsPage from "../features/clients/ClientConsentsPage";
|
|||||||
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
||||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||||
import ClientsPage from "../features/clients/ClientsPage";
|
import ClientsPage from "../features/clients/ClientsPage";
|
||||||
|
import ProfilePage from "../features/profile/ProfilePage";
|
||||||
|
|
||||||
export const router = createBrowserRouter(
|
export const router = createBrowserRouter(
|
||||||
[
|
[
|
||||||
@@ -33,6 +34,7 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||||
|
{ path: "profile", element: <ProfilePage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -5,6 +6,7 @@ import {
|
|||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sun,
|
Sun,
|
||||||
|
User as UserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
@@ -13,6 +15,8 @@ import { t } from "../../lib/i18n";
|
|||||||
import { resolveProfileRole } from "../../lib/role";
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import { Toaster } from "../ui/toaster";
|
import { Toaster } from "../ui/toaster";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { fetchMe } from "../../features/auth/authApi";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
@@ -41,6 +45,13 @@ function AppLayout() {
|
|||||||
const [isRefreshingSession, setIsRefreshingSession] = useState(false);
|
const [isRefreshingSession, setIsRefreshingSession] = useState(false);
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
|
const { data: profile } = useQuery({
|
||||||
|
queryKey: ["userMe"],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
enabled: hasAccessToken,
|
||||||
|
});
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) {
|
if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) {
|
||||||
auth.removeUser();
|
auth.removeUser();
|
||||||
@@ -100,6 +111,10 @@ function AppLayout() {
|
|||||||
const currentRole = resolveProfileRole(
|
const currentRole = resolveProfileRole(
|
||||||
auth.user?.profile as Record<string, unknown> | undefined,
|
auth.user?.profile as Record<string, unknown> | undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Use profile.role from API if available, otherwise fallback to local role
|
||||||
|
const displayRoleKey = profile?.role || currentRole;
|
||||||
|
|
||||||
const isDevConsoleAllowed = [
|
const isDevConsoleAllowed = [
|
||||||
"super_admin",
|
"super_admin",
|
||||||
"tenant_admin",
|
"tenant_admin",
|
||||||
@@ -306,14 +321,35 @@ function AppLayout() {
|
|||||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
{t("ui.dev.profile.menu_title", "Account")}
|
{t("ui.dev.profile.menu_title", "Account")}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 rounded-lg border border-border px-3 py-2">
|
<div className="mt-2 rounded-lg border border-border px-3 py-3 flex flex-col gap-2">
|
||||||
<p className="truncate text-sm font-semibold text-foreground">
|
<div>
|
||||||
{profileName}
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
</p>
|
{profileName}
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
</p>
|
||||||
{profileEmail}
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
</p>
|
{profileEmail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center pt-1">
|
||||||
|
<Badge variant="outline" className="text-[10px] px-2 py-0">
|
||||||
|
{t(`ui.common.role.${displayRoleKey}`, displayRoleKey.toUpperCase())}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="mt-2 w-full flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/profile");
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserIcon size={16} className="text-muted-foreground" />
|
||||||
|
<span>{t("ui.dev.profile.title", "내 정보")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ function ClientConsentsPage() {
|
|||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs text-muted-foreground p-0 h-auto"
|
className="text-xs text-muted-foreground p-0 h-auto"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ function ClientsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs text-muted-foreground ml-auto"
|
className="text-xs text-muted-foreground ml-auto"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -291,7 +291,7 @@ function ClientsPage() {
|
|||||||
item.tone === "up"
|
item.tone === "up"
|
||||||
? "success"
|
? "success"
|
||||||
: item.tone === "down"
|
: item.tone === "down"
|
||||||
? "destructive"
|
? "warning"
|
||||||
: "muted"
|
: "muted"
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
186
devfront/src/features/profile/ProfilePage.tsx
Normal file
186
devfront/src/features/profile/ProfilePage.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { User, Shield, Briefcase, Mail, Fingerprint, Building2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { fetchMe } from "../auth/authApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
|
function ProfilePage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
|
|
||||||
|
const { data: profile, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["userMe"],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
enabled: hasAccessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<"basic" | "role">("basic");
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
{t("ui.dev.profile.loading", "Loading profile...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !profile) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-red-500">
|
||||||
|
{t("ui.dev.profile.error", "Failed to load profile information.")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to token information if API data is incomplete
|
||||||
|
const displayTenant = profile.tenant?.name || profile.tenantId || auth.user?.profile?.tenant_id?.toString() || "-";
|
||||||
|
const displayCompanyCode = profile.companyCode || auth.user?.profile?.companyCode?.toString() || "-";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl mx-auto">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-black tracking-tight">
|
||||||
|
{t("ui.dev.profile.title", "내 정보")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
{t(
|
||||||
|
"ui.dev.profile.subtitle",
|
||||||
|
"사용자 상세 정보 및 할당된 역할(Role)을 확인합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-1 border-b border-border pb-px">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("basic")}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === "basic"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("ui.dev.profile.tab.basic", "기본 정보")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("role")}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === "role"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("ui.dev.profile.tab.role", "권한 및 역할")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
{activeTab === "basic" && (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<User className="h-5 w-5 text-primary" />
|
||||||
|
{t("ui.dev.profile.basic.title", "사용자 정보")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<Fingerprint className="h-4 w-4" />
|
||||||
|
{t("ui.dev.profile.basic.id", "User ID")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm break-all font-mono bg-muted/50 p-2 rounded-md">
|
||||||
|
{profile.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
{t("ui.dev.profile.basic.name", "Name")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{profile.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
{t("ui.dev.profile.basic.email", "Email")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{profile.email}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
|
{t("ui.dev.profile.org.title", "조직 정보")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("ui.dev.profile.org.tenant", "테넌트")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{displayTenant}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("ui.dev.profile.org.company_code", "회사 코드")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{displayCompanyCode}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "role" && (
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-primary" />
|
||||||
|
{t("ui.dev.profile.role.title", "시스템 역할")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"ui.dev.profile.role.description",
|
||||||
|
"현재 계정에 부여된 권한 등급입니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4 bg-muted/30 p-4 rounded-lg border border-border">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||||
|
<Briefcase className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground font-medium uppercase tracking-wider">
|
||||||
|
{t("ui.dev.profile.role.current", "Current Role")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold mt-1">
|
||||||
|
{t(`ui.common.role.${profile.role}`, profile.role.toUpperCase())}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePage;
|
||||||
@@ -1399,6 +1399,36 @@ verify = "Verify"
|
|||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Action"
|
action = "Action"
|
||||||
|
|
||||||
|
[ui.dev.profile]
|
||||||
|
unknown_name = "Unknown User"
|
||||||
|
unknown_email = "unknown@example.com"
|
||||||
|
menu_aria = "Open account menu"
|
||||||
|
menu_title = "Account"
|
||||||
|
title = "Profile"
|
||||||
|
subtitle = "View user details and assigned roles."
|
||||||
|
loading = "Loading profile..."
|
||||||
|
error = "Failed to load profile."
|
||||||
|
|
||||||
|
[ui.dev.profile.tab]
|
||||||
|
basic = "Basic Info"
|
||||||
|
role = "Roles & Permissions"
|
||||||
|
|
||||||
|
[ui.dev.profile.basic]
|
||||||
|
title = "User Info"
|
||||||
|
id = "User ID"
|
||||||
|
name = "Name"
|
||||||
|
email = "Email"
|
||||||
|
|
||||||
|
[ui.dev.profile.org]
|
||||||
|
title = "Organization Info"
|
||||||
|
tenant = "Tenant"
|
||||||
|
company_code = "Company Code"
|
||||||
|
|
||||||
|
[ui.dev.profile.role]
|
||||||
|
title = "System Role"
|
||||||
|
description = "The permission level granted to this account."
|
||||||
|
current = "Current Role"
|
||||||
|
|
||||||
[ui.admin.nav]
|
[ui.admin.nav]
|
||||||
api_keys = "API Keys"
|
api_keys = "API Keys"
|
||||||
audit_logs = "Audit Logs"
|
audit_logs = "Audit Logs"
|
||||||
|
|||||||
@@ -1411,3 +1411,33 @@ tenant_dashboard = "테넌트 대시보드"
|
|||||||
user_groups = "유저 그룹"
|
user_groups = "유저 그룹"
|
||||||
tenants = "테넌트"
|
tenants = "테넌트"
|
||||||
users = "사용자"
|
users = "사용자"
|
||||||
|
|
||||||
|
[ui.dev.profile]
|
||||||
|
unknown_name = "알 수 없는 사용자"
|
||||||
|
unknown_email = "unknown@example.com"
|
||||||
|
menu_aria = "계정 메뉴 열기"
|
||||||
|
menu_title = "Account"
|
||||||
|
title = "내 정보"
|
||||||
|
subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다."
|
||||||
|
loading = "프로필 정보를 불러오는 중..."
|
||||||
|
error = "프로필 정보를 불러오지 못했습니다."
|
||||||
|
|
||||||
|
[ui.dev.profile.tab]
|
||||||
|
basic = "기본 정보"
|
||||||
|
role = "권한 및 역할"
|
||||||
|
|
||||||
|
[ui.dev.profile.basic]
|
||||||
|
title = "사용자 정보"
|
||||||
|
id = "사용자 ID"
|
||||||
|
name = "이름"
|
||||||
|
email = "이메일"
|
||||||
|
|
||||||
|
[ui.dev.profile.org]
|
||||||
|
title = "조직 정보"
|
||||||
|
tenant = "테넌트"
|
||||||
|
company_code = "회사 코드"
|
||||||
|
|
||||||
|
[ui.dev.profile.role]
|
||||||
|
title = "시스템 역할"
|
||||||
|
description = "현재 계정에 부여된 권한 등급입니다."
|
||||||
|
current = "현재 역할"
|
||||||
|
|||||||
@@ -1410,3 +1410,33 @@ verify = ""
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = ""
|
action = ""
|
||||||
|
|
||||||
|
[ui.dev.profile]
|
||||||
|
unknown_name = ""
|
||||||
|
unknown_email = ""
|
||||||
|
menu_aria = ""
|
||||||
|
menu_title = ""
|
||||||
|
title = ""
|
||||||
|
subtitle = ""
|
||||||
|
loading = ""
|
||||||
|
error = ""
|
||||||
|
|
||||||
|
[ui.dev.profile.tab]
|
||||||
|
basic = ""
|
||||||
|
role = ""
|
||||||
|
|
||||||
|
[ui.dev.profile.basic]
|
||||||
|
title = ""
|
||||||
|
id = ""
|
||||||
|
name = ""
|
||||||
|
email = ""
|
||||||
|
|
||||||
|
[ui.dev.profile.org]
|
||||||
|
title = ""
|
||||||
|
tenant = ""
|
||||||
|
company_code = ""
|
||||||
|
|
||||||
|
[ui.dev.profile.role]
|
||||||
|
title = ""
|
||||||
|
description = ""
|
||||||
|
current = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user