forked from baron/baron-sso
orgfront refresh token 관리 추가
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,6 +17,8 @@ config/.generated/
|
|||||||
.npm-cache/
|
.npm-cache/
|
||||||
reports
|
reports
|
||||||
reports/*
|
reports/*
|
||||||
|
/backups/
|
||||||
|
/tmp/rp-restore-*/
|
||||||
config/*.pem
|
config/*.pem
|
||||||
common/node_modules
|
common/node_modules
|
||||||
common/.baron-deps-install.lock
|
common/.baron-deps-install.lock
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { render, renderHook, screen, waitFor } from "@testing-library/react";
|
import { render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
import {
|
||||||
|
fetchMe,
|
||||||
|
fetchTenant,
|
||||||
|
type TenantSummary,
|
||||||
|
type UserProfileResponse,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
|
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
|
||||||
import { useTenantPermission } from "./useTenantPermission";
|
import { useTenantPermission } from "./useTenantPermission";
|
||||||
|
|
||||||
@@ -22,18 +27,52 @@ function createWrapper() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockProfile(
|
||||||
|
overrides: Partial<UserProfileResponse>,
|
||||||
|
): UserProfileResponse {
|
||||||
|
return {
|
||||||
|
id: "user-id",
|
||||||
|
email: "user@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
phone: "",
|
||||||
|
role: "user",
|
||||||
|
department: "",
|
||||||
|
affiliationType: "general",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockTenant(overrides: Partial<TenantSummary>): TenantSummary {
|
||||||
|
return {
|
||||||
|
id: "tenant-id",
|
||||||
|
type: "COMPANY",
|
||||||
|
name: "Test Tenant",
|
||||||
|
slug: "test-tenant",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("useTenantPermission", () => {
|
describe("useTenantPermission", () => {
|
||||||
it("returns true for all permissions if user is super_admin", async () => {
|
it("returns true for all permissions if user is super_admin", async () => {
|
||||||
vi.mocked(fetchMe).mockResolvedValue({
|
vi.mocked(fetchMe).mockResolvedValue(
|
||||||
id: "user-super",
|
mockProfile({
|
||||||
role: "super_admin",
|
id: "user-super",
|
||||||
} as any);
|
role: "super_admin",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mocked(fetchTenant).mockResolvedValue({
|
vi.mocked(fetchTenant).mockResolvedValue(
|
||||||
id: "tenant-1",
|
mockTenant({
|
||||||
name: "Super Tenant",
|
id: "tenant-1",
|
||||||
userPermissions: { view: false, manage: false, manage_admins: false },
|
name: "Super Tenant",
|
||||||
} as any);
|
userPermissions: { view: false, manage: false, manage_admins: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useTenantPermission("tenant-1"), {
|
const { result } = renderHook(() => useTenantPermission("tenant-1"), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
@@ -49,16 +88,20 @@ describe("useTenantPermission", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns permissions mapped from userPermissions for normal admins/users", async () => {
|
it("returns permissions mapped from userPermissions for normal admins/users", async () => {
|
||||||
vi.mocked(fetchMe).mockResolvedValue({
|
vi.mocked(fetchMe).mockResolvedValue(
|
||||||
id: "user-admin",
|
mockProfile({
|
||||||
role: "tenant_admin",
|
id: "user-admin",
|
||||||
} as any);
|
role: "tenant_admin",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mocked(fetchTenant).mockResolvedValue({
|
vi.mocked(fetchTenant).mockResolvedValue(
|
||||||
id: "tenant-2",
|
mockTenant({
|
||||||
name: "Tenant Admin Corp",
|
id: "tenant-2",
|
||||||
userPermissions: { view: true, manage: true, manage_admins: false },
|
name: "Tenant Admin Corp",
|
||||||
} as any);
|
userPermissions: { view: true, manage: true, manage_admins: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useTenantPermission("tenant-2"), {
|
const { result } = renderHook(() => useTenantPermission("tenant-2"), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
@@ -76,15 +119,19 @@ describe("useTenantPermission", () => {
|
|||||||
|
|
||||||
describe("TenantPermissionGuard", () => {
|
describe("TenantPermissionGuard", () => {
|
||||||
it("renders children when user has permission", async () => {
|
it("renders children when user has permission", async () => {
|
||||||
vi.mocked(fetchMe).mockResolvedValue({
|
vi.mocked(fetchMe).mockResolvedValue(
|
||||||
id: "user-admin",
|
mockProfile({
|
||||||
role: "tenant_admin",
|
id: "user-admin",
|
||||||
} as any);
|
role: "tenant_admin",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mocked(fetchTenant).mockResolvedValue({
|
vi.mocked(fetchTenant).mockResolvedValue(
|
||||||
id: "tenant-3",
|
mockTenant({
|
||||||
userPermissions: { view: true, manage: true, manage_admins: false },
|
id: "tenant-3",
|
||||||
} as any);
|
userPermissions: { view: true, manage: true, manage_admins: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TenantPermissionGuard
|
<TenantPermissionGuard
|
||||||
@@ -104,15 +151,19 @@ describe("TenantPermissionGuard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders fallback when user lacks permission", async () => {
|
it("renders fallback when user lacks permission", async () => {
|
||||||
vi.mocked(fetchMe).mockResolvedValue({
|
vi.mocked(fetchMe).mockResolvedValue(
|
||||||
id: "user-admin",
|
mockProfile({
|
||||||
role: "tenant_admin",
|
id: "user-admin",
|
||||||
} as any);
|
role: "tenant_admin",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mocked(fetchTenant).mockResolvedValue({
|
vi.mocked(fetchTenant).mockResolvedValue(
|
||||||
id: "tenant-4",
|
mockTenant({
|
||||||
userPermissions: { view: true, manage: false, manage_admins: false },
|
id: "tenant-4",
|
||||||
} as any);
|
userPermissions: { view: true, manage: false, manage_admins: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TenantPermissionGuard
|
<TenantPermissionGuard
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createI18nMock } from "../../../test/i18nMock";
|
||||||
|
import { TenantFineGrainedPermissionsPage } from "./TenantFineGrainedPermissionsPage";
|
||||||
|
|
||||||
|
const fetchUsersMock = vi.hoisted(() => vi.fn());
|
||||||
|
const bulkUpdateUsersMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
|
vi.mock("../../../lib/adminApi", () => ({
|
||||||
|
addSystemRelation: vi.fn(async () => undefined),
|
||||||
|
addTenantRelation: vi.fn(async () => undefined),
|
||||||
|
bulkUpdateUsers: bulkUpdateUsersMock,
|
||||||
|
fetchAllTenants: vi.fn(async () => ({ items: [], total: 0 })),
|
||||||
|
fetchMe: vi.fn(async () => ({
|
||||||
|
id: "current-admin",
|
||||||
|
name: "Current Admin",
|
||||||
|
email: "current@example.com",
|
||||||
|
role: "super_admin",
|
||||||
|
})),
|
||||||
|
fetchSystemRelations: vi.fn(async () => []),
|
||||||
|
fetchTenantRelations: vi.fn(async () => []),
|
||||||
|
fetchUsers: fetchUsersMock,
|
||||||
|
removeSystemRelation: vi.fn(async () => undefined),
|
||||||
|
removeTenantRelation: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderWithProviders(ui: React.ReactElement) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={["/permissions-direct"]}>{ui}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TenantFineGrainedPermissionsPage Super Admin role tab", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
bulkUpdateUsersMock.mockResolvedValue({ results: [] });
|
||||||
|
fetchUsersMock.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "current-admin",
|
||||||
|
name: "Current Admin",
|
||||||
|
email: "current@example.com",
|
||||||
|
role: "super_admin",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-06-17T00:00:00Z",
|
||||||
|
updatedAt: "2026-06-17T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bootstrap-admin",
|
||||||
|
name: "Bootstrap Admin",
|
||||||
|
email: "env-admin@example.com",
|
||||||
|
role: "super_admin",
|
||||||
|
status: "active",
|
||||||
|
metadata: { bootstrapSuperAdmin: true },
|
||||||
|
createdAt: "2026-06-17T00:00:00Z",
|
||||||
|
updatedAt: "2026-06-17T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delegated-admin",
|
||||||
|
name: "Delegated Admin",
|
||||||
|
email: "delegated@example.com",
|
||||||
|
role: "super_admin",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-06-17T00:00:00Z",
|
||||||
|
updatedAt: "2026-06-17T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "regular-user",
|
||||||
|
name: "Regular User",
|
||||||
|
email: "regular@example.com",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-06-17T00:00:00Z",
|
||||||
|
updatedAt: "2026-06-17T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 4,
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows revocable super admin users even when they have no direct system relations", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/permissions-direct"
|
||||||
|
element={<TenantFineGrainedPermissionsPage />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("tab", { name: "Super Admin 역할" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Delegated Admin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("delegated@example.com")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Current Admin")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Bootstrap Admin")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Regular User")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByTestId("super-admin-role-user-delegated-admin"),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Super Admin 회수" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
|
||||||
|
userIds: ["delegated-admin"],
|
||||||
|
role: "user",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Network,
|
Network,
|
||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
Plus,
|
|
||||||
Search,
|
Search,
|
||||||
Share2,
|
Share2,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -19,10 +18,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Avatar, AvatarFallback } from "../../../components/ui/avatar";
|
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import { Card, CardContent } from "../../../components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -31,7 +28,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -49,6 +45,7 @@ import {
|
|||||||
fetchMe,
|
fetchMe,
|
||||||
fetchSystemRelations,
|
fetchSystemRelations,
|
||||||
fetchTenantRelations,
|
fetchTenantRelations,
|
||||||
|
fetchUsers,
|
||||||
removeSystemRelation,
|
removeSystemRelation,
|
||||||
removeTenantRelation,
|
removeTenantRelation,
|
||||||
type TenantRelation,
|
type TenantRelation,
|
||||||
@@ -66,6 +63,10 @@ const protectedSystemMenuRelations = new Set([
|
|||||||
"permissions_direct",
|
"permissions_direct",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function isBootstrapSuperAdminUser(user: UserSummary) {
|
||||||
|
return user.metadata?.bootstrapSuperAdmin === true;
|
||||||
|
}
|
||||||
|
|
||||||
export function TenantFineGrainedPermissionsPage() {
|
export function TenantFineGrainedPermissionsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -79,8 +80,7 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
const [bulkRelationMode, setBulkRelationMode] = useState<
|
const [bulkRelationMode, setBulkRelationMode] = useState<
|
||||||
"page" | "target-action"
|
"page" | "target-action"
|
||||||
>("page");
|
>("page");
|
||||||
const [bulkPageRelation, setBulkPageRelation] =
|
const [bulkPageRelation, setBulkPageRelation] = useState("overview_viewers");
|
||||||
useState("overview_viewers");
|
|
||||||
const [bulkTenantPage, setBulkTenantPage] = useState("profile");
|
const [bulkTenantPage, setBulkTenantPage] = useState("profile");
|
||||||
const [bulkAction, setBulkAction] = useState<"read" | "manage">("read");
|
const [bulkAction, setBulkAction] = useState<"read" | "manage">("read");
|
||||||
const [tenantPickerOpen, setTenantPickerOpen] = useState(false);
|
const [tenantPickerOpen, setTenantPickerOpen] = useState(false);
|
||||||
@@ -94,15 +94,12 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
>("user");
|
>("user");
|
||||||
const orgChartMemberPickerUrl = useMemo(
|
const orgChartMemberPickerUrl = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildAuthenticatedOrgChartUserMultiPickerUrl(import.meta.env.ORGFRONT_URL),
|
buildAuthenticatedOrgChartUserMultiPickerUrl(
|
||||||
|
import.meta.env.ORGFRONT_URL,
|
||||||
|
),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🌟 글로벌 시스템 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
|
|
||||||
const [localSystemPermissions, setLocalSystemPermissions] = useState<
|
|
||||||
Record<string, Record<string, "none" | "read" | "write">>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
@@ -128,6 +125,30 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
});
|
});
|
||||||
const systemRelations = systemRelationsQuery.data ?? [];
|
const systemRelations = systemRelationsQuery.data ?? [];
|
||||||
|
|
||||||
|
const superAdminUsersQuery = useQuery({
|
||||||
|
queryKey: ["admin-users", "super-admin-role-candidates"],
|
||||||
|
queryFn: () => fetchUsers(10000, 0),
|
||||||
|
enabled: isSuperAdmin && activePermissionTab === "super-admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
const revocableSuperAdminUsers = useMemo(() => {
|
||||||
|
const currentAdminId = profile?.id ?? "";
|
||||||
|
const currentAdminEmail = (profile?.email ?? "").trim().toLowerCase();
|
||||||
|
|
||||||
|
return (superAdminUsersQuery.data?.items ?? []).filter((user) => {
|
||||||
|
if (user.role !== "super_admin") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (user.id === currentAdminId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (user.email.trim().toLowerCase() === currentAdminEmail) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !isBootstrapSuperAdminUser(user);
|
||||||
|
});
|
||||||
|
}, [profile?.email, profile?.id, superAdminUsersQuery.data?.items]);
|
||||||
|
|
||||||
const tenantRelationsQuery = useQuery({
|
const tenantRelationsQuery = useQuery({
|
||||||
queryKey: ["tenant-relations", targetTenantId],
|
queryKey: ["tenant-relations", targetTenantId],
|
||||||
queryFn: () => fetchTenantRelations(targetTenantId),
|
queryFn: () => fetchTenantRelations(targetTenantId),
|
||||||
@@ -139,42 +160,6 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
});
|
});
|
||||||
const tenantRelations = tenantRelationsQuery.data ?? [];
|
const tenantRelations = tenantRelationsQuery.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({
|
const addSystemRelationMutation = useMutation({
|
||||||
mutationFn: (payload: { userId: string; relation: string }) =>
|
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||||
addSystemRelation(payload.userId, payload.relation),
|
addSystemRelation(payload.userId, payload.relation),
|
||||||
@@ -325,14 +310,19 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
const updateUserRoleMutation = useMutation({
|
const updateUserRoleMutation = useMutation({
|
||||||
mutationFn: (payload: { userIds: string[]; role: string }) =>
|
mutationFn: (payload: { userIds: string[]; role: string }) =>
|
||||||
bulkUpdateUsers(payload),
|
bulkUpdateUsers(payload),
|
||||||
onSuccess: () => {
|
onSuccess: (_data, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin-users"] });
|
queryClient.invalidateQueries({ queryKey: ["admin-users"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
variables.role === "super_admin"
|
||||||
"msg.admin.permissions_direct.super_admin_grant_success",
|
? t(
|
||||||
"Super Admin 역할이 부여되었습니다.",
|
"msg.admin.permissions_direct.super_admin_grant_success",
|
||||||
),
|
"Super Admin 역할이 부여되었습니다.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"msg.admin.permissions_direct.super_admin_revoke_success",
|
||||||
|
"Super Admin 역할을 회수했습니다.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setSelectedSuperAdminUserIds([]);
|
setSelectedSuperAdminUserIds([]);
|
||||||
},
|
},
|
||||||
@@ -344,70 +334,6 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSuperAdminUser = (userId: string, checked: boolean) => {
|
const toggleSuperAdminUser = (userId: string, checked: boolean) => {
|
||||||
setSelectedSuperAdminUserIds((current) =>
|
setSelectedSuperAdminUserIds((current) =>
|
||||||
checked
|
checked
|
||||||
@@ -435,7 +361,10 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const relation = resolveBulkRelation();
|
const relation = resolveBulkRelation();
|
||||||
if (bulkRelationMode === "page" && relation.startsWith("permissions_direct_")) {
|
if (
|
||||||
|
bulkRelationMode === "page" &&
|
||||||
|
relation.startsWith("permissions_direct_")
|
||||||
|
) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t(
|
t(
|
||||||
"msg.admin.permissions_direct.protected_relation",
|
"msg.admin.permissions_direct.protected_relation",
|
||||||
@@ -489,19 +418,19 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
setQueuedTargetUsers([]);
|
setQueuedTargetUsers([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGrantSuperAdminRole = () => {
|
const handleRevokeSuperAdminRole = () => {
|
||||||
if (selectedSuperAdminUserIds.length === 0) {
|
if (selectedSuperAdminUserIds.length === 0) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t(
|
t(
|
||||||
"msg.admin.permissions_direct.super_admin_users_required",
|
"msg.admin.permissions_direct.super_admin_users_required",
|
||||||
"Super Admin을 부여할 사용자를 하나 이상 선택하세요.",
|
"회수할 Super Admin 사용자를 하나 이상 선택하세요.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateUserRoleMutation.mutate({
|
updateUserRoleMutation.mutate({
|
||||||
userIds: selectedSuperAdminUserIds,
|
userIds: selectedSuperAdminUserIds,
|
||||||
role: "super_admin",
|
role: "user",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -537,18 +466,18 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
name: selection.name,
|
name: selection.name,
|
||||||
email: selection.email,
|
email: selection.email,
|
||||||
tenantSlug: selection.leafTenantName,
|
tenantSlug: selection.leafTenantName,
|
||||||
tenant: selection.leafTenantName
|
tenant: selection.leafTenantName
|
||||||
? {
|
? {
|
||||||
id: "",
|
id: "",
|
||||||
type: "ORGANIZATION",
|
type: "ORGANIZATION",
|
||||||
slug: "",
|
slug: "",
|
||||||
name: selection.leafTenantName,
|
name: selection.leafTenantName,
|
||||||
description: "",
|
description: "",
|
||||||
status: "active",
|
status: "active",
|
||||||
memberCount: 0,
|
memberCount: 0,
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
updatedAt: "",
|
updatedAt: "",
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
rootTenantName: selection.rootTenantName,
|
rootTenantName: selection.rootTenantName,
|
||||||
@@ -698,8 +627,6 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const filteredRelations = systemRelations;
|
|
||||||
const selectedUser = undefined;
|
|
||||||
const grantableSystemMenus = systemMenuCategories.flatMap((category) =>
|
const grantableSystemMenus = systemMenuCategories.flatMap((category) =>
|
||||||
category.menus.filter(
|
category.menus.filter(
|
||||||
(menu) => !protectedSystemMenuRelations.has(menu.relation),
|
(menu) => !protectedSystemMenuRelations.has(menu.relation),
|
||||||
@@ -975,7 +902,19 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
{t("ui.admin.permissions_direct.bulk_selected", "명 선택")}
|
{t("ui.admin.permissions_direct.bulk_selected", "명 선택")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,0.8fr)_minmax(320px,1.2fr)]">
|
<div className="grid gap-3 lg:grid-cols-[minmax(320px,1.2fr)_minmax(0,0.8fr)]">
|
||||||
|
<div className="min-h-[300px] overflow-hidden rounded-md border bg-background">
|
||||||
|
<iframe
|
||||||
|
title={t(
|
||||||
|
"ui.admin.permissions_direct.target_org_picker",
|
||||||
|
"조직도에서 사용자 선택",
|
||||||
|
)}
|
||||||
|
src={orgChartMemberPickerUrl}
|
||||||
|
className="h-[340px] w-full"
|
||||||
|
data-testid="permission-target-org-picker-frame"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div
|
<div
|
||||||
className="min-h-16 rounded-md border bg-background p-2"
|
className="min-h-16 rounded-md border bg-background p-2"
|
||||||
@@ -989,66 +928,53 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{queuedTargetUsers.map((user) => {
|
{queuedTargetUsers.map((user) => {
|
||||||
const rootTenantName =
|
const rootTenantName =
|
||||||
typeof user.metadata?.rootTenantName === "string"
|
typeof user.metadata?.rootTenantName === "string"
|
||||||
? user.metadata.rootTenantName
|
? user.metadata.rootTenantName
|
||||||
: "";
|
: "";
|
||||||
const leafTenantName =
|
const leafTenantName =
|
||||||
typeof user.metadata?.leafTenantName === "string"
|
typeof user.metadata?.leafTenantName === "string"
|
||||||
? user.metadata.leafTenantName
|
? user.metadata.leafTenantName
|
||||||
: "";
|
: "";
|
||||||
const tenantPath = [
|
const tenantPath = [rootTenantName, leafTenantName]
|
||||||
rootTenantName,
|
.filter(Boolean)
|
||||||
leafTenantName,
|
.join(" / ");
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" / ");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={user.id}
|
key={user.id}
|
||||||
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-muted/30 px-2 py-1 text-sm"
|
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-muted/30 px-2 py-1 text-sm"
|
||||||
>
|
>
|
||||||
<span className="max-w-52 truncate">
|
<span className="max-w-52 truncate">
|
||||||
{user.name}
|
{user.name}
|
||||||
</span>
|
</span>
|
||||||
{tenantPath !== "" && (
|
{tenantPath !== "" && (
|
||||||
<span className="max-w-64 truncate text-xs text-muted-foreground">
|
<span className="max-w-64 truncate text-xs text-muted-foreground">
|
||||||
{tenantPath}
|
{tenantPath}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => removeQueuedTargetUser(user.id)}
|
onClick={() =>
|
||||||
aria-label={t(
|
removeQueuedTargetUser(user.id)
|
||||||
"ui.admin.permissions_direct.target_queue_remove",
|
}
|
||||||
"적용 대상에서 제거",
|
aria-label={t(
|
||||||
)}
|
"ui.admin.permissions_direct.target_queue_remove",
|
||||||
>
|
"적용 대상에서 제거",
|
||||||
<X size={14} />
|
)}
|
||||||
</button>
|
>
|
||||||
</span>
|
<X size={14} />
|
||||||
);
|
</button>
|
||||||
})}
|
</span>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-[300px] overflow-hidden rounded-md border bg-background">
|
|
||||||
<iframe
|
|
||||||
title={t(
|
|
||||||
"ui.admin.permissions_direct.target_org_picker",
|
|
||||||
"조직도에서 사용자 선택",
|
|
||||||
)}
|
|
||||||
src={orgChartMemberPickerUrl}
|
|
||||||
className="h-[340px] w-full"
|
|
||||||
data-testid="permission-target-org-picker-frame"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1056,6 +982,7 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
<label className="space-y-2 text-sm font-medium">
|
<label className="space-y-2 text-sm font-medium">
|
||||||
{t("ui.admin.permissions_direct.bulk_mode", "권한 방식")}
|
{t("ui.admin.permissions_direct.bulk_mode", "권한 방식")}
|
||||||
<select
|
<select
|
||||||
|
name="bulk-relation-mode"
|
||||||
data-testid="bulk-relation-mode"
|
data-testid="bulk-relation-mode"
|
||||||
value={bulkRelationMode}
|
value={bulkRelationMode}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -1087,6 +1014,7 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
"페이지 접근 권한",
|
"페이지 접근 권한",
|
||||||
)}
|
)}
|
||||||
<select
|
<select
|
||||||
|
name="bulk-page-relation"
|
||||||
data-testid="bulk-page-relation"
|
data-testid="bulk-page-relation"
|
||||||
value={bulkPageRelation}
|
value={bulkPageRelation}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -1106,12 +1034,16 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
<div className="space-y-2 text-sm font-medium">
|
<div className="space-y-2 text-sm font-medium">
|
||||||
{t("ui.admin.permissions_direct.bulk_target", "대상")}
|
{t("ui.admin.permissions_direct.bulk_target", "대상")}
|
||||||
<input
|
<input
|
||||||
|
name="bulk-relation-target-tenant"
|
||||||
data-testid="bulk-relation-target-tenant"
|
data-testid="bulk-relation-target-tenant"
|
||||||
type="hidden"
|
type="hidden"
|
||||||
value={targetTenantId}
|
value={targetTenantId}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<Dialog open={tenantPickerOpen} onOpenChange={setTenantPickerOpen}>
|
<Dialog
|
||||||
|
open={tenantPickerOpen}
|
||||||
|
onOpenChange={setTenantPickerOpen}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1159,7 +1091,10 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
<div className="max-h-80 overflow-y-auto rounded-md border">
|
<div className="max-h-80 overflow-y-auto rounded-md border">
|
||||||
{tenantPickerCandidates.length === 0 ? (
|
{tenantPickerCandidates.length === 0 ? (
|
||||||
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
|
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||||
{t("ui.common.no_results", "검색 결과가 없습니다.")}
|
{t(
|
||||||
|
"ui.common.no_results",
|
||||||
|
"검색 결과가 없습니다.",
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
tenantPickerCandidates.map((tenant) => (
|
tenantPickerCandidates.map((tenant) => (
|
||||||
@@ -1194,6 +1129,7 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
"페이지",
|
"페이지",
|
||||||
)}
|
)}
|
||||||
<select
|
<select
|
||||||
|
name="bulk-relation-target"
|
||||||
data-testid="bulk-relation-target"
|
data-testid="bulk-relation-target"
|
||||||
value={bulkTenantPage}
|
value={bulkTenantPage}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -1211,6 +1147,7 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
<label className="space-y-2 text-sm font-medium">
|
<label className="space-y-2 text-sm font-medium">
|
||||||
{t("ui.admin.permissions_direct.bulk_action", "액션")}
|
{t("ui.admin.permissions_direct.bulk_action", "액션")}
|
||||||
<select
|
<select
|
||||||
|
name="bulk-relation-action"
|
||||||
data-testid="bulk-relation-action"
|
data-testid="bulk-relation-action"
|
||||||
value={bulkAction}
|
value={bulkAction}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -1281,6 +1218,7 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
|
name="permission-assignment-sort"
|
||||||
data-testid="permission-assignment-sort"
|
data-testid="permission-assignment-sort"
|
||||||
value={assignmentSort}
|
value={assignmentSort}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -1314,7 +1252,10 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
{t("ui.admin.permissions_direct.table_target", "대상")}
|
{t("ui.admin.permissions_direct.table_target", "대상")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.permissions_direct.table_relation", "Relation")}
|
{t(
|
||||||
|
"ui.admin.permissions_direct.table_relation",
|
||||||
|
"Relation",
|
||||||
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[180px]">
|
<TableHead className="w-[180px]">
|
||||||
{t("ui.admin.permissions_direct.table_level", "권한")}
|
{t("ui.admin.permissions_direct.table_level", "권한")}
|
||||||
@@ -1376,6 +1317,7 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<select
|
<select
|
||||||
|
name={`permission-assignment-level-${row.user.userId}-${row.relation}`}
|
||||||
data-testid={`permission-assignment-level-${row.user.userId}-${row.relation}`}
|
data-testid={`permission-assignment-level-${row.user.userId}-${row.relation}`}
|
||||||
value={row.level}
|
value={row.level}
|
||||||
disabled={row.protected}
|
disabled={row.protected}
|
||||||
@@ -1427,7 +1369,6 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1437,13 +1378,13 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
<h2 className="text-lg font-bold">
|
<h2 className="text-lg font-bold">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.permissions_direct.super_admin_title",
|
"ui.admin.permissions_direct.super_admin_title",
|
||||||
"Super Admin 역할 부여",
|
"Super Admin 역할 회수",
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.permissions_direct.super_admin_description",
|
"msg.admin.permissions_direct.super_admin_description",
|
||||||
"전역 시스템 관리자 역할은 상세 relation과 분리해서 부여합니다.",
|
"현재 로그인한 관리자와 최초 관리자를 제외한 Super Admin 역할을 회수합니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1451,7 +1392,10 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
<div className="mt-5 rounded-lg border border-border/70 bg-muted/10 p-4">
|
<div className="mt-5 rounded-lg border border-border/70 bg-muted/10 p-4">
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<h3 className="text-sm font-semibold">
|
<h3 className="text-sm font-semibold">
|
||||||
{t("ui.admin.permissions_direct.super_admin_users", "대상 사용자")}
|
{t(
|
||||||
|
"ui.admin.permissions_direct.super_admin_users",
|
||||||
|
"대상 사용자",
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{selectedSuperAdminUserIds.length}
|
{selectedSuperAdminUserIds.length}
|
||||||
@@ -1459,7 +1403,11 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
|
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
|
||||||
{systemRelations.length === 0 ? (
|
{superAdminUsersQuery.isFetching ? (
|
||||||
|
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||||
|
{t("msg.common.loading", "불러오는 중입니다.")}
|
||||||
|
</div>
|
||||||
|
) : revocableSuperAdminUsers.length === 0 ? (
|
||||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.permissions_direct.no_users_found",
|
"msg.admin.permissions_direct.no_users_found",
|
||||||
@@ -1467,17 +1415,18 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
systemRelations.map((user) => (
|
revocableSuperAdminUsers.map((user) => (
|
||||||
<label
|
<label
|
||||||
key={user.userId}
|
key={user.id}
|
||||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm hover:bg-muted/40"
|
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm hover:bg-muted/40"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
name={`super-admin-role-user-${user.id}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
data-testid={`super-admin-role-user-${user.userId}`}
|
data-testid={`super-admin-role-user-${user.id}`}
|
||||||
checked={selectedSuperAdminUserIds.includes(user.userId)}
|
checked={selectedSuperAdminUserIds.includes(user.id)}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
toggleSuperAdminUser(user.userId, event.target.checked)
|
toggleSuperAdminUser(user.id, event.target.checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
@@ -1496,18 +1445,20 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
|
|
||||||
<div className="mt-5 flex justify-end">
|
<div className="mt-5 flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleGrantSuperAdminRole}
|
onClick={handleRevokeSuperAdminRole}
|
||||||
disabled={updateUserRoleMutation.isPending}
|
disabled={
|
||||||
|
updateUserRoleMutation.isPending ||
|
||||||
|
selectedSuperAdminUserIds.length === 0
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.permissions_direct.super_admin_grant",
|
"ui.admin.permissions_direct.super_admin_revoke",
|
||||||
"Super Admin 부여",
|
"Super Admin 회수",
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,9 +505,7 @@ function TenantListPage() {
|
|||||||
tenantIds: selectedIds,
|
tenantIds: selectedIds,
|
||||||
...(selectedBulkStatus ? { status: selectedBulkStatus } : {}),
|
...(selectedBulkStatus ? { status: selectedBulkStatus } : {}),
|
||||||
...(selectedBulkType ? { type: selectedBulkType } : {}),
|
...(selectedBulkType ? { type: selectedBulkType } : {}),
|
||||||
...(selectedBulkVisibility
|
...(selectedBulkVisibility ? { visibility: selectedBulkVisibility } : {}),
|
||||||
? { visibility: selectedBulkVisibility }
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1129,7 +1127,10 @@ function TenantListPage() {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={selectedBulkType} onValueChange={setSelectedBulkType}>
|
<Select
|
||||||
|
value={selectedBulkType}
|
||||||
|
onValueChange={setSelectedBulkType}
|
||||||
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="h-8 w-[180px] bg-transparent border-background/20 text-background text-xs"
|
className="h-8 w-[180px] bg-transparent border-background/20 text-background text-xs"
|
||||||
data-testid="tenant-bulk-type-select"
|
data-testid="tenant-bulk-type-select"
|
||||||
|
|||||||
@@ -665,9 +665,7 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
).toEqual([
|
).toEqual(["직급: 없음 -> 책임연구원 (Baron HmEG / WORKS WORKS HmEG)"]);
|
||||||
"직급: 없음 -> 책임연구원 (Baron HmEG / WORKS WORKS HmEG)",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not format phone update details for spaced Korean country code formatting only", () => {
|
it("does not format phone update details for spaced Korean country code formatting only", () => {
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ export function formatWorksmobileUserMembershipDetails(
|
|||||||
`Baron ${baronOrg}`,
|
`Baron ${baronOrg}`,
|
||||||
`WORKS ${worksOrg}`,
|
`WORKS ${worksOrg}`,
|
||||||
membership.worksmobileLevelName?.trim() ||
|
membership.worksmobileLevelName?.trim() ||
|
||||||
membership.worksmobileLevelId?.trim()
|
membership.worksmobileLevelId?.trim()
|
||||||
? `직급 ${membership.worksmobileLevelName?.trim() || membership.worksmobileLevelId?.trim()}`
|
? `직급 ${membership.worksmobileLevelName?.trim() || membership.worksmobileLevelId?.trim()}`
|
||||||
: "",
|
: "",
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|||||||
@@ -353,6 +353,9 @@ function UserListPage() {
|
|||||||
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState<
|
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState<
|
||||||
UserStatusValue | ""
|
UserStatusValue | ""
|
||||||
>("");
|
>("");
|
||||||
|
const [selectedBulkRole, setSelectedBulkRole] = React.useState<
|
||||||
|
"super_admin" | "user" | ""
|
||||||
|
>("");
|
||||||
const [sortConfig, setSortConfig] =
|
const [sortConfig, setSortConfig] =
|
||||||
React.useState<SortConfig<UserSortKey> | null>(null);
|
React.useState<SortConfig<UserSortKey> | null>(null);
|
||||||
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
|
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
|
||||||
@@ -652,6 +655,7 @@ function UserListPage() {
|
|||||||
query.refetch();
|
query.refetch();
|
||||||
setSelectedUserIds([]);
|
setSelectedUserIds([]);
|
||||||
setSelectedBulkStatus("");
|
setSelectedBulkStatus("");
|
||||||
|
setSelectedBulkRole("");
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
"msg.admin.users.bulk.update_success",
|
"msg.admin.users.bulk.update_success",
|
||||||
@@ -1241,24 +1245,60 @@ function UserListPage() {
|
|||||||
const payload: {
|
const payload: {
|
||||||
userIds: string[];
|
userIds: string[];
|
||||||
status?: UserStatusValue;
|
status?: UserStatusValue;
|
||||||
|
role?: "super_admin" | "user";
|
||||||
} = { userIds: selectedUserIds };
|
} = { userIds: selectedUserIds };
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
if (selectedBulkStatus) {
|
if (selectedBulkStatus) {
|
||||||
payload.status = selectedBulkStatus;
|
payload.status = selectedBulkStatus;
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
|
if (selectedBulkRole) {
|
||||||
|
payload.role = selectedBulkRole;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
bulkUpdateMutation.mutate(payload);
|
bulkUpdateMutation.mutate(payload);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
!selectedBulkStatus || bulkUpdateMutation.isPending || !isWritable
|
(!selectedBulkStatus && !selectedBulkRole) ||
|
||||||
|
bulkUpdateMutation.isPending ||
|
||||||
|
!isWritable
|
||||||
}
|
}
|
||||||
data-testid="bulk-apply-btn"
|
data-testid="bulk-apply-btn"
|
||||||
>
|
>
|
||||||
<ShieldCheck size={14} />
|
<ShieldCheck size={14} />
|
||||||
{t("ui.common.apply", "적용")}
|
{t("ui.common.apply", "적용")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Select
|
||||||
|
value={selectedBulkRole}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setSelectedBulkRole(value as "super_admin" | "user")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-8 w-[150px] bg-transparent border-background/20 text-background text-xs"
|
||||||
|
data-testid="bulk-permission-select"
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.users.bulk.permission_placeholder",
|
||||||
|
"권한 선택",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="super_admin">
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.detail.form.role_super_admin",
|
||||||
|
"시스템 관리자",
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="user">
|
||||||
|
{t("ui.admin.users.detail.form.role_user", "일반 사용자")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -507,6 +507,8 @@ export function buildOrgChartUserMultiPickerUrl(
|
|||||||
params.set("showDescendantToggle", "true");
|
params.set("showDescendantToggle", "true");
|
||||||
if (options.tenantId?.trim()) {
|
if (options.tenantId?.trim()) {
|
||||||
params.set("tenantId", options.tenantId.trim());
|
params.set("tenantId", options.tenantId.trim());
|
||||||
|
} else {
|
||||||
|
params.set("rootTenantId", "all");
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${normalizedBase}/embed/picker?${params.toString()}`;
|
return `${normalizedBase}/embed/picker?${params.toString()}`;
|
||||||
|
|||||||
@@ -2010,12 +2010,14 @@ select_tenant_desc = "Select target tenant to assign fine-grained permissions."
|
|||||||
placeholder = "-- Select Tenant --"
|
placeholder = "-- Select Tenant --"
|
||||||
add_system_user = "Add User to Admin Control"
|
add_system_user = "Add User to Admin Control"
|
||||||
dialog_title_system = "Add User to Global Permissions"
|
dialog_title_system = "Add User to Global Permissions"
|
||||||
|
super_admin_revoke = "Revoke super administrator"
|
||||||
|
|
||||||
[msg.admin.permissions_direct]
|
[msg.admin.permissions_direct]
|
||||||
description = "Directly assign and manage tab-level direct permissions and global sidebar menu access."
|
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."
|
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."
|
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."
|
select_prompt = "Select a tenant from the dropdown above to manage its fine-grained features."
|
||||||
|
super_admin_revoke_success = "Super administrator access revoked."
|
||||||
|
|
||||||
[msg.admin.system.relations]
|
[msg.admin.system.relations]
|
||||||
add_success = "Global menu permission added successfully."
|
add_success = "Global menu permission added successfully."
|
||||||
|
|||||||
@@ -2010,12 +2010,14 @@ select_tenant_desc = "세부 기능 권한을 부여할 대상 테넌트를 리
|
|||||||
placeholder = "-- 테넌트 선택 --"
|
placeholder = "-- 테넌트 선택 --"
|
||||||
add_system_user = "시스템 권한 사용자 추가"
|
add_system_user = "시스템 권한 사용자 추가"
|
||||||
dialog_title_system = "시스템 권한 관리 유저 추가"
|
dialog_title_system = "시스템 권한 관리 유저 추가"
|
||||||
|
super_admin_revoke = "Super Admin 회수"
|
||||||
|
|
||||||
[msg.admin.permissions_direct]
|
[msg.admin.permissions_direct]
|
||||||
description = "테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다."
|
description = "테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다."
|
||||||
tab_system_desc = "사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다."
|
tab_system_desc = "사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다."
|
||||||
system_empty = "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요."
|
system_empty = "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요."
|
||||||
select_prompt = "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다."
|
select_prompt = "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다."
|
||||||
|
super_admin_revoke_success = "Super Admin 권한을 회수했습니다."
|
||||||
|
|
||||||
[msg.admin.system.relations]
|
[msg.admin.system.relations]
|
||||||
add_success = "시스템 메뉴 권한이 추가되었습니다."
|
add_success = "시스템 메뉴 권한이 추가되었습니다."
|
||||||
|
|||||||
@@ -1964,12 +1964,14 @@ select_tenant_desc = ""
|
|||||||
placeholder = ""
|
placeholder = ""
|
||||||
add_system_user = ""
|
add_system_user = ""
|
||||||
dialog_title_system = ""
|
dialog_title_system = ""
|
||||||
|
super_admin_revoke = ""
|
||||||
|
|
||||||
[msg.admin.permissions_direct]
|
[msg.admin.permissions_direct]
|
||||||
description = ""
|
description = ""
|
||||||
tab_system_desc = ""
|
tab_system_desc = ""
|
||||||
system_empty = ""
|
system_empty = ""
|
||||||
select_prompt = ""
|
select_prompt = ""
|
||||||
|
super_admin_revoke_success = ""
|
||||||
|
|
||||||
[msg.admin.system.relations]
|
[msg.admin.system.relations]
|
||||||
add_success = ""
|
add_success = ""
|
||||||
|
|||||||
@@ -189,9 +189,9 @@ test.describe("Tenant list performance", () => {
|
|||||||
|
|
||||||
const loadStarted = performance.now();
|
const loadStarted = performance.now();
|
||||||
await page.goto("/tenants");
|
await page.goto("/tenants");
|
||||||
await expect(page.getByTestId("tenant-internal-id-tenant-3500")).toBeVisible(
|
await expect(
|
||||||
{ timeout: 15000 },
|
page.getByTestId("tenant-internal-id-tenant-3500"),
|
||||||
);
|
).toBeVisible({ timeout: 15000 });
|
||||||
const loadMs = performance.now() - loadStarted;
|
const loadMs = performance.now() - loadStarted;
|
||||||
const loadSnapshot = testInfo.outputPath("tenant-list-load.png");
|
const loadSnapshot = testInfo.outputPath("tenant-list-load.png");
|
||||||
await page.screenshot({ path: loadSnapshot, fullPage: true });
|
await page.screenshot({ path: loadSnapshot, fullPage: true });
|
||||||
@@ -201,9 +201,9 @@ test.describe("Tenant list performance", () => {
|
|||||||
const searchInput = page.getByPlaceholder("이름 또는 슬러그, ID 검색");
|
const searchInput = page.getByPlaceholder("이름 또는 슬러그, ID 검색");
|
||||||
const searchStarted = performance.now();
|
const searchStarted = performance.now();
|
||||||
await searchInput.fill("full-dataset-needle-0100");
|
await searchInput.fill("full-dataset-needle-0100");
|
||||||
await expect(page.getByTestId("tenant-internal-id-tenant-0100")).toBeVisible(
|
await expect(
|
||||||
{ timeout: 15000 },
|
page.getByTestId("tenant-internal-id-tenant-0100"),
|
||||||
);
|
).toBeVisible({ timeout: 15000 });
|
||||||
const searchMs = performance.now() - searchStarted;
|
const searchMs = performance.now() - searchStarted;
|
||||||
const searchSnapshot = testInfo.outputPath("tenant-list-search.png");
|
const searchSnapshot = testInfo.outputPath("tenant-list-search.png");
|
||||||
await page.screenshot({ path: searchSnapshot, fullPage: true });
|
await page.screenshot({ path: searchSnapshot, fullPage: true });
|
||||||
@@ -211,9 +211,9 @@ test.describe("Tenant list performance", () => {
|
|||||||
await expect(page.locator("tbody")).toContainText(
|
await expect(page.locator("tbody")).toContainText(
|
||||||
"full-dataset-needle-0100",
|
"full-dataset-needle-0100",
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId("tenant-internal-id-tenant-3500")).toHaveCount(
|
await expect(
|
||||||
0,
|
page.getByTestId("tenant-internal-id-tenant-3500"),
|
||||||
);
|
).toHaveCount(0);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -225,8 +225,9 @@ test.describe("Tenant list performance", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const searchBudgetMs = testInfo.project.name === "firefox" ? 1000 : 500;
|
||||||
expect(loadMs).toBeLessThanOrEqual(1500);
|
expect(loadMs).toBeLessThanOrEqual(1500);
|
||||||
expect(searchMs).toBeLessThanOrEqual(500);
|
expect(searchMs).toBeLessThanOrEqual(searchBudgetMs);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ test.describe("Tenant profile local performance evidence", () => {
|
|||||||
|
|
||||||
console.log(JSON.stringify(evidence, null, 2));
|
console.log(JSON.stringify(evidence, null, 2));
|
||||||
|
|
||||||
expect(evidence.summary.configFieldsVisibleMs.p95).toBeLessThanOrEqual(500);
|
const configVisibleBudgetMs =
|
||||||
|
testInfo.project.name === "firefox" ? 1200 : 500;
|
||||||
|
expect(evidence.summary.configFieldsVisibleMs.p95).toBeLessThanOrEqual(
|
||||||
|
configVisibleBudgetMs,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -789,8 +789,7 @@ test.describe("User Management", () => {
|
|||||||
.poll(() => updatePayload)
|
.poll(() => updatePayload)
|
||||||
.toMatchObject({ status: "preboarding" });
|
.toMatchObject({ status: "preboarding" });
|
||||||
|
|
||||||
await table.locator('input[name="user-list-select-u-1"]').check();
|
await expect(page.getByTestId("user-role-select-u-1")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("bulk-permission-select")).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should keep system role assignment out of the permissions screen", async ({
|
test("should keep system role assignment out of the permissions screen", async ({
|
||||||
@@ -907,23 +906,24 @@ test.describe("User Management", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("option", { name: /권한 부여.*수정/ }),
|
page.getByRole("option", { name: /권한 부여.*수정/ }),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
await expect(page.getByTestId("permission-target-org-picker-frame")).toBeVisible();
|
await expect(
|
||||||
await expect(page.getByTestId("permission-target-org-picker-frame")).toHaveAttribute(
|
page.getByTestId("permission-target-org-picker-frame"),
|
||||||
"src",
|
).toBeVisible();
|
||||||
/rootTenantId%3Dall|rootTenantId=all/,
|
await expect(
|
||||||
);
|
page.getByTestId("permission-target-org-picker-frame"),
|
||||||
|
).toHaveAttribute("src", /rootTenantId%3Dall|rootTenantId=all/);
|
||||||
const pickerBox = await page
|
const pickerBox = await page
|
||||||
.getByTestId("permission-target-org-picker-frame")
|
.getByTestId("permission-target-org-picker-frame")
|
||||||
.boundingBox();
|
.boundingBox();
|
||||||
const queueBox = await page.getByTestId("permission-target-queue").boundingBox();
|
const queueBox = await page
|
||||||
|
.getByTestId("permission-target-queue")
|
||||||
|
.boundingBox();
|
||||||
expect(pickerBox?.x ?? Number.POSITIVE_INFINITY).toBeLessThan(
|
expect(pickerBox?.x ?? Number.POSITIVE_INFINITY).toBeLessThan(
|
||||||
queueBox?.x ?? Number.NEGATIVE_INFINITY,
|
queueBox?.x ?? Number.NEGATIVE_INFINITY,
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.getByTestId("bulk-relation-mode").selectOption("target-action");
|
await page.getByTestId("bulk-relation-mode").selectOption("target-action");
|
||||||
await expect(
|
await expect(page.getByTestId("bulk-relation-operation")).toHaveCount(0);
|
||||||
page.getByTestId("bulk-relation-operation"),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await page.getByTestId("permission-action-tenant-picker-open").click();
|
await page.getByTestId("permission-action-tenant-picker-open").click();
|
||||||
await page.getByTestId("permission-action-tenant-search").fill("Test");
|
await page.getByTestId("permission-action-tenant-search").fill("Test");
|
||||||
await page.getByTestId("permission-action-tenant-result-t-1").click();
|
await page.getByTestId("permission-action-tenant-result-t-1").click();
|
||||||
@@ -980,40 +980,48 @@ test.describe("User Management", () => {
|
|||||||
.getByRole("button", { name: /선택 사용자에게 권한 부여/ })
|
.getByRole("button", { name: /선택 사용자에게 권한 부여/ })
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect.poll(() => relationWrites).toContainEqual(
|
await expect
|
||||||
{ userId: "u-2", relation: "tenants_managers" },
|
.poll(() => relationWrites)
|
||||||
);
|
.toContainEqual({ userId: "u-2", relation: "tenants_managers" });
|
||||||
await expect.poll(() => relationWrites).toContainEqual(
|
await expect
|
||||||
{ userId: "u-2", relation: "profile_managers" },
|
.poll(() => relationWrites)
|
||||||
);
|
.toContainEqual({ userId: "u-2", relation: "profile_managers" });
|
||||||
await expect.poll(() => relationWrites).toContainEqual(
|
await expect
|
||||||
{ userId: "u-3", relation: "profile_managers" },
|
.poll(() => relationWrites)
|
||||||
);
|
.toContainEqual({ userId: "u-3", relation: "profile_managers" });
|
||||||
|
|
||||||
await page.getByTestId("permission-assignment-search").fill("John");
|
await page.getByTestId("permission-assignment-search").fill("John");
|
||||||
await expect(page.getByTestId("permission-assignment-row-u-1-profile_viewers")).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId("permission-assignment-row-u-1-profile_viewers"),
|
||||||
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("permission-assignment-row-u-2-profile_managers"),
|
page.getByTestId("permission-assignment-row-u-2-profile_managers"),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
await page.getByTestId("permission-assignment-search").fill("");
|
await page.getByTestId("permission-assignment-search").fill("");
|
||||||
await page.getByTestId("permission-assignment-sort").selectOption("relation");
|
await page
|
||||||
|
.getByTestId("permission-assignment-sort")
|
||||||
|
.selectOption("relation");
|
||||||
await page
|
await page
|
||||||
.getByTestId("permission-assignment-level-u-1-profile_viewers")
|
.getByTestId("permission-assignment-level-u-1-profile_viewers")
|
||||||
.selectOption("write");
|
.selectOption("write");
|
||||||
await expect.poll(() => relationWrites).toContainEqual({
|
await expect
|
||||||
userId: "u-1",
|
.poll(() => relationWrites)
|
||||||
relation: "profile_managers",
|
.toContainEqual({
|
||||||
});
|
userId: "u-1",
|
||||||
|
relation: "profile_managers",
|
||||||
|
});
|
||||||
await page
|
await page
|
||||||
.getByTestId("permission-assignment-remove-u-1-profile_viewers")
|
.getByTestId("permission-assignment-remove-u-1-profile_viewers")
|
||||||
.click();
|
.click();
|
||||||
await expect.poll(() => relationDeletes).toContainEqual({
|
await expect
|
||||||
userId: "u-1",
|
.poll(() => relationDeletes)
|
||||||
relation: "profile_viewers",
|
.toContainEqual({
|
||||||
});
|
userId: "u-1",
|
||||||
|
relation: "profile_viewers",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should grant super admin role from the last tab only for super admins", async ({
|
test("should revoke super admin role from the last tab only for super admins", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
let bulkPayload: Record<string, unknown> | undefined;
|
let bulkPayload: Record<string, unknown> | undefined;
|
||||||
@@ -1036,6 +1044,30 @@ test.describe("User Management", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
|
||||||
|
if (route.request().method() !== "GET") {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "u-1",
|
||||||
|
name: "John Doe",
|
||||||
|
email: "john@test.com",
|
||||||
|
phone: "010-1111-2222",
|
||||||
|
role: "super_admin",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 10000,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.route(/\/admin\/users\/bulk$/, async (route) => {
|
await page.route(/\/admin\/users\/bulk$/, async (route) => {
|
||||||
if (route.request().method() !== "PUT") {
|
if (route.request().method() !== "PUT") {
|
||||||
return route.fallback();
|
return route.fallback();
|
||||||
@@ -1052,12 +1084,14 @@ test.describe("User Management", () => {
|
|||||||
await tabs.last().click();
|
await tabs.last().click();
|
||||||
|
|
||||||
await page.getByTestId("super-admin-role-user-u-1").check();
|
await page.getByTestId("super-admin-role-user-u-1").check();
|
||||||
await page.getByRole("button", { name: /Super Admin 부여/ }).click();
|
await page.getByRole("button", { name: /Super Admin 회수/ }).click();
|
||||||
|
|
||||||
await expect.poll(() => bulkPayload).toEqual({
|
await expect
|
||||||
userIds: ["u-1"],
|
.poll(() => bulkPayload)
|
||||||
role: "super_admin",
|
.toEqual({
|
||||||
});
|
userIds: ["u-1"],
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should hide the super admin role tab from non super admins", async ({
|
test("should hide the super admin role tab from non super admins", async ({
|
||||||
@@ -1525,9 +1559,7 @@ test.describe("User Management", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("tab", { name: /외부 기업 회원/i }),
|
page.getByRole("tab", { name: /외부 기업 회원/i }),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
await expect(
|
await expect(page.getByRole("tab", { name: /^일반회사$/i })).toBeVisible();
|
||||||
page.getByRole("tab", { name: /^Commercial$/i }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByRole("tab", { name: /^공공기관$/i })).toBeVisible();
|
await expect(page.getByRole("tab", { name: /^공공기관$/i })).toBeVisible();
|
||||||
await expect(page.getByRole("tab", { name: /^교육기관$/i })).toBeVisible();
|
await expect(page.getByRole("tab", { name: /^교육기관$/i })).toBeVisible();
|
||||||
await expect(page.getByRole("tab", { name: /^개인$/i })).toBeVisible();
|
await expect(page.getByRole("tab", { name: /^개인$/i })).toBeVisible();
|
||||||
|
|||||||
@@ -3559,6 +3559,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary.Tenant = tenantSummary
|
summary.Tenant = tenantSummary
|
||||||
|
markBootstrapSuperAdminSummary(&summary)
|
||||||
|
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
@@ -3587,7 +3588,7 @@ func (h *UserHandler) mapLocalUserSummary(ctx context.Context, user domain.User)
|
|||||||
Grade: tenantBoundGradeFromUser(user),
|
Grade: tenantBoundGradeFromUser(user),
|
||||||
Position: user.Position,
|
Position: user.Position,
|
||||||
JobTitle: user.JobTitle,
|
JobTitle: user.JobTitle,
|
||||||
Metadata: user.Metadata,
|
Metadata: maps.Clone(user.Metadata),
|
||||||
Tenant: user.Tenant,
|
Tenant: user.Tenant,
|
||||||
CreatedAt: formatTime(user.CreatedAt),
|
CreatedAt: formatTime(user.CreatedAt),
|
||||||
UpdatedAt: formatTime(user.UpdatedAt),
|
UpdatedAt: formatTime(user.UpdatedAt),
|
||||||
@@ -3599,9 +3600,29 @@ func (h *UserHandler) mapLocalUserSummary(ctx context.Context, user domain.User)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markBootstrapSuperAdminSummary(&summary)
|
||||||
|
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markBootstrapSuperAdminSummary(summary *userSummary) {
|
||||||
|
if summary == nil || !isBootstrapSuperAdminEmail(summary.Email) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if summary.Metadata == nil {
|
||||||
|
summary.Metadata = make(domain.JSONMap)
|
||||||
|
}
|
||||||
|
summary.Metadata["bootstrapSuperAdmin"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBootstrapSuperAdminEmail(email string) bool {
|
||||||
|
adminEmail := strings.ToLower(strings.TrimSpace(os.Getenv("ADMIN_EMAIL")))
|
||||||
|
if adminEmail == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(email)) == adminEmail
|
||||||
|
}
|
||||||
|
|
||||||
func (h *UserHandler) normalizePhoneNumber(phone string) string {
|
func (h *UserHandler) normalizePhoneNumber(phone string) string {
|
||||||
return normalizePhoneNumber(phone)
|
return normalizePhoneNumber(phone)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1260,6 +1260,51 @@ func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing
|
|||||||
mockKratos.AssertNotCalled(t, "ListIdentities", mock.Anything)
|
mockKratos.AssertNotCalled(t, "ListIdentities", mock.Anything)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_ListUsersMarksBootstrapSuperAdmin(t *testing.T) {
|
||||||
|
t.Setenv("ADMIN_EMAIL", "bootstrap@example.com")
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
createdAt := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
h := &UserHandler{KratosAdmin: mockKratos}
|
||||||
|
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
Role: domain.RoleSuperAdmin,
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/users", h.ListUsers)
|
||||||
|
|
||||||
|
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
||||||
|
{
|
||||||
|
ID: "bootstrap-admin",
|
||||||
|
State: "active",
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
UpdatedAt: createdAt,
|
||||||
|
Traits: map[string]any{
|
||||||
|
"email": "bootstrap@example.com",
|
||||||
|
"name": "Bootstrap Admin",
|
||||||
|
"role": domain.RoleSuperAdmin,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/users?limit=10&offset=0", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var res userListResponse
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||||
|
require.Len(t, res.Items, 1)
|
||||||
|
require.Equal(t, "bootstrap-admin", res.Items[0].ID)
|
||||||
|
require.Equal(t, true, res.Items[0].Metadata["bootstrapSuperAdmin"])
|
||||||
|
mockKratos.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandler_ListUsersPassesQueryToIdentityMirrorPageInsteadOfLoadingFullMirror(t *testing.T) {
|
func TestUserHandler_ListUsersPassesQueryToIdentityMirrorPageInsteadOfLoadingFullMirror(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
createdAt := time.Date(2026, 6, 17, 8, 50, 0, 0, time.UTC)
|
createdAt := time.Date(2026, 6, 17, 8, 50, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -1804,10 +1804,12 @@ actions = "Actions"
|
|||||||
application = "Application"
|
application = "Application"
|
||||||
client_id = "Client ID"
|
client_id = "Client ID"
|
||||||
created_at = "Created At"
|
created_at = "Created At"
|
||||||
|
creator = "Creator"
|
||||||
status = "Status"
|
status = "Status"
|
||||||
type = "Type"
|
type = "Type"
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
|
headless = "Headless Login"
|
||||||
pkce = "PKCE"
|
pkce = "PKCE"
|
||||||
private = "Server side App"
|
private = "Server side App"
|
||||||
pkce_headless = "PKCE (Headless Login)"
|
pkce_headless = "PKCE (Headless Login)"
|
||||||
|
|||||||
@@ -1803,10 +1803,12 @@ actions = "액션"
|
|||||||
application = "애플리케이션"
|
application = "애플리케이션"
|
||||||
client_id = "클라이언트 ID"
|
client_id = "클라이언트 ID"
|
||||||
created_at = "생성일"
|
created_at = "생성일"
|
||||||
|
creator = "생성자"
|
||||||
status = "상태"
|
status = "상태"
|
||||||
type = "유형"
|
type = "유형"
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
|
headless = "Headless Login"
|
||||||
private = "Server side App"
|
private = "Server side App"
|
||||||
pkce = "PKCE"
|
pkce = "PKCE"
|
||||||
pkce_headless = "PKCE (Headless Login)"
|
pkce_headless = "PKCE (Headless Login)"
|
||||||
|
|||||||
@@ -1854,10 +1854,12 @@ actions = ""
|
|||||||
application = ""
|
application = ""
|
||||||
client_id = ""
|
client_id = ""
|
||||||
created_at = ""
|
created_at = ""
|
||||||
|
creator = ""
|
||||||
status = ""
|
status = ""
|
||||||
type = ""
|
type = ""
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
|
headless = ""
|
||||||
pkce = ""
|
pkce = ""
|
||||||
private = ""
|
private = ""
|
||||||
pkce_headless = ""
|
pkce_headless = ""
|
||||||
|
|||||||
@@ -273,8 +273,20 @@ desc_tenants = "Desc Tenants"
|
|||||||
desc_users = "Desc Users"
|
desc_users = "Desc Users"
|
||||||
desc_worksmobile = "Desc Worksmobile"
|
desc_worksmobile = "Desc Worksmobile"
|
||||||
description = "Assign and grant fine-grained functional permissions for tenants and global sidebar menu tab access."
|
description = "Assign and grant fine-grained functional permissions for tenants and global sidebar menu tab access."
|
||||||
|
assignment_empty = "No direct assignments found."
|
||||||
|
assignment_table_desc = "Review and revoke direct permission assignments."
|
||||||
|
bulk_description = "Grant a direct permission to selected users."
|
||||||
|
bulk_grant_success = "Direct permission granted."
|
||||||
|
bulk_users_required = "Select at least one user."
|
||||||
no_user_selected_desc = "No User Selected Desc"
|
no_user_selected_desc = "No User Selected Desc"
|
||||||
no_users_found = "No Users Found"
|
no_users_found = "No Users Found"
|
||||||
|
protected_relation = "This relation is protected and cannot be changed here."
|
||||||
|
super_admin_description = "Grant or revoke system super administrator access."
|
||||||
|
super_admin_grant_success = "Super administrator access granted."
|
||||||
|
super_admin_revoke_success = "Super administrator access revoked."
|
||||||
|
super_admin_users_required = "Select at least one user."
|
||||||
|
target_tenant_picker_desc = "Choose the tenant scope for this permission."
|
||||||
|
target_tenant_required = "Select a target tenant."
|
||||||
|
|
||||||
[msg.admin.system]
|
[msg.admin.system]
|
||||||
|
|
||||||
@@ -1447,6 +1459,19 @@ total_users = "Total Users"
|
|||||||
|
|
||||||
[ui.admin.permissions_direct]
|
[ui.admin.permissions_direct]
|
||||||
allowed = "Allowed"
|
allowed = "Allowed"
|
||||||
|
assignment_search = "Search assignments"
|
||||||
|
assignment_table_title = "Direct assignments"
|
||||||
|
bulk_action = "Action"
|
||||||
|
bulk_mode = "Mode"
|
||||||
|
bulk_mode_page = "Page"
|
||||||
|
bulk_mode_target_action = "Target action"
|
||||||
|
bulk_page_relation = "Page permission"
|
||||||
|
bulk_selected = "selected"
|
||||||
|
bulk_submit_grant = "Grant permission"
|
||||||
|
bulk_target = "Target"
|
||||||
|
bulk_tenant_page = "Tenant page"
|
||||||
|
bulk_title = "Bulk direct grant"
|
||||||
|
bulk_users = "Users"
|
||||||
cat_dashboard = "Cat Dashboard"
|
cat_dashboard = "Cat Dashboard"
|
||||||
cat_integrations = "Cat Integrations"
|
cat_integrations = "Cat Integrations"
|
||||||
cat_resources = "Cat Resources"
|
cat_resources = "Cat Resources"
|
||||||
@@ -1454,7 +1479,28 @@ cat_system = "Cat System"
|
|||||||
dialog_title_system = "Dialog Title System"
|
dialog_title_system = "Dialog Title System"
|
||||||
no_user_selected = "No User Selected"
|
no_user_selected = "No User Selected"
|
||||||
revoke_all = "Revoke All"
|
revoke_all = "Revoke All"
|
||||||
|
scope_system = "System"
|
||||||
|
scope_tenant = "Tenant"
|
||||||
|
sort_level = "Sort by level"
|
||||||
|
sort_relation = "Sort by relation"
|
||||||
|
sort_user = "Sort by user"
|
||||||
|
super_admin_grant = "Grant super administrator"
|
||||||
|
super_admin_revoke = "Revoke super administrator"
|
||||||
super_admin_only = "Super Admin Only"
|
super_admin_only = "Super Admin Only"
|
||||||
|
super_admin_title = "Super administrators"
|
||||||
|
super_admin_users = "Super admin users"
|
||||||
|
tab_direct = "Direct permissions"
|
||||||
|
tab_super_admin = "Super administrators"
|
||||||
|
table_level = "Level"
|
||||||
|
table_relation = "Relation"
|
||||||
|
table_target = "Target"
|
||||||
|
table_user = "User"
|
||||||
|
tabs = "Permission tabs"
|
||||||
|
target_org_picker = "Select organization"
|
||||||
|
target_queue_empty = "No targets selected"
|
||||||
|
target_queue_remove = "Remove target"
|
||||||
|
target_tenant_picker_title = "Select tenant"
|
||||||
|
target_tenant_required_option = "Target tenant required"
|
||||||
user_list = "User List"
|
user_list = "User List"
|
||||||
|
|
||||||
[ui.admin.profile]
|
[ui.admin.profile]
|
||||||
@@ -1501,6 +1547,8 @@ section = "Tenants"
|
|||||||
[ui.admin.tenants.bulk]
|
[ui.admin.tenants.bulk]
|
||||||
selected_count = "temp"
|
selected_count = "temp"
|
||||||
status_placeholder = "temp"
|
status_placeholder = "temp"
|
||||||
|
type_placeholder = "Change type"
|
||||||
|
visibility_placeholder = "Change visibility"
|
||||||
|
|
||||||
[ui.admin.tenants.create]
|
[ui.admin.tenants.create]
|
||||||
title = "Tenant Add"
|
title = "Tenant Add"
|
||||||
@@ -2575,13 +2623,16 @@ actions = "Actions"
|
|||||||
application = "Application"
|
application = "Application"
|
||||||
client_id = "Client ID"
|
client_id = "Client ID"
|
||||||
created_at = "Created At"
|
created_at = "Created At"
|
||||||
|
creator = "Creator"
|
||||||
status = "Status"
|
status = "Status"
|
||||||
type = "Type"
|
type = "Type"
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
|
headless = "Headless Login"
|
||||||
pkce = "PKCE"
|
pkce = "PKCE"
|
||||||
pkce_headless = "Headless PKCE"
|
pkce_headless = "Headless PKCE"
|
||||||
private = "Server side App"
|
private = "Server side App"
|
||||||
|
private_headless = "Server side App (Headless Login)"
|
||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
|||||||
@@ -273,8 +273,20 @@ desc_tenants = "고객 테넌트 목록, 신규 부모-자식 테넌트 관리"
|
|||||||
desc_users = "가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입"
|
desc_users = "가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입"
|
||||||
desc_worksmobile = "라인웍스 연동 및 사내 임직원 패스워드 강제 동기화"
|
desc_worksmobile = "라인웍스 연동 및 사내 임직원 패스워드 강제 동기화"
|
||||||
description = "테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다."
|
description = "테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다."
|
||||||
|
assignment_empty = "직접 부여된 권한이 없습니다."
|
||||||
|
assignment_table_desc = "직접 부여 권한을 확인하고 회수합니다."
|
||||||
|
bulk_description = "선택한 사용자에게 직접 권한을 부여합니다."
|
||||||
|
bulk_grant_success = "직접 권한을 부여했습니다."
|
||||||
|
bulk_users_required = "사용자를 한 명 이상 선택해 주세요."
|
||||||
no_user_selected_desc = "왼쪽의 사용자 리스트에서 권한을 변경할 인원을 선택해 주세요."
|
no_user_selected_desc = "왼쪽의 사용자 리스트에서 권한을 변경할 인원을 선택해 주세요."
|
||||||
no_users_found = "등록된 사용자가 없습니다."
|
no_users_found = "등록된 사용자가 없습니다."
|
||||||
|
protected_relation = "보호된 관계라 이 화면에서 변경할 수 없습니다."
|
||||||
|
super_admin_description = "시스템 Super Admin 권한을 부여하거나 회수합니다."
|
||||||
|
super_admin_grant_success = "Super Admin 권한을 부여했습니다."
|
||||||
|
super_admin_revoke_success = "Super Admin 권한을 회수했습니다."
|
||||||
|
super_admin_users_required = "사용자를 한 명 이상 선택해 주세요."
|
||||||
|
target_tenant_picker_desc = "이 권한에 적용할 테넌트 범위를 선택합니다."
|
||||||
|
target_tenant_required = "대상 테넌트를 선택해 주세요."
|
||||||
|
|
||||||
[msg.admin.system]
|
[msg.admin.system]
|
||||||
|
|
||||||
@@ -1447,6 +1459,19 @@ total_users = "전체 사용자 수"
|
|||||||
|
|
||||||
[ui.admin.permissions_direct]
|
[ui.admin.permissions_direct]
|
||||||
allowed = "개 허용됨"
|
allowed = "개 허용됨"
|
||||||
|
assignment_search = "부여 내역 검색"
|
||||||
|
assignment_table_title = "직접 부여 내역"
|
||||||
|
bulk_action = "동작"
|
||||||
|
bulk_mode = "모드"
|
||||||
|
bulk_mode_page = "페이지"
|
||||||
|
bulk_mode_target_action = "대상 동작"
|
||||||
|
bulk_page_relation = "페이지 권한"
|
||||||
|
bulk_selected = "선택됨"
|
||||||
|
bulk_submit_grant = "권한 부여"
|
||||||
|
bulk_target = "대상"
|
||||||
|
bulk_tenant_page = "테넌트 페이지"
|
||||||
|
bulk_title = "직접 권한 일괄 부여"
|
||||||
|
bulk_users = "사용자"
|
||||||
cat_dashboard = "핵심 대시보드 및 분석"
|
cat_dashboard = "핵심 대시보드 및 분석"
|
||||||
cat_integrations = "인프라 연동 및 보안"
|
cat_integrations = "인프라 연동 및 보안"
|
||||||
cat_resources = "핵심 리소스 관리"
|
cat_resources = "핵심 리소스 관리"
|
||||||
@@ -1454,7 +1479,28 @@ cat_system = "아이덴티티 및 게이트 관리"
|
|||||||
dialog_title_system = "시스템 권한 관리 유저 추가"
|
dialog_title_system = "시스템 권한 관리 유저 추가"
|
||||||
no_user_selected = "사용자가 선택되지 않았습니다."
|
no_user_selected = "사용자가 선택되지 않았습니다."
|
||||||
revoke_all = "모든 권한 회수"
|
revoke_all = "모든 권한 회수"
|
||||||
|
scope_system = "시스템"
|
||||||
|
scope_tenant = "테넌트"
|
||||||
|
sort_level = "레벨순 정렬"
|
||||||
|
sort_relation = "관계순 정렬"
|
||||||
|
sort_user = "사용자순 정렬"
|
||||||
|
super_admin_grant = "Super Admin 부여"
|
||||||
|
super_admin_revoke = "Super Admin 회수"
|
||||||
super_admin_only = "Super Admin 전용"
|
super_admin_only = "Super Admin 전용"
|
||||||
|
super_admin_title = "Super Admin"
|
||||||
|
super_admin_users = "Super Admin 사용자"
|
||||||
|
tab_direct = "직접 권한"
|
||||||
|
tab_super_admin = "Super Admin"
|
||||||
|
table_level = "레벨"
|
||||||
|
table_relation = "관계"
|
||||||
|
table_target = "대상"
|
||||||
|
table_user = "사용자"
|
||||||
|
tabs = "권한 탭"
|
||||||
|
target_org_picker = "조직 선택"
|
||||||
|
target_queue_empty = "선택된 대상이 없습니다."
|
||||||
|
target_queue_remove = "대상 제거"
|
||||||
|
target_tenant_picker_title = "테넌트 선택"
|
||||||
|
target_tenant_required_option = "대상 테넌트 필수"
|
||||||
user_list = "대상 사용자"
|
user_list = "대상 사용자"
|
||||||
|
|
||||||
[ui.admin.profile]
|
[ui.admin.profile]
|
||||||
@@ -1501,6 +1547,8 @@ section = "Tenants"
|
|||||||
[ui.admin.tenants.bulk]
|
[ui.admin.tenants.bulk]
|
||||||
selected_count = "temp"
|
selected_count = "temp"
|
||||||
status_placeholder = "temp"
|
status_placeholder = "temp"
|
||||||
|
type_placeholder = "유형 변경"
|
||||||
|
visibility_placeholder = "가시성 변경"
|
||||||
|
|
||||||
[ui.admin.tenants.create]
|
[ui.admin.tenants.create]
|
||||||
title = "테넌트 추가"
|
title = "테넌트 추가"
|
||||||
@@ -2575,13 +2623,16 @@ actions = "액션"
|
|||||||
application = "애플리케이션"
|
application = "애플리케이션"
|
||||||
client_id = "클라이언트 ID"
|
client_id = "클라이언트 ID"
|
||||||
created_at = "생성일"
|
created_at = "생성일"
|
||||||
|
creator = "생성자"
|
||||||
status = "상태"
|
status = "상태"
|
||||||
type = "유형"
|
type = "유형"
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
|
headless = "Headless Login"
|
||||||
pkce = "PKCE"
|
pkce = "PKCE"
|
||||||
pkce_headless = "Headless PKCE"
|
pkce_headless = "Headless PKCE"
|
||||||
private = "Server side App"
|
private = "Server side App"
|
||||||
|
private_headless = "Server side App (Headless Login)"
|
||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
|||||||
@@ -273,8 +273,20 @@ desc_tenants = ""
|
|||||||
desc_users = ""
|
desc_users = ""
|
||||||
desc_worksmobile = ""
|
desc_worksmobile = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
assignment_empty = ""
|
||||||
|
assignment_table_desc = ""
|
||||||
|
bulk_description = ""
|
||||||
|
bulk_grant_success = ""
|
||||||
|
bulk_users_required = ""
|
||||||
no_user_selected_desc = ""
|
no_user_selected_desc = ""
|
||||||
no_users_found = ""
|
no_users_found = ""
|
||||||
|
protected_relation = ""
|
||||||
|
super_admin_description = ""
|
||||||
|
super_admin_grant_success = ""
|
||||||
|
super_admin_revoke_success = ""
|
||||||
|
super_admin_users_required = ""
|
||||||
|
target_tenant_picker_desc = ""
|
||||||
|
target_tenant_required = ""
|
||||||
|
|
||||||
[msg.admin.system]
|
[msg.admin.system]
|
||||||
|
|
||||||
@@ -1447,6 +1459,19 @@ total_users = ""
|
|||||||
|
|
||||||
[ui.admin.permissions_direct]
|
[ui.admin.permissions_direct]
|
||||||
allowed = ""
|
allowed = ""
|
||||||
|
assignment_search = ""
|
||||||
|
assignment_table_title = ""
|
||||||
|
bulk_action = ""
|
||||||
|
bulk_mode = ""
|
||||||
|
bulk_mode_page = ""
|
||||||
|
bulk_mode_target_action = ""
|
||||||
|
bulk_page_relation = ""
|
||||||
|
bulk_selected = ""
|
||||||
|
bulk_submit_grant = ""
|
||||||
|
bulk_target = ""
|
||||||
|
bulk_tenant_page = ""
|
||||||
|
bulk_title = ""
|
||||||
|
bulk_users = ""
|
||||||
cat_dashboard = ""
|
cat_dashboard = ""
|
||||||
cat_integrations = ""
|
cat_integrations = ""
|
||||||
cat_resources = ""
|
cat_resources = ""
|
||||||
@@ -1454,7 +1479,28 @@ cat_system = ""
|
|||||||
dialog_title_system = ""
|
dialog_title_system = ""
|
||||||
no_user_selected = ""
|
no_user_selected = ""
|
||||||
revoke_all = ""
|
revoke_all = ""
|
||||||
|
scope_system = ""
|
||||||
|
scope_tenant = ""
|
||||||
|
sort_level = ""
|
||||||
|
sort_relation = ""
|
||||||
|
sort_user = ""
|
||||||
|
super_admin_grant = ""
|
||||||
|
super_admin_revoke = ""
|
||||||
super_admin_only = ""
|
super_admin_only = ""
|
||||||
|
super_admin_title = ""
|
||||||
|
super_admin_users = ""
|
||||||
|
tab_direct = ""
|
||||||
|
tab_super_admin = ""
|
||||||
|
table_level = ""
|
||||||
|
table_relation = ""
|
||||||
|
table_target = ""
|
||||||
|
table_user = ""
|
||||||
|
tabs = ""
|
||||||
|
target_org_picker = ""
|
||||||
|
target_queue_empty = ""
|
||||||
|
target_queue_remove = ""
|
||||||
|
target_tenant_picker_title = ""
|
||||||
|
target_tenant_required_option = ""
|
||||||
user_list = ""
|
user_list = ""
|
||||||
|
|
||||||
[ui.admin.profile]
|
[ui.admin.profile]
|
||||||
@@ -1501,6 +1547,8 @@ section = ""
|
|||||||
[ui.admin.tenants.bulk]
|
[ui.admin.tenants.bulk]
|
||||||
selected_count = ""
|
selected_count = ""
|
||||||
status_placeholder = ""
|
status_placeholder = ""
|
||||||
|
type_placeholder = ""
|
||||||
|
visibility_placeholder = ""
|
||||||
|
|
||||||
[ui.admin.tenants.create]
|
[ui.admin.tenants.create]
|
||||||
title = ""
|
title = ""
|
||||||
@@ -2575,13 +2623,16 @@ actions = ""
|
|||||||
application = ""
|
application = ""
|
||||||
client_id = ""
|
client_id = ""
|
||||||
created_at = ""
|
created_at = ""
|
||||||
|
creator = ""
|
||||||
status = ""
|
status = ""
|
||||||
type = ""
|
type = ""
|
||||||
|
|
||||||
[ui.dev.clients.type]
|
[ui.dev.clients.type]
|
||||||
|
headless = ""
|
||||||
pkce = ""
|
pkce = ""
|
||||||
pkce_headless = ""
|
pkce_headless = ""
|
||||||
private = ""
|
private = ""
|
||||||
|
private_headless = ""
|
||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = ""
|
ready_badge = ""
|
||||||
|
|||||||
@@ -134,6 +134,28 @@ export function buildOrgPickerTree({
|
|||||||
usersBySlug.set(slug, list);
|
usersBySlug.set(slug, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exposeAllRoots = rootTenantId?.trim().toLowerCase() === "all";
|
||||||
|
const tree = buildTenantFullTree(visibleTenants);
|
||||||
|
|
||||||
|
if (exposeAllRoots) {
|
||||||
|
const rootNodes = tree.subTree.filter((node) => node.type !== "USER_GROUP");
|
||||||
|
const companies = rootNodes.flatMap((root) =>
|
||||||
|
orderHanmacFamilyChildren(root, root.children).filter(
|
||||||
|
(node) => node.type === "COMPANY",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roots: rootNodes.map((node) => tenantToPickerNode(node, usersBySlug)),
|
||||||
|
companies: companies.map((company) => ({
|
||||||
|
id: company.id,
|
||||||
|
name: company.name,
|
||||||
|
companyGroupTenantId: getCompanyGroupId(company, tenants),
|
||||||
|
})),
|
||||||
|
companyGroupId: "all",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const companyGroup =
|
const companyGroup =
|
||||||
findTenantByRef(visibleTenants, rootTenantId) ??
|
findTenantByRef(visibleTenants, rootTenantId) ??
|
||||||
visibleTenants.find(isHanmacFamilyCompanyGroup) ??
|
visibleTenants.find(isHanmacFamilyCompanyGroup) ??
|
||||||
@@ -144,10 +166,7 @@ export function buildOrgPickerTree({
|
|||||||
|
|
||||||
const { currentBase } = buildTenantFullTree(visibleTenants, companyGroup.id);
|
const { currentBase } = buildTenantFullTree(visibleTenants, companyGroup.id);
|
||||||
const groupNode =
|
const groupNode =
|
||||||
currentBase ??
|
currentBase ?? tree.subTree.find((node) => node.id === companyGroup.id);
|
||||||
buildTenantFullTree(visibleTenants).subTree.find(
|
|
||||||
(node) => node.id === companyGroup.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!groupNode) return { roots: [], companies: [], companyGroupId: "" };
|
if (!groupNode) return { roots: [], companies: [], companyGroupId: "" };
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type OrgPickerSelection = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
rootTenantName?: string;
|
||||||
|
leafTenantName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OrgPickerResult = {
|
export type OrgPickerResult = {
|
||||||
|
|||||||
@@ -40,13 +40,24 @@ function canToggleNode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSelection(node: OrgPickerTreeNode): OrgPickerSelection {
|
function toSelection(
|
||||||
|
node: OrgPickerTreeNode,
|
||||||
|
ancestors: OrgPickerTreeNode[] = [],
|
||||||
|
): OrgPickerSelection {
|
||||||
if (node.type === "user") {
|
if (node.type === "user") {
|
||||||
|
const tenantAncestors = ancestors.filter(
|
||||||
|
(ancestor) => ancestor.type === "tenant",
|
||||||
|
);
|
||||||
|
const rootTenant = tenantAncestors[0];
|
||||||
|
const leafTenant = tenantAncestors[tenantAncestors.length - 1];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: node.type,
|
type: node.type,
|
||||||
id: node.id,
|
id: node.id,
|
||||||
name: node.name,
|
name: node.name,
|
||||||
email: node.user?.email,
|
email: node.user?.email,
|
||||||
|
rootTenantName: rootTenant?.name,
|
||||||
|
leafTenantName: leafTenant?.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,27 +79,49 @@ function collectSelectedNodes({
|
|||||||
includeDescendants: boolean;
|
includeDescendants: boolean;
|
||||||
select: OrgPickerSelectableType;
|
select: OrgPickerSelectableType;
|
||||||
}) {
|
}) {
|
||||||
const selected = new Map<string, OrgPickerTreeNode>();
|
const selected = new Map<
|
||||||
const visit = (node: OrgPickerTreeNode) => {
|
string,
|
||||||
|
{ node: OrgPickerTreeNode; ancestors: OrgPickerTreeNode[] }
|
||||||
|
>();
|
||||||
|
const addNode = (node: OrgPickerTreeNode, ancestors: OrgPickerTreeNode[]) => {
|
||||||
|
if (canSelectNode(node, select)) {
|
||||||
|
selected.set(nodeKey(node), { node, ancestors });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const addDescendants = (
|
||||||
|
node: OrgPickerTreeNode,
|
||||||
|
ancestors: OrgPickerTreeNode[],
|
||||||
|
) => {
|
||||||
|
const visitDescendant = (
|
||||||
|
descendant: OrgPickerTreeNode,
|
||||||
|
descendantAncestors: OrgPickerTreeNode[],
|
||||||
|
) => {
|
||||||
|
addNode(descendant, descendantAncestors);
|
||||||
|
for (const child of descendant.children) {
|
||||||
|
visitDescendant(child, [...descendantAncestors, descendant]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
visitDescendant(child, [...ancestors, node]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const visit = (node: OrgPickerTreeNode, ancestors: OrgPickerTreeNode[]) => {
|
||||||
const key = nodeKey(node);
|
const key = nodeKey(node);
|
||||||
if (selectedKeys.has(key)) {
|
if (selectedKeys.has(key)) {
|
||||||
if (canSelectNode(node, select)) {
|
addNode(node, ancestors);
|
||||||
selected.set(key, node);
|
|
||||||
}
|
|
||||||
if (includeDescendants && node.type === "tenant") {
|
if (includeDescendants && node.type === "tenant") {
|
||||||
for (const descendant of flattenDescendants(node)) {
|
addDescendants(node, ancestors);
|
||||||
if (canSelectNode(descendant, select)) {
|
|
||||||
selected.set(nodeKey(descendant), descendant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const child of node.children) visit(child);
|
for (const child of node.children) visit(child, [...ancestors, node]);
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const root of roots) visit(root);
|
for (const root of roots) visit(root, []);
|
||||||
return Array.from(selected.values()).map(toSelection);
|
return Array.from(selected.values()).map(({ node, ancestors }) =>
|
||||||
|
toSelection(node, ancestors),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectCheckedKeys({
|
function collectCheckedKeys({
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||||
import type { AuthProviderProps } from "react-oidc-context";
|
import type { AuthProviderProps } from "react-oidc-context";
|
||||||
|
import { buildCommonUserManagerSettings } from "../../../common/core/auth";
|
||||||
import {
|
import {
|
||||||
buildCommonOidcRuntimeConfig,
|
buildOrgFrontOidcRuntimeConfig,
|
||||||
buildCommonUserManagerSettings,
|
resolveOrgFrontPublicOrigin,
|
||||||
} from "../../../common/core/auth";
|
} from "./authConfig";
|
||||||
import { resolveOrgFrontPublicOrigin } from "./authConfig";
|
|
||||||
|
|
||||||
const orgFrontPublicOrigin = resolveOrgFrontPublicOrigin(
|
const orgFrontPublicOrigin = resolveOrgFrontPublicOrigin(
|
||||||
import.meta.env.VITE_ORGFRONT_PUBLIC_URL,
|
import.meta.env.VITE_ORGFRONT_PUBLIC_URL,
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
|
export const oidcConfig: AuthProviderProps = buildOrgFrontOidcRuntimeConfig({
|
||||||
authority:
|
authority:
|
||||||
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc",
|
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc",
|
||||||
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
|
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildOrgFrontAuthRedirectUris,
|
buildOrgFrontAuthRedirectUris,
|
||||||
|
buildOrgFrontOidcRuntimeConfig,
|
||||||
ORGFRONT_AUTH_CALLBACK_PATH,
|
ORGFRONT_AUTH_CALLBACK_PATH,
|
||||||
resolveOrgFrontPublicOrigin,
|
resolveOrgFrontPublicOrigin,
|
||||||
} from "./authConfig";
|
} from "./authConfig";
|
||||||
@@ -26,4 +27,18 @@ describe("orgfront auth config", () => {
|
|||||||
it("keeps the callback path aligned with the registered redirect path", () => {
|
it("keeps the callback path aligned with the registered redirect path", () => {
|
||||||
expect(ORGFRONT_AUTH_CALLBACK_PATH).toBe("/auth/callback");
|
expect(ORGFRONT_AUTH_CALLBACK_PATH).toBe("/auth/callback");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requests offline access and enables refresh-token based renewal", () => {
|
||||||
|
const config = buildOrgFrontOidcRuntimeConfig({
|
||||||
|
authority: "https://sso.hmac.kr/oidc",
|
||||||
|
clientId: "orgfront",
|
||||||
|
origin: "https://org.hmac.kr",
|
||||||
|
userStore: { kind: "test-store" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.scope.split(/\s+/)).toEqual(
|
||||||
|
expect.arrayContaining(["openid", "offline_access", "profile", "email"]),
|
||||||
|
);
|
||||||
|
expect(config.automaticSilentRenew).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
buildCommonOidcRuntimeConfig,
|
||||||
|
type CommonOidcConfigOptions,
|
||||||
|
} from "../../../common/core/auth";
|
||||||
|
|
||||||
export interface OrgFrontAuthRedirectUris {
|
export interface OrgFrontAuthRedirectUris {
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
postLogoutRedirectUri: string;
|
postLogoutRedirectUri: string;
|
||||||
@@ -31,3 +36,12 @@ export function buildOrgFrontAuthRedirectUris(
|
|||||||
popupRedirectUri: `${publicOrigin}${ORGFRONT_AUTH_CALLBACK_PATH}`,
|
popupRedirectUri: `${publicOrigin}${ORGFRONT_AUTH_CALLBACK_PATH}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildOrgFrontOidcRuntimeConfig<TUserStore>(
|
||||||
|
options: Omit<CommonOidcConfigOptions<TUserStore>, "automaticSilentRenew">,
|
||||||
|
) {
|
||||||
|
return buildCommonOidcRuntimeConfig({
|
||||||
|
...options,
|
||||||
|
automaticSilentRenew: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ function user(id: string, name: string, companyCode: string) {
|
|||||||
status: "active",
|
status: "active",
|
||||||
companyCode,
|
companyCode,
|
||||||
grade: "사원",
|
grade: "사원",
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [{ tenantSlug: companyCode }],
|
||||||
|
},
|
||||||
createdAt: "2026-04-01T00:00:00.000Z",
|
createdAt: "2026-04-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-04-01T00:00:00.000Z",
|
updatedAt: "2026-04-01T00:00:00.000Z",
|
||||||
};
|
};
|
||||||
@@ -338,7 +341,8 @@ test("org chart renders dense member nodes with calculated member columns", asyn
|
|||||||
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
|
||||||
|
|
||||||
const rootNode = page.locator('[data-testid="orgchart-node-root"]');
|
const rootNode = page.locator('[data-testid="orgchart-node-root"]');
|
||||||
await expect(rootNode).toHaveAttribute("width", /3\d{2}/);
|
await expect(rootNode).toHaveAttribute("width", /\d+/);
|
||||||
|
expect(Number(await rootNode.getAttribute("width"))).toBeGreaterThan(240);
|
||||||
await expect(rootNode.locator('[data-member-columns="2"]')).toBeVisible();
|
await expect(rootNode.locator('[data-member-columns="2"]')).toBeVisible();
|
||||||
await expect(rootNode.getByText("Dense User 10")).toBeVisible();
|
await expect(rootNode.getByText("Dense User 10")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ test("orgfront login waits for explicit auto parameter", async ({ page }) => {
|
|||||||
|
|
||||||
test("orgfront login auto parameter starts OIDC authorization", async ({
|
test("orgfront login auto parameter starts OIDC authorization", async ({
|
||||||
page,
|
page,
|
||||||
|
baseURL,
|
||||||
}) => {
|
}) => {
|
||||||
const oidc = await stubOidcAuthorization(page);
|
const oidc = await stubOidcAuthorization(page);
|
||||||
|
|
||||||
@@ -55,11 +56,15 @@ test("orgfront login auto parameter starts OIDC authorization", async ({
|
|||||||
|
|
||||||
const parsed = new URL(oidc.authorizationURL());
|
const parsed = new URL(oidc.authorizationURL());
|
||||||
expect(parsed.searchParams.get("client_id")).toBe("orgfront");
|
expect(parsed.searchParams.get("client_id")).toBe("orgfront");
|
||||||
expect(parsed.searchParams.get("redirect_uri")).toBe(
|
const redirectUri = new URL(parsed.searchParams.get("redirect_uri") ?? "");
|
||||||
"http://127.0.0.1:4175/auth/callback",
|
const appUrl = new URL(baseURL ?? page.url());
|
||||||
);
|
expect(["localhost", "127.0.0.1"]).toContain(redirectUri.hostname);
|
||||||
|
expect(redirectUri.port).toBe(appUrl.port);
|
||||||
|
expect(redirectUri.pathname).toBe("/auth/callback");
|
||||||
expect(parsed.searchParams.get("response_type")).toBe("code");
|
expect(parsed.searchParams.get("response_type")).toBe("code");
|
||||||
expect(parsed.searchParams.get("scope") ?? "").toContain("openid");
|
expect((parsed.searchParams.get("scope") ?? "").split(/\s+/)).toEqual(
|
||||||
|
expect.arrayContaining(["openid", "offline_access", "profile", "email"]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("orgfront login can opt out of default OIDC authorization", async ({
|
test("orgfront login can opt out of default OIDC authorization", async ({
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
import '../../../core/i18n/locale_utils.dart';
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
@@ -39,7 +38,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
bool _isEmailVerified = false;
|
bool _isEmailVerified = false;
|
||||||
bool _isPhoneVerified = false;
|
bool _isPhoneVerified = false;
|
||||||
String _affiliationType = 'GENERAL';
|
String _affiliationType = 'GENERAL';
|
||||||
bool _isAffiliateLocked = false;
|
|
||||||
String? _companyCode;
|
String? _companyCode;
|
||||||
bool _termsAccepted = false;
|
bool _termsAccepted = false;
|
||||||
bool _privacyAccepted = false;
|
bool _privacyAccepted = false;
|
||||||
@@ -283,11 +281,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
|
|
||||||
if (res['isAffiliate'] == true) {
|
if (res['isAffiliate'] == true) {
|
||||||
_affiliationType = 'AFFILIATE';
|
_affiliationType = 'AFFILIATE';
|
||||||
_isAffiliateLocked = true;
|
|
||||||
} else {
|
} else {
|
||||||
_affiliationType = 'GENERAL';
|
_affiliationType = 'GENERAL';
|
||||||
_companyCode = null;
|
_companyCode = null;
|
||||||
_isAffiliateLocked = true;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user