@@ -33,7 +32,7 @@ export default function LandingPage() {
variant="admin"
title={`동아리 운영에 특화된\n운영 관리 서비스`}
subtitle="기수별 멤버 관리와 정기모임 출석을
하나의 흐름 안에서 운영할 수 있습니다."
- serviceLabel="관리자 서비스"
+ serviceLabel="운영진 서비스"
features={ADMIN_FEATURES}
/>
diff --git a/src/app/api/proxy-v1/[...path]/route.ts b/src/app/api/proxy-v1/[...path]/route.ts
new file mode 100644
index 00000000..10087ba2
--- /dev/null
+++ b/src/app/api/proxy-v1/[...path]/route.ts
@@ -0,0 +1,79 @@
+import { cookies } from 'next/headers';
+import { NextRequest, NextResponse } from 'next/server';
+import { API_V1_BASE_PATH } from '@/constants/api';
+import { ACCESS_TOKEN_KEY } from '@/lib/apis/cookies';
+
+async function handler(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+ let baseUrl: URL;
+ try {
+ baseUrl = new URL(API_V1_BASE_PATH);
+ } catch {
+ return NextResponse.json({ error: 'API URL not configured' }, { status: 500 });
+ }
+
+ const { path } = await params;
+ if (path.some((segment) => segment === '.' || segment === '..')) {
+ return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
+ }
+
+ const encodedPath = path.map((segment) => encodeURIComponent(segment)).join('/');
+ const url = new URL(baseUrl);
+ url.pathname = `${baseUrl.pathname.replace(/\/$/, '')}/${encodedPath}`;
+ url.search = request.nextUrl.search;
+
+ const cookieStore = await cookies();
+ const accessToken = cookieStore.get(ACCESS_TOKEN_KEY)?.value;
+
+ const headers = new Headers(request.headers);
+ headers.delete('cookie');
+ headers.delete('accept-encoding');
+ headers.delete('authorization');
+ headers.set('host', new URL(API_V1_BASE_PATH).host);
+
+ if (accessToken) {
+ headers.set('Authorization', `Bearer ${accessToken}`);
+ }
+
+ const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
+
+ const timeout = AbortSignal.timeout(10_000);
+ const signal = AbortSignal.any([timeout, request.signal]);
+
+ try {
+ const response = await fetch(url.toString(), {
+ method: request.method,
+ headers,
+ body: hasBody ? request.body : undefined,
+ signal,
+ duplex: 'half',
+ } as RequestInit);
+
+ const body = await response.arrayBuffer();
+
+ const responseHeaders = new Headers(response.headers);
+ responseHeaders.delete('transfer-encoding');
+ responseHeaders.delete('content-encoding');
+ responseHeaders.delete('content-length');
+ responseHeaders.delete('set-cookie');
+
+ return new NextResponse(body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ });
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'TimeoutError') {
+ return NextResponse.json({ error: 'Upstream timeout' }, { status: 504 });
+ }
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ return NextResponse.json({ error: 'Request aborted' }, { status: 499 });
+ }
+ return NextResponse.json({ error: 'Upstream unreachable' }, { status: 502 });
+ }
+}
+
+export const GET = handler;
+export const POST = handler;
+export const PUT = handler;
+export const PATCH = handler;
+export const DELETE = handler;
diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts
index 85b3c861..07d5181b 100644
--- a/src/app/api/proxy/[...path]/route.ts
+++ b/src/app/api/proxy/[...path]/route.ts
@@ -17,6 +17,7 @@ async function handler(request: NextRequest, { params }: { params: Promise<{ pat
const headers = new Headers(request.headers);
headers.delete('cookie');
+ headers.delete('accept-encoding');
headers.set('host', new URL(API_BASE_PATH).host);
if (accessToken) {
@@ -35,9 +36,26 @@ async function handler(request: NextRequest, { params }: { params: Promise<{ pat
const responseHeaders = new Headers(response.headers);
responseHeaders.delete('transfer-encoding');
+ responseHeaders.delete('content-encoding');
responseHeaders.delete('set-cookie');
- return new NextResponse(response.body, {
+ const isSSE =
+ request.headers.get('accept')?.includes('text/event-stream') && response.ok && response.body;
+
+ if (isSSE) {
+ responseHeaders.set('content-type', 'text/event-stream');
+ responseHeaders.set('cache-control', 'no-cache');
+
+ return new NextResponse(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: responseHeaders,
+ });
+ }
+
+ const body = await response.arrayBuffer();
+
+ return new NextResponse(body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
diff --git a/src/app/api/proxy/auth/refresh/route.ts b/src/app/api/proxy/auth/refresh/route.ts
index 07585644..6aa946a4 100644
--- a/src/app/api/proxy/auth/refresh/route.ts
+++ b/src/app/api/proxy/auth/refresh/route.ts
@@ -1,5 +1,5 @@
import { cookies } from 'next/headers';
-import { NextResponse } from 'next/server';
+import { NextRequest, NextResponse } from 'next/server';
import { API_BASE_PATH } from '@/constants/api';
import {
ACCESS_TOKEN_KEY,
@@ -7,42 +7,95 @@ import {
ACCESS_COOKIE_OPTIONS,
REFRESH_COOKIE_OPTIONS,
} from '@/lib/apis/cookies';
+import { clearAuthCookies, requestTokenRefreshWithResponse } from '@/lib/apis/refresh';
+
+function buildLoginResponse(appUrl: string, redirectPath?: string) {
+ const loginUrl = new URL('/login', appUrl);
+ if (redirectPath) {
+ loginUrl.searchParams.set('redirect', redirectPath);
+ }
+ return clearAuthCookies(NextResponse.redirect(loginUrl));
+}
export async function POST() {
try {
if (!API_BASE_PATH) {
- return NextResponse.json({ error: 'API URL not configured' }, { status: 500 });
+ return clearAuthCookies(
+ NextResponse.json({ error: 'API URL not configured' }, { status: 500 }),
+ );
}
const cookieStore = await cookies();
const refreshToken = cookieStore.get(REFRESH_TOKEN_KEY)?.value;
if (!refreshToken) {
- return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
+ return clearAuthCookies(NextResponse.json({ error: 'No refresh token' }, { status: 401 }));
}
- const response = await fetch(`${API_BASE_PATH}/users/social/refresh`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization_refresh: `Bearer ${refreshToken}`,
- },
- });
+ const refreshResult = await requestTokenRefreshWithResponse(refreshToken);
- if (!response.ok) {
- cookieStore.delete(ACCESS_TOKEN_KEY);
- cookieStore.delete(REFRESH_TOKEN_KEY);
- return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
+ if (!refreshResult) {
+ return clearAuthCookies(NextResponse.json({ error: 'Refresh failed' }, { status: 401 }));
}
- const json = await response.json();
- const { accessToken: newAccessToken, refreshToken: newRefreshToken } = json.data;
+ if (!refreshResult.tokens) {
+ return clearAuthCookies(NextResponse.json({ error: 'Refresh failed' }, { status: 401 }));
+ }
- cookieStore.set(ACCESS_TOKEN_KEY, newAccessToken, ACCESS_COOKIE_OPTIONS);
- cookieStore.set(REFRESH_TOKEN_KEY, newRefreshToken, REFRESH_COOKIE_OPTIONS);
+ cookieStore.set(ACCESS_TOKEN_KEY, refreshResult.tokens.accessToken, ACCESS_COOKIE_OPTIONS);
+ cookieStore.set(REFRESH_TOKEN_KEY, refreshResult.tokens.refreshToken, REFRESH_COOKIE_OPTIONS);
return NextResponse.json({ success: true });
- } catch {
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+ } catch (error) {
+ console.error('[refresh-route][POST] unexpected error', error);
+ return clearAuthCookies(NextResponse.json({ error: 'Internal server error' }, { status: 500 }));
+ }
+}
+
+export async function GET(request: NextRequest) {
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? request.nextUrl.origin;
+ const redirectPath = request.nextUrl.searchParams.get('redirect');
+ const safeRedirect =
+ redirectPath && redirectPath.startsWith('/') && !redirectPath.startsWith('//')
+ ? redirectPath
+ : '/hub';
+
+ try {
+ if (!API_BASE_PATH) {
+ return buildLoginResponse(appUrl, safeRedirect);
+ }
+
+ const cookieStore = await cookies();
+ const refreshToken = cookieStore.get(REFRESH_TOKEN_KEY)?.value;
+
+ if (!refreshToken) {
+ return buildLoginResponse(appUrl, safeRedirect);
+ }
+
+ const refreshResult = await requestTokenRefreshWithResponse(refreshToken);
+
+ if (!refreshResult) {
+ return buildLoginResponse(appUrl, safeRedirect);
+ }
+
+ if (!refreshResult.tokens) {
+ return buildLoginResponse(appUrl, safeRedirect);
+ }
+
+ const redirectResponse = NextResponse.redirect(new URL(safeRedirect, appUrl));
+ redirectResponse.cookies.set(
+ ACCESS_TOKEN_KEY,
+ refreshResult.tokens.accessToken,
+ ACCESS_COOKIE_OPTIONS,
+ );
+ redirectResponse.cookies.set(
+ REFRESH_TOKEN_KEY,
+ refreshResult.tokens.refreshToken,
+ REFRESH_COOKIE_OPTIONS,
+ );
+ return redirectResponse;
+ } catch (error) {
+ console.error('[refresh-route][GET] unexpected error', error);
+ return buildLoginResponse(appUrl, safeRedirect);
}
}
diff --git a/src/app/globals.css b/src/app/globals.css
index 0928be80..6193f59b 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -106,10 +106,12 @@
--state-error: #ff5857;
--kakao-bg: #fee500;
+ --kakao-text: rgba(0, 0, 0, 0.85);
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
+ --font-weight-black: 900;
--letter-spacing: -0.005em;
--h1-size: 40px;
@@ -120,8 +122,13 @@
--h3-line-height: 30px;
--sub1-size: 18px;
--sub1-line-height: 22px;
+ --sub1-weight: var(--font-weight-semibold);
--sub2-size: 16px;
--sub2-line-height: 20px;
+ --sub2-weight: var(--font-weight-black);
+ --sub3-size: 16px;
+ --sub3-line-height: 20px;
+ --sub3-weight: var(--font-weight-semibold);
--body1-size: 16px;
--body1-line-height: 24px;
--body1-weight: 470;
@@ -161,6 +168,7 @@
--shadow-dialog: 0 10px 40px 0 rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 5px 0 rgba(17, 33, 49, 0.15);
--shadow-lg: 0 10px 30px 0 rgba(17, 33, 49, 0.3);
+ --shadow-weeth: 0 0 5px 0 var(--brand-primary);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
@@ -204,6 +212,9 @@
--sub1-line-height: 20px;
--sub2-size: 14px;
--sub2-line-height: 18px;
+ --sub3-size: 14px;
+ --font-weight: var(--sub3-weight);
+ --sub3-line-height: 18px;
--body1-size: 14px;
--body1-line-height: 22px;
--body1-weight: 500;
@@ -216,8 +227,8 @@
--caption2-line-height: 16px;
--button1-size: 14px;
--button1-line-height: 24px;
- --button2-size: 13px;
- --button2-line-height: 16px;
+ --button2-size: 14px;
+ --button2-line-height: 20px;
}
.dark {
@@ -342,6 +353,42 @@
--color-state-caution: var(--state-caution);
--color-state-error: var(--state-error);
+ --color-neutral-0: var(--neutral-0);
+ --color-neutral-100: var(--neutral-100);
+ --color-neutral-200: var(--neutral-200);
+ --color-neutral-300: var(--neutral-300);
+ --color-neutral-400: var(--neutral-400);
+ --color-neutral-500: var(--neutral-500);
+ --color-neutral-600: var(--neutral-600);
+ --color-neutral-700: var(--neutral-700);
+ --color-neutral-800: var(--neutral-800);
+ --color-neutral-900: var(--neutral-900);
+
+ --color-primary-100: var(--primary-100);
+ --color-primary-200: var(--primary-200);
+ --color-primary-500: var(--primary-500);
+ --color-primary-600: var(--primary-600);
+ --color-primary-700: var(--primary-700);
+ --color-primary-900: var(--primary-900);
+
+ --color-secondary-100: var(--secondary-100);
+ --color-secondary-200: var(--secondary-200);
+ --color-secondary-500: var(--secondary-500);
+ --color-secondary-700: var(--secondary-700);
+ --color-secondary-900: var(--secondary-900);
+
+ --color-purple-100: var(--purple-100);
+ --color-purple-200: var(--purple-200);
+ --color-purple-500: var(--purple-500);
+ --color-purple-700: var(--purple-700);
+ --color-purple-900: var(--purple-900);
+
+ --color-pink-100: var(--pink-100);
+ --color-pink-200: var(--pink-200);
+ --color-pink-500: var(--pink-500);
+ --color-pink-700: var(--pink-700);
+ --color-pink-900: var(--pink-900);
+
--spacing-0: 0px;
--spacing-100: 4px;
--spacing-200: 8px;
@@ -372,6 +419,10 @@
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
+
+ --shadow-dialog: 0 10px 40px 0 rgba(0, 0, 0, 0.4);
+ --shadow-weeth: 0 0 5px 0 var(--brand-primary);
+
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -443,13 +494,20 @@ select:focus {
@utility typo-sub1 {
font-size: var(--sub1-size);
line-height: var(--sub1-line-height);
- font-weight: var(--font-weight-semibold);
+ font-weight: var(--sub1-weight);
letter-spacing: var(--letter-spacing);
}
@utility typo-sub2 {
font-size: var(--sub2-size);
line-height: var(--sub2-line-height);
+ font-weight: var(--sub2-weight);
+ letter-spacing: var(--letter-spacing);
+}
+
+@utility typo-sub3 {
+ font-size: var(--sub3-size);
+ line-height: var(--sub3-line-height);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--letter-spacing);
}
@@ -496,6 +554,57 @@ select:focus {
letter-spacing: var(--letter-spacing);
}
+@utility typo-social {
+ font-family:
+ 'SF Pro',
+ -apple-system,
+ BlinkMacSystemFont,
+ system-ui,
+ sans-serif;
+ font-size: 19px;
+ font-weight: 590;
+ line-height: 24px;
+}
+
+/* ── Markdown / Editor Typography ─────────────────────── */
+
+@utility typo-md-title {
+ font-size: 32px;
+ line-height: 40px;
+ font-weight: var(--font-weight-bold);
+ letter-spacing: var(--letter-spacing);
+}
+
+@utility typo-md-h1 {
+ font-size: 30px;
+ line-height: 38px;
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: var(--letter-spacing);
+}
+
+@utility typo-md-h2 {
+ font-size: 24px;
+ line-height: 32px;
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: var(--letter-spacing);
+}
+
+@utility typo-md-h3 {
+ font-size: 20px;
+ line-height: 28px;
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: var(--letter-spacing);
+}
+
+@utility typo-md-body {
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+ letter-spacing: var(--letter-spacing);
+}
+
+/* ────────────────────────────────────────────────────── */
+
.scrollbar-none {
scrollbar-width: none;
}
@@ -541,15 +650,40 @@ select:focus {
}
.ProseMirror h1 {
- @apply typo-h3 mt-300 mb-100;
+ @apply typo-md-h1;
+}
+
+.ProseMirror h1::after {
+ content: '';
+ display: block;
+ margin-top: 16px;
+ height: 1px;
+ background-color: var(--line);
}
.ProseMirror h2 {
- @apply typo-sub1 mt-300 mb-100;
+ @apply typo-md-h2;
}
.ProseMirror h3 {
- @apply typo-sub2 mt-300 mb-100;
+ @apply typo-md-h3;
+}
+
+.ProseMirror p {
+ @apply typo-md-body;
+}
+
+.ProseMirror hr {
+ margin: 0;
+ border: none;
+ height: 1px;
+ background-color: var(--line);
+}
+
+/* heading·구분선 전후 16px 간격, p+p는 0 */
+.ProseMirror > * + :is(h1, h2, h3, hr),
+.ProseMirror > :is(h1, h2, h3, hr) + * {
+ margin-top: 16px;
}
.ProseMirror ul {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 45e5f84f..e683d8e7 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -65,7 +65,7 @@ export default function RootLayout({
{isProduction && (
diff --git a/src/assets/icons/admin/ic_admin_attendance.svg b/src/assets/icons/admin/ic_admin_attendance.svg
index 837e944e..87f99d3b 100644
--- a/src/assets/icons/admin/ic_admin_attendance.svg
+++ b/src/assets/icons/admin/ic_admin_attendance.svg
@@ -1,4 +1,4 @@
-