Skip to content

Commit ef19b60

Browse files
authored
Merge pull request #619 from solid-connection/feat/admin-api-environment-switcher
✨ 어드민 API 환경(stage/prod) 플로팅 스위처 추가
2 parents 43e57e3 + 10e7dcc commit ef19b60

13 files changed

Lines changed: 233 additions & 105 deletions

apps/admin/src/app/layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ReactNode } from "react";
22
import "../styles.css";
3+
import { EnvironmentSwitcherFab } from "@/components/layout/EnvironmentSwitcherFab";
34
import { Providers } from "./providers";
45

56
export const metadata = {
@@ -11,7 +12,10 @@ export default function RootLayout({ children }: { children: ReactNode }) {
1112
return (
1213
<html lang="ko">
1314
<body>
14-
<Providers>{children}</Providers>
15+
<Providers>
16+
{children}
17+
<EnvironmentSwitcherFab />
18+
</Providers>
1519
</body>
1620
</html>
1721
);

apps/admin/src/components/layout/EnvironmentBanner.tsx

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,12 @@
11
"use client";
22

33
import { useEffect, useState } from "react";
4-
import { getAdminApiServerUrl } from "@/lib/env";
5-
import { loadAdminApiEnvironment } from "@/lib/utils/localStorage";
6-
7-
type DisplayedEnvironment = "dev" | "prod" | "local";
8-
9-
const environmentStyles: Record<DisplayedEnvironment, string> = {
10-
dev: "bg-magic-success-surface text-magic-success",
11-
prod: "bg-magic-danger-surface text-magic-danger",
12-
local: "bg-bg-50 text-k-600",
13-
};
14-
15-
const environmentLabels: Record<DisplayedEnvironment, string> = {
16-
dev: "DEV",
17-
prod: "PROD",
18-
local: "LOCAL",
19-
};
20-
21-
const resolveDisplayedEnvironment = (): DisplayedEnvironment => {
22-
if (import.meta.env.DEV && getAdminApiServerUrl()) {
23-
return "local";
24-
}
25-
return loadAdminApiEnvironment() ?? "prod";
26-
};
4+
import {
5+
type DisplayedEnvironment,
6+
environmentLabels,
7+
environmentStyles,
8+
resolveDisplayedEnvironment,
9+
} from "./environmentDisplay";
2710

2811
export function EnvironmentBanner() {
2912
const [environment, setEnvironment] = useState<DisplayedEnvironment | null>(null);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import type { AdminApiEnvironment } from "@/lib/auth/environment";
5+
import { switchAdminApiEnvironment } from "@/lib/auth/session";
6+
import {
7+
type DisplayedEnvironment,
8+
environmentLabels,
9+
environmentStyles,
10+
resolveDisplayedEnvironment,
11+
} from "./environmentDisplay";
12+
13+
const nextEnvironmentOf = (environment: AdminApiEnvironment): AdminApiEnvironment =>
14+
environment === "stage" ? "prod" : "stage";
15+
16+
export function EnvironmentSwitcherFab() {
17+
const [environment, setEnvironment] = useState<DisplayedEnvironment | null>(null);
18+
19+
useEffect(() => {
20+
setEnvironment(resolveDisplayedEnvironment());
21+
22+
const handleStorageChange = () => setEnvironment(resolveDisplayedEnvironment());
23+
window.addEventListener("storage", handleStorageChange);
24+
return () => window.removeEventListener("storage", handleStorageChange);
25+
}, []);
26+
27+
if (!environment) {
28+
return null;
29+
}
30+
31+
const isLocal = environment === "local";
32+
33+
const handleClick = () => {
34+
if (isLocal) {
35+
return;
36+
}
37+
38+
const next = nextEnvironmentOf(environment);
39+
const confirmed = window.confirm(`환경을 ${environmentLabels[next]}(으)로 전환하면 로그아웃됩니다. 계속할까요?`);
40+
if (!confirmed) {
41+
return;
42+
}
43+
44+
switchAdminApiEnvironment(next);
45+
};
46+
47+
return (
48+
<button
49+
type="button"
50+
onClick={handleClick}
51+
disabled={isLocal}
52+
className={`fixed bottom-6 right-6 z-50 inline-flex items-center gap-1.5 rounded-full px-4 py-2 typo-sb-9 shadow-magic-admin-header transition disabled:cursor-not-allowed disabled:opacity-70 ${environmentStyles[environment]}`}
53+
>
54+
{environmentLabels[environment]}
55+
</button>
56+
);
57+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { AdminApiEnvironment } from "@/lib/auth/environment";
2+
import { getAdminApiServerUrl } from "@/lib/env";
3+
import { loadAdminApiEnvironment } from "@/lib/utils/localStorage";
4+
5+
export type DisplayedEnvironment = AdminApiEnvironment | "local";
6+
7+
export const environmentStyles: Record<DisplayedEnvironment, string> = {
8+
stage: "bg-magic-success-surface text-magic-success",
9+
prod: "bg-magic-danger-surface text-magic-danger",
10+
local: "bg-bg-50 text-k-600",
11+
};
12+
13+
export const environmentLabels: Record<DisplayedEnvironment, string> = {
14+
stage: "STAGE",
15+
prod: "PROD",
16+
local: "LOCAL",
17+
};
18+
19+
export const resolveDisplayedEnvironment = (): DisplayedEnvironment => {
20+
if (import.meta.env.DEV && getAdminApiServerUrl()) {
21+
return "local";
22+
}
23+
return loadAdminApiEnvironment() ?? "prod";
24+
};

apps/admin/src/lib/api/auth.test.ts

Lines changed: 23 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,38 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

3-
const { create, loadAccessToken, post, removeAccessToken, requestInterceptors, saveAdminApiEnvironment } = vi.hoisted(
4-
() => {
5-
const hoistedPost = vi.fn();
6-
return {
7-
create: vi.fn(() => ({
8-
post: hoistedPost,
9-
interceptors: {
10-
request: {
11-
use: (fn: (config: Record<string, unknown>) => Record<string, unknown>) => {
12-
requestInterceptors.push(fn);
13-
},
3+
const { create, loadAccessToken, post, removeAccessToken, requestInterceptors } = vi.hoisted(() => {
4+
const hoistedPost = vi.fn();
5+
return {
6+
create: vi.fn(() => ({
7+
post: hoistedPost,
8+
interceptors: {
9+
request: {
10+
use: (fn: (config: Record<string, unknown>) => Record<string, unknown>) => {
11+
requestInterceptors.push(fn);
1412
},
1513
},
16-
})),
17-
loadAccessToken: vi.fn(),
18-
post: hoistedPost,
19-
removeAccessToken: vi.fn(),
20-
requestInterceptors: [] as Array<(config: Record<string, unknown>) => Record<string, unknown>>,
21-
saveAdminApiEnvironment: vi.fn(),
22-
};
23-
},
24-
);
14+
},
15+
})),
16+
loadAccessToken: vi.fn(),
17+
post: hoistedPost,
18+
removeAccessToken: vi.fn(),
19+
requestInterceptors: [] as Array<(config: Record<string, unknown>) => Record<string, unknown>>,
20+
};
21+
});
2522

2623
vi.mock("axios", () => ({
2724
default: {
2825
create,
2926
},
3027
}));
3128

32-
vi.mock("@/lib/auth/environment", () => ({
33-
getApiBaseUrlForEnvironment: (environment: string) =>
34-
environment === "dev" ? "https://api.stage.solid-connection.com" : "https://api.solid-connection.com",
35-
resolveEnvironmentFromEmail: (email: string) => (email === "dev@solid-connection.com" ? "dev" : "prod"),
36-
}));
37-
3829
vi.mock("@/lib/env", () => ({
3930
resolveActiveApiBaseUrl: () => "https://api.solid-connection.com",
4031
}));
4132

4233
vi.mock("@/lib/utils/localStorage", () => ({
4334
loadAccessToken,
4435
removeAccessToken,
45-
saveAdminApiEnvironment,
4636
}));
4737

4838
import { adminSignInApi, adminSignOutApi, reissueAccessTokenApi } from "./auth";
@@ -52,7 +42,6 @@ describe("어드민 인증 API", () => {
5242
loadAccessToken.mockReset();
5343
post.mockReset();
5444
removeAccessToken.mockReset();
55-
saveAdminApiEnvironment.mockReset();
5645
});
5746

5847
it("어드민 refresh token 쿠키를 주고받도록 credentials를 포함한다", () => {
@@ -61,18 +50,16 @@ describe("어드민 인증 API", () => {
6150
});
6251
});
6352

64-
it("어드민 전용 로그인 API를 이메일 환경에 맞춰 호출한다", async () => {
53+
it("어드민 전용 로그인 API를 호출하고 이전 access token을 정리한다", async () => {
6554
post.mockResolvedValue({ data: { accessToken: "access-token" } });
6655

67-
await adminSignInApi("dev@solid-connection.com", "password");
56+
await adminSignInApi("admin@solid-connection.com", "password");
6857

6958
expect(removeAccessToken).toHaveBeenCalledOnce();
70-
expect(saveAdminApiEnvironment).toHaveBeenCalledWith("dev");
71-
expect(post).toHaveBeenCalledWith(
72-
"/admin/auth/sign-in",
73-
{ email: "dev@solid-connection.com", password: "password" },
74-
{ baseURL: "https://api.stage.solid-connection.com" },
75-
);
59+
expect(post).toHaveBeenCalledWith("/admin/auth/sign-in", {
60+
email: "admin@solid-connection.com",
61+
password: "password",
62+
});
7663
});
7764

7865
it("어드민 전용 재발급 API를 호출한다", async () => {

apps/admin/src/lib/api/auth.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import axios, { type AxiosResponse } from "axios";
2-
import { getApiBaseUrlForEnvironment, resolveEnvironmentFromEmail } from "@/lib/auth/environment";
32
import { resolveActiveApiBaseUrl } from "@/lib/env";
4-
import { loadAccessToken, removeAccessToken, saveAdminApiEnvironment } from "@/lib/utils/localStorage";
3+
import { loadAccessToken, removeAccessToken } from "@/lib/utils/localStorage";
54
import type { AdminSignInResponse, ReissueAccessTokenResponse } from "@/types/auth";
65

76
const authAxiosInstance = axios.create({
@@ -12,22 +11,14 @@ const ADMIN_AUTH_PATH = "/admin/auth";
1211

1312
authAxiosInstance.interceptors.request.use((config) => {
1413
const newConfig = { ...config };
15-
// 로그인에서 직접 지정한 baseURL을 우선하고, 나머지 요청은 저장된 어드민 환경을 사용한다.
1614
newConfig.baseURL = config.baseURL ?? resolveActiveApiBaseUrl();
1715
return newConfig;
1816
});
1917

2018
export const adminSignInApi = (email: string, password: string): Promise<AxiosResponse<AdminSignInResponse>> => {
21-
const environment = resolveEnvironmentFromEmail(email);
22-
2319
removeAccessToken();
24-
saveAdminApiEnvironment(environment);
2520

26-
return authAxiosInstance.post(
27-
`${ADMIN_AUTH_PATH}/sign-in`,
28-
{ email, password },
29-
{ baseURL: getApiBaseUrlForEnvironment(environment) },
30-
);
21+
return authAxiosInstance.post(`${ADMIN_AUTH_PATH}/sign-in`, { email, password });
3122
};
3223

3324
export const reissueAccessTokenApi = (): Promise<AxiosResponse<ReissueAccessTokenResponse>> => {
Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,9 @@
11
import { describe, expect, it } from "vitest";
2-
import { getApiBaseUrlForEnvironment, resolveEnvironmentFromEmail } from "./environment";
3-
4-
describe("resolveEnvironmentFromEmail", () => {
5-
it("dev 계정 이메일(dev@solid-connection.com)은 dev 환경으로 판별한다", () => {
6-
expect(resolveEnvironmentFromEmail("dev@solid-connection.com")).toBe("dev");
7-
});
8-
9-
it("대소문자와 앞뒤 공백을 무시하고 판별한다", () => {
10-
expect(resolveEnvironmentFromEmail(" Dev@Solid-Connection.Com ")).toBe("dev");
11-
});
12-
13-
it("dev 계정 이메일이 아닌 이메일은 prod 환경으로 판별한다", () => {
14-
expect(resolveEnvironmentFromEmail("admin@solid-connection.com")).toBe("prod");
15-
});
16-
17-
it("로컬파트가 dev로 시작하지만 정확히 일치하지 않는 이메일은 prod로 판별한다", () => {
18-
expect(resolveEnvironmentFromEmail("dev2@solid-connection.com")).toBe("prod");
19-
expect(resolveEnvironmentFromEmail("dev@notsolid-connection.com")).toBe("prod");
20-
});
21-
});
2+
import { getApiBaseUrlForEnvironment } from "./environment";
223

234
describe("getApiBaseUrlForEnvironment", () => {
245
it("환경에 맞는 API base URL을 반환한다", () => {
25-
expect(getApiBaseUrlForEnvironment("dev")).toBe("https://api.stage.solid-connection.com");
6+
expect(getApiBaseUrlForEnvironment("stage")).toBe("https://api.stage.solid-connection.com");
267
expect(getApiBaseUrlForEnvironment("prod")).toBe("https://api.solid-connection.com");
278
});
289
});
Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
1-
export type AdminApiEnvironment = "dev" | "prod";
2-
3-
const DEV_ACCOUNT_EMAIL = "dev@solid-connection.com";
1+
export type AdminApiEnvironment = "stage" | "prod";
42

53
const API_BASE_URLS: Record<AdminApiEnvironment, string> = {
6-
dev: "https://api.stage.solid-connection.com",
4+
stage: "https://api.stage.solid-connection.com",
75
prod: "https://api.solid-connection.com",
86
};
97

10-
export const resolveEnvironmentFromEmail = (email: string): AdminApiEnvironment => {
11-
const normalizedEmail = email.trim().toLowerCase();
12-
return normalizedEmail === DEV_ACCOUNT_EMAIL ? "dev" : "prod";
13-
};
14-
158
export const getApiBaseUrlForEnvironment = (environment: AdminApiEnvironment): string => API_BASE_URLS[environment];
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { removeAccessToken, removeAdminApiEnvironment, saveAdminApiEnvironment } = vi.hoisted(() => ({
4+
removeAccessToken: vi.fn(),
5+
removeAdminApiEnvironment: vi.fn(),
6+
saveAdminApiEnvironment: vi.fn(),
7+
}));
8+
9+
vi.mock("@/lib/api/auth", () => ({
10+
reissueAccessTokenApi: vi.fn(),
11+
}));
12+
13+
vi.mock("@/lib/utils/localStorage", () => ({
14+
loadAccessToken: vi.fn(),
15+
removeAccessToken,
16+
removeAdminApiEnvironment,
17+
saveAccessToken: vi.fn(),
18+
saveAdminApiEnvironment,
19+
}));
20+
21+
describe("switchAdminApiEnvironment", () => {
22+
beforeEach(() => {
23+
removeAccessToken.mockReset();
24+
removeAdminApiEnvironment.mockReset();
25+
saveAdminApiEnvironment.mockReset();
26+
});
27+
28+
it("세션(access token, 환경 값)을 모두 지운 뒤 새 환경을 저장한다", async () => {
29+
const { switchAdminApiEnvironment } = await import("./session");
30+
const redirect = vi.fn();
31+
const callOrder: string[] = [];
32+
33+
removeAdminApiEnvironment.mockImplementation(() => callOrder.push("removeAdminApiEnvironment"));
34+
saveAdminApiEnvironment.mockImplementation(() => callOrder.push("saveAdminApiEnvironment"));
35+
36+
switchAdminApiEnvironment("prod", redirect);
37+
38+
expect(removeAccessToken).toHaveBeenCalledOnce();
39+
expect(removeAdminApiEnvironment).toHaveBeenCalledOnce();
40+
expect(saveAdminApiEnvironment).toHaveBeenCalledWith("prod");
41+
expect(callOrder).toEqual(["removeAdminApiEnvironment", "saveAdminApiEnvironment"]);
42+
expect(redirect).toHaveBeenCalledWith("/auth/login");
43+
});
44+
});

0 commit comments

Comments
 (0)