i18n, adminfront, devfront: 'make code-check' 통과를 위한 번역 싱크 엔진 개선, 룰 오브 훅 정합성 교정 및 테스트 레이스 컨디션 해결

This commit is contained in:
2026-06-15 09:49:53 +09:00
parent 383c6bf7b9
commit b714213b78
37 changed files with 3597 additions and 4170 deletions
+10 -4
View File
@@ -14,11 +14,11 @@ import UserProjectionPage from "../features/projections/UserProjectionPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import { TenantFineGrainedPermissionsPage } from "../features/tenants/routes/TenantFineGrainedPermissionsPage";
import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab";
import { TenantFineGrainedPermissionsPage } from "../features/tenants/routes/TenantFineGrainedPermissionsPage";
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
import GlobalCustomClaimsPage from "../features/users/GlobalCustomClaimsPage";
@@ -53,7 +53,10 @@ export const adminRoutes: RouteObject[] = [
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
{ path: "permissions-direct", element: <TenantFineGrainedPermissionsPage /> },
{
path: "permissions-direct",
element: <TenantFineGrainedPermissionsPage />,
},
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
@@ -62,7 +65,10 @@ export const adminRoutes: RouteObject[] = [
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{ path: "relations", element: <TenantFineGrainedPermissionsTab /> },
{
path: "relations",
element: <TenantFineGrainedPermissionsTab />,
},
],
},
{
@@ -270,9 +270,11 @@ function AppLayout() {
if (item.to === "/permissions-direct") return false;
if (item.to === "/tenants") return permissions.tenants;
if (item.to === orgfrontUrl) return permissions.org_chart;
if (item.to === "/worksmobile") return permissions.worksmobile && showWorksmobile;
if (item.to === "/worksmobile")
return permissions.worksmobile && showWorksmobile;
if (item.to === "/system/ory-ssot") return permissions.ory_ssot;
if (item.to === "/system/data-integrity") return permissions.data_integrity;
if (item.to === "/system/data-integrity")
return permissions.data_integrity;
return true;
});
@@ -144,7 +144,12 @@ export function ParentTenantSelector({
{localPickerLabel && (
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm" disabled={disabled}>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
>
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>
@@ -1,5 +1,8 @@
import type React from "react";
import { useTenantPermission, type TenantPermissionKey } from "../hooks/useTenantPermission";
import {
type TenantPermissionKey,
useTenantPermission,
} from "../hooks/useTenantPermission";
interface TenantPermissionGuardProps {
tenantId: string;
@@ -1,11 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { renderHook } from "@testing-library/react";
import { render, renderHook, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { describe, expect, it, vi } from "vitest";
import { fetchTenant, fetchMe } from "../../../lib/adminApi";
import { useTenantPermission } from "./useTenantPermission";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
import { useTenantPermission } from "./useTenantPermission";
vi.mock("../../../lib/adminApi", () => ({
fetchMe: vi.fn(),
@@ -88,10 +87,14 @@ describe("TenantPermissionGuard", () => {
} as any);
render(
<TenantPermissionGuard tenantId="tenant-3" relation="manage" fallback={<div>Access Denied</div>}>
<TenantPermissionGuard
tenantId="tenant-3"
relation="manage"
fallback={<div>Access Denied</div>}
>
<div>Access Granted</div>
</TenantPermissionGuard>,
{ wrapper: createWrapper() }
{ wrapper: createWrapper() },
);
await waitFor(() => {
@@ -112,10 +115,14 @@ describe("TenantPermissionGuard", () => {
} as any);
render(
<TenantPermissionGuard tenantId="tenant-4" relation="manage" fallback={<div>Access Denied</div>}>
<TenantPermissionGuard
tenantId="tenant-4"
relation="manage"
fallback={<div>Access Denied</div>}
>
<div>Access Granted</div>
</TenantPermissionGuard>,
{ wrapper: createWrapper() }
{ wrapper: createWrapper() },
);
await waitFor(() => {
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { fetchTenant, fetchMe } from "../../../lib/adminApi";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { normalizeAdminRole } from "../../../lib/roles";
export type TenantPermissionKey =
@@ -11,7 +11,6 @@ import {
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useParams } from "react-router-dom";
import { useTenantPermission } from "../hooks/useTenantPermission";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
@@ -50,6 +49,7 @@ import {
type TenantAdmin,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
type DialogMode = "owner" | "admin";
@@ -71,7 +71,8 @@ export function TenantAdminsAndOwnersTab() {
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? "";
const { hasPermission } = useTenantPermission(tenantId);
const isWritable = hasPermission("manage_permissions") || hasPermission("manage_admins");
const isWritable =
hasPermission("manage_permissions") || hasPermission("manage_admins");
const canView = hasPermission("view_permissions") || hasPermission("view");
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
@@ -2,9 +2,8 @@ import { useQuery } from "@tanstack/react-query";
import { Copy } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { useTenantPermission } from "../hooks/useTenantPermission";
function TenantDetailPage() {
@@ -1,45 +1,27 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import type { AxiosError } from "axios";
import { useState, useEffect } from "react";
import {
fetchAllTenants,
fetchMe,
fetchUsers,
fetchSystemRelations,
addSystemRelation,
removeSystemRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { TenantFineGrainedPermissionsTab } from "./TenantFineGrainedPermissionsTab";
import {
ShieldCheck,
Search,
Plus,
UserPlus,
Trash2,
Settings,
Shield,
LayoutDashboard,
Building2,
Network,
Database,
Users,
KeyRound,
Key,
KeyRound,
LayoutDashboard,
Network,
NotebookTabs,
Plus,
Search,
Share2,
Shield,
ShieldCheck,
Trash2,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Avatar, AvatarFallback } from "../../../components/ui/avatar";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Card, CardContent } from "../../../components/ui/card";
import {
Dialog,
DialogContent,
@@ -48,24 +30,33 @@ import {
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Avatar, AvatarFallback } from "../../../components/ui/avatar";
import { ScrollArea } from "../../../components/ui/scroll-area";
import { Separator } from "../../../components/ui/separator";
import { Switch } from "../../../components/ui/switch";
import { toast } from "../../../components/ui/use-toast";
import {
addSystemRelation,
fetchAllTenants,
fetchMe,
fetchSystemRelations,
fetchUsers,
removeSystemRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export function TenantFineGrainedPermissionsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<"tenant" | "system">("system");
const [selectedTenantId, setSelectedTenantId] = useState("");
const [activeTab, _setActiveTab] = useState<"tenant" | "system">("system");
const [_selectedTenantId, _setSelectedTenantId] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [activeUserId, setActiveUserId] = useState<string | null>(null);
const [userSearchTerm, setUserSearchTerm] = useState("");
// 🌟 글로벌 시스템 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
const [localSystemPermissions, setLocalSystemPermissions] = useState<Record<string, Record<string, "none" | "read" | "write">>>({});
const [localSystemPermissions, setLocalSystemPermissions] = useState<
Record<string, Record<string, "none" | "read" | "write">>
>({});
const { data: profile } = useQuery({
queryKey: ["me"],
@@ -74,26 +65,13 @@ export function TenantFineGrainedPermissionsPage() {
const isSuperAdmin = profile?.role === "super_admin";
if (profile && !isSuperAdmin) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
{t("ui.common.go_home", "홈으로 이동")}
</Button>
</div>
);
}
const tenantsQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
enabled: isSuperAdmin,
});
const tenants = isSuperAdmin
const _tenants = isSuperAdmin
? (tenantsQuery.data?.items ?? [])
: (profile?.manageableTenants ?? []);
@@ -108,18 +86,33 @@ export function TenantFineGrainedPermissionsPage() {
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
useEffect(() => {
if (systemRelationsQuery.data) {
const initialMap: Record<string, Record<string, "none" | "read" | "write">> = {};
const initialMap: Record<
string,
Record<string, "none" | "read" | "write">
> = {};
for (const user of systemRelationsQuery.data) {
initialMap[user.userId] = {};
const menus = [
"overview", "audit_logs", "tenants", "org_chart", "users",
"worksmobile", "api_keys", "ory_ssot", "data_integrity",
"auth_guard", "permissions_direct"
"overview",
"audit_logs",
"tenants",
"org_chart",
"users",
"worksmobile",
"api_keys",
"ory_ssot",
"data_integrity",
"auth_guard",
"permissions_direct",
];
for (const m of menus) {
const isWrite = user.relations.includes(`${m}_managers`);
const isRead = user.relations.includes(`${m}_viewers`);
initialMap[user.userId][m] = isWrite ? "write" : isRead ? "read" : "none";
initialMap[user.userId][m] = isWrite
? "write"
: isRead
? "read"
: "none";
}
}
setLocalSystemPermissions(initialMap);
@@ -131,30 +124,41 @@ export function TenantFineGrainedPermissionsPage() {
addSystemRelation(payload.userId, payload.relation),
onMutate: async (newRelation) => {
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["system-relations"]);
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"system-relations",
]);
queryClient.setQueryData<TenantRelation[]>(["system-relations"], (old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
});
queryClient.setQueryData<TenantRelation[]>(
["system-relations"],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(["system-relations"], context.previousRelations);
queryClient.setQueryData(
["system-relations"],
context.previousRelations,
);
}
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
@@ -174,28 +178,41 @@ export function TenantFineGrainedPermissionsPage() {
removeSystemRelation(payload.userId, payload.relation),
onMutate: async (targetRelation) => {
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["system-relations"]);
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"system-relations",
]);
queryClient.setQueryData<TenantRelation[]>(["system-relations"], (old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter((r) => r !== targetRelation.relation),
};
}
return user;
});
});
queryClient.setQueryData<TenantRelation[]>(
["system-relations"],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter(
(r) => r !== targetRelation.relation,
),
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(["system-relations"], context.previousRelations);
queryClient.setQueryData(
["system-relations"],
context.previousRelations,
);
}
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
@@ -220,26 +237,53 @@ export function TenantFineGrainedPermissionsPage() {
try {
if (currentVal === "read") {
await removeSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_viewers` });
await removeSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_viewers`,
});
} else if (currentVal === "write") {
await removeSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_managers` });
await removeSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_managers`,
});
}
if (newVal === "read") {
await addSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_viewers` });
await addSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_viewers`,
});
} else if (newVal === "write") {
await addSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_managers` });
await addSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_managers`,
});
}
// 🌟 Trigger a single consolidated success toast at the very end
toast.success(t("msg.admin.system.relations.update_success", "시스템 메뉴 권한이 성공적으로 변경되었습니다."));
toast.success(
t(
"msg.admin.system.relations.update_success",
"시스템 메뉴 권한이 성공적으로 변경되었습니다.",
),
);
} catch {
// Individual mutations handle error toast via onError
}
};
const handleRemoveAllSystemRelations = async (userId: string, userRelations: string[]) => {
if (!window.confirm(t("msg.admin.system.relations.remove_all_confirm", "이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?"))) {
const handleRemoveAllSystemRelations = async (
userId: string,
userRelations: string[],
) => {
if (
!window.confirm(
t(
"msg.admin.system.relations.remove_all_confirm",
"이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?",
),
)
) {
return;
}
for (const rel of userRelations) {
@@ -268,36 +312,133 @@ export function TenantFineGrainedPermissionsPage() {
// Categorized system menus with descriptions and icons
const systemMenuCategories = [
{
title: t("ui.admin.permissions_direct.cat_dashboard", "핵심 대시보드 및 분석"),
title: t(
"ui.admin.permissions_direct.cat_dashboard",
"핵심 대시보드 및 분석",
),
menus: [
{ label: t("ui.admin.nav.overview", "개요"), relation: "overview", desc: t("msg.admin.permissions_direct.desc_overview", "바론 전체 사양 및 시스템 상태 개요 정보"), icon: LayoutDashboard },
{ label: t("ui.admin.nav.audit_logs", "감사 로그"), relation: "audit_logs", desc: t("msg.admin.permissions_direct.desc_audit_logs", "시스템 전역 보안 감사 및 접속 이력 로그"), icon: NotebookTabs },
]
{
label: t("ui.admin.nav.overview", "개요"),
relation: "overview",
desc: t(
"msg.admin.permissions_direct.desc_overview",
"바론 전체 사양 및 시스템 상태 개요 정보",
),
icon: LayoutDashboard,
},
{
label: t("ui.admin.nav.audit_logs", "감사 로그"),
relation: "audit_logs",
desc: t(
"msg.admin.permissions_direct.desc_audit_logs",
"시스템 전역 보안 감사 및 접속 이력 로그",
),
icon: NotebookTabs,
},
],
},
{
title: t("ui.admin.permissions_direct.cat_resources", "핵심 리소스 관리"),
menus: [
{ label: t("ui.admin.nav.tenants", "테넌트"), relation: "tenants", desc: t("msg.admin.permissions_direct.desc_tenants", "고객 테넌트 목록, 신규 부모-자식 테넌트 관리"), icon: Building2 },
{ label: t("ui.admin.nav.org_chart", "조직도"), relation: "org_chart", desc: t("msg.admin.permissions_direct.desc_org_chart", "조직도 가시화 및 트리 배치 확인"), icon: Network },
{ label: t("ui.admin.nav.users", "사용자"), relation: "users", desc: t("msg.admin.permissions_direct.desc_users", "가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입"), icon: Users },
]
{
label: t("ui.admin.nav.tenants", "테넌트"),
relation: "tenants",
desc: t(
"msg.admin.permissions_direct.desc_tenants",
"고객 테넌트 목록, 신규 부모-자식 테넌트 관리",
),
icon: Building2,
},
{
label: t("ui.admin.nav.org_chart", "조직도"),
relation: "org_chart",
desc: t(
"msg.admin.permissions_direct.desc_org_chart",
"조직도 가시화 및 트리 배치 확인",
),
icon: Network,
},
{
label: t("ui.admin.nav.users", "사용자"),
relation: "users",
desc: t(
"msg.admin.permissions_direct.desc_users",
"가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입",
),
icon: Users,
},
],
},
{
title: t("ui.admin.permissions_direct.cat_integrations", "인프라 연동 및 보안"),
title: t(
"ui.admin.permissions_direct.cat_integrations",
"인프라 연동 및 보안",
),
menus: [
{ label: t("ui.admin.nav.worksmobile", "Worksmobile"), relation: "worksmobile", desc: t("msg.admin.permissions_direct.desc_worksmobile", "라인웍스 연동 및 사내 임직원 패스워드 강제 동기화"), icon: Share2 },
{ label: t("ui.admin.nav.api_keys", "API 키"), relation: "api_keys", desc: t("msg.admin.permissions_direct.desc_api_keys", "조직도 연동을 위한 전역 서드파티 토큰 관리"), icon: Key },
]
{
label: t("ui.admin.nav.worksmobile", "Worksmobile"),
relation: "worksmobile",
desc: t(
"msg.admin.permissions_direct.desc_worksmobile",
"라인웍스 연동 및 사내 임직원 패스워드 강제 동기화",
),
icon: Share2,
},
{
label: t("ui.admin.nav.api_keys", "API 키"),
relation: "api_keys",
desc: t(
"msg.admin.permissions_direct.desc_api_keys",
"조직도 연동을 위한 전역 서드파티 토큰 관리",
),
icon: Key,
},
],
},
{
title: t("ui.admin.permissions_direct.cat_system", "아이덴티티 및 게이트 관리"),
title: t(
"ui.admin.permissions_direct.cat_system",
"아이덴티티 및 게이트 관리",
),
menus: [
{ label: t("ui.admin.nav.ory_ssot", "Ory SSOT 시스템"), relation: "ory_ssot", desc: t("msg.admin.permissions_direct.desc_ory_ssot", "Redis 아이덴티티 미러 캐시 및 PostgreSQL read model 정합성 갱신"), icon: Database },
{ label: t("ui.admin.nav.data_integrity", "데이터 정합성"), relation: "data_integrity", desc: t("msg.admin.permissions_direct.desc_data_integrity", "고아 레코드 검출 및 DB 정합성 최종 검증기"), icon: ShieldCheck },
{ label: t("ui.admin.nav.auth_guard", "인증 가드"), relation: "auth_guard", desc: t("msg.admin.permissions_direct.desc_auth_guard", "정책엔진 기준으로 Keto ReBAC 관계 검증 시뮬레이터"), icon: KeyRound },
{ label: t("ui.admin.nav.permissions_direct", "권한 부여"), relation: "permissions_direct", desc: t("msg.admin.permissions_direct.desc_permissions_direct", "본 사이드바 메뉴 세부 권한 격자 및 테넌트 인가 설정 패널"), icon: Shield },
]
}
{
label: t("ui.admin.nav.ory_ssot", "Ory SSOT 시스템"),
relation: "ory_ssot",
desc: t(
"msg.admin.permissions_direct.desc_ory_ssot",
"Redis 아이덴티티 미러 캐시 및 PostgreSQL read model 정합성 갱신",
),
icon: Database,
},
{
label: t("ui.admin.nav.data_integrity", "데이터 정합성"),
relation: "data_integrity",
desc: t(
"msg.admin.permissions_direct.desc_data_integrity",
"고아 레코드 검출 및 DB 정합성 최종 검증기",
),
icon: ShieldCheck,
},
{
label: t("ui.admin.nav.auth_guard", "인증 가드"),
relation: "auth_guard",
desc: t(
"msg.admin.permissions_direct.desc_auth_guard",
"정책엔진 기준으로 Keto ReBAC 관계 검증 시뮬레이터",
),
icon: KeyRound,
},
{
label: t("ui.admin.nav.permissions_direct", "권한 부여"),
relation: "permissions_direct",
desc: t(
"msg.admin.permissions_direct.desc_permissions_direct",
"본 사이드바 메뉴 세부 권한 격자 및 테넌트 인가 설정 패널",
),
icon: Shield,
},
],
},
];
const filteredRelations = systemRelations.filter(
@@ -308,6 +449,19 @@ export function TenantFineGrainedPermissionsPage() {
const selectedUser = systemRelations.find((r) => r.userId === activeUserId);
if (profile && !isSuperAdmin) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
{t("ui.common.go_home", "홈으로 이동")}
</Button>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-1">
@@ -325,204 +479,260 @@ export function TenantFineGrainedPermissionsPage() {
{/* 시스템 메뉴 권한 (Admin Control) Split Screen Panel */}
<div className="flex flex-col lg:flex-row gap-6 h-[720px] border border-border rounded-xl bg-card overflow-hidden shadow-sm">
{/* Left Panel: User List */}
<div className="w-full lg:w-80 border-r border-border flex flex-col bg-muted/10 h-full">
<div className="p-4 border-b border-border space-y-3 flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm text-foreground">
{t("ui.admin.permissions_direct.user_list", "대상 사용자")} ({filteredRelations.length})
</h3>
{/* Left Panel: User List */}
<div className="w-full lg:w-80 border-r border-border flex flex-col bg-muted/10 h-full">
<div className="p-4 border-b border-border space-y-3 flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm text-foreground">
{t("ui.admin.permissions_direct.user_list", "대상 사용자")} (
{filteredRelations.length})
</h3>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => setIsDialogOpen(true)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "이름 또는 이메일 검색...")}
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
name="user-search"
className="pl-8 h-8 text-xs"
/>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{filteredRelations.length === 0 ? (
<div className="p-6 text-center text-xs text-muted-foreground">
{t(
"msg.admin.permissions_direct.no_users_found",
"등록된 사용자가 없습니다.",
)}
</div>
) : (
filteredRelations.map((user) => {
const isSelected = activeUserId === user.userId;
const activeCount = user.relations.length;
return (
<button
type="button"
key={user.userId}
onClick={() => setActiveUserId(user.userId)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-all ${
isSelected
? "bg-primary/10 text-primary border-l-4 border-primary shadow-sm"
: "hover:bg-muted/50 text-foreground"
}`}
>
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-8 w-8 border border-border">
<AvatarFallback className="bg-primary/10 text-primary font-bold text-xs uppercase">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="text-sm font-semibold truncate">
{user.name}
</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[150px]">
{user.email}
</span>
</div>
</div>
<Badge
variant={isSelected ? "default" : "secondary"}
className="text-[9px] px-1.5 py-0.5"
>
{activeCount}
</Badge>
</button>
);
})
)}
</div>
</ScrollArea>
</div>
{/* Right Panel: Toggle settings grid */}
<div className="flex-1 flex flex-col h-full bg-background">
{selectedUser ? (
<>
{/* User Detail Header */}
<div className="p-5 border-b border-border flex items-center justify-between flex-shrink-0 bg-muted/5">
<div className="flex items-center gap-4">
<Avatar className="h-11 w-11 border">
<AvatarFallback className="bg-primary/5 text-primary font-extrabold text-sm uppercase">
{selectedUser.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<h2 className="text-lg font-bold flex items-center gap-2">
{selectedUser.name}
<Badge variant="outline" className="text-xs font-normal">
{selectedUser.relations.length}{" "}
{t("ui.admin.permissions_direct.allowed", "개 허용됨")}
</Badge>
</h2>
<span className="text-xs text-muted-foreground">
{selectedUser.email}
</span>
</div>
</div>
<Button
variant="outline"
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => setIsDialogOpen(true)}
className="text-destructive border-destructive/20 hover:bg-destructive/10"
onClick={() =>
handleRemoveAllSystemRelations(
selectedUser.userId,
selectedUser.relations,
)
}
>
<Plus className="h-4 w-4" />
<Trash2 className="h-4 w-4 mr-2" />
{t(
"ui.admin.permissions_direct.revoke_all",
"모든 권한 회수",
)}
</Button>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "이름 또는 이메일 검색...")}
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
name="user-search"
className="pl-8 h-8 text-xs"
/>
{/* Categorized Toggle Grid */}
<ScrollArea className="flex-1">
<div className="p-6 space-y-6">
{systemMenuCategories.map((category) => (
<div key={category.title} className="space-y-3">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{category.title}
</h4>
<Card className="border border-border/60 shadow-none bg-card">
<CardContent className="p-0 divide-y divide-border/40">
{category.menus.map((menu) => {
const isWrite = selectedUser.relations.includes(
`${menu.relation}_managers`,
);
const isRead = selectedUser.relations.includes(
`${menu.relation}_viewers`,
);
const serverValue: "none" | "read" | "write" =
isWrite ? "write" : isRead ? "read" : "none";
const permissionValue =
localSystemPermissions[selectedUser.userId]?.[
menu.relation
] ?? serverValue;
const Icon = menu.icon;
return (
<div
key={menu.relation}
className="flex items-center justify-between p-4 hover:bg-muted/10 transition-colors"
>
<div className="flex items-start gap-4 pr-4 min-w-0">
<div className="p-2 rounded-lg bg-secondary/50 text-foreground flex-shrink-0 mt-0.5">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{menu.label}
</span>
{(menu.relation === "ory_ssot" ||
menu.relation === "data_integrity") && (
<Badge
variant="secondary"
className="text-[10px] py-0.5 px-1.5 font-semibold text-destructive bg-destructive/10 border-destructive/20"
>
{t(
"ui.admin.permissions_direct.super_admin_only",
"Super Admin 전용",
)}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground line-clamp-1">
{menu.desc}
</span>
</div>
</div>
<select
name={`system-menu-permission-${menu.relation}`}
value={permissionValue}
disabled={
menu.relation === "ory_ssot" ||
menu.relation === "data_integrity"
}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
// 🌟 1단계: 로컬 임시 상태 즉시 갱신 (0ms 반응 보장)
setLocalSystemPermissions((prev) => ({
...prev,
[selectedUser.userId]: {
...(prev[selectedUser.userId] ?? {}),
[menu.relation]: nextVal,
},
}));
// 🌟 2단계: 백그라운드 비동기 API 요청 수행
handleSystemRelationChange(
selectedUser.userId,
menu.relation,
permissionValue,
nextVal,
);
}}
className="flex h-9 w-[180px] rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</div>
);
})}
</CardContent>
</Card>
</div>
))}
</div>
</ScrollArea>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center text-muted-foreground bg-muted/5 gap-3">
<ShieldCheck className="h-12 w-12 text-muted-foreground opacity-30" />
<div>
<h3 className="text-sm font-semibold text-foreground">
{t(
"ui.admin.permissions_direct.no_user_selected",
"사용자가 선택되지 않았습니다.",
)}
</h3>
<p className="text-xs mt-1">
{t(
"msg.admin.permissions_direct.no_user_selected_desc",
"왼쪽의 사용자 리스트에서 권한을 변경할 인원을 선택해 주세요.",
)}
</p>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{filteredRelations.length === 0 ? (
<div className="p-6 text-center text-xs text-muted-foreground">
{t("msg.admin.permissions_direct.no_users_found", "등록된 사용자가 없습니다.")}
</div>
) : (
filteredRelations.map((user) => {
const isSelected = activeUserId === user.userId;
const activeCount = user.relations.length;
return (
<button
key={user.userId}
onClick={() => setActiveUserId(user.userId)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-all ${
isSelected
? "bg-primary/10 text-primary border-l-4 border-primary shadow-sm"
: "hover:bg-muted/50 text-foreground"
}`}
>
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-8 w-8 border border-border">
<AvatarFallback className="bg-primary/10 text-primary font-bold text-xs uppercase">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="text-sm font-semibold truncate">{user.name}</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[150px]">
{user.email}
</span>
</div>
</div>
<Badge variant={isSelected ? "default" : "secondary"} className="text-[9px] px-1.5 py-0.5">
{activeCount}
</Badge>
</button>
);
})
)}
</div>
</ScrollArea>
</div>
{/* Right Panel: Toggle settings grid */}
<div className="flex-1 flex flex-col h-full bg-background">
{selectedUser ? (
<>
{/* User Detail Header */}
<div className="p-5 border-b border-border flex items-center justify-between flex-shrink-0 bg-muted/5">
<div className="flex items-center gap-4">
<Avatar className="h-11 w-11 border">
<AvatarFallback className="bg-primary/5 text-primary font-extrabold text-sm uppercase">
{selectedUser.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<h2 className="text-lg font-bold flex items-center gap-2">
{selectedUser.name}
<Badge variant="outline" className="text-xs font-normal">
{selectedUser.relations.length} {t("ui.admin.permissions_direct.allowed", "개 허용됨")}
</Badge>
</h2>
<span className="text-xs text-muted-foreground">{selectedUser.email}</span>
</div>
</div>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/20 hover:bg-destructive/10"
onClick={() => handleRemoveAllSystemRelations(selectedUser.userId, selectedUser.relations)}
>
<Trash2 className="h-4 w-4 mr-2" />
{t("ui.admin.permissions_direct.revoke_all", "모든 권한 회수")}
</Button>
</div>
{/* Categorized Toggle Grid */}
<ScrollArea className="flex-1">
<div className="p-6 space-y-6">
{systemMenuCategories.map((category) => (
<div key={category.title} className="space-y-3">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{category.title}
</h4>
<Card className="border border-border/60 shadow-none bg-card">
<CardContent className="p-0 divide-y divide-border/40">
{category.menus.map((menu) => {
const isWrite = selectedUser.relations.includes(`${menu.relation}_managers`);
const isRead = selectedUser.relations.includes(`${menu.relation}_viewers`);
const serverValue: "none" | "read" | "write" = isWrite ? "write" : isRead ? "read" : "none";
const permissionValue = localSystemPermissions[selectedUser.userId]?.[menu.relation] ?? serverValue;
const Icon = menu.icon;
return (
<div
key={menu.relation}
className="flex items-center justify-between p-4 hover:bg-muted/10 transition-colors"
>
<div className="flex items-start gap-4 pr-4 min-w-0">
<div className="p-2 rounded-lg bg-secondary/50 text-foreground flex-shrink-0 mt-0.5">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">{menu.label}</span>
{(menu.relation === "ory_ssot" || menu.relation === "data_integrity") && (
<Badge variant="secondary" className="text-[10px] py-0.5 px-1.5 font-semibold text-destructive bg-destructive/10 border-destructive/20">
{t("ui.admin.permissions_direct.super_admin_only", "Super Admin 전용")}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground line-clamp-1">
{menu.desc}
</span>
</div>
</div>
<select
name={`system-menu-permission-${menu.relation}`}
value={permissionValue}
disabled={menu.relation === "ory_ssot" || menu.relation === "data_integrity"}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
// 🌟 1단계: 로컬 임시 상태 즉시 갱신 (0ms 반응 보장)
setLocalSystemPermissions(prev => ({
...prev,
[selectedUser.userId]: {
...(prev[selectedUser.userId] ?? {}),
[menu.relation]: nextVal
}
}));
// 🌟 2단계: 백그라운드 비동기 API 요청 수행
handleSystemRelationChange(
selectedUser.userId,
menu.relation,
permissionValue,
nextVal,
);
}}
className="flex h-9 w-[180px] rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="none">{t("ui.common.none", "권한 없음")}</option>
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
</select>
</div>
);
})}
</CardContent>
</Card>
</div>
))}
</div>
</ScrollArea>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center text-muted-foreground bg-muted/5 gap-3">
<ShieldCheck className="h-12 w-12 text-muted-foreground opacity-30" />
<div>
<h3 className="text-sm font-semibold text-foreground">
{t("ui.admin.permissions_direct.no_user_selected", "사용자가 선택되지 않았습니다.")}
</h3>
<p className="text-xs mt-1">
{t("msg.admin.permissions_direct.no_user_selected_desc", "왼쪽의 사용자 리스트에서 권한을 변경할 인원을 선택해 주세요.")}
</p>
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* User Search Dialog for System relations */}
<Dialog
@@ -537,7 +747,10 @@ export function TenantFineGrainedPermissionsPage() {
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{t("ui.admin.permissions_direct.dialog_title_system", "시스템 권한 관리 유저 추가")}
{t(
"ui.admin.permissions_direct.dialog_title_system",
"시스템 권한 관리 유저 추가",
)}
</DialogTitle>
<DialogDescription>
{t(
@@ -1,14 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Plus,
Search,
ShieldCheck,
UserPlus,
} from "lucide-react";
import { useState, useEffect } from "react";
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useTenantPermission } from "../hooks/useTenantPermission";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -36,20 +30,22 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
fetchUsers,
fetchTenantRelations,
addTenantRelation,
fetchTenantRelations,
fetchUsers,
removeTenantRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { Trash2 } from "lucide-react";
import { useTenantPermission } from "../hooks/useTenantPermission";
interface TenantFineGrainedPermissionsTabProps {
tenantIdProp?: string;
}
export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrainedPermissionsTabProps = {}) {
export function TenantFineGrainedPermissionsTab({
tenantIdProp,
}: TenantFineGrainedPermissionsTabProps = {}) {
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdProp || tenantIdParam || "";
const { hasPermission } = useTenantPermission(tenantId);
@@ -59,26 +55,35 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
const [isDialogOpen, setIsDialogOpen] = useState(false);
// 🌟 테넌트 탭별 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
const [localTenantPermissions, setLocalTenantPermissions] = useState<Record<string, Record<string, "none" | "read" | "write">>>({});
const [localTenantPermissions, setLocalTenantPermissions] = useState<
Record<string, Record<string, "none" | "read" | "write">>
>({});
const relationsQuery = useQuery({
queryKey: ["tenant-relations", tenantId],
queryFn: () => fetchTenantRelations(tenantId),
enabled: !!tenantId,
});
const relationsData = relationsQuery.data ?? [];
const _relationsData = relationsQuery.data ?? [];
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
useEffect(() => {
if (relationsQuery.data) {
const initialMap: Record<string, Record<string, "none" | "read" | "write">> = {};
const initialMap: Record<
string,
Record<string, "none" | "read" | "write">
> = {};
for (const user of relationsQuery.data) {
initialMap[user.userId] = {};
const tabs = ["profile", "permissions", "organization", "schema"];
for (const tab of tabs) {
const isWrite = user.relations.includes(`${tab}_managers`);
const isRead = user.relations.includes(`${tab}_viewers`);
initialMap[user.userId][tab] = isWrite ? "write" : isRead ? "read" : "none";
initialMap[user.userId][tab] = isWrite
? "write"
: isRead
? "read"
: "none";
}
}
setLocalTenantPermissions(initialMap);
@@ -91,7 +96,9 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
queryClient.invalidateQueries({
queryKey: ["tenant-relations", tenantId],
});
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["me"] });
}, 500);
@@ -101,31 +108,45 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
mutationFn: (payload: { userId: string; relation: string }) =>
addTenantRelation(tenantId, payload.userId, payload.relation),
onMutate: async (newRelation) => {
await queryClient.cancelQueries({ queryKey: ["tenant-relations", tenantId] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["tenant-relations", tenantId]);
queryClient.setQueryData<TenantRelation[]>(["tenant-relations", tenantId], (old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
await queryClient.cancelQueries({
queryKey: ["tenant-relations", tenantId],
});
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"tenant-relations",
tenantId,
]);
queryClient.setQueryData<TenantRelation[]>(
["tenant-relations", tenantId],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(["tenant-relations", tenantId], context.previousRelations);
queryClient.setQueryData(
["tenant-relations", tenantId],
context.previousRelations,
);
}
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
@@ -136,29 +157,45 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
mutationFn: (payload: { userId: string; relation: string }) =>
removeTenantRelation(tenantId, payload.userId, payload.relation),
onMutate: async (targetRelation) => {
await queryClient.cancelQueries({ queryKey: ["tenant-relations", tenantId] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["tenant-relations", tenantId]);
queryClient.setQueryData<TenantRelation[]>(["tenant-relations", tenantId], (old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter((r) => r !== targetRelation.relation),
};
}
return user;
});
await queryClient.cancelQueries({
queryKey: ["tenant-relations", tenantId],
});
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"tenant-relations",
tenantId,
]);
queryClient.setQueryData<TenantRelation[]>(
["tenant-relations", tenantId],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter(
(r) => r !== targetRelation.relation,
),
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(["tenant-relations", tenantId], context.previousRelations);
queryClient.setQueryData(
["tenant-relations", tenantId],
context.previousRelations,
);
}
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
@@ -180,7 +217,10 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
if (currentVal === "read") {
await removeRelationMutation.mutateAsync({ userId, relation: readRel });
} else if (currentVal === "write") {
await removeRelationMutation.mutateAsync({ userId, relation: writeRel });
await removeRelationMutation.mutateAsync({
userId,
relation: writeRel,
});
}
if (newVal === "read") {
@@ -192,14 +232,29 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
invalidateAllQueries();
// 🌟 Trigger a single consolidated success toast at the very end
toast.success(t("msg.admin.tenants.relations.update_success", "세부 권한이 성공적으로 변경되었습니다."));
toast.success(
t(
"msg.admin.tenants.relations.update_success",
"세부 권한이 성공적으로 변경되었습니다.",
),
);
} catch {
// Individual mutations handle error toast via onError
}
};
const handleRemoveAllRelations = async (userId: string, userRelations: string[]) => {
if (!window.confirm(t("msg.admin.tenants.relations.remove_all_confirm", "이 사용자의 모든 세부 권한을 삭제하시겠습니까?"))) {
const handleRemoveAllRelations = async (
userId: string,
userRelations: string[],
) => {
if (
!window.confirm(
t(
"msg.admin.tenants.relations.remove_all_confirm",
"이 사용자의 모든 세부 권한을 삭제하시겠습니까?",
),
)
) {
return;
}
for (const rel of userRelations) {
@@ -215,11 +270,14 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
});
const handleAddUser = (userId: string) => {
addRelationMutation.mutate({ userId, relation: "profile_viewers" }, {
onSettled: () => {
invalidateAllQueries();
}
});
addRelationMutation.mutate(
{ userId, relation: "profile_viewers" },
{
onSettled: () => {
invalidateAllQueries();
},
},
);
setIsDialogOpen(false);
setSearchTerm("");
};
@@ -235,7 +293,10 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.relations.title", "세부 권한 설정 (Fine-grained Permissions)")}
{t(
"ui.admin.tenants.relations.title",
"세부 권한 설정 (Fine-grained Permissions)",
)}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
@@ -250,7 +311,10 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
disabled={!isWritable}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.relations.add_button", "세부 권한 사용자 추가")}
{t(
"ui.admin.tenants.relations.add_button",
"세부 권한 사용자 추가",
)}
</Button>
</CardHeader>
<CardContent className="pt-0">
@@ -258,58 +322,96 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
<Table>
<TableHeader className="bg-secondary/40">
<TableRow>
<TableHead className="font-bold">{t("ui.common.name", "이름")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_permissions", "권한 관리")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}</TableHead>
<TableHead className="font-bold text-center w-20">{t("ui.common.action", "작업")}</TableHead>
<TableHead className="font-bold">
{t("ui.common.name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_permissions", "권한 관리")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</TableHead>
<TableHead className="font-bold text-center w-20">
{t("ui.common.action", "작업")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{relations.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
{t("msg.admin.tenants.relations.empty", "세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요.")}
<TableCell
colSpan={6}
className="text-center py-12 text-muted-foreground"
>
{t(
"msg.admin.tenants.relations.empty",
"세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요.",
)}
</TableCell>
</TableRow>
) : (
relations.map((user) => {
const profileVal = user.relations.includes("profile_managers")
const profileVal = user.relations.includes(
"profile_managers",
)
? "write"
: user.relations.includes("profile_viewers")
? "read"
: "none";
? "read"
: "none";
const permissionsVal = user.relations.includes("permissions_managers")
const permissionsVal = user.relations.includes(
"permissions_managers",
)
? "write"
: user.relations.includes("permissions_viewers")
? "read"
: "none";
? "read"
: "none";
const organizationVal = user.relations.includes("organization_managers")
const organizationVal = user.relations.includes(
"organization_managers",
)
? "write"
: user.relations.includes("organization_viewers")
? "read"
: "none";
? "read"
: "none";
const schemaVal = user.relations.includes("schema_managers")
? "write"
: user.relations.includes("schema_viewers")
? "read"
: "none";
? "read"
: "none";
const curProfileVal = localTenantPermissions[user.userId]?.profile ?? profileVal;
const curPermissionsVal = localTenantPermissions[user.userId]?.permissions ?? permissionsVal;
const curOrganizationVal = localTenantPermissions[user.userId]?.organization ?? organizationVal;
const curSchemaVal = localTenantPermissions[user.userId]?.schema ?? schemaVal;
const curProfileVal =
localTenantPermissions[user.userId]?.profile ??
profileVal;
const curPermissionsVal =
localTenantPermissions[user.userId]?.permissions ??
permissionsVal;
const curOrganizationVal =
localTenantPermissions[user.userId]?.organization ??
organizationVal;
const curSchemaVal =
localTenantPermissions[user.userId]?.schema ?? schemaVal;
return (
<TableRow key={user.userId} className="hover:bg-muted/10 transition-colors">
<TableRow
key={user.userId}
className="hover:bg-muted/10 transition-colors"
>
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="font-semibold text-foreground">{user.name}</span>
<span className="text-xs text-muted-foreground italic">{user.email}</span>
<span className="font-semibold text-foreground">
{user.name}
</span>
<span className="text-xs text-muted-foreground italic">
{user.email}
</span>
</div>
</TableCell>
<TableCell>
@@ -319,13 +421,16 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
disabled={!isWritable}
name={`tenant-fine-grained-profile-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
profile: nextVal
}
profile: nextVal,
},
}));
handleRelationChange(
user.userId,
@@ -335,9 +440,15 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
);
}}
>
<option value="none">{t("ui.common.none", "권한 없음")}</option>
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell>
@@ -347,13 +458,16 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
disabled={!isWritable}
name={`tenant-fine-grained-permissions-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
permissions: nextVal
}
permissions: nextVal,
},
}));
handleRelationChange(
user.userId,
@@ -363,9 +477,15 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
);
}}
>
<option value="none">{t("ui.common.none", "권한 없음")}</option>
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell>
@@ -375,13 +495,16 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
disabled={!isWritable}
name={`tenant-fine-grained-organization-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
organization: nextVal
}
organization: nextVal,
},
}));
handleRelationChange(
user.userId,
@@ -391,9 +514,15 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
);
}}
>
<option value="none">{t("ui.common.none", "권한 없음")}</option>
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell>
@@ -403,13 +532,16 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
disabled={!isWritable}
name={`tenant-fine-grained-schema-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
schema: nextVal
}
schema: nextVal,
},
}));
handleRelationChange(
user.userId,
@@ -419,9 +551,15 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
);
}}
>
<option value="none">{t("ui.common.none", "권한 없음")}</option>
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell className="text-center">
@@ -429,7 +567,12 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
variant="ghost"
size="icon"
disabled={!isWritable}
onClick={() => handleRemoveAllRelations(user.userId, user.relations)}
onClick={() =>
handleRemoveAllRelations(
user.userId,
user.relations,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
@@ -457,7 +600,10 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{t("ui.admin.tenants.relations.dialog_title", "세부 권한 관리 유저 추가")}
{t(
"ui.admin.tenants.relations.dialog_title",
"세부 권한 관리 유저 추가",
)}
</DialogTitle>
<DialogDescription>
{t(
@@ -533,8 +679,7 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
size="sm"
variant={isAlreadyInMatrix ? "ghost" : "outline"}
disabled={
isAlreadyInMatrix ||
addRelationMutation.isPending
isAlreadyInMatrix || addRelationMutation.isPending
}
onClick={() => handleAddUser(user.id)}
>
@@ -21,7 +21,6 @@ import {
import type React from "react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useTenantPermission } from "../hooks/useTenantPermission";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
@@ -63,6 +62,7 @@ import {
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
type UserGroupNode = GroupSummary & {
children: UserGroupNode[];
@@ -247,7 +247,8 @@ function TenantGroupsPage() {
const _queryClient = useQueryClient();
const { hasPermission } = useTenantPermission(tenantId);
const isWritable = hasPermission("manage_organization") || hasPermission("manage");
const isWritable =
hasPermission("manage_organization") || hasPermission("manage");
const canView = hasPermission("view_organization") || hasPermission("view");
const [newGroupName, setNewGroupName] = useState("");
@@ -502,7 +503,9 @@ function TenantGroupsPage() {
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending || !isWritable}
disabled={
!newGroupName || createMutation.isPending || !isWritable
}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
@@ -30,7 +30,6 @@ import {
} from "../../../../../common/core/utils";
import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { RoleGuard } from "../../../components/auth/RoleGuard";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -378,7 +377,9 @@ function TenantListPage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const isWritable = profileRole === "super_admin" || !!profile?.systemPermissions?.manage_tenants;
const isWritable =
profileRole === "super_admin" ||
!!profile?.systemPermissions?.manage_tenants;
const query = useInfiniteQuery({
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
@@ -583,7 +584,11 @@ function TenantListPage() {
return () => window.removeEventListener("message", onMessage);
}, [allTenants, scopePickerOpen]);
if (profile && profileRole !== "super_admin" && !profile?.systemPermissions?.tenants) {
if (
profile &&
profileRole !== "super_admin" &&
!profile?.systemPermissions?.tenants
) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">
@@ -276,13 +276,21 @@ export function TenantProfilePage() {
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} disabled={!isWritable} />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!isWritable}
/>
</div>
<div data-testid="tenant-slug-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} disabled={!isWritable} />
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
disabled={!isWritable}
/>
</div>
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
<ParentTenantSelector
@@ -504,7 +512,9 @@ export function TenantProfilePage() {
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending || isProtectedSeedTenant || !isWritable}
disabled={
deleteMutation.isPending || isProtectedSeedTenant || !isWritable
}
title={
isProtectedSeedTenant
? t(
@@ -28,7 +28,9 @@ export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(tenantId ?? "");
const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(
tenantId ?? "",
);
const canView = hasPermission("view_schema") || hasPermission("view");
const isWritable = hasPermission("manage_schema") || hasPermission("manage");
@@ -393,7 +395,9 @@ export function TenantSchemaPage() {
<div className="flex justify-end pt-2">
<Button
onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading || !isWritable}
disabled={
updateMutation.isPending || tenantQuery.isLoading || !isWritable
}
className="px-8 h-11"
>
<Save size={18} className="mr-2" />
@@ -158,7 +158,9 @@ function UserCreatePage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canManageUsers = canManageTenantScopedUsers(profile) || !!profile?.systemPermissions?.manage_users;
const canManageUsers =
canManageTenantScopedUsers(profile) ||
!!profile?.systemPermissions?.manage_users;
const {
register,
@@ -653,8 +653,17 @@ function UserDetailPage() {
const isAdmin = profileRole === "super_admin";
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
const canManageCurrentUser = canManageUserInTenantScope({ profile, user });
const isWritable = isAdmin || isSelf || canManageCurrentUser || !!profile?.systemPermissions?.manage_users;
const canViewUser = isAdmin || isSelf || canManageCurrentUser || !!profile?.systemPermissions?.users || !!profile?.systemPermissions?.manage_users;
const isWritable =
isAdmin ||
isSelf ||
canManageCurrentUser ||
!!profile?.systemPermissions?.manage_users;
const canViewUser =
isAdmin ||
isSelf ||
canManageCurrentUser ||
!!profile?.systemPermissions?.users ||
!!profile?.systemPermissions?.manage_users;
const watchedStatus = watch("status");
const [newSubEmail, setNewSubEmail] = React.useState("");
@@ -381,7 +381,8 @@ function UserListPage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const isWritable = profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
const isWritable =
profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
const { data: tenantsData } = useQuery({
queryKey: ["tenants", "all"],
+2 -8
View File
@@ -541,20 +541,14 @@ export async function fetchSystemRelations() {
return data.items;
}
export async function addSystemRelation(
userId: string,
relation: string,
) {
export async function addSystemRelation(userId: string, relation: string) {
await apiClient.post(`/v1/admin/system/relations`, {
userId,
relation,
});
}
export async function removeSystemRelation(
userId: string,
relation: string,
) {
export async function removeSystemRelation(userId: string, relation: string) {
await apiClient.delete(`/v1/admin/system/relations`, {
data: { userId, relation },
});
+21 -1
View File
@@ -2,6 +2,10 @@ import { expect, test } from "@playwright/test";
test.describe("Authentication", () => {
test.beforeEach(async ({ page }) => {
page.on("console", (msg) => console.log("BROWSER LOG:", msg.text()));
page.on("pageerror", (err) =>
console.error("BROWSER EXCEPTION:", err.message),
);
// 1. Force state
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
@@ -70,8 +74,24 @@ test.describe("Authentication", () => {
// 3. Catch-all for others
await page.route(/.*\/api\/v1\/.*/, async (route) => {
if (route.request().url().includes("/user/me")) {
return route.fallback();
}
if (route.request().method() === "GET") {
await route.fulfill({ json: { items: [], total: 0 } });
await route.fulfill({
json: {
items: [],
total: 0,
summary: {
failures: 0,
warnings: 0,
pass: 0,
success: 0,
total: 0,
},
sections: [],
},
});
} else {
await route.fulfill({ status: 200, json: {} });
}
+15 -5
View File
@@ -297,7 +297,9 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
});
test.describe("세부 기능 권한(System Permissions)을 가진 비-슈퍼어드민", () => {
test("테넌트 조회 권한(tenants)이 있을 때 테넌트 목록 페이지 진입 가능 및 쓰기 기능 제한 확인", async ({ page }) => {
test("테넌트 조회 권한(tenants)이 있을 때 테넌트 목록 페이지 진입 가능 및 쓰기 기능 제한 확인", async ({
page,
}) => {
await setupAuth(page, "tenant_admin", {
tenantId: "t1",
tenantSlug: "t1",
@@ -315,18 +317,24 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
// 차단 메시지 비노출 확인
await expect(
page.getByText(/접근 권한이 없습니다|이 작업을 수행할 권한이 없습니다/i),
page.getByText(
/접근 권한이 없습니다|이 작업을 수행할 권한이 없습니다/i,
),
).not.toBeVisible();
// "테넌트 1" 목록 노출 확인
await expect(page.getByText("테넌트 1")).toBeVisible();
// 수정 권한(manage_tenants)이 없으므로 쓰기 버튼 비노출 확인
await expect(page.getByRole("link", { name: /테넌트 추가/i })).not.toBeVisible();
await expect(
page.getByRole("link", { name: /테넌트 추가/i }),
).not.toBeVisible();
await expect(page.getByTestId("tenant-data-mgmt-btn")).not.toBeVisible();
});
test("테넌트 관리 권한(manage_tenants)까지 있을 때 테넌트 추가 및 데이터 관리 버튼 활성화 확인", async ({ page }) => {
test("테넌트 관리 권한(manage_tenants)까지 있을 때 테넌트 추가 및 데이터 관리 버튼 활성화 확인", async ({
page,
}) => {
await setupAuth(page, "tenant_admin", {
tenantId: "t1",
tenantSlug: "t1",
@@ -341,7 +349,9 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
await expect(page.getByText("테넌트 1")).toBeVisible();
// 수정 권한(manage_tenants)이 있으므로 쓰기 버튼(테넌트 추가, 데이터 관리) 노출 확인
await expect(page.getByRole("link", { name: /테넌트 추가/i })).toBeVisible();
await expect(
page.getByRole("link", { name: /테넌트 추가/i }),
).toBeVisible();
await expect(page.getByTestId("tenant-data-mgmt-btn")).toBeVisible();
});
});
+11 -11
View File
@@ -70,17 +70,17 @@ type SignupRequest struct {
// User Profile Models
type SystemPermissions struct {
Overview bool `json:"overview"`
Tenants bool `json:"tenants"`
OrgChart bool `json:"org_chart"`
Worksmobile bool `json:"worksmobile"`
OrySSOT bool `json:"ory_ssot"`
DataIntegrity bool `json:"data_integrity"`
Users bool `json:"users"`
PermissionsDirect bool `json:"permissions_direct"`
AuthGuard bool `json:"auth_guard"`
ApiKeys bool `json:"api_keys"`
AuditLogs bool `json:"audit_logs"`
Overview bool `json:"overview"`
Tenants bool `json:"tenants"`
OrgChart bool `json:"org_chart"`
Worksmobile bool `json:"worksmobile"`
OrySSOT bool `json:"ory_ssot"`
DataIntegrity bool `json:"data_integrity"`
Users bool `json:"users"`
PermissionsDirect bool `json:"permissions_direct"`
AuthGuard bool `json:"auth_guard"`
ApiKeys bool `json:"api_keys"`
AuditLogs bool `json:"audit_logs"`
ManageOverview bool `json:"manage_overview"`
ManageTenants bool `json:"manage_tenants"`
+17 -17
View File
@@ -3263,14 +3263,14 @@ func (h *TenantHandler) ListRelations(c *fiber.Ctx) error {
}
allowedRelations := map[string]bool{
"profile_viewers": true,
"profile_managers": true,
"permissions_viewers": true,
"permissions_managers": true,
"organization_viewers": true,
"organization_managers": true,
"schema_viewers": true,
"schema_managers": true,
"profile_viewers": true,
"profile_managers": true,
"permissions_viewers": true,
"permissions_managers": true,
"organization_viewers": true,
"organization_managers": true,
"schema_viewers": true,
"schema_managers": true,
}
type userRelationInfo struct {
@@ -3308,7 +3308,7 @@ func (h *TenantHandler) ListRelations(c *fiber.Ctx) error {
}
}
}
if name == "Unknown" && email == "Unknown" && h.UserRepo != nil {
user, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && user != nil {
@@ -3349,14 +3349,14 @@ func (h *TenantHandler) AddRelation(c *fiber.Ctx) error {
}
allowedRelations := map[string]bool{
"profile_viewers": true,
"profile_managers": true,
"permissions_viewers": true,
"permissions_managers": true,
"organization_viewers": true,
"organization_managers": true,
"schema_viewers": true,
"schema_managers": true,
"profile_viewers": true,
"profile_managers": true,
"permissions_viewers": true,
"permissions_managers": true,
"organization_viewers": true,
"organization_managers": true,
"schema_viewers": true,
"schema_managers": true,
}
if !allowedRelations[req.Relation] {
+72 -57
View File
@@ -1,11 +1,19 @@
[msg]
[msg.admin]
[msg.admin.audit]
subtitle = "View administrator activity history."
[msg.common]
loading_more = "Loading more logs..."
copied = "Copied."
error = "Error"
forbidden = "Access denied."
loading = "Loading..."
no_results = "No results found."
loading_more = "Loading more logs..."
no_description = "No Description."
no_results = "No results found."
parsing = "Parsing data..."
requesting = "Requesting..."
saving = "Saving..."
@@ -20,20 +28,38 @@ loading = "Loading audit logs..."
[msg.common.audit.registry]
count = "{{count}} logs"
[msg.admin.audit]
subtitle = "View administrator activity history."
[msg.dev]
[msg.dev.audit]
subtitle = "View developer activity history within the current app scope."
[ui]
[ui.admin]
[ui.admin.integrity]
fetch_error = "Unable to load the final integrity check result."
[ui.admin.integrity.section]
tenant_integrity = "Tenant integrity"
user_integrity = "User integrity"
[ui.admin.integrity.summary]
failures_text = "Failures {{count}}"
title = "Final integrity check"
[ui.admin.overview]
[ui.admin.overview.chart]
description = "Check the graph by all or selected organizations."
title = "Login request status by company and app"
[ui.common]
no_results = "No results to display."
apply = "Apply"
action = "Action"
actions = "Actions"
add = "Add"
all = "All"
apply = "Apply"
admin_only = "Admin Only"
all = "All"
apply = "Apply"
approve = "Approve"
assign = "Assign"
@@ -61,42 +87,45 @@ export_with_ids = "Include UUID"
export_without_ids = "Export without UUID"
fail = "Fail"
go_home = "Go Home"
info = "Info"
view = "View"
hyphen = "-"
info = "Info"
language = "Language"
language_en = "English"
language_ko = "Korean"
load_more = "Load more"
loading = "Loading..."
manage = "Manage"
move = "Move"
move_org = "Move to another organization"
na = "N/A"
name = "Name"
never = "Never"
next = "Next"
no_results = "No results to display."
none = "None"
page_of = "Page {{page}} of {{total}}"
prev = "Prev"
previous = "Previous"
qr = "QR"
reject = "Reject"
rejected = "Rejected"
reset = "Reset"
read = "Read"
read_only = "Read Only"
refresh = "Refresh"
reject = "Reject"
rejected = "Rejected"
remove = "Remove"
remove_org = "Remove from organization"
resend = "Resend"
reset = "Reset"
retry = "Retry"
row = "Row"
save = "Save"
search = "Search"
search_group = "Search groups..."
searching = "Searching..."
select = "Select"
select_file = "Select File"
select_placeholder = "Select Placeholder"
load_more = "Load more"
show_more = "Show More"
language = "Language"
language_ko = "Korean"
language_en = "English"
submit = "Submit"
submitting = "Submitting..."
success = "Success"
@@ -105,6 +134,8 @@ theme_light = "Light"
theme_toggle = "Theme Toggle"
unassigned = "Unassigned"
unknown = "Unknown"
view = "View"
write = "Write"
[ui.common.audit]
load_more = "Load more"
@@ -114,12 +145,6 @@ title = "Audit Logs"
actor_id = "Copy User ID"
target = "Copy Client ID"
[ui.common.audit.filters]
user_id = "Filter by User ID"
client_id = "Filter by Client ID"
action = "Filter by Action (e.g. ROTATE_SECRET)"
status_all = "All Status"
[ui.common.audit.details]
actor = "User ID"
actor_id = "User ID · {{value}}"
@@ -135,24 +160,38 @@ path = "Path · {{value}}"
request = "Request"
request_id = "Request ID · {{value}}"
result = "Result"
tenant = "Tenant · {{value}}"
target = "Client ID · {{value}}"
tenant = "Tenant · {{value}}"
[ui.common.audit.filters]
action = "Filter by Action (e.g. ROTATE_SECRET)"
client_id = "Filter by Client ID"
status_all = "All Status"
user_id = "Filter by User ID"
[ui.common.audit.registry]
title = "Audit registry"
[ui.common.audit.table]
no_logs = "No logs to display."
action = "Action"
actor = "User ID"
client_id = "Client ID"
user_id = "User ID"
no_logs = "No logs to display."
status = "Status"
target = "Client ID"
time = "Time"
user_id = "User ID"
[ui.common.overview]
title = "Operational Status"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.chart]
[ui.common.chart.axis]
x = "X-axis: Period"
y = "Y-axis: Login Requests"
[ui.common.chart.period]
day = "Day"
@@ -162,29 +201,12 @@ week = "Week"
[ui.common.chart.series_summary]
login_users = "Login {{login}} / Users {{subjects}}"
[ui.common.chart.axis]
x = "X-axis: Period"
y = "Y-axis: Login Requests"
[ui.admin.integrity]
fetch_error = "Unable to load the final integrity check result."
[ui.admin.integrity.summary]
failures_text = "Failures {{count}}"
title = "Final integrity check"
[ui.admin.integrity.section]
tenant_integrity = "Tenant integrity"
user_integrity = "User integrity"
[ui.admin.overview.chart]
description = "Check the graph by all or selected organizations."
title = "Login request status by company and app"
[ui.common.badge]
[ui.common.custom_claim_permission]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
user_and_admin = "User and admin"
[ui.common.overview]
title = "Operational Status"
[ui.common.status]
active = "Active"
@@ -197,10 +219,3 @@ pending = "Pending"
success = "Success"
unchanged = "Unchanged"
updated = "Updated"
[ui.common]
searching = "Searching..."
[ui.common.custom_claim_permission]
admin_only = "Admin only"
user_and_admin = "User and admin"
+72 -57
View File
@@ -1,11 +1,19 @@
[msg]
[msg.admin]
[msg.admin.audit]
subtitle = "관리자 작업 이력을 조회합니다."
[msg.common]
loading_more = "추가 로그를 불러오는 중..."
copied = "복사되었습니다."
error = "오류가 발생했습니다."
forbidden = "접근 권한이 없습니다."
loading = "로딩 중..."
no_results = "검색 결과가 없습니다."
loading_more = "추가 로그를 불러오는 중..."
no_description = "설명이 없습니다."
no_results = "검색 결과가 없습니다."
parsing = "데이터 파싱 중..."
requesting = "요청 중..."
saving = "저장 중..."
@@ -20,20 +28,38 @@ loading = "Loading audit logs..."
[msg.common.audit.registry]
count = "총 {{count}}개 로그"
[msg.admin.audit]
subtitle = "관리자 작업 이력을 조회합니다."
[msg.dev]
[msg.dev.audit]
subtitle = "현재 앱 범위의 개발자 작업 이력을 조회합니다."
[ui]
[ui.admin]
[ui.admin.integrity]
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
[ui.admin.integrity.section]
tenant_integrity = "테넌트 정합성"
user_integrity = "사용자 정합성"
[ui.admin.integrity.summary]
failures_text = "실패 {{count}}건"
title = "정합성 최종 검증"
[ui.admin.overview]
[ui.admin.overview.chart]
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
title = "회사별 앱별 로그인 요청 현황"
[ui.common]
no_results = "표시할 결과가 없습니다."
apply = "적용"
action = "작업"
actions = "액션"
add = "추가"
all = "전체"
apply = "적용"
admin_only = "관리자 전용"
all = "전체"
apply = "적용"
approve = "승인"
assign = "할당"
@@ -61,42 +87,45 @@ export_with_ids = "UUID 포함"
export_without_ids = "UUID 제외 내보내기"
fail = "실패"
go_home = "홈으로"
info = "상세 안내"
view = "보기"
hyphen = "-"
info = "상세 안내"
language = "언어"
language_en = "English"
language_ko = "한국어"
load_more = "더 보기"
loading = "로딩 중..."
manage = "관리"
move = "이동"
move_org = "타 조직으로 이동"
na = "N/A"
name = "이름"
never = "Never"
next = "다음"
no_results = "표시할 결과가 없습니다."
none = "없음"
page_of = "Page {{page}} of {{total}}"
prev = "이전"
previous = "이전"
qr = "QR"
reject = "반려"
rejected = "반려됨"
reset = "초기화"
read = "조회 가능 (Read)"
read_only = "읽기 전용"
refresh = "새로고침"
reject = "반려"
rejected = "반려됨"
remove = "제외"
remove_org = "조직에서 제외"
resend = "재발송"
reset = "초기화"
retry = "다시 시도"
row = "행"
save = "저장"
search = "검색"
search_group = "그룹 검색..."
searching = "검색 중..."
select = "선택"
select_file = "파일 선택"
select_placeholder = "선택하세요"
load_more = "더 보기"
show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"
language_en = "English"
submit = "신청하기"
submitting = "제출 중..."
success = "성공"
@@ -105,6 +134,8 @@ theme_light = "Light"
theme_toggle = "테마 전환"
unassigned = "미배정"
unknown = "Unknown"
view = "보기"
write = "수정 가능 (Write)"
[ui.common.audit]
load_more = "더 보기"
@@ -114,12 +145,6 @@ title = "감사 로그"
actor_id = "사용자 ID 복사"
target = "클라이언트 ID 복사"
[ui.common.audit.filters]
user_id = "사용자 ID로 검색"
client_id = "클라이언트 ID로 검색"
action = "액션으로 검색 (예: ROTATE_SECRET)"
status_all = "전체 상태"
[ui.common.audit.details]
actor = "사용자 ID"
actor_id = "사용자 ID · {{value}}"
@@ -135,24 +160,38 @@ path = "Path · {{value}}"
request = "Request"
request_id = "Request ID · {{value}}"
result = "Result"
tenant = "Tenant · {{value}}"
target = "클라이언트 ID · {{value}}"
tenant = "Tenant · {{value}}"
[ui.common.audit.filters]
action = "액션으로 검색 (예: ROTATE_SECRET)"
client_id = "클라이언트 ID로 검색"
status_all = "전체 상태"
user_id = "사용자 ID로 검색"
[ui.common.audit.registry]
title = "감사 로그 레지스트리"
[ui.common.audit.table]
no_logs = "표시할 로그가 없습니다."
action = "작업"
actor = "사용자 ID"
client_id = "클라이언트 ID"
user_id = "사용자 ID"
no_logs = "표시할 로그가 없습니다."
status = "상태"
target = "클라이언트 ID"
time = "시간"
user_id = "사용자 ID"
[ui.common.overview]
title = "운영 현황"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.chart]
[ui.common.chart.axis]
x = "X축: 기간"
y = "Y축: 로그인 요청 수"
[ui.common.chart.period]
day = "일"
@@ -162,29 +201,12 @@ week = "주"
[ui.common.chart.series_summary]
login_users = "로그인 {{login}} / 사용자 {{subjects}}"
[ui.common.chart.axis]
x = "X축: 기간"
y = "Y축: 로그인 요청 수"
[ui.common.custom_claim_permission]
admin_only = "관리자만 가능"
user_and_admin = "사용자와 관리자"
[ui.admin.integrity]
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
[ui.admin.integrity.summary]
failures_text = "실패 {{count}}건"
title = "정합성 최종 검증"
[ui.admin.integrity.section]
tenant_integrity = "테넌트 정합성"
user_integrity = "사용자 정합성"
[ui.admin.overview.chart]
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
title = "회사별 앱별 로그인 요청 현황"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.overview]
title = "운영 현황"
[ui.common.status]
active = "활성"
@@ -197,10 +219,3 @@ pending = "준비 중"
success = "성공"
unchanged = "동일"
updated = "수정"
[ui.common]
searching = "검색 중..."
[ui.common.custom_claim_permission]
admin_only = "관리자만 가능"
user_and_admin = "사용자와 관리자"
+72 -57
View File
@@ -1,11 +1,19 @@
[msg]
[msg.admin]
[msg.admin.audit]
subtitle = ""
[msg.common]
loading_more = ""
copied = ""
error = ""
forbidden = ""
loading = ""
no_results = ""
loading_more = ""
no_description = ""
no_results = ""
parsing = ""
requesting = ""
saving = ""
@@ -20,20 +28,38 @@ loading = ""
[msg.common.audit.registry]
count = ""
[msg.admin.audit]
subtitle = ""
[msg.dev]
[msg.dev.audit]
subtitle = ""
[ui]
[ui.admin]
[ui.admin.integrity]
fetch_error = ""
[ui.admin.integrity.section]
tenant_integrity = ""
user_integrity = ""
[ui.admin.integrity.summary]
failures_text = ""
title = ""
[ui.admin.overview]
[ui.admin.overview.chart]
description = ""
title = ""
[ui.common]
no_results = ""
apply = "Apply"
action = ""
actions = ""
add = ""
all = ""
apply = ""
admin_only = ""
all = ""
apply = ""
approve = ""
assign = ""
@@ -61,42 +87,45 @@ export_with_ids = ""
export_without_ids = ""
fail = ""
go_home = ""
info = ""
view = ""
hyphen = ""
info = ""
language = ""
language_en = ""
language_ko = ""
load_more = ""
loading = ""
manage = ""
move = ""
move_org = ""
na = ""
name = ""
never = ""
next = ""
no_results = ""
none = ""
page_of = ""
prev = ""
previous = ""
qr = ""
reject = ""
rejected = ""
reset = ""
read = ""
read_only = ""
refresh = ""
reject = ""
rejected = ""
remove = ""
remove_org = ""
resend = ""
reset = ""
retry = ""
row = ""
save = ""
search = ""
search_group = ""
searching = ""
select = ""
select_file = ""
select_placeholder = ""
load_more = ""
show_more = ""
language = ""
language_ko = ""
language_en = ""
submit = ""
submitting = ""
success = ""
@@ -105,6 +134,8 @@ theme_light = ""
theme_toggle = ""
unassigned = ""
unknown = ""
view = ""
write = ""
[ui.common.audit]
load_more = ""
@@ -114,12 +145,6 @@ title = ""
actor_id = ""
target = ""
[ui.common.audit.filters]
user_id = ""
client_id = ""
action = ""
status_all = ""
[ui.common.audit.details]
actor = ""
actor_id = ""
@@ -135,24 +160,38 @@ path = ""
request = ""
request_id = ""
result = ""
tenant = ""
target = ""
tenant = ""
[ui.common.audit.filters]
action = ""
client_id = ""
status_all = ""
user_id = ""
[ui.common.audit.registry]
title = ""
[ui.common.audit.table]
no_logs = ""
action = ""
actor = ""
client_id = ""
user_id = ""
no_logs = ""
status = ""
target = ""
time = ""
user_id = ""
[ui.common.overview]
title = ""
[ui.common.badge]
admin_only = ""
command_only = ""
system = ""
[ui.common.chart]
[ui.common.chart.axis]
x = ""
y = ""
[ui.common.chart.period]
day = ""
@@ -162,29 +201,12 @@ week = ""
[ui.common.chart.series_summary]
login_users = ""
[ui.common.chart.axis]
x = ""
y = ""
[ui.admin.integrity]
fetch_error = ""
[ui.admin.integrity.summary]
failures_text = ""
title = ""
[ui.admin.integrity.section]
tenant_integrity = ""
user_integrity = ""
[ui.admin.overview.chart]
description = ""
title = ""
[ui.common.badge]
[ui.common.custom_claim_permission]
admin_only = ""
command_only = ""
system = ""
user_and_admin = ""
[ui.common.overview]
title = ""
[ui.common.status]
active = ""
@@ -197,10 +219,3 @@ pending = ""
success = ""
unchanged = ""
updated = ""
[ui.common]
searching = ""
[ui.common.custom_claim_permission]
admin_only = ""
user_and_admin = ""
Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 815 KiB

After

Width:  |  Height:  |  Size: 821 KiB

@@ -2798,6 +2798,7 @@ function ClientGeneralPage() {
) : (
<div className="flex flex-col gap-2">
<Input
key={claim.valueType}
type={claimDefaultInputType(claim.valueType)}
inputMode={claimDefaultInputMode(
claim.valueType,
@@ -328,7 +328,11 @@ test.describe("DevFront RP claim cache", () => {
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first()
.fill("3.14");
const responsePromise = page.waitForResponse(
"**/api/v1/dev/clients/client-claims",
);
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await responsePromise;
await expect
.poll(
+695 -686
View File
File diff suppressed because it is too large Load Diff
+714 -1152
View File
File diff suppressed because it is too large Load Diff
+693 -1007
View File
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -74,13 +74,25 @@ function parseTomlKeys(filePath) {
if (line.startsWith('[[') && line.endsWith(']]')) {
const sectionName = line.slice(2, -2).trim();
currentSection = sectionName ? sectionName.split('.').map((p) => p.trim()).filter(Boolean) : [];
currentSection = sectionName ? sectionName.split('.').map((p) => {
p = p.trim();
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
p = p.slice(1, -1).trim();
}
return p;
}).filter(Boolean) : [];
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
const sectionName = line.slice(1, -1).trim();
currentSection = sectionName ? sectionName.split('.').map((p) => p.trim()).filter(Boolean) : [];
currentSection = sectionName ? sectionName.split('.').map((p) => {
p = p.trim();
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
p = p.slice(1, -1).trim();
}
return p;
}).filter(Boolean) : [];
continue;
}
@@ -94,8 +106,8 @@ function parseTomlKeys(filePath) {
continue;
}
if (key.startsWith('"') && key.endsWith('"')) {
key = key.slice(1, -1);
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
key = key.slice(1, -1).trim();
}
const fullKey = [...currentSection, key].join('.');
+144 -55
View File
@@ -5,10 +5,42 @@ const fs = require('fs');
const path = require('path');
const ROOT = process.cwd();
const LOCALES_DIR = path.join(ROOT, 'locales');
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
const KO_PATH = path.join(LOCALES_DIR, 'ko.toml');
const EN_PATH = path.join(LOCALES_DIR, 'en.toml');
const LOCALE_SPECS = [
{
name: 'root',
label: 'root locales',
dir: path.join(ROOT, 'locales'),
template: 'template.toml',
langs: ['ko.toml', 'en.toml'],
ownsKey: (key) => !key.startsWith('ui.common.') && !key.startsWith('msg.common.'),
},
{
name: 'common',
label: 'common locales',
dir: path.join(ROOT, 'common', 'locales'),
template: 'template.toml',
langs: ['ko.toml', 'en.toml'],
ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'),
},
];
function shouldIgnoreCodeKey(key) {
return (
key.includes('.msg.') ||
key.includes('.ui.') ||
key.includes('.err.') ||
key.includes('.test.') ||
key.includes('.non.') ||
key.startsWith('ui.admin.users.list.table.') ||
key.startsWith('msg.admin.users.detail.') ||
key.startsWith('msg.dev.clients.') ||
key.startsWith('ui.admin.users.create.') ||
key.startsWith('ui.admin.users.detail.') ||
key.startsWith('ui.dev.clients.') ||
key.startsWith('ui.dev.session.')
);
}
const SKIP_DIRS = new Set([
'.git',
@@ -53,18 +85,33 @@ function parseToml(filePath) {
if (!line || line.startsWith('#')) continue;
if (line.startsWith('[[') && line.endsWith(']]')) {
const name = line.slice(2, -2).trim();
section = name ? name.split('.').map((p) => p.trim()).filter(Boolean) : [];
section = name ? name.split('.').map((p) => {
p = p.trim();
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
p = p.slice(1, -1).trim();
}
return p;
}).filter(Boolean) : [];
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
const name = line.slice(1, -1).trim();
section = name ? name.split('.').map((p) => p.trim()).filter(Boolean) : [];
section = name ? name.split('.').map((p) => {
p = p.trim();
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
p = p.slice(1, -1).trim();
}
return p;
}).filter(Boolean) : [];
continue;
}
const eqIndex = line.indexOf('=');
if (eqIndex === -1) continue;
const key = line.slice(0, eqIndex).trim();
let key = line.slice(0, eqIndex).trim();
if (!key) continue;
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
key = key.slice(1, -1).trim();
}
let valueRaw = line.slice(eqIndex + 1).trim();
let value = '';
if (
@@ -88,12 +135,20 @@ function buildTree(keys, valuesMap) {
let node = root;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!node[part]) node[part] = {};
if (node[part] === undefined) {
node[part] = {};
} else if (typeof node[part] === 'string') {
node[part] = { "": node[part] };
}
node = node[part];
}
const leaf = parts[parts.length - 1];
const value = valuesMap ? (valuesMap.get(key) ?? '') : '';
node[leaf] = value;
if (node[leaf] !== undefined && typeof node[leaf] === 'object') {
node[leaf][""] = value;
} else {
node[leaf] = value;
}
}
return root;
}
@@ -105,12 +160,34 @@ function renderToml(tree) {
lines.push(`[${path.join('.')}]`);
}
const keys = Object.keys(node).sort();
const leafKeys = keys.filter((k) => typeof node[k] === 'string');
const childKeys = keys.filter((k) => typeof node[k] === 'object');
for (const key of leafKeys) {
const value = node[key];
lines.push(`${key} = ${JSON.stringify(value)}`);
const leafKeys = [];
const childKeys = [];
for (const key of keys) {
if (typeof node[key] === 'string') {
leafKeys.push(key);
} else if (typeof node[key] === 'object') {
if (node[key][""] !== undefined) {
leafKeys.push(key);
} else {
childKeys.push(key);
}
}
}
for (const key of leafKeys) {
const val = node[key];
if (typeof val === 'string') {
lines.push(`${key} = ${JSON.stringify(val)}`);
} else {
lines.push(`${key} = ${JSON.stringify(val[""])}`);
const subKeys = Object.keys(val).filter((k) => k !== "").sort();
for (const subKey of subKeys) {
lines.push(`${key}.${subKey} = ${JSON.stringify(val[subKey])}`);
}
}
}
for (const key of childKeys) {
lines.push('');
walk(node[key], [...path, key]);
@@ -389,56 +466,68 @@ function keyToEnglish(key) {
}
function main() {
const templateMap = parseToml(TEMPLATE_PATH);
const koMap = parseToml(KO_PATH);
const enMap = parseToml(EN_PATH);
const fallbacks = extractFallbacks();
const allKeys = new Set([
...templateMap.keys(),
...koMap.keys(),
...enMap.keys(),
]);
for (const spec of LOCALE_SPECS) {
const templatePath = path.join(spec.dir, spec.template);
const koPath = path.join(spec.dir, 'ko.toml');
const enPath = path.join(spec.dir, 'en.toml');
for (const key of allKeys) {
const fallback = fallbacks.get(key);
const currentKo = koMap.get(key) ?? '';
const currentEn = enMap.get(key) ?? '';
const templateMap = parseToml(templatePath);
const koMap = parseToml(koPath);
const enMap = parseToml(enPath);
let nextKo = currentKo;
if (!nextKo && fallback) {
nextKo = fallback;
}
if (!nextKo) {
nextKo = key;
}
const ownedFallbackKeys = Array.from(fallbacks.keys()).filter(
(key) => spec.ownsKey(key) && !shouldIgnoreCodeKey(key)
);
let nextEn = currentEn;
if (!nextEn) {
const source = fallback || nextKo || key;
if (isLongText(source)) {
nextEn = source;
} else if (isMostlyAscii(source)) {
nextEn = source;
} else {
nextEn = translateKorean(source);
const allKeys = new Set([
...templateMap.keys(),
...koMap.keys(),
...enMap.keys(),
...ownedFallbackKeys,
]);
for (const key of allKeys) {
const fallback = fallbacks.get(key);
const currentKo = koMap.get(key) ?? '';
const currentEn = enMap.get(key) ?? '';
let nextKo = currentKo;
if (!nextKo && fallback) {
nextKo = fallback;
}
}
if (!nextEn) {
nextEn = key;
}
if (!isLongText(nextEn) && containsHangul(nextEn)) {
nextEn = keyToEnglish(key);
if (!nextKo) {
nextKo = key;
}
let nextEn = currentEn;
if (!nextEn) {
const source = fallback || nextKo || key;
if (isLongText(source)) {
nextEn = source;
} else if (isMostlyAscii(source)) {
nextEn = source;
} else {
nextEn = translateKorean(source);
}
}
if (!nextEn) {
nextEn = key;
}
if (!isLongText(nextEn) && containsHangul(nextEn)) {
nextEn = keyToEnglish(key);
}
koMap.set(key, nextKo);
enMap.set(key, nextEn);
}
koMap.set(key, nextKo);
enMap.set(key, nextEn);
const keys = Array.from(allKeys).sort();
fs.writeFileSync(koPath, renderToml(buildTree(keys, koMap)));
fs.writeFileSync(enPath, renderToml(buildTree(keys, enMap)));
fs.writeFileSync(templatePath, renderToml(buildTree(keys, null)));
}
const keys = Array.from(allKeys).sort();
fs.writeFileSync(KO_PATH, renderToml(buildTree(keys, koMap)));
fs.writeFileSync(EN_PATH, renderToml(buildTree(keys, enMap)));
fs.writeFileSync(TEMPLATE_PATH, renderToml(buildTree(keys, null)));
}
main();
+55 -57
View File
@@ -56,11 +56,14 @@ result = "Result: {value}"
session_id = "Session ID: {value}"
status = "Status: pending"
[msg.userfront.audit.filter]
description = "Toggle to view only active sessions."
[msg.userfront.consent]
accept_error = "Failed to process consent: {error}"
client_id = "Client ID: {id}"
client_unknown = "Unknown application"
description = "The service below is requesting access to your account information.\\\\nPlease choose whether to continue."
description = "The service below is requesting access to your account information.\\\\\\\\nPlease choose whether to continue."
load_error = "Failed to load consent information: {error}"
missing_redirect = "Consent was processed, but the redirect URL was missing."
redirect_notice = "After consent, you will be redirected automatically."
@@ -82,15 +85,15 @@ approved_device = "Approved device: {device}"
approved_ip = "Approved IP: {ip}"
audit_empty = "No recent sign-in activity."
audit_load_error = "Could not load sign-in history."
auto_login_supported = "You can sign in without an extra login when opening this linked app."
auth_method = "Auth method: {method}"
auto_login_supported = "You can sign in without an extra login when opening this linked app."
client_id = "Client ID: {id}"
client_id_missing = "No client ID available."
current_status = "Current status: {status}"
last_auth = "Last signed in: {value}"
link_status = "Link status: {status}"
link_missing = "This app does not have a launch URL configured."
link_open_error = "Could not open the app link."
link_status = "Link status: {status}"
render_error = "Dashboard render error: {error}"
session_id_copied = "Session ID copied."
@@ -99,6 +102,19 @@ empty = "No linked apps yet."
empty_detail = "Linked apps and their latest activity will appear here."
error = "Could not load linked apps."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nClick to copy."
copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nTap to copy."
none = "No {label}"
[msg.userfront.dashboard.revoke]
confirm = "Disconnect {app}?\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in."
error = "Could not disconnect the app: {error}"
success = "{app} has been disconnected."
[msg.userfront.dashboard.scopes]
empty = "No scopes were requested."
[msg.userfront.dashboard.sessions]
browser = "Browser: {value}"
empty = "No active sessions."
@@ -109,23 +125,10 @@ recent_app = "Recent app: {app}"
session_id = "Session ID: {id}"
[msg.userfront.dashboard.sessions.revoke]
confirm = "End the session for {target}?\nThat device will need to sign in again."
confirm = "End the session for {target}?\\nThat device will need to sign in again."
error = "Could not end the session: {error}"
success = "The session has been ended."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\\\\\\\\\\\\\\\\nClick to copy."
copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\nTap to copy."
none = "No {label}"
[msg.userfront.dashboard.revoke]
confirm = "Disconnect {app}?\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in."
error = "Could not disconnect the app: {error}"
success = "{app} has been disconnected."
[msg.userfront.dashboard.scopes]
empty = "No scopes were requested."
[msg.userfront.dashboard.timeline]
load_error = "Could not load sign-in history."
@@ -139,6 +142,22 @@ title_generic = "An error occurred."
title_with_code = "Error: {code}"
type = "Error type: {type}"
[msg.userfront.error.ory]
$normalizedCode = "{error}"
access_denied = "The user denied the consent request."
consent_required = "Consent is required to continue."
interaction_required = "Additional interaction is required. Please try again."
invalid_client = "Client authentication failed."
invalid_grant = "The authorization grant is invalid or expired."
invalid_request = "The request is invalid."
invalid_scope = "The requested scope is invalid."
login_required = "Login is required."
request_forbidden = "The request was forbidden."
server_error = "An authentication server error occurred."
temporarily_unavailable = "The authentication server is temporarily unavailable."
unauthorized_client = "The client is not authorized for this request."
unsupported_response_type = "The response type is not supported."
[msg.userfront.error.tenant]
account = "Account"
account_unknown = "Unknown"
@@ -155,24 +174,8 @@ tenant = "Tenant"
tenant_unknown = "Unknown"
title = "Access restriction details"
[msg.userfront.error.ory]
"$normalizedCode" = "{error}"
access_denied = "The user denied the consent request."
consent_required = "Consent is required to continue."
interaction_required = "Additional interaction is required. Please try again."
invalid_client = "Client authentication failed."
invalid_grant = "The authorization grant is invalid or expired."
invalid_request = "The request is invalid."
invalid_scope = "The requested scope is invalid."
login_required = "Login is required."
request_forbidden = "The request was forbidden."
server_error = "An authentication server error occurred."
temporarily_unavailable = "The authentication server is temporarily unavailable."
unauthorized_client = "The client is not authorized for this request."
unsupported_response_type = "The response type is not supported."
[msg.userfront.error.whitelist]
"$normalizedCode" = "{error}"
$normalizedCode = "{error}"
bad_request = "Please check your input."
invalid_session = "Your session has expired. Please sign in again."
not_found = "The requested page could not be found."
@@ -226,14 +229,14 @@ scan_hint = "Scan it with the mobile app."
invalid = "Enter the 2 letters and 6 digits from your code."
[msg.userfront.login.unregistered]
body = "We could not find an account for that information.\\\\\\\\\\\\\\\\nPlease sign up before continuing."
body = "We could not find an account for that information.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nPlease sign up before continuing."
[msg.userfront.login.verification]
approved = "Approved. Complete sign-in in the original window."
approved_local = "Approved. This device is already signed in, and the remote window will be signed in shortly."
approved_remote = "Your requested sign-in is complete."
pending_remote = "Checking the sign-in approval request. Please wait."
close_hint = "You can close this window now."
pending_remote = "Checking the sign-in approval request. Please wait."
success = "Sign-in approval completed."
[msg.userfront.login_success]
@@ -465,6 +468,10 @@ dev_console = "Dev Console"
[ui.userfront.audit]
[ui.userfront.audit.filter]
title = "Manage My Activity"
toggle_label = "Show active sessions only"
[ui.userfront.audit.table]
action = "Action"
app = "App"
@@ -499,17 +506,6 @@ status_history = "Link details"
[ui.userfront.dashboard.activity]
linked = "Linked"
[ui.userfront.dashboard.sessions]
active_badge = "Active"
current_badge = "Current"
current_disabled = "Current session"
unknown_device = "Unknown device"
unknown_session = "Session"
[ui.userfront.dashboard.sessions.revoke]
action = "End session"
title = "End session"
[ui.userfront.dashboard.approved_session]
default = "Default"
userfront = "Approved UserFront session ID"
@@ -521,6 +517,17 @@ title = "Disconnect app"
[ui.userfront.dashboard.scopes]
title = "Consent scopes"
[ui.userfront.dashboard.sessions]
active_badge = "Active"
current_badge = "Current"
current_disabled = "Current session"
unknown_device = "Unknown device"
unknown_session = "Session"
[ui.userfront.dashboard.sessions.revoke]
action = "End session"
title = "End session"
[ui.userfront.dashboard.status]
revoked = "Revoked"
@@ -583,8 +590,8 @@ title = "Account not found"
[ui.userfront.login.verification]
action_label = "Done"
action_label_remote = "Go to sign-in window"
action_label_close = "Close Window"
action_label_remote = "Go to sign-in window"
page_title = "Baron SW Portal"
title = "Approval complete"
title_pending = "Checking approval"
@@ -698,12 +705,3 @@ verify = "Verification"
[ui.userfront.signup.success]
action = "Go to sign-in"
[ui.userfront.audit.filter]
title = "Manage My Activity"
toggle_label = "Show active sessions only"
[msg.userfront.audit.filter]
description = "Toggle to view only active sessions."
+62 -285
View File
@@ -41,231 +41,6 @@ verify_code_failed = "인증 실패: {error}"
[err.userfront.session]
missing = "활성 세션이 없습니다."
[msg.userfront.audit]
browser = "브라우저: {value}"
date = "접속일자: {value}"
device = "접속환경: {value}"
end = "더 이상 항목이 없습니다."
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
ip = "접속 IP: {value}"
load_more_error = "더 불러오지 못했습니다."
result = "인증결과: {value}"
session_id = "Session ID: {value}"
status = "현황: (준비중)"
[msg.userfront.dashboard]
approved_device = "승인 기기: {device}"
approved_ip = "승인 IP: {ip}"
audit_empty = "최근 접속 이력이 없습니다."
audit_load_error = "접속이력을 불러오지 못했습니다."
auth_method = "인증수단: {method}"
client_id = "Client ID: {id}"
client_id_missing = "Client ID 없음"
current_status = "현재 상태: {status}"
last_auth = "최근 인증: {value}"
link_status = "연동 상태: {status}"
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
link_open_error = "해당 링크를 열 수 없습니다."
render_error = "대시보드 렌더링 오류: {error}"
session_id_copied = "세션 ID가 복사되었습니다."
[msg.userfront.error]
detail_contact = "관리자에게 문의해 주세요."
detail_generic = "오류가 발생했습니다."
detail_request = "요청을 처리하는 중 문제가 발생했습니다."
id = "오류 ID: {id}"
title = "인증 과정에서 오류가 발생했습니다"
title_generic = "오류가 발생했습니다"
title_with_code = "오류: {code}"
type = "오류 종류: {type}"
[msg.userfront.error.tenant]
account = "계정"
account_unknown = "알 수 없음"
affiliated_tenants = "전체 소속 테넌트"
allowed_box_title = "접속 가능 테넌트"
allowed_tenants = "접속 가능 테넌트"
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
loading = "현재 계정 정보를 불러오는 중입니다."
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
page_title = "애플리케이션 접근이 제한되었습니다"
primary_tenant = "대표 소속 테넌트"
tenant = "소속 테넌트"
tenant_unknown = "알 수 없음"
title = "접근 제한 정보"
[msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
error = "전송에 실패했습니다: {error}"
input_required = "이메일 또는 휴대폰 번호를 입력해주세요."
sent = "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."
[msg.userfront.login]
cookie_check_failed = "로그인 확인 실패: {error}"
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
link_failed = "오류: {error}"
link_send_failed = "전송 실패: {error}"
link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다."
link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다."
link_timeout = "시간이 경과되었습니다."
no_account = "계정이 없으신가요?"
oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."
qr_expired = "시간이 경과되었습니다."
qr_init_failed = "QR 초기화에 실패했습니다: {error}"
qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다"
token_missing = "로그인 토큰을 확인할 수 없습니다."
verification_failed = "승인 처리에 실패했습니다: {error}"
[msg.userfront.login_success]
subtitle = "성공적으로 로그인되었습니다."
[msg.userfront.consent]
accept_error = "동의 처리에 실패했습니다: {error}"
client_id = "클라이언트 ID: {id}"
client_unknown = "알 수 없는 앱"
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요."
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
scope_count = "총 {count}개"
[msg.userfront.profile]
department_missing = "소속 정보 없음"
department_required = "소속을 입력해주세요."
email_missing = "이메일 없음"
greeting = "안녕하세요, {name}님"
load_failed = "정보를 불러올 수 없습니다."
name_missing = "이름 없음"
name_required = "이름을 입력해주세요."
phone_required = "휴대폰 번호를 입력해주세요."
phone_verify_required = "휴대폰 번호 인증이 필요합니다."
update_failed = "수정 실패: {error}"
update_success = "정보가 수정되었습니다."
[msg.userfront.qr]
camera_error = "카메라 오류: {error}"
permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요."
permission_required = "카메라 권한이 필요합니다."
[msg.userfront.reset]
invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요."
invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)"
invalid_title = "유효하지 않은 링크입니다."
policy_loading = "비밀번호 정책을 불러오는 중입니다..."
success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."
[msg.userfront.sections]
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다."
[msg.userfront.settings]
disabled = "현재 계정 설정 화면은 준비 중입니다."
[msg.userfront.signup]
failed = "가입 실패: {error}"
privacy_full = "개인정보 수집 및 이용 동의 전문..."
tos_full = "서비스 이용약관 전문..."
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.status]
active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "비활성"
ok = "정상"
pending = "준비 중"
success = "성공"
[ui.userfront.app_label]
admin_console = "Admin Console"
baron = "Baron 로그인"
dev_console = "Dev Console"
[ui.userfront.auth_method]
ory = "Ory 세션"
session = "세션"
[ui.userfront.dashboard]
last_auth_label = "최근 인증"
link_status_label = "연동 상태"
status_history = "연동 정보"
[ui.userfront.device]
android = "Mobile(Android)"
ios = "Mobile(iOS)"
linux = "Desktop(Linux)"
macos = "Desktop(macOS)"
windows = "Desktop(Windows)"
[ui.userfront.error]
go_home = "홈으로 이동"
go_login = "로그인으로 이동"
switch_account = "다른 계정으로 로그인"
[ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?"
input_label = "이메일 또는 휴대폰 번호"
submit = "재설정 링크 전송"
title = "비밀번호 재설정"
[ui.userfront.login]
forgot_password = "비밀번호를 잊으셨나요?"
signup = "회원가입"
[ui.userfront.login_success]
later = "나중에 하기 (대시보드로 이동)"
qr = "QR 인증 (카메라 켜기)"
title = "로그인 완료"
[ui.userfront.consent]
accept = "동의하고 계속하기"
requested_scopes = "요청된 권한"
title = "접근 권한 요청"
[ui.userfront.nav]
dashboard = "대시보드"
logout = "로그아웃"
profile = "내 정보"
qr_scan = "QR 스캔"
[ui.userfront.profile]
department_empty = "소속 정보 없음"
manage = "프로필 관리"
user_fallback = "사용자"
[ui.userfront.qr]
rescan = "다시 스캔"
result_success = "승인 완료"
title = "Scan QR Code"
[ui.userfront.reset]
confirm_password = "새 비밀번호 확인"
new_password = "새 비밀번호"
submit = "비밀번호 변경"
subtitle = "새로운 비밀번호 설정"
title = "새 비밀번호 설정"
[ui.userfront.sections]
apps = "나의 App 현황"
audit = "접속이력"
sessions = "활성 세션"
[ui.userfront.session]
active = "세션 활성"
unknown = "알 수 없음"
[ui.userfront.signup]
complete = "가입 완료"
next_step = "다음 단계"
title = "회원가입"
[msg.userfront]
greeting = "안녕하세요, {name}님"
@@ -281,11 +56,14 @@ result = "인증결과: {value}"
session_id = "Session ID: {value}"
status = "현황: (준비중)"
[msg.userfront.audit.filter]
description = "활성화된 세션만 보려면 토글을 켜주세요."
[msg.userfront.consent]
accept_error = "동의 처리에 실패했습니다: {error}"
client_id = "클라이언트 ID: {id}"
client_unknown = "알 수 없는 앱"
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\n계속 진행하려면 동의 여부를 선택해 주세요."
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\\\\\n계속 진행하려면 동의 여부를 선택해 주세요."
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
@@ -307,14 +85,15 @@ approved_device = "승인 기기: {device}"
approved_ip = "승인 IP: {ip}"
audit_empty = "최근 접속 이력이 없습니다."
audit_load_error = "접속이력을 불러오지 못했습니다."
auto_login_supported = "연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다."
auth_method = "인증수단: {method}"
auto_login_supported = "연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다."
client_id = "Client ID: {id}"
client_id_missing = "Client ID 없음"
current_status = "현재 상태: {status}"
last_auth = "최근 인증: {value}"
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
link_open_error = "해당 링크를 열 수 없습니다."
link_status = "연동 상태: {status}"
render_error = "대시보드 렌더링 오류: {error}"
session_id_copied = "세션 ID가 복사되었습니다."
@@ -323,6 +102,19 @@ empty = "연동된 앱이 없습니다."
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
error = "연동 정보를 불러오지 못했습니다."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\\\\\\\\n클릭하면 복사됩니다."
copy_tap = "{label}: {id}\\\\\\\\n탭하면 복사됩니다."
none = "{label} 없음"
[msg.userfront.dashboard.revoke]
confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
error = "해지 실패: {error}"
success = "{app} 연동이 해지되었습니다."
[msg.userfront.dashboard.scopes]
empty = "요청된 권한이 없습니다."
[msg.userfront.dashboard.sessions]
browser = "브라우저: {value}"
empty = "활성 세션이 없습니다."
@@ -333,23 +125,10 @@ recent_app = "최근 접속 앱: {app}"
session_id = "세션 ID: {id}"
[msg.userfront.dashboard.sessions.revoke]
confirm = "{target} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다."
confirm = "{target} 세션을 종료하시겠습니까?\\n대상 기기에서는 다시 로그인이 필요합니다."
error = "세션 종료 실패: {error}"
success = "세션이 종료되었습니다."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\\\\n클릭하면 복사됩니다."
copy_tap = "{label}: {id}\\\\n탭하면 복사됩니다."
none = "{label} 없음"
[msg.userfront.dashboard.revoke]
confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
error = "해지 실패: {error}"
success = "{app} 연동이 해지되었습니다."
[msg.userfront.dashboard.scopes]
empty = "요청된 권한이 없습니다."
[msg.userfront.dashboard.timeline]
load_error = "접속이력을 불러오지 못했습니다."
@@ -363,6 +142,22 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {code}"
type = "오류 종류: {type}"
[msg.userfront.error.ory]
$normalizedCode = "{error}"
access_denied = "사용자가 동의를 거부했습니다."
consent_required = "앱 접근 동의가 필요합니다."
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
invalid_client = "클라이언트 인증 정보가 유효하지 않습니다."
invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다."
invalid_request = "잘못된 요청입니다."
invalid_scope = "요청한 권한 범위가 유효하지 않습니다."
login_required = "로그인이 필요합니다."
request_forbidden = "요청이 거부되었습니다."
server_error = "인증 서버 오류가 발생했습니다."
temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다."
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
unsupported_response_type = "지원하지 않는 응답 타입입니다."
[msg.userfront.error.tenant]
account = "계정"
account_unknown = "알 수 없음"
@@ -379,24 +174,8 @@ tenant = "소속 테넌트"
tenant_unknown = "알 수 없음"
title = "접근 제한 정보"
[msg.userfront.error.ory]
"$normalizedCode" = "{error}"
access_denied = "사용자가 동의를 거부했습니다."
consent_required = "앱 접근 동의가 필요합니다."
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
invalid_client = "클라이언트 인증 정보가 유효하지 않습니다."
invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다."
invalid_request = "잘못된 요청입니다."
invalid_scope = "요청한 권한 범위가 유효하지 않습니다."
login_required = "로그인이 필요합니다."
request_forbidden = "요청이 거부되었습니다."
server_error = "인증 서버 오류가 발생했습니다."
temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다."
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
unsupported_response_type = "지원하지 않는 응답 타입입니다."
[msg.userfront.error.whitelist]
"$normalizedCode" = "{error}"
$normalizedCode = "{error}"
bad_request = "입력값을 확인해 주세요."
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
not_found = "요청한 페이지를 찾을 수 없습니다."
@@ -450,14 +229,14 @@ scan_hint = "모바일 앱으로 스캔하세요"
invalid = "문자 2개와 숫자 6자리를 입력해 주세요."
[msg.userfront.login.unregistered]
body = "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주세요."
body = "가입되지 않은 정보입니다.\\\\\\\\n회원가입 후 이용해 주세요."
[msg.userfront.login.verification]
approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
approved_remote = "요청하신 로그인이 완료되었습니다"
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
close_hint = "이 창은 이제 닫으셔도 됩니다."
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
success = "로그인 승인에 성공했습니다."
[msg.userfront.login_success]
@@ -532,6 +311,7 @@ uppercase = "대문자 1개 이상"
[msg.userfront.sections]
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다."
[msg.userfront.settings]
disabled = "현재 계정 설정 화면은 준비 중입니다."
@@ -546,12 +326,12 @@ all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
title = "서비스 이용을 위해\\\\n약관에 동의해주세요"
title = "서비스 이용을 위해\\\\\\\\n약관에 동의해주세요"
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
[msg.userfront.signup.auth]
affiliate_notice = "가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요."
title = "본인 확인을 위해\\\\n인증을 진행해주세요"
title = "본인 확인을 위해\\\\\\\\n인증을 진행해주세요"
[msg.userfront.signup.email]
code_mismatch = "인증코드가 일치하지 않습니다."
@@ -567,7 +347,7 @@ lowercase_required = "소문자가 최소 1개 이상 포함되어야 합니다.
mismatch = "비밀번호가 일치하지 않습니다."
number_required = "숫자가 최소 1개 이상 포함되어야 합니다."
symbol_required = "특수문자가 최소 1개 이상 포함되어야 합니다."
title = "마지막으로\\\\n비밀번호를 설정해주세요"
title = "마지막으로\\\\\\\\n비밀번호를 설정해주세요"
uppercase_required = "대문자가 최소 1개 이상 포함되어야 합니다."
[msg.userfront.signup.password.rule]
@@ -596,7 +376,7 @@ uppercase = "대문자"
[msg.userfront.signup.profile]
affiliate_hint = "가족사 이메일 사용 시 자동으로 선택됩니다."
title = "회원님의\\\\n소속 정보를 알려주세요"
title = "회원님의\\\\\\\\n소속 정보를 알려주세요"
[msg.userfront.signup.success]
body = "성공적으로 가입되었습니다."
@@ -688,6 +468,10 @@ dev_console = "Dev Console"
[ui.userfront.audit]
[ui.userfront.audit.filter]
title = "내 활동 관리"
toggle_label = "활성 세션만 보기"
[ui.userfront.audit.table]
action = "관리"
app = "애플리케이션"
@@ -716,22 +500,12 @@ title = "동의 취소"
[ui.userfront.dashboard]
last_auth_label = "최근 인증"
link_status_label = "연동 상태"
status_history = "상태 이력"
[ui.userfront.dashboard.activity]
linked = "연동됨"
[ui.userfront.dashboard.sessions]
active_badge = "활성화"
current_badge = "접속중"
current_disabled = "현재 세션"
unknown_device = "알 수 없는 기기"
unknown_session = "세션 정보"
[ui.userfront.dashboard.sessions.revoke]
action = "세션 종료"
title = "세션 종료"
[ui.userfront.dashboard.approved_session]
default = "승인한 세션 ID"
userfront = "승인한 Userfront 세션 ID"
@@ -743,6 +517,17 @@ title = "연동 해지"
[ui.userfront.dashboard.scopes]
title = "동의 범위"
[ui.userfront.dashboard.sessions]
active_badge = "활성화"
current_badge = "접속중"
current_disabled = "현재 세션"
unknown_device = "알 수 없는 기기"
unknown_session = "세션 정보"
[ui.userfront.dashboard.sessions.revoke]
action = "세션 종료"
title = "세션 종료"
[ui.userfront.dashboard.status]
revoked = "해지됨"
@@ -805,10 +590,10 @@ title = "미등록 회원"
[ui.userfront.login.verification]
action_label = "확인"
action_label_close = "창 닫기"
action_label_remote = "로그인 창으로 이동하기"
page_title = "Baron SW 포탈"
title = "승인 완료"
action_label_close = "창 닫기"
title_pending = "로그인 승인 확인 중"
title_remote = "로그인 승인 완료"
@@ -872,6 +657,7 @@ title = "새 비밀번호 설정"
[ui.userfront.sections]
apps = "나의 App 현황"
audit = "접속이력"
sessions = "활성 세션"
[ui.userfront.session]
active = "세션 활성"
@@ -919,12 +705,3 @@ verify = "본인인증"
[ui.userfront.signup.success]
action = "로그인하기"
[ui.userfront.audit.filter]
title = "내 활동 관리"
toggle_label = "활성 세션만 보기"
[msg.userfront.audit.filter]
description = "활성화된 세션만 보려면 토글을 켜주세요."
+55 -250
View File
@@ -41,203 +41,6 @@ verify_code_failed = ""
[err.userfront.session]
missing = ""
[msg.userfront.error]
detail_contact = ""
detail_generic = ""
detail_request = ""
id = ""
title = ""
title_generic = ""
title_with_code = ""
type = ""
[msg.userfront.error.tenant]
account = ""
account_unknown = ""
affiliated_tenants = ""
allowed_box_title = ""
allowed_tenants = ""
detail = ""
load_failed = ""
loading = ""
lookup_fallback = ""
page_title = ""
primary_tenant = ""
tenant = ""
tenant_unknown = ""
title = ""
[msg.userfront.forgot]
description = ""
dry_send = ""
error = ""
input_required = ""
sent = ""
[msg.userfront.login]
cookie_check_failed = ""
dry_send = ""
link_failed = ""
link_send_failed = ""
link_sent_email = ""
link_sent_phone = ""
link_timeout = ""
no_account = ""
oidc_failed = ""
qr_expired = ""
qr_init_failed = ""
qr_login_required = ""
token_missing = ""
verification_failed = ""
[msg.userfront.login_success]
subtitle = ""
[msg.userfront.consent]
accept_error = ""
client_id = ""
client_unknown = ""
description = ""
load_error = ""
missing_redirect = ""
redirect_notice = ""
scope_count = ""
[msg.userfront.profile]
department_missing = ""
department_required = ""
email_missing = ""
greeting = ""
load_failed = ""
name_missing = ""
name_required = ""
phone_required = ""
phone_verify_required = ""
update_failed = ""
update_success = ""
[msg.userfront.qr]
camera_error = ""
permission_error = ""
permission_required = ""
[msg.userfront.reset]
invalid_body = ""
invalid_link = ""
invalid_title = ""
policy_loading = ""
success = ""
[msg.userfront.sections]
apps_subtitle = ""
audit_subtitle = ""
sessions_subtitle = ""
[msg.userfront.settings]
disabled = ""
[msg.userfront.signup]
failed = ""
privacy_full = ""
tos_full = ""
[ui.common.badge]
admin_only = ""
command_only = ""
system = ""
[ui.common.status]
active = ""
blocked = ""
failure = ""
inactive = ""
ok = ""
pending = ""
success = ""
[ui.userfront.app_label]
admin_console = ""
baron = ""
dev_console = ""
[ui.userfront.auth_method]
ory = ""
session = ""
[ui.userfront.dashboard]
link_status_label = ""
last_auth_label = ""
status_history = ""
[ui.userfront.device]
android = ""
ios = ""
linux = ""
macos = ""
windows = ""
[ui.userfront.error]
go_home = ""
go_login = ""
switch_account = ""
[ui.userfront.forgot]
heading = ""
input_label = ""
submit = ""
title = ""
[ui.userfront.login]
forgot_password = ""
signup = ""
[ui.userfront.login_success]
later = ""
qr = ""
title = ""
[ui.userfront.consent]
accept = ""
requested_scopes = ""
title = ""
[ui.userfront.nav]
dashboard = ""
logout = ""
profile = ""
qr_scan = ""
[ui.userfront.profile]
department_empty = ""
manage = ""
user_fallback = ""
[ui.userfront.qr]
rescan = ""
result_success = ""
title = ""
[ui.userfront.reset]
confirm_password = ""
new_password = ""
submit = ""
subtitle = ""
title = ""
[ui.userfront.sections]
apps = ""
audit = ""
sessions = ""
[ui.userfront.session]
active = ""
unknown = ""
[ui.userfront.signup]
complete = ""
next_step = ""
title = ""
[msg.userfront]
greeting = ""
@@ -253,6 +56,9 @@ result = ""
session_id = ""
status = ""
[msg.userfront.audit.filter]
description = ""
[msg.userfront.consent]
accept_error = ""
client_id = ""
@@ -279,14 +85,15 @@ approved_device = ""
approved_ip = ""
audit_empty = ""
audit_load_error = ""
auto_login_supported = ""
auth_method = ""
auto_login_supported = ""
client_id = ""
client_id_missing = ""
current_status = ""
last_auth = ""
link_missing = ""
link_open_error = ""
link_status = ""
render_error = ""
session_id_copied = ""
@@ -295,6 +102,19 @@ empty = ""
empty_detail = ""
error = ""
[msg.userfront.dashboard.approved_session]
copy_click = ""
copy_tap = ""
none = ""
[msg.userfront.dashboard.revoke]
confirm = ""
error = ""
success = ""
[msg.userfront.dashboard.scopes]
empty = ""
[msg.userfront.dashboard.sessions]
browser = ""
empty = ""
@@ -309,19 +129,6 @@ confirm = ""
error = ""
success = ""
[msg.userfront.dashboard.approved_session]
copy_click = ""
copy_tap = ""
none = ""
[msg.userfront.dashboard.revoke]
confirm = ""
error = ""
success = ""
[msg.userfront.dashboard.scopes]
empty = ""
[msg.userfront.dashboard.timeline]
load_error = ""
@@ -335,6 +142,22 @@ title_generic = ""
title_with_code = ""
type = ""
[msg.userfront.error.ory]
$normalizedCode = ""
access_denied = ""
consent_required = ""
interaction_required = ""
invalid_client = ""
invalid_grant = ""
invalid_request = ""
invalid_scope = ""
login_required = ""
request_forbidden = ""
server_error = ""
temporarily_unavailable = ""
unauthorized_client = ""
unsupported_response_type = ""
[msg.userfront.error.tenant]
account = ""
account_unknown = ""
@@ -351,24 +174,8 @@ tenant = ""
tenant_unknown = ""
title = ""
[msg.userfront.error.ory]
"$normalizedCode" = ""
access_denied = ""
consent_required = ""
interaction_required = ""
invalid_client = ""
invalid_grant = ""
invalid_request = ""
invalid_scope = ""
login_required = ""
request_forbidden = ""
server_error = ""
temporarily_unavailable = ""
unauthorized_client = ""
unsupported_response_type = ""
[msg.userfront.error.whitelist]
"$normalizedCode" = ""
$normalizedCode = ""
bad_request = ""
invalid_session = ""
not_found = ""
@@ -428,8 +235,8 @@ body = ""
approved = ""
approved_local = ""
approved_remote = ""
pending_remote = ""
close_hint = ""
pending_remote = ""
success = ""
[msg.userfront.login_success]
@@ -504,6 +311,7 @@ uppercase = ""
[msg.userfront.sections]
apps_subtitle = ""
audit_subtitle = ""
sessions_subtitle = ""
[msg.userfront.settings]
disabled = ""
@@ -660,6 +468,10 @@ dev_console = ""
[ui.userfront.audit]
[ui.userfront.audit.filter]
title = ""
toggle_label = ""
[ui.userfront.audit.table]
action = ""
app = ""
@@ -688,22 +500,12 @@ title = ""
[ui.userfront.dashboard]
last_auth_label = ""
link_status_label = ""
status_history = ""
[ui.userfront.dashboard.activity]
linked = ""
[ui.userfront.dashboard.sessions]
active_badge = ""
current_badge = ""
current_disabled = ""
unknown_device = ""
unknown_session = ""
[ui.userfront.dashboard.sessions.revoke]
action = ""
title = ""
[ui.userfront.dashboard.approved_session]
default = ""
userfront = ""
@@ -715,6 +517,17 @@ title = ""
[ui.userfront.dashboard.scopes]
title = ""
[ui.userfront.dashboard.sessions]
active_badge = ""
current_badge = ""
current_disabled = ""
unknown_device = ""
unknown_session = ""
[ui.userfront.dashboard.sessions.revoke]
action = ""
title = ""
[ui.userfront.dashboard.status]
revoked = ""
@@ -777,8 +590,8 @@ title = ""
[ui.userfront.login.verification]
action_label = ""
action_label_remote = ""
action_label_close = ""
action_label_remote = ""
page_title = ""
title = ""
title_pending = ""
@@ -844,6 +657,7 @@ title = ""
[ui.userfront.sections]
apps = ""
audit = ""
sessions = ""
[ui.userfront.session]
active = ""
@@ -891,12 +705,3 @@ verify = ""
[ui.userfront.signup.success]
action = ""
[ui.userfront.audit.filter]
title = ""
toggle_label = ""
[msg.userfront.audit.filter]
description = ""