diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
index 95d655da..dc8039a1 100644
--- a/apps/admin/src/app/layout.tsx
+++ b/apps/admin/src/app/layout.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import "../styles.css";
+import { EnvironmentSwitcherFab } from "@/components/layout/EnvironmentSwitcherFab";
import { Providers } from "./providers";
export const metadata = {
@@ -11,7 +12,10 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
- {children}
+
+ {children}
+
+
);
diff --git a/apps/admin/src/components/layout/EnvironmentBanner.tsx b/apps/admin/src/components/layout/EnvironmentBanner.tsx
index b4af8735..1b96dabf 100644
--- a/apps/admin/src/components/layout/EnvironmentBanner.tsx
+++ b/apps/admin/src/components/layout/EnvironmentBanner.tsx
@@ -1,29 +1,12 @@
"use client";
import { useEffect, useState } from "react";
-import { getAdminApiServerUrl } from "@/lib/env";
-import { loadAdminApiEnvironment } from "@/lib/utils/localStorage";
-
-type DisplayedEnvironment = "dev" | "prod" | "local";
-
-const environmentStyles: Record = {
- dev: "bg-magic-success-surface text-magic-success",
- prod: "bg-magic-danger-surface text-magic-danger",
- local: "bg-bg-50 text-k-600",
-};
-
-const environmentLabels: Record = {
- dev: "DEV",
- prod: "PROD",
- local: "LOCAL",
-};
-
-const resolveDisplayedEnvironment = (): DisplayedEnvironment => {
- if (import.meta.env.DEV && getAdminApiServerUrl()) {
- return "local";
- }
- return loadAdminApiEnvironment() ?? "prod";
-};
+import {
+ type DisplayedEnvironment,
+ environmentLabels,
+ environmentStyles,
+ resolveDisplayedEnvironment,
+} from "./environmentDisplay";
export function EnvironmentBanner() {
const [environment, setEnvironment] = useState(null);
diff --git a/apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx b/apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx
new file mode 100644
index 00000000..e7362be9
--- /dev/null
+++ b/apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { AdminApiEnvironment } from "@/lib/auth/environment";
+import { switchAdminApiEnvironment } from "@/lib/auth/session";
+import {
+ type DisplayedEnvironment,
+ environmentLabels,
+ environmentStyles,
+ resolveDisplayedEnvironment,
+} from "./environmentDisplay";
+
+const nextEnvironmentOf = (environment: AdminApiEnvironment): AdminApiEnvironment =>
+ environment === "stage" ? "prod" : "stage";
+
+export function EnvironmentSwitcherFab() {
+ const [environment, setEnvironment] = useState(null);
+
+ useEffect(() => {
+ setEnvironment(resolveDisplayedEnvironment());
+
+ const handleStorageChange = () => setEnvironment(resolveDisplayedEnvironment());
+ window.addEventListener("storage", handleStorageChange);
+ return () => window.removeEventListener("storage", handleStorageChange);
+ }, []);
+
+ if (!environment) {
+ return null;
+ }
+
+ const isLocal = environment === "local";
+
+ const handleClick = () => {
+ if (isLocal) {
+ return;
+ }
+
+ const next = nextEnvironmentOf(environment);
+ const confirmed = window.confirm(`환경을 ${environmentLabels[next]}(으)로 전환하면 로그아웃됩니다. 계속할까요?`);
+ if (!confirmed) {
+ return;
+ }
+
+ switchAdminApiEnvironment(next);
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/admin/src/components/layout/environmentDisplay.ts b/apps/admin/src/components/layout/environmentDisplay.ts
new file mode 100644
index 00000000..6577abf2
--- /dev/null
+++ b/apps/admin/src/components/layout/environmentDisplay.ts
@@ -0,0 +1,24 @@
+import type { AdminApiEnvironment } from "@/lib/auth/environment";
+import { getAdminApiServerUrl } from "@/lib/env";
+import { loadAdminApiEnvironment } from "@/lib/utils/localStorage";
+
+export type DisplayedEnvironment = AdminApiEnvironment | "local";
+
+export const environmentStyles: Record = {
+ stage: "bg-magic-success-surface text-magic-success",
+ prod: "bg-magic-danger-surface text-magic-danger",
+ local: "bg-bg-50 text-k-600",
+};
+
+export const environmentLabels: Record = {
+ stage: "STAGE",
+ prod: "PROD",
+ local: "LOCAL",
+};
+
+export const resolveDisplayedEnvironment = (): DisplayedEnvironment => {
+ if (import.meta.env.DEV && getAdminApiServerUrl()) {
+ return "local";
+ }
+ return loadAdminApiEnvironment() ?? "prod";
+};
diff --git a/apps/admin/src/lib/api/auth.test.ts b/apps/admin/src/lib/api/auth.test.ts
index 6a49e4e6..62d82bbd 100644
--- a/apps/admin/src/lib/api/auth.test.ts
+++ b/apps/admin/src/lib/api/auth.test.ts
@@ -1,27 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-const { create, loadAccessToken, post, removeAccessToken, requestInterceptors, saveAdminApiEnvironment } = vi.hoisted(
- () => {
- const hoistedPost = vi.fn();
- return {
- create: vi.fn(() => ({
- post: hoistedPost,
- interceptors: {
- request: {
- use: (fn: (config: Record) => Record) => {
- requestInterceptors.push(fn);
- },
+const { create, loadAccessToken, post, removeAccessToken, requestInterceptors } = vi.hoisted(() => {
+ const hoistedPost = vi.fn();
+ return {
+ create: vi.fn(() => ({
+ post: hoistedPost,
+ interceptors: {
+ request: {
+ use: (fn: (config: Record) => Record) => {
+ requestInterceptors.push(fn);
},
},
- })),
- loadAccessToken: vi.fn(),
- post: hoistedPost,
- removeAccessToken: vi.fn(),
- requestInterceptors: [] as Array<(config: Record) => Record>,
- saveAdminApiEnvironment: vi.fn(),
- };
- },
-);
+ },
+ })),
+ loadAccessToken: vi.fn(),
+ post: hoistedPost,
+ removeAccessToken: vi.fn(),
+ requestInterceptors: [] as Array<(config: Record) => Record>,
+ };
+});
vi.mock("axios", () => ({
default: {
@@ -29,12 +26,6 @@ vi.mock("axios", () => ({
},
}));
-vi.mock("@/lib/auth/environment", () => ({
- getApiBaseUrlForEnvironment: (environment: string) =>
- environment === "dev" ? "https://api.stage.solid-connection.com" : "https://api.solid-connection.com",
- resolveEnvironmentFromEmail: (email: string) => (email === "dev@solid-connection.com" ? "dev" : "prod"),
-}));
-
vi.mock("@/lib/env", () => ({
resolveActiveApiBaseUrl: () => "https://api.solid-connection.com",
}));
@@ -42,7 +33,6 @@ vi.mock("@/lib/env", () => ({
vi.mock("@/lib/utils/localStorage", () => ({
loadAccessToken,
removeAccessToken,
- saveAdminApiEnvironment,
}));
import { adminSignInApi, adminSignOutApi, reissueAccessTokenApi } from "./auth";
@@ -52,7 +42,6 @@ describe("어드민 인증 API", () => {
loadAccessToken.mockReset();
post.mockReset();
removeAccessToken.mockReset();
- saveAdminApiEnvironment.mockReset();
});
it("어드민 refresh token 쿠키를 주고받도록 credentials를 포함한다", () => {
@@ -61,18 +50,16 @@ describe("어드민 인증 API", () => {
});
});
- it("어드민 전용 로그인 API를 이메일 환경에 맞춰 호출한다", async () => {
+ it("어드민 전용 로그인 API를 호출하고 이전 access token을 정리한다", async () => {
post.mockResolvedValue({ data: { accessToken: "access-token" } });
- await adminSignInApi("dev@solid-connection.com", "password");
+ await adminSignInApi("admin@solid-connection.com", "password");
expect(removeAccessToken).toHaveBeenCalledOnce();
- expect(saveAdminApiEnvironment).toHaveBeenCalledWith("dev");
- expect(post).toHaveBeenCalledWith(
- "/admin/auth/sign-in",
- { email: "dev@solid-connection.com", password: "password" },
- { baseURL: "https://api.stage.solid-connection.com" },
- );
+ expect(post).toHaveBeenCalledWith("/admin/auth/sign-in", {
+ email: "admin@solid-connection.com",
+ password: "password",
+ });
});
it("어드민 전용 재발급 API를 호출한다", async () => {
diff --git a/apps/admin/src/lib/api/auth.ts b/apps/admin/src/lib/api/auth.ts
index ae246cc5..d24cf009 100644
--- a/apps/admin/src/lib/api/auth.ts
+++ b/apps/admin/src/lib/api/auth.ts
@@ -1,7 +1,6 @@
import axios, { type AxiosResponse } from "axios";
-import { getApiBaseUrlForEnvironment, resolveEnvironmentFromEmail } from "@/lib/auth/environment";
import { resolveActiveApiBaseUrl } from "@/lib/env";
-import { loadAccessToken, removeAccessToken, saveAdminApiEnvironment } from "@/lib/utils/localStorage";
+import { loadAccessToken, removeAccessToken } from "@/lib/utils/localStorage";
import type { AdminSignInResponse, ReissueAccessTokenResponse } from "@/types/auth";
const authAxiosInstance = axios.create({
@@ -12,22 +11,14 @@ const ADMIN_AUTH_PATH = "/admin/auth";
authAxiosInstance.interceptors.request.use((config) => {
const newConfig = { ...config };
- // 로그인에서 직접 지정한 baseURL을 우선하고, 나머지 요청은 저장된 어드민 환경을 사용한다.
newConfig.baseURL = config.baseURL ?? resolveActiveApiBaseUrl();
return newConfig;
});
export const adminSignInApi = (email: string, password: string): Promise> => {
- const environment = resolveEnvironmentFromEmail(email);
-
removeAccessToken();
- saveAdminApiEnvironment(environment);
- return authAxiosInstance.post(
- `${ADMIN_AUTH_PATH}/sign-in`,
- { email, password },
- { baseURL: getApiBaseUrlForEnvironment(environment) },
- );
+ return authAxiosInstance.post(`${ADMIN_AUTH_PATH}/sign-in`, { email, password });
};
export const reissueAccessTokenApi = (): Promise> => {
diff --git a/apps/admin/src/lib/auth/environment.test.ts b/apps/admin/src/lib/auth/environment.test.ts
index e081fb4d..524c94eb 100644
--- a/apps/admin/src/lib/auth/environment.test.ts
+++ b/apps/admin/src/lib/auth/environment.test.ts
@@ -1,28 +1,9 @@
import { describe, expect, it } from "vitest";
-import { getApiBaseUrlForEnvironment, resolveEnvironmentFromEmail } from "./environment";
-
-describe("resolveEnvironmentFromEmail", () => {
- it("dev 계정 이메일(dev@solid-connection.com)은 dev 환경으로 판별한다", () => {
- expect(resolveEnvironmentFromEmail("dev@solid-connection.com")).toBe("dev");
- });
-
- it("대소문자와 앞뒤 공백을 무시하고 판별한다", () => {
- expect(resolveEnvironmentFromEmail(" Dev@Solid-Connection.Com ")).toBe("dev");
- });
-
- it("dev 계정 이메일이 아닌 이메일은 prod 환경으로 판별한다", () => {
- expect(resolveEnvironmentFromEmail("admin@solid-connection.com")).toBe("prod");
- });
-
- it("로컬파트가 dev로 시작하지만 정확히 일치하지 않는 이메일은 prod로 판별한다", () => {
- expect(resolveEnvironmentFromEmail("dev2@solid-connection.com")).toBe("prod");
- expect(resolveEnvironmentFromEmail("dev@notsolid-connection.com")).toBe("prod");
- });
-});
+import { getApiBaseUrlForEnvironment } from "./environment";
describe("getApiBaseUrlForEnvironment", () => {
it("환경에 맞는 API base URL을 반환한다", () => {
- expect(getApiBaseUrlForEnvironment("dev")).toBe("https://api.stage.solid-connection.com");
+ expect(getApiBaseUrlForEnvironment("stage")).toBe("https://api.stage.solid-connection.com");
expect(getApiBaseUrlForEnvironment("prod")).toBe("https://api.solid-connection.com");
});
});
diff --git a/apps/admin/src/lib/auth/environment.ts b/apps/admin/src/lib/auth/environment.ts
index 4ac54ff9..ae2d2d56 100644
--- a/apps/admin/src/lib/auth/environment.ts
+++ b/apps/admin/src/lib/auth/environment.ts
@@ -1,15 +1,8 @@
-export type AdminApiEnvironment = "dev" | "prod";
-
-const DEV_ACCOUNT_EMAIL = "dev@solid-connection.com";
+export type AdminApiEnvironment = "stage" | "prod";
const API_BASE_URLS: Record = {
- dev: "https://api.stage.solid-connection.com",
+ stage: "https://api.stage.solid-connection.com",
prod: "https://api.solid-connection.com",
};
-export const resolveEnvironmentFromEmail = (email: string): AdminApiEnvironment => {
- const normalizedEmail = email.trim().toLowerCase();
- return normalizedEmail === DEV_ACCOUNT_EMAIL ? "dev" : "prod";
-};
-
export const getApiBaseUrlForEnvironment = (environment: AdminApiEnvironment): string => API_BASE_URLS[environment];
diff --git a/apps/admin/src/lib/auth/session.test.ts b/apps/admin/src/lib/auth/session.test.ts
new file mode 100644
index 00000000..2c623e5d
--- /dev/null
+++ b/apps/admin/src/lib/auth/session.test.ts
@@ -0,0 +1,44 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { removeAccessToken, removeAdminApiEnvironment, saveAdminApiEnvironment } = vi.hoisted(() => ({
+ removeAccessToken: vi.fn(),
+ removeAdminApiEnvironment: vi.fn(),
+ saveAdminApiEnvironment: vi.fn(),
+}));
+
+vi.mock("@/lib/api/auth", () => ({
+ reissueAccessTokenApi: vi.fn(),
+}));
+
+vi.mock("@/lib/utils/localStorage", () => ({
+ loadAccessToken: vi.fn(),
+ removeAccessToken,
+ removeAdminApiEnvironment,
+ saveAccessToken: vi.fn(),
+ saveAdminApiEnvironment,
+}));
+
+describe("switchAdminApiEnvironment", () => {
+ beforeEach(() => {
+ removeAccessToken.mockReset();
+ removeAdminApiEnvironment.mockReset();
+ saveAdminApiEnvironment.mockReset();
+ });
+
+ it("세션(access token, 환경 값)을 모두 지운 뒤 새 환경을 저장한다", async () => {
+ const { switchAdminApiEnvironment } = await import("./session");
+ const redirect = vi.fn();
+ const callOrder: string[] = [];
+
+ removeAdminApiEnvironment.mockImplementation(() => callOrder.push("removeAdminApiEnvironment"));
+ saveAdminApiEnvironment.mockImplementation(() => callOrder.push("saveAdminApiEnvironment"));
+
+ switchAdminApiEnvironment("prod", redirect);
+
+ expect(removeAccessToken).toHaveBeenCalledOnce();
+ expect(removeAdminApiEnvironment).toHaveBeenCalledOnce();
+ expect(saveAdminApiEnvironment).toHaveBeenCalledWith("prod");
+ expect(callOrder).toEqual(["removeAdminApiEnvironment", "saveAdminApiEnvironment"]);
+ expect(redirect).toHaveBeenCalledWith("/auth/login");
+ });
+});
diff --git a/apps/admin/src/lib/auth/session.ts b/apps/admin/src/lib/auth/session.ts
index 93104ee8..daa7ebfd 100644
--- a/apps/admin/src/lib/auth/session.ts
+++ b/apps/admin/src/lib/auth/session.ts
@@ -1,10 +1,12 @@
import { reissueAccessTokenApi } from "@/lib/api/auth";
+import type { AdminApiEnvironment } from "@/lib/auth/environment";
import { isTokenExpired } from "@/lib/utils/jwtUtils";
import {
loadAccessToken,
removeAccessToken,
removeAdminApiEnvironment,
saveAccessToken,
+ saveAdminApiEnvironment,
} from "@/lib/utils/localStorage";
let reissuePromise: Promise | null = null;
@@ -31,6 +33,23 @@ export const clearSession = () => {
removeAdminApiEnvironment();
};
+/**
+ * 어드민 API 환경(stage/prod)을 전환한다. clearSession()이 환경 저장 키까지 함께 지우므로,
+ * 반드시 clearSession → saveAdminApiEnvironment 순서로 처리해야 새 환경 값이 유지된다.
+ * 이후 react-query 캐시 등 메모리 상태를 확실히 비우기 위해 전체 페이지 이동으로 리다이렉트한다.
+ * redirect는 테스트에서 주입할 수 있도록 매개변수로 분리했다.
+ */
+export const switchAdminApiEnvironment = (
+ next: AdminApiEnvironment,
+ redirect: (url: string) => void = (url) => {
+ window.location.href = url;
+ },
+) => {
+ clearSession();
+ saveAdminApiEnvironment(next);
+ redirect("/auth/login");
+};
+
export const reissueAccessTokenIfPossible = async (): Promise => {
if (reissuePromise) {
return reissuePromise;
diff --git a/apps/admin/src/lib/env.ts b/apps/admin/src/lib/env.ts
index fc83b62e..d76f332c 100644
--- a/apps/admin/src/lib/env.ts
+++ b/apps/admin/src/lib/env.ts
@@ -4,12 +4,13 @@ import { loadAdminApiEnvironment } from "@/lib/utils/localStorage";
const getTrimmedEnv = (key: string) => import.meta.env[key]?.trim() ?? "";
// Local-dev-only override (e.g. http://localhost:8080). When unset, the API server
-// is resolved at runtime from the signed-in account's environment (see resolveActiveApiBaseUrl).
+// is resolved at runtime from the environment stored via the environment switcher FAB
+// (see resolveActiveApiBaseUrl).
export const getAdminApiServerUrl = () => getTrimmedEnv("VITE_API_SERVER_URL");
export const resolveActiveApiBaseUrl = (): string => {
// The override only applies to `vinext dev` (import.meta.env.DEV). Deployed builds
- // (Preview/Production) always resolve dev/prod from the signed-in account's email,
+ // (Preview/Production) always resolve stage/prod from the stored environment selection,
// regardless of whether VITE_API_SERVER_URL happens to still be set on Vercel.
const localOverride = import.meta.env.DEV ? getAdminApiServerUrl() : "";
if (localOverride) {
diff --git a/apps/admin/src/lib/utils/localStorage.test.ts b/apps/admin/src/lib/utils/localStorage.test.ts
index 972ff643..e88eb955 100644
--- a/apps/admin/src/lib/utils/localStorage.test.ts
+++ b/apps/admin/src/lib/utils/localStorage.test.ts
@@ -1,8 +1,16 @@
import { afterEach, describe, expect, it } from "vitest";
-import { loadAccessToken, removeAccessToken, saveAccessToken } from "./localStorage";
+import {
+ loadAccessToken,
+ loadAdminApiEnvironment,
+ removeAccessToken,
+ removeAdminApiEnvironment,
+ saveAccessToken,
+ saveAdminApiEnvironment,
+} from "./localStorage";
const ADMIN_ACCESS_TOKEN_KEY = "adminAccessToken";
+const ADMIN_API_ENVIRONMENT_KEY = "adminApiEnvironment";
describe("어드민 access token localStorage 저장", () => {
afterEach(() => {
@@ -35,3 +43,35 @@ describe("어드민 access token localStorage 저장", () => {
expect(localStorage.getItem("accessToken")).toBeNull();
});
});
+
+describe("어드민 API 환경 localStorage 저장", () => {
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it("환경 값을 저장하고 조회한다", () => {
+ saveAdminApiEnvironment("prod");
+
+ expect(loadAdminApiEnvironment()).toBe("prod");
+ });
+
+ it("환경 값을 제거한다", () => {
+ saveAdminApiEnvironment("stage");
+
+ removeAdminApiEnvironment();
+
+ expect(loadAdminApiEnvironment()).toBeNull();
+ });
+
+ it("레거시 값 'dev'는 'stage'로 해석한다", () => {
+ localStorage.setItem(ADMIN_API_ENVIRONMENT_KEY, "dev");
+
+ expect(loadAdminApiEnvironment()).toBe("stage");
+ });
+
+ it("알 수 없는 값은 null을 반환한다", () => {
+ localStorage.setItem(ADMIN_API_ENVIRONMENT_KEY, "unknown");
+
+ expect(loadAdminApiEnvironment()).toBeNull();
+ });
+});
diff --git a/apps/admin/src/lib/utils/localStorage.ts b/apps/admin/src/lib/utils/localStorage.ts
index 4cc69a35..e68a6058 100644
--- a/apps/admin/src/lib/utils/localStorage.ts
+++ b/apps/admin/src/lib/utils/localStorage.ts
@@ -35,7 +35,11 @@ export const loadAdminApiEnvironment = (): AdminApiEnvironment | null => {
try {
const value = localStorage.getItem(ADMIN_API_ENVIRONMENT_KEY);
- return value === "dev" || value === "prod" ? value : null;
+ // 레거시 값("dev")으로 저장된 기존 사용자의 선택을 보존하기 위해 stage로 해석한다.
+ if (value === "dev") {
+ return "stage";
+ }
+ return value === "stage" || value === "prod" ? value : null;
} catch (err) {
console.error("Could not load admin api environment", err);
return null;