Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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>
);
}
27 changes: 13 additions & 14 deletions apps/admin/src/lib/api/auth.ts
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 });

Copy link
Copy Markdown

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의 saveAdminApiEnvironmentlocalStorage 오류를 내부에서 처리하고 성공 여부를 반환하지 않습니다. 저장이 실패하면 요청 인터셉터는 환경을 읽지 못하고 production URL을 선택합니다. 그러면 dev 계정의 이메일과 비밀번호가 production API로 전송됩니다.

saveAdminApiEnvironment가 성공 여부를 반환하게 하십시오. 저장에 실패하면 Line 23의 로그인 요청을 보내지 마십시오. localStorage.setItem이 예외를 던질 때 요청이 전송되지 않는 회귀 테스트도 추가하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/admin/src/lib/api/auth.ts` around lines 21 - 23, Update
saveAdminApiEnvironment to return whether localStorage persistence succeeded,
then update the sign-in flow around saveAdminApiEnvironment in the auth request
to abort before authAxiosInstance.post when saving the resolved environment
fails. Preserve the existing token removal and successful-login request
behavior, and add a regression test confirming localStorage.setItem failures
prevent the request from being sent.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Route sign-in independently of fallible storage

When localStorage is unavailable (for example, browser storage is disabled), saveAdminApiEnvironment catches and suppresses the write failure, after which the request interceptor reads no stored environment and defaults to production. A @dev.solid-connection.com login therefore sends the credentials to the production API instead of the stage API; the resolved environment should be applied directly to this sign-in request rather than relying on a successful storage round trip.

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, {
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