-
Notifications
You must be signed in to change notification settings - Fork 3
어드민 로그인 계정에 따라 dev/prod API 서버 런타임 자동 전환 #615
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| "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"; | ||
| }; | ||
|
|
||
| export function EnvironmentBanner() { | ||
| 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; | ||
| } | ||
|
|
||
| return ( | ||
| <span | ||
| className={`inline-flex items-center rounded-full px-2.5 py-0.5 typo-medium-4 ${environmentStyles[environment]}`} | ||
| > | ||
| {environmentLabels[environment]} | ||
| </span> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,34 +1,33 @@ | ||
| import axios, { type AxiosResponse } from "axios"; | ||
| import { createMissingAdminApiServerUrlError, getAdminApiServerUrl } from "@/lib/env"; | ||
| import { loadAccessToken } from "@/lib/utils/localStorage"; | ||
| import { resolveEnvironmentFromEmail } from "@/lib/auth/environment"; | ||
| import { resolveActiveApiBaseUrl } from "@/lib/env"; | ||
| import { loadAccessToken, removeAccessToken, saveAdminApiEnvironment } from "@/lib/utils/localStorage"; | ||
| import type { AdminSignInResponse, ReissueAccessTokenResponse } from "@/types/auth"; | ||
|
|
||
| const API_SERVER_URL = getAdminApiServerUrl(); | ||
|
|
||
| const authAxiosInstance = axios.create({ | ||
| baseURL: API_SERVER_URL || undefined, | ||
| baseURL: resolveActiveApiBaseUrl(), | ||
| withCredentials: true, | ||
| }); | ||
|
|
||
| const assertAdminApiServerUrl = () => { | ||
| if (!API_SERVER_URL) { | ||
| throw createMissingAdminApiServerUrlError(); | ||
| } | ||
| }; | ||
| authAxiosInstance.interceptors.request.use((config) => { | ||
| const newConfig = { ...config }; | ||
| newConfig.baseURL = resolveActiveApiBaseUrl(); | ||
| return newConfig; | ||
| }); | ||
|
|
||
| export const adminSignInApi = (email: string, password: string): Promise<AxiosResponse<AdminSignInResponse>> => { | ||
| assertAdminApiServerUrl(); | ||
| // Clear any previous environment's token before switching, so a stale token can never | ||
| // ride along to the newly resolved environment's API (e.g. dev token leaking to prod). | ||
| removeAccessToken(); | ||
| saveAdminApiEnvironment(resolveEnvironmentFromEmail(email)); | ||
| return authAxiosInstance.post("/auth/email/sign-in", { email, password }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| }; | ||
|
|
||
| export const reissueAccessTokenApi = (): Promise<AxiosResponse<ReissueAccessTokenResponse>> => { | ||
| assertAdminApiServerUrl(); | ||
| return authAxiosInstance.post("/auth/reissue"); | ||
| }; | ||
|
|
||
| export const adminSignOutApi = (): Promise<AxiosResponse<void>> => { | ||
| assertAdminApiServerUrl(); | ||
|
|
||
| const accessToken = loadAccessToken(); | ||
|
|
||
| return authAxiosInstance.post("/auth/sign-out", undefined, { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { getApiBaseUrlForEnvironment, resolveEnvironmentFromEmail } from "./environment"; | ||
|
|
||
| describe("resolveEnvironmentFromEmail", () => { | ||
| it("dev 도메인 이메일은 dev 환경으로 판별한다", () => { | ||
| expect(resolveEnvironmentFromEmail("admin@dev.solid-connection.com")).toBe("dev"); | ||
| }); | ||
|
|
||
| it("대소문자와 앞뒤 공백을 무시하고 판별한다", () => { | ||
| expect(resolveEnvironmentFromEmail(" Admin@Dev.Solid-Connection.Com ")).toBe("dev"); | ||
| }); | ||
|
|
||
| it("dev 도메인이 아닌 이메일은 prod 환경으로 판별한다", () => { | ||
| expect(resolveEnvironmentFromEmail("admin@solid-connection.com")).toBe("prod"); | ||
| }); | ||
|
|
||
| it("dev 도메인을 부분 문자열로만 포함하는 이메일은 prod로 판별한다", () => { | ||
| expect(resolveEnvironmentFromEmail("admin@notdev.solid-connection.com")).toBe("prod"); | ||
| expect(resolveEnvironmentFromEmail("admin@dev.solid-connection.com.evil.com")).toBe("prod"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("getApiBaseUrlForEnvironment", () => { | ||
| it("환경에 맞는 API base URL을 반환한다", () => { | ||
| expect(getApiBaseUrlForEnvironment("dev")).toBe("https://api.stage.solid-connection.com"); | ||
| expect(getApiBaseUrlForEnvironment("prod")).toBe("https://api.solid-connection.com"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| export type AdminApiEnvironment = "dev" | "prod"; | ||
|
|
||
| const DEV_EMAIL_DOMAIN = "dev.solid-connection.com"; | ||
|
|
||
| const API_BASE_URLS: Record<AdminApiEnvironment, string> = { | ||
| dev: "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.endsWith(`@${DEV_EMAIL_DOMAIN}`) ? "dev" : "prod"; | ||
| }; | ||
|
|
||
| export const getApiBaseUrlForEnvironment = (environment: AdminApiEnvironment): string => API_BASE_URLS[environment]; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,21 @@ | ||
| import { getApiBaseUrlForEnvironment } from "@/lib/auth/environment"; | ||
| 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). | ||
| export const getAdminApiServerUrl = () => getTrimmedEnv("VITE_API_SERVER_URL"); | ||
|
|
||
| export const createMissingAdminApiServerUrlError = () => | ||
| new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment."); | ||
| 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, | ||
| // regardless of whether VITE_API_SERVER_URL happens to still be set on Vercel. | ||
| const localOverride = import.meta.env.DEV ? getAdminApiServerUrl() : ""; | ||
| if (localOverride) { | ||
| return localOverride; | ||
| } | ||
|
|
||
| const storedEnvironment = loadAdminApiEnvironment(); | ||
| return getApiBaseUrlForEnvironment(storedEnvironment ?? "prod"); | ||
| }; |
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.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
1. 환경 저장에 실패하면 로그인 요청을 중단하십시오.
Line 22의
saveAdminApiEnvironment는localStorage오류를 내부에서 처리하고 성공 여부를 반환하지 않습니다. 저장이 실패하면 요청 인터셉터는 환경을 읽지 못하고 production URL을 선택합니다. 그러면 dev 계정의 이메일과 비밀번호가 production API로 전송됩니다.saveAdminApiEnvironment가 성공 여부를 반환하게 하십시오. 저장에 실패하면 Line 23의 로그인 요청을 보내지 마십시오.localStorage.setItem이 예외를 던질 때 요청이 전송되지 않는 회귀 테스트도 추가하십시오.🤖 Prompt for AI Agents