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)}

View File

@@ -1,7 +1,7 @@
import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
import { userManager } from "./auth";
import { clearAdminAuthSession, userManager } from "./auth";
let isRedirectingToLogin = false;
@@ -50,12 +50,7 @@ apiClient.interceptors.response.use(
"[apiClient] 401 Unauthorized detected. Clearing session state.",
);
// 로컬 스토리지의 세션 키 제거
window.localStorage.removeItem("admin_session");
// oidc-client의 유저 상태도 제거하여 isAuthenticated를 false로 만듭니다.
// 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
await userManager.removeUser();
await clearAdminAuthSession();
if (
shouldStartLoginRedirect({

View File

@@ -21,3 +21,31 @@ export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
export const userManager = new UserManager(
buildCommonUserManagerSettings(oidcConfig),
);
export function clearStoredAdminAuthSession(
storage: Storage = window.localStorage,
) {
const keysToRemove: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (
key &&
(key === "admin_session" ||
key.startsWith("oidc.user:") ||
key.startsWith("oidc.state") ||
key.startsWith("oidc.signin"))
) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
storage.removeItem(key);
}
}
export async function clearAdminAuthSession() {
clearStoredAdminAuthSession();
await userManager.removeUser();
}

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
canManageTenantScopedUsers,
canManageUserInTenantScope,
isSuperAdminRole,
normalizeAdminRole,
ROLE_SUPER_ADMIN,
@@ -32,4 +34,43 @@ describe("admin role helpers", () => {
expect(isSuperAdminRole("admin")).toBe(false);
expect(isSuperAdminRole(undefined)).toBe(false);
});
it("allows delegated tenant admins with manageable tenants to manage scoped users", () => {
const profile = {
id: "admin-user",
role: "user",
manageableTenants: [{ id: "tenant-1", slug: "tenant-a" }],
};
expect(canManageTenantScopedUsers(profile)).toBe(true);
expect(
canManageUserInTenantScope({
profile,
user: { id: "user-1", tenantSlug: "tenant-a" },
}),
).toBe(true);
expect(
canManageUserInTenantScope({
profile,
user: { id: "user-2", tenantSlug: "tenant-b" },
}),
).toBe(false);
});
it("does not treat ordinary tenant membership as delegated user management", () => {
const profile = {
id: "member-user",
role: "user",
tenantSlug: "tenant-a",
manageableTenants: [],
};
expect(canManageTenantScopedUsers(profile)).toBe(false);
expect(
canManageUserInTenantScope({
profile,
user: { id: "user-1", tenantSlug: "tenant-a" },
}),
).toBe(false);
});
});

View File

@@ -3,6 +3,21 @@ export const ROLE_USER = "user";
export type AdminRole = typeof ROLE_SUPER_ADMIN | typeof ROLE_USER;
export type TenantAccessSubject = {
id?: string | null;
role?: string | null;
tenantId?: string | null;
tenantSlug?: string | null;
tenant?: {
id?: string | null;
slug?: string | null;
} | null;
manageableTenants?: Array<{
id?: string | null;
slug?: string | null;
}> | null;
};
export function normalizeAdminRole(role?: string | null): AdminRole {
const normalized = role?.trim().toLowerCase() ?? "";
@@ -30,3 +45,60 @@ export function normalizeAdminRole(role?: string | null): AdminRole {
export function isSuperAdminRole(role?: string | null) {
return normalizeAdminRole(role) === ROLE_SUPER_ADMIN;
}
function normalizeTenantAccessKey(value?: string | null) {
const normalized = value?.trim().toLowerCase();
return normalized ? normalized : null;
}
export function getManageableTenantAccessKeys(
profile?: TenantAccessSubject | null,
) {
const keys = new Set<string>();
for (const tenant of profile?.manageableTenants ?? []) {
const id = normalizeTenantAccessKey(tenant.id);
const slug = normalizeTenantAccessKey(tenant.slug);
if (id) keys.add(id);
if (slug) keys.add(slug);
}
return keys;
}
export function canManageTenantScopedUsers(
profile?: TenantAccessSubject | null,
) {
return (
isSuperAdminRole(profile?.role) ||
getManageableTenantAccessKeys(profile).size > 0
);
}
export function canManageUserInTenantScope({
profile,
user,
}: {
profile?: TenantAccessSubject | null;
user?: TenantAccessSubject | null;
}) {
if (isSuperAdminRole(profile?.role)) {
return true;
}
if (profile?.id && user?.id && profile.id === user.id) {
return true;
}
const manageableKeys = getManageableTenantAccessKeys(profile);
if (manageableKeys.size === 0) {
return false;
}
const userTenantKeys = [
normalizeTenantAccessKey(user?.tenantId),
normalizeTenantAccessKey(user?.tenantSlug),
normalizeTenantAccessKey(user?.tenant?.id),
normalizeTenantAccessKey(user?.tenant?.slug),
];
return userTenantKeys.some((key) => key !== null && manageableKeys.has(key));
}