forked from baron/baron-sso
Merge origin/dev into dev
This commit is contained in:
@@ -1426,7 +1426,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p ../reports
|
mkdir -p ../reports
|
||||||
set +e
|
set +e
|
||||||
pnpm test 2>&1 | tee ../reports/devfront-test.log
|
pnpm run test:ci 2>&1 | tee ../reports/devfront-test.log
|
||||||
test_exit_code=${PIPESTATUS[0]}
|
test_exit_code=${PIPESTATUS[0]}
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -1442,7 +1442,7 @@ jobs:
|
|||||||
echo "1. \`cd devfront\`"
|
echo "1. \`cd devfront\`"
|
||||||
echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`"
|
echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`"
|
||||||
echo "3. \`pnpm exec playwright install --with-deps\`"
|
echo "3. \`pnpm exec playwright install --with-deps\`"
|
||||||
echo "4. \`pnpm test\`"
|
echo "4. \`pnpm run test:ci\`"
|
||||||
echo
|
echo
|
||||||
echo "## Log Tail (last 200 lines)"
|
echo "## Log Tail (last 200 lines)"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
|||||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
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 TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||||
@@ -51,6 +53,10 @@ export const adminRoutes: RouteObject[] = [
|
|||||||
{ path: "tenants", element: <TenantListPage /> },
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||||
|
{
|
||||||
|
path: "permissions-direct",
|
||||||
|
element: <TenantFineGrainedPermissionsPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "tenants/:tenantId",
|
path: "tenants/:tenantId",
|
||||||
element: <TenantDetailPage />,
|
element: <TenantDetailPage />,
|
||||||
@@ -59,6 +65,10 @@ export const adminRoutes: RouteObject[] = [
|
|||||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||||
{ path: "schema", element: <TenantSchemaPage /> },
|
{ path: "schema", element: <TenantSchemaPage /> },
|
||||||
|
{
|
||||||
|
path: "relations",
|
||||||
|
element: <TenantFineGrainedPermissionsTab />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ describe("admin AppLayout", () => {
|
|||||||
"Ory SSOT System",
|
"Ory SSOT System",
|
||||||
"Data Integrity",
|
"Data Integrity",
|
||||||
"Users",
|
"Users",
|
||||||
|
"권한 부여",
|
||||||
"Auth Guard",
|
"Auth Guard",
|
||||||
"API Keys",
|
"API Keys",
|
||||||
"Audit Logs",
|
"Audit Logs",
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ const staticNavItems: ShellSidebarNavItem[] = [
|
|||||||
to: "/users",
|
to: "/users",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.admin.nav.permissions_direct",
|
||||||
|
labelFallback: "권한 부여",
|
||||||
|
to: "/permissions-direct",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.admin.nav.auth_guard",
|
labelKey: "ui.admin.nav.auth_guard",
|
||||||
labelFallback: "Auth Guard",
|
labelFallback: "Auth Guard",
|
||||||
@@ -206,70 +212,72 @@ function AppLayout() {
|
|||||||
...profile,
|
...profile,
|
||||||
role: effectiveRole ?? profile?.role,
|
role: effectiveRole ?? profile?.role,
|
||||||
});
|
});
|
||||||
const filteredItems = items.filter((item) => {
|
|
||||||
if (item.to === "/api-keys") return isSuperAdmin;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||||
{ includeInternal: true },
|
{ includeInternal: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isSuperAdmin) {
|
// Splice optional menus in a standard order
|
||||||
filteredItems.splice(1, 0, {
|
items.splice(1, 0, {
|
||||||
labelKey: "ui.admin.nav.tenants",
|
labelKey: "ui.admin.nav.tenants",
|
||||||
labelFallback: "Tenants",
|
labelFallback: "Tenants",
|
||||||
to: "/tenants",
|
to: "/tenants",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
});
|
});
|
||||||
filteredItems.splice(2, 0, {
|
items.splice(2, 0, {
|
||||||
labelKey: "ui.admin.nav.org_chart",
|
labelKey: "ui.admin.nav.org_chart",
|
||||||
labelFallback: "Org Chart",
|
labelFallback: "Org Chart",
|
||||||
to: orgfrontUrl,
|
to: orgfrontUrl,
|
||||||
icon: Network,
|
icon: Network,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
});
|
});
|
||||||
if (showWorksmobile) {
|
items.splice(3, 0, {
|
||||||
filteredItems.splice(3, 0, {
|
labelKey: "ui.admin.nav.worksmobile",
|
||||||
labelKey: "ui.admin.nav.worksmobile",
|
labelFallback: "Worksmobile",
|
||||||
labelFallback: "Worksmobile",
|
to: "/worksmobile",
|
||||||
to: "/worksmobile",
|
icon: LineWorksNavIcon,
|
||||||
icon: LineWorksNavIcon,
|
});
|
||||||
});
|
items.splice(4, 0, {
|
||||||
}
|
labelKey: "ui.admin.nav.ory_ssot",
|
||||||
filteredItems.splice(4, 0, {
|
labelFallback: "Ory SSOT System",
|
||||||
labelKey: "ui.admin.nav.ory_ssot",
|
to: "/system/ory-ssot",
|
||||||
labelFallback: "Ory SSOT System",
|
icon: Database,
|
||||||
to: "/system/ory-ssot",
|
});
|
||||||
icon: Database,
|
items.splice(5, 0, {
|
||||||
});
|
labelKey: "ui.admin.nav.data_integrity",
|
||||||
filteredItems.splice(5, 0, {
|
labelFallback: "Data Integrity",
|
||||||
labelKey: "ui.admin.nav.data_integrity",
|
to: "/system/data-integrity",
|
||||||
labelFallback: "Data Integrity",
|
icon: ShieldCheck,
|
||||||
to: "/system/data-integrity",
|
});
|
||||||
icon: ShieldCheck,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Non-superadmins
|
|
||||||
filteredItems.splice(1, 0, {
|
|
||||||
labelKey: "ui.admin.nav.org_chart",
|
|
||||||
labelFallback: "Org Chart",
|
|
||||||
to: orgfrontUrl,
|
|
||||||
icon: Network,
|
|
||||||
isExternal: true,
|
|
||||||
});
|
|
||||||
if (showWorksmobile) {
|
|
||||||
filteredItems.splice(2, 0, {
|
|
||||||
labelKey: "ui.admin.nav.worksmobile",
|
|
||||||
labelFallback: "Worksmobile",
|
|
||||||
to: "/worksmobile",
|
|
||||||
icon: LineWorksNavIcon,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredItems;
|
const permissions = profile?.systemPermissions;
|
||||||
|
|
||||||
|
return items.filter((item) => {
|
||||||
|
// Super Admin ALWAYS bypasses and gets full access to everything
|
||||||
|
if (isSuperAdmin) {
|
||||||
|
if (item.to === "/worksmobile") return showWorksmobile;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For others, check their fine-grained systemPermissions
|
||||||
|
if (!permissions) return false;
|
||||||
|
|
||||||
|
if (item.to === "/") return permissions.overview;
|
||||||
|
if (item.to === "/users") return permissions.users;
|
||||||
|
if (item.to === "/auth") return permissions.auth_guard;
|
||||||
|
if (item.to === "/api-keys") return permissions.api_keys;
|
||||||
|
if (item.to === "/audit-logs") return permissions.audit_logs;
|
||||||
|
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 === "/system/ory-ssot") return permissions.ory_ssot;
|
||||||
|
if (item.to === "/system/data-integrity")
|
||||||
|
return permissions.data_integrity;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const members = [
|
|||||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
vi.mock("../../lib/adminApi", () => ({
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
|
fetchMe: vi.fn(async () => ({ id: "admin-user", role: "super_admin" })),
|
||||||
fetchTenant: vi.fn(async () => tenant),
|
fetchTenant: vi.fn(async () => tenant),
|
||||||
fetchUsers: vi.fn(async () => ({
|
fetchUsers: vi.fn(async () => ({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createI18nMock } from "../../test/i18nMock";
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
||||||
|
import { TenantFineGrainedPermissionsTab } from "../tenants/routes/TenantFineGrainedPermissionsTab";
|
||||||
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
||||||
|
|
||||||
const exportUsersCSVMock = vi.hoisted(() =>
|
const exportUsersCSVMock = vi.hoisted(() =>
|
||||||
@@ -94,12 +95,29 @@ vi.mock("react-oidc-context", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../lib/adminApi", () => ({
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
|
fetchMe: vi.fn(async () => users[0]),
|
||||||
|
fetchTenant: vi.fn(async (tenantId) => ({
|
||||||
|
id: tenantId,
|
||||||
|
name: "Test Tenant",
|
||||||
|
slug: "test-tenant",
|
||||||
|
userPermissions: { view: true, manage: true, manage_admins: true },
|
||||||
|
})),
|
||||||
fetchTenantOwners: vi.fn(async () => [users[0]]),
|
fetchTenantOwners: vi.fn(async () => [users[0]]),
|
||||||
fetchTenantAdmins: vi.fn(async () => [users[1]]),
|
fetchTenantAdmins: vi.fn(async () => [users[1]]),
|
||||||
addTenantOwner: vi.fn(async () => undefined),
|
addTenantOwner: vi.fn(async () => undefined),
|
||||||
addTenantAdmin: vi.fn(async () => undefined),
|
addTenantAdmin: vi.fn(async () => undefined),
|
||||||
removeTenantOwner: vi.fn(async () => undefined),
|
removeTenantOwner: vi.fn(async () => undefined),
|
||||||
removeTenantAdmin: vi.fn(async () => undefined),
|
removeTenantAdmin: vi.fn(async () => undefined),
|
||||||
|
fetchTenantRelations: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
userId: "user-relation-1",
|
||||||
|
name: "Relation User",
|
||||||
|
email: "relation@example.com",
|
||||||
|
relations: ["profile_managers", "schema_viewers"],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
addTenantRelation: vi.fn(async () => undefined),
|
||||||
|
removeTenantRelation: vi.fn(async () => undefined),
|
||||||
fetchUsers: vi.fn(async () => ({
|
fetchUsers: vi.fn(async () => ({
|
||||||
items: users,
|
items: users,
|
||||||
total: users.length,
|
total: users.length,
|
||||||
@@ -160,6 +178,22 @@ describe("admin tenant tab coverage smoke", () => {
|
|||||||
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders tenant fine-grained relations list", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/tenants/:tenantId/relations"
|
||||||
|
element={<TenantFineGrainedPermissionsTab />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/tenant-company/relations",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Relation User")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("relation@example.com")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("세부 권한 설정 (Fine-grained Permissions)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders tenant hierarchy and selected organization members", async () => {
|
it("renders tenant hierarchy and selected organization members", async () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type DomainTagInputProps = {
|
|||||||
confirmedConflicts?: string[];
|
confirmedConflicts?: string[];
|
||||||
onConfirmedConflictsChange?: (domains: string[]) => void;
|
onConfirmedConflictsChange?: (domains: string[]) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DomainTagInput({
|
export function DomainTagInput({
|
||||||
@@ -40,6 +41,7 @@ export function DomainTagInput({
|
|||||||
confirmedConflicts = [],
|
confirmedConflicts = [],
|
||||||
onConfirmedConflictsChange,
|
onConfirmedConflictsChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
}: DomainTagInputProps) {
|
}: DomainTagInputProps) {
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
|
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
|
||||||
@@ -107,14 +109,16 @@ export function DomainTagInput({
|
|||||||
className="gap-1 rounded-md"
|
className="gap-1 rounded-md"
|
||||||
>
|
>
|
||||||
<span>{domain}</span>
|
<span>{domain}</span>
|
||||||
<button
|
{!disabled && (
|
||||||
type="button"
|
<button
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
type="button"
|
||||||
onClick={() => removeDomain(domain)}
|
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
||||||
aria-label={t("ui.common.remove", "삭제")}
|
onClick={() => removeDomain(domain)}
|
||||||
>
|
aria-label={t("ui.common.remove", "삭제")}
|
||||||
<X size={12} />
|
>
|
||||||
</button>
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
<Input
|
<Input
|
||||||
@@ -133,6 +137,7 @@ export function DomainTagInput({
|
|||||||
tokenizeInput();
|
tokenizeInput();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={disabled}
|
||||||
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0"
|
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0"
|
||||||
placeholder={value.length === 0 ? placeholder : undefined}
|
placeholder={value.length === 0 ? placeholder : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type ParentTenantSelectorProps = {
|
|||||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
controlTestId?: string;
|
controlTestId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ParentTenantSelector({
|
export function ParentTenantSelector({
|
||||||
@@ -55,6 +56,7 @@ export function ParentTenantSelector({
|
|||||||
localTenantFilter,
|
localTenantFilter,
|
||||||
compact = false,
|
compact = false,
|
||||||
controlTestId,
|
controlTestId,
|
||||||
|
disabled = false,
|
||||||
}: ParentTenantSelectorProps) {
|
}: ParentTenantSelectorProps) {
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
||||||
@@ -117,6 +119,7 @@ export function ParentTenantSelector({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={compact ? "h-8 shrink-0 px-2" : undefined}
|
className={compact ? "h-8 shrink-0 px-2" : undefined}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Building2 className="h-4 w-4" />
|
<Building2 className="h-4 w-4" />
|
||||||
{orgChartPickerLabel ??
|
{orgChartPickerLabel ??
|
||||||
@@ -147,7 +150,12 @@ export function ParentTenantSelector({
|
|||||||
{localPickerLabel && (
|
{localPickerLabel && (
|
||||||
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button type="button" variant="outline" size="sm">
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
<Building2 className="h-4 w-4" />
|
<Building2 className="h-4 w-4" />
|
||||||
{localPickerLabel}
|
{localPickerLabel}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -234,6 +242,7 @@ export function ParentTenantSelector({
|
|||||||
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
|
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
|
||||||
onClick={() => onChange("")}
|
onClick={() => onChange("")}
|
||||||
aria-label={noneLabel}
|
aria-label={noneLabel}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import {
|
||||||
|
type TenantPermissionKey,
|
||||||
|
useTenantPermission,
|
||||||
|
} from "../hooks/useTenantPermission";
|
||||||
|
|
||||||
|
interface TenantPermissionGuardProps {
|
||||||
|
tenantId: string;
|
||||||
|
relation: TenantPermissionKey;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TenantPermissionGuard({
|
||||||
|
tenantId,
|
||||||
|
relation,
|
||||||
|
fallback = null,
|
||||||
|
children,
|
||||||
|
}: TenantPermissionGuardProps) {
|
||||||
|
const { hasPermission, isLoading } = useTenantPermission(tenantId);
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
|
if (!hasPermission(relation)) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||||
|
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
|
||||||
|
import { useTenantPermission } from "./useTenantPermission";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/adminApi", () => ({
|
||||||
|
fetchMe: vi.fn(),
|
||||||
|
fetchTenant: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useTenantPermission", () => {
|
||||||
|
it("returns true for all permissions if user is super_admin", async () => {
|
||||||
|
vi.mocked(fetchMe).mockResolvedValue({
|
||||||
|
id: "user-super",
|
||||||
|
role: "super_admin",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(fetchTenant).mockResolvedValue({
|
||||||
|
id: "tenant-1",
|
||||||
|
name: "Super Tenant",
|
||||||
|
userPermissions: { view: false, manage: false, manage_admins: false },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTenantPermission("tenant-1"), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.hasPermission("view")).toBe(true);
|
||||||
|
expect(result.current.hasPermission("manage")).toBe(true);
|
||||||
|
expect(result.current.hasPermission("manage_admins")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns permissions mapped from userPermissions for normal admins/users", async () => {
|
||||||
|
vi.mocked(fetchMe).mockResolvedValue({
|
||||||
|
id: "user-admin",
|
||||||
|
role: "tenant_admin",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(fetchTenant).mockResolvedValue({
|
||||||
|
id: "tenant-2",
|
||||||
|
name: "Tenant Admin Corp",
|
||||||
|
userPermissions: { view: true, manage: true, manage_admins: false },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTenantPermission("tenant-2"), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.hasPermission("view")).toBe(true);
|
||||||
|
expect(result.current.hasPermission("manage")).toBe(true);
|
||||||
|
expect(result.current.hasPermission("manage_admins")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TenantPermissionGuard", () => {
|
||||||
|
it("renders children when user has permission", async () => {
|
||||||
|
vi.mocked(fetchMe).mockResolvedValue({
|
||||||
|
id: "user-admin",
|
||||||
|
role: "tenant_admin",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(fetchTenant).mockResolvedValue({
|
||||||
|
id: "tenant-3",
|
||||||
|
userPermissions: { view: true, manage: true, manage_admins: false },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TenantPermissionGuard
|
||||||
|
tenantId="tenant-3"
|
||||||
|
relation="manage"
|
||||||
|
fallback={<div>Access Denied</div>}
|
||||||
|
>
|
||||||
|
<div>Access Granted</div>
|
||||||
|
</TenantPermissionGuard>,
|
||||||
|
{ wrapper: createWrapper() },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Access Granted")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText("Access Denied")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders fallback when user lacks permission", async () => {
|
||||||
|
vi.mocked(fetchMe).mockResolvedValue({
|
||||||
|
id: "user-admin",
|
||||||
|
role: "tenant_admin",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(fetchTenant).mockResolvedValue({
|
||||||
|
id: "tenant-4",
|
||||||
|
userPermissions: { view: true, manage: false, manage_admins: false },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TenantPermissionGuard
|
||||||
|
tenantId="tenant-4"
|
||||||
|
relation="manage"
|
||||||
|
fallback={<div>Access Denied</div>}
|
||||||
|
>
|
||||||
|
<div>Access Granted</div>
|
||||||
|
</TenantPermissionGuard>,
|
||||||
|
{ wrapper: createWrapper() },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Access Denied")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText("Access Granted")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
39
adminfront/src/features/tenants/hooks/useTenantPermission.ts
Normal file
39
adminfront/src/features/tenants/hooks/useTenantPermission.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||||
|
import { normalizeAdminRole } from "../../../lib/roles";
|
||||||
|
|
||||||
|
export type TenantPermissionKey =
|
||||||
|
| "view"
|
||||||
|
| "manage"
|
||||||
|
| "manage_admins"
|
||||||
|
| "view_profile"
|
||||||
|
| "manage_profile"
|
||||||
|
| "view_permissions"
|
||||||
|
| "manage_permissions"
|
||||||
|
| "view_organization"
|
||||||
|
| "manage_organization"
|
||||||
|
| "view_schema"
|
||||||
|
| "manage_schema";
|
||||||
|
|
||||||
|
export function useTenantPermission(tenantId: string) {
|
||||||
|
const { data: profile } = useQuery({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tenant } = useQuery({
|
||||||
|
queryKey: ["tenant", tenantId],
|
||||||
|
queryFn: () => fetchTenant(tenantId),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasPermission = (requiredRelation: TenantPermissionKey): boolean => {
|
||||||
|
// Super Admin always has full bypass access
|
||||||
|
if (normalizeAdminRole(profile?.role) === "super_admin") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!tenant?.userPermissions?.[requiredRelation];
|
||||||
|
};
|
||||||
|
|
||||||
|
return { hasPermission, isLoading: !tenant };
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
type TenantAdmin,
|
type TenantAdmin,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
|
|
||||||
type DialogMode = "owner" | "admin";
|
type DialogMode = "owner" | "admin";
|
||||||
|
|
||||||
@@ -69,6 +70,10 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
const _currentUserId = auth.user?.profile.sub;
|
const _currentUserId = auth.user?.profile.sub;
|
||||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||||
const tenantId = tenantIdParam ?? "";
|
const tenantId = tenantIdParam ?? "";
|
||||||
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
|
const isWritable =
|
||||||
|
hasPermission("manage_permissions") || hasPermission("manage_admins");
|
||||||
|
const canView = hasPermission("view_permissions") || hasPermission("view");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||||
@@ -338,6 +343,16 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
|
|
||||||
if (!tenantId) return null;
|
if (!tenantId) return null;
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
return (
|
||||||
|
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||||
|
<h3 className="text-xl font-bold text-destructive">
|
||||||
|
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const serverOwners = ownersQuery.data || [];
|
const serverOwners = ownersQuery.data || [];
|
||||||
const serverAdmins = adminsQuery.data || [];
|
const serverAdmins = adminsQuery.data || [];
|
||||||
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
||||||
@@ -362,7 +377,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
|
||||||
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
|
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
|
||||||
{/* Owners Card */}
|
{/* Owners Card */}
|
||||||
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
||||||
@@ -382,6 +397,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
<Button
|
<Button
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
onClick={() => setDialogMode("owner")}
|
onClick={() => setDialogMode("owner")}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
||||||
@@ -471,6 +487,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
<Button
|
<Button
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
onClick={() => setDialogMode("admin")}
|
onClick={() => setDialogMode("admin")}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { Copy } from "lucide-react";
|
import { Copy } from "lucide-react";
|
||||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
import { fetchTenant } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { normalizeAdminRole } from "../../../lib/roles";
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
|
|
||||||
function TenantDetailPage() {
|
function TenantDetailPage() {
|
||||||
const params = useParams<{ tenantId: string }>();
|
const params = useParams<{ tenantId: string }>();
|
||||||
@@ -17,13 +17,7 @@ function TenantDetailPage() {
|
|||||||
enabled: tenantId.length > 0,
|
enabled: tenantId.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
queryKey: ["me"],
|
|
||||||
queryFn: fetchMe,
|
|
||||||
});
|
|
||||||
|
|
||||||
const profileRole = normalizeAdminRole(profile?.role);
|
|
||||||
const canAccessSchema = profileRole === "super_admin";
|
|
||||||
|
|
||||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||||
const isOrganizationTab = location.pathname.includes("/organization");
|
const isOrganizationTab = location.pathname.includes("/organization");
|
||||||
@@ -110,7 +104,7 @@ function TenantDetailPage() {
|
|||||||
>
|
>
|
||||||
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
|
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
|
||||||
</Link>
|
</Link>
|
||||||
{canAccessSchema && (
|
{hasPermission("view") && (
|
||||||
<Link
|
<Link
|
||||||
to={`/tenants/${tenantId}/schema`}
|
to={`/tenants/${tenantId}/schema`}
|
||||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||||
@@ -122,6 +116,18 @@ function TenantDetailPage() {
|
|||||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{hasPermission("view") && (
|
||||||
|
<Link
|
||||||
|
to={`/tenants/${tenantId}/relations`}
|
||||||
|
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||||
|
location.pathname.includes("/relations")
|
||||||
|
? "text-primary border-b-2 border-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("ui.admin.tenants.detail.tab_relations", "세부 권한")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Outlet for nested routes */}
|
{/* Outlet for nested routes */}
|
||||||
|
|||||||
@@ -0,0 +1,860 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Database,
|
||||||
|
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 } from "../../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../../../components/ui/dialog";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||||
|
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 [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 { data: profile } = useQuery({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSuperAdmin = profile?.role === "super_admin";
|
||||||
|
|
||||||
|
const tenantsQuery = useQuery({
|
||||||
|
queryKey: ["tenants", "list-all"],
|
||||||
|
queryFn: () => fetchAllTenants(),
|
||||||
|
enabled: isSuperAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _tenants = isSuperAdmin
|
||||||
|
? (tenantsQuery.data?.items ?? [])
|
||||||
|
: (profile?.manageableTenants ?? []);
|
||||||
|
|
||||||
|
// System Relations (Admin Control) Queries & Mutations
|
||||||
|
const systemRelationsQuery = useQuery({
|
||||||
|
queryKey: ["system-relations"],
|
||||||
|
queryFn: fetchSystemRelations,
|
||||||
|
enabled: isSuperAdmin && activeTab === "system",
|
||||||
|
});
|
||||||
|
const systemRelations = systemRelationsQuery.data ?? [];
|
||||||
|
|
||||||
|
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (systemRelationsQuery.data) {
|
||||||
|
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",
|
||||||
|
];
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLocalSystemPermissions(initialMap);
|
||||||
|
}
|
||||||
|
}, [systemRelationsQuery.data]);
|
||||||
|
|
||||||
|
const addSystemRelationMutation = useMutation({
|
||||||
|
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||||
|
addSystemRelation(payload.userId, payload.relation),
|
||||||
|
onMutate: async (newRelation) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ["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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previousRelations };
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>, _, context) => {
|
||||||
|
if (context?.previousRelations) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["system-relations"],
|
||||||
|
context.previousRelations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.error ||
|
||||||
|
t("msg.common.error", "오류가 발생했습니다."),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Quiet mutate
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeSystemRelationMutation = useMutation({
|
||||||
|
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||||
|
removeSystemRelation(payload.userId, payload.relation),
|
||||||
|
onMutate: async (targetRelation) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ["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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previousRelations };
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>, _, context) => {
|
||||||
|
if (context?.previousRelations) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["system-relations"],
|
||||||
|
context.previousRelations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.error ||
|
||||||
|
t("msg.common.error", "오류가 발생했습니다."),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Quiet mutate
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSystemRelationChange = async (
|
||||||
|
userId: string,
|
||||||
|
menuKey: string,
|
||||||
|
currentVal: "none" | "read" | "write",
|
||||||
|
newVal: "none" | "read" | "write",
|
||||||
|
) => {
|
||||||
|
if (currentVal === newVal) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentVal === "read") {
|
||||||
|
await removeSystemRelationMutation.mutateAsync({
|
||||||
|
userId,
|
||||||
|
relation: `${menuKey}_viewers`,
|
||||||
|
});
|
||||||
|
} else if (currentVal === "write") {
|
||||||
|
await removeSystemRelationMutation.mutateAsync({
|
||||||
|
userId,
|
||||||
|
relation: `${menuKey}_managers`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newVal === "read") {
|
||||||
|
await addSystemRelationMutation.mutateAsync({
|
||||||
|
userId,
|
||||||
|
relation: `${menuKey}_viewers`,
|
||||||
|
});
|
||||||
|
} else if (newVal === "write") {
|
||||||
|
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",
|
||||||
|
"시스템 메뉴 권한이 성공적으로 변경되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} 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",
|
||||||
|
"이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const rel of userRelations) {
|
||||||
|
await removeSystemRelationMutation.mutateAsync({ userId, relation: rel });
|
||||||
|
}
|
||||||
|
if (activeUserId === userId) {
|
||||||
|
setActiveUserId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["admin-users-search", searchTerm],
|
||||||
|
queryFn: () => fetchUsers(20, 0, searchTerm),
|
||||||
|
enabled: isDialogOpen && searchTerm.length >= 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddSystemUser = (userId: string) => {
|
||||||
|
addSystemRelationMutation.mutate({ userId, relation: "overview_viewers" });
|
||||||
|
setActiveUserId(userId);
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchResults = usersQuery.data?.items || [];
|
||||||
|
|
||||||
|
// Categorized system menus with descriptions and icons
|
||||||
|
const systemMenuCategories = [
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredRelations = systemRelations.filter(
|
||||||
|
(r) =>
|
||||||
|
r.name.toLowerCase().includes(userSearchTerm.toLowerCase()) ||
|
||||||
|
r.email.toLowerCase().includes(userSearchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-8 w-8 text-primary" />
|
||||||
|
{t("ui.admin.nav.permissions_direct", "권한 부여")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.permissions_direct.description",
|
||||||
|
"테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시스템 메뉴 권한 (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>
|
||||||
|
<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"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* User Search Dialog for System relations */}
|
||||||
|
<Dialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">
|
||||||
|
{t(
|
||||||
|
"ui.admin.permissions_direct.dialog_title_system",
|
||||||
|
"시스템 권한 관리 유저 추가",
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.admins.dialog_description",
|
||||||
|
"이름 또는 이메일로 사용자를 검색하세요.",
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.tenants.admins.dialog_search_placeholder",
|
||||||
|
"사용자 검색 (최소 2자)...",
|
||||||
|
)}
|
||||||
|
className="pl-10 h-11"
|
||||||
|
autoFocus
|
||||||
|
value={searchTerm}
|
||||||
|
name="system-user-dialog-search"
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
|
||||||
|
{searchTerm.length < 2 ? (
|
||||||
|
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
|
||||||
|
<Search className="h-8 w-8 opacity-20" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.admins.dialog_search_hint",
|
||||||
|
"검색어를 입력해 주세요.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : usersQuery.isLoading ? (
|
||||||
|
<div className="p-10 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||||
|
</div>
|
||||||
|
) : searchResults.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.admins.dialog_no_results",
|
||||||
|
"검색 결과가 없습니다.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{searchResults.map((user) => {
|
||||||
|
const isAlreadyInMatrix = systemRelations.some(
|
||||||
|
(r) => r.userId === user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||||
|
{user.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isAlreadyInMatrix ? "ghost" : "outline"}
|
||||||
|
disabled={
|
||||||
|
isAlreadyInMatrix ||
|
||||||
|
addSystemRelationMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => handleAddSystemUser(user.id)}
|
||||||
|
>
|
||||||
|
{isAlreadyInMatrix ? (
|
||||||
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.relations.already_added",
|
||||||
|
"이미 추가됨",
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />{" "}
|
||||||
|
{t("ui.common.add", "추가")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../../../components/ui/dialog";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../../components/ui/table";
|
||||||
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
|
import {
|
||||||
|
addTenantRelation,
|
||||||
|
fetchTenantRelations,
|
||||||
|
fetchUsers,
|
||||||
|
removeTenantRelation,
|
||||||
|
type TenantRelation,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
|
|
||||||
|
interface TenantFineGrainedPermissionsTabProps {
|
||||||
|
tenantIdProp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TenantFineGrainedPermissionsTab({
|
||||||
|
tenantIdProp,
|
||||||
|
}: TenantFineGrainedPermissionsTabProps = {}) {
|
||||||
|
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||||
|
const tenantId = tenantIdProp || tenantIdParam || "";
|
||||||
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
|
const isWritable = hasPermission("manage_admins");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// 🌟 테넌트 탭별 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
|
||||||
|
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 ?? [];
|
||||||
|
|
||||||
|
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (relationsQuery.data) {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLocalTenantPermissions(initialMap);
|
||||||
|
}
|
||||||
|
}, [relationsQuery.data]);
|
||||||
|
const relations = relationsQuery.data ?? [];
|
||||||
|
|
||||||
|
const invalidateAllQueries = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["tenant-relations", tenantId],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRelationMutation = useMutation({
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previousRelations };
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>, _, context) => {
|
||||||
|
if (context?.previousRelations) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["tenant-relations", tenantId],
|
||||||
|
context.previousRelations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.error ||
|
||||||
|
t("msg.common.error", "오류가 발생했습니다."),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Quiet mutate
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeRelationMutation = useMutation({
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previousRelations };
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>, _, context) => {
|
||||||
|
if (context?.previousRelations) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["tenant-relations", tenantId],
|
||||||
|
context.previousRelations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.error ||
|
||||||
|
t("msg.common.error", "오류가 발생했습니다."),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Quiet mutate
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRelationChange = async (
|
||||||
|
userId: string,
|
||||||
|
tab: "profile" | "permissions" | "organization" | "schema",
|
||||||
|
currentVal: "none" | "read" | "write",
|
||||||
|
newVal: "none" | "read" | "write",
|
||||||
|
) => {
|
||||||
|
const readRel = `${tab}_viewers`;
|
||||||
|
const writeRel = `${tab}_managers`;
|
||||||
|
|
||||||
|
if (currentVal === newVal) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentVal === "read") {
|
||||||
|
await removeRelationMutation.mutateAsync({ userId, relation: readRel });
|
||||||
|
} else if (currentVal === "write") {
|
||||||
|
await removeRelationMutation.mutateAsync({
|
||||||
|
userId,
|
||||||
|
relation: writeRel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newVal === "read") {
|
||||||
|
await addRelationMutation.mutateAsync({ userId, relation: readRel });
|
||||||
|
} else if (newVal === "write") {
|
||||||
|
await addRelationMutation.mutateAsync({ userId, relation: writeRel });
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateAllQueries();
|
||||||
|
|
||||||
|
// 🌟 Trigger a single consolidated success toast at the very end
|
||||||
|
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",
|
||||||
|
"이 사용자의 모든 세부 권한을 삭제하시겠습니까?",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const rel of userRelations) {
|
||||||
|
await removeRelationMutation.mutateAsync({ userId, relation: rel });
|
||||||
|
}
|
||||||
|
invalidateAllQueries();
|
||||||
|
};
|
||||||
|
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["admin-users-search", searchTerm],
|
||||||
|
queryFn: () => fetchUsers(20, 0, searchTerm),
|
||||||
|
enabled: isDialogOpen && searchTerm.length >= 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddUser = (userId: string) => {
|
||||||
|
addRelationMutation.mutate(
|
||||||
|
{ userId, relation: "profile_viewers" },
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
invalidateAllQueries();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tenantId) return null;
|
||||||
|
|
||||||
|
const searchResults = usersQuery.data?.items || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
|
||||||
|
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
|
||||||
|
<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)",
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.tenants.relations.subtitle",
|
||||||
|
"사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
onClick={() => setIsDialogOpen(true)}
|
||||||
|
disabled={!isWritable}
|
||||||
|
>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.relations.add_button",
|
||||||
|
"세부 권한 사용자 추가",
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="rounded-md border border-border overflow-hidden">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
relations.map((user) => {
|
||||||
|
const profileVal = user.relations.includes(
|
||||||
|
"profile_managers",
|
||||||
|
)
|
||||||
|
? "write"
|
||||||
|
: user.relations.includes("profile_viewers")
|
||||||
|
? "read"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
const permissionsVal = user.relations.includes(
|
||||||
|
"permissions_managers",
|
||||||
|
)
|
||||||
|
? "write"
|
||||||
|
: user.relations.includes("permissions_viewers")
|
||||||
|
? "read"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
const organizationVal = user.relations.includes(
|
||||||
|
"organization_managers",
|
||||||
|
)
|
||||||
|
? "write"
|
||||||
|
: user.relations.includes("organization_viewers")
|
||||||
|
? "read"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
const schemaVal = user.relations.includes("schema_managers")
|
||||||
|
? "write"
|
||||||
|
: user.relations.includes("schema_viewers")
|
||||||
|
? "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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full 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"
|
||||||
|
value={curProfileVal}
|
||||||
|
disabled={!isWritable}
|
||||||
|
name={`tenant-fine-grained-profile-${user.userId}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextVal = e.target.value as
|
||||||
|
| "none"
|
||||||
|
| "read"
|
||||||
|
| "write";
|
||||||
|
setLocalTenantPermissions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[user.userId]: {
|
||||||
|
...(prev[user.userId] ?? {}),
|
||||||
|
profile: nextVal,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
handleRelationChange(
|
||||||
|
user.userId,
|
||||||
|
"profile",
|
||||||
|
profileVal,
|
||||||
|
nextVal,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full 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"
|
||||||
|
value={curPermissionsVal}
|
||||||
|
disabled={!isWritable}
|
||||||
|
name={`tenant-fine-grained-permissions-${user.userId}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextVal = e.target.value as
|
||||||
|
| "none"
|
||||||
|
| "read"
|
||||||
|
| "write";
|
||||||
|
setLocalTenantPermissions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[user.userId]: {
|
||||||
|
...(prev[user.userId] ?? {}),
|
||||||
|
permissions: nextVal,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
handleRelationChange(
|
||||||
|
user.userId,
|
||||||
|
"permissions",
|
||||||
|
permissionsVal,
|
||||||
|
nextVal,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full 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"
|
||||||
|
value={curOrganizationVal}
|
||||||
|
disabled={!isWritable}
|
||||||
|
name={`tenant-fine-grained-organization-${user.userId}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextVal = e.target.value as
|
||||||
|
| "none"
|
||||||
|
| "read"
|
||||||
|
| "write";
|
||||||
|
setLocalTenantPermissions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[user.userId]: {
|
||||||
|
...(prev[user.userId] ?? {}),
|
||||||
|
organization: nextVal,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
handleRelationChange(
|
||||||
|
user.userId,
|
||||||
|
"organization",
|
||||||
|
organizationVal,
|
||||||
|
nextVal,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full 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"
|
||||||
|
value={curSchemaVal}
|
||||||
|
disabled={!isWritable}
|
||||||
|
name={`tenant-fine-grained-schema-${user.userId}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextVal = e.target.value as
|
||||||
|
| "none"
|
||||||
|
| "read"
|
||||||
|
| "write";
|
||||||
|
setLocalTenantPermissions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[user.userId]: {
|
||||||
|
...(prev[user.userId] ?? {}),
|
||||||
|
schema: nextVal,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
handleRelationChange(
|
||||||
|
user.userId,
|
||||||
|
"schema",
|
||||||
|
schemaVal,
|
||||||
|
nextVal,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!isWritable}
|
||||||
|
onClick={() =>
|
||||||
|
handleRemoveAllRelations(
|
||||||
|
user.userId,
|
||||||
|
user.relations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Common Dialog for adding users */}
|
||||||
|
<Dialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.relations.dialog_title",
|
||||||
|
"세부 권한 관리 유저 추가",
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.admins.dialog_description",
|
||||||
|
"이름 또는 이메일로 사용자를 검색하세요.",
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.tenants.admins.dialog_search_placeholder",
|
||||||
|
"사용자 검색 (최소 2자)...",
|
||||||
|
)}
|
||||||
|
className="pl-10 h-11"
|
||||||
|
autoFocus
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
|
||||||
|
{searchTerm.length < 2 ? (
|
||||||
|
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
|
||||||
|
<Search className="h-8 w-8 opacity-20" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.admins.dialog_search_hint",
|
||||||
|
"검색어를 입력해 주세요.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : usersQuery.isLoading ? (
|
||||||
|
<div className="p-10 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||||
|
</div>
|
||||||
|
) : searchResults.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.admins.dialog_no_results",
|
||||||
|
"검색 결과가 없습니다.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{searchResults.map((user) => {
|
||||||
|
const isAlreadyInMatrix = relations.some(
|
||||||
|
(r) => r.userId === user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||||
|
{user.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isAlreadyInMatrix ? "ghost" : "outline"}
|
||||||
|
disabled={
|
||||||
|
isAlreadyInMatrix || addRelationMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => handleAddUser(user.id)}
|
||||||
|
>
|
||||||
|
{isAlreadyInMatrix ? (
|
||||||
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.relations.already_added",
|
||||||
|
"이미 추가됨",
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />{" "}
|
||||||
|
{t("ui.common.add", "추가")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
removeGroupMember,
|
removeGroupMember,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
|
|
||||||
type UserGroupNode = GroupSummary & {
|
type UserGroupNode = GroupSummary & {
|
||||||
children: UserGroupNode[];
|
children: UserGroupNode[];
|
||||||
@@ -126,6 +127,7 @@ interface UserGroupTreeNodeProps {
|
|||||||
AxiosError<{ error?: string }>,
|
AxiosError<{ error?: string }>,
|
||||||
{ groupId: string; userId: string }
|
{ groupId: string; userId: string }
|
||||||
>;
|
>;
|
||||||
|
isWritable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||||
@@ -137,6 +139,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
|||||||
onAddSubGroup,
|
onAddSubGroup,
|
||||||
addMemberMutation,
|
addMemberMutation,
|
||||||
removeMemberMutation,
|
removeMemberMutation,
|
||||||
|
isWritable = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = node.children.length > 0;
|
||||||
@@ -200,6 +203,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onAddSubGroup(node.id);
|
onAddSubGroup(node.id);
|
||||||
}}
|
}}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -210,6 +214,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(node.id);
|
onDelete(node.id);
|
||||||
}}
|
}}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} className="text-destructive" />
|
<Trash2 size={14} className="text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -229,6 +234,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
|||||||
onAddSubGroup={onAddSubGroup}
|
onAddSubGroup={onAddSubGroup}
|
||||||
addMemberMutation={addMemberMutation}
|
addMemberMutation={addMemberMutation}
|
||||||
removeMemberMutation={removeMemberMutation}
|
removeMemberMutation={removeMemberMutation}
|
||||||
|
isWritable={isWritable}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -240,6 +246,11 @@ function TenantGroupsPage() {
|
|||||||
const tenantId = params.tenantId ?? "";
|
const tenantId = params.tenantId ?? "";
|
||||||
const _queryClient = useQueryClient();
|
const _queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
|
const isWritable =
|
||||||
|
hasPermission("manage_organization") || hasPermission("manage");
|
||||||
|
const canView = hasPermission("view_organization") || hasPermission("view");
|
||||||
|
|
||||||
const [newGroupName, setNewGroupName] = useState("");
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||||
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
|
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
|
||||||
@@ -387,6 +398,16 @@ function TenantGroupsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
return (
|
||||||
|
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||||
|
<h3 className="text-xl font-bold text-destructive">
|
||||||
|
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const groupTree = groupsQuery.data
|
const groupTree = groupsQuery.data
|
||||||
? buildGroupTree(groupsQuery.data, tenantId)
|
? buildGroupTree(groupsQuery.data, tenantId)
|
||||||
: [];
|
: [];
|
||||||
@@ -423,6 +444,7 @@ function TenantGroupsPage() {
|
|||||||
id="name"
|
id="name"
|
||||||
value={newGroupName}
|
value={newGroupName}
|
||||||
onChange={(e) => setNewGroupName(e.target.value)}
|
onChange={(e) => setNewGroupName(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.groups.form.name_placeholder",
|
"ui.admin.groups.form.name_placeholder",
|
||||||
"예: 개발팀, 인사팀",
|
"예: 개발팀, 인사팀",
|
||||||
@@ -437,6 +459,7 @@ function TenantGroupsPage() {
|
|||||||
id="unitType"
|
id="unitType"
|
||||||
value={newGroupUnitType}
|
value={newGroupUnitType}
|
||||||
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.groups.form.unit_level_placeholder",
|
"ui.admin.groups.form.unit_level_placeholder",
|
||||||
"예: 본부, 팀, 셀",
|
"예: 본부, 팀, 셀",
|
||||||
@@ -449,9 +472,10 @@ function TenantGroupsPage() {
|
|||||||
</Label>
|
</Label>
|
||||||
<select
|
<select
|
||||||
id="parentId"
|
id="parentId"
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
value={newGroupParentId || ""}
|
value={newGroupParentId || ""}
|
||||||
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<option value="">{t("ui.common.none", "없음")}</option>
|
<option value="">{t("ui.common.none", "없음")}</option>
|
||||||
{groupsQuery.data?.map((group) => (
|
{groupsQuery.data?.map((group) => (
|
||||||
@@ -469,6 +493,7 @@ function TenantGroupsPage() {
|
|||||||
id="desc"
|
id="desc"
|
||||||
value={newGroupDesc}
|
value={newGroupDesc}
|
||||||
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.groups.form.desc_placeholder",
|
"ui.admin.groups.form.desc_placeholder",
|
||||||
"그룹 용도 설명",
|
"그룹 용도 설명",
|
||||||
@@ -478,7 +503,9 @@ function TenantGroupsPage() {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => createMutation.mutate()}
|
onClick={() => createMutation.mutate()}
|
||||||
disabled={!newGroupName || createMutation.isPending}
|
disabled={
|
||||||
|
!newGroupName || createMutation.isPending || !isWritable
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("ui.admin.groups.form.submit", "생성하기")}
|
{t("ui.admin.groups.form.submit", "생성하기")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -569,6 +596,7 @@ function TenantGroupsPage() {
|
|||||||
onAddSubGroup={handleAddSubGroup}
|
onAddSubGroup={handleAddSubGroup}
|
||||||
addMemberMutation={addMemberMutation}
|
addMemberMutation={addMemberMutation}
|
||||||
removeMemberMutation={removeMemberMutation}
|
removeMemberMutation={removeMemberMutation}
|
||||||
|
isWritable={isWritable}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
} from "../../../../../common/core/utils";
|
} from "../../../../../common/core/utils";
|
||||||
import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar";
|
import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar";
|
||||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -378,6 +377,9 @@ function TenantListPage() {
|
|||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
});
|
});
|
||||||
const profileRole = normalizeAdminRole(profile?.role);
|
const profileRole = normalizeAdminRole(profile?.role);
|
||||||
|
const isWritable =
|
||||||
|
profileRole === "super_admin" ||
|
||||||
|
!!profile?.systemPermissions?.manage_tenants;
|
||||||
|
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
|
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
|
||||||
@@ -582,7 +584,11 @@ function TenantListPage() {
|
|||||||
return () => window.removeEventListener("message", onMessage);
|
return () => window.removeEventListener("message", onMessage);
|
||||||
}, [allTenants, scopePickerOpen]);
|
}, [allTenants, scopePickerOpen]);
|
||||||
|
|
||||||
if (profile && profileRole !== "super_admin") {
|
if (
|
||||||
|
profile &&
|
||||||
|
profileRole !== "super_admin" &&
|
||||||
|
!profile?.systemPermissions?.tenants
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||||
<h3 className="text-lg font-bold">
|
<h3 className="text-lg font-bold">
|
||||||
@@ -841,81 +847,83 @@ function TenantListPage() {
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<RoleGuard roles={["super_admin"]}>
|
{isWritable && (
|
||||||
<input
|
<>
|
||||||
ref={fileInputRef}
|
<input
|
||||||
name="tenant-import-file"
|
ref={fileInputRef}
|
||||||
type="file"
|
name="tenant-import-file"
|
||||||
accept=".csv,text/csv"
|
type="file"
|
||||||
className="hidden"
|
accept=".csv,text/csv"
|
||||||
data-testid="tenant-import-input"
|
className="hidden"
|
||||||
onChange={handleImportFile}
|
data-testid="tenant-import-input"
|
||||||
/>
|
onChange={handleImportFile}
|
||||||
<DropdownMenu>
|
/>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
variant="outline"
|
<Button
|
||||||
data-testid="tenant-data-mgmt-btn"
|
variant="outline"
|
||||||
className="gap-2 h-9"
|
data-testid="tenant-data-mgmt-btn"
|
||||||
>
|
className="gap-2 h-9"
|
||||||
<LayoutDashboard size={16} />
|
>
|
||||||
{t("ui.admin.tenants.data_mgmt", "데이터 관리")}
|
<LayoutDashboard size={16} />
|
||||||
<ChevronDown size={14} className="opacity-50" />
|
{t("ui.admin.tenants.data_mgmt", "데이터 관리")}
|
||||||
</Button>
|
<ChevronDown size={14} className="opacity-50" />
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
onClick={handleTemplateDownload}
|
<DropdownMenuItem
|
||||||
data-testid="tenant-template-menu-item"
|
onClick={handleTemplateDownload}
|
||||||
className="cursor-pointer"
|
data-testid="tenant-template-menu-item"
|
||||||
>
|
className="cursor-pointer"
|
||||||
<FileSpreadsheet
|
>
|
||||||
size={16}
|
<FileSpreadsheet
|
||||||
className="mr-2 opacity-50"
|
size={16}
|
||||||
/>
|
className="mr-2 opacity-50"
|
||||||
{t(
|
/>
|
||||||
"ui.admin.tenants.csv_template",
|
{t(
|
||||||
"템플릿 다운로드",
|
"ui.admin.tenants.csv_template",
|
||||||
)}
|
"템플릿 다운로드",
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
<DropdownMenuSeparator />
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuSeparator />
|
||||||
onClick={() => fileInputRef.current?.click()}
|
<DropdownMenuItem
|
||||||
disabled={importMutation.isPending}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
data-testid="tenant-import-menu-item"
|
disabled={importMutation.isPending}
|
||||||
className="cursor-pointer"
|
data-testid="tenant-import-menu-item"
|
||||||
>
|
className="cursor-pointer"
|
||||||
<Upload size={16} className="mr-2 opacity-50" />
|
>
|
||||||
{t("ui.admin.tenants.import", "CSV 가져오기")}
|
<Upload size={16} className="mr-2 opacity-50" />
|
||||||
</DropdownMenuItem>
|
{t("ui.admin.tenants.import", "CSV 가져오기")}
|
||||||
<DropdownMenuSeparator />
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuSeparator />
|
||||||
onClick={() => exportMutation.mutate(false)}
|
<DropdownMenuItem
|
||||||
disabled={exportMutation.isPending}
|
onClick={() => exportMutation.mutate(false)}
|
||||||
data-testid="tenant-export-menu-item"
|
disabled={exportMutation.isPending}
|
||||||
className="cursor-pointer"
|
data-testid="tenant-export-menu-item"
|
||||||
>
|
className="cursor-pointer"
|
||||||
<Download size={16} className="mr-2 opacity-50" />
|
>
|
||||||
{t(
|
<Download size={16} className="mr-2 opacity-50" />
|
||||||
"ui.admin.tenants.export_without_ids",
|
{t(
|
||||||
"UUID 제외 내보내기",
|
"ui.admin.tenants.export_without_ids",
|
||||||
)}
|
"UUID 제외 내보내기",
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={() => exportMutation.mutate(true)}
|
<DropdownMenuItem
|
||||||
disabled={exportMutation.isPending}
|
onClick={() => exportMutation.mutate(true)}
|
||||||
data-testid="tenant-export-with-ids-menu-item"
|
disabled={exportMutation.isPending}
|
||||||
className="cursor-pointer"
|
data-testid="tenant-export-with-ids-menu-item"
|
||||||
>
|
className="cursor-pointer"
|
||||||
<Download size={16} className="mr-2 opacity-50" />
|
>
|
||||||
{t(
|
<Download size={16} className="mr-2 opacity-50" />
|
||||||
"ui.admin.tenants.export_with_ids",
|
{t(
|
||||||
"UUID 포함 내보내기",
|
"ui.admin.tenants.export_with_ids",
|
||||||
)}
|
"UUID 포함 내보내기",
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</DropdownMenuContent>
|
||||||
</RoleGuard>
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -929,14 +937,14 @@ function TenantListPage() {
|
|||||||
{t("ui.common.refresh", "새로고침")}
|
{t("ui.common.refresh", "새로고침")}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<RoleGuard roles={["super_admin"]}>
|
{isWritable && (
|
||||||
<Button asChild size="sm" className="h-9">
|
<Button asChild size="sm" className="h-9">
|
||||||
<Link to="/tenants/new">
|
<Link to="/tenants/new">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{t("ui.admin.tenants.add", "테넌트 추가")}
|
{t("ui.admin.tenants.add", "테넌트 추가")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</RoleGuard>
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1072,7 +1080,7 @@ function TenantListPage() {
|
|||||||
{t("ui.common.apply", "적용")}
|
{t("ui.common.apply", "적용")}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||||
<RoleGuard roles={["super_admin"]}>
|
{isWritable && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1084,7 +1092,7 @@ function TenantListPage() {
|
|||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
{t("ui.common.delete", "삭제")}
|
{t("ui.common.delete", "삭제")}
|
||||||
</Button>
|
</Button>
|
||||||
</RoleGuard>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { DomainTagInput } from "../components/DomainTagInput";
|
import { DomainTagInput } from "../components/DomainTagInput";
|
||||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||||
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
import {
|
import {
|
||||||
formatDomainConflictMessage,
|
formatDomainConflictMessage,
|
||||||
type ServerDomainConflict,
|
type ServerDomainConflict,
|
||||||
@@ -52,6 +53,10 @@ export function TenantProfilePage() {
|
|||||||
enabled: tenantId.length > 0,
|
enabled: tenantId.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
|
const isWritable = hasPermission("manage_profile") || hasPermission("manage");
|
||||||
|
const canView = hasPermission("view_profile") || hasPermission("view");
|
||||||
|
|
||||||
const parentQuery = useQuery({
|
const parentQuery = useQuery({
|
||||||
queryKey: ["tenants", "list-all"],
|
queryKey: ["tenants", "list-all"],
|
||||||
queryFn: () => fetchAllTenants(),
|
queryFn: () => fetchAllTenants(),
|
||||||
@@ -203,6 +208,16 @@ export function TenantProfilePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
return (
|
||||||
|
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||||
|
<h3 className="text-xl font-bold text-destructive">
|
||||||
|
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (isProtectedSeedTenant) {
|
if (isProtectedSeedTenant) {
|
||||||
return;
|
return;
|
||||||
@@ -261,13 +276,21 @@ export function TenantProfilePage() {
|
|||||||
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
|
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div data-testid="tenant-slug-slot" className="space-y-1">
|
<div data-testid="tenant-slug-slot" className="space-y-1">
|
||||||
<Label className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
<Input
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
|
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
|
||||||
<ParentTenantSelector
|
<ParentTenantSelector
|
||||||
@@ -283,6 +306,7 @@ export function TenantProfilePage() {
|
|||||||
excludeTenantId={tenantId}
|
excludeTenantId={tenantId}
|
||||||
compact
|
compact
|
||||||
controlTestId="tenant-parent-picker-control"
|
controlTestId="tenant-parent-picker-control"
|
||||||
|
disabled={!isWritable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,6 +324,7 @@ export function TenantProfilePage() {
|
|||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value)}
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<option value="COMPANY">
|
<option value="COMPANY">
|
||||||
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
||||||
@@ -346,9 +371,10 @@ export function TenantProfilePage() {
|
|||||||
id="tenant-org-unit-type"
|
id="tenant-org-unit-type"
|
||||||
name="tenant-org-unit-type"
|
name="tenant-org-unit-type"
|
||||||
data-testid="tenant-org-unit-type-select"
|
data-testid="tenant-org-unit-type-select"
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent 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"
|
||||||
value={orgUnitType}
|
value={orgUnitType}
|
||||||
onChange={(event) => setOrgUnitType(event.target.value)}
|
onChange={(event) => setOrgUnitType(event.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<option value="">{t("ui.common.none", "없음")}</option>
|
<option value="">{t("ui.common.none", "없음")}</option>
|
||||||
{orgUnitTypeOptions.map((option) => (
|
{orgUnitTypeOptions.map((option) => (
|
||||||
@@ -365,13 +391,14 @@ export function TenantProfilePage() {
|
|||||||
<select
|
<select
|
||||||
id="tenant-visibility"
|
id="tenant-visibility"
|
||||||
name="tenant-visibility"
|
name="tenant-visibility"
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent 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"
|
||||||
value={tenantVisibility}
|
value={tenantVisibility}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setTenantVisibility(
|
setTenantVisibility(
|
||||||
event.target.value as TenantVisibility,
|
event.target.value as TenantVisibility,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
{TENANT_VISIBILITY_OPTIONS.map((option) => (
|
{TENANT_VISIBILITY_OPTIONS.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
@@ -392,11 +419,12 @@ export function TenantProfilePage() {
|
|||||||
</Label>
|
</Label>
|
||||||
<select
|
<select
|
||||||
id="worksmobileExcluded"
|
id="worksmobileExcluded"
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent 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"
|
||||||
value={worksmobileExcluded ? "excluded" : "enabled"}
|
value={worksmobileExcluded ? "excluded" : "enabled"}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setWorksmobileExcluded(event.target.value === "excluded")
|
setWorksmobileExcluded(event.target.value === "excluded")
|
||||||
}
|
}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<option value="enabled">
|
<option value="enabled">
|
||||||
{t(
|
{t(
|
||||||
@@ -424,6 +452,7 @@ export function TenantProfilePage() {
|
|||||||
rows={2}
|
rows={2}
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -442,6 +471,7 @@ export function TenantProfilePage() {
|
|||||||
confirmedConflicts={forceDomainConflicts}
|
confirmedConflicts={forceDomainConflicts}
|
||||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||||
placeholder="example.com, example.kr"
|
placeholder="example.com, example.kr"
|
||||||
|
disabled={!isWritable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -454,6 +484,7 @@ export function TenantProfilePage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant={status === "active" ? "default" : "outline"}
|
variant={status === "active" ? "default" : "outline"}
|
||||||
onClick={() => setStatus("active")}
|
onClick={() => setStatus("active")}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
{t("ui.common.status.active", "활성")}
|
{t("ui.common.status.active", "활성")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -462,6 +493,7 @@ export function TenantProfilePage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant={status === "inactive" ? "default" : "outline"}
|
variant={status === "inactive" ? "default" : "outline"}
|
||||||
onClick={() => setStatus("inactive")}
|
onClick={() => setStatus("inactive")}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
{t("ui.common.status.inactive", "비활성")}
|
{t("ui.common.status.inactive", "비활성")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -480,7 +512,9 @@ export function TenantProfilePage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={deleteMutation.isPending || isProtectedSeedTenant}
|
disabled={
|
||||||
|
deleteMutation.isPending || isProtectedSeedTenant || !isWritable
|
||||||
|
}
|
||||||
title={
|
title={
|
||||||
isProtectedSeedTenant
|
isProtectedSeedTenant
|
||||||
? t(
|
? t(
|
||||||
@@ -499,7 +533,7 @@ export function TenantProfilePage() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
className="bg-green-600 hover:bg-green-700"
|
className="bg-green-600 hover:bg-green-700"
|
||||||
onClick={handleApprove}
|
onClick={handleApprove}
|
||||||
disabled={approveMutation.isPending}
|
disabled={approveMutation.isPending || !isWritable}
|
||||||
>
|
>
|
||||||
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
|
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -512,7 +546,8 @@ export function TenantProfilePage() {
|
|||||||
disabled={
|
disabled={
|
||||||
updateMutation.isPending ||
|
updateMutation.isPending ||
|
||||||
tenantQuery.isLoading ||
|
tenantQuery.isLoading ||
|
||||||
name.trim() === ""
|
name.trim() === "" ||
|
||||||
|
!isWritable
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Save size={16} />
|
<Save size={16} />
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import {
|
|||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { normalizeAdminRole } from "../../../lib/roles";
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
import {
|
import {
|
||||||
createSchemaField,
|
createSchemaField,
|
||||||
isSchemaFieldType,
|
isSchemaFieldType,
|
||||||
@@ -28,13 +28,11 @@ export function TenantSchemaPage() {
|
|||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(
|
||||||
queryKey: ["me"],
|
tenantId ?? "",
|
||||||
queryFn: fetchMe,
|
);
|
||||||
});
|
const canView = hasPermission("view_schema") || hasPermission("view");
|
||||||
|
const isWritable = hasPermission("manage_schema") || hasPermission("manage");
|
||||||
const profileRole = normalizeAdminRole(profile?.role);
|
|
||||||
const canAccess = profileRole === "super_admin";
|
|
||||||
|
|
||||||
const tenantQuery = useQuery({
|
const tenantQuery = useQuery({
|
||||||
queryKey: ["tenant", tenantId],
|
queryKey: ["tenant", tenantId],
|
||||||
@@ -42,7 +40,7 @@ export function TenantSchemaPage() {
|
|||||||
if (!tenantId) throw new Error("Tenant ID is required");
|
if (!tenantId) throw new Error("Tenant ID is required");
|
||||||
return fetchTenant(tenantId);
|
return fetchTenant(tenantId);
|
||||||
},
|
},
|
||||||
enabled: !!tenantId && canAccess,
|
enabled: !!tenantId && canView,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [fields, setFields] = useState<SchemaField[]>([]);
|
const [fields, setFields] = useState<SchemaField[]>([]);
|
||||||
@@ -85,7 +83,7 @@ export function TenantSchemaPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isProfileLoading) {
|
if (isPermissionLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center animate-pulse text-muted-foreground">
|
<div className="p-8 text-center animate-pulse text-muted-foreground">
|
||||||
{t("msg.common.loading", "로딩 중...")}
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
@@ -93,7 +91,7 @@ export function TenantSchemaPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canAccess) {
|
if (!canView) {
|
||||||
return (
|
return (
|
||||||
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||||
<h3 className="text-xl font-bold text-destructive">
|
<h3 className="text-xl font-bold text-destructive">
|
||||||
@@ -147,7 +145,7 @@ export function TenantSchemaPage() {
|
|||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={addField} size="sm">
|
<Button onClick={addField} size="sm" disabled={!isWritable}>
|
||||||
<Plus size={16} className="mr-2" />
|
<Plus size={16} className="mr-2" />
|
||||||
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
|
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -182,6 +180,7 @@ export function TenantSchemaPage() {
|
|||||||
"예: employee_id",
|
"예: employee_id",
|
||||||
)}
|
)}
|
||||||
className="h-10"
|
className="h-10"
|
||||||
|
disabled={!isWritable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -198,6 +197,7 @@ export function TenantSchemaPage() {
|
|||||||
"예: 사번",
|
"예: 사번",
|
||||||
)}
|
)}
|
||||||
className="h-10"
|
className="h-10"
|
||||||
|
disabled={!isWritable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -207,8 +207,9 @@ export function TenantSchemaPage() {
|
|||||||
<select
|
<select
|
||||||
id={`tenant-schema-field-type-${field.key || index}`}
|
id={`tenant-schema-field-type-${field.key || index}`}
|
||||||
name={`tenant-schema-field-type-${field.key || index}`}
|
name={`tenant-schema-field-type-${field.key || index}`}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
|
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary disabled:opacity-60"
|
||||||
value={field.type}
|
value={field.type}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const nextType = e.target.value;
|
const nextType = e.target.value;
|
||||||
if (isSchemaFieldType(nextType)) {
|
if (isSchemaFieldType(nextType)) {
|
||||||
@@ -271,10 +272,11 @@ export function TenantSchemaPage() {
|
|||||||
name={`tenant-schema-field-required-${field.key || index}`}
|
name={`tenant-schema-field-required-${field.key || index}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.required}
|
checked={field.required}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, { required: e.target.checked })
|
updateField(index, { required: e.target.checked })
|
||||||
}
|
}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
|
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
|
||||||
@@ -285,10 +287,11 @@ export function TenantSchemaPage() {
|
|||||||
name={`tenant-schema-field-admin-only-${field.key || index}`}
|
name={`tenant-schema-field-admin-only-${field.key || index}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.adminOnly}
|
checked={field.adminOnly}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, { adminOnly: e.target.checked })
|
updateField(index, { adminOnly: e.target.checked })
|
||||||
}
|
}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
@@ -302,6 +305,7 @@ export function TenantSchemaPage() {
|
|||||||
name={`tenant-schema-field-login-id-${field.key || index}`}
|
name={`tenant-schema-field-login-id-${field.key || index}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.isLoginId || false}
|
checked={field.isLoginId || false}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
isLoginId: e.target.checked,
|
isLoginId: e.target.checked,
|
||||||
@@ -309,7 +313,7 @@ export function TenantSchemaPage() {
|
|||||||
type: e.target.checked ? "text" : field.type,
|
type: e.target.checked ? "text" : field.type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-blue-600">
|
<span className="text-sm font-medium text-blue-600">
|
||||||
{t(
|
{t(
|
||||||
@@ -323,7 +327,7 @@ export function TenantSchemaPage() {
|
|||||||
name={`tenant-schema-field-indexed-${field.key || index}`}
|
name={`tenant-schema-field-indexed-${field.key || index}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.indexed || field.isLoginId || false}
|
checked={field.indexed || field.isLoginId || false}
|
||||||
disabled={field.isLoginId}
|
disabled={field.isLoginId || !isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, { indexed: e.target.checked })
|
updateField(index, { indexed: e.target.checked })
|
||||||
}
|
}
|
||||||
@@ -342,10 +346,11 @@ export function TenantSchemaPage() {
|
|||||||
name={`tenant-schema-field-unsigned-${field.key || index}`}
|
name={`tenant-schema-field-unsigned-${field.key || index}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.unsigned}
|
checked={field.unsigned}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, { unsigned: e.target.checked })
|
updateField(index, { unsigned: e.target.checked })
|
||||||
}
|
}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
@@ -359,6 +364,7 @@ export function TenantSchemaPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input
|
<Input
|
||||||
value={field.validation}
|
value={field.validation}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, { validation: e.target.value })
|
updateField(index, { validation: e.target.value })
|
||||||
}
|
}
|
||||||
@@ -375,6 +381,7 @@ export function TenantSchemaPage() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="text-destructive hover:bg-destructive/10 h-10 w-10"
|
className="text-destructive hover:bg-destructive/10 h-10 w-10"
|
||||||
onClick={() => removeField(index)}
|
onClick={() => removeField(index)}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -388,7 +395,9 @@ export function TenantSchemaPage() {
|
|||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => updateMutation.mutate(fields)}
|
onClick={() => updateMutation.mutate(fields)}
|
||||||
disabled={updateMutation.isPending || tenantQuery.isLoading}
|
disabled={
|
||||||
|
updateMutation.isPending || tenantQuery.isLoading || !isWritable
|
||||||
|
}
|
||||||
className="px-8 h-11"
|
className="px-8 h-11"
|
||||||
>
|
>
|
||||||
<Save size={18} className="mr-2" />
|
<Save size={18} className="mr-2" />
|
||||||
|
|||||||
@@ -159,7 +159,9 @@ function UserCreatePage() {
|
|||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
});
|
});
|
||||||
const profileRole = normalizeAdminRole(profile?.role);
|
const profileRole = normalizeAdminRole(profile?.role);
|
||||||
const canManageUsers = canManageTenantScopedUsers(profile);
|
const canManageUsers =
|
||||||
|
canManageTenantScopedUsers(profile) ||
|
||||||
|
!!profile?.systemPermissions?.manage_users;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
|||||||
@@ -654,6 +654,17 @@ function UserDetailPage() {
|
|||||||
const isAdmin = profileRole === "super_admin";
|
const isAdmin = profileRole === "super_admin";
|
||||||
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
||||||
const canManageCurrentUser = canManageUserInTenantScope({ profile, user });
|
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 watchedStatus = watch("status");
|
const watchedStatus = watch("status");
|
||||||
|
|
||||||
const [newSubEmail, setNewSubEmail] = React.useState("");
|
const [newSubEmail, setNewSubEmail] = React.useState("");
|
||||||
@@ -1235,7 +1246,7 @@ function UserDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdmin && !isSelf && !canManageCurrentUser) {
|
if (profile && !canViewUser) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||||
<ShieldAlert size={48} className="text-destructive" />
|
<ShieldAlert size={48} className="text-destructive" />
|
||||||
@@ -1944,22 +1955,24 @@ function UserDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end pt-4">
|
{isWritable && (
|
||||||
<Button
|
<div className="flex justify-end pt-4">
|
||||||
type="submit"
|
<Button
|
||||||
disabled={mutation.isPending}
|
type="submit"
|
||||||
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
|
disabled={mutation.isPending}
|
||||||
>
|
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
|
||||||
{mutation.isPending ? (
|
>
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
{mutation.isPending ? (
|
||||||
) : (
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
<Save className="mr-2 h-5 w-5" />
|
) : (
|
||||||
)}
|
<Save className="mr-2 h-5 w-5" />
|
||||||
<span className="text-base font-bold">
|
)}
|
||||||
{t("ui.admin.users.detail.save", "저장하기")}
|
<span className="text-base font-bold">
|
||||||
</span>
|
{t("ui.admin.users.detail.save", "저장하기")}
|
||||||
</Button>
|
</span>
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent
|
||||||
|
|||||||
@@ -382,6 +382,8 @@ function UserListPage() {
|
|||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
});
|
});
|
||||||
const profileRole = normalizeAdminRole(profile?.role);
|
const profileRole = normalizeAdminRole(profile?.role);
|
||||||
|
const isWritable =
|
||||||
|
profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
|
||||||
|
|
||||||
const { data: tenantsData } = useQuery({
|
const { data: tenantsData } = useQuery({
|
||||||
queryKey: ["tenants", "all"],
|
queryKey: ["tenants", "all"],
|
||||||
@@ -796,8 +798,9 @@ function UserListPage() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setBulkUploadOpen(true);
|
if (isWritable) setBulkUploadOpen(true);
|
||||||
}}
|
}}
|
||||||
|
disabled={!isWritable}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<Upload size={16} className="mr-2 opacity-50" />
|
<Upload size={16} className="mr-2 opacity-50" />
|
||||||
@@ -889,12 +892,19 @@ function UserListPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Button asChild size="sm" className="h-9">
|
{isWritable ? (
|
||||||
<Link to="/users/new">
|
<Button asChild size="sm" className="h-9">
|
||||||
|
<Link to="/users/new">
|
||||||
|
<Plus size={16} />
|
||||||
|
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" className="h-9" disabled>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{t("ui.admin.users.list.add", "사용자 추가")}
|
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1172,7 +1182,8 @@ function UserListPage() {
|
|||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
statusMutation.isPending ||
|
statusMutation.isPending ||
|
||||||
user.id === profile?.id
|
user.id === profile?.id ||
|
||||||
|
!isWritable
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
@@ -1341,7 +1352,8 @@ function UserListPage() {
|
|||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
(!selectedBulkStatus && !selectedBulkPermission) ||
|
(!selectedBulkStatus && !selectedBulkPermission) ||
|
||||||
bulkUpdateMutation.isPending
|
bulkUpdateMutation.isPending ||
|
||||||
|
!isWritable
|
||||||
}
|
}
|
||||||
data-testid="bulk-apply-btn"
|
data-testid="bulk-apply-btn"
|
||||||
>
|
>
|
||||||
@@ -1354,6 +1366,7 @@ function UserListPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
||||||
onClick={handleBulkDelete}
|
onClick={handleBulkDelete}
|
||||||
|
disabled={!isWritable}
|
||||||
data-testid="bulk-delete-btn"
|
data-testid="bulk-delete-btn"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ export type TenantSummary = {
|
|||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
memberCount: number; // 해당 테넌트 직접 소속 인원
|
memberCount: number; // 해당 테넌트 직접 소속 인원
|
||||||
totalMemberCount?: number; // 하위 테넌트 포함 전체 인원
|
totalMemberCount?: number; // 하위 테넌트 포함 전체 인원
|
||||||
|
userPermissions?: {
|
||||||
|
view: boolean;
|
||||||
|
manage: boolean;
|
||||||
|
manage_admins: boolean;
|
||||||
|
view_profile?: boolean;
|
||||||
|
manage_profile?: boolean;
|
||||||
|
view_permissions?: boolean;
|
||||||
|
manage_permissions?: boolean;
|
||||||
|
view_organization?: boolean;
|
||||||
|
manage_organization?: boolean;
|
||||||
|
view_schema?: boolean;
|
||||||
|
manage_schema?: boolean;
|
||||||
|
};
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
@@ -469,6 +482,61 @@ export async function removeTenantOwner(tenantId: string, userId: string) {
|
|||||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TenantRelation = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
relations: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchTenantRelations(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<{ items: TenantRelation[] }>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/relations`,
|
||||||
|
);
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTenantRelation(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
relation: string,
|
||||||
|
) {
|
||||||
|
await apiClient.post(`/v1/admin/tenants/${tenantId}/relations`, {
|
||||||
|
userId,
|
||||||
|
relation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTenantRelation(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
relation: string,
|
||||||
|
) {
|
||||||
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}/relations`, {
|
||||||
|
data: { userId, relation },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSystemRelations() {
|
||||||
|
const { data } = await apiClient.get<{ items: TenantRelation[] }>(
|
||||||
|
`/v1/admin/system/relations`,
|
||||||
|
);
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
await apiClient.delete(`/v1/admin/system/relations`, {
|
||||||
|
data: { userId, relation },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Group Management
|
// Group Management
|
||||||
export type GroupMember = {
|
export type GroupMember = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1192,6 +1260,32 @@ export async function fetchUserRpHistory(userId: string) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SystemPermissions = {
|
||||||
|
overview: boolean;
|
||||||
|
tenants: boolean;
|
||||||
|
org_chart: boolean;
|
||||||
|
worksmobile: boolean;
|
||||||
|
ory_ssot: boolean;
|
||||||
|
data_integrity: boolean;
|
||||||
|
users: boolean;
|
||||||
|
permissions_direct: boolean;
|
||||||
|
auth_guard: boolean;
|
||||||
|
api_keys: boolean;
|
||||||
|
audit_logs: boolean;
|
||||||
|
|
||||||
|
manage_overview?: boolean;
|
||||||
|
manage_tenants?: boolean;
|
||||||
|
manage_org_chart?: boolean;
|
||||||
|
manage_worksmobile?: boolean;
|
||||||
|
manage_ory_ssot?: boolean;
|
||||||
|
manage_data_integrity?: boolean;
|
||||||
|
manage_users?: boolean;
|
||||||
|
manage_permissions_direct?: boolean;
|
||||||
|
manage_auth_guard?: boolean;
|
||||||
|
manage_api_keys?: boolean;
|
||||||
|
manage_audit_logs?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserProfileResponse = {
|
export type UserProfileResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -1205,6 +1299,7 @@ export type UserProfileResponse = {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
tenant?: TenantSummary;
|
tenant?: TenantSummary;
|
||||||
manageableTenants?: TenantSummary[];
|
manageableTenants?: TenantSummary[];
|
||||||
|
systemPermissions?: SystemPermissions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchMe() {
|
export async function fetchMe() {
|
||||||
|
|||||||
@@ -972,6 +972,7 @@ org_chart = "Org Chart"
|
|||||||
api_keys = "API Keys"
|
api_keys = "API Keys"
|
||||||
audit_logs = "Audit Logs"
|
audit_logs = "Audit Logs"
|
||||||
auth_guard = "Auth Guard"
|
auth_guard = "Auth Guard"
|
||||||
|
permissions_direct = "Direct Permissions"
|
||||||
data_integrity = "Data Integrity"
|
data_integrity = "Data Integrity"
|
||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
overview = "Overview"
|
overview = "Overview"
|
||||||
@@ -996,10 +997,6 @@ title = "Redis identity cache"
|
|||||||
[ui.admin.ory_ssot.forbidden]
|
[ui.admin.ory_ssot.forbidden]
|
||||||
title = "Access denied"
|
title = "Access denied"
|
||||||
|
|
||||||
[ui.admin.ory_ssot.projection_card]
|
|
||||||
description = "PostgreSQL read model status used by admin search and statistics."
|
|
||||||
title = "Backend user read model"
|
|
||||||
|
|
||||||
[ui.admin.ory_ssot.status]
|
[ui.admin.ory_ssot.status]
|
||||||
failed = "failed"
|
failed = "failed"
|
||||||
not_ready = "not ready"
|
not_ready = "not ready"
|
||||||
@@ -1008,11 +1005,8 @@ ready = "ready"
|
|||||||
[ui.admin.ory_ssot.summary]
|
[ui.admin.ory_ssot.summary]
|
||||||
cache_keys = "Cache keys"
|
cache_keys = "Cache keys"
|
||||||
last_refreshed = "Last refreshed"
|
last_refreshed = "Last refreshed"
|
||||||
last_synced = "Last read-model refresh"
|
|
||||||
local_users = "Local users"
|
|
||||||
observed_identities = "Observed identities"
|
observed_identities = "Observed identities"
|
||||||
status = "Status"
|
status = "Status"
|
||||||
updated_at = "Updated at"
|
|
||||||
|
|
||||||
[ui.admin.auth_guard]
|
[ui.admin.auth_guard]
|
||||||
subtitle = "Verify admin privileges and ReBAC relationships against the policy engine."
|
subtitle = "Verify admin privileges and ReBAC relationships against the policy engine."
|
||||||
@@ -1184,6 +1178,7 @@ tab_organization = "Organization Manage"
|
|||||||
tab_permissions = "Permissions"
|
tab_permissions = "Permissions"
|
||||||
tab_profile = "Profile"
|
tab_profile = "Profile"
|
||||||
tab_schema = "Tab Schema"
|
tab_schema = "Tab Schema"
|
||||||
|
tab_relations = "Fine-grained Permissions"
|
||||||
title = "Details"
|
title = "Details"
|
||||||
|
|
||||||
[ui.admin.tenants.list]
|
[ui.admin.tenants.list]
|
||||||
@@ -1494,7 +1489,6 @@ email = "Email"
|
|||||||
name = "Name"
|
name = "Name"
|
||||||
role = "Role"
|
role = "Role"
|
||||||
|
|
||||||
|
|
||||||
[ui.common.role]
|
[ui.common.role]
|
||||||
admin = "Admin"
|
admin = "Admin"
|
||||||
rp_admin = "RP Admin"
|
rp_admin = "RP Admin"
|
||||||
@@ -2006,3 +2000,24 @@ verify = "Verify"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Action"
|
action = "Action"
|
||||||
|
|
||||||
|
[ui.admin.permissions_direct]
|
||||||
|
tab_tenant = "Tenant Features"
|
||||||
|
tab_system = "Admin Control"
|
||||||
|
tab_system_title = "Global Sidebar Access Control"
|
||||||
|
select_tenant = "Select target tenant"
|
||||||
|
select_tenant_desc = "Select target tenant to assign fine-grained permissions."
|
||||||
|
placeholder = "-- Select Tenant --"
|
||||||
|
add_system_user = "Add User to Admin Control"
|
||||||
|
dialog_title_system = "Add User to Global Permissions"
|
||||||
|
|
||||||
|
[msg.admin.permissions_direct]
|
||||||
|
description = "Directly assign and manage tab-level direct permissions and global sidebar menu access."
|
||||||
|
tab_system_desc = "Directly grant users access to each sidebar menu page. Super admins always bypass and pass all access checks."
|
||||||
|
system_empty = "No users with custom global menu permissions found. Add users to start managing."
|
||||||
|
select_prompt = "Select a tenant from the dropdown above to manage its fine-grained features."
|
||||||
|
|
||||||
|
[msg.admin.system.relations]
|
||||||
|
add_success = "Global menu permission added successfully."
|
||||||
|
remove_success = "Global menu permission revoked successfully."
|
||||||
|
remove_all_confirm = "Are you sure you want to revoke all global menu permissions for this user?"
|
||||||
|
|||||||
@@ -976,6 +976,7 @@ org_chart = "조직도"
|
|||||||
api_keys = "API 키"
|
api_keys = "API 키"
|
||||||
audit_logs = "감사 로그"
|
audit_logs = "감사 로그"
|
||||||
auth_guard = "인증 가드"
|
auth_guard = "인증 가드"
|
||||||
|
permissions_direct = "권한 부여"
|
||||||
data_integrity = "데이터 정합성"
|
data_integrity = "데이터 정합성"
|
||||||
logout = "로그아웃"
|
logout = "로그아웃"
|
||||||
overview = "개요"
|
overview = "개요"
|
||||||
@@ -1000,10 +1001,6 @@ title = "Redis identity cache"
|
|||||||
[ui.admin.ory_ssot.forbidden]
|
[ui.admin.ory_ssot.forbidden]
|
||||||
title = "접근 권한이 없습니다"
|
title = "접근 권한이 없습니다"
|
||||||
|
|
||||||
[ui.admin.ory_ssot.projection_card]
|
|
||||||
description = "관리자 검색과 통계에서 사용하는 PostgreSQL read model 상태입니다."
|
|
||||||
title = "Backend 사용자 read model"
|
|
||||||
|
|
||||||
[ui.admin.ory_ssot.status]
|
[ui.admin.ory_ssot.status]
|
||||||
failed = "실패"
|
failed = "실패"
|
||||||
not_ready = "준비되지 않음"
|
not_ready = "준비되지 않음"
|
||||||
@@ -1012,11 +1009,8 @@ ready = "준비됨"
|
|||||||
[ui.admin.ory_ssot.summary]
|
[ui.admin.ory_ssot.summary]
|
||||||
cache_keys = "Cache keys"
|
cache_keys = "Cache keys"
|
||||||
last_refreshed = "마지막 refresh"
|
last_refreshed = "마지막 refresh"
|
||||||
last_synced = "마지막 read-model refresh"
|
|
||||||
local_users = "Local users"
|
|
||||||
observed_identities = "관측 identity"
|
observed_identities = "관측 identity"
|
||||||
status = "상태"
|
status = "상태"
|
||||||
updated_at = "상태 갱신"
|
|
||||||
|
|
||||||
[ui.admin.auth_guard]
|
[ui.admin.auth_guard]
|
||||||
subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다."
|
subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다."
|
||||||
@@ -1188,6 +1182,7 @@ tab_organization = "조직 관리"
|
|||||||
tab_permissions = "권한"
|
tab_permissions = "권한"
|
||||||
tab_profile = "프로필"
|
tab_profile = "프로필"
|
||||||
tab_schema = "사용자 스키마"
|
tab_schema = "사용자 스키마"
|
||||||
|
tab_relations = "세부 권한"
|
||||||
title = "상세"
|
title = "상세"
|
||||||
|
|
||||||
[ui.admin.tenants.list]
|
[ui.admin.tenants.list]
|
||||||
@@ -1498,7 +1493,6 @@ email = "이메일"
|
|||||||
name = "이름"
|
name = "이름"
|
||||||
role = "역할"
|
role = "역할"
|
||||||
|
|
||||||
|
|
||||||
[ui.common.role]
|
[ui.common.role]
|
||||||
admin = "Admin"
|
admin = "Admin"
|
||||||
rp_admin = "RP Admin"
|
rp_admin = "RP Admin"
|
||||||
@@ -2006,3 +2000,24 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
action = "로그인하기"
|
||||||
|
|
||||||
|
[ui.admin.permissions_direct]
|
||||||
|
tab_tenant = "테넌트 기능 권한"
|
||||||
|
tab_system = "시스템 메뉴 권한 (Admin Control)"
|
||||||
|
tab_system_title = "글로벌 메뉴 접근 제어 (Admin Control)"
|
||||||
|
select_tenant = "대상 테넌트 선택"
|
||||||
|
select_tenant_desc = "세부 기능 권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요."
|
||||||
|
placeholder = "-- 테넌트 선택 --"
|
||||||
|
add_system_user = "시스템 권한 사용자 추가"
|
||||||
|
dialog_title_system = "시스템 권한 관리 유저 추가"
|
||||||
|
|
||||||
|
[msg.admin.permissions_direct]
|
||||||
|
description = "테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다."
|
||||||
|
tab_system_desc = "사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다."
|
||||||
|
system_empty = "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요."
|
||||||
|
select_prompt = "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다."
|
||||||
|
|
||||||
|
[msg.admin.system.relations]
|
||||||
|
add_success = "시스템 메뉴 권한이 추가되었습니다."
|
||||||
|
remove_success = "시스템 메뉴 권한이 회수되었습니다."
|
||||||
|
remove_all_confirm = "이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?"
|
||||||
|
|||||||
@@ -193,7 +193,6 @@ worksmobile_excluded = ""
|
|||||||
worksmobile_sync = ""
|
worksmobile_sync = ""
|
||||||
allowed_domains = ""
|
allowed_domains = ""
|
||||||
|
|
||||||
|
|
||||||
[msg.admin.ory_ssot]
|
[msg.admin.ory_ssot]
|
||||||
flush_confirm = ""
|
flush_confirm = ""
|
||||||
flush_error = ""
|
flush_error = ""
|
||||||
@@ -981,6 +980,7 @@ org_chart = ""
|
|||||||
api_keys = ""
|
api_keys = ""
|
||||||
audit_logs = ""
|
audit_logs = ""
|
||||||
auth_guard = ""
|
auth_guard = ""
|
||||||
|
permissions_direct = ""
|
||||||
data_integrity = ""
|
data_integrity = ""
|
||||||
logout = ""
|
logout = ""
|
||||||
overview = ""
|
overview = ""
|
||||||
@@ -1005,10 +1005,6 @@ title = ""
|
|||||||
[ui.admin.ory_ssot.forbidden]
|
[ui.admin.ory_ssot.forbidden]
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.admin.ory_ssot.projection_card]
|
|
||||||
description = ""
|
|
||||||
title = ""
|
|
||||||
|
|
||||||
[ui.admin.ory_ssot.status]
|
[ui.admin.ory_ssot.status]
|
||||||
failed = ""
|
failed = ""
|
||||||
not_ready = ""
|
not_ready = ""
|
||||||
@@ -1017,11 +1013,8 @@ ready = ""
|
|||||||
[ui.admin.ory_ssot.summary]
|
[ui.admin.ory_ssot.summary]
|
||||||
cache_keys = ""
|
cache_keys = ""
|
||||||
last_refreshed = ""
|
last_refreshed = ""
|
||||||
last_synced = ""
|
|
||||||
local_users = ""
|
|
||||||
observed_identities = ""
|
observed_identities = ""
|
||||||
status = ""
|
status = ""
|
||||||
updated_at = ""
|
|
||||||
|
|
||||||
[ui.admin.auth_guard]
|
[ui.admin.auth_guard]
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
@@ -1193,6 +1186,7 @@ tab_organization = ""
|
|||||||
tab_permissions = ""
|
tab_permissions = ""
|
||||||
tab_profile = ""
|
tab_profile = ""
|
||||||
tab_schema = ""
|
tab_schema = ""
|
||||||
|
tab_relations = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.admin.tenants.list]
|
[ui.admin.tenants.list]
|
||||||
@@ -1451,7 +1445,6 @@ email = ""
|
|||||||
name = ""
|
name = ""
|
||||||
role = ""
|
role = ""
|
||||||
|
|
||||||
|
|
||||||
[ui.common.role]
|
[ui.common.role]
|
||||||
admin = ""
|
admin = ""
|
||||||
rp_admin = ""
|
rp_admin = ""
|
||||||
@@ -1961,3 +1954,24 @@ verify = ""
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = ""
|
action = ""
|
||||||
|
|
||||||
|
[ui.admin.permissions_direct]
|
||||||
|
tab_tenant = ""
|
||||||
|
tab_system = ""
|
||||||
|
tab_system_title = ""
|
||||||
|
select_tenant = ""
|
||||||
|
select_tenant_desc = ""
|
||||||
|
placeholder = ""
|
||||||
|
add_system_user = ""
|
||||||
|
dialog_title_system = ""
|
||||||
|
|
||||||
|
[msg.admin.permissions_direct]
|
||||||
|
description = ""
|
||||||
|
tab_system_desc = ""
|
||||||
|
system_empty = ""
|
||||||
|
select_prompt = ""
|
||||||
|
|
||||||
|
[msg.admin.system.relations]
|
||||||
|
add_success = ""
|
||||||
|
remove_success = ""
|
||||||
|
remove_all_confirm = ""
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { expect, test } from "@playwright/test";
|
|||||||
|
|
||||||
test.describe("Authentication", () => {
|
test.describe("Authentication", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
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
|
// 1. Force state
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
window.localStorage.setItem("locale", "ko");
|
window.localStorage.setItem("locale", "ko");
|
||||||
@@ -70,8 +74,24 @@ test.describe("Authentication", () => {
|
|||||||
|
|
||||||
// 3. Catch-all for others
|
// 3. Catch-all for others
|
||||||
await page.route(/.*\/api\/v1\/.*/, async (route) => {
|
await page.route(/.*\/api\/v1\/.*/, async (route) => {
|
||||||
|
if (route.request().url().includes("/user/me")) {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
if (route.request().method() === "GET") {
|
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 {
|
} else {
|
||||||
await route.fulfill({ status: 200, json: {} });
|
await route.fulfill({ status: 200, json: {} });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,11 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
|||||||
|
|
||||||
test.describe("일반 사용자 (Tenant Member) 제한", () => {
|
test.describe("일반 사용자 (Tenant Member) 제한", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupAuth(page, "user");
|
await setupAuth(page, "user", {
|
||||||
|
systemPermissions: {
|
||||||
|
audit_logs: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.locator("aside")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("aside")).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
@@ -291,4 +295,64 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
|||||||
).not.toBeVisible();
|
).not.toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("세부 기능 권한(System Permissions)을 가진 비-슈퍼어드민", () => {
|
||||||
|
test("테넌트 조회 권한(tenants)이 있을 때 테넌트 목록 페이지 진입 가능 및 쓰기 기능 제한 확인", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupAuth(page, "tenant_admin", {
|
||||||
|
tenantId: "t1",
|
||||||
|
tenantSlug: "t1",
|
||||||
|
systemPermissions: {
|
||||||
|
tenants: true,
|
||||||
|
manage_tenants: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator("aside")).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// 테넌트 목록 메뉴 노출 및 클릭 진입 확인
|
||||||
|
await expect(page.locator('a[href="/tenants"]')).toBeVisible();
|
||||||
|
await page.goto("/tenants");
|
||||||
|
|
||||||
|
// 차단 메시지 비노출 확인
|
||||||
|
await expect(
|
||||||
|
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.getByTestId("tenant-data-mgmt-btn")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("테넌트 관리 권한(manage_tenants)까지 있을 때 테넌트 추가 및 데이터 관리 버튼 활성화 확인", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupAuth(page, "tenant_admin", {
|
||||||
|
tenantId: "t1",
|
||||||
|
tenantSlug: "t1",
|
||||||
|
systemPermissions: {
|
||||||
|
tenants: true,
|
||||||
|
manage_tenants: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await page.goto("/tenants");
|
||||||
|
|
||||||
|
// "테넌트 1" 목록 노출 확인
|
||||||
|
await expect(page.getByText("테넌트 1")).toBeVisible();
|
||||||
|
|
||||||
|
// 수정 권한(manage_tenants)이 있으므로 쓰기 버튼(테넌트 추가, 데이터 관리) 노출 확인
|
||||||
|
await expect(
|
||||||
|
page.getByRole("link", { name: /테넌트 추가/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByTestId("tenant-data-mgmt-btn")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -764,6 +764,14 @@ func main() {
|
|||||||
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
|
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
|
||||||
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
|
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
|
||||||
|
|
||||||
|
admin.Get("/tenants/:id/relations", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage_admins"), tenantHandler.ListRelations)
|
||||||
|
admin.Post("/tenants/:id/relations", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage_admins"), tenantHandler.AddRelation)
|
||||||
|
admin.Delete("/tenants/:id/relations", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage_admins"), tenantHandler.RemoveRelation)
|
||||||
|
|
||||||
|
admin.Get("/system/relations", requireSuperAdmin, tenantHandler.ListSystemRelations)
|
||||||
|
admin.Post("/system/relations", requireSuperAdmin, tenantHandler.AddSystemRelation)
|
||||||
|
admin.Delete("/system/relations", requireSuperAdmin, tenantHandler.RemoveSystemRelation)
|
||||||
|
|
||||||
admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview)
|
admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview)
|
||||||
admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison)
|
admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison)
|
||||||
admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches)
|
admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches)
|
||||||
|
|||||||
@@ -69,24 +69,51 @@ type SignupRequest struct {
|
|||||||
|
|
||||||
// User Profile Models
|
// 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"`
|
||||||
|
|
||||||
|
ManageOverview bool `json:"manage_overview"`
|
||||||
|
ManageTenants bool `json:"manage_tenants"`
|
||||||
|
ManageOrgChart bool `json:"manage_org_chart"`
|
||||||
|
ManageWorksmobile bool `json:"manage_worksmobile"`
|
||||||
|
ManageOrySSOT bool `json:"manage_ory_ssot"`
|
||||||
|
ManageDataIntegrity bool `json:"manage_data_integrity"`
|
||||||
|
ManageUsers bool `json:"manage_users"`
|
||||||
|
ManagePermissionsDirect bool `json:"manage_permissions_direct"`
|
||||||
|
ManageAuthGuard bool `json:"manage_auth_guard"`
|
||||||
|
ManageApiKeys bool `json:"manage_api_keys"`
|
||||||
|
ManageAuditLogs bool `json:"manage_audit_logs"`
|
||||||
|
}
|
||||||
|
|
||||||
type UserProfileResponse struct {
|
type UserProfileResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
LoginID string `json:"loginId,omitempty"`
|
LoginID string `json:"loginId,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"` // 추가
|
Role string `json:"role"` // 추가
|
||||||
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
|
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
AffiliationType string `json:"affiliationType"`
|
AffiliationType string `json:"affiliationType"`
|
||||||
CompanyCode string `json:"companyCode,omitempty"`
|
CompanyCode string `json:"companyCode,omitempty"`
|
||||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||||
SessionTenantID *string `json:"sessionTenantId,omitempty"` // [New] 로그인에 사용된 식별자 기반 테넌트
|
SessionTenantID *string `json:"sessionTenantId,omitempty"` // [New] 로그인에 사용된 식별자 기반 테넌트
|
||||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
Tenant *Tenant `json:"tenant,omitempty"`
|
Tenant *Tenant `json:"tenant,omitempty"`
|
||||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||||
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
||||||
|
SystemPermissions *SystemPermissions `json:"systemPermissions,omitempty"` // [New] 글로벌 메뉴 접근 권한
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserRequest struct {
|
type UpdateUserRequest struct {
|
||||||
|
|||||||
@@ -4928,6 +4928,125 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.KetoService != nil {
|
||||||
|
subject := "User:" + profile.ID
|
||||||
|
var sp domain.SystemPermissions
|
||||||
|
|
||||||
|
if profile.Role == "super_admin" {
|
||||||
|
sp = domain.SystemPermissions{
|
||||||
|
Overview: true,
|
||||||
|
Tenants: true,
|
||||||
|
OrgChart: true,
|
||||||
|
Worksmobile: true,
|
||||||
|
OrySSOT: true,
|
||||||
|
DataIntegrity: true,
|
||||||
|
Users: true,
|
||||||
|
PermissionsDirect: true,
|
||||||
|
AuthGuard: true,
|
||||||
|
ApiKeys: true,
|
||||||
|
AuditLogs: true,
|
||||||
|
ManageOverview: true,
|
||||||
|
ManageTenants: true,
|
||||||
|
ManageOrgChart: true,
|
||||||
|
ManageWorksmobile: true,
|
||||||
|
ManageOrySSOT: true,
|
||||||
|
ManageDataIntegrity: true,
|
||||||
|
ManageUsers: true,
|
||||||
|
ManagePermissionsDirect: true,
|
||||||
|
ManageAuthGuard: true,
|
||||||
|
ManageApiKeys: true,
|
||||||
|
ManageAuditLogs: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Query Keto in parallel for maximum performance
|
||||||
|
type checkResult struct {
|
||||||
|
menu string
|
||||||
|
allowed bool
|
||||||
|
}
|
||||||
|
menus := map[string]string{
|
||||||
|
"overview": "access_overview",
|
||||||
|
"manage_overview": "manage_overview",
|
||||||
|
"tenants": "access_tenants",
|
||||||
|
"manage_tenants": "manage_tenants",
|
||||||
|
"org_chart": "access_org_chart",
|
||||||
|
"manage_org_chart": "manage_org_chart",
|
||||||
|
"worksmobile": "access_worksmobile",
|
||||||
|
"manage_worksmobile": "manage_worksmobile",
|
||||||
|
"ory_ssot": "access_ory_ssot",
|
||||||
|
"manage_ory_ssot": "manage_ory_ssot",
|
||||||
|
"data_integrity": "access_data_integrity",
|
||||||
|
"manage_data_integrity": "manage_data_integrity",
|
||||||
|
"users": "access_users",
|
||||||
|
"manage_users": "manage_users",
|
||||||
|
"permissions_direct": "access_permissions_direct",
|
||||||
|
"manage_permissions_direct": "manage_permissions_direct",
|
||||||
|
"auth_guard": "access_auth_guard",
|
||||||
|
"manage_auth_guard": "manage_auth_guard",
|
||||||
|
"api_keys": "access_api_keys",
|
||||||
|
"manage_api_keys": "manage_api_keys",
|
||||||
|
"audit_logs": "access_audit_logs",
|
||||||
|
"manage_audit_logs": "manage_audit_logs",
|
||||||
|
}
|
||||||
|
ch := make(chan checkResult, len(menus))
|
||||||
|
for m, rel := range menus {
|
||||||
|
go func(menuName, relation string) {
|
||||||
|
allowed, _ := h.KetoService.CheckPermission(ctx, subject, "System", "system", relation)
|
||||||
|
ch <- checkResult{menu: menuName, allowed: allowed}
|
||||||
|
}(m, rel)
|
||||||
|
}
|
||||||
|
for range menus {
|
||||||
|
res := <-ch
|
||||||
|
switch res.menu {
|
||||||
|
case "overview":
|
||||||
|
sp.Overview = res.allowed
|
||||||
|
case "manage_overview":
|
||||||
|
sp.ManageOverview = res.allowed
|
||||||
|
case "tenants":
|
||||||
|
sp.Tenants = res.allowed
|
||||||
|
case "manage_tenants":
|
||||||
|
sp.ManageTenants = res.allowed
|
||||||
|
case "org_chart":
|
||||||
|
sp.OrgChart = res.allowed
|
||||||
|
case "manage_org_chart":
|
||||||
|
sp.ManageOrgChart = res.allowed
|
||||||
|
case "worksmobile":
|
||||||
|
sp.Worksmobile = res.allowed
|
||||||
|
case "manage_worksmobile":
|
||||||
|
sp.ManageWorksmobile = res.allowed
|
||||||
|
case "ory_ssot":
|
||||||
|
sp.OrySSOT = res.allowed
|
||||||
|
case "manage_ory_ssot":
|
||||||
|
sp.ManageOrySSOT = res.allowed
|
||||||
|
case "data_integrity":
|
||||||
|
sp.DataIntegrity = res.allowed
|
||||||
|
case "manage_data_integrity":
|
||||||
|
sp.ManageDataIntegrity = res.allowed
|
||||||
|
case "users":
|
||||||
|
sp.Users = res.allowed
|
||||||
|
case "manage_users":
|
||||||
|
sp.ManageUsers = res.allowed
|
||||||
|
case "permissions_direct":
|
||||||
|
sp.PermissionsDirect = res.allowed
|
||||||
|
case "manage_permissions_direct":
|
||||||
|
sp.ManagePermissionsDirect = res.allowed
|
||||||
|
case "auth_guard":
|
||||||
|
sp.AuthGuard = res.allowed
|
||||||
|
case "manage_auth_guard":
|
||||||
|
sp.ManageAuthGuard = res.allowed
|
||||||
|
case "api_keys":
|
||||||
|
sp.ApiKeys = res.allowed
|
||||||
|
case "manage_api_keys":
|
||||||
|
sp.ManageApiKeys = res.allowed
|
||||||
|
case "audit_logs":
|
||||||
|
sp.AuditLogs = res.allowed
|
||||||
|
case "manage_audit_logs":
|
||||||
|
sp.ManageAuditLogs = res.allowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profile.SystemPermissions = &sp
|
||||||
|
}
|
||||||
|
|
||||||
return profile
|
return profile
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8426,7 +8545,7 @@ func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs [
|
|||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, scope := range append([]string{"openid"}, scopes...) {
|
for _, scope := range append([]string{"openid"}, scopes...) {
|
||||||
scope = strings.TrimSpace(scope)
|
scope = strings.TrimSpace(scope)
|
||||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := seen[scope]; ok {
|
if _, ok := seen[scope]; ok {
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
|
|||||||
|
|
||||||
appendIfPresent := func(scope string) {
|
appendIfPresent := func(scope string) {
|
||||||
scope = strings.TrimSpace(scope)
|
scope = strings.TrimSpace(scope)
|
||||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := seen[scope]; ok {
|
if _, ok := seen[scope]; ok {
|
||||||
@@ -485,7 +485,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
|
|||||||
|
|
||||||
for _, scope := range combined {
|
for _, scope := range combined {
|
||||||
scope = strings.TrimSpace(scope)
|
scope = strings.TrimSpace(scope)
|
||||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := seen[scope]; ok {
|
if _, ok := seen[scope]; ok {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@@ -153,7 +154,7 @@ func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAlias
|
|||||||
[]string{"openid", "offline", "profile", "offline_access"},
|
[]string{"openid", "offline", "profile", "offline_access"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t, []string{"openid", "tenant", "profile", "email"}, merged)
|
assert.Equal(t, []string{"openid", "tenant", "profile", "offline_access", "email"}, merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) {
|
func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) {
|
||||||
@@ -166,10 +167,11 @@ func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T)
|
|||||||
parsed, err := url.Parse(urlString)
|
parsed, err := url.Parse(urlString)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
scopes := parsed.Query().Get("scope")
|
scopes := parsed.Query().Get("scope")
|
||||||
|
scopeItems := strings.Fields(scopes)
|
||||||
|
|
||||||
assert.Equal(t, "openid profile email", scopes)
|
assert.Equal(t, "openid profile offline_access email", scopes)
|
||||||
assert.NotContains(t, scopes, "offline")
|
assert.NotContains(t, scopeItems, "offline")
|
||||||
assert.NotContains(t, scopes, "offline_access")
|
assert.Contains(t, scopeItems, "offline_access")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) {
|
func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) {
|
||||||
|
|||||||
@@ -3848,7 +3848,7 @@ func normalizeClientScopes(scopes []string) []string {
|
|||||||
seen := make(map[string]struct{}, len(scopes))
|
seen := make(map[string]struct{}, len(scopes))
|
||||||
for _, scope := range scopes {
|
for _, scope := range scopes {
|
||||||
scope = strings.TrimSpace(scope)
|
scope = strings.TrimSpace(scope)
|
||||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := seen[scope]; ok {
|
if _, ok := seen[scope]; ok {
|
||||||
@@ -3860,9 +3860,9 @@ func normalizeClientScopes(scopes []string) []string {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func isRefreshTokenScopeAlias(scope string) bool {
|
func isLegacyRefreshTokenScopeAlias(scope string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(scope)) {
|
switch strings.ToLower(strings.TrimSpace(scope)) {
|
||||||
case "offline", "offline_access":
|
case "offline":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -2229,9 +2229,9 @@ func TestCreateClient_StripsOfflineScopesAndKeepsRefreshTokenGrant(t *testing.T)
|
|||||||
|
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
assert.Equal(t, "openid profile email", captured.Scope)
|
assert.Equal(t, "openid profile offline_access email", captured.Scope)
|
||||||
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
|
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
|
||||||
assert.NotContains(t, strings.Fields(captured.Scope), "offline_access")
|
assert.Contains(t, strings.Fields(captured.Scope), "offline_access")
|
||||||
assert.Contains(t, captured.GrantTypes, "refresh_token")
|
assert.Contains(t, captured.GrantTypes, "refresh_token")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2296,9 +2296,9 @@ func TestUpdateClient_StripsStoredOfflineScopesAndKeepsRefreshTokenGrant(t *test
|
|||||||
|
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Equal(t, "openid profile email", captured.Scope)
|
assert.Equal(t, "openid profile offline_access email", captured.Scope)
|
||||||
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
|
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
|
||||||
assert.NotContains(t, strings.Fields(captured.Scope), "offline_access")
|
assert.Contains(t, strings.Fields(captured.Scope), "offline_access")
|
||||||
assert.Contains(t, captured.GrantTypes, "refresh_token")
|
assert.Contains(t, captured.GrantTypes, "refresh_token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,20 +84,36 @@ func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
|
|||||||
h.Worksmobile = syncer
|
h.Worksmobile = syncer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tenantPermissions struct {
|
||||||
|
View bool `json:"view"`
|
||||||
|
Manage bool `json:"manage"`
|
||||||
|
ManageAdmins bool `json:"manage_admins"`
|
||||||
|
|
||||||
|
ViewProfile bool `json:"view_profile"`
|
||||||
|
ManageProfile bool `json:"manage_profile"`
|
||||||
|
ViewPermissions bool `json:"view_permissions"`
|
||||||
|
ManagePermissions bool `json:"manage_permissions"`
|
||||||
|
ViewOrganization bool `json:"view_organization"`
|
||||||
|
ManageOrganization bool `json:"manage_organization"`
|
||||||
|
ViewSchema bool `json:"view_schema"`
|
||||||
|
ManageSchema bool `json:"manage_schema"`
|
||||||
|
}
|
||||||
|
|
||||||
type tenantSummary struct {
|
type tenantSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ParentID *string `json:"parentId"`
|
ParentID *string `json:"parentId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Domains []string `json:"domains,omitempty"`
|
Domains []string `json:"domains,omitempty"`
|
||||||
Config domain.JSONMap `json:"config,omitempty"`
|
Config domain.JSONMap `json:"config,omitempty"`
|
||||||
MemberCount int64 `json:"memberCount"`
|
MemberCount int64 `json:"memberCount"`
|
||||||
TotalMemberCount int64 `json:"totalMemberCount"`
|
TotalMemberCount int64 `json:"totalMemberCount"`
|
||||||
CreatedAt string `json:"createdAt"`
|
UserPermissions *tenantPermissions `json:"userPermissions,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tenantListResponse struct {
|
type tenantListResponse struct {
|
||||||
@@ -1709,6 +1725,83 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
|||||||
summary.MemberCount = memberCounts[tenant.ID]
|
summary.MemberCount = memberCounts[tenant.ID]
|
||||||
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
|
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
|
||||||
|
|
||||||
|
// Populate Keto-based permissions for the current user
|
||||||
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if ok && profile != nil {
|
||||||
|
role := domain.NormalizeRole(profile.Role)
|
||||||
|
if role == domain.RoleSuperAdmin {
|
||||||
|
summary.UserPermissions = &tenantPermissions{
|
||||||
|
View: true,
|
||||||
|
Manage: true,
|
||||||
|
ManageAdmins: true,
|
||||||
|
ViewProfile: true,
|
||||||
|
ManageProfile: true,
|
||||||
|
ViewPermissions: true,
|
||||||
|
ManagePermissions: true,
|
||||||
|
ViewOrganization: true,
|
||||||
|
ManageOrganization: true,
|
||||||
|
ViewSchema: true,
|
||||||
|
ManageSchema: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Query Keto in parallel for maximum performance
|
||||||
|
subject := "User:" + profile.ID
|
||||||
|
type checkResult struct {
|
||||||
|
relation string
|
||||||
|
allowed bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan checkResult, 11)
|
||||||
|
relations := []string{
|
||||||
|
"view", "manage", "manage_admins",
|
||||||
|
"view_profile", "manage_profile",
|
||||||
|
"view_permissions", "manage_permissions",
|
||||||
|
"view_organization", "manage_organization",
|
||||||
|
"view_schema", "manage_schema",
|
||||||
|
}
|
||||||
|
for _, rel := range relations {
|
||||||
|
go func(r string) {
|
||||||
|
allowed, err := h.Keto.CheckPermission(c.Context(), subject, "Tenant", tenant.ID, r)
|
||||||
|
ch <- checkResult{relation: r, allowed: allowed, err: err}
|
||||||
|
}(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := &tenantPermissions{}
|
||||||
|
for range relations {
|
||||||
|
res := <-ch
|
||||||
|
if res.err != nil {
|
||||||
|
slog.Error("Failed to check Keto permission in GetTenant", "error", res.err, "relation", res.relation, "userID", profile.ID, "tenantID", tenant.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch res.relation {
|
||||||
|
case "view":
|
||||||
|
perms.View = res.allowed
|
||||||
|
case "manage":
|
||||||
|
perms.Manage = res.allowed
|
||||||
|
case "manage_admins":
|
||||||
|
perms.ManageAdmins = res.allowed
|
||||||
|
case "view_profile":
|
||||||
|
perms.ViewProfile = res.allowed
|
||||||
|
case "manage_profile":
|
||||||
|
perms.ManageProfile = res.allowed
|
||||||
|
case "view_permissions":
|
||||||
|
perms.ViewPermissions = res.allowed
|
||||||
|
case "manage_permissions":
|
||||||
|
perms.ManagePermissions = res.allowed
|
||||||
|
case "view_organization":
|
||||||
|
perms.ViewOrganization = res.allowed
|
||||||
|
case "manage_organization":
|
||||||
|
perms.ManageOrganization = res.allowed
|
||||||
|
case "view_schema":
|
||||||
|
perms.ViewSchema = res.allowed
|
||||||
|
case "manage_schema":
|
||||||
|
perms.ManageSchema = res.allowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.UserPermissions = perms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(summary)
|
return c.JSON(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3652,3 +3745,419 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
|||||||
"sharedWith": link.Name,
|
"sharedWith": link.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tenantRelationRequest struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) ListRelations(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
if tenantID == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.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,
|
||||||
|
}
|
||||||
|
|
||||||
|
type userRelationInfo struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Relations []string `json:"relations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
userMap := make(map[string][]string)
|
||||||
|
for _, rel := range relations {
|
||||||
|
if !allowedRelations[rel.Relation] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(rel.SubjectID, "User:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userID := strings.TrimPrefix(rel.SubjectID, "User:")
|
||||||
|
userMap[userID] = append(userMap[userID], rel.Relation)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := []userRelationInfo{}
|
||||||
|
for userID, rels := range userMap {
|
||||||
|
name := "Unknown"
|
||||||
|
email := "Unknown"
|
||||||
|
|
||||||
|
if h.KratosAdmin != nil {
|
||||||
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||||
|
if err == nil && identity != nil {
|
||||||
|
if n, ok := identity.Traits["name"].(string); ok {
|
||||||
|
name = n
|
||||||
|
}
|
||||||
|
if e, ok := identity.Traits["email"].(string); ok {
|
||||||
|
email = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "Unknown" && email == "Unknown" && h.UserRepo != nil {
|
||||||
|
user, err := h.UserRepo.FindByID(c.Context(), userID)
|
||||||
|
if err == nil && user != nil {
|
||||||
|
name = user.Name
|
||||||
|
email = user.Email
|
||||||
|
} else if userID == "00000000-0000-0000-0000-000000000000" {
|
||||||
|
name = "Dev Mock User"
|
||||||
|
email = "mock@hmac.kr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, userRelationInfo{
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
Email: email,
|
||||||
|
Relations: rels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) AddRelation(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
if tenantID == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req tenantRelationRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.UserID == "" || req.Relation == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowedRelations[req.Relation] {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid or unsupported relation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.Keto != nil {
|
||||||
|
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID)
|
||||||
|
if err == nil && len(relations) > 0 {
|
||||||
|
return errorJSON(c, fiber.StatusConflict, "이미 해당 세부 권한이 등록된 사용자입니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var directWriteErr error
|
||||||
|
if h.Keto != nil {
|
||||||
|
directWriteErr = h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.KetoOutbox != nil {
|
||||||
|
status := domain.KetoOutboxStatusPending
|
||||||
|
var processedAt *time.Time
|
||||||
|
if directWriteErr == nil && h.Keto != nil {
|
||||||
|
status = domain.KetoOutboxStatusProcessed
|
||||||
|
now := time.Now()
|
||||||
|
processedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: req.Relation,
|
||||||
|
Subject: "User:" + req.UserID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
Status: status,
|
||||||
|
ProcessedAt: processedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if directWriteErr != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) RemoveRelation(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
if tenantID == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req tenantRelationRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.UserID == "" || req.Relation == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var directWriteErr error
|
||||||
|
if h.Keto != nil {
|
||||||
|
directWriteErr = h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.KetoOutbox != nil {
|
||||||
|
status := domain.KetoOutboxStatusPending
|
||||||
|
var processedAt *time.Time
|
||||||
|
if directWriteErr == nil && h.Keto != nil {
|
||||||
|
status = domain.KetoOutboxStatusProcessed
|
||||||
|
now := time.Now()
|
||||||
|
processedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: req.Relation,
|
||||||
|
Subject: "User:" + req.UserID,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
Status: status,
|
||||||
|
ProcessedAt: processedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if directWriteErr != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) ListSystemRelations(c *fiber.Ctx) error {
|
||||||
|
relations, err := h.Keto.ListRelations(c.Context(), "System", "system", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedRelations := map[string]bool{
|
||||||
|
"overview_viewers": true,
|
||||||
|
"tenants_viewers": true,
|
||||||
|
"org_chart_viewers": true,
|
||||||
|
"worksmobile_viewers": true,
|
||||||
|
"ory_ssot_viewers": true,
|
||||||
|
"data_integrity_viewers": true,
|
||||||
|
"users_viewers": true,
|
||||||
|
"permissions_direct_viewers": true,
|
||||||
|
"auth_guard_viewers": true,
|
||||||
|
"api_keys_viewers": true,
|
||||||
|
"audit_logs_viewers": true,
|
||||||
|
|
||||||
|
"overview_managers": true,
|
||||||
|
"tenants_managers": true,
|
||||||
|
"org_chart_managers": true,
|
||||||
|
"worksmobile_managers": true,
|
||||||
|
"ory_ssot_managers": true,
|
||||||
|
"data_integrity_managers": true,
|
||||||
|
"users_managers": true,
|
||||||
|
"permissions_direct_managers": true,
|
||||||
|
"auth_guard_managers": true,
|
||||||
|
"api_keys_managers": true,
|
||||||
|
"audit_logs_managers": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
type userRelationInfo struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Relations []string `json:"relations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
userMap := make(map[string][]string)
|
||||||
|
for _, rel := range relations {
|
||||||
|
if !allowedRelations[rel.Relation] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(rel.SubjectID, "User:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userID := strings.TrimPrefix(rel.SubjectID, "User:")
|
||||||
|
userMap[userID] = append(userMap[userID], rel.Relation)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := []userRelationInfo{}
|
||||||
|
for userID, rels := range userMap {
|
||||||
|
name := "Unknown"
|
||||||
|
email := "Unknown"
|
||||||
|
|
||||||
|
if h.KratosAdmin != nil {
|
||||||
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||||
|
if err == nil && identity != nil {
|
||||||
|
if n, ok := identity.Traits["name"].(string); ok {
|
||||||
|
name = n
|
||||||
|
}
|
||||||
|
if e, ok := identity.Traits["email"].(string); ok {
|
||||||
|
email = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "Unknown" && email == "Unknown" && h.UserRepo != nil {
|
||||||
|
user, err := h.UserRepo.FindByID(c.Context(), userID)
|
||||||
|
if err == nil && user != nil {
|
||||||
|
name = user.Name
|
||||||
|
email = user.Email
|
||||||
|
} else if userID == "00000000-0000-0000-0000-000000000000" {
|
||||||
|
name = "Dev Mock User"
|
||||||
|
email = "mock@hmac.kr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, userRelationInfo{
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
Email: email,
|
||||||
|
Relations: rels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) AddSystemRelation(c *fiber.Ctx) error {
|
||||||
|
var req tenantRelationRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.UserID == "" || req.Relation == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedRelations := map[string]bool{
|
||||||
|
"overview_viewers": true,
|
||||||
|
"tenants_viewers": true,
|
||||||
|
"org_chart_viewers": true,
|
||||||
|
"worksmobile_viewers": true,
|
||||||
|
"ory_ssot_viewers": true,
|
||||||
|
"data_integrity_viewers": true,
|
||||||
|
"users_viewers": true,
|
||||||
|
"permissions_direct_viewers": true,
|
||||||
|
"auth_guard_viewers": true,
|
||||||
|
"api_keys_viewers": true,
|
||||||
|
"audit_logs_viewers": true,
|
||||||
|
|
||||||
|
"overview_managers": true,
|
||||||
|
"tenants_managers": true,
|
||||||
|
"org_chart_managers": true,
|
||||||
|
"worksmobile_managers": true,
|
||||||
|
"ory_ssot_managers": true,
|
||||||
|
"data_integrity_managers": true,
|
||||||
|
"users_managers": true,
|
||||||
|
"permissions_direct_managers": true,
|
||||||
|
"auth_guard_managers": true,
|
||||||
|
"api_keys_managers": true,
|
||||||
|
"audit_logs_managers": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowedRelations[req.Relation] {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid or unsupported relation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.Keto != nil {
|
||||||
|
relations, err := h.Keto.ListRelations(c.Context(), "System", "system", req.Relation, "User:"+req.UserID)
|
||||||
|
if err == nil && len(relations) > 0 {
|
||||||
|
return errorJSON(c, fiber.StatusConflict, "이미 해당 세부 권한이 등록된 사용자입니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var directWriteErr error
|
||||||
|
if h.Keto != nil {
|
||||||
|
directWriteErr = h.Keto.CreateRelation(c.Context(), "System", "system", req.Relation, "User:"+req.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.KetoOutbox != nil {
|
||||||
|
status := domain.KetoOutboxStatusPending
|
||||||
|
var processedAt *time.Time
|
||||||
|
if directWriteErr == nil && h.Keto != nil {
|
||||||
|
status = domain.KetoOutboxStatusProcessed
|
||||||
|
now := time.Now()
|
||||||
|
processedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "System",
|
||||||
|
Object: "system",
|
||||||
|
Relation: req.Relation,
|
||||||
|
Subject: "User:" + req.UserID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
Status: status,
|
||||||
|
ProcessedAt: processedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if directWriteErr != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) RemoveSystemRelation(c *fiber.Ctx) error {
|
||||||
|
var req tenantRelationRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.UserID == "" || req.Relation == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var directWriteErr error
|
||||||
|
if h.Keto != nil {
|
||||||
|
directWriteErr = h.Keto.DeleteRelation(c.Context(), "System", "system", req.Relation, "User:"+req.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.KetoOutbox != nil {
|
||||||
|
status := domain.KetoOutboxStatusPending
|
||||||
|
var processedAt *time.Time
|
||||||
|
if directWriteErr == nil && h.Keto != nil {
|
||||||
|
status = domain.KetoOutboxStatusProcessed
|
||||||
|
now := time.Now()
|
||||||
|
processedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "System",
|
||||||
|
Object: "system",
|
||||||
|
Relation: req.Relation,
|
||||||
|
Subject: "User:" + req.UserID,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
Status: status,
|
||||||
|
ProcessedAt: processedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if directWriteErr != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
}
|
||||||
|
|||||||
151
backend/internal/handler/tenant_handler_get_test.go
Normal file
151
backend/internal/handler/tenant_handler_get_test.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/testsupport"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTenantHandler_GetTenant_SuperAdmin(t *testing.T) {
|
||||||
|
if !testsupport.DockerAvailable() {
|
||||||
|
t.Skip("Docker provider is unavailable in this environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
db := newTenantHandlerSeedDeleteDB(t)
|
||||||
|
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate tenant domains: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test tenant in DB with a valid UUID
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: "00000000-0000-0000-0000-000000000010",
|
||||||
|
Name: "Super Admin Test Tenant",
|
||||||
|
Slug: "super-admin-test-tenant",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
if err := db.Create(&tenant).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create tenant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
mockSvc := new(MockTenantService)
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
|
||||||
|
h := &TenantHandler{
|
||||||
|
DB: db,
|
||||||
|
Service: mockSvc,
|
||||||
|
Keto: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll simulate middleware setting "user_profile" for a Super Admin
|
||||||
|
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
|
||||||
|
profile := &domain.UserProfileResponse{
|
||||||
|
ID: "user-super-admin-id",
|
||||||
|
Role: domain.RoleSuperAdmin,
|
||||||
|
}
|
||||||
|
c.Locals("user_profile", profile)
|
||||||
|
return h.GetTenant(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000010", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var got tenantSummary
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "00000000-0000-0000-0000-000000000010", got.ID)
|
||||||
|
assert.Equal(t, "Super Admin Test Tenant", got.Name)
|
||||||
|
assert.NotNil(t, got.UserPermissions)
|
||||||
|
assert.True(t, got.UserPermissions.View)
|
||||||
|
assert.True(t, got.UserPermissions.Manage)
|
||||||
|
assert.True(t, got.UserPermissions.ManageAdmins)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantHandler_GetTenant_NormalUser(t *testing.T) {
|
||||||
|
if !testsupport.DockerAvailable() {
|
||||||
|
t.Skip("Docker provider is unavailable in this environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
db := newTenantHandlerSeedDeleteDB(t)
|
||||||
|
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate tenant domains: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test tenant in DB with a valid UUID
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: "00000000-0000-0000-0000-000000000020",
|
||||||
|
Name: "Normal User Test Tenant",
|
||||||
|
Slug: "normal-user-test-tenant",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
if err := db.Create(&tenant).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create tenant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
mockSvc := new(MockTenantService)
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
|
||||||
|
h := &TenantHandler{
|
||||||
|
DB: db,
|
||||||
|
Service: mockSvc,
|
||||||
|
Keto: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock Keto response: allowed view/manage but not manage_admins
|
||||||
|
subject := "User:user-normal-id"
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "view").Return(true, nil)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage").Return(true, nil)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage_admins").Return(false, nil)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", mock.Anything).Return(false, nil).Maybe()
|
||||||
|
|
||||||
|
// We'll simulate middleware setting "user_profile" for a regular admin/user
|
||||||
|
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
|
||||||
|
profile := &domain.UserProfileResponse{
|
||||||
|
ID: "user-normal-id",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
}
|
||||||
|
c.Locals("user_profile", profile)
|
||||||
|
return h.GetTenant(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000020", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var got tenantSummary
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "00000000-0000-0000-0000-000000000020", got.ID)
|
||||||
|
assert.Equal(t, "Normal User Test Tenant", got.Name)
|
||||||
|
assert.NotNil(t, got.UserPermissions)
|
||||||
|
assert.True(t, got.UserPermissions.View)
|
||||||
|
assert.True(t, got.UserPermissions.Manage)
|
||||||
|
assert.False(t, got.UserPermissions.ManageAdmins)
|
||||||
|
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
}
|
||||||
276
backend/internal/handler/tenant_handler_relations_test.go
Normal file
276
backend/internal/handler/tenant_handler_relations_test.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/repository"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"baron-sso-backend/internal/testsupport"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTenantHandler_Relations(t *testing.T) {
|
||||||
|
if !testsupport.DockerAvailable() {
|
||||||
|
t.Skip("Docker provider is unavailable in this environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
db := newTenantHandlerSeedDeleteDB(t)
|
||||||
|
if err := db.AutoMigrate(&domain.TenantDomain{}, &domain.KetoOutbox{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate tenant domains or outbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test tenant in DB with a valid UUID
|
||||||
|
tenantID := "00000000-0000-0000-0000-000000000030"
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: tenantID,
|
||||||
|
Name: "Relation Test Tenant",
|
||||||
|
Slug: "relation-test-tenant",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
if err := db.Create(&tenant).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create tenant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSvc := new(MockTenantService)
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
realOutbox := repository.NewKetoOutboxRepository(db)
|
||||||
|
|
||||||
|
h := &TenantHandler{
|
||||||
|
DB: db,
|
||||||
|
Service: mockSvc,
|
||||||
|
Keto: mockKeto,
|
||||||
|
KetoOutbox: realOutbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := "user-relation-1"
|
||||||
|
|
||||||
|
t.Run("ListRelations - Returns correct relations aggregated by user", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/tenants/:id/relations", h.ListRelations)
|
||||||
|
|
||||||
|
mockKeto.On("ListRelations", mock.Anything, "Tenant", tenantID, "", "").Return([]service.RelationTuple{
|
||||||
|
{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "schema_managers",
|
||||||
|
SubjectID: "User:" + userID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "profile_viewers",
|
||||||
|
SubjectID: "User:" + userID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "unrelated_relation", // Should be filtered out
|
||||||
|
SubjectID: "User:" + userID,
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/tenants/"+tenantID+"/relations", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Items []struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Relations []string `json:"relations"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, got.Items, 1)
|
||||||
|
assert.Equal(t, userID, got.Items[0].UserID)
|
||||||
|
assert.Contains(t, got.Items[0].Relations, "schema_managers")
|
||||||
|
assert.Contains(t, got.Items[0].Relations, "profile_viewers")
|
||||||
|
assert.NotContains(t, got.Items[0].Relations, "unrelated_relation")
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddRelation - Inserts into KetoOutbox DB table", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/tenants/:id/relations", h.AddRelation)
|
||||||
|
|
||||||
|
mockKeto.On("ListRelations", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once()
|
||||||
|
mockKeto.On("CreateRelation", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return(nil).Once()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"userId": userID,
|
||||||
|
"relation": "schema_managers",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/tenants/"+tenantID+"/relations", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// Verify row was written to the keto_outboxes DB table
|
||||||
|
var outboxEntries []domain.KetoOutbox
|
||||||
|
if err := db.Where("object = ? AND relation = ? AND action = ?", tenantID, "schema_managers", domain.KetoOutboxActionCreate).Find(&outboxEntries).Error; err != nil {
|
||||||
|
t.Fatalf("failed to query outbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, outboxEntries, 1)
|
||||||
|
assert.Equal(t, "Tenant", outboxEntries[0].Namespace)
|
||||||
|
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
|
||||||
|
assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RemoveRelation - Inserts delete action into KetoOutbox DB table", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
app.Delete("/tenants/:id/relations", h.RemoveRelation)
|
||||||
|
|
||||||
|
mockKeto.On("DeleteRelation", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return(nil).Once()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"userId": userID,
|
||||||
|
"relation": "schema_managers",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("DELETE", "/tenants/"+tenantID+"/relations", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// Verify delete action row was written to the keto_outboxes DB table
|
||||||
|
var outboxEntries []domain.KetoOutbox
|
||||||
|
if err := db.Where("object = ? AND relation = ? AND action = ?", tenantID, "schema_managers", domain.KetoOutboxActionDelete).Find(&outboxEntries).Error; err != nil {
|
||||||
|
t.Fatalf("failed to query outbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, outboxEntries, 1)
|
||||||
|
assert.Equal(t, "Tenant", outboxEntries[0].Namespace)
|
||||||
|
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
|
||||||
|
assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantHandler_SystemRelations(t *testing.T) {
|
||||||
|
if !testsupport.DockerAvailable() {
|
||||||
|
t.Skip("Docker provider is unavailable in this environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
db := newTenantHandlerSeedDeleteDB(t)
|
||||||
|
if err := db.AutoMigrate(&domain.KetoOutbox{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate outbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSvc := new(MockTenantService)
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
realOutbox := repository.NewKetoOutboxRepository(db)
|
||||||
|
|
||||||
|
h := &TenantHandler{
|
||||||
|
DB: db,
|
||||||
|
Service: mockSvc,
|
||||||
|
Keto: mockKeto,
|
||||||
|
KetoOutbox: realOutbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := "user-system-1"
|
||||||
|
|
||||||
|
t.Run("ListSystemRelations - Returns correct system relations", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/system/relations", h.ListSystemRelations)
|
||||||
|
|
||||||
|
mockKeto.On("ListRelations", mock.Anything, "System", "system", "", "").Return([]service.RelationTuple{
|
||||||
|
{
|
||||||
|
Namespace: "System",
|
||||||
|
Object: "system",
|
||||||
|
Relation: "ory_ssot_viewers",
|
||||||
|
SubjectID: "User:" + userID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Namespace: "System",
|
||||||
|
Object: "system",
|
||||||
|
Relation: "audit_logs_viewers",
|
||||||
|
SubjectID: "User:" + userID,
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/system/relations", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Items []struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Relations []string `json:"relations"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, got.Items, 1)
|
||||||
|
assert.Equal(t, userID, got.Items[0].UserID)
|
||||||
|
assert.Contains(t, got.Items[0].Relations, "ory_ssot_viewers")
|
||||||
|
assert.Contains(t, got.Items[0].Relations, "audit_logs_viewers")
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddSystemRelation - Inserts into KetoOutbox DB table with System namespace", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/system/relations", h.AddSystemRelation)
|
||||||
|
|
||||||
|
mockKeto.On("ListRelations", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once()
|
||||||
|
mockKeto.On("CreateRelation", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return(nil).Once()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"userId": userID,
|
||||||
|
"relation": "ory_ssot_viewers",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/system/relations", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var outboxEntries []domain.KetoOutbox
|
||||||
|
if err := db.Where("object = ? AND relation = ? AND action = ?", "system", "ory_ssot_viewers", domain.KetoOutboxActionCreate).Find(&outboxEntries).Error; err != nil {
|
||||||
|
t.Fatalf("failed to query outbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, outboxEntries, 1)
|
||||||
|
assert.Equal(t, "System", outboxEntries[0].Namespace)
|
||||||
|
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
|
||||||
|
assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -234,7 +234,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
|||||||
slog.Warn("Skipping local user sync during AddMember because identity read is unavailable", "user", userID, "error", identityErr)
|
slog.Warn("Skipping local user sync during AddMember because identity read is unavailable", "user", userID, "error", identityErr)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
slog.Warn("Skipping local user sync during AddMember because identity projection is unavailable", "user", userID, "error", err)
|
slog.Warn("Skipping local user sync during AddMember because identity read is unavailable", "user", userID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if localUser != nil {
|
if localUser != nil {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
|
||||||
|
[msg]
|
||||||
|
|
||||||
|
[msg.admin]
|
||||||
|
|
||||||
|
[msg.admin.audit]
|
||||||
|
subtitle = "View administrator activity history."
|
||||||
|
|
||||||
[msg.common]
|
[msg.common]
|
||||||
loading_more = "Loading more logs..."
|
|
||||||
copied = "Copied."
|
copied = "Copied."
|
||||||
error = "Error"
|
error = "Error"
|
||||||
forbidden = "Access denied."
|
forbidden = "Access denied."
|
||||||
loading = "Loading..."
|
loading = "Loading..."
|
||||||
no_results = "No results found."
|
loading_more = "Loading more logs..."
|
||||||
no_description = "No Description."
|
no_description = "No Description."
|
||||||
|
no_results = "No results found."
|
||||||
parsing = "Parsing data..."
|
parsing = "Parsing data..."
|
||||||
requesting = "Requesting..."
|
requesting = "Requesting..."
|
||||||
saving = "Saving..."
|
saving = "Saving..."
|
||||||
@@ -20,20 +28,38 @@ loading = "Loading audit logs..."
|
|||||||
[msg.common.audit.registry]
|
[msg.common.audit.registry]
|
||||||
count = "{{count}} logs"
|
count = "{{count}} logs"
|
||||||
|
|
||||||
[msg.admin.audit]
|
[msg.dev]
|
||||||
subtitle = "View administrator activity history."
|
|
||||||
|
|
||||||
[msg.dev.audit]
|
[msg.dev.audit]
|
||||||
subtitle = "View developer activity history within the current app scope."
|
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]
|
[ui.common]
|
||||||
no_results = "No results to display."
|
action = "Action"
|
||||||
apply = "Apply"
|
|
||||||
actions = "Actions"
|
actions = "Actions"
|
||||||
add = "Add"
|
add = "Add"
|
||||||
all = "All"
|
|
||||||
apply = "Apply"
|
|
||||||
admin_only = "Admin Only"
|
admin_only = "Admin Only"
|
||||||
|
all = "All"
|
||||||
apply = "Apply"
|
apply = "Apply"
|
||||||
approve = "Approve"
|
approve = "Approve"
|
||||||
assign = "Assign"
|
assign = "Assign"
|
||||||
@@ -61,42 +87,45 @@ export_with_ids = "Include UUID"
|
|||||||
export_without_ids = "Export without UUID"
|
export_without_ids = "Export without UUID"
|
||||||
fail = "Fail"
|
fail = "Fail"
|
||||||
go_home = "Go Home"
|
go_home = "Go Home"
|
||||||
info = "Info"
|
|
||||||
view = "View"
|
|
||||||
hyphen = "-"
|
hyphen = "-"
|
||||||
|
info = "Info"
|
||||||
|
language = "Language"
|
||||||
|
language_en = "English"
|
||||||
|
language_ko = "Korean"
|
||||||
|
load_more = "Load more"
|
||||||
loading = "Loading..."
|
loading = "Loading..."
|
||||||
manage = "Manage"
|
manage = "Manage"
|
||||||
move = "Move"
|
move = "Move"
|
||||||
move_org = "Move to another organization"
|
move_org = "Move to another organization"
|
||||||
na = "N/A"
|
na = "N/A"
|
||||||
|
name = "Name"
|
||||||
never = "Never"
|
never = "Never"
|
||||||
next = "Next"
|
next = "Next"
|
||||||
|
no_results = "No results to display."
|
||||||
none = "None"
|
none = "None"
|
||||||
page_of = "Page {{page}} of {{total}}"
|
page_of = "Page {{page}} of {{total}}"
|
||||||
prev = "Prev"
|
prev = "Prev"
|
||||||
previous = "Previous"
|
previous = "Previous"
|
||||||
qr = "QR"
|
qr = "QR"
|
||||||
reject = "Reject"
|
read = "Read"
|
||||||
rejected = "Rejected"
|
|
||||||
reset = "Reset"
|
|
||||||
read_only = "Read Only"
|
read_only = "Read Only"
|
||||||
refresh = "Refresh"
|
refresh = "Refresh"
|
||||||
|
reject = "Reject"
|
||||||
|
rejected = "Rejected"
|
||||||
remove = "Remove"
|
remove = "Remove"
|
||||||
remove_org = "Remove from organization"
|
remove_org = "Remove from organization"
|
||||||
resend = "Resend"
|
resend = "Resend"
|
||||||
|
reset = "Reset"
|
||||||
retry = "Retry"
|
retry = "Retry"
|
||||||
row = "Row"
|
row = "Row"
|
||||||
save = "Save"
|
save = "Save"
|
||||||
search = "Search"
|
search = "Search"
|
||||||
search_group = "Search groups..."
|
search_group = "Search groups..."
|
||||||
|
searching = "Searching..."
|
||||||
select = "Select"
|
select = "Select"
|
||||||
select_file = "Select File"
|
select_file = "Select File"
|
||||||
select_placeholder = "Select Placeholder"
|
select_placeholder = "Select Placeholder"
|
||||||
load_more = "Load more"
|
|
||||||
show_more = "Show More"
|
show_more = "Show More"
|
||||||
language = "Language"
|
|
||||||
language_ko = "Korean"
|
|
||||||
language_en = "English"
|
|
||||||
submit = "Submit"
|
submit = "Submit"
|
||||||
submitting = "Submitting..."
|
submitting = "Submitting..."
|
||||||
success = "Success"
|
success = "Success"
|
||||||
@@ -105,6 +134,8 @@ theme_light = "Light"
|
|||||||
theme_toggle = "Theme Toggle"
|
theme_toggle = "Theme Toggle"
|
||||||
unassigned = "Unassigned"
|
unassigned = "Unassigned"
|
||||||
unknown = "Unknown"
|
unknown = "Unknown"
|
||||||
|
view = "View"
|
||||||
|
write = "Write"
|
||||||
|
|
||||||
[ui.common.audit]
|
[ui.common.audit]
|
||||||
load_more = "Load more"
|
load_more = "Load more"
|
||||||
@@ -114,12 +145,6 @@ title = "Audit Logs"
|
|||||||
actor_id = "Copy User ID"
|
actor_id = "Copy User ID"
|
||||||
target = "Copy Client 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]
|
[ui.common.audit.details]
|
||||||
actor = "User ID"
|
actor = "User ID"
|
||||||
actor_id = "User ID · {{value}}"
|
actor_id = "User ID · {{value}}"
|
||||||
@@ -135,24 +160,38 @@ path = "Path · {{value}}"
|
|||||||
request = "Request"
|
request = "Request"
|
||||||
request_id = "Request ID · {{value}}"
|
request_id = "Request ID · {{value}}"
|
||||||
result = "Result"
|
result = "Result"
|
||||||
tenant = "Tenant · {{value}}"
|
|
||||||
target = "Client ID · {{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]
|
[ui.common.audit.registry]
|
||||||
title = "Audit registry"
|
title = "Audit registry"
|
||||||
|
|
||||||
[ui.common.audit.table]
|
[ui.common.audit.table]
|
||||||
no_logs = "No logs to display."
|
|
||||||
action = "Action"
|
action = "Action"
|
||||||
actor = "User ID"
|
actor = "User ID"
|
||||||
client_id = "Client ID"
|
client_id = "Client ID"
|
||||||
user_id = "User ID"
|
no_logs = "No logs to display."
|
||||||
status = "Status"
|
status = "Status"
|
||||||
target = "Client ID"
|
target = "Client ID"
|
||||||
time = "Time"
|
time = "Time"
|
||||||
|
user_id = "User ID"
|
||||||
|
|
||||||
[ui.common.overview]
|
[ui.common.badge]
|
||||||
title = "Operational Status"
|
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]
|
[ui.common.chart.period]
|
||||||
day = "Day"
|
day = "Day"
|
||||||
@@ -162,29 +201,12 @@ week = "Week"
|
|||||||
[ui.common.chart.series_summary]
|
[ui.common.chart.series_summary]
|
||||||
login_users = "Login {{login}} / Users {{subjects}}"
|
login_users = "Login {{login}} / Users {{subjects}}"
|
||||||
|
|
||||||
[ui.common.chart.axis]
|
[ui.common.custom_claim_permission]
|
||||||
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]
|
|
||||||
admin_only = "Admin only"
|
admin_only = "Admin only"
|
||||||
command_only = "Command only"
|
user_and_admin = "User and admin"
|
||||||
system = "System"
|
|
||||||
|
[ui.common.overview]
|
||||||
|
title = "Operational Status"
|
||||||
|
|
||||||
[ui.common.status]
|
[ui.common.status]
|
||||||
active = "Active"
|
active = "Active"
|
||||||
@@ -197,10 +219,3 @@ pending = "Pending"
|
|||||||
success = "Success"
|
success = "Success"
|
||||||
unchanged = "Unchanged"
|
unchanged = "Unchanged"
|
||||||
updated = "Updated"
|
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]
|
[msg.common]
|
||||||
loading_more = "추가 로그를 불러오는 중..."
|
|
||||||
copied = "복사되었습니다."
|
copied = "복사되었습니다."
|
||||||
error = "오류가 발생했습니다."
|
error = "오류가 발생했습니다."
|
||||||
forbidden = "접근 권한이 없습니다."
|
forbidden = "접근 권한이 없습니다."
|
||||||
loading = "로딩 중..."
|
loading = "로딩 중..."
|
||||||
no_results = "검색 결과가 없습니다."
|
loading_more = "추가 로그를 불러오는 중..."
|
||||||
no_description = "설명이 없습니다."
|
no_description = "설명이 없습니다."
|
||||||
|
no_results = "검색 결과가 없습니다."
|
||||||
parsing = "데이터 파싱 중..."
|
parsing = "데이터 파싱 중..."
|
||||||
requesting = "요청 중..."
|
requesting = "요청 중..."
|
||||||
saving = "저장 중..."
|
saving = "저장 중..."
|
||||||
@@ -20,20 +28,38 @@ loading = "Loading audit logs..."
|
|||||||
[msg.common.audit.registry]
|
[msg.common.audit.registry]
|
||||||
count = "총 {{count}}개 로그"
|
count = "총 {{count}}개 로그"
|
||||||
|
|
||||||
[msg.admin.audit]
|
[msg.dev]
|
||||||
subtitle = "관리자 작업 이력을 조회합니다."
|
|
||||||
|
|
||||||
[msg.dev.audit]
|
[msg.dev.audit]
|
||||||
subtitle = "현재 앱 범위의 개발자 작업 이력을 조회합니다."
|
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]
|
[ui.common]
|
||||||
no_results = "표시할 결과가 없습니다."
|
action = "작업"
|
||||||
apply = "적용"
|
|
||||||
actions = "액션"
|
actions = "액션"
|
||||||
add = "추가"
|
add = "추가"
|
||||||
all = "전체"
|
|
||||||
apply = "적용"
|
|
||||||
admin_only = "관리자 전용"
|
admin_only = "관리자 전용"
|
||||||
|
all = "전체"
|
||||||
apply = "적용"
|
apply = "적용"
|
||||||
approve = "승인"
|
approve = "승인"
|
||||||
assign = "할당"
|
assign = "할당"
|
||||||
@@ -61,42 +87,45 @@ export_with_ids = "UUID 포함"
|
|||||||
export_without_ids = "UUID 제외 내보내기"
|
export_without_ids = "UUID 제외 내보내기"
|
||||||
fail = "실패"
|
fail = "실패"
|
||||||
go_home = "홈으로"
|
go_home = "홈으로"
|
||||||
info = "상세 안내"
|
|
||||||
view = "보기"
|
|
||||||
hyphen = "-"
|
hyphen = "-"
|
||||||
|
info = "상세 안내"
|
||||||
|
language = "언어"
|
||||||
|
language_en = "English"
|
||||||
|
language_ko = "한국어"
|
||||||
|
load_more = "더 보기"
|
||||||
loading = "로딩 중..."
|
loading = "로딩 중..."
|
||||||
manage = "관리"
|
manage = "관리"
|
||||||
move = "이동"
|
move = "이동"
|
||||||
move_org = "타 조직으로 이동"
|
move_org = "타 조직으로 이동"
|
||||||
na = "N/A"
|
na = "N/A"
|
||||||
|
name = "이름"
|
||||||
never = "Never"
|
never = "Never"
|
||||||
next = "다음"
|
next = "다음"
|
||||||
|
no_results = "표시할 결과가 없습니다."
|
||||||
none = "없음"
|
none = "없음"
|
||||||
page_of = "Page {{page}} of {{total}}"
|
page_of = "Page {{page}} of {{total}}"
|
||||||
prev = "이전"
|
prev = "이전"
|
||||||
previous = "이전"
|
previous = "이전"
|
||||||
qr = "QR"
|
qr = "QR"
|
||||||
reject = "반려"
|
read = "조회 가능 (Read)"
|
||||||
rejected = "반려됨"
|
|
||||||
reset = "초기화"
|
|
||||||
read_only = "읽기 전용"
|
read_only = "읽기 전용"
|
||||||
refresh = "새로고침"
|
refresh = "새로고침"
|
||||||
|
reject = "반려"
|
||||||
|
rejected = "반려됨"
|
||||||
remove = "제외"
|
remove = "제외"
|
||||||
remove_org = "조직에서 제외"
|
remove_org = "조직에서 제외"
|
||||||
resend = "재발송"
|
resend = "재발송"
|
||||||
|
reset = "초기화"
|
||||||
retry = "다시 시도"
|
retry = "다시 시도"
|
||||||
row = "행"
|
row = "행"
|
||||||
save = "저장"
|
save = "저장"
|
||||||
search = "검색"
|
search = "검색"
|
||||||
search_group = "그룹 검색..."
|
search_group = "그룹 검색..."
|
||||||
|
searching = "검색 중..."
|
||||||
select = "선택"
|
select = "선택"
|
||||||
select_file = "파일 선택"
|
select_file = "파일 선택"
|
||||||
select_placeholder = "선택하세요"
|
select_placeholder = "선택하세요"
|
||||||
load_more = "더 보기"
|
|
||||||
show_more = "+ 더보기"
|
show_more = "+ 더보기"
|
||||||
language = "언어"
|
|
||||||
language_ko = "한국어"
|
|
||||||
language_en = "English"
|
|
||||||
submit = "신청하기"
|
submit = "신청하기"
|
||||||
submitting = "제출 중..."
|
submitting = "제출 중..."
|
||||||
success = "성공"
|
success = "성공"
|
||||||
@@ -105,6 +134,8 @@ theme_light = "Light"
|
|||||||
theme_toggle = "테마 전환"
|
theme_toggle = "테마 전환"
|
||||||
unassigned = "미배정"
|
unassigned = "미배정"
|
||||||
unknown = "Unknown"
|
unknown = "Unknown"
|
||||||
|
view = "보기"
|
||||||
|
write = "수정 가능 (Write)"
|
||||||
|
|
||||||
[ui.common.audit]
|
[ui.common.audit]
|
||||||
load_more = "더 보기"
|
load_more = "더 보기"
|
||||||
@@ -114,12 +145,6 @@ title = "감사 로그"
|
|||||||
actor_id = "사용자 ID 복사"
|
actor_id = "사용자 ID 복사"
|
||||||
target = "클라이언트 ID 복사"
|
target = "클라이언트 ID 복사"
|
||||||
|
|
||||||
[ui.common.audit.filters]
|
|
||||||
user_id = "사용자 ID로 검색"
|
|
||||||
client_id = "클라이언트 ID로 검색"
|
|
||||||
action = "액션으로 검색 (예: ROTATE_SECRET)"
|
|
||||||
status_all = "전체 상태"
|
|
||||||
|
|
||||||
[ui.common.audit.details]
|
[ui.common.audit.details]
|
||||||
actor = "사용자 ID"
|
actor = "사용자 ID"
|
||||||
actor_id = "사용자 ID · {{value}}"
|
actor_id = "사용자 ID · {{value}}"
|
||||||
@@ -135,24 +160,38 @@ path = "Path · {{value}}"
|
|||||||
request = "Request"
|
request = "Request"
|
||||||
request_id = "Request ID · {{value}}"
|
request_id = "Request ID · {{value}}"
|
||||||
result = "Result"
|
result = "Result"
|
||||||
tenant = "Tenant · {{value}}"
|
|
||||||
target = "클라이언트 ID · {{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]
|
[ui.common.audit.registry]
|
||||||
title = "감사 로그 레지스트리"
|
title = "감사 로그 레지스트리"
|
||||||
|
|
||||||
[ui.common.audit.table]
|
[ui.common.audit.table]
|
||||||
no_logs = "표시할 로그가 없습니다."
|
|
||||||
action = "작업"
|
action = "작업"
|
||||||
actor = "사용자 ID"
|
actor = "사용자 ID"
|
||||||
client_id = "클라이언트 ID"
|
client_id = "클라이언트 ID"
|
||||||
user_id = "사용자 ID"
|
no_logs = "표시할 로그가 없습니다."
|
||||||
status = "상태"
|
status = "상태"
|
||||||
target = "클라이언트 ID"
|
target = "클라이언트 ID"
|
||||||
time = "시간"
|
time = "시간"
|
||||||
|
user_id = "사용자 ID"
|
||||||
|
|
||||||
[ui.common.overview]
|
[ui.common.badge]
|
||||||
title = "운영 현황"
|
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]
|
[ui.common.chart.period]
|
||||||
day = "일"
|
day = "일"
|
||||||
@@ -162,29 +201,12 @@ week = "주"
|
|||||||
[ui.common.chart.series_summary]
|
[ui.common.chart.series_summary]
|
||||||
login_users = "로그인 {{login}} / 사용자 {{subjects}}"
|
login_users = "로그인 {{login}} / 사용자 {{subjects}}"
|
||||||
|
|
||||||
[ui.common.chart.axis]
|
[ui.common.custom_claim_permission]
|
||||||
x = "X축: 기간"
|
admin_only = "관리자만 가능"
|
||||||
y = "Y축: 로그인 요청 수"
|
user_and_admin = "사용자와 관리자"
|
||||||
|
|
||||||
[ui.admin.integrity]
|
[ui.common.overview]
|
||||||
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
|
title = "운영 현황"
|
||||||
|
|
||||||
[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.status]
|
[ui.common.status]
|
||||||
active = "활성"
|
active = "활성"
|
||||||
@@ -197,10 +219,3 @@ pending = "준비 중"
|
|||||||
success = "성공"
|
success = "성공"
|
||||||
unchanged = "동일"
|
unchanged = "동일"
|
||||||
updated = "수정"
|
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]
|
[msg.common]
|
||||||
loading_more = ""
|
|
||||||
copied = ""
|
copied = ""
|
||||||
error = ""
|
error = ""
|
||||||
forbidden = ""
|
forbidden = ""
|
||||||
loading = ""
|
loading = ""
|
||||||
no_results = ""
|
loading_more = ""
|
||||||
no_description = ""
|
no_description = ""
|
||||||
|
no_results = ""
|
||||||
parsing = ""
|
parsing = ""
|
||||||
requesting = ""
|
requesting = ""
|
||||||
saving = ""
|
saving = ""
|
||||||
@@ -20,20 +28,38 @@ loading = ""
|
|||||||
[msg.common.audit.registry]
|
[msg.common.audit.registry]
|
||||||
count = ""
|
count = ""
|
||||||
|
|
||||||
[msg.admin.audit]
|
[msg.dev]
|
||||||
subtitle = ""
|
|
||||||
|
|
||||||
[msg.dev.audit]
|
[msg.dev.audit]
|
||||||
subtitle = ""
|
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]
|
[ui.common]
|
||||||
no_results = ""
|
action = ""
|
||||||
apply = "Apply"
|
|
||||||
actions = ""
|
actions = ""
|
||||||
add = ""
|
add = ""
|
||||||
all = ""
|
|
||||||
apply = ""
|
|
||||||
admin_only = ""
|
admin_only = ""
|
||||||
|
all = ""
|
||||||
apply = ""
|
apply = ""
|
||||||
approve = ""
|
approve = ""
|
||||||
assign = ""
|
assign = ""
|
||||||
@@ -61,42 +87,45 @@ export_with_ids = ""
|
|||||||
export_without_ids = ""
|
export_without_ids = ""
|
||||||
fail = ""
|
fail = ""
|
||||||
go_home = ""
|
go_home = ""
|
||||||
info = ""
|
|
||||||
view = ""
|
|
||||||
hyphen = ""
|
hyphen = ""
|
||||||
|
info = ""
|
||||||
|
language = ""
|
||||||
|
language_en = ""
|
||||||
|
language_ko = ""
|
||||||
|
load_more = ""
|
||||||
loading = ""
|
loading = ""
|
||||||
manage = ""
|
manage = ""
|
||||||
move = ""
|
move = ""
|
||||||
move_org = ""
|
move_org = ""
|
||||||
na = ""
|
na = ""
|
||||||
|
name = ""
|
||||||
never = ""
|
never = ""
|
||||||
next = ""
|
next = ""
|
||||||
|
no_results = ""
|
||||||
none = ""
|
none = ""
|
||||||
page_of = ""
|
page_of = ""
|
||||||
prev = ""
|
prev = ""
|
||||||
previous = ""
|
previous = ""
|
||||||
qr = ""
|
qr = ""
|
||||||
reject = ""
|
read = ""
|
||||||
rejected = ""
|
|
||||||
reset = ""
|
|
||||||
read_only = ""
|
read_only = ""
|
||||||
refresh = ""
|
refresh = ""
|
||||||
|
reject = ""
|
||||||
|
rejected = ""
|
||||||
remove = ""
|
remove = ""
|
||||||
remove_org = ""
|
remove_org = ""
|
||||||
resend = ""
|
resend = ""
|
||||||
|
reset = ""
|
||||||
retry = ""
|
retry = ""
|
||||||
row = ""
|
row = ""
|
||||||
save = ""
|
save = ""
|
||||||
search = ""
|
search = ""
|
||||||
search_group = ""
|
search_group = ""
|
||||||
|
searching = ""
|
||||||
select = ""
|
select = ""
|
||||||
select_file = ""
|
select_file = ""
|
||||||
select_placeholder = ""
|
select_placeholder = ""
|
||||||
load_more = ""
|
|
||||||
show_more = ""
|
show_more = ""
|
||||||
language = ""
|
|
||||||
language_ko = ""
|
|
||||||
language_en = ""
|
|
||||||
submit = ""
|
submit = ""
|
||||||
submitting = ""
|
submitting = ""
|
||||||
success = ""
|
success = ""
|
||||||
@@ -105,6 +134,8 @@ theme_light = ""
|
|||||||
theme_toggle = ""
|
theme_toggle = ""
|
||||||
unassigned = ""
|
unassigned = ""
|
||||||
unknown = ""
|
unknown = ""
|
||||||
|
view = ""
|
||||||
|
write = ""
|
||||||
|
|
||||||
[ui.common.audit]
|
[ui.common.audit]
|
||||||
load_more = ""
|
load_more = ""
|
||||||
@@ -114,12 +145,6 @@ title = ""
|
|||||||
actor_id = ""
|
actor_id = ""
|
||||||
target = ""
|
target = ""
|
||||||
|
|
||||||
[ui.common.audit.filters]
|
|
||||||
user_id = ""
|
|
||||||
client_id = ""
|
|
||||||
action = ""
|
|
||||||
status_all = ""
|
|
||||||
|
|
||||||
[ui.common.audit.details]
|
[ui.common.audit.details]
|
||||||
actor = ""
|
actor = ""
|
||||||
actor_id = ""
|
actor_id = ""
|
||||||
@@ -135,24 +160,38 @@ path = ""
|
|||||||
request = ""
|
request = ""
|
||||||
request_id = ""
|
request_id = ""
|
||||||
result = ""
|
result = ""
|
||||||
tenant = ""
|
|
||||||
target = ""
|
target = ""
|
||||||
|
tenant = ""
|
||||||
|
|
||||||
|
[ui.common.audit.filters]
|
||||||
|
action = ""
|
||||||
|
client_id = ""
|
||||||
|
status_all = ""
|
||||||
|
user_id = ""
|
||||||
|
|
||||||
[ui.common.audit.registry]
|
[ui.common.audit.registry]
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.common.audit.table]
|
[ui.common.audit.table]
|
||||||
no_logs = ""
|
|
||||||
action = ""
|
action = ""
|
||||||
actor = ""
|
actor = ""
|
||||||
client_id = ""
|
client_id = ""
|
||||||
user_id = ""
|
no_logs = ""
|
||||||
status = ""
|
status = ""
|
||||||
target = ""
|
target = ""
|
||||||
time = ""
|
time = ""
|
||||||
|
user_id = ""
|
||||||
|
|
||||||
[ui.common.overview]
|
[ui.common.badge]
|
||||||
title = ""
|
admin_only = ""
|
||||||
|
command_only = ""
|
||||||
|
system = ""
|
||||||
|
|
||||||
|
[ui.common.chart]
|
||||||
|
|
||||||
|
[ui.common.chart.axis]
|
||||||
|
x = ""
|
||||||
|
y = ""
|
||||||
|
|
||||||
[ui.common.chart.period]
|
[ui.common.chart.period]
|
||||||
day = ""
|
day = ""
|
||||||
@@ -162,29 +201,12 @@ week = ""
|
|||||||
[ui.common.chart.series_summary]
|
[ui.common.chart.series_summary]
|
||||||
login_users = ""
|
login_users = ""
|
||||||
|
|
||||||
[ui.common.chart.axis]
|
[ui.common.custom_claim_permission]
|
||||||
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]
|
|
||||||
admin_only = ""
|
admin_only = ""
|
||||||
command_only = ""
|
user_and_admin = ""
|
||||||
system = ""
|
|
||||||
|
[ui.common.overview]
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.common.status]
|
[ui.common.status]
|
||||||
active = ""
|
active = ""
|
||||||
@@ -197,10 +219,3 @@ pending = ""
|
|||||||
success = ""
|
success = ""
|
||||||
unchanged = ""
|
unchanged = ""
|
||||||
updated = ""
|
updated = ""
|
||||||
|
|
||||||
[ui.common]
|
|
||||||
searching = ""
|
|
||||||
|
|
||||||
[ui.common.custom_claim_permission]
|
|
||||||
admin_only = ""
|
|
||||||
user_and_admin = ""
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 805 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 815 KiB |
@@ -12,6 +12,7 @@
|
|||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
|
"test:ci": "pnpm test",
|
||||||
"test:coverage": "vitest run --coverage --bail 1",
|
"test:coverage": "vitest run --coverage --bail 1",
|
||||||
"test:unit": "vitest run --bail 1",
|
"test:unit": "vitest run --bail 1",
|
||||||
"test:roles": "playwright test tests/devfront-role-switch-report.spec.ts",
|
"test:roles": "playwright test tests/devfront-role-switch-report.spec.ts",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { act } from "react";
|
import { act, useEffect } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import AppLayout from "./AppLayout";
|
import AppLayout from "./AppLayout";
|
||||||
|
|
||||||
@@ -49,6 +49,24 @@ vi.mock("../../lib/i18n", () => ({
|
|||||||
|
|
||||||
const roots: Root[] = [];
|
const roots: Root[] = [];
|
||||||
|
|
||||||
|
type TestWindow = Window & {
|
||||||
|
__baronNavigate?: (to: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RouteProbe() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(window as TestWindow).__baronNavigate = navigate;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
delete (window as TestWindow).__baronNavigate;
|
||||||
|
};
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
return <div>Client outlet</div>;
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authState.isAuthenticated = true;
|
authState.isAuthenticated = true;
|
||||||
authState.isLoading = false;
|
authState.isLoading = false;
|
||||||
@@ -89,7 +107,7 @@ async function renderLayout(initialEntry = "/clients") {
|
|||||||
<MemoryRouter initialEntries={[initialEntry]}>
|
<MemoryRouter initialEntries={[initialEntry]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppLayout />}>
|
<Route path="/" element={<AppLayout />}>
|
||||||
<Route path="clients" element={<div>Client outlet</div>} />
|
<Route path="clients" element={<RouteProbe />} />
|
||||||
<Route path="profile" element={<div>Profile outlet</div>} />
|
<Route path="profile" element={<div>Profile outlet</div>} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -181,4 +199,15 @@ describe("devfront AppLayout", () => {
|
|||||||
|
|
||||||
expect(authState.signinSilent).toHaveBeenCalled();
|
expect(authState.signinSilent).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attempts silent renewal when route changes and the session is expiring", async () => {
|
||||||
|
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
|
||||||
|
await renderLayout();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
(window as TestWindow).__baronNavigate?.("/profile");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authState.signinSilent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -201,4 +201,85 @@ describe("ClientConsentsPage RP custom claims", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps date claim inputs and timezone selectors on the same row", async () => {
|
||||||
|
fetchClientMock.mockResolvedValue({
|
||||||
|
...clientDetail,
|
||||||
|
client: {
|
||||||
|
...clientDetail.client,
|
||||||
|
metadata: {
|
||||||
|
id_token_claims: [
|
||||||
|
{
|
||||||
|
namespace: "rp_claims",
|
||||||
|
key: "contract_date",
|
||||||
|
value: "",
|
||||||
|
valueType: "date",
|
||||||
|
readPermission: "admin_only",
|
||||||
|
writePermission: "admin_only",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fetchConsentsMock.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
subject: "user-1",
|
||||||
|
userName: "Consent User",
|
||||||
|
clientId: "client-a",
|
||||||
|
clientName: "Claims App",
|
||||||
|
grantedScopes: ["openid", "profile"],
|
||||||
|
authenticatedAt: "2026-06-11T09:00:00Z",
|
||||||
|
createdAt: "2026-06-10T09:00:00Z",
|
||||||
|
status: "active",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
tenantName: "Hanmac",
|
||||||
|
rpMetadata: {
|
||||||
|
contract_date: 1781017200,
|
||||||
|
contract_date_permissions: {
|
||||||
|
readPermission: "admin_only",
|
||||||
|
writePermission: "admin_only",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fetchRPUserMetadataMock.mockResolvedValue({
|
||||||
|
clientId: "client-a",
|
||||||
|
userId: "user-1",
|
||||||
|
metadata: {
|
||||||
|
contract_date: 1781017200,
|
||||||
|
contract_date_permissions: {
|
||||||
|
readPermission: "admin_only",
|
||||||
|
writePermission: "admin_only",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = await renderPage();
|
||||||
|
|
||||||
|
const editButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) =>
|
||||||
|
button.textContent?.includes("사용자 Claim 설정") ||
|
||||||
|
button.textContent?.includes("User Claim Settings"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const dateInput = container.querySelector(
|
||||||
|
'input[aria-label="contract_date date"]',
|
||||||
|
);
|
||||||
|
const timeZoneSelect = container.querySelector(
|
||||||
|
'select[aria-label="contract_date timezone"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dateInput).not.toBeNull();
|
||||||
|
expect(timeZoneSelect).not.toBeNull();
|
||||||
|
expect(dateInput?.parentElement).toBe(timeZoneSelect?.parentElement);
|
||||||
|
expect(dateInput?.parentElement?.className).toContain("items-center");
|
||||||
|
expect(dateInput?.parentElement?.className).not.toContain("flex-col");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1060,7 +1060,14 @@ function ClientConsentsPage() {
|
|||||||
aria-label={`${row.key} ${row.valueType}`}
|
aria-label={`${row.key} ${row.valueType}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex gap-2",
|
||||||
|
row.valueType === "date" || row.valueType === "datetime"
|
||||||
|
? "items-center"
|
||||||
|
: "flex-col",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
type={rpClaimInputType(row.valueType)}
|
type={rpClaimInputType(row.valueType)}
|
||||||
inputMode={rpClaimInputMode(row.valueType)}
|
inputMode={rpClaimInputMode(row.valueType)}
|
||||||
@@ -1087,7 +1094,7 @@ function ClientConsentsPage() {
|
|||||||
timeZone: event.target.value,
|
timeZone: event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="h-10 rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="h-10 min-w-[160px] rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
aria-label={`${row.key} timezone`}
|
aria-label={`${row.key} timezone`}
|
||||||
>
|
>
|
||||||
{timeZoneOptions.map((timeZone) => (
|
{timeZoneOptions.map((timeZone) => (
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ describe("ClientGeneralPage RP claims", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows supported scopes and custom claims without integrated offline_access from the add scope button", async () => {
|
it("shows supported scopes including offline_access and custom claims from the add scope button", async () => {
|
||||||
const { container } = await renderPage();
|
const { container } = await renderPage();
|
||||||
|
|
||||||
const addScopeButton = Array.from(
|
const addScopeButton = Array.from(
|
||||||
@@ -422,7 +422,7 @@ describe("ClientGeneralPage RP claims", () => {
|
|||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
expect(container.textContent).not.toContain("offline_access");
|
expect(container.textContent).toContain("offline_access");
|
||||||
expect(container.textContent).toContain("old_claim");
|
expect(container.textContent).toContain("old_claim");
|
||||||
|
|
||||||
const customClaimButton = Array.from(
|
const customClaimButton = Array.from(
|
||||||
|
|||||||
@@ -759,6 +759,15 @@ function ClientGeneralPage() {
|
|||||||
description: tenantScopeDescription,
|
description: tenantScopeDescription,
|
||||||
source: "standard",
|
source: "standard",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "standard-offline-access",
|
||||||
|
name: "offline_access",
|
||||||
|
description: t(
|
||||||
|
"msg.dev.clients.scopes.offline_access",
|
||||||
|
"refresh token 발급 요청",
|
||||||
|
),
|
||||||
|
source: "standard",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[tenantScopeDescription],
|
[tenantScopeDescription],
|
||||||
);
|
);
|
||||||
@@ -2789,6 +2798,7 @@ function ClientGeneralPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
key={claim.valueType}
|
||||||
type={claimDefaultInputType(claim.valueType)}
|
type={claimDefaultInputType(claim.valueType)}
|
||||||
inputMode={claimDefaultInputMode(
|
inputMode={claimDefaultInputMode(
|
||||||
claim.valueType,
|
claim.valueType,
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ test.describe("DevFront RP claim cache", () => {
|
|||||||
await expect(claimKeyInput).toHaveValue("new_claim");
|
await expect(claimKeyInput).toHaveValue("new_claim");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adds supported scopes and custom claim keys from the scope picker without offline_access", async ({
|
test("adds supported scopes and custom claim keys from the scope picker including offline_access", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const state = {
|
const state = {
|
||||||
@@ -142,9 +142,9 @@ test.describe("DevFront RP claim cache", () => {
|
|||||||
.getByRole("button", { name: /스코프 추가|Scope 추가|Add Scope/i })
|
.getByRole("button", { name: /스코프 추가|Scope 추가|Add Scope/i })
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect(page.getByText("offline_access", { exact: true })).toHaveCount(
|
await expect(
|
||||||
0,
|
page.getByText("offline_access", { exact: true }),
|
||||||
);
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: /employee_code/ }),
|
page.getByRole("button", { name: /employee_code/ }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -328,7 +328,11 @@ test.describe("DevFront RP claim cache", () => {
|
|||||||
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
|
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
|
||||||
.first()
|
.first()
|
||||||
.fill("3.14");
|
.fill("3.14");
|
||||||
|
const responsePromise = page.waitForResponse(
|
||||||
|
"**/api/v1/dev/clients/client-claims",
|
||||||
|
);
|
||||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||||
|
await responsePromise;
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -357,13 +361,7 @@ test.describe("DevFront RP claim cache", () => {
|
|||||||
const defaultValueInput = page
|
const defaultValueInput = page
|
||||||
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
|
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
|
||||||
.first();
|
.first();
|
||||||
await expect(defaultValueInput).toHaveAttribute("inputmode", "numeric");
|
|
||||||
await expect(defaultValueInput).toHaveAttribute("pattern", "-?[0-9]*");
|
|
||||||
await defaultValueInput.fill("3.14");
|
await defaultValueInput.fill("3.14");
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: /^저장$|^Save$/i }),
|
page.getByRole("button", { name: /^저장$|^Save$/i }),
|
||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
|
|||||||
239
devfront/tests/devfront-login-claims.spec.ts
Normal file
239
devfront/tests/devfront-login-claims.spec.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
getPersistedOidcUser,
|
||||||
|
installDevApiMock,
|
||||||
|
seedAuth,
|
||||||
|
} from "./helpers/devfront-fixtures";
|
||||||
|
import { captureEvidence } from "./helpers/evidence";
|
||||||
|
|
||||||
|
type ClaimScenario = {
|
||||||
|
title: string;
|
||||||
|
role: "super_admin" | "user";
|
||||||
|
tenantName: string;
|
||||||
|
userMeTenantId: string;
|
||||||
|
userMeCompanyCode: string;
|
||||||
|
profileClaims: Record<string, unknown>;
|
||||||
|
expectedProfileAssertions: Record<string, unknown>;
|
||||||
|
expectTenantsToBeAbsent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const claimScenarios: ClaimScenario[] = [
|
||||||
|
{
|
||||||
|
title: "Server Side App preserves tenant and rp claims",
|
||||||
|
role: "super_admin",
|
||||||
|
tenantName: "Server Side Tenant",
|
||||||
|
userMeTenantId: "tenant-server",
|
||||||
|
userMeCompanyCode: "server-hq",
|
||||||
|
profileClaims: {
|
||||||
|
tenant_id: "tenant-server",
|
||||||
|
companyCode: "server-hq",
|
||||||
|
profile: {
|
||||||
|
names: {
|
||||||
|
name: "서버 앱 사용자",
|
||||||
|
},
|
||||||
|
emails: ["server@example.com"],
|
||||||
|
},
|
||||||
|
joined_tenants: ["tenant-server", "tenant-ops"],
|
||||||
|
tenants: {
|
||||||
|
"tenant-server": {
|
||||||
|
department: "Platform",
|
||||||
|
grade: "Lead",
|
||||||
|
},
|
||||||
|
"tenant-ops": {
|
||||||
|
department: "Operations",
|
||||||
|
grade: "Member",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rp_claims: {
|
||||||
|
approvalLevel: "A",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
rp_custom_claims: {
|
||||||
|
"server-app": {
|
||||||
|
approvalLevel: "A",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedProfileAssertions: {
|
||||||
|
tenant_id: "tenant-server",
|
||||||
|
companyCode: "server-hq",
|
||||||
|
joined_tenants: ["tenant-server", "tenant-ops"],
|
||||||
|
rp_claims: {
|
||||||
|
approvalLevel: "A",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "PKCE preserves nested profile claims without tenant map expansion",
|
||||||
|
role: "user",
|
||||||
|
tenantName: "PKCE Tenant",
|
||||||
|
userMeTenantId: "tenant-pkce",
|
||||||
|
userMeCompanyCode: "pkce-hq",
|
||||||
|
profileClaims: {
|
||||||
|
tenant_id: "tenant-pkce",
|
||||||
|
companyCode: "pkce-hq",
|
||||||
|
profile: {
|
||||||
|
names: {
|
||||||
|
name: "PKCE 사용자",
|
||||||
|
},
|
||||||
|
emails: ["pkce@example.com"],
|
||||||
|
},
|
||||||
|
joined_tenants: ["tenant-pkce"],
|
||||||
|
rp_claims: {
|
||||||
|
features: ["sso", "claims"],
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
rp_custom_claims: {
|
||||||
|
"pkce-app": {
|
||||||
|
features: ["sso", "claims"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedProfileAssertions: {
|
||||||
|
tenant_id: "tenant-pkce",
|
||||||
|
companyCode: "pkce-hq",
|
||||||
|
joined_tenants: ["tenant-pkce"],
|
||||||
|
rp_claims: {
|
||||||
|
features: ["sso", "claims"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectTenantsToBeAbsent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Headless login keeps session claims together with rp claims",
|
||||||
|
role: "super_admin",
|
||||||
|
tenantName: "Headless Tenant",
|
||||||
|
userMeTenantId: "tenant-headless",
|
||||||
|
userMeCompanyCode: "headless-hq",
|
||||||
|
profileClaims: {
|
||||||
|
tenant_id: "tenant-headless",
|
||||||
|
companyCode: "headless-hq",
|
||||||
|
profile: {
|
||||||
|
names: {
|
||||||
|
name: "헤드리스 사용자",
|
||||||
|
},
|
||||||
|
emails: ["headless@example.com"],
|
||||||
|
},
|
||||||
|
joined_tenants: ["tenant-headless", "tenant-support"],
|
||||||
|
tenants: {
|
||||||
|
"tenant-headless": {
|
||||||
|
department: "Automation",
|
||||||
|
grade: "Manager",
|
||||||
|
},
|
||||||
|
"tenant-support": {
|
||||||
|
department: "Support",
|
||||||
|
grade: "Agent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rp_claims: {
|
||||||
|
approvalLevel: "B",
|
||||||
|
loginMode: "headless",
|
||||||
|
},
|
||||||
|
sid: "session-headless-1",
|
||||||
|
session_id: "session-headless-1",
|
||||||
|
metadata: {
|
||||||
|
rp_custom_claims: {
|
||||||
|
"headless-app": {
|
||||||
|
approvalLevel: "B",
|
||||||
|
loginMode: "headless",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedProfileAssertions: {
|
||||||
|
tenant_id: "tenant-headless",
|
||||||
|
companyCode: "headless-hq",
|
||||||
|
joined_tenants: ["tenant-headless", "tenant-support"],
|
||||||
|
rp_claims: {
|
||||||
|
approvalLevel: "B",
|
||||||
|
loginMode: "headless",
|
||||||
|
},
|
||||||
|
sid: "session-headless-1",
|
||||||
|
session_id: "session-headless-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe("DevFront login claims", () => {
|
||||||
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.status === "passed") {
|
||||||
|
await captureEvidence(page, testInfo, testInfo.title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const scenario of claimScenarios) {
|
||||||
|
test(scenario.title, async ({ page }) => {
|
||||||
|
await seedAuth(page, {
|
||||||
|
role: scenario.role,
|
||||||
|
profile: scenario.profileClaims,
|
||||||
|
});
|
||||||
|
|
||||||
|
await installDevApiMock(page, {
|
||||||
|
clients: [],
|
||||||
|
consents: [],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
users: [],
|
||||||
|
tenants: [
|
||||||
|
{
|
||||||
|
id: scenario.userMeTenantId,
|
||||||
|
name: scenario.tenantName,
|
||||||
|
slug: scenario.userMeCompanyCode,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/v1/user/me", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: "playwright-user",
|
||||||
|
loginId: "playwright@example.com",
|
||||||
|
email: "playwright@example.com",
|
||||||
|
name: "Playwright User",
|
||||||
|
phoneNumber: "",
|
||||||
|
department: "QA",
|
||||||
|
tenantId: "",
|
||||||
|
tenantName: "",
|
||||||
|
role: scenario.role,
|
||||||
|
createdAt: "2026-06-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/profile");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "내 정보" }),
|
||||||
|
).toBeVisible();
|
||||||
|
const storedUser = await getPersistedOidcUser(page);
|
||||||
|
expect(storedUser).not.toBeNull();
|
||||||
|
expect(storedUser?.profile).toMatchObject(
|
||||||
|
scenario.expectedProfileAssertions,
|
||||||
|
);
|
||||||
|
if (scenario.expectTenantsToBeAbsent) {
|
||||||
|
expect(storedUser?.profile).not.toHaveProperty("tenants");
|
||||||
|
} else {
|
||||||
|
expect(storedUser?.profile).toHaveProperty("tenants");
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
page.getByText(String(scenario.profileClaims.tenant_id)),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText(scenario.userMeCompanyCode)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "권한 및 역할" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "시스템 역할" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
scenario.role === "super_admin"
|
||||||
|
? /^(시스템 관리자|Super Admin|SUPER_ADMIN)$/i
|
||||||
|
: /^(일반 사용자|General User|USER)$/i,
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -73,6 +73,22 @@ export type DeveloperRequest = {
|
|||||||
adminNotes?: string; // 추가
|
adminNotes?: string; // 추가
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SeedAuthOptions = {
|
||||||
|
role?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
idToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
sessionState?: string;
|
||||||
|
expiresInSeconds?: number;
|
||||||
|
state?: Record<string, unknown>;
|
||||||
|
profile?: Record<string, unknown>;
|
||||||
|
tenantId?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ClientRelation = {
|
export type ClientRelation = {
|
||||||
relation: string;
|
relation: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -148,30 +164,100 @@ export function makeClient(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedAuth(page: Page, role?: string) {
|
function resolveSeedAuthOptions(
|
||||||
|
roleOrOptions?: string | SeedAuthOptions,
|
||||||
|
): Required<Pick<SeedAuthOptions, "role">> & SeedAuthOptions {
|
||||||
|
if (typeof roleOrOptions === "string") {
|
||||||
|
return { role: roleOrOptions };
|
||||||
|
}
|
||||||
|
return { role: roleOrOptions?.role ?? "super_admin", ...roleOrOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPersistedOidcUser(page: Page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const storage = window.localStorage;
|
||||||
|
for (let index = 0; index < storage.length; index += 1) {
|
||||||
|
const key = storage.key(index);
|
||||||
|
if (
|
||||||
|
key === null ||
|
||||||
|
!key.startsWith("oidc.user:") ||
|
||||||
|
!key.endsWith(":devfront")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawValue = storage.getItem(key);
|
||||||
|
if (!rawValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawValue) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedAuth(
|
||||||
|
page: Page,
|
||||||
|
roleOrOptions?: string | SeedAuthOptions,
|
||||||
|
) {
|
||||||
|
const options = resolveSeedAuthOptions(roleOrOptions);
|
||||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||||
seededRoles.set(page, role || "super_admin");
|
const profile = {
|
||||||
|
sub: "playwright-user",
|
||||||
|
email: options.email ?? "playwright@example.com",
|
||||||
|
name: options.name ?? "Playwright User",
|
||||||
|
phone: options.phone ?? "",
|
||||||
|
role: options.profile?.role ?? options.role,
|
||||||
|
tenant_id: options.tenantId ?? "tenant-a",
|
||||||
|
companyCode: options.companyCode ?? "tenant-a",
|
||||||
|
...options.profile,
|
||||||
|
};
|
||||||
|
seededRoles.set(
|
||||||
|
page,
|
||||||
|
typeof profile.role === "string" ? profile.role : options.role,
|
||||||
|
);
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ issuedAt, injectedRole }) => {
|
({
|
||||||
|
issuedAt,
|
||||||
|
injectedRole,
|
||||||
|
injectedProfile,
|
||||||
|
injectedState,
|
||||||
|
injectedIdToken,
|
||||||
|
injectedAccessToken,
|
||||||
|
injectedRefreshToken,
|
||||||
|
injectedSessionState,
|
||||||
|
injectedExpiresInSeconds,
|
||||||
|
}) => {
|
||||||
(
|
(
|
||||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||||
)._IS_TEST_MODE = true;
|
)._IS_TEST_MODE = true;
|
||||||
|
|
||||||
const mockOidcUser = {
|
const mockOidcUser = {
|
||||||
id_token: "playwright-id-token",
|
id_token: injectedIdToken,
|
||||||
session_state: "playwright-session",
|
session_state: injectedSessionState,
|
||||||
access_token: "playwright-access-token",
|
access_token: injectedAccessToken,
|
||||||
refresh_token: "playwright-refresh-token",
|
refresh_token: injectedRefreshToken,
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
scope: "openid profile email",
|
scope: "openid profile email",
|
||||||
profile: {
|
profile: {
|
||||||
sub: "playwright-user",
|
sub: "playwright-user",
|
||||||
email: "playwright@example.com",
|
email: "playwright@example.com",
|
||||||
name: "Playwright User",
|
name: "Playwright User",
|
||||||
...(injectedRole ? { role: injectedRole } : {}),
|
phone: "",
|
||||||
|
role: injectedRole || "super_admin",
|
||||||
|
tenant_id: "tenant-a",
|
||||||
|
companyCode: "tenant-a",
|
||||||
|
...(injectedProfile || {}),
|
||||||
},
|
},
|
||||||
expires_at: issuedAt + 3600,
|
state: injectedState,
|
||||||
|
expires_at: issuedAt + injectedExpiresInSeconds,
|
||||||
};
|
};
|
||||||
|
|
||||||
const storageKeys = [
|
const storageKeys = [
|
||||||
@@ -191,9 +277,25 @@ export async function seedAuth(page: Page, role?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.localStorage.setItem("dev_role", injectedRole || "super_admin");
|
window.localStorage.setItem("dev_role", injectedRole || "super_admin");
|
||||||
window.localStorage.setItem("dev_tenant_id", "tenant-a");
|
window.localStorage.setItem(
|
||||||
|
"dev_tenant_id",
|
||||||
|
typeof injectedProfile.tenant_id === "string"
|
||||||
|
? injectedProfile.tenant_id
|
||||||
|
: "tenant-a",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
issuedAt: nowInSeconds,
|
||||||
|
injectedRole:
|
||||||
|
typeof profile.role === "string" ? profile.role : options.role,
|
||||||
|
injectedProfile: profile,
|
||||||
|
injectedState: options.state ?? { returnTo: "/clients" },
|
||||||
|
injectedIdToken: options.idToken ?? "playwright-id-token",
|
||||||
|
injectedAccessToken: options.accessToken ?? "playwright-access-token",
|
||||||
|
injectedRefreshToken: options.refreshToken ?? "playwright-refresh-token",
|
||||||
|
injectedSessionState: options.sessionState ?? "playwright-session",
|
||||||
|
injectedExpiresInSeconds: options.expiresInSeconds ?? 3600,
|
||||||
},
|
},
|
||||||
{ issuedAt: nowInSeconds, injectedRole: role ?? "" },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.route("**/oidc/**", async (route) => {
|
await page.route("**/oidc/**", async (route) => {
|
||||||
|
|||||||
@@ -6,11 +6,126 @@ class System implements Namespace {
|
|||||||
related: {
|
related: {
|
||||||
super_admins: User[]
|
super_admins: User[]
|
||||||
authenticated_users: User[]
|
authenticated_users: User[]
|
||||||
|
|
||||||
|
// 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 - 조회(Read)
|
||||||
|
overview_viewers: User[]
|
||||||
|
tenants_viewers: User[]
|
||||||
|
org_chart_viewers: User[]
|
||||||
|
worksmobile_viewers: User[]
|
||||||
|
ory_ssot_viewers: User[]
|
||||||
|
data_integrity_viewers: User[]
|
||||||
|
users_viewers: User[]
|
||||||
|
permissions_direct_viewers: User[]
|
||||||
|
auth_guard_viewers: User[]
|
||||||
|
api_keys_viewers: User[]
|
||||||
|
audit_logs_viewers: User[]
|
||||||
|
|
||||||
|
// 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 - 수정(Write)
|
||||||
|
overview_managers: User[]
|
||||||
|
tenants_managers: User[]
|
||||||
|
org_chart_managers: User[]
|
||||||
|
worksmobile_managers: User[]
|
||||||
|
ory_ssot_managers: User[]
|
||||||
|
data_integrity_managers: User[]
|
||||||
|
users_managers: User[]
|
||||||
|
permissions_direct_managers: User[]
|
||||||
|
auth_guard_managers: User[]
|
||||||
|
api_keys_managers: User[]
|
||||||
|
audit_logs_managers: User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
permits = {
|
permits = {
|
||||||
manage_all: (ctx: Context): boolean =>
|
manage_all: (ctx: Context): boolean =>
|
||||||
this.related.super_admins.includes(ctx.subject)
|
this.related.super_admins.includes(ctx.subject),
|
||||||
|
|
||||||
|
// 🌟 글로벌 메뉴 허가 규칙 (Permit Rules) - 조회(access_)와 수정(manage_) 완전 분리 이원화
|
||||||
|
access_overview: (ctx: Context): boolean =>
|
||||||
|
this.related.overview_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_overview(ctx),
|
||||||
|
|
||||||
|
manage_overview: (ctx: Context): boolean =>
|
||||||
|
this.related.overview_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
|
access_tenants: (ctx: Context): boolean =>
|
||||||
|
this.related.tenants_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_tenants(ctx),
|
||||||
|
|
||||||
|
manage_tenants: (ctx: Context): boolean =>
|
||||||
|
this.related.tenants_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
|
access_org_chart: (ctx: Context): boolean =>
|
||||||
|
this.related.org_chart_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_org_chart(ctx),
|
||||||
|
|
||||||
|
manage_org_chart: (ctx: Context): boolean =>
|
||||||
|
this.related.org_chart_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
|
access_worksmobile: (ctx: Context): boolean =>
|
||||||
|
this.related.worksmobile_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_worksmobile(ctx),
|
||||||
|
|
||||||
|
manage_worksmobile: (ctx: Context): boolean =>
|
||||||
|
this.related.worksmobile_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
|
access_ory_ssot: (ctx: Context): boolean =>
|
||||||
|
this.related.ory_ssot_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_ory_ssot(ctx),
|
||||||
|
|
||||||
|
manage_ory_ssot: (ctx: Context): boolean =>
|
||||||
|
this.related.ory_ssot_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
|
access_data_integrity: (ctx: Context): boolean =>
|
||||||
|
this.related.data_integrity_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_data_integrity(ctx),
|
||||||
|
|
||||||
|
manage_data_integrity: (ctx: Context): boolean =>
|
||||||
|
this.related.data_integrity_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
|
access_users: (ctx: Context): boolean =>
|
||||||
|
this.related.users_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_users(ctx),
|
||||||
|
|
||||||
|
manage_users: (ctx: Context): boolean =>
|
||||||
|
this.related.users_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
|
access_permissions_direct: (ctx: Context): boolean =>
|
||||||
|
this.related.permissions_direct_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_permissions_direct(ctx),
|
||||||
|
|
||||||
|
manage_permissions_direct: (ctx: Context): boolean =>
|
||||||
|
this.related.permissions_direct_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
|
access_auth_guard: (ctx: Context): boolean =>
|
||||||
|
this.related.auth_guard_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_auth_guard(ctx),
|
||||||
|
|
||||||
|
manage_auth_guard: (ctx: Context): boolean =>
|
||||||
|
this.related.auth_guard_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
|
access_api_keys: (ctx: Context): boolean =>
|
||||||
|
this.related.api_keys_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_api_keys(ctx),
|
||||||
|
|
||||||
|
manage_api_keys: (ctx: Context): boolean =>
|
||||||
|
this.related.api_keys_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
|
access_audit_logs: (ctx: Context): boolean =>
|
||||||
|
this.related.audit_logs_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_audit_logs(ctx),
|
||||||
|
|
||||||
|
manage_audit_logs: (ctx: Context): boolean =>
|
||||||
|
this.related.audit_logs_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_all(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,9 +137,63 @@ class Tenant implements Namespace {
|
|||||||
parents: Tenant[]
|
parents: Tenant[]
|
||||||
developer_console_viewer: (User | SubjectSet<System, "super_admins">)[]
|
developer_console_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||||
developer_console_grant_manager: (User | SubjectSet<System, "super_admins">)[]
|
developer_console_grant_manager: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
// 🌟 신규 직접 관계 (Direct Relations) 정의
|
||||||
|
profile_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
profile_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
permissions_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
permissions_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
organization_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
organization_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
schema_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
schema_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
permits = {
|
permits = {
|
||||||
|
// 1. 프로필 (Profile) 탭 허가 규칙
|
||||||
|
view_profile: (ctx: Context): boolean =>
|
||||||
|
this.related.profile_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_profile(ctx) ||
|
||||||
|
this.permits.view(ctx), // 멤버/관리자/소유자는 기본 조회 가능
|
||||||
|
|
||||||
|
manage_profile: (ctx: Context): boolean =>
|
||||||
|
this.related.profile_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx), // 관리자/소유자는 기본 수정 가능
|
||||||
|
|
||||||
|
// 2. 권한 관리 (Permissions) 탭 허가 규칙
|
||||||
|
view_permissions: (ctx: Context): boolean =>
|
||||||
|
this.related.permissions_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_permissions(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_permissions: (ctx: Context): boolean =>
|
||||||
|
this.related.permissions_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_admins(ctx), // 소유자는 기본 관리 가능
|
||||||
|
|
||||||
|
// 3. 조직 관리 (Organization) 탭 허가 규칙
|
||||||
|
view_organization: (ctx: Context): boolean =>
|
||||||
|
this.related.organization_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_organization(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_organization: (ctx: Context): boolean =>
|
||||||
|
this.related.organization_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx),
|
||||||
|
|
||||||
|
// 4. 사용자 스키마 (Schema) 탭 허가 규칙
|
||||||
|
view_schema: (ctx: Context): boolean =>
|
||||||
|
this.related.schema_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_schema(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_schema: (ctx: Context): boolean =>
|
||||||
|
this.related.schema_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx),
|
||||||
|
|
||||||
|
// --- 기존 마스터 및 상속 규칙 보존 ---
|
||||||
view: (ctx: Context): boolean =>
|
view: (ctx: Context): boolean =>
|
||||||
this.related.members.includes(ctx.subject) ||
|
this.related.members.includes(ctx.subject) ||
|
||||||
this.related.admins.includes(ctx.subject) ||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
|
|||||||
181
docs/adminfront-tab-level-direct-permission-design.md
Normal file
181
docs/adminfront-tab-level-direct-permission-design.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# [RFC/Design] adminfront: 각 탭별 ReBAC 기반 세부 권한 직접 부여 기능 설계
|
||||||
|
|
||||||
|
## 1. 배경 및 목적
|
||||||
|
|
||||||
|
현재 `adminfront` 테넌트 상세 페이지는 대략적인 역할 기반 제어(Coarse-grained RBAC/ReBAC) 형태로만 동작합니다.
|
||||||
|
운영자는 사용자를 **"소유자(Owner)"** 또는 **"테넌트 관리자(Admin)"**로만 임명할 수 있으며, 이 역할에 의해 테넌트 하위의 4개 탭(프로필, 권한 관리, 조직 관리, 사용자 스키마)의 읽기/쓰기 권한이 통째로 결정됩니다.
|
||||||
|
|
||||||
|
하지만 더욱 세밀한 운영 권한 관리가 필요하다는 비즈니스 요구사항에 따라, **"사용자 A에게는 조직 관리 및 스키마 읽기 권한만 부여"**, **"사용자 B에게는 스키마 수정 권한만 부여"**와 같이 탭 레벨에서 세분화된(Fine-grained) 권한을 직접 지정할 수 있는 기능을 신설합니다.
|
||||||
|
|
||||||
|
이 설계는 `devfront`에서 이슈 #1029를 통해 구현 완료한 **"RP 세부 관계 직접 부여"** 철학과 완벽히 동일하며, Ory Keto(ReBAC) 및 아웃박스 정합성 엔진을 관통하여 설계됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 세부 설계 사양
|
||||||
|
|
||||||
|
### 2.1 Ory Keto OPL 스키마 변경 (`docker/ory/keto/namespaces.ts`)
|
||||||
|
|
||||||
|
`Tenant` 네임스페이스 하위에 각 탭별 읽기(`_viewers`)와 쓰기(`_managers`)를 결정하는 **물리적인 직접 관계(Direct Relations)**를 추가합니다.
|
||||||
|
기존 `members`, `admins`, `owners`에 의한 상속 허가 식(Permits)을 유지하여 하위 호환성 및 기존 관리체계의 안정성을 완벽히 보장합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class Tenant implements Namespace {
|
||||||
|
related: {
|
||||||
|
owners: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
admins: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
members: (User | SubjectSet<System, "super_admins"> | SubjectSet<Tenant, "admins"> | SubjectSet<Tenant, "owners">)[]
|
||||||
|
parents: Tenant[]
|
||||||
|
developer_console_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
developer_console_grant_manager: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
// 🌟 신규 직접 관계 (Direct Relations) 정의
|
||||||
|
profile_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
profile_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
permissions_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
permissions_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
organization_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
organization_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
schema_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
schema_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
permits = {
|
||||||
|
// 1. 프로필 (Profile) 탭 허가 규칙
|
||||||
|
view_profile: (ctx: Context): boolean =>
|
||||||
|
this.related.profile_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_profile(ctx) ||
|
||||||
|
this.permits.view(ctx), // 멤버/관리자/소유자는 기본 조회 가능
|
||||||
|
|
||||||
|
manage_profile: (ctx: Context): boolean =>
|
||||||
|
this.related.profile_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx), // 관리자/소유자는 기본 수정 가능
|
||||||
|
|
||||||
|
// 2. 권한 관리 (Permissions) 탭 허가 규칙
|
||||||
|
view_permissions: (ctx: Context): boolean =>
|
||||||
|
this.related.permissions_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_permissions(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_permissions: (ctx: Context): boolean =>
|
||||||
|
this.related.permissions_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_admins(ctx), // 소유자는 기본 관리 가능
|
||||||
|
|
||||||
|
// 3. 조직 관리 (Organization) 탭 허가 규칙
|
||||||
|
view_organization: (ctx: Context): boolean =>
|
||||||
|
this.related.organization_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_organization(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_organization: (ctx: Context): boolean =>
|
||||||
|
this.related.organization_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx),
|
||||||
|
|
||||||
|
// 4. 사용자 스키마 (Schema) 탭 허가 규칙
|
||||||
|
view_schema: (ctx: Context): boolean =>
|
||||||
|
this.related.schema_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_schema(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_schema: (ctx: Context): boolean =>
|
||||||
|
this.related.schema_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx),
|
||||||
|
|
||||||
|
// --- 기존 마스터 및 상속 규칙 보존 ---
|
||||||
|
view: (ctx: Context): boolean =>
|
||||||
|
this.related.members.includes(ctx.subject) ||
|
||||||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
|
this.related.owners.includes(ctx.subject) ||
|
||||||
|
this.related.parents.traverse((p) => p.permits.view(ctx)),
|
||||||
|
|
||||||
|
manage: (ctx: Context): boolean =>
|
||||||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
|
this.related.owners.includes(ctx.subject) ||
|
||||||
|
this.related.parents.traverse((p) => p.permits.manage(ctx)),
|
||||||
|
|
||||||
|
manage_admins: (ctx: Context): boolean =>
|
||||||
|
this.related.owners.includes(ctx.subject) ||
|
||||||
|
this.related.parents.traverse((p) => p.permits.manage_admins(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 백엔드 API 설계 (`backend/internal/handler/tenant_handler.go`)
|
||||||
|
|
||||||
|
세부 권한 부여/회수 API는 해당 테넌트의 최상위 권한 관리자만 수행할 수 있도록 **`Tenant#manage_admins`** 허가 규칙으로 강력하게 인가 보호합니다.
|
||||||
|
|
||||||
|
#### A. 세부 권한 관계 전체 조회 API
|
||||||
|
* **Endpoint**: `GET /api/v1/admin/tenants/:id/relations`
|
||||||
|
* **인가 필터**: `RequireKetoPermission(config, "Tenant", "manage_admins")`
|
||||||
|
* **반환 DTO**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"userId": "00000000-0000-0000-0000-000000000010",
|
||||||
|
"name": "홍길동",
|
||||||
|
"email": "kildong@hmac.kr",
|
||||||
|
"relations": ["profile_managers", "schema_viewers"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. 세부 권한 관계 부여 API
|
||||||
|
* **Endpoint**: `POST /api/v1/admin/tenants/:id/relations`
|
||||||
|
* **인가 필터**: `RequireKetoPermission(config, "Tenant", "manage_admins")`
|
||||||
|
* **Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "00000000-0000-0000-0000-000000000010",
|
||||||
|
"relation": "profile_managers"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **동작**: 트랜잭셔널 아웃박스에 적재하여 Keto에 `Tenant:<ID>#profile_managers@User:<UserID>` 튜플 반영.
|
||||||
|
|
||||||
|
#### C. 세부 권한 관계 회수 API
|
||||||
|
* **Endpoint**: `DELETE /api/v1/admin/tenants/:id/relations`
|
||||||
|
* **인가 필터**: `RequireKetoPermission(config, "Tenant", "manage_admins")`
|
||||||
|
* **Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "00000000-0000-0000-0000-000000000010",
|
||||||
|
"relation": "profile_managers"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **동작**: 트랜잭셔널 아웃박스에 적재하여 Keto 내 튜플 삭제 반영.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 프론트엔드 UI 설계
|
||||||
|
|
||||||
|
사용자에게 역할(Role) 외에 세부적인 설정을 직관적으로 관리할 수 있도록, 기존 **"권한 관리"** 탭 하단에 **"세부 권한 설정 (Fine-grained Permissions)"** 섹션을 신설합니다.
|
||||||
|
|
||||||
|
#### A. 구성 요소
|
||||||
|
1. **유저 검색/추가 패널**: 테넌트 소속 사용자를 검색하여 격리 설정 테이블(Matrix)에 추가합니다.
|
||||||
|
2. **세부 권한 격리 매트릭스 (Matrix Table)**:
|
||||||
|
* 컬럼: `이름` | `이메일` | `테넌트 프로필` | `권한 관리` | `조직 관리` | `사용자 스키마` | `작업`
|
||||||
|
* 각 탭 컬럼은 드롭다운 셀렉트 박스로 채워집니다:
|
||||||
|
* **`권한 없음 (None)`** / **`조회 가능 (Read)`** / **`수정 가능 (Write)`**
|
||||||
|
3. **상태 동기화 연동**:
|
||||||
|
* 셀렉트 박스에서 `조회 가능(Read)` 선택 시: `_viewers` 관계 추가(`POST`) & `_managers` 관계 회수(`DELETE`).
|
||||||
|
* 셀렉트 박스에서 `수정 가능(Write)` 선택 시: `_managers` 관계 추가(`POST`) & `_viewers` 관계 회수(`DELETE`).
|
||||||
|
* 셀렉트 박스에서 `권한 없음(None)` 선택 시: 둘 다 회수(`DELETE`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 작업 계획 및 테스트 전략
|
||||||
|
|
||||||
|
1. **OPL 컴파일 및 빌드 검증**:
|
||||||
|
* namespaces.ts 수정 후 Keto OPL 테스트를 구동하여 컴파일 문법에 문제가 없는지 사전 검증합니다.
|
||||||
|
2. **백엔드 구현 및 DB 연동**:
|
||||||
|
* `tenant_handler.go`에 신규 핸들러 추가 후 gg/gorm 아웃박스 통합을 완료합니다.
|
||||||
|
3. **프론트엔드 연동 및 Matrix UI 개발**:
|
||||||
|
* `TenantAdminsAndOwnersTab.tsx` 하단부 카드에 매트릭스 테이블 영역을 추가합니다.
|
||||||
|
4. **유형 및 단위 테스트**:
|
||||||
|
* 신설된 REST API 명세를 테스트하는 고성능 백엔드 단위 테스트를 작성합니다.
|
||||||
|
* 프론트엔드에서 체크박스 변경 시 올바른 릴레이션이 트리거되는지 검증하는 Vitest 렌더 테스트를 작성합니다.
|
||||||
1296
locales/en.toml
1296
locales/en.toml
File diff suppressed because it is too large
Load Diff
1782
locales/ko.toml
1782
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(']]')) {
|
if (line.startsWith('[[') && line.endsWith(']]')) {
|
||||||
const sectionName = line.slice(2, -2).trim();
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line.startsWith('[') && line.endsWith(']')) {
|
if (line.startsWith('[') && line.endsWith(']')) {
|
||||||
const sectionName = line.slice(1, -1).trim();
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +106,8 @@ function parseTomlKeys(filePath) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.startsWith('"') && key.endsWith('"')) {
|
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
|
||||||
key = key.slice(1, -1);
|
key = key.slice(1, -1).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullKey = [...currentSection, key].join('.');
|
const fullKey = [...currentSection, key].join('.');
|
||||||
|
|||||||
@@ -5,10 +5,42 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const LOCALES_DIR = path.join(ROOT, 'locales');
|
|
||||||
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
|
const LOCALE_SPECS = [
|
||||||
const KO_PATH = path.join(LOCALES_DIR, 'ko.toml');
|
{
|
||||||
const EN_PATH = path.join(LOCALES_DIR, 'en.toml');
|
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([
|
const SKIP_DIRS = new Set([
|
||||||
'.git',
|
'.git',
|
||||||
@@ -53,18 +85,33 @@ function parseToml(filePath) {
|
|||||||
if (!line || line.startsWith('#')) continue;
|
if (!line || line.startsWith('#')) continue;
|
||||||
if (line.startsWith('[[') && line.endsWith(']]')) {
|
if (line.startsWith('[[') && line.endsWith(']]')) {
|
||||||
const name = line.slice(2, -2).trim();
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (line.startsWith('[') && line.endsWith(']')) {
|
if (line.startsWith('[') && line.endsWith(']')) {
|
||||||
const name = line.slice(1, -1).trim();
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const eqIndex = line.indexOf('=');
|
const eqIndex = line.indexOf('=');
|
||||||
if (eqIndex === -1) continue;
|
if (eqIndex === -1) continue;
|
||||||
const key = line.slice(0, eqIndex).trim();
|
let key = line.slice(0, eqIndex).trim();
|
||||||
if (!key) continue;
|
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 valueRaw = line.slice(eqIndex + 1).trim();
|
||||||
let value = '';
|
let value = '';
|
||||||
if (
|
if (
|
||||||
@@ -88,12 +135,20 @@ function buildTree(keys, valuesMap) {
|
|||||||
let node = root;
|
let node = root;
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
const part = parts[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];
|
node = node[part];
|
||||||
}
|
}
|
||||||
const leaf = parts[parts.length - 1];
|
const leaf = parts[parts.length - 1];
|
||||||
const value = valuesMap ? (valuesMap.get(key) ?? '') : '';
|
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;
|
return root;
|
||||||
}
|
}
|
||||||
@@ -105,12 +160,34 @@ function renderToml(tree) {
|
|||||||
lines.push(`[${path.join('.')}]`);
|
lines.push(`[${path.join('.')}]`);
|
||||||
}
|
}
|
||||||
const keys = Object.keys(node).sort();
|
const keys = Object.keys(node).sort();
|
||||||
const leafKeys = keys.filter((k) => typeof node[k] === 'string');
|
const leafKeys = [];
|
||||||
const childKeys = keys.filter((k) => typeof node[k] === 'object');
|
const childKeys = [];
|
||||||
for (const key of leafKeys) {
|
|
||||||
const value = node[key];
|
for (const key of keys) {
|
||||||
lines.push(`${key} = ${JSON.stringify(value)}`);
|
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) {
|
for (const key of childKeys) {
|
||||||
lines.push('');
|
lines.push('');
|
||||||
walk(node[key], [...path, key]);
|
walk(node[key], [...path, key]);
|
||||||
@@ -389,56 +466,68 @@ function keyToEnglish(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const templateMap = parseToml(TEMPLATE_PATH);
|
|
||||||
const koMap = parseToml(KO_PATH);
|
|
||||||
const enMap = parseToml(EN_PATH);
|
|
||||||
const fallbacks = extractFallbacks();
|
const fallbacks = extractFallbacks();
|
||||||
|
|
||||||
const allKeys = new Set([
|
for (const spec of LOCALE_SPECS) {
|
||||||
...templateMap.keys(),
|
const templatePath = path.join(spec.dir, spec.template);
|
||||||
...koMap.keys(),
|
const koPath = path.join(spec.dir, 'ko.toml');
|
||||||
...enMap.keys(),
|
const enPath = path.join(spec.dir, 'en.toml');
|
||||||
]);
|
|
||||||
|
|
||||||
for (const key of allKeys) {
|
const templateMap = parseToml(templatePath);
|
||||||
const fallback = fallbacks.get(key);
|
const koMap = parseToml(koPath);
|
||||||
const currentKo = koMap.get(key) ?? '';
|
const enMap = parseToml(enPath);
|
||||||
const currentEn = enMap.get(key) ?? '';
|
|
||||||
|
|
||||||
let nextKo = currentKo;
|
const ownedFallbackKeys = Array.from(fallbacks.keys()).filter(
|
||||||
if (!nextKo && fallback) {
|
(key) => spec.ownsKey(key) && !shouldIgnoreCodeKey(key)
|
||||||
nextKo = fallback;
|
);
|
||||||
}
|
|
||||||
if (!nextKo) {
|
|
||||||
nextKo = key;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextEn = currentEn;
|
const allKeys = new Set([
|
||||||
if (!nextEn) {
|
...templateMap.keys(),
|
||||||
const source = fallback || nextKo || key;
|
...koMap.keys(),
|
||||||
if (isLongText(source)) {
|
...enMap.keys(),
|
||||||
nextEn = source;
|
...ownedFallbackKeys,
|
||||||
} else if (isMostlyAscii(source)) {
|
]);
|
||||||
nextEn = source;
|
|
||||||
} else {
|
for (const key of allKeys) {
|
||||||
nextEn = translateKorean(source);
|
const fallback = fallbacks.get(key);
|
||||||
|
const currentKo = koMap.get(key) ?? '';
|
||||||
|
const currentEn = enMap.get(key) ?? '';
|
||||||
|
|
||||||
|
let nextKo = currentKo;
|
||||||
|
if (!nextKo && fallback) {
|
||||||
|
nextKo = fallback;
|
||||||
}
|
}
|
||||||
}
|
if (!nextKo) {
|
||||||
if (!nextEn) {
|
nextKo = key;
|
||||||
nextEn = key;
|
}
|
||||||
}
|
|
||||||
if (!isLongText(nextEn) && containsHangul(nextEn)) {
|
let nextEn = currentEn;
|
||||||
nextEn = keyToEnglish(key);
|
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);
|
const keys = Array.from(allKeys).sort();
|
||||||
enMap.set(key, nextEn);
|
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();
|
main();
|
||||||
|
|||||||
@@ -56,11 +56,14 @@ result = "Result: {value}"
|
|||||||
session_id = "Session ID: {value}"
|
session_id = "Session ID: {value}"
|
||||||
status = "Status: pending"
|
status = "Status: pending"
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = "Toggle to view only active sessions."
|
||||||
|
|
||||||
[msg.userfront.consent]
|
[msg.userfront.consent]
|
||||||
accept_error = "Failed to process consent: {error}"
|
accept_error = "Failed to process consent: {error}"
|
||||||
client_id = "Client ID: {id}"
|
client_id = "Client ID: {id}"
|
||||||
client_unknown = "Unknown application"
|
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}"
|
load_error = "Failed to load consent information: {error}"
|
||||||
missing_redirect = "Consent was processed, but the redirect URL was missing."
|
missing_redirect = "Consent was processed, but the redirect URL was missing."
|
||||||
redirect_notice = "After consent, you will be redirected automatically."
|
redirect_notice = "After consent, you will be redirected automatically."
|
||||||
@@ -82,15 +85,15 @@ approved_device = "Approved device: {device}"
|
|||||||
approved_ip = "Approved IP: {ip}"
|
approved_ip = "Approved IP: {ip}"
|
||||||
audit_empty = "No recent sign-in activity."
|
audit_empty = "No recent sign-in activity."
|
||||||
audit_load_error = "Could not load sign-in history."
|
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}"
|
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 = "Client ID: {id}"
|
||||||
client_id_missing = "No client ID available."
|
client_id_missing = "No client ID available."
|
||||||
current_status = "Current status: {status}"
|
current_status = "Current status: {status}"
|
||||||
last_auth = "Last signed in: {value}"
|
last_auth = "Last signed in: {value}"
|
||||||
link_status = "Link status: {status}"
|
|
||||||
link_missing = "This app does not have a launch URL configured."
|
link_missing = "This app does not have a launch URL configured."
|
||||||
link_open_error = "Could not open the app link."
|
link_open_error = "Could not open the app link."
|
||||||
|
link_status = "Link status: {status}"
|
||||||
render_error = "Dashboard render error: {error}"
|
render_error = "Dashboard render error: {error}"
|
||||||
session_id_copied = "Session ID copied."
|
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."
|
empty_detail = "Linked apps and their latest activity will appear here."
|
||||||
error = "Could not load linked apps."
|
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]
|
[msg.userfront.dashboard.sessions]
|
||||||
browser = "Browser: {value}"
|
browser = "Browser: {value}"
|
||||||
empty = "No active sessions."
|
empty = "No active sessions."
|
||||||
@@ -109,23 +125,10 @@ recent_app = "Recent app: {app}"
|
|||||||
session_id = "Session ID: {id}"
|
session_id = "Session ID: {id}"
|
||||||
|
|
||||||
[msg.userfront.dashboard.sessions.revoke]
|
[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}"
|
error = "Could not end the session: {error}"
|
||||||
success = "The session has been ended."
|
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]
|
[msg.userfront.dashboard.timeline]
|
||||||
load_error = "Could not load sign-in history."
|
load_error = "Could not load sign-in history."
|
||||||
|
|
||||||
@@ -139,22 +142,6 @@ title_generic = "An error occurred."
|
|||||||
title_with_code = "Error: {code}"
|
title_with_code = "Error: {code}"
|
||||||
type = "Error type: {type}"
|
type = "Error type: {type}"
|
||||||
|
|
||||||
[msg.userfront.error.tenant]
|
|
||||||
account = "Account"
|
|
||||||
account_unknown = "Unknown"
|
|
||||||
affiliated_tenants = "All affiliated tenants"
|
|
||||||
allowed_box_title = "Allowed tenants"
|
|
||||||
allowed_tenants = "Allowed tenants"
|
|
||||||
detail = "The currently signed-in account cannot access this application."
|
|
||||||
load_failed = "We could not confirm the account details. Please try again."
|
|
||||||
loading = "Loading the current account details."
|
|
||||||
lookup_fallback = "Some fields could not be verified because the access context was incomplete."
|
|
||||||
page_title = "Access to this application is restricted"
|
|
||||||
primary_tenant = "Primary affiliated tenant"
|
|
||||||
tenant = "Tenant"
|
|
||||||
tenant_unknown = "Unknown"
|
|
||||||
title = "Access restriction details"
|
|
||||||
|
|
||||||
[msg.userfront.error.ory]
|
[msg.userfront.error.ory]
|
||||||
"$normalizedCode" = "{error}"
|
"$normalizedCode" = "{error}"
|
||||||
access_denied = "The user denied the consent request."
|
access_denied = "The user denied the consent request."
|
||||||
@@ -171,6 +158,22 @@ temporarily_unavailable = "The authentication server is temporarily unavailable.
|
|||||||
unauthorized_client = "The client is not authorized for this request."
|
unauthorized_client = "The client is not authorized for this request."
|
||||||
unsupported_response_type = "The response type is not supported."
|
unsupported_response_type = "The response type is not supported."
|
||||||
|
|
||||||
|
[msg.userfront.error.tenant]
|
||||||
|
account = "Account"
|
||||||
|
account_unknown = "Unknown"
|
||||||
|
affiliated_tenants = "All affiliated tenants"
|
||||||
|
allowed_box_title = "Allowed tenants"
|
||||||
|
allowed_tenants = "Allowed tenants"
|
||||||
|
detail = "The currently signed-in account cannot access this application."
|
||||||
|
load_failed = "We could not confirm the account details. Please try again."
|
||||||
|
loading = "Loading the current account details."
|
||||||
|
lookup_fallback = "Some fields could not be verified because the access context was incomplete."
|
||||||
|
page_title = "Access to this application is restricted"
|
||||||
|
primary_tenant = "Primary affiliated tenant"
|
||||||
|
tenant = "Tenant"
|
||||||
|
tenant_unknown = "Unknown"
|
||||||
|
title = "Access restriction details"
|
||||||
|
|
||||||
[msg.userfront.error.whitelist]
|
[msg.userfront.error.whitelist]
|
||||||
"$normalizedCode" = "{error}"
|
"$normalizedCode" = "{error}"
|
||||||
bad_request = "Please check your input."
|
bad_request = "Please check your input."
|
||||||
@@ -226,14 +229,14 @@ scan_hint = "Scan it with the mobile app."
|
|||||||
invalid = "Enter the 2 letters and 6 digits from your code."
|
invalid = "Enter the 2 letters and 6 digits from your code."
|
||||||
|
|
||||||
[msg.userfront.login.unregistered]
|
[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]
|
[msg.userfront.login.verification]
|
||||||
approved = "Approved. Complete sign-in in the original window."
|
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_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."
|
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."
|
close_hint = "You can close this window now."
|
||||||
|
pending_remote = "Checking the sign-in approval request. Please wait."
|
||||||
success = "Sign-in approval completed."
|
success = "Sign-in approval completed."
|
||||||
|
|
||||||
[msg.userfront.login_success]
|
[msg.userfront.login_success]
|
||||||
@@ -465,6 +468,10 @@ dev_console = "Dev Console"
|
|||||||
|
|
||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = "Manage My Activity"
|
||||||
|
toggle_label = "Show active sessions only"
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
action = "Action"
|
action = "Action"
|
||||||
app = "App"
|
app = "App"
|
||||||
@@ -499,17 +506,6 @@ status_history = "Link details"
|
|||||||
[ui.userfront.dashboard.activity]
|
[ui.userfront.dashboard.activity]
|
||||||
linked = "Linked"
|
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]
|
[ui.userfront.dashboard.approved_session]
|
||||||
default = "Default"
|
default = "Default"
|
||||||
userfront = "Approved UserFront session ID"
|
userfront = "Approved UserFront session ID"
|
||||||
@@ -521,6 +517,17 @@ title = "Disconnect app"
|
|||||||
[ui.userfront.dashboard.scopes]
|
[ui.userfront.dashboard.scopes]
|
||||||
title = "Consent 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]
|
[ui.userfront.dashboard.status]
|
||||||
revoked = "Revoked"
|
revoked = "Revoked"
|
||||||
|
|
||||||
@@ -583,8 +590,8 @@ title = "Account not found"
|
|||||||
|
|
||||||
[ui.userfront.login.verification]
|
[ui.userfront.login.verification]
|
||||||
action_label = "Done"
|
action_label = "Done"
|
||||||
action_label_remote = "Go to sign-in window"
|
|
||||||
action_label_close = "Close Window"
|
action_label_close = "Close Window"
|
||||||
|
action_label_remote = "Go to sign-in window"
|
||||||
page_title = "Baron SW Portal"
|
page_title = "Baron SW Portal"
|
||||||
title = "Approval complete"
|
title = "Approval complete"
|
||||||
title_pending = "Checking approval"
|
title_pending = "Checking approval"
|
||||||
@@ -698,12 +705,3 @@ verify = "Verification"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Go to sign-in"
|
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]
|
[err.userfront.session]
|
||||||
missing = "활성 세션이 없습니다."
|
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]
|
[msg.userfront]
|
||||||
greeting = "안녕하세요, {name}님"
|
greeting = "안녕하세요, {name}님"
|
||||||
|
|
||||||
@@ -281,11 +56,14 @@ result = "인증결과: {value}"
|
|||||||
session_id = "Session ID: {value}"
|
session_id = "Session ID: {value}"
|
||||||
status = "현황: (준비중)"
|
status = "현황: (준비중)"
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||||
|
|
||||||
[msg.userfront.consent]
|
[msg.userfront.consent]
|
||||||
accept_error = "동의 처리에 실패했습니다: {error}"
|
accept_error = "동의 처리에 실패했습니다: {error}"
|
||||||
client_id = "클라이언트 ID: {id}"
|
client_id = "클라이언트 ID: {id}"
|
||||||
client_unknown = "알 수 없는 앱"
|
client_unknown = "알 수 없는 앱"
|
||||||
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\n계속 진행하려면 동의 여부를 선택해 주세요."
|
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\\\\\n계속 진행하려면 동의 여부를 선택해 주세요."
|
||||||
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
|
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
|
||||||
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
|
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
|
||||||
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
|
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
|
||||||
@@ -307,14 +85,15 @@ approved_device = "승인 기기: {device}"
|
|||||||
approved_ip = "승인 IP: {ip}"
|
approved_ip = "승인 IP: {ip}"
|
||||||
audit_empty = "최근 접속 이력이 없습니다."
|
audit_empty = "최근 접속 이력이 없습니다."
|
||||||
audit_load_error = "접속이력을 불러오지 못했습니다."
|
audit_load_error = "접속이력을 불러오지 못했습니다."
|
||||||
auto_login_supported = "연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다."
|
|
||||||
auth_method = "인증수단: {method}"
|
auth_method = "인증수단: {method}"
|
||||||
|
auto_login_supported = "연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다."
|
||||||
client_id = "Client ID: {id}"
|
client_id = "Client ID: {id}"
|
||||||
client_id_missing = "Client ID 없음"
|
client_id_missing = "Client ID 없음"
|
||||||
current_status = "현재 상태: {status}"
|
current_status = "현재 상태: {status}"
|
||||||
last_auth = "최근 인증: {value}"
|
last_auth = "최근 인증: {value}"
|
||||||
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
|
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
|
||||||
link_open_error = "해당 링크를 열 수 없습니다."
|
link_open_error = "해당 링크를 열 수 없습니다."
|
||||||
|
link_status = "연동 상태: {status}"
|
||||||
render_error = "대시보드 렌더링 오류: {error}"
|
render_error = "대시보드 렌더링 오류: {error}"
|
||||||
session_id_copied = "세션 ID가 복사되었습니다."
|
session_id_copied = "세션 ID가 복사되었습니다."
|
||||||
|
|
||||||
@@ -323,6 +102,19 @@ empty = "연동된 앱이 없습니다."
|
|||||||
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
|
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
|
||||||
error = "연동 정보를 불러오지 못했습니다."
|
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]
|
[msg.userfront.dashboard.sessions]
|
||||||
browser = "브라우저: {value}"
|
browser = "브라우저: {value}"
|
||||||
empty = "활성 세션이 없습니다."
|
empty = "활성 세션이 없습니다."
|
||||||
@@ -333,23 +125,10 @@ recent_app = "최근 접속 앱: {app}"
|
|||||||
session_id = "세션 ID: {id}"
|
session_id = "세션 ID: {id}"
|
||||||
|
|
||||||
[msg.userfront.dashboard.sessions.revoke]
|
[msg.userfront.dashboard.sessions.revoke]
|
||||||
confirm = "{target} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다."
|
confirm = "{target} 세션을 종료하시겠습니까?\\n대상 기기에서는 다시 로그인이 필요합니다."
|
||||||
error = "세션 종료 실패: {error}"
|
error = "세션 종료 실패: {error}"
|
||||||
success = "세션이 종료되었습니다."
|
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]
|
[msg.userfront.dashboard.timeline]
|
||||||
load_error = "접속이력을 불러오지 못했습니다."
|
load_error = "접속이력을 불러오지 못했습니다."
|
||||||
|
|
||||||
@@ -363,22 +142,6 @@ title_generic = "오류가 발생했습니다"
|
|||||||
title_with_code = "오류: {code}"
|
title_with_code = "오류: {code}"
|
||||||
type = "오류 종류: {type}"
|
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.error.ory]
|
[msg.userfront.error.ory]
|
||||||
"$normalizedCode" = "{error}"
|
"$normalizedCode" = "{error}"
|
||||||
access_denied = "사용자가 동의를 거부했습니다."
|
access_denied = "사용자가 동의를 거부했습니다."
|
||||||
@@ -395,6 +158,22 @@ temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습
|
|||||||
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
|
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
|
||||||
unsupported_response_type = "지원하지 않는 응답 타입입니다."
|
unsupported_response_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.error.whitelist]
|
[msg.userfront.error.whitelist]
|
||||||
"$normalizedCode" = "{error}"
|
"$normalizedCode" = "{error}"
|
||||||
bad_request = "입력값을 확인해 주세요."
|
bad_request = "입력값을 확인해 주세요."
|
||||||
@@ -450,14 +229,14 @@ scan_hint = "모바일 앱으로 스캔하세요"
|
|||||||
invalid = "문자 2개와 숫자 6자리를 입력해 주세요."
|
invalid = "문자 2개와 숫자 6자리를 입력해 주세요."
|
||||||
|
|
||||||
[msg.userfront.login.unregistered]
|
[msg.userfront.login.unregistered]
|
||||||
body = "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주세요."
|
body = "가입되지 않은 정보입니다.\\\\\\\\n회원가입 후 이용해 주세요."
|
||||||
|
|
||||||
[msg.userfront.login.verification]
|
[msg.userfront.login.verification]
|
||||||
approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."
|
approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."
|
||||||
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
|
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
|
||||||
approved_remote = "요청하신 로그인이 완료되었습니다"
|
approved_remote = "요청하신 로그인이 완료되었습니다"
|
||||||
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
|
||||||
close_hint = "이 창은 이제 닫으셔도 됩니다."
|
close_hint = "이 창은 이제 닫으셔도 됩니다."
|
||||||
|
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
||||||
success = "로그인 승인에 성공했습니다."
|
success = "로그인 승인에 성공했습니다."
|
||||||
|
|
||||||
[msg.userfront.login_success]
|
[msg.userfront.login_success]
|
||||||
@@ -532,6 +311,7 @@ uppercase = "대문자 1개 이상"
|
|||||||
[msg.userfront.sections]
|
[msg.userfront.sections]
|
||||||
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
|
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
|
||||||
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
|
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
|
||||||
|
sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다."
|
||||||
|
|
||||||
[msg.userfront.settings]
|
[msg.userfront.settings]
|
||||||
disabled = "현재 계정 설정 화면은 준비 중입니다."
|
disabled = "현재 계정 설정 화면은 준비 중입니다."
|
||||||
@@ -546,12 +326,12 @@ all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계
|
|||||||
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
|
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
|
||||||
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
|
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
|
||||||
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
|
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
|
||||||
title = "서비스 이용을 위해\\\\n약관에 동의해주세요"
|
title = "서비스 이용을 위해\\\\\\\\n약관에 동의해주세요"
|
||||||
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
|
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
|
||||||
|
|
||||||
[msg.userfront.signup.auth]
|
[msg.userfront.signup.auth]
|
||||||
affiliate_notice = "가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요."
|
affiliate_notice = "가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요."
|
||||||
title = "본인 확인을 위해\\\\n인증을 진행해주세요"
|
title = "본인 확인을 위해\\\\\\\\n인증을 진행해주세요"
|
||||||
|
|
||||||
[msg.userfront.signup.email]
|
[msg.userfront.signup.email]
|
||||||
code_mismatch = "인증코드가 일치하지 않습니다."
|
code_mismatch = "인증코드가 일치하지 않습니다."
|
||||||
@@ -567,7 +347,7 @@ lowercase_required = "소문자가 최소 1개 이상 포함되어야 합니다.
|
|||||||
mismatch = "비밀번호가 일치하지 않습니다."
|
mismatch = "비밀번호가 일치하지 않습니다."
|
||||||
number_required = "숫자가 최소 1개 이상 포함되어야 합니다."
|
number_required = "숫자가 최소 1개 이상 포함되어야 합니다."
|
||||||
symbol_required = "특수문자가 최소 1개 이상 포함되어야 합니다."
|
symbol_required = "특수문자가 최소 1개 이상 포함되어야 합니다."
|
||||||
title = "마지막으로\\\\n비밀번호를 설정해주세요"
|
title = "마지막으로\\\\\\\\n비밀번호를 설정해주세요"
|
||||||
uppercase_required = "대문자가 최소 1개 이상 포함되어야 합니다."
|
uppercase_required = "대문자가 최소 1개 이상 포함되어야 합니다."
|
||||||
|
|
||||||
[msg.userfront.signup.password.rule]
|
[msg.userfront.signup.password.rule]
|
||||||
@@ -596,7 +376,7 @@ uppercase = "대문자"
|
|||||||
|
|
||||||
[msg.userfront.signup.profile]
|
[msg.userfront.signup.profile]
|
||||||
affiliate_hint = "가족사 이메일 사용 시 자동으로 선택됩니다."
|
affiliate_hint = "가족사 이메일 사용 시 자동으로 선택됩니다."
|
||||||
title = "회원님의\\\\n소속 정보를 알려주세요"
|
title = "회원님의\\\\\\\\n소속 정보를 알려주세요"
|
||||||
|
|
||||||
[msg.userfront.signup.success]
|
[msg.userfront.signup.success]
|
||||||
body = "성공적으로 가입되었습니다."
|
body = "성공적으로 가입되었습니다."
|
||||||
@@ -688,6 +468,10 @@ dev_console = "Dev Console"
|
|||||||
|
|
||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = "내 활동 관리"
|
||||||
|
toggle_label = "활성 세션만 보기"
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
action = "관리"
|
action = "관리"
|
||||||
app = "애플리케이션"
|
app = "애플리케이션"
|
||||||
@@ -716,22 +500,12 @@ title = "동의 취소"
|
|||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
last_auth_label = "최근 인증"
|
last_auth_label = "최근 인증"
|
||||||
|
link_status_label = "연동 상태"
|
||||||
status_history = "상태 이력"
|
status_history = "상태 이력"
|
||||||
|
|
||||||
[ui.userfront.dashboard.activity]
|
[ui.userfront.dashboard.activity]
|
||||||
linked = "연동됨"
|
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]
|
[ui.userfront.dashboard.approved_session]
|
||||||
default = "승인한 세션 ID"
|
default = "승인한 세션 ID"
|
||||||
userfront = "승인한 Userfront 세션 ID"
|
userfront = "승인한 Userfront 세션 ID"
|
||||||
@@ -743,6 +517,17 @@ title = "연동 해지"
|
|||||||
[ui.userfront.dashboard.scopes]
|
[ui.userfront.dashboard.scopes]
|
||||||
title = "동의 범위"
|
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]
|
[ui.userfront.dashboard.status]
|
||||||
revoked = "해지됨"
|
revoked = "해지됨"
|
||||||
|
|
||||||
@@ -805,10 +590,10 @@ title = "미등록 회원"
|
|||||||
|
|
||||||
[ui.userfront.login.verification]
|
[ui.userfront.login.verification]
|
||||||
action_label = "확인"
|
action_label = "확인"
|
||||||
|
action_label_close = "창 닫기"
|
||||||
action_label_remote = "로그인 창으로 이동하기"
|
action_label_remote = "로그인 창으로 이동하기"
|
||||||
page_title = "Baron SW 포탈"
|
page_title = "Baron SW 포탈"
|
||||||
title = "승인 완료"
|
title = "승인 완료"
|
||||||
action_label_close = "창 닫기"
|
|
||||||
title_pending = "로그인 승인 확인 중"
|
title_pending = "로그인 승인 확인 중"
|
||||||
title_remote = "로그인 승인 완료"
|
title_remote = "로그인 승인 완료"
|
||||||
|
|
||||||
@@ -872,6 +657,7 @@ title = "새 비밀번호 설정"
|
|||||||
[ui.userfront.sections]
|
[ui.userfront.sections]
|
||||||
apps = "나의 App 현황"
|
apps = "나의 App 현황"
|
||||||
audit = "접속이력"
|
audit = "접속이력"
|
||||||
|
sessions = "활성 세션"
|
||||||
|
|
||||||
[ui.userfront.session]
|
[ui.userfront.session]
|
||||||
active = "세션 활성"
|
active = "세션 활성"
|
||||||
@@ -919,12 +705,3 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
action = "로그인하기"
|
||||||
|
|
||||||
|
|
||||||
[ui.userfront.audit.filter]
|
|
||||||
title = "내 활동 관리"
|
|
||||||
toggle_label = "활성 세션만 보기"
|
|
||||||
|
|
||||||
[msg.userfront.audit.filter]
|
|
||||||
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,203 +41,6 @@ verify_code_failed = ""
|
|||||||
[err.userfront.session]
|
[err.userfront.session]
|
||||||
missing = ""
|
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]
|
[msg.userfront]
|
||||||
greeting = ""
|
greeting = ""
|
||||||
|
|
||||||
@@ -253,6 +56,9 @@ result = ""
|
|||||||
session_id = ""
|
session_id = ""
|
||||||
status = ""
|
status = ""
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = ""
|
||||||
|
|
||||||
[msg.userfront.consent]
|
[msg.userfront.consent]
|
||||||
accept_error = ""
|
accept_error = ""
|
||||||
client_id = ""
|
client_id = ""
|
||||||
@@ -279,14 +85,15 @@ approved_device = ""
|
|||||||
approved_ip = ""
|
approved_ip = ""
|
||||||
audit_empty = ""
|
audit_empty = ""
|
||||||
audit_load_error = ""
|
audit_load_error = ""
|
||||||
auto_login_supported = ""
|
|
||||||
auth_method = ""
|
auth_method = ""
|
||||||
|
auto_login_supported = ""
|
||||||
client_id = ""
|
client_id = ""
|
||||||
client_id_missing = ""
|
client_id_missing = ""
|
||||||
current_status = ""
|
current_status = ""
|
||||||
last_auth = ""
|
last_auth = ""
|
||||||
link_missing = ""
|
link_missing = ""
|
||||||
link_open_error = ""
|
link_open_error = ""
|
||||||
|
link_status = ""
|
||||||
render_error = ""
|
render_error = ""
|
||||||
session_id_copied = ""
|
session_id_copied = ""
|
||||||
|
|
||||||
@@ -295,6 +102,19 @@ empty = ""
|
|||||||
empty_detail = ""
|
empty_detail = ""
|
||||||
error = ""
|
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]
|
[msg.userfront.dashboard.sessions]
|
||||||
browser = ""
|
browser = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
@@ -309,19 +129,6 @@ confirm = ""
|
|||||||
error = ""
|
error = ""
|
||||||
success = ""
|
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]
|
[msg.userfront.dashboard.timeline]
|
||||||
load_error = ""
|
load_error = ""
|
||||||
|
|
||||||
@@ -335,22 +142,6 @@ title_generic = ""
|
|||||||
title_with_code = ""
|
title_with_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.error.ory]
|
[msg.userfront.error.ory]
|
||||||
"$normalizedCode" = ""
|
"$normalizedCode" = ""
|
||||||
access_denied = ""
|
access_denied = ""
|
||||||
@@ -367,6 +158,22 @@ temporarily_unavailable = ""
|
|||||||
unauthorized_client = ""
|
unauthorized_client = ""
|
||||||
unsupported_response_type = ""
|
unsupported_response_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.error.whitelist]
|
[msg.userfront.error.whitelist]
|
||||||
"$normalizedCode" = ""
|
"$normalizedCode" = ""
|
||||||
bad_request = ""
|
bad_request = ""
|
||||||
@@ -428,8 +235,8 @@ body = ""
|
|||||||
approved = ""
|
approved = ""
|
||||||
approved_local = ""
|
approved_local = ""
|
||||||
approved_remote = ""
|
approved_remote = ""
|
||||||
pending_remote = ""
|
|
||||||
close_hint = ""
|
close_hint = ""
|
||||||
|
pending_remote = ""
|
||||||
success = ""
|
success = ""
|
||||||
|
|
||||||
[msg.userfront.login_success]
|
[msg.userfront.login_success]
|
||||||
@@ -504,6 +311,7 @@ uppercase = ""
|
|||||||
[msg.userfront.sections]
|
[msg.userfront.sections]
|
||||||
apps_subtitle = ""
|
apps_subtitle = ""
|
||||||
audit_subtitle = ""
|
audit_subtitle = ""
|
||||||
|
sessions_subtitle = ""
|
||||||
|
|
||||||
[msg.userfront.settings]
|
[msg.userfront.settings]
|
||||||
disabled = ""
|
disabled = ""
|
||||||
@@ -660,6 +468,10 @@ dev_console = ""
|
|||||||
|
|
||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = ""
|
||||||
|
toggle_label = ""
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
action = ""
|
action = ""
|
||||||
app = ""
|
app = ""
|
||||||
@@ -688,22 +500,12 @@ title = ""
|
|||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
last_auth_label = ""
|
last_auth_label = ""
|
||||||
|
link_status_label = ""
|
||||||
status_history = ""
|
status_history = ""
|
||||||
|
|
||||||
[ui.userfront.dashboard.activity]
|
[ui.userfront.dashboard.activity]
|
||||||
linked = ""
|
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]
|
[ui.userfront.dashboard.approved_session]
|
||||||
default = ""
|
default = ""
|
||||||
userfront = ""
|
userfront = ""
|
||||||
@@ -715,6 +517,17 @@ title = ""
|
|||||||
[ui.userfront.dashboard.scopes]
|
[ui.userfront.dashboard.scopes]
|
||||||
title = ""
|
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]
|
[ui.userfront.dashboard.status]
|
||||||
revoked = ""
|
revoked = ""
|
||||||
|
|
||||||
@@ -777,8 +590,8 @@ title = ""
|
|||||||
|
|
||||||
[ui.userfront.login.verification]
|
[ui.userfront.login.verification]
|
||||||
action_label = ""
|
action_label = ""
|
||||||
action_label_remote = ""
|
|
||||||
action_label_close = ""
|
action_label_close = ""
|
||||||
|
action_label_remote = ""
|
||||||
page_title = ""
|
page_title = ""
|
||||||
title = ""
|
title = ""
|
||||||
title_pending = ""
|
title_pending = ""
|
||||||
@@ -844,6 +657,7 @@ title = ""
|
|||||||
[ui.userfront.sections]
|
[ui.userfront.sections]
|
||||||
apps = ""
|
apps = ""
|
||||||
audit = ""
|
audit = ""
|
||||||
|
sessions = ""
|
||||||
|
|
||||||
[ui.userfront.session]
|
[ui.userfront.session]
|
||||||
active = ""
|
active = ""
|
||||||
@@ -891,12 +705,3 @@ verify = ""
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = ""
|
action = ""
|
||||||
|
|
||||||
|
|
||||||
[ui.userfront.audit.filter]
|
|
||||||
title = ""
|
|
||||||
toggle_label = ""
|
|
||||||
|
|
||||||
[msg.userfront.audit.filter]
|
|
||||||
description = ""
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'providers/linked_rps_provider.dart';
|
import 'models.dart';
|
||||||
|
|
||||||
String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
|
String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
|
||||||
final normalizedStatus = rp.status.trim().toLowerCase();
|
final normalizedStatus = rp.status.trim().toLowerCase();
|
||||||
|
|||||||
@@ -4,57 +4,7 @@ import 'package:userfront/core/services/auth_proxy_service.dart';
|
|||||||
import 'package:userfront/core/services/auth_token_store.dart';
|
import 'package:userfront/core/services/auth_token_store.dart';
|
||||||
import 'package:userfront/core/services/http_client.dart';
|
import 'package:userfront/core/services/http_client.dart';
|
||||||
import 'package:userfront/core/services/runtime_env.dart';
|
import 'package:userfront/core/services/runtime_env.dart';
|
||||||
|
import '../models.dart';
|
||||||
class LinkedRp {
|
|
||||||
final String id;
|
|
||||||
final String name;
|
|
||||||
final String logo;
|
|
||||||
final String url;
|
|
||||||
final String initUrl;
|
|
||||||
final bool autoLoginSupported;
|
|
||||||
final String autoLoginUrl;
|
|
||||||
final String status;
|
|
||||||
final List<String> scopes;
|
|
||||||
final DateTime? lastAuthenticatedAt;
|
|
||||||
|
|
||||||
LinkedRp({
|
|
||||||
required this.id,
|
|
||||||
required this.name,
|
|
||||||
required this.logo,
|
|
||||||
required this.url,
|
|
||||||
required this.initUrl,
|
|
||||||
required this.autoLoginSupported,
|
|
||||||
required this.autoLoginUrl,
|
|
||||||
required this.status,
|
|
||||||
required this.scopes,
|
|
||||||
required this.lastAuthenticatedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory LinkedRp.fromJson(Map<String, dynamic> json) {
|
|
||||||
final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? '';
|
|
||||||
DateTime? parsedLastAuth;
|
|
||||||
if (rawLastAuth.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
parsedLastAuth = DateTime.parse(rawLastAuth).toLocal();
|
|
||||||
} catch (_) {
|
|
||||||
parsedLastAuth = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return LinkedRp(
|
|
||||||
id: json['id']?.toString() ?? '',
|
|
||||||
name: json['name']?.toString() ?? '',
|
|
||||||
logo: json['logo']?.toString() ?? '',
|
|
||||||
url: json['url']?.toString() ?? '',
|
|
||||||
initUrl: json['init_url']?.toString() ?? '',
|
|
||||||
autoLoginSupported: json['auto_login_supported'] == true,
|
|
||||||
autoLoginUrl: json['auto_login_url']?.toString() ?? '',
|
|
||||||
status: json['status']?.toString() ?? 'unknown',
|
|
||||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
|
||||||
lastAuthenticatedAt: parsedLastAuth,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import '../../../../core/ui/layout_breakpoints.dart';
|
|||||||
import '../../../../core/ui/toast_service.dart';
|
import '../../../../core/ui/toast_service.dart';
|
||||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
import '../domain/dashboard_providers.dart';
|
import '../domain/dashboard_providers.dart';
|
||||||
import '../domain/models.dart' hide LinkedRp;
|
import '../domain/models.dart';
|
||||||
import 'audit_device_utils.dart';
|
import 'audit_device_utils.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:userfront/features/dashboard/domain/linked_rp_launch.dart';
|
import 'package:userfront/features/dashboard/domain/linked_rp_launch.dart';
|
||||||
import 'package:userfront/features/dashboard/domain/providers/linked_rps_provider.dart';
|
import 'package:userfront/features/dashboard/domain/models.dart';
|
||||||
|
|
||||||
LinkedRp _linkedRp({
|
LinkedRp _linkedRp({
|
||||||
required String status,
|
required String status,
|
||||||
|
|||||||
Reference in New Issue
Block a user