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
7 changes: 4 additions & 3 deletions apps/admin/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# API 서버 URL (필수)
# 빌드/프리뷰 시 이 값이 없으면 API 호출은 실패하지만 SSR shell은 500 없이 응답해야 합니다.
VITE_API_SERVER_URL=https://api.example.com
# API 서버 URL (로컬 개발 전용 override, 선택)
# 설정 시 로그인 계정의 이메일 패턴과 무관하게 이 값이 항상 사용됩니다.
# 비워두면 로그인한 계정의 이메일 도메인에 따라 dev/prod API가 런타임에 자동 선택됩니다.
# VITE_API_SERVER_URL=http://localhost:8080

# 업로드 CDN URL (선택)
VITE_UPLOADED_IMAGE_URL=https://cdn.upload.solid-connection.com
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { axiosInstance } from "@/lib/api/client";
import { resolveActiveApiBaseUrl } from "@/lib/env";
import { cn } from "@/lib/utils";

type EndpointItem = BrunoApiDefinitionRegistryItem;
Expand Down Expand Up @@ -46,7 +47,6 @@ const ALL_ENDPOINTS: EndpointItem[] = [...brunoApiDefinitionRegistry].sort((a, b
const METHOD_FILTERS: MethodFilter[] = ["ALL", "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
const MUTATING_METHODS = new Set<DefinitionMethod>(["POST", "PUT", "PATCH", "DELETE"]);
const METHODS_WITHOUT_BODY = new Set<string>(["GET", "HEAD"]);
const apiServerUrl = import.meta.env.VITE_API_SERVER_URL?.trim() ?? "";

const toPrettyJson = (value: unknown): string => {
if (value === undefined) {
Expand Down Expand Up @@ -199,7 +199,7 @@ export function BrunoApiPageContent() {
const remoteWarningUrl =
selectedEndpoint && isAbsoluteUrl(selectedEndpoint.definition.path)
? selectedEndpoint.definition.path
: apiServerUrl;
: resolveActiveApiBaseUrl();
const showRemoteWarning = isRemoteApiServer(remoteWarningUrl);

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { resolveActiveApiBaseUrl } from "@/lib/env";
import { loadAccessToken } from "@/lib/utils/localStorage";

type ConnectionState = "DISCONNECTED" | "CONNECTING" | "CONNECTED" | "ERROR";
Expand Down Expand Up @@ -92,7 +93,7 @@ export function ChatSocketPageContent() {
const subscriptionRef = useRef<StompSubscription | null>(null);

const [connectionState, setConnectionState] = useState<ConnectionState>("DISCONNECTED");
const [serverUrl, setServerUrl] = useState(import.meta.env.VITE_API_SERVER_URL?.trim() ?? "");
const [serverUrl, setServerUrl] = useState(resolveActiveApiBaseUrl());
const [token, setToken] = useState("");
const [roomId, setRoomId] = useState("");
const [topicTemplate, setTopicTemplate] = useState(defaultTopicTemplate);
Expand Down
2 changes: 2 additions & 0 deletions apps/admin/src/components/layout/AdminLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { toast } from "sonner";
import { adminSignOutApi } from "@/lib/api/auth";
import { clearSession } from "@/lib/auth/session";
import { type ActiveAdminMenu, AdminSidebar } from "./AdminSidebar";
import { EnvironmentBanner } from "./EnvironmentBanner";

interface AdminLayoutProps {
children: React.ReactNode;
Expand Down Expand Up @@ -51,6 +52,7 @@ export function AdminLayout({ children, activeMenu, title, description }: AdminL
</div>
</div>
<div className="flex items-center gap-2">
<EnvironmentBanner />
<p className="hidden rounded-full bg-bg-50 px-3 py-1 typo-medium-4 text-k-600 sm:block">운영 콘솔</p>
<button
type="button"
Expand Down
50 changes: 50 additions & 0 deletions apps/admin/src/components/layout/EnvironmentBanner.tsx
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>
);
}
60 changes: 60 additions & 0 deletions apps/admin/src/lib/api/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { post, requestInterceptors } = vi.hoisted(() => ({
post: vi.fn().mockResolvedValue({ data: { accessToken: "token" } }),
requestInterceptors: [] as Array<(config: Record<string, unknown>) => Record<string, unknown>>,
}));

vi.mock("axios", () => ({
default: {
create: () => ({
post,
interceptors: {
request: {
use: (fn: (config: Record<string, unknown>) => Record<string, unknown>) => {
requestInterceptors.push(fn);
},
},
},
}),
},
}));

describe("adminSignInApi", () => {
beforeEach(() => {
post.mockClear();
localStorage.clear();
});

afterEach(() => {
vi.restoreAllMocks();
});

it("dev 이메일 로그인 요청은 localStorage 저장 성공 여부와 무관하게 stage baseURL로 전송된다", async () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage unavailable");
});

const { adminSignInApi } = await import("./auth");
await adminSignInApi("admin@dev.solid-connection.com", "password");

expect(post).toHaveBeenCalledWith(
"/auth/email/sign-in",
{ email: "admin@dev.solid-connection.com", password: "password" },
{ baseURL: "https://api.stage.solid-connection.com" },
);

setItemSpy.mockRestore();
});

it("prod 이메일 로그인 요청은 prod baseURL로 전송된다", async () => {
const { adminSignInApi } = await import("./auth");
await adminSignInApi("admin@solid-connection.com", "password");

expect(post).toHaveBeenCalledWith(
"/auth/email/sign-in",
{ email: "admin@solid-connection.com", password: "password" },
{ baseURL: "https://api.solid-connection.com" },
);
});
});
39 changes: 24 additions & 15 deletions apps/admin/src/lib/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,43 @@
import axios, { type AxiosResponse } from "axios";
import { createMissingAdminApiServerUrlError, getAdminApiServerUrl } from "@/lib/env";
import { loadAccessToken } from "@/lib/utils/localStorage";
import { getApiBaseUrlForEnvironment, 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,
withCredentials: true,
});

const assertAdminApiServerUrl = () => {
if (!API_SERVER_URL) {
throw createMissingAdminApiServerUrlError();
}
};
authAxiosInstance.interceptors.request.use((config) => {
const newConfig = { ...config };
// Respect an explicitly-provided baseURL (e.g. adminSignInApi pinning the just-resolved
// environment) instead of always re-deriving it from potentially-unavailable storage.
newConfig.baseURL = config.baseURL ?? resolveActiveApiBaseUrl();
return newConfig;
});

export const adminSignInApi = (email: string, password: string): Promise<AxiosResponse<AdminSignInResponse>> => {
assertAdminApiServerUrl();
return authAxiosInstance.post("/auth/email/sign-in", { email, password });
const environment = resolveEnvironmentFromEmail(email);

// 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(environment);

// Pin this request's baseURL directly instead of relying on a localStorage round trip:
// if storage is unavailable, the request still goes to the correctly resolved environment.
return authAxiosInstance.post(
"/auth/email/sign-in",
{ email, password },
{ baseURL: getApiBaseUrlForEnvironment(environment) },
);
};

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, {
Expand Down
10 changes: 2 additions & 8 deletions apps/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from "axios";
import { clearSession, ensureSessionToken, reissueAccessTokenIfPossible } from "@/lib/auth/session";
import { createMissingAdminApiServerUrlError, getAdminApiServerUrl } from "@/lib/env";
import { resolveActiveApiBaseUrl } from "@/lib/env";

const convertToBearer = (token: string) => `Bearer ${token}`;

const API_SERVER_URL = getAdminApiServerUrl();

export const axiosInstance: AxiosInstance = axios.create({
baseURL: API_SERVER_URL || undefined,
withCredentials: true,
});

Expand All @@ -19,11 +16,8 @@ const redirectToLogin = () => {

axiosInstance.interceptors.request.use(
async (config) => {
if (!API_SERVER_URL) {
return Promise.reject(createMissingAdminApiServerUrlError());
}

const newConfig = { ...config };
newConfig.baseURL = resolveActiveApiBaseUrl();
const accessToken = await ensureSessionToken();

if (!accessToken) {
Expand Down
28 changes: 28 additions & 0 deletions apps/admin/src/lib/auth/environment.test.ts
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");
});
});
15 changes: 15 additions & 0 deletions apps/admin/src/lib/auth/environment.ts
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];
8 changes: 7 additions & 1 deletion apps/admin/src/lib/auth/session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { reissueAccessTokenApi } from "@/lib/api/auth";
import { isTokenExpired } from "@/lib/utils/jwtUtils";
import { loadAccessToken, removeAccessToken, saveAccessToken } from "@/lib/utils/localStorage";
import {
loadAccessToken,
removeAccessToken,
removeAdminApiEnvironment,
saveAccessToken,
} from "@/lib/utils/localStorage";

let reissuePromise: Promise<string | null> | null = null;
let sessionVersion = 0;
Expand All @@ -23,6 +28,7 @@ export const clearSession = () => {
sessionVersion += 1;
reissuePromise = null;
removeAccessToken();
removeAdminApiEnvironment();
};

export const reissueAccessTokenIfPossible = async (): Promise<string | null> => {
Expand Down
19 changes: 17 additions & 2 deletions apps/admin/src/lib/env.ts
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");
};
34 changes: 34 additions & 0 deletions apps/admin/src/lib/utils/localStorage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type { AdminApiEnvironment } from "@/lib/auth/environment";

const ADMIN_API_ENVIRONMENT_KEY = "adminApiEnvironment";

export const loadAccessToken = () => {
try {
return localStorage.getItem("accessToken");
Expand All @@ -22,3 +26,33 @@ export const removeAccessToken = () => {
console.error("Could not remove access token", err);
}
};

export const loadAdminApiEnvironment = (): AdminApiEnvironment | null => {
if (typeof window === "undefined") {
return null;
}

try {
const value = localStorage.getItem(ADMIN_API_ENVIRONMENT_KEY);
return value === "dev" || value === "prod" ? value : null;
} catch (err) {
console.error("Could not load admin api environment", err);
return null;
}
};

export const saveAdminApiEnvironment = (environment: AdminApiEnvironment) => {
try {
localStorage.setItem(ADMIN_API_ENVIRONMENT_KEY, environment);
} catch (err) {
console.error("Could not save admin api environment", err);
}
};

export const removeAdminApiEnvironment = () => {
try {
localStorage.removeItem(ADMIN_API_ENVIRONMENT_KEY);
} catch (err) {
console.error("Could not remove admin api environment", err);
}
};
Loading