-
Notifications
You must be signed in to change notification settings - Fork 3
✨ 어드민 API 환경(stage/prod) 플로팅 스위처 추가 #619
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
57 changes: 57 additions & 0 deletions
57
apps/admin/src/components/layout/EnvironmentSwitcherFab.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()); | ||
| 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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.