1
0
forked from baron/baron-sso

백업/복구로직 변경, 깜빡임 버그 해결

This commit is contained in:
2026-06-05 12:26:51 +09:00
parent 4bae1dd00d
commit 29038254dd
43 changed files with 3695 additions and 75 deletions

View File

@@ -0,0 +1,56 @@
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AuthGuard from "./AuthGuard";
const authState = {
activeNavigator: undefined,
error: undefined as Error | undefined,
isAuthenticated: false,
isLoading: false,
removeUser: vi.fn(async () => undefined),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
function renderAuthGuard(initialEntry = "/users") {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/" element={<AuthGuard />}>
<Route path="users" element={<div>Users outlet</div>} />
</Route>
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>
</MemoryRouter>,
);
}
describe("AuthGuard", () => {
beforeEach(() => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = false;
authState.activeNavigator = undefined;
authState.error = undefined;
authState.isAuthenticated = false;
authState.isLoading = false;
authState.removeUser.mockClear();
window.localStorage.clear();
});
it("clears stale auth state and returns to login when OIDC reports an error", async () => {
window.localStorage.setItem("admin_session", "stale-token");
authState.error = new Error("stale session");
renderAuthGuard();
await waitFor(() => {
expect(authState.removeUser).toHaveBeenCalled();
});
await screen.findByText("Login outlet");
expect(window.localStorage.getItem("admin_session")).toBeNull();
});
});

View File

@@ -1,13 +1,31 @@
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import { clearStoredAdminAuthSession } from "../../lib/auth";
export default function AuthGuard() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const handledAuthErrorRef = useRef(false);
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
useEffect(() => {
if (!auth.error || handledAuthErrorRef.current || isTest) {
return;
}
handledAuthErrorRef.current = true;
clearStoredAdminAuthSession();
void Promise.resolve(
auth.removeUser ? auth.removeUser() : undefined,
).finally(() => {
navigate("/login", { replace: true });
});
}, [auth, auth.error, isTest, navigate]);
if (isTest) {
return <Outlet />;
}

View File

@@ -49,7 +49,11 @@ import {
type UserCreateResponse,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
import {
canManageTenantScopedUsers,
isSuperAdminRole,
normalizeAdminRole,
} from "../../lib/roles";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
@@ -154,6 +158,7 @@ function UserCreatePage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canManageUsers = canManageTenantScopedUsers(profile);
const {
register,
@@ -204,8 +209,12 @@ function UserCreatePage() {
// Lock company for non-super_admin
React.useEffect(() => {
if (profileRole !== "super_admin" && profile?.tenantSlug) {
setValue("tenantSlug", profile.tenantSlug);
if (profileRole !== "super_admin") {
const delegatedTenantSlug =
profile?.tenantSlug || profile?.manageableTenants?.[0]?.slug;
if (delegatedTenantSlug) {
setValue("tenantSlug", delegatedTenantSlug);
}
}
}, [profile, profileRole, setValue]);
@@ -524,8 +533,7 @@ function UserCreatePage() {
}
};
// Access Control: Only super_admin can create users
if (profile && profileRole !== "super_admin") {
if (profile && !canManageUsers) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<ShieldAlert size={48} className="text-destructive" />

View File

@@ -75,7 +75,10 @@ import {
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { normalizeAdminRole } from "../../lib/roles";
import {
canManageUserInTenantScope,
normalizeAdminRole,
} from "../../lib/roles";
import { generateSecurePassword } from "../../lib/utils";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
@@ -472,6 +475,7 @@ function UserDetailPage() {
const profileRole = normalizeAdminRole(profile?.role);
const isAdmin = profileRole === "super_admin";
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
const canManageCurrentUser = canManageUserInTenantScope({ profile, user });
const watchedStatus = watch("status");
const [newSubEmail, setNewSubEmail] = React.useState("");
@@ -999,8 +1003,7 @@ function UserDetailPage() {
);
}
// Access Control: Only super_admin or self can view details
if (!isAdmin && !isSelf) {
if (!isAdmin && !isSelf && !canManageCurrentUser) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<ShieldAlert size={48} className="text-destructive" />

View File

@@ -189,4 +189,19 @@ describe("UserListPage search rendering", () => {
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
});
it("keeps rendered form fields identifiable for browser autofill diagnostics", async () => {
const { container } = renderUserListPage();
await screen.findByText("User 0");
const anonymousFields = Array.from(
container.querySelectorAll("input, select, textarea"),
).filter(
(field) =>
!field.getAttribute("id")?.trim() &&
!field.getAttribute("name")?.trim(),
);
expect(anonymousFields).toHaveLength(0);
});
});

View File

@@ -207,6 +207,8 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
<div className="relative w-48">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="user-list-search"
name="user-list-search"
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
@@ -223,6 +225,8 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
</div>
<select
id="user-list-tenant-filter"
name="user-list-tenant-filter"
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)}
@@ -727,6 +731,7 @@ function UserListPage() {
className="flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-muted/50"
>
<input
name={`user-list-column-${field.key}`}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
checked={visibleColumns[field.key] !== false}
@@ -802,6 +807,7 @@ function UserListPage() {
<TableHead className={`${userTableHeadClassName} w-12`}>
<div className="flex h-full items-center justify-center">
<input
name="user-list-select-all"
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={
@@ -980,6 +986,7 @@ function UserListPage() {
>
<TableCell>
<input
name={`user-list-select-${user.id}`}
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
checked={selectedUserIds.includes(user.id)}