forked from baron/baron-sso
i18n, adminfront, devfront: 'make code-check' 통과를 위한 번역 싱크 엔진 개선, 룰 오브 훅 정합성 교정 및 테스트 레이스 컨디션 해결
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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: {} });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "사용자와 관리자"
|
||||
|
||||
@@ -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(
|
||||
|
||||
1381
locales/en.toml
1381
locales/en.toml
File diff suppressed because it is too large
Load Diff
1866
locales/ko.toml
1866
locales/ko.toml
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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('.');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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 = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user