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
9 changes: 6 additions & 3 deletions docs/mpa-school-link-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@

## 호출 시퀀스 (목표 상태)

```
```text
앱 (ac/re 보유, cchaksa_session 없음)
├─ ① POST /api/session
│ body: { accessToken, refreshToken, isPortalLinked }
│ body: { accessToken, refreshToken }
│ (isPortalLinked 는 서버가 항상 false 로 강제. 요청 바디에서 받지 않음)
│ └─ BFF: sealData → Set-Cookie: cchaksa_session
├─ ② WebView open /mpa/resync/login
Expand Down Expand Up @@ -83,6 +84,7 @@ Auth0 의 *Native to Web SSO* 가 사용하는 패턴. 앱이 ac/re 토큰 자
iOS 14+ 의 ITP(Intelligent Tracking Prevention) 가 app-bound 로 등록되지 않은 도메인의 쿠키를 강등시킨다. 우리가 발급하는 `cchaksa_session` 의 30일 maxAge 가 사실상 무력화되어 webview 진입마다 재익스체인지가 필요해진다.

`Info.plist`:

```xml
<key>WKAppBoundDomains</key>
<array>
Expand Down Expand Up @@ -126,7 +128,8 @@ iOS 14+ 의 ITP(Intelligent Tracking Prevention) 가 app-bound 로 등록되지
- 이후 앱의 ac/re 토큰이 회전될 때마다 다시 호출 (선택, webview 가 살아있는 동안 401 방지)

호출 예시:
```

```http
POST https://cchaksa.com/api/session
Content-Type: application/json

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"swagger-typescript-api": "^13.1.1",
"typescript": "5.6.3",
"vitest": "^3.0.5",
"wrangler": "^4"
"wrangler": "^4.59.1"
},
"packageManager": "yarn@4.5.0",
"resolutions": {
Expand Down
4 changes: 2 additions & 2 deletions src/app/(funnel)/complete/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ROUTES } from '@/constants/routes';
import { useInternalRouter } from '@/hooks/useInternalRouter';
import { getSemesterInfo } from '@/lib/utils/semester';
import { FunnelHeadline } from '../components';
import { useStudentInfo } from '../contexts';
import { useFunnelContext } from '../contexts';
import styles from './page.module.scss';

function InfoRow({ label, value }: { label: string; value: string }) {
Expand All @@ -19,7 +19,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
}

export default function Complete() {
const { studentInfo } = useStudentInfo();
const { studentInfo } = useFunnelContext();
const router = useInternalRouter();
const handleNext = () => {
router.push(`${ROUTES.FUNNEL.TARGET_SCORE}`);
Expand Down
8 changes: 0 additions & 8 deletions src/app/(funnel)/contexts/FunnelContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,6 @@ export function FunnelProvider({ children }: { children: ReactNode }) {
);
}

export function useStudentInfo() {
const context = useContext(FunnelContext);
if (!context) {
throw new Error('useStudentInfo must be used within a FunnelProvider');
}
return context;
}

export function useFunnelContext() {
const context = useContext(FunnelContext);
if (!context) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/(funnel)/contexts/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { FunnelProvider, useStudentInfo, useFunnelContext } from './FunnelContext';
export { FunnelProvider, useFunnelContext } from './FunnelContext';
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ export function PortalLoginForm({ onSuccess, onError }: PortalLoginFormProps) {
setJobId(newJobId);
onSuccess();
} else {
setErrorMessage('연동 요청에 실패했습니다. 다시 시도해주세요.');
const fallbackError = new Error('연동 요청에 실패했습니다. 다시 시도해주세요.');
setErrorMessage(fallbackError.message);
onError?.(fallbackError);
}
} catch (err: any) {
} catch (err: unknown) {
console.error('[PortalLoginForm] 연동 요청 에러', err);
const message = err?.message ?? '알 수 없는 오류가 발생했어요\n잠시후 다시 시도해주세요';
const errorInstance = err instanceof Error ? err : new Error(String(err));
const message = errorInstance.message || '알 수 없는 오류가 발생했어요\n잠시후 다시 시도해주세요';
setErrorMessage(message);
onError?.(err as Error);
onError?.(errorInstance);
}
};

Expand Down
7 changes: 6 additions & 1 deletion src/app/(funnel)/scraping/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { setUser } from '@sentry/nextjs';
import { ROUTES } from '@/constants/routes';
import { useInternalRouter } from '@/hooks/useInternalRouter';
Expand All @@ -19,9 +19,14 @@ export default function ScrapingPage() {
const jobDetail = jobStatusData?.data;

const { data: summaryData } = usePortalLinkSummary(jobId, jobStatus);
const handledRef = useRef(false);

useEffect(() => {
if (handledRef.current) {
return;
}
if (summaryData?.data?.studentInfo) {
handledRef.current = true;
setStudentInfo(summaryData.data.studentInfo);
if (jobId) {
setUser({ id: jobId });
Expand Down
14 changes: 12 additions & 2 deletions src/app/(mpa)/mpa/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,24 @@ import {
SyncUpdateButton,
} from '@/features/dashboard/components';
import { useRefreshProfileOnVisible } from '@/features/dashboard/hooks/useRefreshProfileOnVisible';
import { useInternalRouter } from '@/hooks/useInternalRouter';
import { navigateNative } from '@/lib/webview';
import AsyncBoundary from '@/shared/components/AsyncBoundary';

const MpaHome = () => {
useRefreshProfileOnVisible();
const router = useInternalRouter();

const goGraduation = () => navigateNative(ROUTES.MPA.GRADUATION_PROGRESS);
const goResync = () => navigateNative(ROUTES.MPA.RESYNC_LOGIN);
const goGraduation = () => {
if (!navigateNative(ROUTES.MPA.GRADUATION_PROGRESS)) {
router.push(ROUTES.MPA.GRADUATION_PROGRESS);
}
};
const goResync = () => {
if (!navigateNative(ROUTES.MPA.RESYNC_LOGIN)) {
router.push(ROUTES.MPA.RESYNC_LOGIN);
}
};

return (
<>
Expand Down
15 changes: 8 additions & 7 deletions src/app/(mpa)/mpa/resync/scraping/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ export default function MpaScrapingPage() {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const router = useInternalRouter();

const [jobId] = useState<string | null>(() => {
if (typeof window !== 'undefined') {
return sessionStorage.getItem(RESYNC_JOB_ID_KEY);
}
return null;
});
const [jobId, setJobId] = useState<string | null>(null);
const [isJobIdResolved, setIsJobIdResolved] = useState(false);

useEffect(() => {
setJobId(sessionStorage.getItem(RESYNC_JOB_ID_KEY));
setIsJobIdResolved(true);
}, []);

const { data: jobStatusData, isTimedOut } = usePortalLinkJobPolling(jobId);
const jobStatus = jobStatusData?.data?.status;
Expand Down Expand Up @@ -64,7 +65,7 @@ export default function MpaScrapingPage() {
router.push(ROUTES.MPA.RESYNC_LOGIN);
};

if (!jobId) {
if (isJobIdResolved && !jobId) {
return (
<div className={errorStyles.container}>
<ErrorScreen errorMessage={MISSING_JOB_MESSAGE} />
Expand Down
14 changes: 13 additions & 1 deletion src/app/api/session/refresh/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,24 @@ import type { RefreshTokenApiResponse } from '@/shared/api/data-contracts';

export const dynamic = 'force-dynamic';

const REFRESH_TIMEOUT_MS = 10_000;

export async function POST() {
const session = await getSession();

if (!session.refreshToken) {
return NextResponse.json({ error: 'NO_REFRESH_TOKEN' }, { status: 401 });
}

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS);

try {
const response = await fetch(`${getApiBaseUrl()}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: session.refreshToken }),
signal: controller.signal,
});

if (!response.ok) {
Expand All @@ -37,8 +43,14 @@ export async function POST() {

return NextResponse.json({ accessToken: payload.data.accessToken });
} catch (error) {
console.error('[session/refresh] unexpected error', error);
session.destroy();
if (error instanceof DOMException && error.name === 'AbortError') {
console.error('[session/refresh] timed out after', REFRESH_TIMEOUT_MS, 'ms');
return NextResponse.json({ error: 'REFRESH_TIMEOUT' }, { status: 504 });
}
console.error('[session/refresh] unexpected error', error);
return NextResponse.json({ error: 'REFRESH_ERROR' }, { status: 500 });
} finally {
clearTimeout(timeoutId);
}
}
2 changes: 1 addition & 1 deletion src/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function GET(request: Request) {
const idToken = await getKakaoToken(code, redirectUri);
const { accessToken, refreshToken, isPortalLinked } = await authService.login(idToken, nonce, 'KAKAO');

if (isPortalLinked === undefined || !refreshToken) {
if (typeof isPortalLinked !== 'boolean' || !refreshToken) {
throw new AuthError('User is missing or malformed.');
}

Expand Down
5 changes: 3 additions & 2 deletions src/app/auth/success/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { type RoutePath, useInternalRouter } from '@/hooks/useInternalRouter';
import styles from './success.module.scss';

const SuccessContent = () => {
const router = useInternalRouter();
Expand All @@ -28,12 +29,12 @@ const SuccessContent = () => {
hydrate();
}, [router, searchParams]);

return <div className="flex items-center justify-center h-screen"></div>;
return <div className={styles.container} />;
};

const SuccessPage = () => {
return (
<Suspense fallback={<div className="flex items-center justify-center h-screen"></div>}>
<Suspense fallback={<div className={styles.container} />}>
<SuccessContent />
</Suspense>
);
Expand Down
6 changes: 6 additions & 0 deletions src/app/auth/success/success.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
15 changes: 8 additions & 7 deletions src/app/resync/scraping/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ export default function ScrapingPage() {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const router = useInternalRouter();

const [jobId] = useState<string | null>(() => {
if (typeof window !== 'undefined') {
return sessionStorage.getItem(RESYNC_JOB_ID_KEY);
}
return null;
});
const [jobId, setJobId] = useState<string | null>(null);
const [isJobIdResolved, setIsJobIdResolved] = useState(false);

useEffect(() => {
setJobId(sessionStorage.getItem(RESYNC_JOB_ID_KEY));
setIsJobIdResolved(true);
}, []);

const { data: jobStatusData, isTimedOut } = usePortalLinkJobPolling(jobId);
const jobStatus = jobStatusData?.data?.status;
Expand Down Expand Up @@ -48,7 +49,7 @@ export default function ScrapingPage() {
throw new Error(errorMessage);
}

if (!jobId) {
if (isJobIdResolved && !jobId) {
throw new Error('연동 정보를 찾을 수 없습니다. 다시 로그인해주세요.');
}

Expand Down
2 changes: 1 addition & 1 deletion src/app/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import styles from '../privacy-policy/PrivacyPolicy.module.scss';
import styles from '@/app/privacy-policy/PrivacyPolicy.module.scss';

export default function TermsPage() {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const SyncUpdateButton = ({ onNavigate }: SyncUpdateButtonProps = {}) => {

return (
<button className={clsx(styles.container, styles.text, '--body-sm-medium')} onClick={handleResyncLogin}>
{formattedLastSyncedAt} 업데이트
{formattedLastSyncedAt ? `${formattedLastSyncedAt} 업데이트` : '정보 업데이트'}
<Icon name="refresh" size={16} />
</button>
);
Expand Down
24 changes: 18 additions & 6 deletions src/features/portal-link/hooks/usePortalLinkJobPolling.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ENV } from '@/config/environment';
import { getJobStatus } from '../services/portalLinkService';

const POLLING_INTERVAL_MS = 2000;

export function usePortalLinkJobPolling(jobId: string | null) {
const startedAtRef = useRef<number | null>(null);
const [isTimedOut, setIsTimedOut] = useState(false);

useEffect(() => {
setIsTimedOut(false);
startedAtRef.current = jobId ? Date.now() : null;
if (!jobId) {
return;
}

const timeoutId = setTimeout(() => {
setIsTimedOut(true);
}, ENV.PORTAL_LINK_TIMEOUT_MS);

return () => clearTimeout(timeoutId);
}, [jobId]);

const query = useQuery({
Expand All @@ -23,13 +30,18 @@ export function usePortalLinkJobPolling(jobId: string | null) {
if (status === 'succeeded' || status === 'failed') {
return false;
}
if (startedAtRef.current && Date.now() - startedAtRef.current >= ENV.PORTAL_LINK_TIMEOUT_MS) {
setIsTimedOut(true);
if (isTimedOut) {
return false;
}
return POLLING_INTERVAL_MS;
},
});

return { data: query.data, isTimedOut };
return {
data: query.data,
isTimedOut,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
};
}
7 changes: 6 additions & 1 deletion src/features/portal-link/hooks/usePortalLinkSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { getJobSummary } from '../services/portalLinkService';
export function usePortalLinkSummary(jobId: string | null, jobStatus: string | undefined) {
return useQuery({
queryKey: ['portal-link-summary', jobId],
queryFn: () => getJobSummary(jobId!),
queryFn: () => {
if (!jobId) {
throw new Error('jobId is required to fetch portal link summary');
}
return getJobSummary(jobId);
},
enabled: Boolean(jobId) && jobStatus === 'succeeded',
});
}
Loading