forked from baron/baron-sso
조직도 표현 개선
This commit is contained in:
@@ -105,7 +105,10 @@ function createEmptyAppointment(): AppointmentDraft {
|
|||||||
tenantId: "",
|
tenantId: "",
|
||||||
tenantName: "",
|
tenantName: "",
|
||||||
tenantSlug: "",
|
tenantSlug: "",
|
||||||
|
isPrimary: false,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isManager: false,
|
||||||
grade: "",
|
grade: "",
|
||||||
jobTitle: "",
|
jobTitle: "",
|
||||||
position: "",
|
position: "",
|
||||||
@@ -314,8 +317,8 @@ function UserCreatePage() {
|
|||||||
if (currentIndex === index) {
|
if (currentIndex === index) {
|
||||||
return { ...appointment, ...patch };
|
return { ...appointment, ...patch };
|
||||||
}
|
}
|
||||||
if (patch.isOwner === true) {
|
if (patch.isPrimary === true) {
|
||||||
return { ...appointment, isOwner: false };
|
return { ...appointment, isPrimary: false };
|
||||||
}
|
}
|
||||||
return appointment;
|
return appointment;
|
||||||
}),
|
}),
|
||||||
@@ -425,8 +428,10 @@ function UserCreatePage() {
|
|||||||
tenantId: appointment.tenantId,
|
tenantId: appointment.tenantId,
|
||||||
tenantSlug: appointment.tenantSlug,
|
tenantSlug: appointment.tenantSlug,
|
||||||
tenantName: appointment.tenantName,
|
tenantName: appointment.tenantName,
|
||||||
isPrimary: appointment.isOwner,
|
isPrimary: appointment.isPrimary === true,
|
||||||
isOwner: appointment.isOwner,
|
...(appointment.isOwner === true ? { isOwner: true } : {}),
|
||||||
|
...(appointment.isAdmin === true ? { isAdmin: true } : {}),
|
||||||
|
...(appointment.isManager === true ? { isManager: true } : {}),
|
||||||
grade: appointment.grade,
|
grade: appointment.grade,
|
||||||
jobTitle: appointment.jobTitle,
|
jobTitle: appointment.jobTitle,
|
||||||
position: appointment.position,
|
position: appointment.position,
|
||||||
@@ -442,12 +447,11 @@ function UserCreatePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const primary = appointments.find((a) => a.isOwner);
|
const primary = appointments.find((a) => a.isPrimary);
|
||||||
if (primary) {
|
if (primary) {
|
||||||
metadata.primaryTenantId = primary.tenantId;
|
metadata.primaryTenantId = primary.tenantId;
|
||||||
metadata.primaryTenantSlug = primary.tenantSlug;
|
metadata.primaryTenantSlug = primary.tenantSlug;
|
||||||
metadata.primaryTenantName = primary.tenantName;
|
metadata.primaryTenantName = primary.tenantName;
|
||||||
metadata.primaryTenantIsOwner = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
payload.additionalAppointments = appointments;
|
payload.additionalAppointments = appointments;
|
||||||
@@ -811,10 +815,10 @@ function UserCreatePage() {
|
|||||||
)}
|
)}
|
||||||
<label className="flex items-center gap-3 text-sm">
|
<label className="flex items-center gap-3 text-sm">
|
||||||
<Switch
|
<Switch
|
||||||
checked={appointment.isOwner}
|
checked={appointment.isPrimary === true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateAppointment(index, {
|
updateAppointment(index, {
|
||||||
isOwner: checked === true,
|
isPrimary: checked === true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
aria-label={t(
|
aria-label={t(
|
||||||
@@ -827,6 +831,24 @@ function UserCreatePage() {
|
|||||||
"대표 조직",
|
"대표 조직",
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<Switch
|
||||||
|
checked={appointment.isManager === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateAppointment(index, {
|
||||||
|
isManager: checked === true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={t(
|
||||||
|
"ui.admin.users.detail.form.appointment_manager",
|
||||||
|
"조직장",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.detail.form.appointment_manager",
|
||||||
|
"조직장",
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ function createEmptyAppointment(): AppointmentDraft {
|
|||||||
tenantSlug: "",
|
tenantSlug: "",
|
||||||
isPrimary: false,
|
isPrimary: false,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isManager: false,
|
||||||
grade: "",
|
grade: "",
|
||||||
jobTitle: "",
|
jobTitle: "",
|
||||||
position: "",
|
position: "",
|
||||||
@@ -551,8 +553,8 @@ function UserDetailPage() {
|
|||||||
if (currentIndex === index) {
|
if (currentIndex === index) {
|
||||||
return { ...appointment, ...patch };
|
return { ...appointment, ...patch };
|
||||||
}
|
}
|
||||||
if (patch.isOwner === true) {
|
if (patch.isPrimary === true) {
|
||||||
return { ...appointment, isOwner: false };
|
return { ...appointment, isPrimary: false };
|
||||||
}
|
}
|
||||||
return appointment;
|
return appointment;
|
||||||
}),
|
}),
|
||||||
@@ -663,6 +665,9 @@ function UserDetailPage() {
|
|||||||
isPrimary:
|
isPrimary:
|
||||||
appointment.isPrimary === true ||
|
appointment.isPrimary === true ||
|
||||||
appointment.tenantId === primaryFromMetadata?.id,
|
appointment.tenantId === primaryFromMetadata?.id,
|
||||||
|
isOwner: appointment.isOwner === true,
|
||||||
|
isAdmin: appointment.isAdmin === true,
|
||||||
|
isManager: appointment.isManager === true,
|
||||||
draftId: createDraftId(),
|
draftId: createDraftId(),
|
||||||
}))
|
}))
|
||||||
: isUserHanmacFamily
|
: isUserHanmacFamily
|
||||||
@@ -676,6 +681,8 @@ function UserDetailPage() {
|
|||||||
isOwner:
|
isOwner:
|
||||||
metadata.primaryTenantIsOwner === true &&
|
metadata.primaryTenantIsOwner === true &&
|
||||||
tenant.id === fallbackAppointment?.id,
|
tenant.id === fallbackAppointment?.id,
|
||||||
|
isAdmin: false,
|
||||||
|
isManager: false,
|
||||||
grade: user.grade,
|
grade: user.grade,
|
||||||
jobTitle: user.jobTitle,
|
jobTitle: user.jobTitle,
|
||||||
position: user.position,
|
position: user.position,
|
||||||
@@ -689,6 +696,8 @@ function UserDetailPage() {
|
|||||||
tenantSlug: fallbackAppointment.slug,
|
tenantSlug: fallbackAppointment.slug,
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
isOwner: metadata.primaryTenantIsOwner === true,
|
isOwner: metadata.primaryTenantIsOwner === true,
|
||||||
|
isAdmin: false,
|
||||||
|
isManager: false,
|
||||||
grade: user.grade,
|
grade: user.grade,
|
||||||
jobTitle: user.jobTitle,
|
jobTitle: user.jobTitle,
|
||||||
position: user.position,
|
position: user.position,
|
||||||
@@ -779,23 +788,23 @@ function UserDetailPage() {
|
|||||||
tenantId: appointment.tenantId,
|
tenantId: appointment.tenantId,
|
||||||
tenantSlug: appointment.tenantSlug,
|
tenantSlug: appointment.tenantSlug,
|
||||||
tenantName: appointment.tenantName,
|
tenantName: appointment.tenantName,
|
||||||
isPrimary: appointment.isOwner,
|
isPrimary: appointment.isPrimary === true,
|
||||||
isOwner: appointment.isOwner,
|
...(appointment.isOwner === true ? { isOwner: true } : {}),
|
||||||
|
...(appointment.isAdmin === true ? { isAdmin: true } : {}),
|
||||||
|
...(appointment.isManager === true ? { isManager: true } : {}),
|
||||||
grade: appointment.grade,
|
grade: appointment.grade,
|
||||||
jobTitle: appointment.jobTitle,
|
jobTitle: appointment.jobTitle,
|
||||||
position: appointment.position,
|
position: appointment.position,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const primary = appointments.find((a) => a.isOwner);
|
const primary = appointments.find((a) => a.isPrimary);
|
||||||
if (primary) {
|
if (primary) {
|
||||||
payload.tenantSlug = primary.tenantSlug;
|
payload.tenantSlug = primary.tenantSlug;
|
||||||
payload.primaryTenantId = primary.tenantId;
|
payload.primaryTenantId = primary.tenantId;
|
||||||
payload.primaryTenantName = primary.tenantName;
|
payload.primaryTenantName = primary.tenantName;
|
||||||
payload.primaryTenantIsOwner = true;
|
|
||||||
metadata.primaryTenantId = primary.tenantId;
|
metadata.primaryTenantId = primary.tenantId;
|
||||||
metadata.primaryTenantSlug = primary.tenantSlug;
|
metadata.primaryTenantSlug = primary.tenantSlug;
|
||||||
metadata.primaryTenantName = primary.tenantName;
|
metadata.primaryTenantName = primary.tenantName;
|
||||||
metadata.primaryTenantIsOwner = true;
|
|
||||||
} else {
|
} else {
|
||||||
payload.tenantSlug = undefined;
|
payload.tenantSlug = undefined;
|
||||||
}
|
}
|
||||||
@@ -811,12 +820,10 @@ function UserDetailPage() {
|
|||||||
primaryTenantId: primary?.tenantId,
|
primaryTenantId: primary?.tenantId,
|
||||||
primaryTenantName: primary?.tenantName,
|
primaryTenantName: primary?.tenantName,
|
||||||
primaryTenantSlug: primary?.tenantSlug,
|
primaryTenantSlug: primary?.tenantSlug,
|
||||||
primaryTenantIsOwner: primary?.isOwner ?? false,
|
|
||||||
};
|
};
|
||||||
payload.tenantSlug = primary?.tenantSlug;
|
payload.tenantSlug = primary?.tenantSlug;
|
||||||
payload.primaryTenantId = primary?.tenantId;
|
payload.primaryTenantId = primary?.tenantId;
|
||||||
payload.primaryTenantName = primary?.tenantName;
|
payload.primaryTenantName = primary?.tenantName;
|
||||||
payload.primaryTenantIsOwner = primary?.isOwner ?? false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation.mutate(payload);
|
mutation.mutate(payload);
|
||||||
@@ -1221,13 +1228,13 @@ function UserDetailPage() {
|
|||||||
)}
|
)}
|
||||||
<label className="flex items-center gap-3 text-sm">
|
<label className="flex items-center gap-3 text-sm">
|
||||||
<Switch
|
<Switch
|
||||||
checked={appointment.isOwner}
|
checked={appointment.isPrimary === true}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateAppointment(index, {
|
updateAppointment(index, {
|
||||||
isOwner: checked === true,
|
isPrimary: checked === true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={appointment.isPrimary}
|
disabled={appointment.isPrimary === true}
|
||||||
aria-label={t(
|
aria-label={t(
|
||||||
"ui.admin.users.detail.form.appointment_owner",
|
"ui.admin.users.detail.form.appointment_owner",
|
||||||
"대표 조직",
|
"대표 조직",
|
||||||
@@ -1238,6 +1245,24 @@ function UserDetailPage() {
|
|||||||
"대표 조직",
|
"대표 조직",
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<Switch
|
||||||
|
checked={appointment.isManager === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateAppointment(index, {
|
||||||
|
isManager: checked === true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={t(
|
||||||
|
"ui.admin.users.detail.form.appointment_manager",
|
||||||
|
"조직장",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.detail.form.appointment_manager",
|
||||||
|
"조직장",
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
148
adminfront/src/features/users/UserListPage.render.test.tsx
Normal file
148
adminfront/src/features/users/UserListPage.render.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
|
import UserListPage from "./UserListPage";
|
||||||
|
|
||||||
|
const selectRenderCounter = vi.hoisted(() => ({ count: 0 }));
|
||||||
|
|
||||||
|
const users = Array.from({ length: 200 }, (_, index) => ({
|
||||||
|
id: `user-${index}`,
|
||||||
|
name: `User ${index}`,
|
||||||
|
email: `user${index}@example.com`,
|
||||||
|
phone: `010-${String(index).padStart(4, "0")}-0000`,
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
tenantSlug: "hanmac",
|
||||||
|
tenant: { id: "tenant-1", name: "한맥", slug: "hanmac" },
|
||||||
|
metadata: {},
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchUsersMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
|
fetchMe: vi.fn(async () => ({
|
||||||
|
id: "admin-user",
|
||||||
|
role: "super_admin",
|
||||||
|
name: "Admin",
|
||||||
|
email: "admin@example.com",
|
||||||
|
})),
|
||||||
|
fetchAllTenants: vi.fn(async () => ({
|
||||||
|
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
|
||||||
|
total: 1,
|
||||||
|
})),
|
||||||
|
fetchTenant: vi.fn(async () => ({
|
||||||
|
id: "tenant-1",
|
||||||
|
name: "한맥",
|
||||||
|
slug: "hanmac",
|
||||||
|
config: { userSchema: [] },
|
||||||
|
})),
|
||||||
|
fetchUsers: fetchUsersMock,
|
||||||
|
bulkCreateUsers: vi.fn(),
|
||||||
|
bulkDeleteUsers: vi.fn(),
|
||||||
|
bulkUpdateUsers: vi.fn(),
|
||||||
|
deleteUser: vi.fn(),
|
||||||
|
exportUsersCSV: vi.fn(),
|
||||||
|
updateUser: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/ui/select", () => ({
|
||||||
|
Select: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
SelectTrigger: ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||||
|
selectRenderCounter.count += 1;
|
||||||
|
return (
|
||||||
|
<button type="button" {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
SelectValue: () => <span />,
|
||||||
|
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
SelectItem: ({
|
||||||
|
children,
|
||||||
|
value: _value,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
value: string;
|
||||||
|
}) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderUserListPage() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<UserListPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("UserListPage search rendering", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
selectRenderCounter.count = 0;
|
||||||
|
fetchUsersMock.mockReset();
|
||||||
|
fetchUsersMock.mockImplementation(
|
||||||
|
async (
|
||||||
|
_limit: number,
|
||||||
|
_offset: number,
|
||||||
|
search?: string,
|
||||||
|
) => {
|
||||||
|
const normalizedSearch = search?.trim().toLowerCase();
|
||||||
|
const items = normalizedSearch
|
||||||
|
? users.filter((user) =>
|
||||||
|
`${user.name} ${user.email}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(normalizedSearch),
|
||||||
|
)
|
||||||
|
: users;
|
||||||
|
return { items, total: items.length };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not rerender user table controls while typing a draft search", async () => {
|
||||||
|
renderUserListPage();
|
||||||
|
|
||||||
|
await screen.findByText("User 199");
|
||||||
|
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
|
||||||
|
const renderCountBeforeTyping = selectRenderCounter.count;
|
||||||
|
|
||||||
|
fireEvent.change(searchInput, { target: { value: "u" } });
|
||||||
|
|
||||||
|
expect(searchInput).toHaveValue("u");
|
||||||
|
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a 200-user search result update within 200ms after search submit", async () => {
|
||||||
|
renderUserListPage();
|
||||||
|
|
||||||
|
await screen.findByText("User 199");
|
||||||
|
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
|
||||||
|
const startedAt = performance.now();
|
||||||
|
|
||||||
|
fireEvent.change(searchInput, { target: { value: "user 19" } });
|
||||||
|
fireEvent.keyDown(searchInput, { key: "Enter" });
|
||||||
|
|
||||||
|
await screen.findByText("User 19");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(performance.now() - startedAt).toBeLessThan(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,7 @@ import {
|
|||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { toast } from "../../components/ui/use-toast";
|
import { toast } from "../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
|
type TenantSummary,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
bulkUpdateUsers,
|
bulkUpdateUsers,
|
||||||
@@ -130,11 +131,115 @@ function assignableSystemRoleValue(role?: string | null) {
|
|||||||
return isSuperAdminRole(role) ? "super_admin" : "user";
|
return isSuperAdminRole(role) ? "super_admin" : "user";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userMatchesSearch(user: UserSummary, search: string) {
|
||||||
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
|
if (!normalizedSearch) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
user.name,
|
||||||
|
user.email,
|
||||||
|
user.phone,
|
||||||
|
user.id,
|
||||||
|
user.tenantSlug,
|
||||||
|
user.tenant?.name,
|
||||||
|
user.department,
|
||||||
|
].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserListSearchControlsProps = {
|
||||||
|
search: string;
|
||||||
|
selectedCompany: string;
|
||||||
|
tenants: TenantSummary[];
|
||||||
|
profileRole?: string | null;
|
||||||
|
onSearch: (value: string) => void;
|
||||||
|
onCompanyChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||||
|
search,
|
||||||
|
selectedCompany,
|
||||||
|
tenants,
|
||||||
|
profileRole,
|
||||||
|
onSearch,
|
||||||
|
onCompanyChange,
|
||||||
|
}: UserListSearchControlsProps) {
|
||||||
|
const [searchDraft, setSearchDraft] = React.useState(search);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSearchDraft(search);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const handleSearch = React.useCallback(() => {
|
||||||
|
onSearch(searchDraft);
|
||||||
|
}, [onSearch, searchDraft]);
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenantOptions = React.useMemo(
|
||||||
|
() =>
|
||||||
|
tenants.map((tenant) => (
|
||||||
|
<option key={tenant.id} value={tenant.slug}>
|
||||||
|
{tenant.name}
|
||||||
|
</option>
|
||||||
|
)),
|
||||||
|
[tenants],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchFilterBar
|
||||||
|
primary={
|
||||||
|
<>
|
||||||
|
<div className="relative w-48">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.users.list.search_placeholder",
|
||||||
|
"이름 또는 이메일 검색...",
|
||||||
|
)}
|
||||||
|
className="h-9 pl-9"
|
||||||
|
value={searchDraft}
|
||||||
|
onChange={(event) => setSearchDraft(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||||
|
value={selectedCompany}
|
||||||
|
onChange={(event) => onCompanyChange(event.target.value)}
|
||||||
|
disabled={profileRole === "tenant_admin"}
|
||||||
|
>
|
||||||
|
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
|
||||||
|
{tenantOptions}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
{t("ui.common.search", "검색")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function UserListPage() {
|
function UserListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [page, setPage] = React.useState(1);
|
const [page, setPage] = React.useState(1);
|
||||||
const [search, setSearch] = React.useState("");
|
const [search, setSearch] = React.useState("");
|
||||||
const [searchDraft, setSearchDraft] = React.useState("");
|
|
||||||
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
||||||
const [visibleColumns, setVisibleColumns] = React.useState<
|
const [visibleColumns, setVisibleColumns] = React.useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
@@ -254,16 +359,15 @@ function UserListPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = React.useCallback((nextSearch: string) => {
|
||||||
setSearch(searchDraft);
|
setSearch(nextSearch);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleCompanyChange = React.useCallback((nextCompany: string) => {
|
||||||
if (e.key === "Enter") {
|
setSelectedCompany(nextCompany);
|
||||||
handleSearch();
|
setPage(1);
|
||||||
}
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = (includeIds = false) => {
|
const handleExport = (includeIds = false) => {
|
||||||
exportMutation.mutate(includeIds);
|
exportMutation.mutate(includeIds);
|
||||||
@@ -279,7 +383,14 @@ function UserListPage() {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const rawItems = query.data?.items ?? [];
|
const serverItems = query.data?.items ?? [];
|
||||||
|
const rawItems = React.useMemo(() => {
|
||||||
|
if (!query.isFetching || search.trim() === "") {
|
||||||
|
return serverItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverItems.filter((user) => userMatchesSearch(user, search));
|
||||||
|
}, [query.isFetching, search, serverItems]);
|
||||||
const userSortResolvers = React.useMemo<
|
const userSortResolvers = React.useMemo<
|
||||||
SortResolverMap<UserSummary, UserSortKey>
|
SortResolverMap<UserSummary, UserSortKey>
|
||||||
>(
|
>(
|
||||||
@@ -436,52 +547,13 @@ function UserListPage() {
|
|||||||
)}
|
)}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchFilterBar
|
<UserListSearchControls
|
||||||
primary={
|
search={search}
|
||||||
<>
|
selectedCompany={selectedCompany}
|
||||||
<div className="relative w-48">
|
tenants={tenants}
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
profileRole={profile?.role}
|
||||||
<Input
|
onSearch={handleSearch}
|
||||||
placeholder={t(
|
onCompanyChange={handleCompanyChange}
|
||||||
"ui.admin.users.list.search_placeholder",
|
|
||||||
"이름 또는 이메일 검색...",
|
|
||||||
)}
|
|
||||||
className="h-9 pl-9"
|
|
||||||
value={searchDraft}
|
|
||||||
onChange={(e) => setSearchDraft(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<select
|
|
||||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
|
||||||
value={selectedCompany}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedCompany(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
disabled={profile?.role === "tenant_admin"}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{t("ui.common.all", "전체 테넌트")}
|
|
||||||
</option>
|
|
||||||
{tenants.map((t) => (
|
|
||||||
<option key={t.id} value={t.slug}>
|
|
||||||
{t.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSearch}
|
|
||||||
className="h-9"
|
|
||||||
>
|
|
||||||
{t("ui.common.search", "검색")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -206,6 +206,12 @@ function cleanAdditionalAppointment(
|
|||||||
...(appointment.isOwner !== undefined
|
...(appointment.isOwner !== undefined
|
||||||
? { isOwner: appointment.isOwner }
|
? { isOwner: appointment.isOwner }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(appointment.isAdmin !== undefined
|
||||||
|
? { isAdmin: appointment.isAdmin }
|
||||||
|
: {}),
|
||||||
|
...(appointment.isManager !== undefined
|
||||||
|
? { isManager: appointment.isManager }
|
||||||
|
: {}),
|
||||||
...(appointment.department ? { department: appointment.department } : {}),
|
...(appointment.department ? { department: appointment.department } : {}),
|
||||||
...(appointment.grade ? { grade: appointment.grade } : {}),
|
...(appointment.grade ? { grade: appointment.grade } : {}),
|
||||||
...(appointment.position ? { position: appointment.position } : {}),
|
...(appointment.position ? { position: appointment.position } : {}),
|
||||||
|
|||||||
@@ -701,7 +701,9 @@ export type UserAppointment = {
|
|||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
tenantName: string;
|
tenantName: string;
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
isOwner: boolean;
|
isOwner?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
isManager?: boolean;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
grade?: string;
|
grade?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
@@ -713,6 +715,8 @@ export type BulkUserAppointment = {
|
|||||||
tenantName?: string;
|
tenantName?: string;
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
isOwner?: boolean;
|
isOwner?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
isManager?: boolean;
|
||||||
department?: string;
|
department?: string;
|
||||||
grade?: string;
|
grade?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ describe("common cursor pagination fetch", () => {
|
|||||||
expect(response.items).toEqual([{ id: "tenant-1" }, { id: "tenant-2" }]);
|
expect(response.items).toEqual([{ id: "tenant-1" }, { id: "tenant-2" }]);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(fetchMock.mock.calls[0][0].toString()).toContain(
|
expect(fetchMock.mock.calls[0][0].toString()).toContain(
|
||||||
"/api/v1/admin/tenants?parentId=parent-1&limit=1&offset=0",
|
"/api/v1/admin/tenants?parentId=parent-1&limit=1",
|
||||||
);
|
);
|
||||||
|
expect(fetchMock.mock.calls[0][0].toString()).not.toContain("offset=");
|
||||||
expect(fetchMock.mock.calls[1][0].toString()).toContain("cursor=cursor-1");
|
expect(fetchMock.mock.calls[1][0].toString()).toContain("cursor=cursor-1");
|
||||||
|
expect(fetchMock.mock.calls[1][0].toString()).not.toContain("offset=");
|
||||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({
|
expect(fetchMock.mock.calls[0][1]).toMatchObject({
|
||||||
headers: { Authorization: "Bearer token" },
|
headers: { Authorization: "Bearer token" },
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
|
|||||||
@@ -782,12 +782,10 @@ test.describe("User Management", () => {
|
|||||||
tenantSlug: "hanmac-team",
|
tenantSlug: "hanmac-team",
|
||||||
primaryTenantId: "hanmac-team-id",
|
primaryTenantId: "hanmac-team-id",
|
||||||
primaryTenantName: "한맥팀",
|
primaryTenantName: "한맥팀",
|
||||||
primaryTenantIsOwner: true,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
primaryTenantId: "hanmac-team-id",
|
primaryTenantId: "hanmac-team-id",
|
||||||
primaryTenantName: "한맥팀",
|
primaryTenantName: "한맥팀",
|
||||||
primaryTenantSlug: "hanmac-team",
|
primaryTenantSlug: "hanmac-team",
|
||||||
primaryTenantIsOwner: true,
|
|
||||||
additionalAppointments: [
|
additionalAppointments: [
|
||||||
{
|
{
|
||||||
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||||
@@ -797,6 +795,14 @@ test.describe("User Management", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(updatePayload?.primaryTenantIsOwner).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
(updatePayload?.metadata as Record<string, unknown>)
|
||||||
|
?.primaryTenantIsOwner,
|
||||||
|
).toBeUndefined();
|
||||||
|
const appointments = (updatePayload?.metadata as Record<string, unknown>)
|
||||||
|
?.additionalAppointments as Array<Record<string, unknown>>;
|
||||||
|
expect(appointments[1].isOwner).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show conflict error when creating with an existing Login ID", async ({
|
test("should show conflict error when creating with an existing Login ID", async ({
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ type orgContextMember struct {
|
|||||||
Position string `json:"position,omitempty"`
|
Position string `json:"position,omitempty"`
|
||||||
JobTitle string `json:"jobTitle,omitempty"`
|
JobTitle string `json:"jobTitle,omitempty"`
|
||||||
IsOwner bool `json:"isOwner"`
|
IsOwner bool `json:"isOwner"`
|
||||||
IsLeader bool `json:"isLeader"`
|
IsManager bool `json:"isManager"`
|
||||||
IsPrimary bool `json:"isPrimary"`
|
IsPrimary bool `json:"isPrimary"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2412,12 +2412,12 @@ func mapOrgContextMember(user domain.User, appointment map[string]any, includeUs
|
|||||||
department = value
|
department = value
|
||||||
}
|
}
|
||||||
isOwner := false
|
isOwner := false
|
||||||
if value, ok := metadataBoolFromMap(appointment, "isOwner", "isManager"); ok {
|
if value, ok := metadataBoolFromMap(appointment, "isOwner"); ok {
|
||||||
isOwner = value
|
isOwner = value
|
||||||
}
|
}
|
||||||
isLeader := isOwner
|
isManager := false
|
||||||
if value, ok := metadataBoolFromMap(appointment, "lead", "isLead"); ok {
|
if value, ok := metadataBoolFromMap(appointment, "isManager", "lead", "isLead"); ok {
|
||||||
isLeader = value
|
isManager = value
|
||||||
}
|
}
|
||||||
isPrimary := false
|
isPrimary := false
|
||||||
if value, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok {
|
if value, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok {
|
||||||
@@ -2439,7 +2439,7 @@ func mapOrgContextMember(user domain.User, appointment map[string]any, includeUs
|
|||||||
Position: position,
|
Position: position,
|
||||||
JobTitle: jobTitle,
|
JobTitle: jobTitle,
|
||||||
IsOwner: isOwner,
|
IsOwner: isOwner,
|
||||||
IsLeader: isLeader,
|
IsManager: isManager,
|
||||||
IsPrimary: isPrimary,
|
IsPrimary: isPrimary,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -690,7 +690,7 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
|
|||||||
"additionalAppointments": []any{
|
"additionalAppointments": []any{
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tenantSlug": "sso",
|
"tenantSlug": "sso",
|
||||||
"lead": true,
|
"isManager": true,
|
||||||
"position": "파트장",
|
"position": "파트장",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -743,8 +743,9 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
|
|||||||
require.Equal(t, "lead@example.com", firstUser["email"])
|
require.Equal(t, "lead@example.com", firstUser["email"])
|
||||||
require.Equal(t, "플랫폼 리드", firstUser["name"])
|
require.Equal(t, "플랫폼 리드", firstUser["name"])
|
||||||
require.Equal(t, true, firstUser["isOwner"])
|
require.Equal(t, true, firstUser["isOwner"])
|
||||||
require.Equal(t, true, firstUser["isLeader"])
|
require.Equal(t, false, firstUser["isManager"])
|
||||||
require.Equal(t, true, firstUser["isPrimary"])
|
require.Equal(t, true, firstUser["isPrimary"])
|
||||||
|
require.NotContains(t, firstUser, "isLeader")
|
||||||
require.Equal(t, "수석", firstUser["grade"])
|
require.Equal(t, "수석", firstUser["grade"])
|
||||||
require.Equal(t, "실장", firstUser["position"])
|
require.Equal(t, "실장", firstUser["position"])
|
||||||
require.Equal(t, "기술기획", firstUser["jobTitle"])
|
require.Equal(t, "기술기획", firstUser["jobTitle"])
|
||||||
@@ -754,7 +755,8 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
|
|||||||
appointmentOnly := ssoMembers[0].(map[string]any)
|
appointmentOnly := ssoMembers[0].(map[string]any)
|
||||||
require.Equal(t, "appointment@example.com", appointmentOnly["email"])
|
require.Equal(t, "appointment@example.com", appointmentOnly["email"])
|
||||||
require.Equal(t, false, appointmentOnly["isOwner"])
|
require.Equal(t, false, appointmentOnly["isOwner"])
|
||||||
require.Equal(t, true, appointmentOnly["isLeader"])
|
require.Equal(t, true, appointmentOnly["isManager"])
|
||||||
|
require.NotContains(t, appointmentOnly, "isLeader")
|
||||||
|
|
||||||
tree := got["tree"].(map[string]any)
|
tree := got["tree"].(map[string]any)
|
||||||
require.Equal(t, "group-hanmac-family", tree["id"])
|
require.Equal(t, "group-hanmac-family", tree["id"])
|
||||||
|
|||||||
@@ -191,8 +191,8 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
|
|||||||
type worksmobileAppointment struct {
|
type worksmobileAppointment struct {
|
||||||
TenantID string
|
TenantID string
|
||||||
IsPrimary bool
|
IsPrimary bool
|
||||||
IsOwner bool
|
IsManager bool
|
||||||
HasOwner bool
|
HasManager bool
|
||||||
JobTitle string
|
JobTitle string
|
||||||
PositionID string
|
PositionID string
|
||||||
}
|
}
|
||||||
@@ -247,8 +247,8 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
|
|||||||
Primary: appointment.IsPrimary,
|
Primary: appointment.IsPrimary,
|
||||||
PositionID: appointment.PositionID,
|
PositionID: appointment.PositionID,
|
||||||
}
|
}
|
||||||
if appointment.HasOwner {
|
if appointment.HasManager {
|
||||||
isManager := appointment.IsOwner
|
isManager := appointment.IsManager
|
||||||
orgUnit.IsManager = &isManager
|
orgUnit.IsManager = &isManager
|
||||||
}
|
}
|
||||||
organizations = append(organizations, WorksmobileUserOrganization{
|
organizations = append(organizations, WorksmobileUserOrganization{
|
||||||
@@ -285,9 +285,9 @@ func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileA
|
|||||||
JobTitle: metadataString(domain.JSONMap(item), "jobTitle", "job_title", "task"),
|
JobTitle: metadataString(domain.JSONMap(item), "jobTitle", "job_title", "task"),
|
||||||
PositionID: metadataString(domain.JSONMap(item), "worksmobilePositionId", "positionId", "position_id"),
|
PositionID: metadataString(domain.JSONMap(item), "worksmobilePositionId", "positionId", "position_id"),
|
||||||
}
|
}
|
||||||
if isOwner, ok := metadataOptionalBool(domain.JSONMap(item), "isOwner", "isManager"); ok {
|
if isManager, ok := metadataOptionalBool(domain.JSONMap(item), "isManager", "lead", "isLead"); ok {
|
||||||
appointment.IsOwner = isOwner
|
appointment.IsManager = isManager
|
||||||
appointment.HasOwner = true
|
appointment.HasManager = true
|
||||||
}
|
}
|
||||||
appointments = append(appointments, appointment)
|
appointments = append(appointments, appointment)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,14 +150,14 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
|
|||||||
map[string]any{
|
map[string]any{
|
||||||
"tenantId": secondaryTenantID,
|
"tenantId": secondaryTenantID,
|
||||||
"isPrimary": false,
|
"isPrimary": false,
|
||||||
"isOwner": true,
|
"isManager": true,
|
||||||
"jobTitle": "PM",
|
"jobTitle": "PM",
|
||||||
"position": "팀장",
|
"position": "팀장",
|
||||||
},
|
},
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tenantId": primaryTenantID,
|
"tenantId": primaryTenantID,
|
||||||
"isPrimary": true,
|
"isPrimary": true,
|
||||||
"isOwner": false,
|
"isOwner": true,
|
||||||
"jobTitle": "Engineering",
|
"jobTitle": "Engineering",
|
||||||
"position": "책임",
|
"position": "책임",
|
||||||
},
|
},
|
||||||
@@ -194,8 +194,7 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
|
|||||||
require.True(t, payload.Organizations[0].Primary)
|
require.True(t, payload.Organizations[0].Primary)
|
||||||
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
|
||||||
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
|
||||||
require.NotNil(t, payload.Organizations[0].OrgUnits[0].IsManager)
|
require.Nil(t, payload.Organizations[0].OrgUnits[0].IsManager)
|
||||||
require.False(t, *payload.Organizations[0].OrgUnits[0].IsManager)
|
|
||||||
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
|
require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
|
||||||
require.False(t, payload.Organizations[1].Primary)
|
require.False(t, payload.Organizations[1].Primary)
|
||||||
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ function buildCursorFetchUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
url.searchParams.set("limit", String(pageSize));
|
url.searchParams.set("limit", String(pageSize));
|
||||||
url.searchParams.set("offset", "0");
|
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
url.searchParams.set("cursor", cursor);
|
url.searchParams.set("cursor", cursor);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -805,7 +805,7 @@ body = "We could not find an account for that information.\\\\\\\\\\\\\\\\nPleas
|
|||||||
[msg.userfront.login.verification]
|
[msg.userfront.login.verification]
|
||||||
approved = "Approved. Complete sign-in in the original window."
|
approved = "Approved. Complete sign-in in the original window."
|
||||||
approved_local = "Approved. This device is already signed in, and the remote window will be signed in shortly."
|
approved_local = "Approved. This device is already signed in, and the remote window will be signed in shortly."
|
||||||
approved_remote = "Approved. Please return to the original browser or PC screen."
|
approved_remote = "Your requested sign-in is complete."
|
||||||
pending_remote = "Checking the sign-in approval request. Please wait."
|
pending_remote = "Checking the sign-in approval request. Please wait."
|
||||||
success = "Sign-in approval completed."
|
success = "Sign-in approval completed."
|
||||||
|
|
||||||
@@ -2528,6 +2528,7 @@ title = "Account not found"
|
|||||||
|
|
||||||
[ui.userfront.login.verification]
|
[ui.userfront.login.verification]
|
||||||
action_label = "Done"
|
action_label = "Done"
|
||||||
|
action_label_remote = "Go to sign-in window"
|
||||||
action_label_close = "Close Window"
|
action_label_close = "Close Window"
|
||||||
page_title = "Sign-in approval"
|
page_title = "Sign-in approval"
|
||||||
title = "Approval complete"
|
title = "Approval complete"
|
||||||
|
|||||||
@@ -1296,7 +1296,7 @@ body = "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주
|
|||||||
[msg.userfront.login.verification]
|
[msg.userfront.login.verification]
|
||||||
approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."
|
approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."
|
||||||
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
|
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
|
||||||
approved_remote = "승인되었습니다. 요청하신 브라우저 또는 PC 화면으로 돌아가 주세요."
|
approved_remote = "요청하신 로그인이 완료되었습니다"
|
||||||
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
||||||
success = "로그인 승인에 성공했습니다."
|
success = "로그인 승인에 성공했습니다."
|
||||||
|
|
||||||
@@ -2953,6 +2953,7 @@ title = "미등록 회원"
|
|||||||
|
|
||||||
[ui.userfront.login.verification]
|
[ui.userfront.login.verification]
|
||||||
action_label = "확인"
|
action_label = "확인"
|
||||||
|
action_label_remote = "로그인 창으로 이동하기"
|
||||||
page_title = "로그인 승인"
|
page_title = "로그인 승인"
|
||||||
title = "승인 완료"
|
title = "승인 완료"
|
||||||
action_label_close = "창 닫기"
|
action_label_close = "창 닫기"
|
||||||
|
|||||||
@@ -2833,6 +2833,7 @@ title = ""
|
|||||||
|
|
||||||
[ui.userfront.login.verification]
|
[ui.userfront.login.verification]
|
||||||
action_label = ""
|
action_label = ""
|
||||||
|
action_label_remote = ""
|
||||||
action_label_close = ""
|
action_label_close = ""
|
||||||
page_title = ""
|
page_title = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|||||||
30
orgfront/src/features/orgchart/rankPriority.test.ts
Normal file
30
orgfront/src/features/orgchart/rankPriority.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
compareOrgRanks,
|
||||||
|
getOrgRankDisplayName,
|
||||||
|
getOrgRankWeight,
|
||||||
|
} from "./rankPriority";
|
||||||
|
|
||||||
|
describe("org chart rank priority", () => {
|
||||||
|
it("normalizes long rank aliases to short display labels", () => {
|
||||||
|
expect(getOrgRankDisplayName("전무이사")).toBe("전무");
|
||||||
|
expect(getOrgRankDisplayName("상무이사")).toBe("상무");
|
||||||
|
expect(getOrgRankDisplayName("수석연구원")).toBe("수석");
|
||||||
|
expect(getOrgRankDisplayName("책임연구원")).toBe("책임");
|
||||||
|
expect(getOrgRankDisplayName("선임연구원")).toBe("선임");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders executive and research ranks with shared priority weights", () => {
|
||||||
|
expect(getOrgRankWeight("사장")).toBeLessThan(getOrgRankWeight("부사장"));
|
||||||
|
expect(getOrgRankWeight("전무이사")).toBeLessThan(
|
||||||
|
getOrgRankWeight("상무"),
|
||||||
|
);
|
||||||
|
expect(getOrgRankWeight("수석연구원")).toBeLessThan(
|
||||||
|
getOrgRankWeight("책임"),
|
||||||
|
);
|
||||||
|
expect(getOrgRankWeight("책임연구원")).toBeLessThan(
|
||||||
|
getOrgRankWeight("선임"),
|
||||||
|
);
|
||||||
|
expect(compareOrgRanks("부장", "차장")).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
orgfront/src/features/orgchart/rankPriority.ts
Normal file
54
orgfront/src/features/orgchart/rankPriority.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export type OrgRankDefinition = {
|
||||||
|
aliases: string[];
|
||||||
|
label: string;
|
||||||
|
weight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ORG_RANK_DEFINITIONS: OrgRankDefinition[] = [
|
||||||
|
{ label: "사장", weight: 0, aliases: ["사장"] },
|
||||||
|
{ label: "부사장", weight: 10, aliases: ["부사장"] },
|
||||||
|
{ label: "전무", weight: 20, aliases: ["전무", "전무이사"] },
|
||||||
|
{ label: "상무", weight: 30, aliases: ["상무", "상무이사"] },
|
||||||
|
{ label: "이사", weight: 40, aliases: ["이사"] },
|
||||||
|
{ label: "부장", weight: 50, aliases: ["부장"] },
|
||||||
|
{ label: "수석", weight: 50, aliases: ["수석", "수석연구원"] },
|
||||||
|
{ label: "차장", weight: 60, aliases: ["차장"] },
|
||||||
|
{ label: "과장", weight: 70, aliases: ["과장"] },
|
||||||
|
{ label: "책임", weight: 70, aliases: ["책임", "책임연구원"] },
|
||||||
|
{ label: "대리", weight: 80, aliases: ["대리"] },
|
||||||
|
{ label: "선임", weight: 80, aliases: ["선임", "선임연구원"] },
|
||||||
|
{ label: "연구원", weight: 90, aliases: ["연구원"] },
|
||||||
|
{ label: "사원", weight: 90, aliases: ["사원"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const UNKNOWN_RANK_WEIGHT = 999;
|
||||||
|
|
||||||
|
function normalizeRankText(value: unknown) {
|
||||||
|
return typeof value === "string" ? value.trim().replace(/\s+/g, "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrgRankDefinition(rank: unknown) {
|
||||||
|
const normalizedRank = normalizeRankText(rank);
|
||||||
|
if (!normalizedRank) return undefined;
|
||||||
|
|
||||||
|
return ORG_RANK_DEFINITIONS.find((definition) =>
|
||||||
|
definition.aliases.some(
|
||||||
|
(alias) => normalizeRankText(alias) === normalizedRank,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrgRankDisplayName(rank: unknown) {
|
||||||
|
return (
|
||||||
|
getOrgRankDefinition(rank)?.label ??
|
||||||
|
(typeof rank === "string" ? rank.trim() : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrgRankWeight(rank: unknown) {
|
||||||
|
return getOrgRankDefinition(rank)?.weight ?? UNKNOWN_RANK_WEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareOrgRanks(a: unknown, b: unknown) {
|
||||||
|
return getOrgRankWeight(a) - getOrgRankWeight(b);
|
||||||
|
}
|
||||||
@@ -99,12 +99,52 @@ describe("org chart layout", () => {
|
|||||||
const rootNode = layout.nodes.find((item) => item.node.id === "root");
|
const rootNode = layout.nodes.find((item) => item.node.id === "root");
|
||||||
|
|
||||||
expect(rootNode).toBeDefined();
|
expect(rootNode).toBeDefined();
|
||||||
expect(rootNode?.width).toBeGreaterThan(340);
|
expect(rootNode?.width).toBeGreaterThan(240);
|
||||||
expect(rootNode?.height).toBeLessThan(42 + 24 + 10 * 24);
|
expect(rootNode?.height).toBeLessThan(42 + 24 + 10 * 24);
|
||||||
expect(layout.width).toBeGreaterThan((rootNode?.width ?? 0) + 72 * 2 - 1);
|
expect(layout.width).toBeGreaterThan((rootNode?.width ?? 0) + 72 * 2 - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps modest member groups in one column until another column improves the rendered ratio", () => {
|
it("sizes member cards from an eight-character baseline and expands for long display names", () => {
|
||||||
|
const shortMembers = Array.from({ length: 6 }, (_, index) => ({
|
||||||
|
...member(`short-${index + 1}`),
|
||||||
|
name: `홍길${index + 1}`,
|
||||||
|
grade: "책임",
|
||||||
|
}));
|
||||||
|
const longMembers = shortMembers.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
id: `long-${index + 1}`,
|
||||||
|
name: `매우긴사용자이름${index + 1}`,
|
||||||
|
}));
|
||||||
|
const shortLayout = layoutForest(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...orgNode("short"),
|
||||||
|
members: shortMembers,
|
||||||
|
totalCount: shortMembers.length,
|
||||||
|
totalMemberIds: new Set(shortMembers.map((item) => item.id)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const longLayout = layoutForest(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...orgNode("long"),
|
||||||
|
members: longMembers,
|
||||||
|
totalCount: longMembers.length,
|
||||||
|
totalMemberIds: new Set(longMembers.map((item) => item.id)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const shortNode = shortLayout.nodes.find((item) => item.node.id === "short");
|
||||||
|
const longNode = longLayout.nodes.find((item) => item.node.id === "long");
|
||||||
|
|
||||||
|
expect(shortNode?.width).toBeLessThan(320);
|
||||||
|
expect(longNode?.width).toBeGreaterThan(shortNode?.width ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses compact member columns when another column improves the rendered ratio", () => {
|
||||||
const tenMembers = Array.from({ length: 10 }, (_, index) =>
|
const tenMembers = Array.from({ length: 10 }, (_, index) =>
|
||||||
member(`member-${index + 1}`),
|
member(`member-${index + 1}`),
|
||||||
);
|
);
|
||||||
@@ -134,15 +174,44 @@ describe("org chart layout", () => {
|
|||||||
const sixNode = sixLayout.nodes.find((item) => item.node.id === "six");
|
const sixNode = sixLayout.nodes.find((item) => item.node.id === "six");
|
||||||
const tenNode = tenLayout.nodes.find((item) => item.node.id === "ten");
|
const tenNode = tenLayout.nodes.find((item) => item.node.id === "ten");
|
||||||
|
|
||||||
expect(sixNode?.width).toBe(340);
|
expect(sixNode?.width).toBeGreaterThan(240);
|
||||||
expect(tenNode?.width).toBeGreaterThan(sixNode?.width ?? 0);
|
expect(tenNode?.width).toBe(sixNode?.width);
|
||||||
|
expect(sixNode?.height).toBeLessThan(42 + 24 + 6 * 24);
|
||||||
expect(tenNode?.height).toBeLessThan(42 + 24 + 10 * 24);
|
expect(tenNode?.height).toBeLessThan(42 + 24 + 10 * 24);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("chooses member columns from the rendered node aspect ratio instead of fixed five-member buckets", () => {
|
it("chooses member columns from the rendered node aspect ratio instead of fixed five-member buckets", () => {
|
||||||
expect(getMemberGridMetrics(6)).toEqual({ columnCount: 1, rowCount: 6 });
|
expect(getMemberGridMetrics(6)).toEqual({ columnCount: 2, rowCount: 3 });
|
||||||
expect(getMemberGridMetrics(10)).toEqual({ columnCount: 2, rowCount: 5 });
|
expect(getMemberGridMetrics(10)).toEqual({ columnCount: 2, rowCount: 5 });
|
||||||
expect(getMemberGridMetrics(25)).toEqual({ columnCount: 2, rowCount: 13 });
|
expect(getMemberGridMetrics(25)).toEqual({ columnCount: 4, rowCount: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts members by normalized rank inside the same organization", () => {
|
||||||
|
const members = [
|
||||||
|
{ ...member("staff"), name: "사원", grade: "사원" },
|
||||||
|
{ ...member("principal"), name: "수석", grade: "수석연구원" },
|
||||||
|
{ ...member("director"), name: "전무", grade: "전무이사" },
|
||||||
|
{ ...member("lead"), name: "책임", grade: "책임" },
|
||||||
|
];
|
||||||
|
const layout = layoutForest(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...orgNode("root"),
|
||||||
|
members,
|
||||||
|
totalCount: members.length,
|
||||||
|
totalMemberIds: new Set(members.map((item) => item.id)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const rootNode = layout.nodes.find((item) => item.node.id === "root");
|
||||||
|
|
||||||
|
expect(rootNode?.members.map((item) => item.id)).toEqual([
|
||||||
|
"director",
|
||||||
|
"principal",
|
||||||
|
"lead",
|
||||||
|
"staff",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
|
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
|
||||||
@@ -163,7 +232,7 @@ describe("org chart layout", () => {
|
|||||||
expect(uniqueChildRows.size).toBeGreaterThan(1);
|
expect(uniqueChildRows.size).toBeGreaterThan(1);
|
||||||
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
|
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
|
||||||
expect(aspectRatio).toBeLessThanOrEqual(1.61);
|
expect(aspectRatio).toBeLessThanOrEqual(1.61);
|
||||||
expect(childSpan).toBeLessThan(13 * 340 + 12 * 80);
|
expect(childSpan).toBeLessThan(13 * 240 + 12 * 80);
|
||||||
expect(
|
expect(
|
||||||
layout.edges.filter((edge) => edge.key.startsWith("root->")),
|
layout.edges.filter((edge) => edge.key.startsWith("root->")),
|
||||||
).toHaveLength(13);
|
).toHaveLength(13);
|
||||||
@@ -184,7 +253,7 @@ describe("org chart layout", () => {
|
|||||||
);
|
);
|
||||||
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
|
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
|
||||||
|
|
||||||
expect(new Set(childNodes.map((node) => node.x)).size).toBe(2);
|
expect(new Set(childNodes.map((node) => node.x)).size).toBe(4);
|
||||||
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
|
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
|
||||||
expect(aspectRatio).toBeLessThanOrEqual(1.61);
|
expect(aspectRatio).toBeLessThanOrEqual(1.61);
|
||||||
});
|
});
|
||||||
@@ -497,4 +566,44 @@ describe("org chart layout", () => {
|
|||||||
"gpdtdc-user",
|
"gpdtdc-user",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not fall back to a visible parent for hidden leaf memberships", () => {
|
||||||
|
const gpdtdc = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc");
|
||||||
|
const internalLeaf = {
|
||||||
|
...tenantNode(
|
||||||
|
"internal-leaf",
|
||||||
|
"USER_GROUP",
|
||||||
|
"내부 구성 조직",
|
||||||
|
"internal-leaf",
|
||||||
|
),
|
||||||
|
parentId: "gpdtdc",
|
||||||
|
};
|
||||||
|
|
||||||
|
const usersMap = buildUsersMap(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...member("hidden-only-user"),
|
||||||
|
companyCode: undefined,
|
||||||
|
tenantSlug: "gpdtdc",
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{
|
||||||
|
tenantSlug: "internal-leaf",
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
joinedTenants: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[gpdtdc],
|
||||||
|
{
|
||||||
|
activeOnly: true,
|
||||||
|
membershipRootNodes: [{ ...gpdtdc, children: [internalLeaf] }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(usersMap.get("gpdtdc")).toBeUndefined();
|
||||||
|
expect(usersMap.get("internal-leaf")).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
orderHanmacFamilyTenants,
|
orderHanmacFamilyTenants,
|
||||||
} from "../hanmacFamilyOrder";
|
} from "../hanmacFamilyOrder";
|
||||||
import { filterTenantsByVisibility, getOrgUnitType } from "../tenantVisibility";
|
import { filterTenantsByVisibility, getOrgUnitType } from "../tenantVisibility";
|
||||||
|
import { getOrgRankWeight } from "../rankPriority";
|
||||||
import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay";
|
import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay";
|
||||||
|
|
||||||
export type OrgNode = {
|
export type OrgNode = {
|
||||||
@@ -84,12 +85,16 @@ export type OrgChartLayoutOptions = {
|
|||||||
|
|
||||||
export type SemanticZoomMode = "overview" | "compact" | "detail";
|
export type SemanticZoomMode = "overview" | "compact" | "detail";
|
||||||
|
|
||||||
const NODE_WIDTH = 340;
|
const NODE_WIDTH = 168;
|
||||||
const MEMBER_COLUMN_WIDTH = 300;
|
const MEMBER_COLUMN_WIDTH = 128;
|
||||||
const MEMBER_COLUMN_GAP = 8;
|
const MEMBER_COLUMN_GAP = 8;
|
||||||
const HEADER_HEIGHT = 42;
|
const HEADER_HEIGHT = 42;
|
||||||
const MEMBER_ROW_HEIGHT = 24;
|
const MEMBER_ROW_HEIGHT = 24;
|
||||||
const NODE_PADDING_Y = 12;
|
const NODE_PADDING_Y = 12;
|
||||||
|
const MEMBER_CARD_BASE_CHAR_COUNT = 8;
|
||||||
|
const MEMBER_CARD_CHAR_WIDTH = 12;
|
||||||
|
const MEMBER_CARD_TEXT_PADDING_X = 28;
|
||||||
|
const MEMBER_COLUMN_MAX_WIDTH = 280;
|
||||||
const MEMBER_GRID_TARGET_ASPECT_RATIO = 2;
|
const MEMBER_GRID_TARGET_ASPECT_RATIO = 2;
|
||||||
const MAX_MEMBER_COLUMN_COUNT = 8;
|
const MAX_MEMBER_COLUMN_COUNT = 8;
|
||||||
const ROOT_GAP_X = 120;
|
const ROOT_GAP_X = 120;
|
||||||
@@ -132,32 +137,59 @@ type DepthColorNodeData = {
|
|||||||
|
|
||||||
type DepthColorNode = ReactFlowNode<DepthColorNodeData, "org-depth-color">;
|
type DepthColorNode = ReactFlowNode<DepthColorNodeData, "org-depth-color">;
|
||||||
|
|
||||||
const ROLE_ORDER = [
|
|
||||||
"사장",
|
|
||||||
"부사장",
|
|
||||||
"전무",
|
|
||||||
"상무",
|
|
||||||
"이사",
|
|
||||||
"수석",
|
|
||||||
"책임",
|
|
||||||
"선임",
|
|
||||||
"주임",
|
|
||||||
"사원",
|
|
||||||
];
|
|
||||||
|
|
||||||
function getRankWeight(
|
function getRankWeight(
|
||||||
user: UserSummary,
|
user: UserSummary,
|
||||||
tenant?: { id: string; slug: string },
|
tenant?: { id: string; slug: string },
|
||||||
) {
|
) {
|
||||||
const profile = getUserOrgProfile(user, tenant);
|
const profile = getUserOrgProfile(user, tenant);
|
||||||
const role = profile.grade || "";
|
return getOrgRankWeight(profile.grade);
|
||||||
const order = ROLE_ORDER.indexOf(role);
|
|
||||||
const isLeader =
|
|
||||||
profile.position.endsWith("장") || profile.jobTitle.endsWith("장");
|
|
||||||
return (isLeader ? -100 : 0) + (order === -1 ? 99 : order);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMemberGridMetrics(memberCount: number) {
|
function getComplementaryColor(hexColor: string) {
|
||||||
|
const normalized = hexColor.trim().replace("#", "");
|
||||||
|
if (!/^[\da-f]{6}$/i.test(normalized)) return "#f59e0b";
|
||||||
|
|
||||||
|
const red = 255 - Number.parseInt(normalized.slice(0, 2), 16);
|
||||||
|
const green = 255 - Number.parseInt(normalized.slice(2, 4), 16);
|
||||||
|
const blue = 255 - Number.parseInt(normalized.slice(4, 6), 16);
|
||||||
|
return `#${[red, green, blue]
|
||||||
|
.map((value) => value.toString(16).padStart(2, "0"))
|
||||||
|
.join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayTextWidthUnit(value: string) {
|
||||||
|
return Array.from(value).reduce((sum, char) => {
|
||||||
|
if (char === " ") return sum + 0.4;
|
||||||
|
if (/^[\x00-\x7f]$/.test(char)) return sum + 0.55;
|
||||||
|
return sum + 1;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberColumnWidth(
|
||||||
|
members: UserSummary[],
|
||||||
|
tenant?: { id: string; slug: string },
|
||||||
|
) {
|
||||||
|
const baselineWidth =
|
||||||
|
MEMBER_CARD_TEXT_PADDING_X +
|
||||||
|
MEMBER_CARD_BASE_CHAR_COUNT * MEMBER_CARD_CHAR_WIDTH;
|
||||||
|
const maxDisplayWidth = members.reduce((maxWidth, member) => {
|
||||||
|
const displayName = getOrgChartUserDisplayName(member, tenant);
|
||||||
|
const estimatedWidth =
|
||||||
|
MEMBER_CARD_TEXT_PADDING_X +
|
||||||
|
getDisplayTextWidthUnit(displayName) * MEMBER_CARD_CHAR_WIDTH;
|
||||||
|
return Math.max(maxWidth, estimatedWidth);
|
||||||
|
}, baselineWidth);
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
MEMBER_COLUMN_MAX_WIDTH,
|
||||||
|
Math.max(MEMBER_COLUMN_WIDTH, Math.ceil(maxDisplayWidth)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemberGridMetrics(
|
||||||
|
memberCount: number,
|
||||||
|
memberColumnWidth = MEMBER_COLUMN_WIDTH,
|
||||||
|
) {
|
||||||
if (memberCount <= 0) return { columnCount: 1, rowCount: 1 };
|
if (memberCount <= 0) return { columnCount: 1, rowCount: 1 };
|
||||||
|
|
||||||
const maxColumnCount = Math.min(
|
const maxColumnCount = Math.min(
|
||||||
@@ -174,8 +206,8 @@ export function getMemberGridMetrics(memberCount: number) {
|
|||||||
? NODE_WIDTH
|
? NODE_WIDTH
|
||||||
: Math.max(
|
: Math.max(
|
||||||
NODE_WIDTH,
|
NODE_WIDTH,
|
||||||
NODE_PADDING_Y * 2 +
|
NODE_PADDING_Y * 2 +
|
||||||
columnCount * MEMBER_COLUMN_WIDTH +
|
columnCount * memberColumnWidth +
|
||||||
(columnCount - 1) * MEMBER_COLUMN_GAP,
|
(columnCount - 1) * MEMBER_COLUMN_GAP,
|
||||||
);
|
);
|
||||||
const height =
|
const height =
|
||||||
@@ -197,31 +229,33 @@ export function getMemberGridMetrics(memberCount: number) {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMemberColumnCount(memberCount: number) {
|
function getMemberColumnCount(memberCount: number, memberColumnWidth?: number) {
|
||||||
return getMemberGridMetrics(memberCount).columnCount;
|
return getMemberGridMetrics(memberCount, memberColumnWidth).columnCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMemberRowCount(memberCount: number) {
|
function getMemberRowCount(memberCount: number, memberColumnWidth?: number) {
|
||||||
return getMemberGridMetrics(memberCount).rowCount;
|
return getMemberGridMetrics(memberCount, memberColumnWidth).rowCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeWidth(members: UserSummary[]) {
|
function getNodeWidth(members: UserSummary[], memberColumnWidth: number) {
|
||||||
const columnCount = getMemberColumnCount(members.length);
|
const columnCount = getMemberColumnCount(members.length, memberColumnWidth);
|
||||||
if (columnCount <= 1) return NODE_WIDTH;
|
if (columnCount <= 1) {
|
||||||
|
return Math.max(NODE_WIDTH, NODE_PADDING_Y * 2 + memberColumnWidth);
|
||||||
|
}
|
||||||
|
|
||||||
return Math.max(
|
return Math.max(
|
||||||
NODE_WIDTH,
|
NODE_WIDTH,
|
||||||
NODE_PADDING_Y * 2 +
|
NODE_PADDING_Y * 2 +
|
||||||
columnCount * MEMBER_COLUMN_WIDTH +
|
columnCount * memberColumnWidth +
|
||||||
(columnCount - 1) * MEMBER_COLUMN_GAP,
|
(columnCount - 1) * MEMBER_COLUMN_GAP,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeHeight(members: UserSummary[]) {
|
function getNodeHeight(members: UserSummary[], memberColumnWidth: number) {
|
||||||
return (
|
return (
|
||||||
HEADER_HEIGHT +
|
HEADER_HEIGHT +
|
||||||
NODE_PADDING_Y * 2 +
|
NODE_PADDING_Y * 2 +
|
||||||
getMemberRowCount(members.length) * MEMBER_ROW_HEIGHT
|
getMemberRowCount(members.length, memberColumnWidth) * MEMBER_ROW_HEIGHT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,12 +765,13 @@ function layoutTree(
|
|||||||
collapsedIds: Set<string>,
|
collapsedIds: Set<string>,
|
||||||
options: OrgChartLayoutOptions,
|
options: OrgChartLayoutOptions,
|
||||||
): ChartLayout {
|
): ChartLayout {
|
||||||
|
const tenantIdentity = { id: node.id, slug: node.companyCode ?? "" };
|
||||||
const members = [...node.members].sort(
|
const members = [...node.members].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
getRankWeight(a, { id: node.id, slug: node.companyCode ?? "" }) -
|
getRankWeight(a, tenantIdentity) - getRankWeight(b, tenantIdentity),
|
||||||
getRankWeight(b, { id: node.id, slug: node.companyCode ?? "" }),
|
|
||||||
);
|
);
|
||||||
const nodeHeight = getNodeHeight(members);
|
const memberColumnWidth = getMemberColumnWidth(members, tenantIdentity);
|
||||||
|
const nodeHeight = getNodeHeight(members, memberColumnWidth);
|
||||||
const collapsed = collapsedIds.has(node.id);
|
const collapsed = collapsedIds.has(node.id);
|
||||||
const childLayouts = collapsed
|
const childLayouts = collapsed
|
||||||
? []
|
? []
|
||||||
@@ -758,7 +793,7 @@ function layoutTree(
|
|||||||
const childCenters = childRoots.map(
|
const childCenters = childRoots.map(
|
||||||
(childRoot) => childRoot.x + childRoot.width / 2,
|
(childRoot) => childRoot.x + childRoot.width / 2,
|
||||||
);
|
);
|
||||||
const nodeWidth = getNodeWidth(members);
|
const nodeWidth = getNodeWidth(members, memberColumnWidth);
|
||||||
const firstChildCenter =
|
const firstChildCenter =
|
||||||
childCenters.length > 0 ? Math.min(...childCenters) : nodeWidth / 2;
|
childCenters.length > 0 ? Math.min(...childCenters) : nodeWidth / 2;
|
||||||
const lastChildCenter =
|
const lastChildCenter =
|
||||||
@@ -1116,6 +1151,31 @@ export function filterSystemGlobalTenants(
|
|||||||
return filterTenantsByVisibility(filtered, "public");
|
return filterTenantsByVisibility(filtered, "public");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterOrgChartMembershipTenants(tenants: TenantSummary[]) {
|
||||||
|
const excludedIds = new Set(
|
||||||
|
tenants.filter(isSystemGlobalTenant).map((tenant) => tenant.id),
|
||||||
|
);
|
||||||
|
let changed = true;
|
||||||
|
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
if (
|
||||||
|
tenant.parentId &&
|
||||||
|
excludedIds.has(tenant.parentId) &&
|
||||||
|
!excludedIds.has(tenant.id)
|
||||||
|
) {
|
||||||
|
excludedIds.add(tenant.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenants.filter(
|
||||||
|
(tenant) => !excludedIds.has(tenant.id) && isOrgFrontTenantType(tenant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type TenantIndexes = {
|
type TenantIndexes = {
|
||||||
byId: Map<string, TenantNode>;
|
byId: Map<string, TenantNode>;
|
||||||
bySlug: Map<string, TenantNode>;
|
bySlug: Map<string, TenantNode>;
|
||||||
@@ -1209,9 +1269,12 @@ function getLeafMembershipSlugs(
|
|||||||
export function buildUsersMap(
|
export function buildUsersMap(
|
||||||
users: UserSummary[],
|
users: UserSummary[],
|
||||||
rootNodes: TenantNode[],
|
rootNodes: TenantNode[],
|
||||||
options: { activeOnly: boolean },
|
options: { activeOnly: boolean; membershipRootNodes?: TenantNode[] },
|
||||||
) {
|
) {
|
||||||
const tenantIndexes = buildTenantIndexes(rootNodes);
|
const visibleTenantIndexes = buildTenantIndexes(rootNodes);
|
||||||
|
const membershipTenantIndexes = buildTenantIndexes(
|
||||||
|
options.membershipRootNodes ?? rootNodes,
|
||||||
|
);
|
||||||
const map = new Map<string, UserSummary[]>();
|
const map = new Map<string, UserSummary[]>();
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
@@ -1230,7 +1293,7 @@ export function buildUsersMap(
|
|||||||
name: primarySlug,
|
name: primarySlug,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
addTenantSlugCandidate(slugs, tenantIndexes, primarySlug);
|
addTenantSlugCandidate(slugs, membershipTenantIndexes, primarySlug);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
legacyCompanySlug &&
|
legacyCompanySlug &&
|
||||||
@@ -1241,31 +1304,40 @@ export function buildUsersMap(
|
|||||||
name: legacyCompanySlug,
|
name: legacyCompanySlug,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
addTenantSlugCandidate(slugs, tenantIndexes, legacyCompanySlug);
|
addTenantSlugCandidate(slugs, membershipTenantIndexes, legacyCompanySlug);
|
||||||
}
|
}
|
||||||
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
|
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
|
||||||
addTenantSlugCandidate(slugs, tenantIndexes, user.tenant.slug);
|
addTenantSlugCandidate(slugs, membershipTenantIndexes, user.tenant.slug);
|
||||||
}
|
}
|
||||||
for (const joinedTenant of user.joinedTenants || []) {
|
for (const joinedTenant of user.joinedTenants || []) {
|
||||||
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
|
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
|
||||||
addTenantSlugCandidate(slugs, tenantIndexes, joinedTenant.slug);
|
addTenantSlugCandidate(
|
||||||
|
slugs,
|
||||||
|
membershipTenantIndexes,
|
||||||
|
joinedTenant.slug,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const appointment of getUserOrgAppointmentRefs(user)) {
|
for (const appointment of getUserOrgAppointmentRefs(user)) {
|
||||||
if (appointment.tenantSlug) {
|
if (appointment.tenantSlug) {
|
||||||
addTenantSlugCandidate(slugs, tenantIndexes, appointment.tenantSlug);
|
addTenantSlugCandidate(
|
||||||
|
slugs,
|
||||||
|
membershipTenantIndexes,
|
||||||
|
appointment.tenantSlug,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantById = appointment.tenantId
|
const tenantById = appointment.tenantId
|
||||||
? tenantIndexes.byId.get(appointment.tenantId)
|
? membershipTenantIndexes.byId.get(appointment.tenantId)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (tenantById) {
|
if (tenantById) {
|
||||||
addTenantSlugCandidate(slugs, tenantIndexes, tenantById.slug);
|
addTenantSlugCandidate(slugs, membershipTenantIndexes, tenantById.slug);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const slug of getLeafMembershipSlugs(slugs, tenantIndexes)) {
|
for (const slug of getLeafMembershipSlugs(slugs, membershipTenantIndexes)) {
|
||||||
|
if (!visibleTenantIndexes.bySlug.has(slug)) continue;
|
||||||
const list = map.get(slug) || [];
|
const list = map.get(slug) || [];
|
||||||
if (!list.some((existing) => existing.id === user.id)) list.push(user);
|
if (!list.some((existing) => existing.id === user.id)) list.push(user);
|
||||||
map.set(slug, list);
|
map.set(slug, list);
|
||||||
@@ -1340,11 +1412,15 @@ export function TenantOrgChartPage() {
|
|||||||
const rootNodes = buildTenantFullTree(
|
const rootNodes = buildTenantFullTree(
|
||||||
filterSystemGlobalTenants(publicQuery.data.tenants, "public"),
|
filterSystemGlobalTenants(publicQuery.data.tenants, "public"),
|
||||||
).subTree;
|
).subTree;
|
||||||
|
const membershipRootNodes = buildTenantFullTree(
|
||||||
|
filterOrgChartMembershipTenants(publicQuery.data.tenants),
|
||||||
|
).subTree;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rootNodes,
|
rootNodes,
|
||||||
usersMap: buildUsersMap(publicQuery.data.users, rootNodes, {
|
usersMap: buildUsersMap(publicQuery.data.users, rootNodes, {
|
||||||
activeOnly: false,
|
activeOnly: false,
|
||||||
|
membershipRootNodes,
|
||||||
}),
|
}),
|
||||||
sharedWith: publicQuery.data.sharedWith,
|
sharedWith: publicQuery.data.sharedWith,
|
||||||
};
|
};
|
||||||
@@ -1361,11 +1437,15 @@ export function TenantOrgChartPage() {
|
|||||||
const rootNodes = buildTenantFullTree(
|
const rootNodes = buildTenantFullTree(
|
||||||
filterSystemGlobalTenants(tenantsQuery.data.items, visibilityMode),
|
filterSystemGlobalTenants(tenantsQuery.data.items, visibilityMode),
|
||||||
).subTree;
|
).subTree;
|
||||||
|
const membershipRootNodes = buildTenantFullTree(
|
||||||
|
filterOrgChartMembershipTenants(tenantsQuery.data.items),
|
||||||
|
).subTree;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rootNodes,
|
rootNodes,
|
||||||
usersMap: buildUsersMap(usersQuery.data.items, rootNodes, {
|
usersMap: buildUsersMap(usersQuery.data.items, rootNodes, {
|
||||||
activeOnly: true,
|
activeOnly: true,
|
||||||
|
membershipRootNodes,
|
||||||
}),
|
}),
|
||||||
sharedWith: "",
|
sharedWith: "",
|
||||||
};
|
};
|
||||||
@@ -1827,11 +1907,15 @@ function SvgOrgNode({
|
|||||||
visualNode: VisualNode;
|
visualNode: VisualNode;
|
||||||
}) {
|
}) {
|
||||||
const { node, x, y, width, height, members, collapsed } = visualNode;
|
const { node, x, y, width, height, members, collapsed } = visualNode;
|
||||||
|
const tenantIdentity = { id: node.id, slug: node.companyCode ?? "" };
|
||||||
const headerFill = getOrgNodeHeaderFill(
|
const headerFill = getOrgNodeHeaderFill(
|
||||||
node.companyColorDepth ?? node.level,
|
node.companyColorDepth ?? node.level,
|
||||||
node.companyColorKey,
|
node.companyColorKey,
|
||||||
);
|
);
|
||||||
const memberColumnCount = getMemberColumnCount(members.length);
|
const memberColumnCount = getMemberColumnCount(
|
||||||
|
members.length,
|
||||||
|
getMemberColumnWidth(members, tenantIdentity),
|
||||||
|
);
|
||||||
const showMemberRows = semanticZoomMode === "detail";
|
const showMemberRows = semanticZoomMode === "detail";
|
||||||
const showNodeName =
|
const showNodeName =
|
||||||
semanticZoomMode === "detail" ||
|
semanticZoomMode === "detail" ||
|
||||||
@@ -1899,23 +1983,31 @@ function SvgOrgNode({
|
|||||||
gridTemplateColumns: `repeat(${memberColumnCount}, minmax(0, 1fr))`,
|
gridTemplateColumns: `repeat(${memberColumnCount}, minmax(0, 1fr))`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{members.map((member) => (
|
{members.map((member) => {
|
||||||
<div
|
const profile = getUserOrgProfile(member, tenantIdentity);
|
||||||
className="flex h-5 items-center overflow-hidden rounded border border-[#e5e7eb] bg-white text-xs font-extrabold text-[#334155]"
|
const isHighlighted = profile.isHighlighted === true;
|
||||||
key={member.id}
|
|
||||||
>
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-full w-1 shrink-0"
|
className="flex h-5 items-center overflow-hidden rounded border border-[#e5e7eb] bg-white text-xs font-extrabold text-[#334155]"
|
||||||
style={{ backgroundColor: headerFill }}
|
data-highlighted={isHighlighted ? "true" : "false"}
|
||||||
/>
|
data-testid={`orgchart-member-${member.id}`}
|
||||||
<div className="min-w-0 truncate px-2">
|
key={member.id}
|
||||||
{getOrgChartUserDisplayName(member, {
|
>
|
||||||
id: node.id,
|
<div
|
||||||
slug: node.companyCode ?? "",
|
className="h-full w-1 shrink-0"
|
||||||
})}
|
style={{
|
||||||
|
backgroundColor: isHighlighted
|
||||||
|
? getComplementaryColor(headerFill)
|
||||||
|
: "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 truncate px-2">
|
||||||
|
{getOrgChartUserDisplayName(member, tenantIdentity)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 items-center px-4 text-xs font-bold text-[#94a3b8]">
|
<div className="flex flex-1 items-center px-4 text-xs font-bold text-[#94a3b8]">
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { UserSummary } from "../../lib/adminApi";
|
import type { UserSummary } from "../../lib/adminApi";
|
||||||
import { getOrgChartUserDisplayName } from "./userDisplay";
|
import {
|
||||||
|
getOrgChartUserDisplayName,
|
||||||
|
getUserOrgProfile,
|
||||||
|
} from "./userDisplay";
|
||||||
|
|
||||||
function user(overrides: Partial<UserSummary>): UserSummary {
|
function user(overrides: Partial<UserSummary>): UserSummary {
|
||||||
return {
|
return {
|
||||||
@@ -16,15 +19,16 @@ function user(overrides: Partial<UserSummary>): UserSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("getOrgChartUserDisplayName", () => {
|
describe("getOrgChartUserDisplayName", () => {
|
||||||
it("renders name with grade and optional position", () => {
|
it("renders name with grade and without job details", () => {
|
||||||
expect(
|
expect(
|
||||||
getOrgChartUserDisplayName(
|
getOrgChartUserDisplayName(
|
||||||
user({
|
user({
|
||||||
grade: "수석",
|
grade: "수석",
|
||||||
position: "팀장",
|
position: "팀장",
|
||||||
|
jobTitle: "구조",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toBe("홍길동 수석(팀장)");
|
).toBe("홍길동 수석");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses tenant appointment grade before the user grade", () => {
|
it("uses tenant appointment grade before the user grade", () => {
|
||||||
@@ -44,6 +48,123 @@ describe("getOrgChartUserDisplayName", () => {
|
|||||||
}),
|
}),
|
||||||
{ id: "tenant-1", slug: "hanmac" },
|
{ id: "tenant-1", slug: "hanmac" },
|
||||||
),
|
),
|
||||||
).toBe("홍길동 수석(센터장)");
|
).toBe("홍길동 수석");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses short grade aliases in the display name", () => {
|
||||||
|
expect(
|
||||||
|
getOrgChartUserDisplayName(
|
||||||
|
user({
|
||||||
|
grade: "책임연구원",
|
||||||
|
jobTitle: "구조",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("홍길동 책임");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add leader text to the display name", () => {
|
||||||
|
expect(
|
||||||
|
getOrgChartUserDisplayName(
|
||||||
|
user({
|
||||||
|
grade: "책임",
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{
|
||||||
|
tenantSlug: "hanmac",
|
||||||
|
isOwner: true,
|
||||||
|
grade: "수석",
|
||||||
|
position: "센터장",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ id: "tenant-1", slug: "hanmac" },
|
||||||
|
),
|
||||||
|
).toBe("홍길동 수석");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leak an owner appointment flag into another tenant display", () => {
|
||||||
|
expect(
|
||||||
|
getOrgChartUserDisplayName(
|
||||||
|
user({
|
||||||
|
grade: "책임",
|
||||||
|
position: "팀원",
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{
|
||||||
|
tenantSlug: "hanmac",
|
||||||
|
isOwner: true,
|
||||||
|
grade: "수석",
|
||||||
|
position: "센터장",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ id: "tenant-2", slug: "baron" },
|
||||||
|
),
|
||||||
|
).toBe("홍길동 책임");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUserOrgProfile", () => {
|
||||||
|
it("marks owner, manager, and admin flags as highlighted profiles", () => {
|
||||||
|
expect(
|
||||||
|
getUserOrgProfile(
|
||||||
|
user({
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{
|
||||||
|
tenantSlug: "owner",
|
||||||
|
isOwner: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenantSlug: "manager",
|
||||||
|
isManager: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenantSlug: "admin",
|
||||||
|
isAdmin: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ id: "tenant-1", slug: "owner" },
|
||||||
|
).isHighlighted,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
getUserOrgProfile(
|
||||||
|
user({
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [{ tenantSlug: "leader", isLeader: true }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ id: "tenant-2", slug: "leader" },
|
||||||
|
).isHighlighted,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
getUserOrgProfile(
|
||||||
|
user({
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{ tenantSlug: "manager", isManager: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ id: "tenant-2", slug: "manager" },
|
||||||
|
).isHighlighted,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
getUserOrgProfile(
|
||||||
|
user({
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [{ tenantSlug: "admin", isAdmin: true }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ id: "tenant-3", slug: "admin" },
|
||||||
|
).isHighlighted,
|
||||||
|
).toBe(true);
|
||||||
|
expect(getUserOrgProfile(user({ grade: "책임" })).isHighlighted).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||||
|
import { getOrgRankDisplayName } from "./rankPriority";
|
||||||
|
|
||||||
type UserAppointment = {
|
type UserAppointment = {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
isManager?: boolean;
|
||||||
|
isOwner?: boolean;
|
||||||
grade?: string;
|
grade?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
@@ -26,6 +30,9 @@ function getUserAppointments(user: UserSummary): UserAppointment[] {
|
|||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
tenantId: normalizeText(item.tenantId),
|
tenantId: normalizeText(item.tenantId),
|
||||||
tenantSlug: normalizeText(item.tenantSlug),
|
tenantSlug: normalizeText(item.tenantSlug),
|
||||||
|
isAdmin: item.isAdmin === true,
|
||||||
|
isManager: item.isManager === true,
|
||||||
|
isOwner: item.isOwner === true,
|
||||||
grade: normalizeText(item.grade),
|
grade: normalizeText(item.grade),
|
||||||
jobTitle: normalizeText(item.jobTitle),
|
jobTitle: normalizeText(item.jobTitle),
|
||||||
position: normalizeText(item.position),
|
position: normalizeText(item.position),
|
||||||
@@ -47,6 +54,10 @@ export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
grade: appointment?.grade || normalizeText(user.grade),
|
grade: appointment?.grade || normalizeText(user.grade),
|
||||||
|
isHighlighted:
|
||||||
|
appointment?.isAdmin === true ||
|
||||||
|
appointment?.isManager === true ||
|
||||||
|
appointment?.isOwner === true,
|
||||||
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
|
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
|
||||||
position: appointment?.position || normalizeText(user.position),
|
position: appointment?.position || normalizeText(user.position),
|
||||||
};
|
};
|
||||||
@@ -56,12 +67,11 @@ export function getOrgChartUserDisplayName(
|
|||||||
user: UserSummary,
|
user: UserSummary,
|
||||||
tenant?: TenantIdentity,
|
tenant?: TenantIdentity,
|
||||||
) {
|
) {
|
||||||
const { grade, jobTitle, position } = getUserOrgProfile(user, tenant);
|
const { grade } = getUserOrgProfile(user, tenant);
|
||||||
const baseName = user.name.trim();
|
const baseName = user.name.trim();
|
||||||
const detail = position || jobTitle;
|
const displayGrade = getOrgRankDisplayName(grade);
|
||||||
|
|
||||||
if (grade && detail) return `${baseName} ${grade}(${detail})`;
|
let displayName = baseName;
|
||||||
if (grade) return `${baseName} ${grade}`;
|
if (displayGrade) displayName = `${baseName} ${displayGrade}`;
|
||||||
if (detail) return `${baseName}(${detail})`;
|
return displayName;
|
||||||
return baseName;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -976,7 +976,12 @@ function selectionKey(selection: OrgPickerSelection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMember(member: OrgContextMember) {
|
function formatMember(member: OrgContextMember) {
|
||||||
return [member.name, member.position, member.jobTitle]
|
return [
|
||||||
|
member.name,
|
||||||
|
member.position,
|
||||||
|
member.jobTitle,
|
||||||
|
member.isLeader || member.isOwner ? "조직장" : "",
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ describe("org-context chart SDK", () => {
|
|||||||
expect(
|
expect(
|
||||||
chartContainer.querySelectorAll("[data-baron-org-node]"),
|
chartContainer.querySelectorAll("[data-baron-org-node]"),
|
||||||
).toHaveLength(3);
|
).toHaveLength(3);
|
||||||
|
expect(chartContainer.textContent).toContain("Leader · 팀장 · 조직장");
|
||||||
const platformCheckbox = pickerContainer.querySelector<HTMLInputElement>(
|
const platformCheckbox = pickerContainer.querySelector<HTMLInputElement>(
|
||||||
'input[value="tenant:team-platform"]',
|
'input[value="tenant:team-platform"]',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ 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", /[4-9]\d{2,}/);
|
await expect(rootNode).toHaveAttribute("width", /3\d{2}/);
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ test("org chart balances large member groups with automatic member columns", asy
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("org chart displays user names with grade and optional position", async ({
|
test("org chart displays user names with short grade aliases and no job details", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
||||||
@@ -247,7 +247,7 @@ test("org chart displays user names with grade and optional position", async ({
|
|||||||
{
|
{
|
||||||
...user("u-eng", "Engineering User", "engineering"),
|
...user("u-eng", "Engineering User", "engineering"),
|
||||||
jobTitle: "Platform Engineer",
|
jobTitle: "Platform Engineer",
|
||||||
grade: "책임",
|
grade: "책임연구원",
|
||||||
position: "팀장",
|
position: "팀장",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -258,7 +258,77 @@ test("org chart displays user names with grade and optional position", async ({
|
|||||||
await page.goto("/chart?token=display-name");
|
await page.goto("/chart?token=display-name");
|
||||||
|
|
||||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||||
await expect(svg.getByText("Engineering User 책임(팀장)")).toBeVisible();
|
await expect(svg.getByText("Engineering User 책임")).toBeVisible();
|
||||||
|
await expect(svg.getByText(/팀장|Platform Engineer/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("org chart only highlights flagged member cards", async ({ page }) => {
|
||||||
|
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
sharedWith: "Playwright",
|
||||||
|
tenants: [
|
||||||
|
tenant("group", "HMAC Group", "hmac"),
|
||||||
|
tenant("engineering", "Engineering", "engineering", "group"),
|
||||||
|
],
|
||||||
|
users: [
|
||||||
|
user("u-normal", "Normal User", "engineering"),
|
||||||
|
{
|
||||||
|
...user("u-owner", "Owner User", "engineering"),
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{
|
||||||
|
tenantSlug: "engineering",
|
||||||
|
isOwner: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...user("u-admin", "Admin User", "engineering"),
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{
|
||||||
|
tenantSlug: "engineering",
|
||||||
|
isAdmin: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...user("u-manager", "Manager User", "engineering"),
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{
|
||||||
|
tenantSlug: "engineering",
|
||||||
|
isManager: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/chart?token=highlighted-members");
|
||||||
|
|
||||||
|
const engineeringNode = page.locator(
|
||||||
|
'[data-testid="orgchart-node-engineering"]',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
engineeringNode.locator('[data-testid="orgchart-member-u-normal"]'),
|
||||||
|
).toHaveAttribute("data-highlighted", "false");
|
||||||
|
await expect(
|
||||||
|
engineeringNode.locator('[data-testid="orgchart-member-u-owner"]'),
|
||||||
|
).toHaveAttribute("data-highlighted", "true");
|
||||||
|
await expect(
|
||||||
|
engineeringNode.locator('[data-testid="orgchart-member-u-admin"]'),
|
||||||
|
).toHaveAttribute("data-highlighted", "true");
|
||||||
|
await expect(
|
||||||
|
engineeringNode.locator('[data-testid="orgchart-member-u-manager"]'),
|
||||||
|
).toHaveAttribute("data-highlighted", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("org chart places multi-tenant users only on leaf memberships without duplicate rendering", async ({
|
test("org chart places multi-tenant users only on leaf memberships without duplicate rendering", async ({
|
||||||
@@ -406,6 +476,14 @@ test("org chart places GPDTDC representative users on visible leaf appointments"
|
|||||||
"ORGANIZATION",
|
"ORGANIZATION",
|
||||||
{ visibility: "internal" },
|
{ visibility: "internal" },
|
||||||
),
|
),
|
||||||
|
tenant(
|
||||||
|
"internal-leaf",
|
||||||
|
"내부 구성 하위 조직",
|
||||||
|
"internal-leaf",
|
||||||
|
"gpdtdc",
|
||||||
|
"USER_GROUP",
|
||||||
|
{ visibility: "internal" },
|
||||||
|
),
|
||||||
],
|
],
|
||||||
users: [
|
users: [
|
||||||
{
|
{
|
||||||
@@ -427,6 +505,19 @@ test("org chart places GPDTDC representative users on visible leaf appointments"
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
...user("u-hidden-only", "Hidden Only User", "gpdtdc"),
|
||||||
|
tenantSlug: "gpdtdc",
|
||||||
|
companyCode: undefined,
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{
|
||||||
|
tenantSlug: "internal-leaf",
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -438,6 +529,8 @@ test("org chart places GPDTDC representative users on visible leaf appointments"
|
|||||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||||
await expect(svg).toBeVisible();
|
await expect(svg).toBeVisible();
|
||||||
await expect(svg.getByText("내부 구성 조직")).toHaveCount(0);
|
await expect(svg.getByText("내부 구성 조직")).toHaveCount(0);
|
||||||
|
await expect(svg.getByText("내부 구성 하위 조직")).toHaveCount(0);
|
||||||
|
await expect(svg.getByText(/Hidden Only User/)).toHaveCount(0);
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.locator('[data-testid="orgchart-node-gpdtdc"]')
|
.locator('[data-testid="orgchart-node-gpdtdc"]')
|
||||||
@@ -446,7 +539,7 @@ test("org chart places GPDTDC representative users on visible leaf appointments"
|
|||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.locator('[data-testid="orgchart-node-tdc-leaf"]')
|
.locator('[data-testid="orgchart-node-tdc-leaf"]')
|
||||||
.getByText("GPDTDC Leaf User 책임(팀장)"),
|
.getByText("GPDTDC Leaf User 책임"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
35
test/gateway_userfront_residue_policy_test.sh
Normal file
35
test/gateway_userfront_residue_policy_test.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
file="$1"
|
||||||
|
pattern="$2"
|
||||||
|
if ! grep -Fq "$pattern" "$file"; then
|
||||||
|
echo "missing pattern in $file: $pattern" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_not_contains() {
|
||||||
|
file="$1"
|
||||||
|
pattern="$2"
|
||||||
|
if grep -Fq "$pattern" "$file"; then
|
||||||
|
echo "forbidden pattern in $file: $pattern" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_gateway="deploy/templates/gateway/nginx.conf"
|
||||||
|
|
||||||
|
if [ ! -f "$deploy_gateway" ]; then
|
||||||
|
echo "missing expected file: $deploy_gateway" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
assert_contains "$deploy_gateway" "root /usr/share/nginx/html;"
|
||||||
|
assert_contains "$deploy_gateway" 'try_files $uri $uri/ /index.html;'
|
||||||
|
assert_not_contains "$deploy_gateway" "baron_userfront"
|
||||||
|
assert_not_contains "$deploy_gateway" "userfront_upstream"
|
||||||
|
assert_not_contains "$deploy_gateway" "proxy_pass http://baron_userfront"
|
||||||
|
|
||||||
|
echo "gateway userfront residue policy checks passed"
|
||||||
@@ -22,6 +22,7 @@ assert_not_contains() {
|
|||||||
staging_pull=".gitea/workflows/staging_code_pull.yml"
|
staging_pull=".gitea/workflows/staging_code_pull.yml"
|
||||||
pull_compose="docker/staging_pull_compose.template.yaml"
|
pull_compose="docker/staging_pull_compose.template.yaml"
|
||||||
deploy_compose="deploy/templates/docker-compose.yaml"
|
deploy_compose="deploy/templates/docker-compose.yaml"
|
||||||
|
deploy_gateway="deploy/templates/gateway/nginx.conf"
|
||||||
userfront_dockerfile="userfront/Dockerfile"
|
userfront_dockerfile="userfront/Dockerfile"
|
||||||
devfront_vite="devfront/vite.config.ts"
|
devfront_vite="devfront/vite.config.ts"
|
||||||
orgfront_vite="orgfront/vite.config.ts"
|
orgfront_vite="orgfront/vite.config.ts"
|
||||||
@@ -34,6 +35,7 @@ for file in \
|
|||||||
"$staging_pull" \
|
"$staging_pull" \
|
||||||
"$pull_compose" \
|
"$pull_compose" \
|
||||||
"$deploy_compose" \
|
"$deploy_compose" \
|
||||||
|
"$deploy_gateway" \
|
||||||
"$userfront_dockerfile" \
|
"$userfront_dockerfile" \
|
||||||
"$adminfront_vite" \
|
"$adminfront_vite" \
|
||||||
"$devfront_vite" \
|
"$devfront_vite" \
|
||||||
@@ -82,6 +84,11 @@ assert_contains "$pull_compose" 'APP_ENV=${APP_ENV:-stage}'
|
|||||||
|
|
||||||
assert_contains "$deploy_compose" "sh ./scripts/runtime-mode.sh"
|
assert_contains "$deploy_compose" "sh ./scripts/runtime-mode.sh"
|
||||||
assert_not_contains "$deploy_compose" "command: npm run dev"
|
assert_not_contains "$deploy_compose" "command: npm run dev"
|
||||||
|
assert_contains "$deploy_gateway" "root /usr/share/nginx/html;"
|
||||||
|
assert_contains "$deploy_gateway" 'try_files $uri $uri/ /index.html;'
|
||||||
|
assert_not_contains "$deploy_gateway" "baron_userfront"
|
||||||
|
assert_not_contains "$deploy_gateway" "userfront_upstream"
|
||||||
|
assert_not_contains "$deploy_gateway" "proxy_pass http://baron_userfront"
|
||||||
|
|
||||||
for app in adminfront devfront orgfront; do
|
for app in adminfront devfront orgfront; do
|
||||||
assert_contains ".gitea/workflows/build_RC.yml" "Build and push $app RC image"
|
assert_contains ".gitea/workflows/build_RC.yml" "Build and push $app RC image"
|
||||||
|
|||||||
@@ -169,6 +169,25 @@ async function makeWindowCloseNavigateToRoot(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
||||||
|
if (await button.count()) {
|
||||||
|
await button.first().evaluate((node) => {
|
||||||
|
(node as HTMLElement).click();
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const placeholder = page.locator('flt-semantics-placeholder').first();
|
||||||
|
if (await placeholder.count()) {
|
||||||
|
await placeholder.evaluate((node) => {
|
||||||
|
(node as HTMLElement).click();
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('UserFront WASM auth routing', () => {
|
test.describe('UserFront WASM auth routing', () => {
|
||||||
test('비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다', async ({ page }) => {
|
test('비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다', async ({ page }) => {
|
||||||
await mockUserfrontApis(page, { sessionStatus: 401 });
|
await mockUserfrontApis(page, { sessionStatus: 401 });
|
||||||
@@ -274,7 +293,7 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
expect(clientFailures).toEqual([]);
|
expect(clientFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('verifyOnly 승인 완료 버튼은 SMS 링크에서 user/me 조회나 루트 이동을 만들지 않는다', async ({
|
test('verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
let userMeCalls = 0;
|
let userMeCalls = 0;
|
||||||
@@ -298,20 +317,48 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
|
|
||||||
const viewport = page.viewportSize();
|
await enableFlutterAccessibility(page);
|
||||||
if (!viewport) throw new Error('viewport is required');
|
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
|
||||||
await page.locator('flt-glass-pane').click({
|
|
||||||
position: {
|
|
||||||
x: Math.floor(viewport.width / 2),
|
|
||||||
y: Math.floor(viewport.height * 0.66),
|
|
||||||
},
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||||
|
expect(
|
||||||
|
clientFailures.filter(
|
||||||
|
(failure) => !failure.includes('401 (Unauthorized)'),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyOnly 원격 승인 완료는 로그인 창 이동 모달 CTA를 표시한다', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let verifyCalls = 0;
|
||||||
|
const clientFailures = collectClientFailures(page);
|
||||||
|
|
||||||
|
await mockUserfrontApis(page, {
|
||||||
|
sessionStatus: 401,
|
||||||
|
captureVerify: () => {
|
||||||
|
verifyCalls += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await makeWindowCloseNavigateToRoot(page);
|
||||||
|
|
||||||
|
await page.goto('/ko/l/AB123456');
|
||||||
|
|
||||||
|
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
|
await enableFlutterAccessibility(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText('요청하신 로그인이 완료되었습니다'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '창 닫기' })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: '로그인 창으로 이동하기' }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||||
expect(clientFailures).toEqual([]);
|
expect(clientFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -428,18 +475,13 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
await expect(popup).toHaveURL(/\/ko\/verify-complete$/);
|
await expect(popup).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
|
|
||||||
const viewport = popup.viewportSize();
|
|
||||||
if (!viewport) throw new Error('viewport is required');
|
|
||||||
if (!popup.isClosed()) {
|
if (!popup.isClosed()) {
|
||||||
|
await enableFlutterAccessibility(popup);
|
||||||
const closePromise = popup.waitForEvent('close').catch(() => undefined);
|
const closePromise = popup.waitForEvent('close').catch(() => undefined);
|
||||||
try {
|
try {
|
||||||
await popup.locator('flt-glass-pane').click({
|
await popup
|
||||||
position: {
|
.getByRole('button', { name: '로그인 창으로 이동하기' })
|
||||||
x: Math.floor(viewport.width / 2),
|
.click();
|
||||||
y: Math.floor(viewport.height * 0.66),
|
|
||||||
},
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!popup.isClosed()) {
|
if (!popup.isClosed()) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -453,7 +495,7 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
expect(clientFailures).toEqual([]);
|
expect(clientFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('verifyOnly 승인 완료 버튼은 이메일 magic link에서도 user/me 조회나 루트 이동을 만들지 않는다', async ({
|
test('verifyOnly 승인 완료 버튼은 이메일 magic link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
let userMeCalls = 0;
|
let userMeCalls = 0;
|
||||||
@@ -485,24 +527,15 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
verifyOnly: true,
|
verifyOnly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewport = page.viewportSize();
|
await enableFlutterAccessibility(page);
|
||||||
if (!viewport) throw new Error('viewport is required');
|
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
|
||||||
await page.locator('flt-glass-pane').click({
|
|
||||||
position: {
|
|
||||||
x: Math.floor(viewport.width / 2),
|
|
||||||
y: Math.floor(viewport.height * 0.66),
|
|
||||||
},
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||||
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
|
|
||||||
expect(clientFailures).toEqual([]);
|
expect(clientFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('verifyOnly 승인 완료 버튼은 이메일 code link에서도 user/me 조회나 루트 이동을 만들지 않는다', async ({
|
test('verifyOnly 승인 완료 버튼은 이메일 code link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
let userMeCalls = 0;
|
let userMeCalls = 0;
|
||||||
@@ -538,20 +571,11 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
verifyOnly: true,
|
verifyOnly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewport = page.viewportSize();
|
await enableFlutterAccessibility(page);
|
||||||
if (!viewport) throw new Error('viewport is required');
|
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
|
||||||
await page.locator('flt-glass-pane').click({
|
|
||||||
position: {
|
|
||||||
x: Math.floor(viewport.width / 2),
|
|
||||||
y: Math.floor(viewport.height * 0.66),
|
|
||||||
},
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||||
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
|
|
||||||
expect(clientFailures).toEqual([]);
|
expect(clientFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ body = "We could not find an account for that information.\\\\\\\\\\\\\\\\nPleas
|
|||||||
[msg.userfront.login.verification]
|
[msg.userfront.login.verification]
|
||||||
approved = "Approved. Complete sign-in in the original window."
|
approved = "Approved. Complete sign-in in the original window."
|
||||||
approved_local = "Approved. This device is already signed in, and the remote window will be signed in shortly."
|
approved_local = "Approved. This device is already signed in, and the remote window will be signed in shortly."
|
||||||
approved_remote = "Approved. Please return to the original browser or PC screen."
|
approved_remote = "Your requested sign-in is complete."
|
||||||
pending_remote = "Checking the sign-in approval request. Please wait."
|
pending_remote = "Checking the sign-in approval request. Please wait."
|
||||||
success = "Sign-in approval completed."
|
success = "Sign-in approval completed."
|
||||||
|
|
||||||
@@ -582,6 +582,7 @@ title = "Account not found"
|
|||||||
|
|
||||||
[ui.userfront.login.verification]
|
[ui.userfront.login.verification]
|
||||||
action_label = "Done"
|
action_label = "Done"
|
||||||
|
action_label_remote = "Go to sign-in window"
|
||||||
action_label_close = "Close Window"
|
action_label_close = "Close Window"
|
||||||
page_title = "Sign-in approval"
|
page_title = "Sign-in approval"
|
||||||
title = "Approval complete"
|
title = "Approval complete"
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ body = "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주
|
|||||||
[msg.userfront.login.verification]
|
[msg.userfront.login.verification]
|
||||||
approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."
|
approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."
|
||||||
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
|
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
|
||||||
approved_remote = "승인되었습니다. 요청하신 브라우저 또는 PC 화면으로 돌아가 주세요."
|
approved_remote = "요청하신 로그인이 완료되었습니다"
|
||||||
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
||||||
success = "로그인 승인에 성공했습니다."
|
success = "로그인 승인에 성공했습니다."
|
||||||
|
|
||||||
@@ -804,6 +804,7 @@ title = "미등록 회원"
|
|||||||
|
|
||||||
[ui.userfront.login.verification]
|
[ui.userfront.login.verification]
|
||||||
action_label = "확인"
|
action_label = "확인"
|
||||||
|
action_label_remote = "로그인 창으로 이동하기"
|
||||||
page_title = "로그인 승인"
|
page_title = "로그인 승인"
|
||||||
title = "승인 완료"
|
title = "승인 완료"
|
||||||
action_label_close = "창 닫기"
|
action_label_close = "창 닫기"
|
||||||
|
|||||||
@@ -776,6 +776,7 @@ title = ""
|
|||||||
|
|
||||||
[ui.userfront.login.verification]
|
[ui.userfront.login.verification]
|
||||||
action_label = ""
|
action_label = ""
|
||||||
|
action_label_remote = ""
|
||||||
action_label_close = ""
|
action_label_close = ""
|
||||||
page_title = ""
|
page_title = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|||||||
@@ -146,8 +146,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_markVerificationApproved(
|
_markVerificationApproved(
|
||||||
tr('msg.userfront.login.verification.approved_remote'),
|
tr('msg.userfront.login.verification.approved_remote'),
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
title: tr('ui.userfront.login.verification.title_remote'),
|
||||||
actionLabel: tr('ui.userfront.login.verification.action_label_close'),
|
actionLabel: tr(
|
||||||
onAction: _closeVerificationWindowIfPossible,
|
'ui.userfront.login.verification.action_label_remote',
|
||||||
|
),
|
||||||
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -859,63 +861,91 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _moveToSigninOrCloseVerificationWindow() {
|
||||||
|
if (webWindow.hasOpener()) {
|
||||||
|
webWindow.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.go(buildLocalizedSigninPath(Uri.base));
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildVerificationResultView() {
|
Widget _buildVerificationResultView() {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
child: Column(
|
child: ConstrainedBox(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
children: [
|
child: Material(
|
||||||
const Icon(
|
color: colorScheme.surface,
|
||||||
Icons.check_circle_outline,
|
elevation: 12,
|
||||||
color: Colors.green,
|
shadowColor: Colors.black.withValues(alpha: 0.18),
|
||||||
size: 72,
|
borderRadius: BorderRadius.circular(24),
|
||||||
),
|
child: Padding(
|
||||||
const SizedBox(height: 16),
|
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
||||||
Text(
|
child: Column(
|
||||||
_verificationTitle,
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: const TextStyle(
|
children: [
|
||||||
fontSize: 22,
|
Icon(
|
||||||
fontWeight: FontWeight.bold,
|
Icons.check_circle_outline,
|
||||||
color: Colors.green,
|
color: colorScheme.primary,
|
||||||
|
size: 72,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_verificationTitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_verificationMessage.isEmpty
|
||||||
|
? tr('msg.userfront.login.verification.success')
|
||||||
|
: _verificationMessage,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (_onVerificationAction != null) {
|
||||||
|
_runVerificationExitAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_verificationOnly) {
|
||||||
|
_closeVerificationWindowIfPossible();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final hasLocalSession =
|
||||||
|
(AuthTokenStore.getToken()?.isNotEmpty ?? false) ||
|
||||||
|
AuthTokenStore.usesCookie();
|
||||||
|
final target = hasLocalSession
|
||||||
|
? buildLocalizedHomePath(Uri.base)
|
||||||
|
: buildLocalizedSigninPath(Uri.base);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_verificationOnly = false;
|
||||||
|
_verificationApproved = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
context.go(target);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
_verificationActionLabel,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
Text(
|
|
||||||
_verificationMessage.isEmpty
|
|
||||||
? tr('msg.userfront.login.verification.success')
|
|
||||||
: _verificationMessage,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(color: Colors.black54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (_onVerificationAction != null) {
|
|
||||||
_runVerificationExitAction();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_verificationOnly) {
|
|
||||||
_closeVerificationWindowIfPossible();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final hasLocalSession =
|
|
||||||
(AuthTokenStore.getToken()?.isNotEmpty ?? false) ||
|
|
||||||
AuthTokenStore.usesCookie();
|
|
||||||
final target = hasLocalSession
|
|
||||||
? buildLocalizedHomePath(Uri.base)
|
|
||||||
: buildLocalizedSigninPath(Uri.base);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_verificationOnly = false;
|
|
||||||
_verificationApproved = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
context.go(target);
|
|
||||||
},
|
|
||||||
child: Text(_verificationActionLabel),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -956,12 +986,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
title: Text(_verificationPageTitle),
|
title: Text(_verificationPageTitle),
|
||||||
leading: _verificationApproved && _onVerificationAction != null
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: _runVerificationExitAction,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
actions: const [ThemeToggleButton(compact: true)],
|
actions: const [ThemeToggleButton(compact: true)],
|
||||||
),
|
),
|
||||||
body: _verificationApproved
|
body: _verificationApproved
|
||||||
@@ -999,9 +1023,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
remoteApprovedMessage,
|
remoteApprovedMessage,
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
title: tr('ui.userfront.login.verification.title_remote'),
|
||||||
actionLabel: tr(
|
actionLabel: tr(
|
||||||
'ui.userfront.login.verification.action_label_close',
|
'ui.userfront.login.verification.action_label_remote',
|
||||||
),
|
),
|
||||||
onAction: _closeVerificationWindowIfPossible,
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1035,9 +1059,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
remoteApprovedMessage,
|
remoteApprovedMessage,
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
title: tr('ui.userfront.login.verification.title_remote'),
|
||||||
actionLabel: tr(
|
actionLabel: tr(
|
||||||
'ui.userfront.login.verification.action_label_close',
|
'ui.userfront.login.verification.action_label_remote',
|
||||||
),
|
),
|
||||||
onAction: _closeVerificationWindowIfPossible,
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1092,9 +1116,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
remoteApprovedMessage,
|
remoteApprovedMessage,
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
title: tr('ui.userfront.login.verification.title_remote'),
|
||||||
actionLabel: tr(
|
actionLabel: tr(
|
||||||
'ui.userfront.login.verification.action_label_close',
|
'ui.userfront.login.verification.action_label_remote',
|
||||||
),
|
),
|
||||||
onAction: _closeVerificationWindowIfPossible,
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1113,9 +1137,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
remoteApprovedMessage,
|
remoteApprovedMessage,
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
title: tr('ui.userfront.login.verification.title_remote'),
|
||||||
actionLabel: tr(
|
actionLabel: tr(
|
||||||
'ui.userfront.login.verification.action_label_close',
|
'ui.userfront.login.verification.action_label_remote',
|
||||||
),
|
),
|
||||||
onAction: _closeVerificationWindowIfPossible,
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1127,8 +1151,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_markVerificationApproved(
|
_markVerificationApproved(
|
||||||
remoteApprovedMessage,
|
remoteApprovedMessage,
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
title: tr('ui.userfront.login.verification.title_remote'),
|
||||||
actionLabel: tr('ui.userfront.login.verification.action_label_close'),
|
actionLabel: tr(
|
||||||
onAction: _closeVerificationWindowIfPossible,
|
'ui.userfront.login.verification.action_label_remote',
|
||||||
|
),
|
||||||
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1146,9 +1172,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
remoteApprovedMessage,
|
remoteApprovedMessage,
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
title: tr('ui.userfront.login.verification.title_remote'),
|
||||||
actionLabel: tr(
|
actionLabel: tr(
|
||||||
'ui.userfront.login.verification.action_label_close',
|
'ui.userfront.login.verification.action_label_remote',
|
||||||
),
|
),
|
||||||
onAction: _closeVerificationWindowIfPossible,
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1194,9 +1220,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
remoteApprovedMessage,
|
remoteApprovedMessage,
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
title: tr('ui.userfront.login.verification.title_remote'),
|
||||||
actionLabel: tr(
|
actionLabel: tr(
|
||||||
'ui.userfront.login.verification.action_label_close',
|
'ui.userfront.login.verification.action_label_remote',
|
||||||
),
|
),
|
||||||
onAction: _closeVerificationWindowIfPossible,
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1215,9 +1241,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
remoteApprovedMessage,
|
remoteApprovedMessage,
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
title: tr('ui.userfront.login.verification.title_remote'),
|
||||||
actionLabel: tr(
|
actionLabel: tr(
|
||||||
'ui.userfront.login.verification.action_label_close',
|
'ui.userfront.login.verification.action_label_remote',
|
||||||
),
|
),
|
||||||
onAction: _closeVerificationWindowIfPossible,
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1229,8 +1255,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_markVerificationApproved(
|
_markVerificationApproved(
|
||||||
remoteApprovedMessage,
|
remoteApprovedMessage,
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
title: tr('ui.userfront.login.verification.title_remote'),
|
||||||
actionLabel: tr('ui.userfront.login.verification.action_label_close'),
|
actionLabel: tr(
|
||||||
onAction: _closeVerificationWindowIfPossible,
|
'ui.userfront.login.verification.action_label_remote',
|
||||||
|
),
|
||||||
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1246,9 +1274,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
remoteApprovedMessage,
|
remoteApprovedMessage,
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
title: tr('ui.userfront.login.verification.title_remote'),
|
||||||
actionLabel: tr(
|
actionLabel: tr(
|
||||||
'ui.userfront.login.verification.action_label_close',
|
'ui.userfront.login.verification.action_label_remote',
|
||||||
),
|
),
|
||||||
onAction: _closeVerificationWindowIfPossible,
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user