-
-
- Bruno API 목록
- setSearch(event.target.value)}
- />
-
- {METHOD_FILTERS.map((method) => {
- const active = methodFilter === method;
- return (
-
- );
- })}
-
-
-
-
- {visibleEndpoints.map((endpoint) => {
- const key = `${endpoint.domain}:${endpoint.name}`;
- const active = key === selectedKey;
-
- return (
-
- );
- })}
-
-
-
-
-
-
-
-
-
요청 빌더
-
-
-
-
-
- {selectedEndpoint ? (
-
-
{selectedEndpoint.definition.method}
-
{selectedEndpoint.definition.path}
-
- ) : (
- 왼쪽에서 API를 선택해주세요.
- )}
-
-
-
-
- Path Params
- Query
- Body
- Headers
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 응답
-
-
- {requestResult ? (
-
- HTTP {requestResult.status}
- {requestResult.durationMs}ms
-
- ) : null}
-
-
- {requestResult ? (
-
-
- Body
- Headers
-
-
-
-
-
-
-
-
- Header
- Value
-
-
-
- {Object.entries(requestResult.headers).map(([key, value]) => (
-
- {key}
- {value}
-
- ))}
-
-
-
-
- ) : (
-
- 요청을 보내면 응답이 여기에 표시됩니다.
+
+
+
+
+ Bruno API 목록
+ setSearch(event.target.value)}
+ />
+
+ {METHOD_FILTERS.map((method) => {
+ const active = methodFilter === method;
+ return (
+
+ );
+ })}
+
+
+
+
+ {visibleEndpoints.map((endpoint) => {
+ const key = `${endpoint.domain}:${endpoint.name}`;
+ const active = key === selectedKey;
+
+ return (
+
+ );
+ })}
-
-
+
+
+
+
+
+
+
+
요청 빌더
+
+
+
+
+
+ {selectedEndpoint ? (
+
+
{selectedEndpoint.definition.method}
+
{selectedEndpoint.definition.path}
+
+ ) : (
+ 왼쪽에서 API를 선택해주세요.
+ )}
+
+
+
+
+ Path Params
+ Query
+ Body
+ Headers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 응답
+
+
+ {requestResult ? (
+
+ HTTP {requestResult.status}
+ {requestResult.durationMs}ms
+
+ ) : null}
+
+
+ {requestResult ? (
+
+
+ Body
+ Headers
+
+
+
+
+
+
+
+
+ Header
+ Value
+
+
+
+ {Object.entries(requestResult.headers).map(([key, value]) => (
+
+ {key}
+ {value}
+
+ ))}
+
+
+
+
+ ) : (
+
+ 요청을 보내면 응답이 여기에 표시됩니다.
+
+ )}
+
+
+
-
+
);
}
diff --git a/apps/admin/src/routes/chat-socket/index.tsx b/apps/admin/src/routes/chat-socket/index.tsx
index 29fdd034..fd92c6b6 100644
--- a/apps/admin/src/routes/chat-socket/index.tsx
+++ b/apps/admin/src/routes/chat-socket/index.tsx
@@ -1,16 +1,16 @@
import { Client, type IMessage, type StompHeaders, type StompSubscription } from "@stomp/stompjs";
import { createFileRoute } from "@tanstack/react-router";
-import { KeyRound, Link2, LogIn, Plug, PlugZap, RefreshCw, Send } from "lucide-react";
+import { Link2, Plug, PlugZap, RefreshCw, Send } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import SockJS from "sockjs-client";
import { toast } from "sonner";
-import { AdminSidebar } from "@/components/layout/AdminSidebar";
+import { AdminLayout } from "@/components/layout/AdminLayout";
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 { adminSignInApi } from "@/lib/api/auth";
-import { loadAccessToken, loadRefreshToken, saveAccessToken, saveRefreshToken } from "@/lib/utils/localStorage";
+import { requireAdminSession } from "@/lib/auth/session";
+import { loadAccessToken } from "@/lib/utils/localStorage";
type ConnectionState = "DISCONNECTED" | "CONNECTING" | "CONNECTED" | "ERROR";
@@ -77,21 +77,6 @@ const parseJsonRecord = (text: string, label: string): Record
=>
const parseJsonBody = (text: string) => JSON.stringify(JSON.parse(text));
-const extractApiErrorMessage = (error: unknown) =>
- error && typeof error === "object" && "response" in error
- ? (error as { response?: { data?: { message?: string } } }).response?.data?.message
- : undefined;
-
-const maskToken = (value: string | null) => {
- if (!value) {
- return "-";
- }
- if (value.length <= 20) {
- return value;
- }
- return `${value.slice(0, 10)}...${value.slice(-10)}`;
-};
-
const maskQueryToken = (value: string) => {
if (!value) {
return "";
@@ -103,6 +88,9 @@ const maskQueryToken = (value: string) => {
};
export const Route = createFileRoute("/chat-socket/")({
+ beforeLoad: async () => {
+ await requireAdminSession();
+ },
component: ChatSocketPage,
});
@@ -113,9 +101,6 @@ function ChatSocketPage() {
const [connectionState, setConnectionState] = useState("DISCONNECTED");
const [serverUrl, setServerUrl] = useState(import.meta.env.VITE_API_SERVER_URL?.trim() ?? "");
const [token, setToken] = useState("");
- const [refreshToken, setRefreshToken] = useState("");
- const [email, setEmail] = useState("");
- const [password, setPassword] = useState("");
const [roomId, setRoomId] = useState("");
const [topicTemplate, setTopicTemplate] = useState(defaultTopicTemplate);
const [destinationTemplate, setDestinationTemplate] = useState(defaultDestinationTemplate);
@@ -124,7 +109,6 @@ function ChatSocketPage() {
const [receivedMessages, setReceivedMessages] = useState([]);
const [eventLogs, setEventLogs] = useState([]);
const [isPending, setIsPending] = useState(false);
- const [isSigningIn, setIsSigningIn] = useState(false);
const socketUrl = useMemo(() => {
const normalized = normalizeBaseUrl(serverUrl);
@@ -149,13 +133,9 @@ function ChatSocketPage() {
useEffect(() => {
const accessToken = loadAccessToken();
- const refreshTokenFromStorage = loadRefreshToken();
if (accessToken) {
setToken(accessToken);
}
- if (refreshTokenFromStorage) {
- setRefreshToken(refreshTokenFromStorage);
- }
}, []);
const deactivateClient = useCallback(
@@ -191,68 +171,33 @@ function ChatSocketPage() {
const handleLoadStoredToken = () => {
const accessToken = loadAccessToken();
- const refreshTokenFromStorage = loadRefreshToken();
setToken(accessToken ?? "");
- setRefreshToken(refreshTokenFromStorage ?? "");
if (accessToken) {
- appendLog("SYSTEM", "저장된 AccessToken/RefreshToken을 불러왔습니다.");
- toast.success("저장된 토큰을 불러왔습니다.");
+ appendLog("SYSTEM", "저장된 AccessToken을 불러왔습니다.");
+ toast.success("저장된 AccessToken을 불러왔습니다.");
return;
}
toast.error("저장된 AccessToken이 없습니다.");
};
- const signInAndStoreTokens = async (nextEmail: string, nextPassword: string) => {
- const response = await adminSignInApi(nextEmail.trim(), nextPassword);
- const nextAccessToken = response.data.accessToken;
- const nextRefreshToken = response.data.refreshToken;
-
- saveAccessToken(nextAccessToken);
- saveRefreshToken(nextRefreshToken);
- setToken(nextAccessToken);
- setRefreshToken(nextRefreshToken);
- return nextAccessToken;
- };
-
- const handleSignIn = async () => {
- if (!email.trim() || !password.trim()) {
- toast.error("이메일/비밀번호를 입력해주세요.");
- return;
- }
-
- setIsSigningIn(true);
- try {
- await signInAndStoreTokens(email, password);
- appendLog("SYSTEM", "로그인 성공: 새 토큰이 반영되었습니다.");
- toast.success("로그인 성공. AccessToken을 갱신했습니다.");
- } catch (error) {
- const message = extractApiErrorMessage(error);
- appendLog("ERROR", `로그인 실패: ${message ?? "이메일/비밀번호를 확인해주세요."}`);
- toast.error(message ?? "로그인에 실패했습니다.");
- } finally {
- setIsSigningIn(false);
- }
- };
-
useEffect(() => {
return () => {
void deactivateClient(false);
};
}, [deactivateClient]);
- const handleConnect = async (tokenOverride?: string) => {
+ const handleConnect = async () => {
if (!roomId.trim()) {
toast.error("Room ID를 입력해주세요.");
return;
}
- const nextToken = tokenOverride ?? token;
const normalizedServerUrl = normalizeBaseUrl(serverUrl);
const nextSocketUrl =
- normalizedServerUrl && nextToken.trim()
- ? `${normalizedServerUrl}/connect?token=${encodeURIComponent(nextToken.trim())}`
+ normalizedServerUrl && token.trim()
+ ? `${normalizedServerUrl}/connect?token=${encodeURIComponent(token.trim())}`
: "";
if (!nextSocketUrl) {
@@ -324,30 +269,6 @@ function ChatSocketPage() {
}
};
- const handleSignInAndConnect = async () => {
- if (!email.trim() || !password.trim()) {
- toast.error("이메일/비밀번호를 입력해주세요.");
- return;
- }
- if (!roomId.trim()) {
- toast.error("Room ID를 입력해주세요.");
- return;
- }
-
- setIsSigningIn(true);
- try {
- const nextAccessToken = await signInAndStoreTokens(email, password);
- appendLog("SYSTEM", "로그인 성공: 새 토큰으로 소켓 연결을 시도합니다.");
- await handleConnect(nextAccessToken);
- } catch (error) {
- const message = extractApiErrorMessage(error);
- appendLog("ERROR", `로그인 실패: ${message ?? "이메일/비밀번호를 확인해주세요."}`);
- toast.error(message ?? "로그인에 실패했습니다.");
- } finally {
- setIsSigningIn(false);
- }
- };
-
const handleSendRaw = () => {
const client = clientRef.current;
if (!client?.connected) {
@@ -387,215 +308,180 @@ function ChatSocketPage() {
};
return (
-
-
-
-
-
-
-
-
- 채팅 소켓 테스트 콘솔
-
-
-
-
즉시 로그인
-
- 이 페이지에서 바로 로그인하면 AccessToken/RefreshToken을 저장하고 즉시 반영합니다.
-
-
- setEmail(event.target.value)}
- />
- setPassword(event.target.value)}
- />
-
-
-
-
-
-
-
-
refresh: {maskToken(refreshToken)}
-
-
-
-
API 서버 URL
-
setServerUrl(event.target.value)} />
-
-
-
-
Room ID
-
setRoomId(event.target.value)} placeholder="예: 123" />
-
-
-
구독 Topic
-
setTopicTemplate(event.target.value)}
- placeholder="/topic/chat/{roomId}"
- />
-
-
-
연결 URL
-
{maskedSocketUrl || "-"}
-
-
-
연결 상태
-
{connectionState}
-
-
-
+
+
+
+
+ 채팅 소켓 테스트 콘솔
+
+
+
+
토큰 동기화
+
+ 로그인은 /auth/login에서 진행하고 저장된 AccessToken을 불러옵니다.
+
+
+
+
+
+
+
API 서버 URL
+
setServerUrl(event.target.value)} />
+
+
+
+
Room ID
+
setRoomId(event.target.value)} placeholder="예: 123" />
+
+
+
구독 Topic
+
setTopicTemplate(event.target.value)}
+ placeholder="/topic/chat/{roomId}"
+ />
+
+
+
연결 URL
+
{maskedSocketUrl || "-"}
+
+
+
연결 상태
+
{connectionState}
+
+
+
+
+
+
+
+
+
+
+
+ 메시지 발행
+
+
+
+ {publishPresets.map((preset) => (
-
-
-
-
-
-
-
- 메시지 발행
-
-
-
- {publishPresets.map((preset) => (
-
- ))}
+ ))}
+
+
+
Destination
+
setDestinationTemplate(event.target.value)}
+ placeholder="/publish/chat/{roomId}"
+ />
+
+
+
Publish Headers(JSON Object)
+
+
+
+
+
+
+
+
+
+ 이벤트 로그
+
+
+
+
+ {eventLogs.length === 0 ? (
+ 로그가 없습니다.
+ ) : (
+ eventLogs.map((eventLog) => (
+
+
+
+
{eventLog.createdAt}
+
{eventLog.type}
+
+
{eventLog.message}
-
-
Destination
-
setDestinationTemplate(event.target.value)}
- placeholder="/publish/chat/{roomId}"
- />
+ ))
+ )}
+
+
+
+
+
+
+ 수신 메시지
+
+
+
+
+ {receivedMessages.length === 0 ? (
+ 수신된 메시지가 없습니다.
+ ) : (
+ receivedMessages.map((item) => (
+
+
{item.receivedAt}
+
topic: {item.destination}
+
headers: {JSON.stringify(item.headers)}
+
-
-
Publish Headers(JSON Object)
-
-
-
-
-
-
-
-
-
- 이벤트 로그
-
-
-
-
- {eventLogs.length === 0 ? (
- 로그가 없습니다.
- ) : (
- eventLogs.map((eventLog) => (
-
-
-
-
{eventLog.createdAt}
-
{eventLog.type}
-
-
{eventLog.message}
-
- ))
- )}
-
-
-
-
-
-
- 수신 메시지
-
-
-
-
- {receivedMessages.length === 0 ? (
- 수신된 메시지가 없습니다.
- ) : (
- receivedMessages.map((item) => (
-
-
{item.receivedAt}
-
topic: {item.destination}
-
headers: {JSON.stringify(item.headers)}
-
-
- ))
- )}
-
-
-
-
-
+ ))
+ )}
+
+
+
-
+
);
}
diff --git a/apps/admin/src/routes/index.tsx b/apps/admin/src/routes/index.tsx
index 45d3c3eb..a3b60572 100644
--- a/apps/admin/src/routes/index.tsx
+++ b/apps/admin/src/routes/index.tsx
@@ -1,7 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
+import { ensureSessionToken } from "@/lib/auth/session";
export const Route = createFileRoute("/")({
- beforeLoad: () => {
- throw redirect({ to: "/scores" });
+ beforeLoad: async () => {
+ const token = await ensureSessionToken();
+ throw redirect({ to: token ? "/scores" : "/auth/login" });
},
});
diff --git a/apps/admin/src/routes/scores/index.tsx b/apps/admin/src/routes/scores/index.tsx
index 5b7bc00b..db69cac0 100644
--- a/apps/admin/src/routes/scores/index.tsx
+++ b/apps/admin/src/routes/scores/index.tsx
@@ -1,21 +1,15 @@
-import { createFileRoute, redirect } from "@tanstack/react-router";
+import { createFileRoute } from "@tanstack/react-router";
import { useId, useState } from "react";
import { GpaScoreTable } from "@/components/features/scores/GpaScoreTable";
import { LanguageScoreTable } from "@/components/features/scores/LanguageScoreTable";
-import { AdminSidebar } from "@/components/layout/AdminSidebar";
+import { AdminLayout } from "@/components/layout/AdminLayout";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { isTokenExpired } from "@/lib/utils/jwtUtils";
-import { loadAccessToken } from "@/lib/utils/localStorage";
+import { requireAdminSession } from "@/lib/auth/session";
import type { VerifyStatus } from "@/types/scores";
export const Route = createFileRoute("/scores/")({
- beforeLoad: () => {
- if (typeof window !== "undefined") {
- const token = loadAccessToken();
- if (!token || isTokenExpired(token)) {
- throw redirect({ to: "/auth/login" });
- }
- }
+ beforeLoad: async () => {
+ await requireAdminSession();
},
component: ScoresPage,
});
@@ -25,62 +19,53 @@ function ScoresPage() {
const verifyFilterId = useId();
return (
-
-
-
-
-
-
-
성적 관리
-
- 원본 어드민 플로우를 기준으로 성적 검수 데이터를 관리합니다.
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
- GPA 성적
-
-
- 어학성적
-
-
+
+
+
+
+ GPA 성적
+
+
+ 어학성적
+
+
-
-
-
+
+
+
-
-
-
-
-
-
-
+
+
+
+
-
+
);
}
diff --git a/apps/admin/src/types/auth.ts b/apps/admin/src/types/auth.ts
index 73034795..46fa8d29 100644
--- a/apps/admin/src/types/auth.ts
+++ b/apps/admin/src/types/auth.ts
@@ -1,6 +1,6 @@
export interface AdminSignInResponse {
accessToken: string;
- refreshToken: string;
+ refreshToken?: string;
}
export interface ReissueAccessTokenResponse {