forked from baron/baron-sso
Merge origin/dev into dev
This commit is contained in:
@@ -1426,7 +1426,7 @@ jobs:
|
||||
run: |
|
||||
mkdir -p ../reports
|
||||
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]}
|
||||
set -e
|
||||
|
||||
@@ -1442,7 +1442,7 @@ jobs:
|
||||
echo "1. \`cd devfront\`"
|
||||
echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`"
|
||||
echo "3. \`pnpm exec playwright install --with-deps\`"
|
||||
echo "4. \`pnpm test\`"
|
||||
echo "4. \`pnpm run test:ci\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
|
||||
@@ -14,6 +14,8 @@ import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
import { TenantFineGrainedPermissionsPage } from "../features/tenants/routes/TenantFineGrainedPermissionsPage";
|
||||
import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab";
|
||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||
@@ -51,6 +53,10 @@ export const adminRoutes: RouteObject[] = [
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||
{
|
||||
path: "permissions-direct",
|
||||
element: <TenantFineGrainedPermissionsPage />,
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
@@ -59,6 +65,10 @@ export const adminRoutes: RouteObject[] = [
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
{
|
||||
path: "relations",
|
||||
element: <TenantFineGrainedPermissionsTab />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,6 +116,7 @@ describe("admin AppLayout", () => {
|
||||
"Ory SSOT System",
|
||||
"Data Integrity",
|
||||
"Users",
|
||||
"권한 부여",
|
||||
"Auth Guard",
|
||||
"API Keys",
|
||||
"Audit Logs",
|
||||
|
||||
@@ -62,6 +62,12 @@ const staticNavItems: ShellSidebarNavItem[] = [
|
||||
to: "/users",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.permissions_direct",
|
||||
labelFallback: "권한 부여",
|
||||
to: "/permissions-direct",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.auth_guard",
|
||||
labelFallback: "Auth Guard",
|
||||
@@ -206,70 +212,72 @@ function AppLayout() {
|
||||
...profile,
|
||||
role: effectiveRole ?? profile?.role,
|
||||
});
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (item.to === "/api-keys") return isSuperAdmin;
|
||||
return true;
|
||||
});
|
||||
|
||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||
{ includeInternal: true },
|
||||
);
|
||||
|
||||
if (isSuperAdmin) {
|
||||
filteredItems.splice(1, 0, {
|
||||
// Splice optional menus in a standard order
|
||||
items.splice(1, 0, {
|
||||
labelKey: "ui.admin.nav.tenants",
|
||||
labelFallback: "Tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
filteredItems.splice(2, 0, {
|
||||
items.splice(2, 0, {
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
if (showWorksmobile) {
|
||||
filteredItems.splice(3, 0, {
|
||||
items.splice(3, 0, {
|
||||
labelKey: "ui.admin.nav.worksmobile",
|
||||
labelFallback: "Worksmobile",
|
||||
to: "/worksmobile",
|
||||
icon: LineWorksNavIcon,
|
||||
});
|
||||
}
|
||||
filteredItems.splice(4, 0, {
|
||||
items.splice(4, 0, {
|
||||
labelKey: "ui.admin.nav.ory_ssot",
|
||||
labelFallback: "Ory SSOT System",
|
||||
to: "/system/ory-ssot",
|
||||
icon: Database,
|
||||
});
|
||||
filteredItems.splice(5, 0, {
|
||||
items.splice(5, 0, {
|
||||
labelKey: "ui.admin.nav.data_integrity",
|
||||
labelFallback: "Data Integrity",
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return filteredItems;
|
||||
// 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]);
|
||||
|
||||
const handleLogout = () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ const members = [
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ id: "admin-user", role: "super_admin" })),
|
||||
fetchTenant: vi.fn(async () => tenant),
|
||||
fetchUsers: vi.fn(async () => ({
|
||||
items: [
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import { TenantFineGrainedPermissionsTab } from "../tenants/routes/TenantFineGrainedPermissionsTab";
|
||||
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
||||
|
||||
const exportUsersCSVMock = vi.hoisted(() =>
|
||||
@@ -94,12 +95,29 @@ vi.mock("react-oidc-context", () => ({
|
||||
}));
|
||||
|
||||
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]]),
|
||||
fetchTenantAdmins: vi.fn(async () => [users[1]]),
|
||||
addTenantOwner: vi.fn(async () => undefined),
|
||||
addTenantAdmin: vi.fn(async () => undefined),
|
||||
removeTenantOwner: 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 () => ({
|
||||
items: users,
|
||||
total: users.length,
|
||||
@@ -160,6 +178,22 @@ describe("admin tenant tab coverage smoke", () => {
|
||||
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 () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
|
||||
@@ -29,6 +29,7 @@ type DomainTagInputProps = {
|
||||
confirmedConflicts?: string[];
|
||||
onConfirmedConflictsChange?: (domains: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function DomainTagInput({
|
||||
@@ -40,6 +41,7 @@ export function DomainTagInput({
|
||||
confirmedConflicts = [],
|
||||
onConfirmedConflictsChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}: DomainTagInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
|
||||
@@ -107,6 +109,7 @@ export function DomainTagInput({
|
||||
className="gap-1 rounded-md"
|
||||
>
|
||||
<span>{domain}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
||||
@@ -115,6 +118,7 @@ export function DomainTagInput({
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
<Input
|
||||
@@ -133,6 +137,7 @@ export function DomainTagInput({
|
||||
tokenizeInput();
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -36,6 +36,7 @@ type ParentTenantSelectorProps = {
|
||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||
compact?: boolean;
|
||||
controlTestId?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ParentTenantSelector({
|
||||
@@ -55,6 +56,7 @@ export function ParentTenantSelector({
|
||||
localTenantFilter,
|
||||
compact = false,
|
||||
controlTestId,
|
||||
disabled = false,
|
||||
}: ParentTenantSelectorProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
||||
@@ -117,6 +119,7 @@ export function ParentTenantSelector({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={compact ? "h-8 shrink-0 px-2" : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{orgChartPickerLabel ??
|
||||
@@ -147,7 +150,12 @@ export function ParentTenantSelector({
|
||||
{localPickerLabel && (
|
||||
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{localPickerLabel}
|
||||
</Button>
|
||||
@@ -234,6 +242,7 @@ export function ParentTenantSelector({
|
||||
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
|
||||
onClick={() => onChange("")}
|
||||
aria-label={noneLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</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,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
|
||||
type DialogMode = "owner" | "admin";
|
||||
|
||||
@@ -69,6 +70,10 @@ export function TenantAdminsAndOwnersTab() {
|
||||
const _currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||
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 [searchTerm, setSearchTerm] = useState("");
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||
@@ -338,6 +343,16 @@ export function TenantAdminsAndOwnersTab() {
|
||||
|
||||
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 serverAdmins = adminsQuery.data || [];
|
||||
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
||||
@@ -362,7 +377,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
);
|
||||
|
||||
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">
|
||||
{/* Owners Card */}
|
||||
<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
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("owner")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
||||
@@ -471,6 +487,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("admin")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
|
||||
function TenantDetailPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
@@ -17,13 +17,7 @@ function TenantDetailPage() {
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccessSchema = profileRole === "super_admin";
|
||||
const { hasPermission } = useTenantPermission(tenantId);
|
||||
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
const isOrganizationTab = location.pathname.includes("/organization");
|
||||
@@ -110,7 +104,7 @@ function TenantDetailPage() {
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
|
||||
</Link>
|
||||
{canAccessSchema && (
|
||||
{hasPermission("view") && (
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/schema`}
|
||||
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", "사용자 스키마")}
|
||||
</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>
|
||||
|
||||
{/* 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,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
|
||||
type UserGroupNode = GroupSummary & {
|
||||
children: UserGroupNode[];
|
||||
@@ -126,6 +127,7 @@ interface UserGroupTreeNodeProps {
|
||||
AxiosError<{ error?: string }>,
|
||||
{ groupId: string; userId: string }
|
||||
>;
|
||||
isWritable?: boolean;
|
||||
}
|
||||
|
||||
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
@@ -137,6 +139,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
onAddSubGroup,
|
||||
addMemberMutation,
|
||||
removeMemberMutation,
|
||||
isWritable = true,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const hasChildren = node.children.length > 0;
|
||||
@@ -200,6 +203,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
e.stopPropagation();
|
||||
onAddSubGroup(node.id);
|
||||
}}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
@@ -210,6 +214,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
e.stopPropagation();
|
||||
onDelete(node.id);
|
||||
}}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</Button>
|
||||
@@ -229,6 +234,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
onAddSubGroup={onAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
isWritable={isWritable}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -240,6 +246,11 @@ function TenantGroupsPage() {
|
||||
const tenantId = params.tenantId ?? "";
|
||||
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 [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||
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
|
||||
? buildGroupTree(groupsQuery.data, tenantId)
|
||||
: [];
|
||||
@@ -423,6 +444,7 @@ function TenantGroupsPage() {
|
||||
id="name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.name_placeholder",
|
||||
"예: 개발팀, 인사팀",
|
||||
@@ -437,6 +459,7 @@ function TenantGroupsPage() {
|
||||
id="unitType"
|
||||
value={newGroupUnitType}
|
||||
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.unit_level_placeholder",
|
||||
"예: 본부, 팀, 셀",
|
||||
@@ -449,9 +472,10 @@ function TenantGroupsPage() {
|
||||
</Label>
|
||||
<select
|
||||
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 || ""}
|
||||
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{groupsQuery.data?.map((group) => (
|
||||
@@ -469,6 +493,7 @@ function TenantGroupsPage() {
|
||||
id="desc"
|
||||
value={newGroupDesc}
|
||||
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.desc_placeholder",
|
||||
"그룹 용도 설명",
|
||||
@@ -478,7 +503,9 @@ function TenantGroupsPage() {
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!newGroupName || createMutation.isPending}
|
||||
disabled={
|
||||
!newGroupName || createMutation.isPending || !isWritable
|
||||
}
|
||||
>
|
||||
{t("ui.admin.groups.form.submit", "생성하기")}
|
||||
</Button>
|
||||
@@ -569,6 +596,7 @@ function TenantGroupsPage() {
|
||||
onAddSubGroup={handleAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
isWritable={isWritable}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from "../../../../../common/core/utils";
|
||||
import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -378,6 +377,9 @@ function TenantListPage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const isWritable =
|
||||
profileRole === "super_admin" ||
|
||||
!!profile?.systemPermissions?.manage_tenants;
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
|
||||
@@ -582,7 +584,11 @@ function TenantListPage() {
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [allTenants, scopePickerOpen]);
|
||||
|
||||
if (profile && profileRole !== "super_admin") {
|
||||
if (
|
||||
profile &&
|
||||
profileRole !== "super_admin" &&
|
||||
!profile?.systemPermissions?.tenants
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<h3 className="text-lg font-bold">
|
||||
@@ -841,7 +847,8 @@ function TenantListPage() {
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
{isWritable && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
name="tenant-import-file"
|
||||
@@ -915,7 +922,8 @@ function TenantListPage() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</RoleGuard>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -929,14 +937,14 @@ function TenantListPage() {
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</span>
|
||||
</Button>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
{isWritable && (
|
||||
<Button asChild size="sm" className="h-9">
|
||||
<Link to="/tenants/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.add", "테넌트 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -1072,7 +1080,7 @@ function TenantListPage() {
|
||||
{t("ui.common.apply", "적용")}
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
{isWritable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1084,7 +1092,7 @@ function TenantListPage() {
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
import {
|
||||
formatDomainConflictMessage,
|
||||
type ServerDomainConflict,
|
||||
@@ -52,6 +53,10 @@ export function TenantProfilePage() {
|
||||
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({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
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 = () => {
|
||||
if (isProtectedSeedTenant) {
|
||||
return;
|
||||
@@ -261,13 +276,21 @@ export function TenantProfilePage() {
|
||||
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="tenant-slug-slot" className="space-y-1">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||
</Label>
|
||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
|
||||
<ParentTenantSelector
|
||||
@@ -283,6 +306,7 @@ export function TenantProfilePage() {
|
||||
excludeTenantId={tenantId}
|
||||
compact
|
||||
controlTestId="tenant-parent-picker-control"
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</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"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<option value="COMPANY">
|
||||
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
||||
@@ -346,9 +371,10 @@ export function TenantProfilePage() {
|
||||
id="tenant-org-unit-type"
|
||||
name="tenant-org-unit-type"
|
||||
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}
|
||||
onChange={(event) => setOrgUnitType(event.target.value)}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{orgUnitTypeOptions.map((option) => (
|
||||
@@ -365,13 +391,14 @@ export function TenantProfilePage() {
|
||||
<select
|
||||
id="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}
|
||||
onChange={(event) =>
|
||||
setTenantVisibility(
|
||||
event.target.value as TenantVisibility,
|
||||
)
|
||||
}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
{TENANT_VISIBILITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -392,11 +419,12 @@ export function TenantProfilePage() {
|
||||
</Label>
|
||||
<select
|
||||
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"}
|
||||
onChange={(event) =>
|
||||
setWorksmobileExcluded(event.target.value === "excluded")
|
||||
}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<option value="enabled">
|
||||
{t(
|
||||
@@ -424,6 +452,7 @@ export function TenantProfilePage() {
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -442,6 +471,7 @@ export function TenantProfilePage() {
|
||||
confirmedConflicts={forceDomainConflicts}
|
||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||
placeholder="example.com, example.kr"
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -454,6 +484,7 @@ export function TenantProfilePage() {
|
||||
size="sm"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => setStatus("active")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
@@ -462,6 +493,7 @@ export function TenantProfilePage() {
|
||||
size="sm"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => setStatus("inactive")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
@@ -480,7 +512,9 @@ export function TenantProfilePage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending || isProtectedSeedTenant}
|
||||
disabled={
|
||||
deleteMutation.isPending || isProtectedSeedTenant || !isWritable
|
||||
}
|
||||
title={
|
||||
isProtectedSeedTenant
|
||||
? t(
|
||||
@@ -499,7 +533,7 @@ export function TenantProfilePage() {
|
||||
variant="default"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={handleApprove}
|
||||
disabled={approveMutation.isPending}
|
||||
disabled={approveMutation.isPending || !isWritable}
|
||||
>
|
||||
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
|
||||
</Button>
|
||||
@@ -512,7 +546,8 @@ export function TenantProfilePage() {
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
tenantQuery.isLoading ||
|
||||
name.trim() === ""
|
||||
name.trim() === "" ||
|
||||
!isWritable
|
||||
}
|
||||
>
|
||||
<Save size={16} />
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
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 { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
import {
|
||||
createSchemaField,
|
||||
isSchemaFieldType,
|
||||
@@ -28,13 +28,11 @@ export function TenantSchemaPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccess = profileRole === "super_admin";
|
||||
const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(
|
||||
tenantId ?? "",
|
||||
);
|
||||
const canView = hasPermission("view_schema") || hasPermission("view");
|
||||
const isWritable = hasPermission("manage_schema") || hasPermission("manage");
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
@@ -42,7 +40,7 @@ export function TenantSchemaPage() {
|
||||
if (!tenantId) throw new Error("Tenant ID is required");
|
||||
return fetchTenant(tenantId);
|
||||
},
|
||||
enabled: !!tenantId && canAccess,
|
||||
enabled: !!tenantId && canView,
|
||||
});
|
||||
|
||||
const [fields, setFields] = useState<SchemaField[]>([]);
|
||||
@@ -85,7 +83,7 @@ export function TenantSchemaPage() {
|
||||
},
|
||||
});
|
||||
|
||||
if (isProfileLoading) {
|
||||
if (isPermissionLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center animate-pulse text-muted-foreground">
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
@@ -93,7 +91,7 @@ export function TenantSchemaPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAccess) {
|
||||
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">
|
||||
@@ -147,7 +145,7 @@ export function TenantSchemaPage() {
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={addField} size="sm">
|
||||
<Button onClick={addField} size="sm" disabled={!isWritable}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
|
||||
</Button>
|
||||
@@ -182,6 +180,7 @@ export function TenantSchemaPage() {
|
||||
"예: employee_id",
|
||||
)}
|
||||
className="h-10"
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -198,6 +197,7 @@ export function TenantSchemaPage() {
|
||||
"예: 사번",
|
||||
)}
|
||||
className="h-10"
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -207,8 +207,9 @@ export function TenantSchemaPage() {
|
||||
<select
|
||||
id={`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}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
if (isSchemaFieldType(nextType)) {
|
||||
@@ -271,10 +272,11 @@ export function TenantSchemaPage() {
|
||||
name={`tenant-schema-field-required-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
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">
|
||||
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
|
||||
@@ -285,10 +287,11 @@ export function TenantSchemaPage() {
|
||||
name={`tenant-schema-field-admin-only-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.adminOnly}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
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">
|
||||
{t(
|
||||
@@ -302,6 +305,7 @@ export function TenantSchemaPage() {
|
||||
name={`tenant-schema-field-login-id-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.isLoginId || false}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
isLoginId: e.target.checked,
|
||||
@@ -309,7 +313,7 @@ export function TenantSchemaPage() {
|
||||
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">
|
||||
{t(
|
||||
@@ -323,7 +327,7 @@ export function TenantSchemaPage() {
|
||||
name={`tenant-schema-field-indexed-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.indexed || field.isLoginId || false}
|
||||
disabled={field.isLoginId}
|
||||
disabled={field.isLoginId || !isWritable}
|
||||
onChange={(e) =>
|
||||
updateField(index, { indexed: e.target.checked })
|
||||
}
|
||||
@@ -342,10 +346,11 @@ export function TenantSchemaPage() {
|
||||
name={`tenant-schema-field-unsigned-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.unsigned}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
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">
|
||||
{t(
|
||||
@@ -359,6 +364,7 @@ export function TenantSchemaPage() {
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={field.validation}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
updateField(index, { validation: e.target.value })
|
||||
}
|
||||
@@ -375,6 +381,7 @@ export function TenantSchemaPage() {
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 h-10 w-10"
|
||||
onClick={() => removeField(index)}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</Button>
|
||||
@@ -388,7 +395,9 @@ export function TenantSchemaPage() {
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate(fields)}
|
||||
disabled={updateMutation.isPending || tenantQuery.isLoading}
|
||||
disabled={
|
||||
updateMutation.isPending || tenantQuery.isLoading || !isWritable
|
||||
}
|
||||
className="px-8 h-11"
|
||||
>
|
||||
<Save size={18} className="mr-2" />
|
||||
|
||||
@@ -159,7 +159,9 @@ function UserCreatePage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canManageUsers = canManageTenantScopedUsers(profile);
|
||||
const canManageUsers =
|
||||
canManageTenantScopedUsers(profile) ||
|
||||
!!profile?.systemPermissions?.manage_users;
|
||||
|
||||
const {
|
||||
register,
|
||||
|
||||
@@ -654,6 +654,17 @@ function UserDetailPage() {
|
||||
const isAdmin = profileRole === "super_admin";
|
||||
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
||||
const canManageCurrentUser = canManageUserInTenantScope({ profile, user });
|
||||
const isWritable =
|
||||
isAdmin ||
|
||||
isSelf ||
|
||||
canManageCurrentUser ||
|
||||
!!profile?.systemPermissions?.manage_users;
|
||||
const canViewUser =
|
||||
isAdmin ||
|
||||
isSelf ||
|
||||
canManageCurrentUser ||
|
||||
!!profile?.systemPermissions?.users ||
|
||||
!!profile?.systemPermissions?.manage_users;
|
||||
const watchedStatus = watch("status");
|
||||
|
||||
const [newSubEmail, setNewSubEmail] = React.useState("");
|
||||
@@ -1235,7 +1246,7 @@ function UserDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin && !isSelf && !canManageCurrentUser) {
|
||||
if (profile && !canViewUser) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<ShieldAlert size={48} className="text-destructive" />
|
||||
@@ -1944,6 +1955,7 @@ function UserDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isWritable && (
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -1960,6 +1972,7 @@ function UserDetailPage() {
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
|
||||
@@ -382,6 +382,8 @@ function UserListPage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const isWritable =
|
||||
profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", "all"],
|
||||
@@ -796,8 +798,9 @@ function UserListPage() {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setBulkUploadOpen(true);
|
||||
if (isWritable) setBulkUploadOpen(true);
|
||||
}}
|
||||
disabled={!isWritable}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Upload size={16} className="mr-2 opacity-50" />
|
||||
@@ -889,12 +892,19 @@ function UserListPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isWritable ? (
|
||||
<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} />
|
||||
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -1172,7 +1182,8 @@ function UserListPage() {
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
user.id === profile?.id ||
|
||||
!isWritable
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -1341,7 +1352,8 @@ function UserListPage() {
|
||||
}}
|
||||
disabled={
|
||||
(!selectedBulkStatus && !selectedBulkPermission) ||
|
||||
bulkUpdateMutation.isPending
|
||||
bulkUpdateMutation.isPending ||
|
||||
!isWritable
|
||||
}
|
||||
data-testid="bulk-apply-btn"
|
||||
>
|
||||
@@ -1354,6 +1366,7 @@ function UserListPage() {
|
||||
size="sm"
|
||||
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
||||
onClick={handleBulkDelete}
|
||||
disabled={!isWritable}
|
||||
data-testid="bulk-delete-btn"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -33,6 +33,19 @@ export type TenantSummary = {
|
||||
config?: Record<string, unknown>;
|
||||
memberCount: 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;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -469,6 +482,61 @@ export async function removeTenantOwner(tenantId: string, userId: string) {
|
||||
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
|
||||
export type GroupMember = {
|
||||
id: string;
|
||||
@@ -1192,6 +1260,32 @@ export async function fetchUserRpHistory(userId: string) {
|
||||
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 = {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -1205,6 +1299,7 @@ export type UserProfileResponse = {
|
||||
metadata?: Record<string, unknown>;
|
||||
tenant?: TenantSummary;
|
||||
manageableTenants?: TenantSummary[];
|
||||
systemPermissions?: SystemPermissions;
|
||||
};
|
||||
|
||||
export async function fetchMe() {
|
||||
|
||||
@@ -972,6 +972,7 @@ org_chart = "Org Chart"
|
||||
api_keys = "API Keys"
|
||||
audit_logs = "Audit Logs"
|
||||
auth_guard = "Auth Guard"
|
||||
permissions_direct = "Direct Permissions"
|
||||
data_integrity = "Data Integrity"
|
||||
logout = "Logout"
|
||||
overview = "Overview"
|
||||
@@ -996,10 +997,6 @@ title = "Redis identity cache"
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
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]
|
||||
failed = "failed"
|
||||
not_ready = "not ready"
|
||||
@@ -1008,11 +1005,8 @@ ready = "ready"
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = "Cache keys"
|
||||
last_refreshed = "Last refreshed"
|
||||
last_synced = "Last read-model refresh"
|
||||
local_users = "Local users"
|
||||
observed_identities = "Observed identities"
|
||||
status = "Status"
|
||||
updated_at = "Updated at"
|
||||
|
||||
[ui.admin.auth_guard]
|
||||
subtitle = "Verify admin privileges and ReBAC relationships against the policy engine."
|
||||
@@ -1184,6 +1178,7 @@ tab_organization = "Organization Manage"
|
||||
tab_permissions = "Permissions"
|
||||
tab_profile = "Profile"
|
||||
tab_schema = "Tab Schema"
|
||||
tab_relations = "Fine-grained Permissions"
|
||||
title = "Details"
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
@@ -1494,7 +1489,6 @@ email = "Email"
|
||||
name = "Name"
|
||||
role = "Role"
|
||||
|
||||
|
||||
[ui.common.role]
|
||||
admin = "Admin"
|
||||
rp_admin = "RP Admin"
|
||||
@@ -2006,3 +2000,24 @@ verify = "Verify"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
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 키"
|
||||
audit_logs = "감사 로그"
|
||||
auth_guard = "인증 가드"
|
||||
permissions_direct = "권한 부여"
|
||||
data_integrity = "데이터 정합성"
|
||||
logout = "로그아웃"
|
||||
overview = "개요"
|
||||
@@ -1000,10 +1001,6 @@ title = "Redis identity cache"
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
title = "접근 권한이 없습니다"
|
||||
|
||||
[ui.admin.ory_ssot.projection_card]
|
||||
description = "관리자 검색과 통계에서 사용하는 PostgreSQL read model 상태입니다."
|
||||
title = "Backend 사용자 read model"
|
||||
|
||||
[ui.admin.ory_ssot.status]
|
||||
failed = "실패"
|
||||
not_ready = "준비되지 않음"
|
||||
@@ -1012,11 +1009,8 @@ ready = "준비됨"
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = "Cache keys"
|
||||
last_refreshed = "마지막 refresh"
|
||||
last_synced = "마지막 read-model refresh"
|
||||
local_users = "Local users"
|
||||
observed_identities = "관측 identity"
|
||||
status = "상태"
|
||||
updated_at = "상태 갱신"
|
||||
|
||||
[ui.admin.auth_guard]
|
||||
subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다."
|
||||
@@ -1188,6 +1182,7 @@ tab_organization = "조직 관리"
|
||||
tab_permissions = "권한"
|
||||
tab_profile = "프로필"
|
||||
tab_schema = "사용자 스키마"
|
||||
tab_relations = "세부 권한"
|
||||
title = "상세"
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
@@ -1498,7 +1493,6 @@ email = "이메일"
|
||||
name = "이름"
|
||||
role = "역할"
|
||||
|
||||
|
||||
[ui.common.role]
|
||||
admin = "Admin"
|
||||
rp_admin = "RP Admin"
|
||||
@@ -2006,3 +2000,24 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
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 = ""
|
||||
allowed_domains = ""
|
||||
|
||||
|
||||
[msg.admin.ory_ssot]
|
||||
flush_confirm = ""
|
||||
flush_error = ""
|
||||
@@ -981,6 +980,7 @@ org_chart = ""
|
||||
api_keys = ""
|
||||
audit_logs = ""
|
||||
auth_guard = ""
|
||||
permissions_direct = ""
|
||||
data_integrity = ""
|
||||
logout = ""
|
||||
overview = ""
|
||||
@@ -1005,10 +1005,6 @@ title = ""
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
title = ""
|
||||
|
||||
[ui.admin.ory_ssot.projection_card]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.ory_ssot.status]
|
||||
failed = ""
|
||||
not_ready = ""
|
||||
@@ -1017,11 +1013,8 @@ ready = ""
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = ""
|
||||
last_refreshed = ""
|
||||
last_synced = ""
|
||||
local_users = ""
|
||||
observed_identities = ""
|
||||
status = ""
|
||||
updated_at = ""
|
||||
|
||||
[ui.admin.auth_guard]
|
||||
subtitle = ""
|
||||
@@ -1193,6 +1186,7 @@ tab_organization = ""
|
||||
tab_permissions = ""
|
||||
tab_profile = ""
|
||||
tab_schema = ""
|
||||
tab_relations = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
@@ -1451,7 +1445,6 @@ email = ""
|
||||
name = ""
|
||||
role = ""
|
||||
|
||||
|
||||
[ui.common.role]
|
||||
admin = ""
|
||||
rp_admin = ""
|
||||
@@ -1961,3 +1954,24 @@ verify = ""
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
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.beforeEach(async ({ page }) => {
|
||||
page.on("console", (msg) => console.log("BROWSER LOG:", msg.text()));
|
||||
page.on("pageerror", (err) =>
|
||||
console.error("BROWSER EXCEPTION:", err.message),
|
||||
);
|
||||
// 1. Force state
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
@@ -70,8 +74,24 @@ test.describe("Authentication", () => {
|
||||
|
||||
// 3. Catch-all for others
|
||||
await page.route(/.*\/api\/v1\/.*/, async (route) => {
|
||||
if (route.request().url().includes("/user/me")) {
|
||||
return route.fallback();
|
||||
}
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({ json: { items: [], total: 0 } });
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [],
|
||||
total: 0,
|
||||
summary: {
|
||||
failures: 0,
|
||||
warnings: 0,
|
||||
pass: 0,
|
||||
success: 0,
|
||||
total: 0,
|
||||
},
|
||||
sections: [],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({ status: 200, json: {} });
|
||||
}
|
||||
|
||||
@@ -198,7 +198,11 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
||||
|
||||
test.describe("일반 사용자 (Tenant Member) 제한", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupAuth(page, "user");
|
||||
await setupAuth(page, "user", {
|
||||
systemPermissions: {
|
||||
audit_logs: true,
|
||||
},
|
||||
});
|
||||
await page.goto("/");
|
||||
await expect(page.locator("aside")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
@@ -291,4 +295,64 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
||||
).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.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/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)
|
||||
|
||||
@@ -69,6 +69,32 @@ type SignupRequest struct {
|
||||
|
||||
// User Profile Models
|
||||
|
||||
type SystemPermissions struct {
|
||||
Overview bool `json:"overview"`
|
||||
Tenants bool `json:"tenants"`
|
||||
OrgChart bool `json:"org_chart"`
|
||||
Worksmobile bool `json:"worksmobile"`
|
||||
OrySSOT bool `json:"ory_ssot"`
|
||||
DataIntegrity bool `json:"data_integrity"`
|
||||
Users bool `json:"users"`
|
||||
PermissionsDirect bool `json:"permissions_direct"`
|
||||
AuthGuard bool `json:"auth_guard"`
|
||||
ApiKeys bool `json:"api_keys"`
|
||||
AuditLogs bool `json:"audit_logs"`
|
||||
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
@@ -87,6 +113,7 @@ type UserProfileResponse struct {
|
||||
Tenant *Tenant `json:"tenant,omitempty"`
|
||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
||||
SystemPermissions *SystemPermissions `json:"systemPermissions,omitempty"` // [New] 글로벌 메뉴 접근 권한
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8426,7 +8545,7 @@ func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs [
|
||||
seen := map[string]struct{}{}
|
||||
for _, scope := range append([]string{"openid"}, scopes...) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
||||
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
|
||||
@@ -464,7 +464,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
|
||||
|
||||
appendIfPresent := func(scope string) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
||||
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
@@ -485,7 +485,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
|
||||
|
||||
for _, scope := range combined {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
||||
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -153,7 +154,7 @@ func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAlias
|
||||
[]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) {
|
||||
@@ -166,10 +167,11 @@ func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T)
|
||||
parsed, err := url.Parse(urlString)
|
||||
assert.NoError(t, err)
|
||||
scopes := parsed.Query().Get("scope")
|
||||
scopeItems := strings.Fields(scopes)
|
||||
|
||||
assert.Equal(t, "openid profile email", scopes)
|
||||
assert.NotContains(t, scopes, "offline")
|
||||
assert.NotContains(t, scopes, "offline_access")
|
||||
assert.Equal(t, "openid profile offline_access email", scopes)
|
||||
assert.NotContains(t, scopeItems, "offline")
|
||||
assert.Contains(t, scopeItems, "offline_access")
|
||||
}
|
||||
|
||||
func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) {
|
||||
|
||||
@@ -3848,7 +3848,7 @@ func normalizeClientScopes(scopes []string) []string {
|
||||
seen := make(map[string]struct{}, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" || isRefreshTokenScopeAlias(scope) {
|
||||
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
@@ -3860,9 +3860,9 @@ func normalizeClientScopes(scopes []string) []string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
func isRefreshTokenScopeAlias(scope string) bool {
|
||||
func isLegacyRefreshTokenScopeAlias(scope string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(scope)) {
|
||||
case "offline", "offline_access":
|
||||
case "offline":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -2229,9 +2229,9 @@ func TestCreateClient_StripsOfflineScopesAndKeepsRefreshTokenGrant(t *testing.T)
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
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_access")
|
||||
assert.Contains(t, strings.Fields(captured.Scope), "offline_access")
|
||||
assert.Contains(t, captured.GrantTypes, "refresh_token")
|
||||
}
|
||||
|
||||
@@ -2296,9 +2296,9 @@ func TestUpdateClient_StripsStoredOfflineScopesAndKeepsRefreshTokenGrant(t *test
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
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_access")
|
||||
assert.Contains(t, strings.Fields(captured.Scope), "offline_access")
|
||||
assert.Contains(t, captured.GrantTypes, "refresh_token")
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,21 @@ func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
@@ -96,6 +111,7 @@ type tenantSummary struct {
|
||||
Config domain.JSONMap `json:"config,omitempty"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
TotalMemberCount int64 `json:"totalMemberCount"`
|
||||
UserPermissions *tenantPermissions `json:"userPermissions,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
@@ -1709,6 +1725,83 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
summary.MemberCount = memberCounts[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)
|
||||
}
|
||||
|
||||
@@ -3652,3 +3745,419 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
"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)
|
||||
}
|
||||
} 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 {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
|
||||
[msg]
|
||||
|
||||
[msg.admin]
|
||||
|
||||
[msg.admin.audit]
|
||||
subtitle = "View administrator activity history."
|
||||
|
||||
[msg.common]
|
||||
loading_more = "Loading more logs..."
|
||||
copied = "Copied."
|
||||
error = "Error"
|
||||
forbidden = "Access denied."
|
||||
loading = "Loading..."
|
||||
no_results = "No results found."
|
||||
loading_more = "Loading more logs..."
|
||||
no_description = "No Description."
|
||||
no_results = "No results found."
|
||||
parsing = "Parsing data..."
|
||||
requesting = "Requesting..."
|
||||
saving = "Saving..."
|
||||
@@ -20,20 +28,38 @@ loading = "Loading audit logs..."
|
||||
[msg.common.audit.registry]
|
||||
count = "{{count}} logs"
|
||||
|
||||
[msg.admin.audit]
|
||||
subtitle = "View administrator activity history."
|
||||
[msg.dev]
|
||||
|
||||
[msg.dev.audit]
|
||||
subtitle = "View developer activity history within the current app scope."
|
||||
|
||||
[ui]
|
||||
|
||||
[ui.admin]
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = "Unable to load the final integrity check result."
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = "Tenant integrity"
|
||||
user_integrity = "User integrity"
|
||||
|
||||
[ui.admin.integrity.summary]
|
||||
failures_text = "Failures {{count}}"
|
||||
title = "Final integrity check"
|
||||
|
||||
[ui.admin.overview]
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "Check the graph by all or selected organizations."
|
||||
title = "Login request status by company and app"
|
||||
|
||||
[ui.common]
|
||||
no_results = "No results to display."
|
||||
apply = "Apply"
|
||||
action = "Action"
|
||||
actions = "Actions"
|
||||
add = "Add"
|
||||
all = "All"
|
||||
apply = "Apply"
|
||||
admin_only = "Admin Only"
|
||||
all = "All"
|
||||
apply = "Apply"
|
||||
approve = "Approve"
|
||||
assign = "Assign"
|
||||
@@ -61,42 +87,45 @@ export_with_ids = "Include UUID"
|
||||
export_without_ids = "Export without UUID"
|
||||
fail = "Fail"
|
||||
go_home = "Go Home"
|
||||
info = "Info"
|
||||
view = "View"
|
||||
hyphen = "-"
|
||||
info = "Info"
|
||||
language = "Language"
|
||||
language_en = "English"
|
||||
language_ko = "Korean"
|
||||
load_more = "Load more"
|
||||
loading = "Loading..."
|
||||
manage = "Manage"
|
||||
move = "Move"
|
||||
move_org = "Move to another organization"
|
||||
na = "N/A"
|
||||
name = "Name"
|
||||
never = "Never"
|
||||
next = "Next"
|
||||
no_results = "No results to display."
|
||||
none = "None"
|
||||
page_of = "Page {{page}} of {{total}}"
|
||||
prev = "Prev"
|
||||
previous = "Previous"
|
||||
qr = "QR"
|
||||
reject = "Reject"
|
||||
rejected = "Rejected"
|
||||
reset = "Reset"
|
||||
read = "Read"
|
||||
read_only = "Read Only"
|
||||
refresh = "Refresh"
|
||||
reject = "Reject"
|
||||
rejected = "Rejected"
|
||||
remove = "Remove"
|
||||
remove_org = "Remove from organization"
|
||||
resend = "Resend"
|
||||
reset = "Reset"
|
||||
retry = "Retry"
|
||||
row = "Row"
|
||||
save = "Save"
|
||||
search = "Search"
|
||||
search_group = "Search groups..."
|
||||
searching = "Searching..."
|
||||
select = "Select"
|
||||
select_file = "Select File"
|
||||
select_placeholder = "Select Placeholder"
|
||||
load_more = "Load more"
|
||||
show_more = "Show More"
|
||||
language = "Language"
|
||||
language_ko = "Korean"
|
||||
language_en = "English"
|
||||
submit = "Submit"
|
||||
submitting = "Submitting..."
|
||||
success = "Success"
|
||||
@@ -105,6 +134,8 @@ theme_light = "Light"
|
||||
theme_toggle = "Theme Toggle"
|
||||
unassigned = "Unassigned"
|
||||
unknown = "Unknown"
|
||||
view = "View"
|
||||
write = "Write"
|
||||
|
||||
[ui.common.audit]
|
||||
load_more = "Load more"
|
||||
@@ -114,12 +145,6 @@ title = "Audit Logs"
|
||||
actor_id = "Copy User ID"
|
||||
target = "Copy Client ID"
|
||||
|
||||
[ui.common.audit.filters]
|
||||
user_id = "Filter by User ID"
|
||||
client_id = "Filter by Client ID"
|
||||
action = "Filter by Action (e.g. ROTATE_SECRET)"
|
||||
status_all = "All Status"
|
||||
|
||||
[ui.common.audit.details]
|
||||
actor = "User ID"
|
||||
actor_id = "User ID · {{value}}"
|
||||
@@ -135,24 +160,38 @@ path = "Path · {{value}}"
|
||||
request = "Request"
|
||||
request_id = "Request ID · {{value}}"
|
||||
result = "Result"
|
||||
tenant = "Tenant · {{value}}"
|
||||
target = "Client ID · {{value}}"
|
||||
tenant = "Tenant · {{value}}"
|
||||
|
||||
[ui.common.audit.filters]
|
||||
action = "Filter by Action (e.g. ROTATE_SECRET)"
|
||||
client_id = "Filter by Client ID"
|
||||
status_all = "All Status"
|
||||
user_id = "Filter by User ID"
|
||||
|
||||
[ui.common.audit.registry]
|
||||
title = "Audit registry"
|
||||
|
||||
[ui.common.audit.table]
|
||||
no_logs = "No logs to display."
|
||||
action = "Action"
|
||||
actor = "User ID"
|
||||
client_id = "Client ID"
|
||||
user_id = "User ID"
|
||||
no_logs = "No logs to display."
|
||||
status = "Status"
|
||||
target = "Client ID"
|
||||
time = "Time"
|
||||
user_id = "User ID"
|
||||
|
||||
[ui.common.overview]
|
||||
title = "Operational Status"
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
command_only = "Command only"
|
||||
system = "System"
|
||||
|
||||
[ui.common.chart]
|
||||
|
||||
[ui.common.chart.axis]
|
||||
x = "X-axis: Period"
|
||||
y = "Y-axis: Login Requests"
|
||||
|
||||
[ui.common.chart.period]
|
||||
day = "Day"
|
||||
@@ -162,29 +201,12 @@ week = "Week"
|
||||
[ui.common.chart.series_summary]
|
||||
login_users = "Login {{login}} / Users {{subjects}}"
|
||||
|
||||
[ui.common.chart.axis]
|
||||
x = "X-axis: Period"
|
||||
y = "Y-axis: Login Requests"
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = "Unable to load the final integrity check result."
|
||||
|
||||
[ui.admin.integrity.summary]
|
||||
failures_text = "Failures {{count}}"
|
||||
title = "Final integrity check"
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = "Tenant integrity"
|
||||
user_integrity = "User integrity"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "Check the graph by all or selected organizations."
|
||||
title = "Login request status by company and app"
|
||||
|
||||
[ui.common.badge]
|
||||
[ui.common.custom_claim_permission]
|
||||
admin_only = "Admin only"
|
||||
command_only = "Command only"
|
||||
system = "System"
|
||||
user_and_admin = "User and admin"
|
||||
|
||||
[ui.common.overview]
|
||||
title = "Operational Status"
|
||||
|
||||
[ui.common.status]
|
||||
active = "Active"
|
||||
@@ -197,10 +219,3 @@ pending = "Pending"
|
||||
success = "Success"
|
||||
unchanged = "Unchanged"
|
||||
updated = "Updated"
|
||||
|
||||
[ui.common]
|
||||
searching = "Searching..."
|
||||
|
||||
[ui.common.custom_claim_permission]
|
||||
admin_only = "Admin only"
|
||||
user_and_admin = "User and admin"
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
|
||||
[msg]
|
||||
|
||||
[msg.admin]
|
||||
|
||||
[msg.admin.audit]
|
||||
subtitle = "관리자 작업 이력을 조회합니다."
|
||||
|
||||
[msg.common]
|
||||
loading_more = "추가 로그를 불러오는 중..."
|
||||
copied = "복사되었습니다."
|
||||
error = "오류가 발생했습니다."
|
||||
forbidden = "접근 권한이 없습니다."
|
||||
loading = "로딩 중..."
|
||||
no_results = "검색 결과가 없습니다."
|
||||
loading_more = "추가 로그를 불러오는 중..."
|
||||
no_description = "설명이 없습니다."
|
||||
no_results = "검색 결과가 없습니다."
|
||||
parsing = "데이터 파싱 중..."
|
||||
requesting = "요청 중..."
|
||||
saving = "저장 중..."
|
||||
@@ -20,20 +28,38 @@ loading = "Loading audit logs..."
|
||||
[msg.common.audit.registry]
|
||||
count = "총 {{count}}개 로그"
|
||||
|
||||
[msg.admin.audit]
|
||||
subtitle = "관리자 작업 이력을 조회합니다."
|
||||
[msg.dev]
|
||||
|
||||
[msg.dev.audit]
|
||||
subtitle = "현재 앱 범위의 개발자 작업 이력을 조회합니다."
|
||||
|
||||
[ui]
|
||||
|
||||
[ui.admin]
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = "테넌트 정합성"
|
||||
user_integrity = "사용자 정합성"
|
||||
|
||||
[ui.admin.integrity.summary]
|
||||
failures_text = "실패 {{count}}건"
|
||||
title = "정합성 최종 검증"
|
||||
|
||||
[ui.admin.overview]
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
|
||||
title = "회사별 앱별 로그인 요청 현황"
|
||||
|
||||
[ui.common]
|
||||
no_results = "표시할 결과가 없습니다."
|
||||
apply = "적용"
|
||||
action = "작업"
|
||||
actions = "액션"
|
||||
add = "추가"
|
||||
all = "전체"
|
||||
apply = "적용"
|
||||
admin_only = "관리자 전용"
|
||||
all = "전체"
|
||||
apply = "적용"
|
||||
approve = "승인"
|
||||
assign = "할당"
|
||||
@@ -61,42 +87,45 @@ export_with_ids = "UUID 포함"
|
||||
export_without_ids = "UUID 제외 내보내기"
|
||||
fail = "실패"
|
||||
go_home = "홈으로"
|
||||
info = "상세 안내"
|
||||
view = "보기"
|
||||
hyphen = "-"
|
||||
info = "상세 안내"
|
||||
language = "언어"
|
||||
language_en = "English"
|
||||
language_ko = "한국어"
|
||||
load_more = "더 보기"
|
||||
loading = "로딩 중..."
|
||||
manage = "관리"
|
||||
move = "이동"
|
||||
move_org = "타 조직으로 이동"
|
||||
na = "N/A"
|
||||
name = "이름"
|
||||
never = "Never"
|
||||
next = "다음"
|
||||
no_results = "표시할 결과가 없습니다."
|
||||
none = "없음"
|
||||
page_of = "Page {{page}} of {{total}}"
|
||||
prev = "이전"
|
||||
previous = "이전"
|
||||
qr = "QR"
|
||||
reject = "반려"
|
||||
rejected = "반려됨"
|
||||
reset = "초기화"
|
||||
read = "조회 가능 (Read)"
|
||||
read_only = "읽기 전용"
|
||||
refresh = "새로고침"
|
||||
reject = "반려"
|
||||
rejected = "반려됨"
|
||||
remove = "제외"
|
||||
remove_org = "조직에서 제외"
|
||||
resend = "재발송"
|
||||
reset = "초기화"
|
||||
retry = "다시 시도"
|
||||
row = "행"
|
||||
save = "저장"
|
||||
search = "검색"
|
||||
search_group = "그룹 검색..."
|
||||
searching = "검색 중..."
|
||||
select = "선택"
|
||||
select_file = "파일 선택"
|
||||
select_placeholder = "선택하세요"
|
||||
load_more = "더 보기"
|
||||
show_more = "+ 더보기"
|
||||
language = "언어"
|
||||
language_ko = "한국어"
|
||||
language_en = "English"
|
||||
submit = "신청하기"
|
||||
submitting = "제출 중..."
|
||||
success = "성공"
|
||||
@@ -105,6 +134,8 @@ theme_light = "Light"
|
||||
theme_toggle = "테마 전환"
|
||||
unassigned = "미배정"
|
||||
unknown = "Unknown"
|
||||
view = "보기"
|
||||
write = "수정 가능 (Write)"
|
||||
|
||||
[ui.common.audit]
|
||||
load_more = "더 보기"
|
||||
@@ -114,12 +145,6 @@ title = "감사 로그"
|
||||
actor_id = "사용자 ID 복사"
|
||||
target = "클라이언트 ID 복사"
|
||||
|
||||
[ui.common.audit.filters]
|
||||
user_id = "사용자 ID로 검색"
|
||||
client_id = "클라이언트 ID로 검색"
|
||||
action = "액션으로 검색 (예: ROTATE_SECRET)"
|
||||
status_all = "전체 상태"
|
||||
|
||||
[ui.common.audit.details]
|
||||
actor = "사용자 ID"
|
||||
actor_id = "사용자 ID · {{value}}"
|
||||
@@ -135,24 +160,38 @@ path = "Path · {{value}}"
|
||||
request = "Request"
|
||||
request_id = "Request ID · {{value}}"
|
||||
result = "Result"
|
||||
tenant = "Tenant · {{value}}"
|
||||
target = "클라이언트 ID · {{value}}"
|
||||
tenant = "Tenant · {{value}}"
|
||||
|
||||
[ui.common.audit.filters]
|
||||
action = "액션으로 검색 (예: ROTATE_SECRET)"
|
||||
client_id = "클라이언트 ID로 검색"
|
||||
status_all = "전체 상태"
|
||||
user_id = "사용자 ID로 검색"
|
||||
|
||||
[ui.common.audit.registry]
|
||||
title = "감사 로그 레지스트리"
|
||||
|
||||
[ui.common.audit.table]
|
||||
no_logs = "표시할 로그가 없습니다."
|
||||
action = "작업"
|
||||
actor = "사용자 ID"
|
||||
client_id = "클라이언트 ID"
|
||||
user_id = "사용자 ID"
|
||||
no_logs = "표시할 로그가 없습니다."
|
||||
status = "상태"
|
||||
target = "클라이언트 ID"
|
||||
time = "시간"
|
||||
user_id = "사용자 ID"
|
||||
|
||||
[ui.common.overview]
|
||||
title = "운영 현황"
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
command_only = "Command only"
|
||||
system = "System"
|
||||
|
||||
[ui.common.chart]
|
||||
|
||||
[ui.common.chart.axis]
|
||||
x = "X축: 기간"
|
||||
y = "Y축: 로그인 요청 수"
|
||||
|
||||
[ui.common.chart.period]
|
||||
day = "일"
|
||||
@@ -162,29 +201,12 @@ week = "주"
|
||||
[ui.common.chart.series_summary]
|
||||
login_users = "로그인 {{login}} / 사용자 {{subjects}}"
|
||||
|
||||
[ui.common.chart.axis]
|
||||
x = "X축: 기간"
|
||||
y = "Y축: 로그인 요청 수"
|
||||
[ui.common.custom_claim_permission]
|
||||
admin_only = "관리자만 가능"
|
||||
user_and_admin = "사용자와 관리자"
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
|
||||
|
||||
[ui.admin.integrity.summary]
|
||||
failures_text = "실패 {{count}}건"
|
||||
title = "정합성 최종 검증"
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = "테넌트 정합성"
|
||||
user_integrity = "사용자 정합성"
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
|
||||
title = "회사별 앱별 로그인 요청 현황"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
command_only = "Command only"
|
||||
system = "System"
|
||||
[ui.common.overview]
|
||||
title = "운영 현황"
|
||||
|
||||
[ui.common.status]
|
||||
active = "활성"
|
||||
@@ -197,10 +219,3 @@ pending = "준비 중"
|
||||
success = "성공"
|
||||
unchanged = "동일"
|
||||
updated = "수정"
|
||||
|
||||
[ui.common]
|
||||
searching = "검색 중..."
|
||||
|
||||
[ui.common.custom_claim_permission]
|
||||
admin_only = "관리자만 가능"
|
||||
user_and_admin = "사용자와 관리자"
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
|
||||
[msg]
|
||||
|
||||
[msg.admin]
|
||||
|
||||
[msg.admin.audit]
|
||||
subtitle = ""
|
||||
|
||||
[msg.common]
|
||||
loading_more = ""
|
||||
copied = ""
|
||||
error = ""
|
||||
forbidden = ""
|
||||
loading = ""
|
||||
no_results = ""
|
||||
loading_more = ""
|
||||
no_description = ""
|
||||
no_results = ""
|
||||
parsing = ""
|
||||
requesting = ""
|
||||
saving = ""
|
||||
@@ -20,20 +28,38 @@ loading = ""
|
||||
[msg.common.audit.registry]
|
||||
count = ""
|
||||
|
||||
[msg.admin.audit]
|
||||
subtitle = ""
|
||||
[msg.dev]
|
||||
|
||||
[msg.dev.audit]
|
||||
subtitle = ""
|
||||
|
||||
[ui]
|
||||
|
||||
[ui.admin]
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = ""
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = ""
|
||||
user_integrity = ""
|
||||
|
||||
[ui.admin.integrity.summary]
|
||||
failures_text = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.overview]
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.common]
|
||||
no_results = ""
|
||||
apply = "Apply"
|
||||
action = ""
|
||||
actions = ""
|
||||
add = ""
|
||||
all = ""
|
||||
apply = ""
|
||||
admin_only = ""
|
||||
all = ""
|
||||
apply = ""
|
||||
approve = ""
|
||||
assign = ""
|
||||
@@ -61,42 +87,45 @@ export_with_ids = ""
|
||||
export_without_ids = ""
|
||||
fail = ""
|
||||
go_home = ""
|
||||
info = ""
|
||||
view = ""
|
||||
hyphen = ""
|
||||
info = ""
|
||||
language = ""
|
||||
language_en = ""
|
||||
language_ko = ""
|
||||
load_more = ""
|
||||
loading = ""
|
||||
manage = ""
|
||||
move = ""
|
||||
move_org = ""
|
||||
na = ""
|
||||
name = ""
|
||||
never = ""
|
||||
next = ""
|
||||
no_results = ""
|
||||
none = ""
|
||||
page_of = ""
|
||||
prev = ""
|
||||
previous = ""
|
||||
qr = ""
|
||||
reject = ""
|
||||
rejected = ""
|
||||
reset = ""
|
||||
read = ""
|
||||
read_only = ""
|
||||
refresh = ""
|
||||
reject = ""
|
||||
rejected = ""
|
||||
remove = ""
|
||||
remove_org = ""
|
||||
resend = ""
|
||||
reset = ""
|
||||
retry = ""
|
||||
row = ""
|
||||
save = ""
|
||||
search = ""
|
||||
search_group = ""
|
||||
searching = ""
|
||||
select = ""
|
||||
select_file = ""
|
||||
select_placeholder = ""
|
||||
load_more = ""
|
||||
show_more = ""
|
||||
language = ""
|
||||
language_ko = ""
|
||||
language_en = ""
|
||||
submit = ""
|
||||
submitting = ""
|
||||
success = ""
|
||||
@@ -105,6 +134,8 @@ theme_light = ""
|
||||
theme_toggle = ""
|
||||
unassigned = ""
|
||||
unknown = ""
|
||||
view = ""
|
||||
write = ""
|
||||
|
||||
[ui.common.audit]
|
||||
load_more = ""
|
||||
@@ -114,12 +145,6 @@ title = ""
|
||||
actor_id = ""
|
||||
target = ""
|
||||
|
||||
[ui.common.audit.filters]
|
||||
user_id = ""
|
||||
client_id = ""
|
||||
action = ""
|
||||
status_all = ""
|
||||
|
||||
[ui.common.audit.details]
|
||||
actor = ""
|
||||
actor_id = ""
|
||||
@@ -135,24 +160,38 @@ path = ""
|
||||
request = ""
|
||||
request_id = ""
|
||||
result = ""
|
||||
tenant = ""
|
||||
target = ""
|
||||
tenant = ""
|
||||
|
||||
[ui.common.audit.filters]
|
||||
action = ""
|
||||
client_id = ""
|
||||
status_all = ""
|
||||
user_id = ""
|
||||
|
||||
[ui.common.audit.registry]
|
||||
title = ""
|
||||
|
||||
[ui.common.audit.table]
|
||||
no_logs = ""
|
||||
action = ""
|
||||
actor = ""
|
||||
client_id = ""
|
||||
user_id = ""
|
||||
no_logs = ""
|
||||
status = ""
|
||||
target = ""
|
||||
time = ""
|
||||
user_id = ""
|
||||
|
||||
[ui.common.overview]
|
||||
title = ""
|
||||
[ui.common.badge]
|
||||
admin_only = ""
|
||||
command_only = ""
|
||||
system = ""
|
||||
|
||||
[ui.common.chart]
|
||||
|
||||
[ui.common.chart.axis]
|
||||
x = ""
|
||||
y = ""
|
||||
|
||||
[ui.common.chart.period]
|
||||
day = ""
|
||||
@@ -162,29 +201,12 @@ week = ""
|
||||
[ui.common.chart.series_summary]
|
||||
login_users = ""
|
||||
|
||||
[ui.common.chart.axis]
|
||||
x = ""
|
||||
y = ""
|
||||
|
||||
[ui.admin.integrity]
|
||||
fetch_error = ""
|
||||
|
||||
[ui.admin.integrity.summary]
|
||||
failures_text = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.integrity.section]
|
||||
tenant_integrity = ""
|
||||
user_integrity = ""
|
||||
|
||||
[ui.admin.overview.chart]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.common.badge]
|
||||
[ui.common.custom_claim_permission]
|
||||
admin_only = ""
|
||||
command_only = ""
|
||||
system = ""
|
||||
user_and_admin = ""
|
||||
|
||||
[ui.common.overview]
|
||||
title = ""
|
||||
|
||||
[ui.common.status]
|
||||
active = ""
|
||||
@@ -197,10 +219,3 @@ pending = ""
|
||||
success = ""
|
||||
unchanged = ""
|
||||
updated = ""
|
||||
|
||||
[ui.common]
|
||||
searching = ""
|
||||
|
||||
[ui.common.custom_claim_permission]
|
||||
admin_only = ""
|
||||
user_and_admin = ""
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 805 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 815 KiB |
@@ -12,6 +12,7 @@
|
||||
"lint": "biome check .",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:ci": "pnpm test",
|
||||
"test:coverage": "vitest run --coverage --bail 1",
|
||||
"test:unit": "vitest run --bail 1",
|
||||
"test:roles": "playwright test tests/devfront-role-switch-report.spec.ts",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { 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 AppLayout from "./AppLayout";
|
||||
|
||||
@@ -49,6 +49,24 @@ vi.mock("../../lib/i18n", () => ({
|
||||
|
||||
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(() => {
|
||||
authState.isAuthenticated = true;
|
||||
authState.isLoading = false;
|
||||
@@ -89,7 +107,7 @@ async function renderLayout(initialEntry = "/clients") {
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<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>
|
||||
</Routes>
|
||||
@@ -181,4 +199,15 @@ describe("devfront AppLayout", () => {
|
||||
|
||||
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}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
row.valueType === "date" || row.valueType === "datetime"
|
||||
? "items-center"
|
||||
: "flex-col",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
type={rpClaimInputType(row.valueType)}
|
||||
inputMode={rpClaimInputMode(row.valueType)}
|
||||
@@ -1087,7 +1094,7 @@ function ClientConsentsPage() {
|
||||
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`}
|
||||
>
|
||||
{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 addScopeButton = Array.from(
|
||||
@@ -422,7 +422,7 @@ describe("ClientGeneralPage RP claims", () => {
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).not.toContain("offline_access");
|
||||
expect(container.textContent).toContain("offline_access");
|
||||
expect(container.textContent).toContain("old_claim");
|
||||
|
||||
const customClaimButton = Array.from(
|
||||
|
||||
@@ -759,6 +759,15 @@ function ClientGeneralPage() {
|
||||
description: tenantScopeDescription,
|
||||
source: "standard",
|
||||
},
|
||||
{
|
||||
id: "standard-offline-access",
|
||||
name: "offline_access",
|
||||
description: t(
|
||||
"msg.dev.clients.scopes.offline_access",
|
||||
"refresh token 발급 요청",
|
||||
),
|
||||
source: "standard",
|
||||
},
|
||||
],
|
||||
[tenantScopeDescription],
|
||||
);
|
||||
@@ -2789,6 +2798,7 @@ function ClientGeneralPage() {
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
key={claim.valueType}
|
||||
type={claimDefaultInputType(claim.valueType)}
|
||||
inputMode={claimDefaultInputMode(
|
||||
claim.valueType,
|
||||
|
||||
@@ -99,7 +99,7 @@ test.describe("DevFront RP claim cache", () => {
|
||||
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,
|
||||
}) => {
|
||||
const state = {
|
||||
@@ -142,9 +142,9 @@ test.describe("DevFront RP claim cache", () => {
|
||||
.getByRole("button", { name: /스코프 추가|Scope 추가|Add Scope/i })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText("offline_access", { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.getByText("offline_access", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /employee_code/ }),
|
||||
).toBeVisible();
|
||||
@@ -328,7 +328,11 @@ test.describe("DevFront RP claim cache", () => {
|
||||
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
|
||||
.first()
|
||||
.fill("3.14");
|
||||
const responsePromise = page.waitForResponse(
|
||||
"**/api/v1/dev/clients/client-claims",
|
||||
);
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
await responsePromise;
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -357,13 +361,7 @@ test.describe("DevFront RP claim cache", () => {
|
||||
const defaultValueInput = page
|
||||
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
|
||||
.first();
|
||||
await expect(defaultValueInput).toHaveAttribute("inputmode", "numeric");
|
||||
await expect(defaultValueInput).toHaveAttribute("pattern", "-?[0-9]*");
|
||||
await defaultValueInput.fill("3.14");
|
||||
|
||||
await expect(
|
||||
page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^저장$|^Save$/i }),
|
||||
).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; // 추가
|
||||
};
|
||||
|
||||
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 = {
|
||||
relation: 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);
|
||||
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(
|
||||
({ issuedAt, injectedRole }) => {
|
||||
({
|
||||
issuedAt,
|
||||
injectedRole,
|
||||
injectedProfile,
|
||||
injectedState,
|
||||
injectedIdToken,
|
||||
injectedAccessToken,
|
||||
injectedRefreshToken,
|
||||
injectedSessionState,
|
||||
injectedExpiresInSeconds,
|
||||
}) => {
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
|
||||
const mockOidcUser = {
|
||||
id_token: "playwright-id-token",
|
||||
session_state: "playwright-session",
|
||||
access_token: "playwright-access-token",
|
||||
refresh_token: "playwright-refresh-token",
|
||||
id_token: injectedIdToken,
|
||||
session_state: injectedSessionState,
|
||||
access_token: injectedAccessToken,
|
||||
refresh_token: injectedRefreshToken,
|
||||
token_type: "Bearer",
|
||||
scope: "openid profile email",
|
||||
profile: {
|
||||
sub: "playwright-user",
|
||||
email: "playwright@example.com",
|
||||
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 = [
|
||||
@@ -191,9 +277,25 @@ export async function seedAuth(page: Page, role?: string) {
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -6,11 +6,126 @@ class System implements Namespace {
|
||||
related: {
|
||||
super_admins: 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 = {
|
||||
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[]
|
||||
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) ||
|
||||
|
||||
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(']]')) {
|
||||
const sectionName = line.slice(2, -2).trim();
|
||||
currentSection = sectionName ? sectionName.split('.').map((p) => p.trim()).filter(Boolean) : [];
|
||||
currentSection = sectionName ? sectionName.split('.').map((p) => {
|
||||
p = p.trim();
|
||||
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
|
||||
p = p.slice(1, -1).trim();
|
||||
}
|
||||
return p;
|
||||
}).filter(Boolean) : [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
const sectionName = line.slice(1, -1).trim();
|
||||
currentSection = sectionName ? sectionName.split('.').map((p) => p.trim()).filter(Boolean) : [];
|
||||
currentSection = sectionName ? sectionName.split('.').map((p) => {
|
||||
p = p.trim();
|
||||
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
|
||||
p = p.slice(1, -1).trim();
|
||||
}
|
||||
return p;
|
||||
}).filter(Boolean) : [];
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -94,8 +106,8 @@ function parseTomlKeys(filePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.startsWith('"') && key.endsWith('"')) {
|
||||
key = key.slice(1, -1);
|
||||
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
|
||||
key = key.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
const fullKey = [...currentSection, key].join('.');
|
||||
|
||||
@@ -5,10 +5,42 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const LOCALES_DIR = path.join(ROOT, 'locales');
|
||||
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
|
||||
const KO_PATH = path.join(LOCALES_DIR, 'ko.toml');
|
||||
const EN_PATH = path.join(LOCALES_DIR, 'en.toml');
|
||||
|
||||
const LOCALE_SPECS = [
|
||||
{
|
||||
name: 'root',
|
||||
label: 'root locales',
|
||||
dir: path.join(ROOT, 'locales'),
|
||||
template: 'template.toml',
|
||||
langs: ['ko.toml', 'en.toml'],
|
||||
ownsKey: (key) => !key.startsWith('ui.common.') && !key.startsWith('msg.common.'),
|
||||
},
|
||||
{
|
||||
name: 'common',
|
||||
label: 'common locales',
|
||||
dir: path.join(ROOT, 'common', 'locales'),
|
||||
template: 'template.toml',
|
||||
langs: ['ko.toml', 'en.toml'],
|
||||
ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'),
|
||||
},
|
||||
];
|
||||
|
||||
function shouldIgnoreCodeKey(key) {
|
||||
return (
|
||||
key.includes('.msg.') ||
|
||||
key.includes('.ui.') ||
|
||||
key.includes('.err.') ||
|
||||
key.includes('.test.') ||
|
||||
key.includes('.non.') ||
|
||||
key.startsWith('ui.admin.users.list.table.') ||
|
||||
key.startsWith('msg.admin.users.detail.') ||
|
||||
key.startsWith('msg.dev.clients.') ||
|
||||
key.startsWith('ui.admin.users.create.') ||
|
||||
key.startsWith('ui.admin.users.detail.') ||
|
||||
key.startsWith('ui.dev.clients.') ||
|
||||
key.startsWith('ui.dev.session.')
|
||||
);
|
||||
}
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'.git',
|
||||
@@ -53,18 +85,33 @@ function parseToml(filePath) {
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
if (line.startsWith('[[') && line.endsWith(']]')) {
|
||||
const name = line.slice(2, -2).trim();
|
||||
section = name ? name.split('.').map((p) => p.trim()).filter(Boolean) : [];
|
||||
section = name ? name.split('.').map((p) => {
|
||||
p = p.trim();
|
||||
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
|
||||
p = p.slice(1, -1).trim();
|
||||
}
|
||||
return p;
|
||||
}).filter(Boolean) : [];
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
const name = line.slice(1, -1).trim();
|
||||
section = name ? name.split('.').map((p) => p.trim()).filter(Boolean) : [];
|
||||
section = name ? name.split('.').map((p) => {
|
||||
p = p.trim();
|
||||
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
|
||||
p = p.slice(1, -1).trim();
|
||||
}
|
||||
return p;
|
||||
}).filter(Boolean) : [];
|
||||
continue;
|
||||
}
|
||||
const eqIndex = line.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
const key = line.slice(0, eqIndex).trim();
|
||||
let key = line.slice(0, eqIndex).trim();
|
||||
if (!key) continue;
|
||||
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
|
||||
key = key.slice(1, -1).trim();
|
||||
}
|
||||
let valueRaw = line.slice(eqIndex + 1).trim();
|
||||
let value = '';
|
||||
if (
|
||||
@@ -88,13 +135,21 @@ function buildTree(keys, valuesMap) {
|
||||
let node = root;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!node[part]) node[part] = {};
|
||||
if (node[part] === undefined) {
|
||||
node[part] = {};
|
||||
} else if (typeof node[part] === 'string') {
|
||||
node[part] = { "": node[part] };
|
||||
}
|
||||
node = node[part];
|
||||
}
|
||||
const leaf = parts[parts.length - 1];
|
||||
const value = valuesMap ? (valuesMap.get(key) ?? '') : '';
|
||||
if (node[leaf] !== undefined && typeof node[leaf] === 'object') {
|
||||
node[leaf][""] = value;
|
||||
} else {
|
||||
node[leaf] = value;
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -105,12 +160,34 @@ function renderToml(tree) {
|
||||
lines.push(`[${path.join('.')}]`);
|
||||
}
|
||||
const keys = Object.keys(node).sort();
|
||||
const leafKeys = keys.filter((k) => typeof node[k] === 'string');
|
||||
const childKeys = keys.filter((k) => typeof node[k] === 'object');
|
||||
for (const key of leafKeys) {
|
||||
const value = node[key];
|
||||
lines.push(`${key} = ${JSON.stringify(value)}`);
|
||||
const leafKeys = [];
|
||||
const childKeys = [];
|
||||
|
||||
for (const key of keys) {
|
||||
if (typeof node[key] === 'string') {
|
||||
leafKeys.push(key);
|
||||
} else if (typeof node[key] === 'object') {
|
||||
if (node[key][""] !== undefined) {
|
||||
leafKeys.push(key);
|
||||
} else {
|
||||
childKeys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of leafKeys) {
|
||||
const val = node[key];
|
||||
if (typeof val === 'string') {
|
||||
lines.push(`${key} = ${JSON.stringify(val)}`);
|
||||
} else {
|
||||
lines.push(`${key} = ${JSON.stringify(val[""])}`);
|
||||
const subKeys = Object.keys(val).filter((k) => k !== "").sort();
|
||||
for (const subKey of subKeys) {
|
||||
lines.push(`${key}.${subKey} = ${JSON.stringify(val[subKey])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of childKeys) {
|
||||
lines.push('');
|
||||
walk(node[key], [...path, key]);
|
||||
@@ -389,15 +466,26 @@ function keyToEnglish(key) {
|
||||
}
|
||||
|
||||
function main() {
|
||||
const templateMap = parseToml(TEMPLATE_PATH);
|
||||
const koMap = parseToml(KO_PATH);
|
||||
const enMap = parseToml(EN_PATH);
|
||||
const fallbacks = extractFallbacks();
|
||||
|
||||
for (const spec of LOCALE_SPECS) {
|
||||
const templatePath = path.join(spec.dir, spec.template);
|
||||
const koPath = path.join(spec.dir, 'ko.toml');
|
||||
const enPath = path.join(spec.dir, 'en.toml');
|
||||
|
||||
const templateMap = parseToml(templatePath);
|
||||
const koMap = parseToml(koPath);
|
||||
const enMap = parseToml(enPath);
|
||||
|
||||
const ownedFallbackKeys = Array.from(fallbacks.keys()).filter(
|
||||
(key) => spec.ownsKey(key) && !shouldIgnoreCodeKey(key)
|
||||
);
|
||||
|
||||
const allKeys = new Set([
|
||||
...templateMap.keys(),
|
||||
...koMap.keys(),
|
||||
...enMap.keys(),
|
||||
...ownedFallbackKeys,
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
@@ -436,9 +524,10 @@ function main() {
|
||||
}
|
||||
|
||||
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)));
|
||||
fs.writeFileSync(koPath, renderToml(buildTree(keys, koMap)));
|
||||
fs.writeFileSync(enPath, renderToml(buildTree(keys, enMap)));
|
||||
fs.writeFileSync(templatePath, renderToml(buildTree(keys, null)));
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -56,11 +56,14 @@ result = "Result: {value}"
|
||||
session_id = "Session ID: {value}"
|
||||
status = "Status: pending"
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = "Toggle to view only active sessions."
|
||||
|
||||
[msg.userfront.consent]
|
||||
accept_error = "Failed to process consent: {error}"
|
||||
client_id = "Client ID: {id}"
|
||||
client_unknown = "Unknown application"
|
||||
description = "The service below is requesting access to your account information.\\\\nPlease choose whether to continue."
|
||||
description = "The service below is requesting access to your account information.\\\\\\\\nPlease choose whether to continue."
|
||||
load_error = "Failed to load consent information: {error}"
|
||||
missing_redirect = "Consent was processed, but the redirect URL was missing."
|
||||
redirect_notice = "After consent, you will be redirected automatically."
|
||||
@@ -82,15 +85,15 @@ approved_device = "Approved device: {device}"
|
||||
approved_ip = "Approved IP: {ip}"
|
||||
audit_empty = "No recent sign-in activity."
|
||||
audit_load_error = "Could not load sign-in history."
|
||||
auto_login_supported = "You can sign in without an extra login when opening this linked app."
|
||||
auth_method = "Auth method: {method}"
|
||||
auto_login_supported = "You can sign in without an extra login when opening this linked app."
|
||||
client_id = "Client ID: {id}"
|
||||
client_id_missing = "No client ID available."
|
||||
current_status = "Current status: {status}"
|
||||
last_auth = "Last signed in: {value}"
|
||||
link_status = "Link status: {status}"
|
||||
link_missing = "This app does not have a launch URL configured."
|
||||
link_open_error = "Could not open the app link."
|
||||
link_status = "Link status: {status}"
|
||||
render_error = "Dashboard render error: {error}"
|
||||
session_id_copied = "Session ID copied."
|
||||
|
||||
@@ -99,6 +102,19 @@ empty = "No linked apps yet."
|
||||
empty_detail = "Linked apps and their latest activity will appear here."
|
||||
error = "Could not load linked apps."
|
||||
|
||||
[msg.userfront.dashboard.approved_session]
|
||||
copy_click = "{label}: {id}\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nClick to copy."
|
||||
copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nTap to copy."
|
||||
none = "No {label}"
|
||||
|
||||
[msg.userfront.dashboard.revoke]
|
||||
confirm = "Disconnect {app}?\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in."
|
||||
error = "Could not disconnect the app: {error}"
|
||||
success = "{app} has been disconnected."
|
||||
|
||||
[msg.userfront.dashboard.scopes]
|
||||
empty = "No scopes were requested."
|
||||
|
||||
[msg.userfront.dashboard.sessions]
|
||||
browser = "Browser: {value}"
|
||||
empty = "No active sessions."
|
||||
@@ -109,23 +125,10 @@ recent_app = "Recent app: {app}"
|
||||
session_id = "Session ID: {id}"
|
||||
|
||||
[msg.userfront.dashboard.sessions.revoke]
|
||||
confirm = "End the session for {target}?\nThat device will need to sign in again."
|
||||
confirm = "End the session for {target}?\\nThat device will need to sign in again."
|
||||
error = "Could not end the session: {error}"
|
||||
success = "The session has been ended."
|
||||
|
||||
[msg.userfront.dashboard.approved_session]
|
||||
copy_click = "{label}: {id}\\\\\\\\\\\\\\\\nClick to copy."
|
||||
copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\nTap to copy."
|
||||
none = "No {label}"
|
||||
|
||||
[msg.userfront.dashboard.revoke]
|
||||
confirm = "Disconnect {app}?\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in."
|
||||
error = "Could not disconnect the app: {error}"
|
||||
success = "{app} has been disconnected."
|
||||
|
||||
[msg.userfront.dashboard.scopes]
|
||||
empty = "No scopes were requested."
|
||||
|
||||
[msg.userfront.dashboard.timeline]
|
||||
load_error = "Could not load sign-in history."
|
||||
|
||||
@@ -139,22 +142,6 @@ title_generic = "An error occurred."
|
||||
title_with_code = "Error: {code}"
|
||||
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]
|
||||
"$normalizedCode" = "{error}"
|
||||
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."
|
||||
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]
|
||||
"$normalizedCode" = "{error}"
|
||||
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."
|
||||
|
||||
[msg.userfront.login.unregistered]
|
||||
body = "We could not find an account for that information.\\\\\\\\\\\\\\\\nPlease sign up before continuing."
|
||||
body = "We could not find an account for that information.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nPlease sign up before continuing."
|
||||
|
||||
[msg.userfront.login.verification]
|
||||
approved = "Approved. Complete sign-in in the original window."
|
||||
approved_local = "Approved. This device is already signed in, and the remote window will be signed in shortly."
|
||||
approved_remote = "Your requested sign-in is complete."
|
||||
pending_remote = "Checking the sign-in approval request. Please wait."
|
||||
close_hint = "You can close this window now."
|
||||
pending_remote = "Checking the sign-in approval request. Please wait."
|
||||
success = "Sign-in approval completed."
|
||||
|
||||
[msg.userfront.login_success]
|
||||
@@ -465,6 +468,10 @@ dev_console = "Dev Console"
|
||||
|
||||
[ui.userfront.audit]
|
||||
|
||||
[ui.userfront.audit.filter]
|
||||
title = "Manage My Activity"
|
||||
toggle_label = "Show active sessions only"
|
||||
|
||||
[ui.userfront.audit.table]
|
||||
action = "Action"
|
||||
app = "App"
|
||||
@@ -499,17 +506,6 @@ status_history = "Link details"
|
||||
[ui.userfront.dashboard.activity]
|
||||
linked = "Linked"
|
||||
|
||||
[ui.userfront.dashboard.sessions]
|
||||
active_badge = "Active"
|
||||
current_badge = "Current"
|
||||
current_disabled = "Current session"
|
||||
unknown_device = "Unknown device"
|
||||
unknown_session = "Session"
|
||||
|
||||
[ui.userfront.dashboard.sessions.revoke]
|
||||
action = "End session"
|
||||
title = "End session"
|
||||
|
||||
[ui.userfront.dashboard.approved_session]
|
||||
default = "Default"
|
||||
userfront = "Approved UserFront session ID"
|
||||
@@ -521,6 +517,17 @@ title = "Disconnect app"
|
||||
[ui.userfront.dashboard.scopes]
|
||||
title = "Consent scopes"
|
||||
|
||||
[ui.userfront.dashboard.sessions]
|
||||
active_badge = "Active"
|
||||
current_badge = "Current"
|
||||
current_disabled = "Current session"
|
||||
unknown_device = "Unknown device"
|
||||
unknown_session = "Session"
|
||||
|
||||
[ui.userfront.dashboard.sessions.revoke]
|
||||
action = "End session"
|
||||
title = "End session"
|
||||
|
||||
[ui.userfront.dashboard.status]
|
||||
revoked = "Revoked"
|
||||
|
||||
@@ -583,8 +590,8 @@ title = "Account not found"
|
||||
|
||||
[ui.userfront.login.verification]
|
||||
action_label = "Done"
|
||||
action_label_remote = "Go to sign-in window"
|
||||
action_label_close = "Close Window"
|
||||
action_label_remote = "Go to sign-in window"
|
||||
page_title = "Baron SW Portal"
|
||||
title = "Approval complete"
|
||||
title_pending = "Checking approval"
|
||||
@@ -698,12 +705,3 @@ verify = "Verification"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "Go to sign-in"
|
||||
|
||||
|
||||
[ui.userfront.audit.filter]
|
||||
title = "Manage My Activity"
|
||||
toggle_label = "Show active sessions only"
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = "Toggle to view only active sessions."
|
||||
|
||||
|
||||
@@ -41,231 +41,6 @@ verify_code_failed = "인증 실패: {error}"
|
||||
[err.userfront.session]
|
||||
missing = "활성 세션이 없습니다."
|
||||
|
||||
[msg.userfront.audit]
|
||||
browser = "브라우저: {value}"
|
||||
date = "접속일자: {value}"
|
||||
device = "접속환경: {value}"
|
||||
end = "더 이상 항목이 없습니다."
|
||||
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
|
||||
ip = "접속 IP: {value}"
|
||||
load_more_error = "더 불러오지 못했습니다."
|
||||
result = "인증결과: {value}"
|
||||
session_id = "Session ID: {value}"
|
||||
status = "현황: (준비중)"
|
||||
|
||||
[msg.userfront.dashboard]
|
||||
approved_device = "승인 기기: {device}"
|
||||
approved_ip = "승인 IP: {ip}"
|
||||
audit_empty = "최근 접속 이력이 없습니다."
|
||||
audit_load_error = "접속이력을 불러오지 못했습니다."
|
||||
auth_method = "인증수단: {method}"
|
||||
client_id = "Client ID: {id}"
|
||||
client_id_missing = "Client ID 없음"
|
||||
current_status = "현재 상태: {status}"
|
||||
last_auth = "최근 인증: {value}"
|
||||
link_status = "연동 상태: {status}"
|
||||
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
|
||||
link_open_error = "해당 링크를 열 수 없습니다."
|
||||
render_error = "대시보드 렌더링 오류: {error}"
|
||||
session_id_copied = "세션 ID가 복사되었습니다."
|
||||
|
||||
[msg.userfront.error]
|
||||
detail_contact = "관리자에게 문의해 주세요."
|
||||
detail_generic = "오류가 발생했습니다."
|
||||
detail_request = "요청을 처리하는 중 문제가 발생했습니다."
|
||||
id = "오류 ID: {id}"
|
||||
title = "인증 과정에서 오류가 발생했습니다"
|
||||
title_generic = "오류가 발생했습니다"
|
||||
title_with_code = "오류: {code}"
|
||||
type = "오류 종류: {type}"
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = "계정"
|
||||
account_unknown = "알 수 없음"
|
||||
affiliated_tenants = "전체 소속 테넌트"
|
||||
allowed_box_title = "접속 가능 테넌트"
|
||||
allowed_tenants = "접속 가능 테넌트"
|
||||
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
|
||||
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
|
||||
loading = "현재 계정 정보를 불러오는 중입니다."
|
||||
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
|
||||
page_title = "애플리케이션 접근이 제한되었습니다"
|
||||
primary_tenant = "대표 소속 테넌트"
|
||||
tenant = "소속 테넌트"
|
||||
tenant_unknown = "알 수 없음"
|
||||
title = "접근 제한 정보"
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
|
||||
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
|
||||
error = "전송에 실패했습니다: {error}"
|
||||
input_required = "이메일 또는 휴대폰 번호를 입력해주세요."
|
||||
sent = "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."
|
||||
|
||||
[msg.userfront.login]
|
||||
cookie_check_failed = "로그인 확인 실패: {error}"
|
||||
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
|
||||
link_failed = "오류: {error}"
|
||||
link_send_failed = "전송 실패: {error}"
|
||||
link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다."
|
||||
link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다."
|
||||
link_timeout = "시간이 경과되었습니다."
|
||||
no_account = "계정이 없으신가요?"
|
||||
oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."
|
||||
qr_expired = "시간이 경과되었습니다."
|
||||
qr_init_failed = "QR 초기화에 실패했습니다: {error}"
|
||||
qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다"
|
||||
token_missing = "로그인 토큰을 확인할 수 없습니다."
|
||||
verification_failed = "승인 처리에 실패했습니다: {error}"
|
||||
|
||||
[msg.userfront.login_success]
|
||||
subtitle = "성공적으로 로그인되었습니다."
|
||||
|
||||
[msg.userfront.consent]
|
||||
accept_error = "동의 처리에 실패했습니다: {error}"
|
||||
client_id = "클라이언트 ID: {id}"
|
||||
client_unknown = "알 수 없는 앱"
|
||||
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요."
|
||||
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
|
||||
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
|
||||
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
|
||||
scope_count = "총 {count}개"
|
||||
|
||||
[msg.userfront.profile]
|
||||
department_missing = "소속 정보 없음"
|
||||
department_required = "소속을 입력해주세요."
|
||||
email_missing = "이메일 없음"
|
||||
greeting = "안녕하세요, {name}님"
|
||||
load_failed = "정보를 불러올 수 없습니다."
|
||||
name_missing = "이름 없음"
|
||||
name_required = "이름을 입력해주세요."
|
||||
phone_required = "휴대폰 번호를 입력해주세요."
|
||||
phone_verify_required = "휴대폰 번호 인증이 필요합니다."
|
||||
update_failed = "수정 실패: {error}"
|
||||
update_success = "정보가 수정되었습니다."
|
||||
|
||||
[msg.userfront.qr]
|
||||
camera_error = "카메라 오류: {error}"
|
||||
permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요."
|
||||
permission_required = "카메라 권한이 필요합니다."
|
||||
|
||||
[msg.userfront.reset]
|
||||
invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요."
|
||||
invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)"
|
||||
invalid_title = "유효하지 않은 링크입니다."
|
||||
policy_loading = "비밀번호 정책을 불러오는 중입니다..."
|
||||
success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."
|
||||
|
||||
[msg.userfront.sections]
|
||||
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
|
||||
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
|
||||
sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다."
|
||||
|
||||
[msg.userfront.settings]
|
||||
disabled = "현재 계정 설정 화면은 준비 중입니다."
|
||||
|
||||
[msg.userfront.signup]
|
||||
failed = "가입 실패: {error}"
|
||||
privacy_full = "개인정보 수집 및 이용 동의 전문..."
|
||||
tos_full = "서비스 이용약관 전문..."
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
command_only = "Command only"
|
||||
system = "System"
|
||||
|
||||
[ui.common.status]
|
||||
active = "활성"
|
||||
blocked = "차단됨"
|
||||
failure = "실패"
|
||||
inactive = "비활성"
|
||||
ok = "정상"
|
||||
pending = "준비 중"
|
||||
success = "성공"
|
||||
|
||||
[ui.userfront.app_label]
|
||||
admin_console = "Admin Console"
|
||||
baron = "Baron 로그인"
|
||||
dev_console = "Dev Console"
|
||||
|
||||
[ui.userfront.auth_method]
|
||||
ory = "Ory 세션"
|
||||
session = "세션"
|
||||
|
||||
[ui.userfront.dashboard]
|
||||
last_auth_label = "최근 인증"
|
||||
link_status_label = "연동 상태"
|
||||
status_history = "연동 정보"
|
||||
|
||||
[ui.userfront.device]
|
||||
android = "Mobile(Android)"
|
||||
ios = "Mobile(iOS)"
|
||||
linux = "Desktop(Linux)"
|
||||
macos = "Desktop(macOS)"
|
||||
windows = "Desktop(Windows)"
|
||||
|
||||
[ui.userfront.error]
|
||||
go_home = "홈으로 이동"
|
||||
go_login = "로그인으로 이동"
|
||||
switch_account = "다른 계정으로 로그인"
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = "비밀번호를 잊으셨나요?"
|
||||
input_label = "이메일 또는 휴대폰 번호"
|
||||
submit = "재설정 링크 전송"
|
||||
title = "비밀번호 재설정"
|
||||
|
||||
[ui.userfront.login]
|
||||
forgot_password = "비밀번호를 잊으셨나요?"
|
||||
signup = "회원가입"
|
||||
|
||||
[ui.userfront.login_success]
|
||||
later = "나중에 하기 (대시보드로 이동)"
|
||||
qr = "QR 인증 (카메라 켜기)"
|
||||
title = "로그인 완료"
|
||||
|
||||
[ui.userfront.consent]
|
||||
accept = "동의하고 계속하기"
|
||||
requested_scopes = "요청된 권한"
|
||||
title = "접근 권한 요청"
|
||||
|
||||
[ui.userfront.nav]
|
||||
dashboard = "대시보드"
|
||||
logout = "로그아웃"
|
||||
profile = "내 정보"
|
||||
qr_scan = "QR 스캔"
|
||||
|
||||
[ui.userfront.profile]
|
||||
department_empty = "소속 정보 없음"
|
||||
manage = "프로필 관리"
|
||||
user_fallback = "사용자"
|
||||
|
||||
[ui.userfront.qr]
|
||||
rescan = "다시 스캔"
|
||||
result_success = "승인 완료"
|
||||
title = "Scan QR Code"
|
||||
|
||||
[ui.userfront.reset]
|
||||
confirm_password = "새 비밀번호 확인"
|
||||
new_password = "새 비밀번호"
|
||||
submit = "비밀번호 변경"
|
||||
subtitle = "새로운 비밀번호 설정"
|
||||
title = "새 비밀번호 설정"
|
||||
|
||||
[ui.userfront.sections]
|
||||
apps = "나의 App 현황"
|
||||
audit = "접속이력"
|
||||
sessions = "활성 세션"
|
||||
|
||||
[ui.userfront.session]
|
||||
active = "세션 활성"
|
||||
unknown = "알 수 없음"
|
||||
|
||||
[ui.userfront.signup]
|
||||
complete = "가입 완료"
|
||||
next_step = "다음 단계"
|
||||
title = "회원가입"
|
||||
|
||||
[msg.userfront]
|
||||
greeting = "안녕하세요, {name}님"
|
||||
|
||||
@@ -281,11 +56,14 @@ result = "인증결과: {value}"
|
||||
session_id = "Session ID: {value}"
|
||||
status = "현황: (준비중)"
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||
|
||||
[msg.userfront.consent]
|
||||
accept_error = "동의 처리에 실패했습니다: {error}"
|
||||
client_id = "클라이언트 ID: {id}"
|
||||
client_unknown = "알 수 없는 앱"
|
||||
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\n계속 진행하려면 동의 여부를 선택해 주세요."
|
||||
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\\\\\n계속 진행하려면 동의 여부를 선택해 주세요."
|
||||
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
|
||||
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
|
||||
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
|
||||
@@ -307,14 +85,15 @@ approved_device = "승인 기기: {device}"
|
||||
approved_ip = "승인 IP: {ip}"
|
||||
audit_empty = "최근 접속 이력이 없습니다."
|
||||
audit_load_error = "접속이력을 불러오지 못했습니다."
|
||||
auto_login_supported = "연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다."
|
||||
auth_method = "인증수단: {method}"
|
||||
auto_login_supported = "연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다."
|
||||
client_id = "Client ID: {id}"
|
||||
client_id_missing = "Client ID 없음"
|
||||
current_status = "현재 상태: {status}"
|
||||
last_auth = "최근 인증: {value}"
|
||||
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
|
||||
link_open_error = "해당 링크를 열 수 없습니다."
|
||||
link_status = "연동 상태: {status}"
|
||||
render_error = "대시보드 렌더링 오류: {error}"
|
||||
session_id_copied = "세션 ID가 복사되었습니다."
|
||||
|
||||
@@ -323,6 +102,19 @@ empty = "연동된 앱이 없습니다."
|
||||
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
|
||||
error = "연동 정보를 불러오지 못했습니다."
|
||||
|
||||
[msg.userfront.dashboard.approved_session]
|
||||
copy_click = "{label}: {id}\\\\\\\\n클릭하면 복사됩니다."
|
||||
copy_tap = "{label}: {id}\\\\\\\\n탭하면 복사됩니다."
|
||||
none = "{label} 없음"
|
||||
|
||||
[msg.userfront.dashboard.revoke]
|
||||
confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
|
||||
error = "해지 실패: {error}"
|
||||
success = "{app} 연동이 해지되었습니다."
|
||||
|
||||
[msg.userfront.dashboard.scopes]
|
||||
empty = "요청된 권한이 없습니다."
|
||||
|
||||
[msg.userfront.dashboard.sessions]
|
||||
browser = "브라우저: {value}"
|
||||
empty = "활성 세션이 없습니다."
|
||||
@@ -333,23 +125,10 @@ recent_app = "최근 접속 앱: {app}"
|
||||
session_id = "세션 ID: {id}"
|
||||
|
||||
[msg.userfront.dashboard.sessions.revoke]
|
||||
confirm = "{target} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다."
|
||||
confirm = "{target} 세션을 종료하시겠습니까?\\n대상 기기에서는 다시 로그인이 필요합니다."
|
||||
error = "세션 종료 실패: {error}"
|
||||
success = "세션이 종료되었습니다."
|
||||
|
||||
[msg.userfront.dashboard.approved_session]
|
||||
copy_click = "{label}: {id}\\\\n클릭하면 복사됩니다."
|
||||
copy_tap = "{label}: {id}\\\\n탭하면 복사됩니다."
|
||||
none = "{label} 없음"
|
||||
|
||||
[msg.userfront.dashboard.revoke]
|
||||
confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
|
||||
error = "해지 실패: {error}"
|
||||
success = "{app} 연동이 해지되었습니다."
|
||||
|
||||
[msg.userfront.dashboard.scopes]
|
||||
empty = "요청된 권한이 없습니다."
|
||||
|
||||
[msg.userfront.dashboard.timeline]
|
||||
load_error = "접속이력을 불러오지 못했습니다."
|
||||
|
||||
@@ -363,22 +142,6 @@ 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.error.ory]
|
||||
"$normalizedCode" = "{error}"
|
||||
access_denied = "사용자가 동의를 거부했습니다."
|
||||
@@ -395,6 +158,22 @@ temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습
|
||||
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
|
||||
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]
|
||||
"$normalizedCode" = "{error}"
|
||||
bad_request = "입력값을 확인해 주세요."
|
||||
@@ -450,14 +229,14 @@ scan_hint = "모바일 앱으로 스캔하세요"
|
||||
invalid = "문자 2개와 숫자 6자리를 입력해 주세요."
|
||||
|
||||
[msg.userfront.login.unregistered]
|
||||
body = "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주세요."
|
||||
body = "가입되지 않은 정보입니다.\\\\\\\\n회원가입 후 이용해 주세요."
|
||||
|
||||
[msg.userfront.login.verification]
|
||||
approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."
|
||||
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
|
||||
approved_remote = "요청하신 로그인이 완료되었습니다"
|
||||
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
||||
close_hint = "이 창은 이제 닫으셔도 됩니다."
|
||||
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
||||
success = "로그인 승인에 성공했습니다."
|
||||
|
||||
[msg.userfront.login_success]
|
||||
@@ -532,6 +311,7 @@ uppercase = "대문자 1개 이상"
|
||||
[msg.userfront.sections]
|
||||
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
|
||||
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
|
||||
sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다."
|
||||
|
||||
[msg.userfront.settings]
|
||||
disabled = "현재 계정 설정 화면은 준비 중입니다."
|
||||
@@ -546,12 +326,12 @@ all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계
|
||||
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
|
||||
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
|
||||
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
|
||||
title = "서비스 이용을 위해\\\\n약관에 동의해주세요"
|
||||
title = "서비스 이용을 위해\\\\\\\\n약관에 동의해주세요"
|
||||
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
|
||||
|
||||
[msg.userfront.signup.auth]
|
||||
affiliate_notice = "가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요."
|
||||
title = "본인 확인을 위해\\\\n인증을 진행해주세요"
|
||||
title = "본인 확인을 위해\\\\\\\\n인증을 진행해주세요"
|
||||
|
||||
[msg.userfront.signup.email]
|
||||
code_mismatch = "인증코드가 일치하지 않습니다."
|
||||
@@ -567,7 +347,7 @@ lowercase_required = "소문자가 최소 1개 이상 포함되어야 합니다.
|
||||
mismatch = "비밀번호가 일치하지 않습니다."
|
||||
number_required = "숫자가 최소 1개 이상 포함되어야 합니다."
|
||||
symbol_required = "특수문자가 최소 1개 이상 포함되어야 합니다."
|
||||
title = "마지막으로\\\\n비밀번호를 설정해주세요"
|
||||
title = "마지막으로\\\\\\\\n비밀번호를 설정해주세요"
|
||||
uppercase_required = "대문자가 최소 1개 이상 포함되어야 합니다."
|
||||
|
||||
[msg.userfront.signup.password.rule]
|
||||
@@ -596,7 +376,7 @@ uppercase = "대문자"
|
||||
|
||||
[msg.userfront.signup.profile]
|
||||
affiliate_hint = "가족사 이메일 사용 시 자동으로 선택됩니다."
|
||||
title = "회원님의\\\\n소속 정보를 알려주세요"
|
||||
title = "회원님의\\\\\\\\n소속 정보를 알려주세요"
|
||||
|
||||
[msg.userfront.signup.success]
|
||||
body = "성공적으로 가입되었습니다."
|
||||
@@ -688,6 +468,10 @@ dev_console = "Dev Console"
|
||||
|
||||
[ui.userfront.audit]
|
||||
|
||||
[ui.userfront.audit.filter]
|
||||
title = "내 활동 관리"
|
||||
toggle_label = "활성 세션만 보기"
|
||||
|
||||
[ui.userfront.audit.table]
|
||||
action = "관리"
|
||||
app = "애플리케이션"
|
||||
@@ -716,22 +500,12 @@ title = "동의 취소"
|
||||
|
||||
[ui.userfront.dashboard]
|
||||
last_auth_label = "최근 인증"
|
||||
link_status_label = "연동 상태"
|
||||
status_history = "상태 이력"
|
||||
|
||||
[ui.userfront.dashboard.activity]
|
||||
linked = "연동됨"
|
||||
|
||||
[ui.userfront.dashboard.sessions]
|
||||
active_badge = "활성화"
|
||||
current_badge = "접속중"
|
||||
current_disabled = "현재 세션"
|
||||
unknown_device = "알 수 없는 기기"
|
||||
unknown_session = "세션 정보"
|
||||
|
||||
[ui.userfront.dashboard.sessions.revoke]
|
||||
action = "세션 종료"
|
||||
title = "세션 종료"
|
||||
|
||||
[ui.userfront.dashboard.approved_session]
|
||||
default = "승인한 세션 ID"
|
||||
userfront = "승인한 Userfront 세션 ID"
|
||||
@@ -743,6 +517,17 @@ title = "연동 해지"
|
||||
[ui.userfront.dashboard.scopes]
|
||||
title = "동의 범위"
|
||||
|
||||
[ui.userfront.dashboard.sessions]
|
||||
active_badge = "활성화"
|
||||
current_badge = "접속중"
|
||||
current_disabled = "현재 세션"
|
||||
unknown_device = "알 수 없는 기기"
|
||||
unknown_session = "세션 정보"
|
||||
|
||||
[ui.userfront.dashboard.sessions.revoke]
|
||||
action = "세션 종료"
|
||||
title = "세션 종료"
|
||||
|
||||
[ui.userfront.dashboard.status]
|
||||
revoked = "해지됨"
|
||||
|
||||
@@ -805,10 +590,10 @@ title = "미등록 회원"
|
||||
|
||||
[ui.userfront.login.verification]
|
||||
action_label = "확인"
|
||||
action_label_close = "창 닫기"
|
||||
action_label_remote = "로그인 창으로 이동하기"
|
||||
page_title = "Baron SW 포탈"
|
||||
title = "승인 완료"
|
||||
action_label_close = "창 닫기"
|
||||
title_pending = "로그인 승인 확인 중"
|
||||
title_remote = "로그인 승인 완료"
|
||||
|
||||
@@ -872,6 +657,7 @@ title = "새 비밀번호 설정"
|
||||
[ui.userfront.sections]
|
||||
apps = "나의 App 현황"
|
||||
audit = "접속이력"
|
||||
sessions = "활성 세션"
|
||||
|
||||
[ui.userfront.session]
|
||||
active = "세션 활성"
|
||||
@@ -919,12 +705,3 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "로그인하기"
|
||||
|
||||
|
||||
[ui.userfront.audit.filter]
|
||||
title = "내 활동 관리"
|
||||
toggle_label = "활성 세션만 보기"
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||
|
||||
|
||||
@@ -41,203 +41,6 @@ verify_code_failed = ""
|
||||
[err.userfront.session]
|
||||
missing = ""
|
||||
|
||||
[msg.userfront.error]
|
||||
detail_contact = ""
|
||||
detail_generic = ""
|
||||
detail_request = ""
|
||||
id = ""
|
||||
title = ""
|
||||
title_generic = ""
|
||||
title_with_code = ""
|
||||
type = ""
|
||||
|
||||
[msg.userfront.error.tenant]
|
||||
account = ""
|
||||
account_unknown = ""
|
||||
affiliated_tenants = ""
|
||||
allowed_box_title = ""
|
||||
allowed_tenants = ""
|
||||
detail = ""
|
||||
load_failed = ""
|
||||
loading = ""
|
||||
lookup_fallback = ""
|
||||
page_title = ""
|
||||
primary_tenant = ""
|
||||
tenant = ""
|
||||
tenant_unknown = ""
|
||||
title = ""
|
||||
|
||||
[msg.userfront.forgot]
|
||||
description = ""
|
||||
dry_send = ""
|
||||
error = ""
|
||||
input_required = ""
|
||||
sent = ""
|
||||
|
||||
[msg.userfront.login]
|
||||
cookie_check_failed = ""
|
||||
dry_send = ""
|
||||
link_failed = ""
|
||||
link_send_failed = ""
|
||||
link_sent_email = ""
|
||||
link_sent_phone = ""
|
||||
link_timeout = ""
|
||||
no_account = ""
|
||||
oidc_failed = ""
|
||||
qr_expired = ""
|
||||
qr_init_failed = ""
|
||||
qr_login_required = ""
|
||||
token_missing = ""
|
||||
verification_failed = ""
|
||||
|
||||
[msg.userfront.login_success]
|
||||
subtitle = ""
|
||||
|
||||
[msg.userfront.consent]
|
||||
accept_error = ""
|
||||
client_id = ""
|
||||
client_unknown = ""
|
||||
description = ""
|
||||
load_error = ""
|
||||
missing_redirect = ""
|
||||
redirect_notice = ""
|
||||
scope_count = ""
|
||||
|
||||
[msg.userfront.profile]
|
||||
department_missing = ""
|
||||
department_required = ""
|
||||
email_missing = ""
|
||||
greeting = ""
|
||||
load_failed = ""
|
||||
name_missing = ""
|
||||
name_required = ""
|
||||
phone_required = ""
|
||||
phone_verify_required = ""
|
||||
update_failed = ""
|
||||
update_success = ""
|
||||
|
||||
[msg.userfront.qr]
|
||||
camera_error = ""
|
||||
permission_error = ""
|
||||
permission_required = ""
|
||||
|
||||
[msg.userfront.reset]
|
||||
invalid_body = ""
|
||||
invalid_link = ""
|
||||
invalid_title = ""
|
||||
policy_loading = ""
|
||||
success = ""
|
||||
|
||||
[msg.userfront.sections]
|
||||
apps_subtitle = ""
|
||||
audit_subtitle = ""
|
||||
sessions_subtitle = ""
|
||||
|
||||
[msg.userfront.settings]
|
||||
disabled = ""
|
||||
|
||||
[msg.userfront.signup]
|
||||
failed = ""
|
||||
privacy_full = ""
|
||||
tos_full = ""
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = ""
|
||||
command_only = ""
|
||||
system = ""
|
||||
|
||||
[ui.common.status]
|
||||
active = ""
|
||||
blocked = ""
|
||||
failure = ""
|
||||
inactive = ""
|
||||
ok = ""
|
||||
pending = ""
|
||||
success = ""
|
||||
|
||||
[ui.userfront.app_label]
|
||||
admin_console = ""
|
||||
baron = ""
|
||||
dev_console = ""
|
||||
|
||||
[ui.userfront.auth_method]
|
||||
ory = ""
|
||||
session = ""
|
||||
|
||||
[ui.userfront.dashboard]
|
||||
link_status_label = ""
|
||||
last_auth_label = ""
|
||||
status_history = ""
|
||||
|
||||
[ui.userfront.device]
|
||||
android = ""
|
||||
ios = ""
|
||||
linux = ""
|
||||
macos = ""
|
||||
windows = ""
|
||||
|
||||
[ui.userfront.error]
|
||||
go_home = ""
|
||||
go_login = ""
|
||||
switch_account = ""
|
||||
|
||||
[ui.userfront.forgot]
|
||||
heading = ""
|
||||
input_label = ""
|
||||
submit = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.login]
|
||||
forgot_password = ""
|
||||
signup = ""
|
||||
|
||||
[ui.userfront.login_success]
|
||||
later = ""
|
||||
qr = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.consent]
|
||||
accept = ""
|
||||
requested_scopes = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.nav]
|
||||
dashboard = ""
|
||||
logout = ""
|
||||
profile = ""
|
||||
qr_scan = ""
|
||||
|
||||
[ui.userfront.profile]
|
||||
department_empty = ""
|
||||
manage = ""
|
||||
user_fallback = ""
|
||||
|
||||
[ui.userfront.qr]
|
||||
rescan = ""
|
||||
result_success = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.reset]
|
||||
confirm_password = ""
|
||||
new_password = ""
|
||||
submit = ""
|
||||
subtitle = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.sections]
|
||||
apps = ""
|
||||
audit = ""
|
||||
sessions = ""
|
||||
|
||||
[ui.userfront.session]
|
||||
active = ""
|
||||
unknown = ""
|
||||
|
||||
[ui.userfront.signup]
|
||||
complete = ""
|
||||
next_step = ""
|
||||
title = ""
|
||||
|
||||
[msg.userfront]
|
||||
greeting = ""
|
||||
|
||||
@@ -253,6 +56,9 @@ result = ""
|
||||
session_id = ""
|
||||
status = ""
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = ""
|
||||
|
||||
[msg.userfront.consent]
|
||||
accept_error = ""
|
||||
client_id = ""
|
||||
@@ -279,14 +85,15 @@ approved_device = ""
|
||||
approved_ip = ""
|
||||
audit_empty = ""
|
||||
audit_load_error = ""
|
||||
auto_login_supported = ""
|
||||
auth_method = ""
|
||||
auto_login_supported = ""
|
||||
client_id = ""
|
||||
client_id_missing = ""
|
||||
current_status = ""
|
||||
last_auth = ""
|
||||
link_missing = ""
|
||||
link_open_error = ""
|
||||
link_status = ""
|
||||
render_error = ""
|
||||
session_id_copied = ""
|
||||
|
||||
@@ -295,6 +102,19 @@ empty = ""
|
||||
empty_detail = ""
|
||||
error = ""
|
||||
|
||||
[msg.userfront.dashboard.approved_session]
|
||||
copy_click = ""
|
||||
copy_tap = ""
|
||||
none = ""
|
||||
|
||||
[msg.userfront.dashboard.revoke]
|
||||
confirm = ""
|
||||
error = ""
|
||||
success = ""
|
||||
|
||||
[msg.userfront.dashboard.scopes]
|
||||
empty = ""
|
||||
|
||||
[msg.userfront.dashboard.sessions]
|
||||
browser = ""
|
||||
empty = ""
|
||||
@@ -309,19 +129,6 @@ confirm = ""
|
||||
error = ""
|
||||
success = ""
|
||||
|
||||
[msg.userfront.dashboard.approved_session]
|
||||
copy_click = ""
|
||||
copy_tap = ""
|
||||
none = ""
|
||||
|
||||
[msg.userfront.dashboard.revoke]
|
||||
confirm = ""
|
||||
error = ""
|
||||
success = ""
|
||||
|
||||
[msg.userfront.dashboard.scopes]
|
||||
empty = ""
|
||||
|
||||
[msg.userfront.dashboard.timeline]
|
||||
load_error = ""
|
||||
|
||||
@@ -335,22 +142,6 @@ 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.error.ory]
|
||||
"$normalizedCode" = ""
|
||||
access_denied = ""
|
||||
@@ -367,6 +158,22 @@ temporarily_unavailable = ""
|
||||
unauthorized_client = ""
|
||||
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]
|
||||
"$normalizedCode" = ""
|
||||
bad_request = ""
|
||||
@@ -428,8 +235,8 @@ body = ""
|
||||
approved = ""
|
||||
approved_local = ""
|
||||
approved_remote = ""
|
||||
pending_remote = ""
|
||||
close_hint = ""
|
||||
pending_remote = ""
|
||||
success = ""
|
||||
|
||||
[msg.userfront.login_success]
|
||||
@@ -504,6 +311,7 @@ uppercase = ""
|
||||
[msg.userfront.sections]
|
||||
apps_subtitle = ""
|
||||
audit_subtitle = ""
|
||||
sessions_subtitle = ""
|
||||
|
||||
[msg.userfront.settings]
|
||||
disabled = ""
|
||||
@@ -660,6 +468,10 @@ dev_console = ""
|
||||
|
||||
[ui.userfront.audit]
|
||||
|
||||
[ui.userfront.audit.filter]
|
||||
title = ""
|
||||
toggle_label = ""
|
||||
|
||||
[ui.userfront.audit.table]
|
||||
action = ""
|
||||
app = ""
|
||||
@@ -688,22 +500,12 @@ title = ""
|
||||
|
||||
[ui.userfront.dashboard]
|
||||
last_auth_label = ""
|
||||
link_status_label = ""
|
||||
status_history = ""
|
||||
|
||||
[ui.userfront.dashboard.activity]
|
||||
linked = ""
|
||||
|
||||
[ui.userfront.dashboard.sessions]
|
||||
active_badge = ""
|
||||
current_badge = ""
|
||||
current_disabled = ""
|
||||
unknown_device = ""
|
||||
unknown_session = ""
|
||||
|
||||
[ui.userfront.dashboard.sessions.revoke]
|
||||
action = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.dashboard.approved_session]
|
||||
default = ""
|
||||
userfront = ""
|
||||
@@ -715,6 +517,17 @@ title = ""
|
||||
[ui.userfront.dashboard.scopes]
|
||||
title = ""
|
||||
|
||||
[ui.userfront.dashboard.sessions]
|
||||
active_badge = ""
|
||||
current_badge = ""
|
||||
current_disabled = ""
|
||||
unknown_device = ""
|
||||
unknown_session = ""
|
||||
|
||||
[ui.userfront.dashboard.sessions.revoke]
|
||||
action = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.dashboard.status]
|
||||
revoked = ""
|
||||
|
||||
@@ -777,8 +590,8 @@ title = ""
|
||||
|
||||
[ui.userfront.login.verification]
|
||||
action_label = ""
|
||||
action_label_remote = ""
|
||||
action_label_close = ""
|
||||
action_label_remote = ""
|
||||
page_title = ""
|
||||
title = ""
|
||||
title_pending = ""
|
||||
@@ -844,6 +657,7 @@ title = ""
|
||||
[ui.userfront.sections]
|
||||
apps = ""
|
||||
audit = ""
|
||||
sessions = ""
|
||||
|
||||
[ui.userfront.session]
|
||||
active = ""
|
||||
@@ -891,12 +705,3 @@ verify = ""
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = ""
|
||||
|
||||
|
||||
[ui.userfront.audit.filter]
|
||||
title = ""
|
||||
toggle_label = ""
|
||||
|
||||
[msg.userfront.audit.filter]
|
||||
description = ""
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'providers/linked_rps_provider.dart';
|
||||
import 'models.dart';
|
||||
|
||||
String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
|
||||
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/http_client.dart';
|
||||
import 'package:userfront/core/services/runtime_env.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
import '../models.dart';
|
||||
|
||||
class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
||||
@override
|
||||
|
||||
@@ -21,7 +21,7 @@ import '../../../../core/ui/layout_breakpoints.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
import '../domain/dashboard_providers.dart';
|
||||
import '../domain/models.dart' hide LinkedRp;
|
||||
import '../domain/models.dart';
|
||||
import 'audit_device_utils.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_test/flutter_test.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({
|
||||
required String status,
|
||||
|
||||
Reference in New Issue
Block a user