Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/admin/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -11,7 +12,10 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<body>
<Providers>{children}</Providers>
<Providers>
{children}
<EnvironmentSwitcherFab />
</Providers>
</body>
</html>
);
Expand Down
29 changes: 6 additions & 23 deletions apps/admin/src/components/layout/EnvironmentBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<DisplayedEnvironment, string> = {
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<DisplayedEnvironment, string> = {
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<DisplayedEnvironment | null>(null);
Expand Down
57 changes: 57 additions & 0 deletions apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx
Original file line number Diff line number Diff line change
@@ -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<DisplayedEnvironment | null>(null);

useEffect(() => {
setEnvironment(resolveDisplayedEnvironment());

const handleStorageChange = () => setEnvironment(resolveDisplayedEnvironment());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reload other tabs when the stored environment changes

When the admin console is open in multiple tabs and one tab switches environments, this handler only changes the badge in the other tabs. Their mounted pages and React Query caches remain populated with data from the old environment, while subsequent requests resolve their base URL from the newly stored environment and the shared access token has been removed, so an old-environment view can issue mutations against the new environment; the storage handler should force the same reload/cache reset used by the initiating tab.

Useful? React with 👍 / 👎.

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 (
<button
type="button"
onClick={handleClick}
disabled={isLocal}
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]}`}
>
{environmentLabels[environment]}
</button>
);
}
24 changes: 24 additions & 0 deletions apps/admin/src/components/layout/environmentDisplay.ts
Original file line number Diff line number Diff line change
@@ -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<DisplayedEnvironment, string> = {
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<DisplayedEnvironment, string> = {
stage: "STAGE",
prod: "PROD",
local: "LOCAL",
};

export const resolveDisplayedEnvironment = (): DisplayedEnvironment => {
if (import.meta.env.DEV && getAdminApiServerUrl()) {
return "local";
}
return loadAdminApiEnvironment() ?? "prod";
};
59 changes: 23 additions & 36 deletions apps/admin/src/lib/api/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,38 @@
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<string, unknown>) => Record<string, unknown>) => {
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<string, unknown>) => Record<string, unknown>) => {
requestInterceptors.push(fn);
},
},
})),
loadAccessToken: vi.fn(),
post: hoistedPost,
removeAccessToken: vi.fn(),
requestInterceptors: [] as Array<(config: Record<string, unknown>) => Record<string, unknown>>,
saveAdminApiEnvironment: vi.fn(),
};
},
);
},
})),
loadAccessToken: vi.fn(),
post: hoistedPost,
removeAccessToken: vi.fn(),
requestInterceptors: [] as Array<(config: Record<string, unknown>) => Record<string, unknown>>,
};
});

vi.mock("axios", () => ({
default: {
create,
},
}));

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",
}));

vi.mock("@/lib/utils/localStorage", () => ({
loadAccessToken,
removeAccessToken,
saveAdminApiEnvironment,
}));

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

it("어드민 refresh token 쿠키를 주고받도록 credentials를 포함한다", () => {
Expand All @@ -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 () => {
Expand Down
13 changes: 2 additions & 11 deletions apps/admin/src/lib/api/auth.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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<AxiosResponse<AdminSignInResponse>> => {
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<AxiosResponse<ReissueAccessTokenResponse>> => {
Expand Down
23 changes: 2 additions & 21 deletions apps/admin/src/lib/auth/environment.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
11 changes: 2 additions & 9 deletions apps/admin/src/lib/auth/environment.ts
Original file line number Diff line number Diff line change
@@ -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<AdminApiEnvironment, string> = {
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];
44 changes: 44 additions & 0 deletions apps/admin/src/lib/auth/session.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading