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
8 changes: 6 additions & 2 deletions apps/admin/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# API 서버 URL
# API 서버 URL (필수)
# 빌드/프리뷰 시 이 값이 없으면 API 호출은 실패하지만 SSR shell은 500 없이 응답해야 합니다.
VITE_API_SERVER_URL=https://api.example.com

# S3 Base URL (성적 인증파일 URL 용)
# 업로드 CDN URL (선택)
VITE_UPLOADED_IMAGE_URL=https://cdn.upload.solid-connection.com

# S3 Base URL (성적 인증파일 URL 용, 선택)
VITE_S3_BASE_URL=https://s3.example.com
21 changes: 17 additions & 4 deletions apps/admin/VINEXT_MIGRATION_REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- `apps/admin`의 TanStack Start 라우팅 구조를 Vinext 호환 App Router 구조로 변경했다.
- `src/routes` 기반 라우트를 `src/app`으로 이동하고, 라우트 파일에 있던 UI는 feature 컴포넌트로 분리했다.
- TanStack Router navigation 의존을 일반 anchor 및 브라우저 이동 흐름으로 교체했다.
- Vinext `dev`, `build`, `start` script를 적용하고 Vite 설정을 Vinext 플러그인 중심으로 정리했다.
- Vinext `dev`, `build`, `start`/`preview` script를 적용하고 Vite 설정을 Vinext/Nitro Vercel output 중심으로 정리했다.
- TanStack Start, TanStack Router, route tree, Nitro 기반 진입점을 제거했다.

## 라우트 매핑
Expand All @@ -28,6 +28,14 @@
- localStorage 기반 admin session 저장소
- login, scores, bruno, chat-socket 사용자 플로우

## SSR 상태

- Vinext build는 RSC, client, SSR environment를 모두 생성하며, `.vercel/output/functions/__server.func/index.mjs`를 통해 각 route의 HTML shell을 서버에서 응답한다.
- 현재 admin 인증은 localStorage의 access token을 기준으로 동작한다. 따라서 `/scores`, `/mentor-applications`, `/regions-countries` 같은 보호 페이지의 실제 테이블 데이터 요청은 서버가 아니라 브라우저에서 세션 확인 후 TanStack Query로 실행된다.
- 즉, 현재 상태는 **route shell SSR + client-side authenticated data fetching**이다. 서버 HTML 응답 단계에서 사용자별 보호 데이터를 미리 채워 넣는 full data SSR은 아직 적용하지 않는다.
- full data SSR을 적용하려면 admin access token 또는 session을 서버가 읽을 수 있는 httpOnly cookie 기반으로 전환하고, server-side API client, React Query dehydration, logout cookie clear 흐름을 함께 설계해야 한다.
- localStorage 인증을 유지한 상태에서 서버가 보호 API를 호출하도록 우회하면 토큰 중복 저장 또는 보안 모델 혼선이 생기므로, 이 변경은 별도 인증 아키텍처 PR로 분리한다.

## 제거한 것

- `RouterProvider`
Expand All @@ -44,13 +52,18 @@
- Vinext build 결과에서 route classification이 `Unknown`으로 표시된다. 현재 Vinext의 정적 분석 한계 안내이며 빌드는 성공한다.
- 현재 로컬 Node가 `v23.10.0`이라 repo 요구사항 `22.x` 경고가 출력된다.
- `@tailwindcss/vite`는 현재 Vite 8 peer range를 공식 포함하지 않아 peer warning이 출력된다. 빌드와 라우트 QA는 통과했다.
- `/`, `/auth/login`, `/scores` 등 API 클라이언트를 import하는 라우트는 `VITE_API_SERVER_URL`이 없으면 기존 로직에 의해 500이 난다. QA는 `.env.example` 기준 예시 값을 주입해 진행했다.
- `/`, `/auth/login`, `/scores` 등 API 클라이언트를 import하는 라우트도 `VITE_API_SERVER_URL` 누락만으로 SSR import 단계에서 500이 나지 않아야 한다. 실제 API 호출 시점에는 명확한 환경변수 오류로 실패한다.
- `vinext build`는 Nitro Vercel preset 기준 `.vercel/output`을 생성하므로, 로컬 production preview는 `vinext start`가 아니라 `.vercel/output/functions/__server.func/index.mjs`를 `srvx`로 실행한다.
- Vercel Project Settings에서 Root Directory를 `apps/admin`으로 잡는 경우 output directory는 비워두고, Build Command는 `pnpm build`를 사용한다. `.vercel/output`은 Vercel Build Output API 경로라서 별도 Output Directory로 지정하지 않는다.

## 검증 결과

- dev: `VITE_API_SERVER_URL=https://api.example.com VITE_S3_BASE_URL=https://s3.example.com pnpm --filter @solid-connect/admin dev` 성공
- build: `pnpm --filter @solid-connect/admin build` 성공
- preview/start: `pnpm --filter @solid-connect/admin start`로 `.vercel/output` SSR preview 성공
- lint: `pnpm --filter @solid-connect/admin lint:check` 성공
- typecheck: `pnpm --filter @solid-connect/admin typecheck` 성공
- compatibility: `pnpm --filter @solid-connect/admin exec vinext check` 100% compatible
- 주요 페이지 수동 QA: `/`, `/login`, `/auth/login`, `/scores`, `/bruno`, `/chat-socket` 모두 HTTP 200 확인
- test: `pnpm --filter @solid-connect/admin test` 성공
- compatibility: `pnpm --filter @solid-connect/admin exec vinext check` 93% compatible
- partial: `next/font/google` 항목은 Vinext가 CDN font 로딩으로 분류한다. 현재 admin source에서 직접 사용 중인 `next/font/google` import는 없다.
- 주요 페이지 수동 QA: `/`, `/login`, `/auth/login`, `/scores`, `/bruno`, `/chat-socket`, `/regions-countries`, `/mentor-applications` 모두 HTTP 200 확인
6 changes: 4 additions & 2 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
"scripts": {
"dev": "vinext dev -p 4000",
"build": "vinext build",
"start": "vinext start -p 4000",
"test": "vitest run",
"start": "pnpm run preview",
"preview": "srvx --prod --host 0.0.0.0 --port 4000 --static \"$PWD/.vercel/output/static\" \"$PWD/.vercel/output/functions/__server.func/index.mjs\"",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

1) preview 스크립트의 경로 지정은 셸 의존성을 줄이는 쪽이 안전합니다.

Line 9의 $PWD는 실행 셸/플랫폼에 따라 깨질 수 있어서, 상대경로(.vercel/output/...)로 바로 넘기거나 Node 기반 경로 해석으로 통일하는 편이 더 안정적입니다.

변경 예시
- "preview": "srvx --prod --host 0.0.0.0 --port 4000 --static \"$PWD/.vercel/output/static\" \"$PWD/.vercel/output/functions/__server.func/index.mjs\"",
+ "preview": "srvx --prod --host 0.0.0.0 --port 4000 --static .vercel/output/static .vercel/output/functions/__server.func/index.mjs",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"preview": "srvx --prod --host 0.0.0.0 --port 4000 --static \"$PWD/.vercel/output/static\" \"$PWD/.vercel/output/functions/__server.func/index.mjs\"",
"preview": "srvx --prod --host 0.0.0.0 --port 4000 --static .vercel/output/static .vercel/output/functions/__server.func/index.mjs",
🤖 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/package.json` at line 9, The preview script in package.json uses
$PWD which is shell-dependent and can break across platforms; update the
"preview" script (the preview entry in package.json) to avoid $PWD by passing
relative paths (e.g. .vercel/output/static and
.vercel/output/functions/__server.func/index.mjs) or switch to a Node-based
resolution approach (resolve with path.join in a small Node launcher script) so
the command no longer relies on shell-specific $PWD expansion.

"test": "vitest run --config vitest.config.ts",
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "biome check --write .",
Expand Down Expand Up @@ -49,6 +50,7 @@
"jsdom": "^27.0.0",
"nitro": "3.0.260522-beta",
"react-server-dom-webpack": "^19.2.6",
"srvx": "^0.11.15",
"typescript": "^5.7.2",
"vinext": "^0.0.51",
"vite": "^8.0.14",
Expand Down
4 changes: 2 additions & 2 deletions apps/admin/src/components/features/auth/AdminLoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ export function AdminLoginPage({ onLoginSuccess }: AdminLoginPageProps) {

onLoginSuccess();
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } };
const error = err as { message?: string; response?: { data?: { message?: string } } };
toast.error("로그인 실패", {
description: error.response?.data?.message || "로그인에 실패했습니다.",
description: error.response?.data?.message || error.message || "로그인에 실패했습니다.",
});
}
};
Expand Down
29 changes: 19 additions & 10 deletions apps/admin/src/lib/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import axios, { type AxiosResponse } from "axios";
import { createMissingAdminApiServerUrlError, getAdminApiServerUrl } from "@/lib/env";
import { loadAccessToken } from "@/lib/utils/localStorage";
import type { AdminSignInResponse, ReissueAccessTokenResponse } from "@/types/auth";

const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim();

if (!API_SERVER_URL) {
throw new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment.");
}
const API_SERVER_URL = getAdminApiServerUrl();

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

export const adminSignInApi = (email: string, password: string): Promise<AxiosResponse<AdminSignInResponse>> =>
authAxiosInstance.post("/auth/email/sign-in", { email, password });
const assertAdminApiServerUrl = () => {
if (!API_SERVER_URL) {
throw createMissingAdminApiServerUrlError();
}
};

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

export const reissueAccessTokenApi = (): Promise<AxiosResponse<ReissueAccessTokenResponse>> =>
authAxiosInstance.post("/auth/reissue");
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
13 changes: 7 additions & 6 deletions apps/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from "axios";
import { clearSession, ensureSessionToken, reissueAccessTokenIfPossible } from "@/lib/auth/session";
import { createMissingAdminApiServerUrlError, getAdminApiServerUrl } from "@/lib/env";

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

const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim();

if (!API_SERVER_URL) {
throw new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment.");
}
const API_SERVER_URL = getAdminApiServerUrl();

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

Expand All @@ -22,6 +19,10 @@ const redirectToLogin = () => {

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

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

Expand Down
6 changes: 6 additions & 0 deletions apps/admin/src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const getTrimmedEnv = (key: string) => import.meta.env[key]?.trim() ?? "";

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.");
14 changes: 14 additions & 0 deletions apps/admin/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vitest/config";

export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
environment: "jsdom",
passWithNoTests: true,
},
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading