forked from baron/baron-sso
백업/복구로직 변경, 깜빡임 버그 해결
This commit is contained in:
56
adminfront/src/features/auth/AuthGuard.test.tsx
Normal file
56
adminfront/src/features/auth/AuthGuard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user