1
0
forked from baron/baron-sso

front류 개발모드에서는 세션 갱신 끄기

This commit is contained in:
2026-05-20 11:48:31 +09:00
parent 0031784c07
commit 0155ee4ee7
17 changed files with 287 additions and 23 deletions

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { t } from "../../lib/i18n";
const LOCALE_STORAGE_KEY = "locale";
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
@@ -34,6 +35,10 @@ function LanguageSelector() {
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
setLocale(next);
if (import.meta.env.MODE === "development") {
window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
return;
}
window.location.reload();
};

View File

@@ -43,6 +43,9 @@ import {
import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
const staticNavItems: ShellSidebarNavItem[] = [
{
labelKey: "ui.admin.nav.overview",
@@ -127,6 +130,7 @@ function AppLayout() {
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null);
const isDevelopmentRuntime = import.meta.env.MODE === "development";
const isDevRoleOverrideEnabled =
import.meta.env.MODE === "development" ||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
@@ -139,8 +143,9 @@ function AppLayout() {
: null;
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileOpen, setIsProfileOpen] = useState(false);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
const [, setDevelopmentRenderRevision] = useState(0);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
);
const {
data: profile,
@@ -290,6 +295,27 @@ function AppLayout() {
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
if (!isDevelopmentRuntime) {
return;
}
const rerenderDevelopmentShell = () => {
setDevelopmentRenderRevision((value) => value + 1);
};
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
window.addEventListener(DEV_ROLE_CHANGED_EVENT, rerenderDevelopmentShell);
return () => {
window.removeEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
window.removeEventListener(
DEV_ROLE_CHANGED_EVENT,
rerenderDevelopmentShell,
);
};
}, [isDevelopmentRuntime]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
@@ -355,6 +381,10 @@ function AppLayout() {
]);
useEffect(() => {
if (isDevelopmentRuntime) {
return;
}
const maybeKeepSessionAlive = async () => {
const now = Date.now();
if (
@@ -397,6 +427,7 @@ function AppLayout() {
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isDevelopmentRuntime,
isSessionExpiryEnabled,
]);

View File

@@ -3,6 +3,8 @@ import type { FC } from "react";
import { useEffect, useState } from "react";
import { t } from "../../lib/i18n";
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
const RoleSwitcher: FC = () => {
const [currentRole, setCurrentRole] = useState<string>("");
const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false);
@@ -31,13 +33,13 @@ const RoleSwitcher: FC = () => {
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
setCurrentRole(role);
setIsOverrideEnabled(true);
window.location.reload();
window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
};
const clearRoleOverride = () => {
window.localStorage.removeItem("X-Mock-Role-Enabled");
setIsOverrideEnabled(false);
window.location.reload();
window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
};
if (import.meta.env.MODE === "production") return null;

View File

@@ -84,8 +84,11 @@ function LoginPage() {
variant="ghost"
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
onClick={() => {
window.location.href =
window.location.origin + window.location.pathname;
void auth.signinRedirect({
state: {
returnTo,
},
});
}}
>

View File

@@ -194,7 +194,14 @@ export function UserGroupDetailPage() {
"Not found"}
</p>
</div>
<Button variant="outline" onClick={() => window.location.reload()}>
<Button
variant="outline"
onClick={() => {
void queryClient.invalidateQueries({
queryKey: ["user-group-detail", id],
});
}}
>
{t("ui.common.retry", "다시 시도")}
</Button>
<div className="pt-4 border-t">

View File

@@ -1,5 +1,8 @@
import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
import {
shouldSuppressDevelopmentSessionRedirect,
} from "../../../common/core/session";
import { userManager } from "./auth";
let isRedirectingToLogin = false;
@@ -42,6 +45,17 @@ apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
if (
shouldSuppressDevelopmentSessionRedirect({
appMode: import.meta.env.MODE,
})
) {
console.warn(
"[apiClient] 401 Unauthorized detected, but development session redirects are disabled.",
);
return Promise.reject(error);
}
console.warn(
"[apiClient] 401 Unauthorized detected. Clearing session state.",
);

View File

@@ -1,10 +1,23 @@
import { describe, expect, it } from "vitest";
import {
readSessionExpiryEnabled,
SESSION_RENEW_THRESHOLD_MS,
shouldAttemptSlidingSessionRenew,
shouldSuppressDevelopmentSessionRedirect,
shouldAttemptUnlimitedSessionRenew,
writeSessionExpiryEnabled,
} from "./sessionSliding";
function memoryStorage(initialValue: string | null = null) {
let value = initialValue;
return {
getItem: (_key: string) => value,
setItem: (_key: string, nextValue: string) => {
value = nextValue;
},
};
}
describe("shouldAttemptSlidingSessionRenew", () => {
const nowMs = 1_700_000_000_000;
@@ -124,3 +137,43 @@ describe("shouldAttemptUnlimitedSessionRenew", () => {
).toBe(false);
});
});
describe("session expiry development preference", () => {
it("defaults session expiry management off in development when no preference is stored", () => {
expect(
readSessionExpiryEnabled({
defaultEnabled: false,
storage: memoryStorage(null),
}),
).toBe(false);
});
it("keeps explicit stored preference over the development default", () => {
const storage = memoryStorage(null);
writeSessionExpiryEnabled(true, storage);
expect(
readSessionExpiryEnabled({
defaultEnabled: false,
storage,
}),
).toBe(true);
});
it("suppresses login redirects only in development with disabled session expiry management", () => {
expect(
shouldSuppressDevelopmentSessionRedirect({
appMode: "development",
storage: memoryStorage(null),
}),
).toBe(true);
expect(
shouldSuppressDevelopmentSessionRedirect({
appMode: "production",
storage: memoryStorage(null),
}),
).toBe(false);
});
});

View File

@@ -1,6 +1,9 @@
export {
DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS,
DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS,
readSessionExpiryEnabled,
shouldAttemptSlidingSessionRenew,
shouldSuppressDevelopmentSessionRedirect,
shouldAttemptUnlimitedSessionRenew,
writeSessionExpiryEnabled,
} from "../../../common/core/session";

View File

@@ -1,5 +1,9 @@
export const DEFAULT_SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000;
export const DEFAULT_SESSION_RENEW_THROTTLE_MS = 30 * 1000;
export const SESSION_EXPIRY_STORAGE_KEY = "baron_session_expiry_enabled";
type SessionExpiryReadableStorage = Pick<Storage, "getItem">;
type SessionExpiryWritableStorage = Pick<Storage, "setItem">;
export type SessionRenewDecisionParams = {
expiresAtSec?: number | null;
@@ -13,6 +17,51 @@ export type SessionRenewDecisionParams = {
throttleMs?: number;
};
export type SessionExpiryPreferenceParams = {
defaultEnabled?: boolean;
storage?: SessionExpiryReadableStorage | null;
};
export type DevelopmentSessionRedirectParams = {
appMode: string;
defaultEnabled?: boolean;
storage?: SessionExpiryReadableStorage | null;
};
function browserStorage() {
if (typeof window === "undefined") {
return null;
}
return window.localStorage;
}
export function readSessionExpiryEnabled({
defaultEnabled = true,
storage = browserStorage(),
}: SessionExpiryPreferenceParams = {}) {
const stored = storage?.getItem(SESSION_EXPIRY_STORAGE_KEY) ?? null;
return stored === null ? defaultEnabled : stored !== "false";
}
export function writeSessionExpiryEnabled(
isEnabled: boolean,
storage: SessionExpiryWritableStorage | null = browserStorage(),
) {
storage?.setItem(SESSION_EXPIRY_STORAGE_KEY, String(isEnabled));
}
export function shouldSuppressDevelopmentSessionRedirect({
appMode,
defaultEnabled = appMode !== "development",
storage = browserStorage(),
}: DevelopmentSessionRedirectParams) {
return (
appMode === "development" &&
!readSessionExpiryEnabled({ defaultEnabled, storage })
);
}
function hasRenewPreconditions({
isAuthenticated,
isLoading,

View File

@@ -1,3 +1,9 @@
import {
SESSION_EXPIRY_STORAGE_KEY,
readSessionExpiryEnabled,
writeSessionExpiryEnabled,
} from "../core/session";
export type ShellTheme = "light" | "dark";
export type ShellTranslator = (
@@ -20,8 +26,7 @@ type ShellProfileSummaryParams = {
};
export const SHELL_THEME_STORAGE_KEY = "admin_theme";
export const SHELL_SESSION_EXPIRY_STORAGE_KEY =
"baron_session_expiry_enabled";
export const SHELL_SESSION_EXPIRY_STORAGE_KEY = SESSION_EXPIRY_STORAGE_KEY;
export { AppSidebar } from "./AppSidebar";
export type { ShellSidebarNavItem } from "./AppSidebar";
export { shellLayoutClasses } from "./layout";
@@ -39,15 +44,12 @@ export function applyShellTheme(theme: ShellTheme) {
window.localStorage.setItem(SHELL_THEME_STORAGE_KEY, theme);
}
export function readShellSessionExpiryEnabled() {
return window.localStorage.getItem(SHELL_SESSION_EXPIRY_STORAGE_KEY) !== "false";
export function readShellSessionExpiryEnabled(defaultEnabled = true) {
return readSessionExpiryEnabled({ defaultEnabled });
}
export function writeShellSessionExpiryEnabled(isEnabled: boolean) {
window.localStorage.setItem(
SHELL_SESSION_EXPIRY_STORAGE_KEY,
String(isEnabled),
);
writeSessionExpiryEnabled(isEnabled);
}
export function buildShellProfileSummary({

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { t } from "../../lib/i18n";
const LOCALE_STORAGE_KEY = "locale";
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
@@ -34,6 +35,10 @@ function LanguageSelector() {
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
setLocale(next);
if (import.meta.env.MODE === "development") {
window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
return;
}
window.location.reload();
};

View File

@@ -35,6 +35,8 @@ import {
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const navItems: ShellSidebarNavItem[] = [
{
labelKey: "ui.dev.nav.overview",
@@ -113,10 +115,12 @@ function AppLayout() {
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null);
const isDevelopmentRuntime = import.meta.env.MODE === "development";
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
const [, setDevelopmentRenderRevision] = useState(0);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
);
const hasAccessToken = Boolean(auth.user?.access_token);
const { data: profile } = useQuery({
@@ -140,6 +144,22 @@ function AppLayout() {
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
if (!isDevelopmentRuntime) {
return;
}
const rerenderDevelopmentShell = () => {
setDevelopmentRenderRevision((value) => value + 1);
};
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
return () => {
window.removeEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
};
}, [isDevelopmentRuntime]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
@@ -205,6 +225,10 @@ function AppLayout() {
]);
useEffect(() => {
if (isDevelopmentRuntime) {
return;
}
const maybeKeepSessionAlive = async () => {
const now = Date.now();
if (
@@ -247,6 +271,7 @@ function AppLayout() {
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isDevelopmentRuntime,
isSessionExpiryEnabled,
]);

View File

@@ -1,5 +1,8 @@
import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
import {
shouldSuppressDevelopmentSessionRedirect,
} from "../../../common/core/session";
import { userManager } from "./auth";
let isRedirectingToLogin = false;
@@ -42,8 +45,22 @@ apiClient.interceptors.response.use(
message.includes("invalid session") ||
message.includes("token is not active")));
if (!shouldRedirectToLogin) {
return Promise.reject(error);
}
if (
shouldSuppressDevelopmentSessionRedirect({
appMode: import.meta.env.MODE,
})
) {
console.warn(
"[apiClient] Auth failure detected, but development session redirects are disabled.",
);
return Promise.reject(error);
}
if (
shouldRedirectToLogin &&
shouldStartLoginRedirect({
pathname: window.location.pathname,
isRedirecting: isRedirectingToLogin,

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { t } from "../../lib/i18n";
const LOCALE_STORAGE_KEY = "locale";
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
@@ -34,6 +35,10 @@ function LanguageSelector() {
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
setLocale(next);
if (import.meta.env.MODE === "development") {
window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
return;
}
window.location.reload();
};

View File

@@ -32,6 +32,8 @@ import {
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const navItems = [
{
labelKey: "ui.dev.nav.clients",
@@ -97,10 +99,12 @@ function AppLayout() {
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null);
const isDevelopmentRuntime = import.meta.env.MODE === "development";
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
const [, setDevelopmentRenderRevision] = useState(0);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
);
const hasAccessToken = Boolean(auth.user?.access_token);
const { data: profile } = useQuery({
@@ -120,6 +124,22 @@ function AppLayout() {
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
if (!isDevelopmentRuntime) {
return;
}
const rerenderDevelopmentShell = () => {
setDevelopmentRenderRevision((value) => value + 1);
};
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
return () => {
window.removeEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
};
}, [isDevelopmentRuntime]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
@@ -185,6 +205,10 @@ function AppLayout() {
]);
useEffect(() => {
if (isDevelopmentRuntime) {
return;
}
const maybeKeepSessionAlive = async () => {
const now = Date.now();
if (
@@ -227,6 +251,7 @@ function AppLayout() {
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isDevelopmentRuntime,
isSessionExpiryEnabled,
]);

View File

@@ -1,10 +1,11 @@
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import { t } from "../../lib/i18n";
export default function AuthGuard() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const searchParams = new URLSearchParams(location.search);
const shareToken = searchParams.get("token");
const isPlaywrightBypass =
@@ -57,7 +58,7 @@ export default function AuthGuard() {
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
onClick={() => {
auth.removeUser();
window.location.href = "/login";
navigate("/login");
}}
>
{t("ui.common.back_to_login", "로그인으로 돌아가기")}

View File

@@ -1,5 +1,8 @@
import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
import {
shouldSuppressDevelopmentSessionRedirect,
} from "../../../common/core/session";
import { userManager } from "./auth";
let isRedirectingToLogin = false;
@@ -42,8 +45,22 @@ apiClient.interceptors.response.use(
message.includes("invalid session") ||
message.includes("token is not active")));
if (!shouldRedirectToLogin) {
return Promise.reject(error);
}
if (
shouldSuppressDevelopmentSessionRedirect({
appMode: import.meta.env.MODE,
})
) {
console.warn(
"[apiClient] Auth failure detected, but development session redirects are disabled.",
);
return Promise.reject(error);
}
if (
shouldRedirectToLogin &&
shouldStartLoginRedirect({
pathname: window.location.pathname,
isRedirecting: isRedirectingToLogin,