From 8509d4c639354bbc7869434e34f55ad483f24ef1 Mon Sep 17 00:00:00 2001 From: ahcgnoej Date: Sun, 24 May 2026 15:50:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[ADD/#107]=20=ED=95=99=EC=83=9D=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20API=20=EC=97=B0=EB=8F=99=20(?= =?UTF-8?q?=EC=9C=A0=EC=84=B8=EC=9D=B8=ED=8A=B8=20=EC=9D=B8=EC=A6=9D,=20?= =?UTF-8?q?=ED=95=99=EC=83=9D=20=EA=B0=80=EC=9E=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 + .../api/useSSUAuthMutation.ts | 8 + .../signup-user-flow/api/useSignupMutation.ts | 8 + .../model/useSignupFlowController.ts | 148 +++++++++++++++- .../model/useSignupStepActions.ts | 4 +- .../model/useSignupUserFlow.ts | 12 ++ .../ui/components/USaintAuthWebViewModal.tsx | 68 ++++++++ .../api/_generated/auth/signupStudent.ts | 162 ++++++++++++++++++ src/shared/api/_generated/auth/ssuAuth.ts | 80 +++++++++ src/shared/api/index.ts | 20 +++ src/shared/api/interceptors.ts | 22 ++- src/shared/config/env.ts | 3 + src/shared/types/react-native-webview.d.ts | 21 +++ .../ui/SignupUserFlowWidget.tsx | 11 +- yarn.lock | 10 +- 15 files changed, 569 insertions(+), 9 deletions(-) create mode 100644 src/features/signup-user-flow/api/useSSUAuthMutation.ts create mode 100644 src/features/signup-user-flow/api/useSignupMutation.ts create mode 100644 src/features/signup-user-flow/ui/components/USaintAuthWebViewModal.tsx create mode 100644 src/shared/api/_generated/auth/signupStudent.ts create mode 100644 src/shared/api/_generated/auth/ssuAuth.ts create mode 100644 src/shared/types/react-native-webview.d.ts diff --git a/package.json b/package.json index d5abb09..3d30182 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "react-native-svg": "15.12.1", "react-native-view-shot": "^4.0.3", "react-native-web": "~0.21.0", + "react-native-webview": "13.15.0", "react-native-worklets": "0.5.1", "zod": "^4.3.6", "zustand": "^5.0.11" diff --git a/src/features/signup-user-flow/api/useSSUAuthMutation.ts b/src/features/signup-user-flow/api/useSSUAuthMutation.ts new file mode 100644 index 0000000..0c300d9 --- /dev/null +++ b/src/features/signup-user-flow/api/useSSUAuthMutation.ts @@ -0,0 +1,8 @@ +import { useMutation } from "@tanstack/react-query"; +import { getSsuAuthApi } from "@/shared/api"; + +const { ssuAuth } = getSsuAuthApi(); + +export function useSSUAuthMutation() { + return useMutation({ mutationFn: ssuAuth }); +} diff --git a/src/features/signup-user-flow/api/useSignupMutation.ts b/src/features/signup-user-flow/api/useSignupMutation.ts new file mode 100644 index 0000000..e434860 --- /dev/null +++ b/src/features/signup-user-flow/api/useSignupMutation.ts @@ -0,0 +1,8 @@ +import { useMutation } from "@tanstack/react-query"; +import { getSignupStudentApi } from "@/shared/api"; + +const { signupStudent } = getSignupStudentApi(); + +export function useSignupMutation() { + return useMutation({ mutationFn: signupStudent }); +} diff --git a/src/features/signup-user-flow/model/useSignupFlowController.ts b/src/features/signup-user-flow/model/useSignupFlowController.ts index f8869aa..1fcefce 100644 --- a/src/features/signup-user-flow/model/useSignupFlowController.ts +++ b/src/features/signup-user-flow/model/useSignupFlowController.ts @@ -1,11 +1,17 @@ import { router } from "expo-router"; -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { Alert } from "react-native"; +import { useSignupMutation } from "@/features/signup-user-flow/api/useSignupMutation"; +import { useSSUAuthMutation } from "@/features/signup-user-flow/api/useSSUAuthMutation"; +import { StudentTokenAuthPayloadDTOUniversity } from "@/shared/api"; +import { ENV } from "@/shared/config/env"; import type { SignupFlowUiContextValue } from "./flowUiContext"; import { useSignupFlowPresentation } from "./useSignupFlowPresentation"; import { useSignupOverlays } from "./useSignupOverlays"; import { useSignupStepActions } from "./useSignupStepActions"; export function useSignupFlowController() { + const FORCE_PHONE_VERIFICATION_BYPASS = false; const { step, form, @@ -19,8 +25,12 @@ export function useSignupFlowController() { setPhone, setVerificationCode, sendVerificationCode, + setIdentityVerified, setRole, setSchool, + setStudentMajor, + setStudentId, + setProfileName, setPartnerEmail, setPartnerPassword, setAdminEmail, @@ -45,11 +55,117 @@ export function useSignupFlowController() { completeDisplayName, agreementHandlers, } = useSignupFlowPresentation(); + const [studentAuthPayload, setStudentAuthPayload] = useState<{ + sIdno: string; + sToken: string; + } | null>(null); + const [isStudentAuthWebViewVisible, setStudentAuthWebViewVisible] = + useState(false); + const ssuAuthMutation = useSSUAuthMutation(); + const signupStudentMutation = useSignupMutation(); + const verifyStudentWithSsu = useCallback( + async ({ sIdno, sToken }: { sIdno: string; sToken: string }) => { + try { + const response = await ssuAuthMutation.mutateAsync({ sIdno, sToken }); + const result = response.result; + if (!response.isSuccess || !result) { + Alert.alert( + "인증 실패", + response.message ?? "유세인트 인증에 실패했습니다.", + ); + return; + } + + setStudentAuthPayload({ sIdno, sToken }); + if (result.studentNumber) { + setStudentId(result.studentNumber); + } + if (result.majorStr) { + setStudentMajor(result.majorStr); + } + if (result.name) { + setProfileName(result.name); + } + + setStudentAuthWebViewVisible(false); + goTo("studentInput2"); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "유세인트 인증에 실패했습니다."; + Alert.alert("인증 실패", message); + } + }, + [goTo, setProfileName, setStudentId, setStudentMajor, ssuAuthMutation], + ); + + const handleStudentSsuVerify = useCallback(async () => { + if (ENV.SSU_TEST_SIDNO && ENV.SSU_TEST_STOKEN) { + await verifyStudentWithSsu({ + sIdno: ENV.SSU_TEST_SIDNO, + sToken: ENV.SSU_TEST_STOKEN, + }); + return; + } + + setStudentAuthWebViewVisible(true); + }, [verifyStudentWithSsu]); + + const handleStudentWebViewVerified = useCallback( + async (payload: { sIdno: string; sToken: string }) => { + await verifyStudentWithSsu(payload); + }, + [verifyStudentWithSsu], + ); + + const handleStudentSignup = useCallback(async () => { + if (!studentAuthPayload) { + Alert.alert("인증 필요", "먼저 LMS 인증을 진행해주세요."); + return; + } + + try { + const response = await signupStudentMutation.mutateAsync({ + locationAgree: form.agreements.agreePrivacy, + marketingAgree: form.agreements.agreeMarketing, + studentTokenAuth: { + sIdno: studentAuthPayload.sIdno, + sToken: studentAuthPayload.sToken, + university: StudentTokenAuthPayloadDTOUniversity.SSU, + }, + }); + + if (!response.isSuccess) { + Alert.alert( + "회원가입 실패", + response.message ?? "회원가입에 실패했습니다.", + ); + return; + } + + goNext(); + } catch (error) { + const message = + error instanceof Error ? error.message : "회원가입에 실패했습니다."; + Alert.alert("회원가입 실패", message); + } + }, [ + form.agreements.agreeMarketing, + form.agreements.agreePrivacy, + goNext, + signupStudentMutation, + studentAuthPayload, + ]); + + const sendIdentityVerificationCode = () => { + sendVerificationCode(); + }; const overlays = useSignupOverlays({ adminOfficeAddressId: form.admin.officeAddressId, partnerOfficeAddressId: form.partner.officeAddressId, - onSendVerificationCode: sendVerificationCode, + onSendVerificationCode: sendIdentityVerificationCode, onSelectAdminOfficeAddress: setAdminOfficeAddress, onSelectPartnerOfficeAddress: setPartnerOfficeAddress, }); @@ -65,11 +181,12 @@ export function useSignupFlowController() { identity: { setPhone, setVerificationCode, - sendVerificationCode, + sendVerificationCode: sendIdentityVerificationCode, }, student: { setRole, setSchool, + onPressStudentVerify: handleStudentSsuVerify, }, partner: { setPartnerEmail, @@ -110,16 +227,33 @@ export function useSignupFlowController() { onBottomButtonPress: step === "complete" ? () => router.replace("/(protected)/(student)/(tabs)/home" as never) - : goNext, + : async () => { + if (step === "identity") { + if (FORCE_PHONE_VERIFICATION_BYPASS) { + setIdentityVerified(); + goNext(); + return; + } + } + + if (step === "studentInput3") { + await handleStudentSignup(); + return; + } + + goNext(); + }, }), [ buttonLabel, currentProgressIndex, goNext, goTo, + handleStudentSignup, isBottomDisabled, progress, progressSteps, + setIdentityVerified, showBottomButton, showProgress, step, @@ -152,5 +286,11 @@ export function useSignupFlowController() { actions: stepContentActions, } satisfies SignupFlowUiContextValue, overlays, + studentAuthWebView: { + visible: isStudentAuthWebViewVisible, + loginUrl: ENV.SSU_LOGIN_URL, + close: () => setStudentAuthWebViewVisible(false), + onVerifySuccess: handleStudentWebViewVerified, + }, }; } diff --git a/src/features/signup-user-flow/model/useSignupStepActions.ts b/src/features/signup-user-flow/model/useSignupStepActions.ts index 4e6b93c..abf6194 100644 --- a/src/features/signup-user-flow/model/useSignupStepActions.ts +++ b/src/features/signup-user-flow/model/useSignupStepActions.ts @@ -18,6 +18,7 @@ type UseSignupStepActionsParams = { student: { setRole: (value: UserType) => void; setSchool: (value: "숭실대학교") => void; + onPressStudentVerify?: () => void; }; partner: { setPartnerEmail: (value: string) => void; @@ -65,7 +66,8 @@ export function useSignupStepActions({ student: { onSelectRole: student.setRole, onSelectSchool: student.setSchool, - onPressStudentVerify: () => goTo("studentInput2"), + onPressStudentVerify: + student.onPressStudentVerify ?? (() => goTo("studentInput2")), }, partner: { onChangePartnerEmail: partner.setPartnerEmail, diff --git a/src/features/signup-user-flow/model/useSignupUserFlow.ts b/src/features/signup-user-flow/model/useSignupUserFlow.ts index b6dd12a..2431855 100644 --- a/src/features/signup-user-flow/model/useSignupUserFlow.ts +++ b/src/features/signup-user-flow/model/useSignupUserFlow.ts @@ -171,6 +171,14 @@ export function useSignupUserFlow() { })); const setSchool = (school: SignupSchool) => setSectionField("student", "school", school); + const setStudentMajor = (major: string) => + setSectionField("student", "major", major); + const setStudentId = (studentId: string) => + setSectionField("student", "studentId", studentId); + const setProfileName = (name: string) => + setSectionField("profile", "name", name); + const setIdentityVerified = () => + setSectionField("identity", "verificationCode", VERIFICATION_SUCCESS_CODE); const setPartnerEmail = (email: string) => setSectionField("partner", "email", email); @@ -261,6 +269,10 @@ export function useSignupUserFlow() { sendVerificationCode, setRole, setSchool, + setStudentMajor, + setStudentId, + setProfileName, + setIdentityVerified, setPartnerEmail, setPartnerPassword, setAdminEmail, diff --git a/src/features/signup-user-flow/ui/components/USaintAuthWebViewModal.tsx b/src/features/signup-user-flow/ui/components/USaintAuthWebViewModal.tsx new file mode 100644 index 0000000..c81a4f6 --- /dev/null +++ b/src/features/signup-user-flow/ui/components/USaintAuthWebViewModal.tsx @@ -0,0 +1,68 @@ +import { useRef } from "react"; +import { Modal, Pressable, Text, View } from "react-native"; +import { WebView } from "react-native-webview"; + +type USaintAuthWebViewModalProps = { + visible: boolean; + loginUrl: string; + onClose: () => void; + onVerifySuccess: (payload: { sToken: string; sIdno: string }) => void; +}; + +export function USaintAuthWebViewModal({ + visible, + loginUrl, + onClose, + onVerifySuccess, +}: USaintAuthWebViewModalProps) { + const processedRef = useRef(false); + + return ( + + + + + LMS 인증 + + { + processedRef.current = false; + onClose(); + }} + > + 닫기 + + + + { + if (processedRef.current || navState.loading) return; + if (!navState.url.includes("saint.ssu.ac.kr")) return; + + try { + const parsedUrl = new URL(navState.url); + const sToken = parsedUrl.searchParams.get("sToken"); + const sIdno = parsedUrl.searchParams.get("sIdno"); + + if (!sToken || !sIdno) return; + + processedRef.current = true; + onVerifySuccess({ sToken, sIdno }); + } catch { + // URL 파싱 실패 시 무시 + } + }} + /> + + + ); +} diff --git a/src/shared/api/_generated/auth/signupStudent.ts b/src/shared/api/_generated/auth/signupStudent.ts new file mode 100644 index 0000000..2ed0b15 --- /dev/null +++ b/src/shared/api/_generated/auth/signupStudent.ts @@ -0,0 +1,162 @@ +/** + * Generated by orval v6.31.0 🍺 + * Do not edit manually. + * ASSU API + * ASSU API 명세서 + * OpenAPI spec version: 1.0.0 + */ +import { customInstance } from "../../orvalMutator"; +/** + * 사용자 기본 정보 + */ +export interface UserBasicInfoDTO { + /** 단과대 */ + department?: string; + /** 전공/학과 */ + major?: string; + /** 이름/업체명/단체명 */ + name?: string; + /** 대학교 */ + university?: string; +} + +/** + * JWT 토큰 정보 + */ +export interface TokensDTO { + /** 액세스 토큰 */ + accessToken?: string; + /** 리프레시 토큰 */ + refreshToken?: string; +} + +/** + * 회원 상태 + */ +export type SignUpResponseDTOStatus = + (typeof SignUpResponseDTOStatus)[keyof typeof SignUpResponseDTOStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const SignUpResponseDTOStatus = { + ACTIVE: "ACTIVE", + INACTIVE: "INACTIVE", + SUSPEND: "SUSPEND", + BLANK: "BLANK", +} as const; + +/** + * 회원 역할 + */ +export type SignUpResponseDTORole = + (typeof SignUpResponseDTORole)[keyof typeof SignUpResponseDTORole]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const SignUpResponseDTORole = { + STUDENT: "STUDENT", + ADMIN: "ADMIN", + PARTNER: "PARTNER", +} as const; + +/** + * 회원가입 성공 응답 + */ +export interface SignUpResponseDTO { + /** 사용자 기본 정보 (캐싱용) */ + basicInfo?: UserBasicInfoDTO; + /** 회원 ID */ + memberId?: number; + /** 회원 역할 */ + role?: SignUpResponseDTORole; + /** 회원 상태 */ + status?: SignUpResponseDTOStatus; + /** 액세스 토큰/리프레시 토큰 */ + tokens?: TokensDTO; +} + +export interface BaseResponseSignUpResponseDTO { + code?: string; + isSuccess?: boolean; + message?: string; + result?: SignUpResponseDTO; +} + +/** + * 대학교 + */ +export type StudentTokenAuthPayloadDTOUniversity = + (typeof StudentTokenAuthPayloadDTOUniversity)[keyof typeof StudentTokenAuthPayloadDTOUniversity]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const StudentTokenAuthPayloadDTOUniversity = { + SSU: "SSU", +} as const; + +/** + * 학생 토큰 인증 페이로드 + */ +export interface StudentTokenAuthPayloadDTO { + /** 유세인트 sIdno */ + sIdno: string; + /** 유세인트 sToken */ + sToken: string; + /** 대학교 */ + university?: StudentTokenAuthPayloadDTOUniversity; +} + +/** + * 학생 토큰 회원가입 요청 + */ +export interface StudentTokenSignUpRequestDTO { + /** 위치 정보 수집 동의 */ + locationAgree: boolean; + /** 마케팅 수신 동의 */ + marketingAgree: boolean; + /** 학생 토큰 인증 정보 */ + studentTokenAuth: StudentTokenAuthPayloadDTO; +} + +export const getAssuApi = () => { + /** + * # [v1.3 (2026-04-02)](https://clumsy-seeder-416.notion.site/2241197c19ed81129c85cf5bbe1f7971) +- `application/json` 요청 바디를 사용합니다. +- 처리: 유세인트 인증 → 학생 정보 추출 → 회원가입 완료 +- 성공 시 200(OK)과 생성된 memberId, JWT 토큰, 기본 정보 반환. + +**Request Body:** + - `StudentTokenSignUpRequestDTO` 객체 (JSON, required): 숭실대 학생 토큰 가입 정보 + - `marketingAgree` (Boolean, required): 마케팅 수신 동의 + - `locationAgree` (Boolean, required): 위치 정보 수집 동의 + - `studentTokenAuth` (StudentTokenAuthPayloadDTO, Object, required): 유세인트 토큰 정보 + - `sToken` (String, required): 유세인트 sToken + - `sIdno` (String, required): 유세인트 sIdno + - `university` (University enum, required): 대학 이름 (SSU) + +**Response:** + - 성공 시 200(OK)과 `SignUpResponseDTO` 객체 반환 + - `memberId` (Long): 회원 ID + - `role` (UserRole): 회원 역할 (STUDENT) + - `status` (ActivationStatus): 회원 상태 (ACTIVE) + - `tokens` (Object): JWT 토큰 정보 (accessToken, refreshToken) + - `basicInfo` (UserBasicInfo): 사용자 기본 정보 (프론트 캐싱용) + - `name` (String): 학생 이름 + - `university` (String): 대학교 (한글명) + - `department` (String): 단과대 (한글명) + - `major` (String): 전공/학과 (한글명) + * @summary 학생 회원가입 API + */ + const signupStudent = ( + studentTokenSignUpRequestDTO: StudentTokenSignUpRequestDTO, + ) => { + return customInstance({ + url: `/auth/students/signup`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: studentTokenSignUpRequestDTO, + }); + }; + + return { signupStudent }; +}; +export type SignupStudentResult = NonNullable< + Awaited["signupStudent"]>> +>; diff --git a/src/shared/api/_generated/auth/ssuAuth.ts b/src/shared/api/_generated/auth/ssuAuth.ts new file mode 100644 index 0000000..4369b1d --- /dev/null +++ b/src/shared/api/_generated/auth/ssuAuth.ts @@ -0,0 +1,80 @@ +/** + * Generated by orval v6.31.0 🍺 + * Do not edit manually. + * ASSU API + * ASSU API 명세서 + * OpenAPI spec version: 1.0.0 + */ +import { customInstance } from "../../orvalMutator"; +/** + * 유세인트 인증 응답 + */ +export interface USaintAuthResponseDTO { + /** 학적 상태 */ + enrollmentStatus?: string; + /** 전공/학과 */ + majorStr?: string; + /** 이름 */ + name?: string; + /** 학번 */ + studentNumber?: string; + /** 학년/학기 */ + yearSemester?: string; +} + +export interface BaseResponseUSaintAuthResponseDTO { + code?: string; + isSuccess?: boolean; + message?: string; + result?: USaintAuthResponseDTO; +} + +/** + * 유세인트 인증 요청 + */ +export interface USaintAuthRequestDTO { + /** 유세인트 sIdno */ + sIdno: string; + /** 유세인트 sToken */ + sToken: string; +} + +export const getAssuApi = () => { + /** + * # [v1.0 (2025-09-03)](https://clumsy-seeder-416.notion.site/23a1197c19ed808d9266e641e5c4ea14?source=copy_link) +- `application/json`으로 호출합니다. + +**Request Body:** + - `sToken` (String, required): 유세인트 sToken + - `sIdno` (String, required): 유세인트 sIdno +- 처리 순서: + 1) 유세인트 SSO 로그인 시도 (sToken, sIdno 검증) + 2) 응답 Body 검증 후 세션 쿠키 추출 + 3) 유세인트 포털 페이지 접근 및 HTML 파싱 + 4) 이름, 학번, 소속, 학적 상태, 학년/학기 정보 추출 + 5) 소속 문자열을 전공 Enum(`Major`)으로 매핑 + 6) 인증 결과를 `USaintAuthResponseDTO`로 반환 + +**Response:** + - 성공 시 200(OK)과 `USaintAuthResponseDTO` 객체 반환 + - `studentNumber` (String): 학번 + - `name` (String): 이름 + - `enrollmentStatus` (String): 학적 상태 + - `yearSemester` (String): 학년/학기 + - `major` (Major enum): 전공/학과 + * @summary 숭실대 유세인트 인증 API + */ + const ssuAuth = (uSaintAuthRequestDTO: USaintAuthRequestDTO) => { + return customInstance({ + url: `/auth/students/ssu-verify`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: uSaintAuthRequestDTO, + }); + }; + + return { ssuAuth }; +}; +export type SsuAuthResult = NonNullable< + Awaited["ssuAuth"]>> +>; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 8a0b5ab..4fc0d15 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -1,4 +1,24 @@ import "./interceptors"; export type { ApiError, ApiResponse } from "@/shared/types/api"; +export type { + BaseResponseSignUpResponseDTO, + SignUpResponseDTO, + StudentTokenAuthPayloadDTO, + StudentTokenSignUpRequestDTO, + TokensDTO, + UserBasicInfoDTO, +} from "./_generated/auth/signupStudent"; +// signupStudent +export { + getAssuApi as getSignupStudentApi, + StudentTokenAuthPayloadDTOUniversity, +} from "./_generated/auth/signupStudent"; +export type { + BaseResponseUSaintAuthResponseDTO, + USaintAuthRequestDTO, + USaintAuthResponseDTO, +} from "./_generated/auth/ssuAuth"; +// ssuAuth +export { getAssuApi as getSsuAuthApi } from "./_generated/auth/ssuAuth"; export { apiInstance } from "./instance"; diff --git a/src/shared/api/interceptors.ts b/src/shared/api/interceptors.ts index 681ca53..87c7cfc 100644 --- a/src/shared/api/interceptors.ts +++ b/src/shared/api/interceptors.ts @@ -8,13 +8,33 @@ apiInstance.interceptors.request.use((config) => { if (token) { config.headers.Authorization = `Bearer ${token}`; } + console.log( + "[API REQ]", + config.method?.toUpperCase(), + config.url, + JSON.stringify(config.data), + ); return config; }); apiInstance.interceptors.response.use( - (response) => response, + (response) => { + console.log( + "[API RES]", + response.status, + response.config.url, + JSON.stringify(response.data), + ); + return response; + }, (error) => { const status = error.response?.status; + console.log( + "[API ERR]", + status, + error.config?.url, + JSON.stringify(error.response?.data), + ); if (status === 401) { // TODO: 로그아웃 처리 (토큰 저장소 연동 후 구현) diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index c9f9d2c..2891e73 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -1,4 +1,7 @@ export const ENV = { API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL ?? "", USE_MOCKS: process.env.EXPO_PUBLIC_USE_MOCKS === "true", + SSU_LOGIN_URL: process.env.EXPO_PUBLIC_SSU_LOGIN_URL ?? "", + SSU_TEST_SIDNO: process.env.EXPO_PUBLIC_SSU_TEST_SIDNO ?? "", + SSU_TEST_STOKEN: process.env.EXPO_PUBLIC_SSU_TEST_STOKEN ?? "", } as const; diff --git a/src/shared/types/react-native-webview.d.ts b/src/shared/types/react-native-webview.d.ts new file mode 100644 index 0000000..4634ad0 --- /dev/null +++ b/src/shared/types/react-native-webview.d.ts @@ -0,0 +1,21 @@ +declare module "react-native-webview" { + import type { ComponentType } from "react"; + import type { ViewStyle } from "react-native"; + + export interface WebViewNavigation { + url: string; + loading: boolean; + } + + export interface WebViewProps { + source: { uri: string }; + style?: ViewStyle; + sharedCookiesEnabled?: boolean; + thirdPartyCookiesEnabled?: boolean; + domStorageEnabled?: boolean; + javaScriptEnabled?: boolean; + onNavigationStateChange?: (navState: WebViewNavigation) => void; + } + + export const WebView: ComponentType; +} diff --git a/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx b/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx index ee2de88..cbf3c02 100644 --- a/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx +++ b/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx @@ -8,13 +8,13 @@ import { SignupProgressBar, SignupStepContent, } from "@/features/signup-user-flow/ui"; - +import { USaintAuthWebViewModal } from "@/features/signup-user-flow/ui/components/USaintAuthWebViewModal"; import { AddressSearchDialog } from "@/shared/ui/address-search"; import { BottomActionSheet } from "@/shared/ui/bottom-sheet"; import { MediumButton } from "@/shared/ui/buttons/SubmitButton"; export function SignupUserFlowWidget() { - const { formMethods, flow, login, flowUi, overlays } = + const { formMethods, flow, login, flowUi, overlays, studentAuthWebView } = useSignupFlowController(); if (flow.step === "login1") { @@ -96,6 +96,13 @@ export function SignupUserFlowWidget() { onAction={overlays.resendSheet.action} /> + + Date: Mon, 25 May 2026 01:10:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[ADD/#107]=20=ED=95=99=EC=83=9D=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loginStudent / refreshToken API 훅 생성 (orval 자동생성) - expo-secure-store 기반 토큰 저장소 구현 (refreshToken → SecureStore, accessToken → 메모리) - axios interceptor 401 자동 갱신 + 무한루프 방지 - 앱 시작 시 역할 기반 자동 로그인 (STUDENT / ADMIN / PARTNER) - LMS 학생 로그인 버튼 활성화 (SSU WebView 연동) - useSignupFlowController 리팩토링 (action hook 분리) Co-Authored-By: Claude Sonnet 4.6 --- app.json | 3 +- package.json | 1 + src/app/_layout.tsx | 16 +- .../api/useLoginStudentMutation.ts | 8 + .../api/useRefreshTokenMutation.ts | 8 + .../signup-user-flow/lib/assertSuccess.ts | 12 ++ src/features/signup-user-flow/lib/auth.ts | 16 ++ .../model/useSignupFlowController.ts | 198 ++++++------------ .../model/useStudentLoginAction.ts | 55 +++++ .../model/useStudentSignupAction.ts | 67 ++++++ .../model/useStudentVerificationAction.ts | 63 ++++++ .../signup-user-flow/ui/LoginFormScreen.tsx | 9 +- .../api/_generated/auth/loginStudent.ts | 148 +++++++++++++ .../api/_generated/auth/refreshToken.ts | 57 +++++ src/shared/api/auth.ts | 70 +++++++ src/shared/api/index.ts | 16 ++ src/shared/api/interceptors.ts | 73 ++++++- src/shared/api/token-storage.ts | 28 +++ src/shared/lib/auth/authStore.ts | 19 ++ .../ui/SignupUserFlowWidget.tsx | 36 +++- yarn.lock | 5 + 21 files changed, 754 insertions(+), 154 deletions(-) create mode 100644 src/features/signup-user-flow/api/useLoginStudentMutation.ts create mode 100644 src/features/signup-user-flow/api/useRefreshTokenMutation.ts create mode 100644 src/features/signup-user-flow/lib/assertSuccess.ts create mode 100644 src/features/signup-user-flow/lib/auth.ts create mode 100644 src/features/signup-user-flow/model/useStudentLoginAction.ts create mode 100644 src/features/signup-user-flow/model/useStudentSignupAction.ts create mode 100644 src/features/signup-user-flow/model/useStudentVerificationAction.ts create mode 100644 src/shared/api/_generated/auth/loginStudent.ts create mode 100644 src/shared/api/_generated/auth/refreshToken.ts create mode 100644 src/shared/api/auth.ts create mode 100644 src/shared/api/token-storage.ts create mode 100644 src/shared/lib/auth/authStore.ts diff --git a/app.json b/app.json index 190584b..ec4b1a2 100644 --- a/app.json +++ b/app.json @@ -42,7 +42,8 @@ } ], "@react-native-community/datetimepicker", - "expo-web-browser" + "expo-web-browser", + "expo-secure-store" ], "experiments": { "typedRoutes": true, diff --git a/package.json b/package.json index 3d30182..d894eb1 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "expo-linking": "~8.0.12", "expo-media-library": "~18.2.1", "expo-router": "~6.0.23", + "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 6451503..954d662 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,9 +1,11 @@ import "react-native-url-polyfill/auto"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Stack } from "expo-router"; +import { router, Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; +import { useEffect, useState } from "react"; import { SafeAreaProvider } from "react-native-safe-area-context"; import "@/shared/api"; +import { getHomeRouteByRole, initAuth } from "@/shared/api/auth"; import { initMocks } from "@/shared/api/mocks"; import { useLoadFonts } from "@/shared/lib/hooks/useLoadFonts"; import "@/shared/styles/global.styles.css"; @@ -23,8 +25,18 @@ export const unstable_settings = { export default function RootLayout() { const fontsLoaded = useLoadFonts(); + const [authReady, setAuthReady] = useState(false); - if (!fontsLoaded) { + useEffect(() => { + initAuth().then(({ isLoggedIn, role }) => { + setAuthReady(true); + if (isLoggedIn) { + router.replace(getHomeRouteByRole(role) as never); + } + }); + }, []); + + if (!fontsLoaded || !authReady) { return null; } diff --git a/src/features/signup-user-flow/api/useLoginStudentMutation.ts b/src/features/signup-user-flow/api/useLoginStudentMutation.ts new file mode 100644 index 0000000..6c01102 --- /dev/null +++ b/src/features/signup-user-flow/api/useLoginStudentMutation.ts @@ -0,0 +1,8 @@ +import { useMutation } from "@tanstack/react-query"; +import { getLoginStudentApi } from "@/shared/api"; + +const { loginStudent } = getLoginStudentApi(); + +export function useLoginStudentMutation() { + return useMutation({ mutationFn: loginStudent }); +} diff --git a/src/features/signup-user-flow/api/useRefreshTokenMutation.ts b/src/features/signup-user-flow/api/useRefreshTokenMutation.ts new file mode 100644 index 0000000..f67d446 --- /dev/null +++ b/src/features/signup-user-flow/api/useRefreshTokenMutation.ts @@ -0,0 +1,8 @@ +import { useMutation } from "@tanstack/react-query"; +import { getRefreshTokenApi } from "@/shared/api"; + +const { refreshToken } = getRefreshTokenApi(); + +export function useRefreshTokenMutation() { + return useMutation({ mutationFn: refreshToken }); +} diff --git a/src/features/signup-user-flow/lib/assertSuccess.ts b/src/features/signup-user-flow/lib/assertSuccess.ts new file mode 100644 index 0000000..af339fd --- /dev/null +++ b/src/features/signup-user-flow/lib/assertSuccess.ts @@ -0,0 +1,12 @@ +export function assertSuccess( + response: { isSuccess?: boolean; result?: R; message?: string }, + fallback: string, +): asserts response is { + isSuccess: true; + result: NonNullable; + message?: string; +} { + if (!response.isSuccess || response.result == null) { + throw new Error(response.message ?? fallback); + } +} diff --git a/src/features/signup-user-flow/lib/auth.ts b/src/features/signup-user-flow/lib/auth.ts new file mode 100644 index 0000000..8c88499 --- /dev/null +++ b/src/features/signup-user-flow/lib/auth.ts @@ -0,0 +1,16 @@ +import { getHomeRouteByRole, saveTokens } from "@/shared/api/auth"; + +interface Tokens { + accessToken?: string; + refreshToken?: string; +} + +export async function completeLogin( + tokens: Tokens, + role?: string | null, +): Promise { + if (tokens.accessToken && tokens.refreshToken) { + await saveTokens(tokens.accessToken, tokens.refreshToken, role); + } + return getHomeRouteByRole(role ?? null); +} diff --git a/src/features/signup-user-flow/model/useSignupFlowController.ts b/src/features/signup-user-flow/model/useSignupFlowController.ts index 1fcefce..b99c845 100644 --- a/src/features/signup-user-flow/model/useSignupFlowController.ts +++ b/src/features/signup-user-flow/model/useSignupFlowController.ts @@ -1,14 +1,13 @@ import { router } from "expo-router"; import { useCallback, useMemo, useState } from "react"; -import { Alert } from "react-native"; -import { useSignupMutation } from "@/features/signup-user-flow/api/useSignupMutation"; -import { useSSUAuthMutation } from "@/features/signup-user-flow/api/useSSUAuthMutation"; -import { StudentTokenAuthPayloadDTOUniversity } from "@/shared/api"; -import { ENV } from "@/shared/config/env"; +import { getHomeRouteByRole } from "@/shared/api/auth"; import type { SignupFlowUiContextValue } from "./flowUiContext"; import { useSignupFlowPresentation } from "./useSignupFlowPresentation"; import { useSignupOverlays } from "./useSignupOverlays"; import { useSignupStepActions } from "./useSignupStepActions"; +import { useStudentLoginAction } from "./useStudentLoginAction"; +import { useStudentSignupAction } from "./useStudentSignupAction"; +import { useStudentVerificationAction } from "./useStudentVerificationAction"; export function useSignupFlowController() { const FORCE_PHONE_VERIFICATION_BYPASS = false; @@ -55,112 +54,51 @@ export function useSignupFlowController() { completeDisplayName, agreementHandlers, } = useSignupFlowPresentation(); + const [studentAuthPayload, setStudentAuthPayload] = useState<{ sIdno: string; sToken: string; } | null>(null); - const [isStudentAuthWebViewVisible, setStudentAuthWebViewVisible] = - useState(false); - const ssuAuthMutation = useSSUAuthMutation(); - const signupStudentMutation = useSignupMutation(); - const verifyStudentWithSsu = useCallback( - async ({ sIdno, sToken }: { sIdno: string; sToken: string }) => { - try { - const response = await ssuAuthMutation.mutateAsync({ sIdno, sToken }); - const result = response.result; - if (!response.isSuccess || !result) { - Alert.alert( - "인증 실패", - response.message ?? "유세인트 인증에 실패했습니다.", - ); - return; - } - - setStudentAuthPayload({ sIdno, sToken }); - if (result.studentNumber) { - setStudentId(result.studentNumber); - } - if (result.majorStr) { - setStudentMajor(result.majorStr); - } - if (result.name) { - setProfileName(result.name); - } - - setStudentAuthWebViewVisible(false); - goTo("studentInput2"); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "유세인트 인증에 실패했습니다."; - Alert.alert("인증 실패", message); - } - }, - [goTo, setProfileName, setStudentId, setStudentMajor, ssuAuthMutation], - ); - - const handleStudentSsuVerify = useCallback(async () => { - if (ENV.SSU_TEST_SIDNO && ENV.SSU_TEST_STOKEN) { - await verifyStudentWithSsu({ - sIdno: ENV.SSU_TEST_SIDNO, - sToken: ENV.SSU_TEST_STOKEN, - }); - return; - } - - setStudentAuthWebViewVisible(true); - }, [verifyStudentWithSsu]); - const handleStudentWebViewVerified = useCallback( - async (payload: { sIdno: string; sToken: string }) => { - await verifyStudentWithSsu(payload); + const handleVerified = useCallback( + ({ + sIdno, + sToken, + studentNumber, + majorStr, + name, + }: { + sIdno: string; + sToken: string; + studentNumber?: string; + majorStr?: string; + name?: string; + }) => { + setStudentAuthPayload({ sIdno, sToken }); + if (studentNumber) setStudentId(studentNumber); + if (majorStr) setStudentMajor(majorStr); + if (name) setProfileName(name); + goTo("studentInput2"); }, - [verifyStudentWithSsu], + [goTo, setProfileName, setStudentId, setStudentMajor], ); - const handleStudentSignup = useCallback(async () => { - if (!studentAuthPayload) { - Alert.alert("인증 필요", "먼저 LMS 인증을 진행해주세요."); - return; - } + const { handlePressVerify, webView: studentAuthWebView } = + useStudentVerificationAction(handleVerified); - try { - const response = await signupStudentMutation.mutateAsync({ - locationAgree: form.agreements.agreePrivacy, - marketingAgree: form.agreements.agreeMarketing, - studentTokenAuth: { - sIdno: studentAuthPayload.sIdno, - sToken: studentAuthPayload.sToken, - university: StudentTokenAuthPayloadDTOUniversity.SSU, - }, - }); + const handleSignupFailure = useCallback(() => goTo("loginForm"), [goTo]); - if (!response.isSuccess) { - Alert.alert( - "회원가입 실패", - response.message ?? "회원가입에 실패했습니다.", - ); - return; - } - - goNext(); - } catch (error) { - const message = - error instanceof Error ? error.message : "회원가입에 실패했습니다."; - Alert.alert("회원가입 실패", message); - } - }, [ - form.agreements.agreeMarketing, - form.agreements.agreePrivacy, - goNext, - signupStudentMutation, + const { signup: handleStudentSignup } = useStudentSignupAction({ studentAuthPayload, - ]); + agreePrivacy: form.agreements.agreePrivacy, + agreeMarketing: form.agreements.agreeMarketing, + onSuccess: goNext, + onFailure: handleSignupFailure, + }); - const sendIdentityVerificationCode = () => { - sendVerificationCode(); - }; + const { handlePressLmsLogin, loginWebView } = useStudentLoginAction(); + + const sendIdentityVerificationCode = () => sendVerificationCode(); const overlays = useSignupOverlays({ adminOfficeAddressId: form.admin.officeAddressId, @@ -186,7 +124,7 @@ export function useSignupFlowController() { student: { setRole, setSchool, - onPressStudentVerify: handleStudentSsuVerify, + onPressStudentVerify: handlePressVerify, }, partner: { setPartnerEmail, @@ -218,31 +156,25 @@ export function useSignupFlowController() { isBottomDisabled, buttonLabel, onSegmentPress: (segmentIndex: number) => { - if (segmentIndex >= currentProgressIndex) { + if (segmentIndex >= currentProgressIndex) return; + goTo(progressSteps[segmentIndex]); + }, + onBottomButtonPress: async () => { + if (step === "complete") { + router.replace(getHomeRouteByRole("STUDENT") as never); return; } - - goTo(progressSteps[segmentIndex]); + if (step === "identity" && FORCE_PHONE_VERIFICATION_BYPASS) { + setIdentityVerified(); + goNext(); + return; + } + if (step === "studentInput3") { + await handleStudentSignup(); + return; + } + goNext(); }, - onBottomButtonPress: - step === "complete" - ? () => router.replace("/(protected)/(student)/(tabs)/home" as never) - : async () => { - if (step === "identity") { - if (FORCE_PHONE_VERIFICATION_BYPASS) { - setIdentityVerified(); - goNext(); - return; - } - } - - if (step === "studentInput3") { - await handleStudentSignup(); - return; - } - - goNext(); - }, }), [ buttonLabel, @@ -266,12 +198,18 @@ export function useSignupFlowController() { password: form.auth.password, onChangeEmail: setAuthEmail, onChangePassword: setAuthPassword, - onPressLogin: () => { - console.log("로그인 성공"); - }, + onPressLogin: () => console.log("로그인 성공"), + onPressLmsLogin: handlePressLmsLogin, onPressSignup: () => goTo("identity"), }), - [form.auth.email, form.auth.password, goTo, setAuthEmail, setAuthPassword], + [ + form.auth.email, + form.auth.password, + goTo, + handlePressLmsLogin, + setAuthEmail, + setAuthPassword, + ], ); return { @@ -286,11 +224,7 @@ export function useSignupFlowController() { actions: stepContentActions, } satisfies SignupFlowUiContextValue, overlays, - studentAuthWebView: { - visible: isStudentAuthWebViewVisible, - loginUrl: ENV.SSU_LOGIN_URL, - close: () => setStudentAuthWebViewVisible(false), - onVerifySuccess: handleStudentWebViewVerified, - }, + studentAuthWebView, + loginWebView, }; } diff --git a/src/features/signup-user-flow/model/useStudentLoginAction.ts b/src/features/signup-user-flow/model/useStudentLoginAction.ts new file mode 100644 index 0000000..a06b0b8 --- /dev/null +++ b/src/features/signup-user-flow/model/useStudentLoginAction.ts @@ -0,0 +1,55 @@ +import { router } from "expo-router"; +import { useCallback, useState } from "react"; +import { Alert } from "react-native"; +import { useLoginStudentMutation } from "@/features/signup-user-flow/api/useLoginStudentMutation"; +import { StudentTokenAuthPayloadDTOUniversity } from "@/shared/api"; +import { ENV } from "@/shared/config/env"; +import { assertSuccess } from "../lib/assertSuccess"; +import { completeLogin } from "../lib/auth"; + +export function useStudentLoginAction() { + const [isWebViewVisible, setWebViewVisible] = useState(false); + const mutation = useLoginStudentMutation(); + + const login = useCallback( + async ({ sIdno, sToken }: { sIdno: string; sToken: string }) => { + try { + const response = await mutation.mutateAsync({ + sIdno, + sToken, + university: StudentTokenAuthPayloadDTOUniversity.SSU, + }); + assertSuccess(response, "로그인에 실패했습니다."); + + const { tokens, role } = response.result; + const homeRoute = await completeLogin(tokens ?? {}, role); + setWebViewVisible(false); + router.replace(homeRoute as never); + } catch (error) { + Alert.alert( + "로그인 실패", + error instanceof Error ? error.message : "로그인에 실패했습니다.", + ); + } + }, + [mutation], + ); + + const handlePressLmsLogin = useCallback(async () => { + if (ENV.SSU_TEST_SIDNO && ENV.SSU_TEST_STOKEN) { + await login({ sIdno: ENV.SSU_TEST_SIDNO, sToken: ENV.SSU_TEST_STOKEN }); + return; + } + setWebViewVisible(true); + }, [login]); + + return { + handlePressLmsLogin, + loginWebView: { + visible: isWebViewVisible, + loginUrl: ENV.SSU_LOGIN_URL, + close: () => setWebViewVisible(false), + onVerifySuccess: login, + }, + }; +} diff --git a/src/features/signup-user-flow/model/useStudentSignupAction.ts b/src/features/signup-user-flow/model/useStudentSignupAction.ts new file mode 100644 index 0000000..41f0eae --- /dev/null +++ b/src/features/signup-user-flow/model/useStudentSignupAction.ts @@ -0,0 +1,67 @@ +import { useCallback } from "react"; +import { Alert } from "react-native"; +import { useSignupMutation } from "@/features/signup-user-flow/api/useSignupMutation"; +import { StudentTokenAuthPayloadDTOUniversity } from "@/shared/api"; + +type Params = { + studentAuthPayload: { sIdno: string; sToken: string } | null; + agreePrivacy: boolean; + agreeMarketing: boolean; + onSuccess: () => void; + onFailure: () => void; +}; + +export function useStudentSignupAction({ + studentAuthPayload, + agreePrivacy, + agreeMarketing, + onSuccess, + onFailure, +}: Params) { + const mutation = useSignupMutation(); + + const signup = useCallback(async () => { + if (!studentAuthPayload) { + Alert.alert("인증 필요", "먼저 LMS 인증을 진행해주세요."); + return; + } + + try { + const response = await mutation.mutateAsync({ + locationAgree: agreePrivacy, + marketingAgree: agreeMarketing, + studentTokenAuth: { + sIdno: studentAuthPayload.sIdno, + sToken: studentAuthPayload.sToken, + university: StudentTokenAuthPayloadDTOUniversity.SSU, + }, + }); + + if (!response.isSuccess) { + Alert.alert( + "회원가입 실패", + response.message ?? "회원가입에 실패했습니다.", + [{ text: "확인", onPress: onFailure }], + ); + return; + } + + onSuccess(); + } catch (error) { + Alert.alert( + "회원가입 실패", + error instanceof Error ? error.message : "회원가입에 실패했습니다.", + [{ text: "확인", onPress: onFailure }], + ); + } + }, [ + agreeMarketing, + agreePrivacy, + mutation, + onFailure, + onSuccess, + studentAuthPayload, + ]); + + return { signup }; +} diff --git a/src/features/signup-user-flow/model/useStudentVerificationAction.ts b/src/features/signup-user-flow/model/useStudentVerificationAction.ts new file mode 100644 index 0000000..5fb2b9a --- /dev/null +++ b/src/features/signup-user-flow/model/useStudentVerificationAction.ts @@ -0,0 +1,63 @@ +import { useCallback, useState } from "react"; +import { Alert } from "react-native"; +import { useSSUAuthMutation } from "@/features/signup-user-flow/api/useSSUAuthMutation"; +import { ENV } from "@/shared/config/env"; +import { assertSuccess } from "../lib/assertSuccess"; + +type OnVerifiedParams = { + sIdno: string; + sToken: string; + studentNumber?: string; + majorStr?: string; + name?: string; +}; + +export function useStudentVerificationAction( + onVerified: (params: OnVerifiedParams) => void, +) { + const [isWebViewVisible, setWebViewVisible] = useState(false); + const mutation = useSSUAuthMutation(); + + const verify = useCallback( + async ({ sIdno, sToken }: { sIdno: string; sToken: string }) => { + try { + const response = await mutation.mutateAsync({ sIdno, sToken }); + assertSuccess(response, "유세인트 인증에 실패했습니다."); + setWebViewVisible(false); + onVerified({ + sIdno, + sToken, + studentNumber: response.result.studentNumber, + majorStr: response.result.majorStr, + name: response.result.name, + }); + } catch (error) { + Alert.alert( + "인증 실패", + error instanceof Error + ? error.message + : "유세인트 인증에 실패했습니다.", + ); + } + }, + [mutation, onVerified], + ); + + const handlePressVerify = useCallback(async () => { + if (ENV.SSU_TEST_SIDNO && ENV.SSU_TEST_STOKEN) { + await verify({ sIdno: ENV.SSU_TEST_SIDNO, sToken: ENV.SSU_TEST_STOKEN }); + return; + } + setWebViewVisible(true); + }, [verify]); + + return { + handlePressVerify, + webView: { + visible: isWebViewVisible, + loginUrl: ENV.SSU_LOGIN_URL, + close: () => setWebViewVisible(false), + onVerifySuccess: verify, + }, + }; +} diff --git a/src/features/signup-user-flow/ui/LoginFormScreen.tsx b/src/features/signup-user-flow/ui/LoginFormScreen.tsx index 437b1fc..d815350 100644 --- a/src/features/signup-user-flow/ui/LoginFormScreen.tsx +++ b/src/features/signup-user-flow/ui/LoginFormScreen.tsx @@ -9,6 +9,7 @@ type LoginFormScreenProps = { onChangeEmail: (value: string) => void; onChangePassword: (value: string) => void; onPressLogin: () => void; + onPressLmsLogin: () => void; onPressSignup: () => void; }; @@ -18,6 +19,7 @@ export function LoginFormScreen({ onChangeEmail, onChangePassword, onPressLogin, + onPressLmsLogin, onPressSignup, }: LoginFormScreenProps) { return ( @@ -47,11 +49,14 @@ export function LoginFormScreen({ - + LMS 학생 로그인 - + 아직 계정이 없으신가요? diff --git a/src/shared/api/_generated/auth/loginStudent.ts b/src/shared/api/_generated/auth/loginStudent.ts new file mode 100644 index 0000000..c6e21b6 --- /dev/null +++ b/src/shared/api/_generated/auth/loginStudent.ts @@ -0,0 +1,148 @@ +/** + * Generated by orval v7.13.2 🍺 + * Do not edit manually. + * ASSU API + * ASSU API 명세서 + * OpenAPI spec version: 1.0.0 + */ +import { customInstance } from "../../orvalMutator"; +/** + * 대학교 + */ +export type StudentTokenAuthPayloadDTOUniversity = + (typeof StudentTokenAuthPayloadDTOUniversity)[keyof typeof StudentTokenAuthPayloadDTOUniversity]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const StudentTokenAuthPayloadDTOUniversity = { + SSU: "SSU", +} as const; + +/** + * 학생 토큰 인증 페이로드 + */ +export interface StudentTokenAuthPayloadDTO { + /** 유세인트 sToken */ + sToken: string; + /** 유세인트 sIdno */ + sIdno: string; + /** 대학교 */ + university?: StudentTokenAuthPayloadDTOUniversity; +} + +export interface BaseResponseLoginResponseDTO { + isSuccess?: boolean; + code?: string; + message?: string; + result?: LoginResponseDTO; +} + +/** + * 회원 역할 + */ +export type LoginResponseDTORole = + (typeof LoginResponseDTORole)[keyof typeof LoginResponseDTORole]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const LoginResponseDTORole = { + STUDENT: "STUDENT", + ADMIN: "ADMIN", + PARTNER: "PARTNER", +} as const; + +/** + * 회원 상태 + */ +export type LoginResponseDTOStatus = + (typeof LoginResponseDTOStatus)[keyof typeof LoginResponseDTOStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const LoginResponseDTOStatus = { + ACTIVE: "ACTIVE", + INACTIVE: "INACTIVE", + SUSPEND: "SUSPEND", + BLANK: "BLANK", +} as const; + +/** + * 로그인 성공 응답 + */ +export interface LoginResponseDTO { + /** 회원 ID */ + memberId?: number; + /** 회원 역할 */ + role?: LoginResponseDTORole; + /** 회원 상태 */ + status?: LoginResponseDTOStatus; + /** 액세스 토큰/리프레시 토큰 */ + tokens?: TokensDTO; + /** 사용자 기본 정보 (캐싱용) */ + basicInfo?: UserBasicInfoDTO; +} + +/** + * JWT 토큰 정보 + */ +export interface TokensDTO { + /** 액세스 토큰 */ + accessToken?: string; + /** 리프레시 토큰 */ + refreshToken?: string; +} + +/** + * 사용자 기본 정보 + */ +export interface UserBasicInfoDTO { + /** 이름/업체명/단체명 */ + name?: string; + /** 대학교 */ + university?: string; + /** 단과대 */ + department?: string; + /** 전공/학과 */ + major?: string; +} + +export const getAssuApi = () => { + /** + * # [v1.2 (2025-09-13)](https://clumsy-seeder-416.notion.site/2501197c19ed80f6b495fa37f8c084a8) +- `application/json`로 호출합니다. +- 바디: `StudentTokenAuthPayloadDTO(sToken, sIdno, university)`. +- 처리: 유세인트 인증 → 기존 회원 확인 → JWT 토큰 발급. +- 성공 시 200(OK)과 토큰(accessToken/refreshToken), 기본 정보 반환. + +**Request Body:** + - `StudentTokenAuthPayloadDTO` 객체 (JSON, required): 숭실대 학생 토큰 로그인 정보 + - `sToken` (String, required): 유세인트 sToken + - `sIdno` (String, required): 유세인트 sIdno + - `university` (University enum, required): 대학 이름 (SSU) + +**Response:** + - 성공 시 200(OK)과 `LoginResponseDTO` 객체 반환 + - `memberId` (Long): 회원 ID + - `role` (UserRole): 회원 역할 (STUDENT) + - `status` (ActivationStatus): 회원 상태 + - `tokens` (Object): JWT 토큰 정보 (accessToken, refreshToken) + - `basicInfo` (UserBasicInfo): 사용자 기본 정보 (프론트 캐싱용) + - `name` (String): 학생 이름 + - `university` (String): 대학교 (한글명) + - `department` (String): 단과대 (한글명) + - `major` (String): 전공/학과 (한글명) + * @summary 학생 로그인 API + */ + const loginStudent = ( + studentTokenAuthPayloadDTO: StudentTokenAuthPayloadDTO, + ) => { + return customInstance({ + url: `/auth/students/login`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: studentTokenAuthPayloadDTO, + }); + }; + + return { loginStudent }; +}; +export type LoginStudentResult = NonNullable< + Awaited["loginStudent"]>> +>; diff --git a/src/shared/api/_generated/auth/refreshToken.ts b/src/shared/api/_generated/auth/refreshToken.ts new file mode 100644 index 0000000..35f4c69 --- /dev/null +++ b/src/shared/api/_generated/auth/refreshToken.ts @@ -0,0 +1,57 @@ +/** + * Generated by orval v7.13.2 🍺 + * Do not edit manually. + * ASSU API + * ASSU API 명세서 + * OpenAPI spec version: 1.0.0 + */ +import { customInstance } from "../../orvalMutator"; +export interface BaseResponseRefreshResponseDTO { + isSuccess?: boolean; + code?: string; + message?: string; + result?: RefreshResponseDTO; +} + +/** + * 액세스 토큰 갱신 응답 + */ +export interface RefreshResponseDTO { + /** 회원 ID */ + memberId?: number; + /** 새로운 액세스 토큰 */ + newAccess?: string; + /** 새로운 리프레시 토큰 */ + newRefresh?: string; +} + +export const getAssuApi = () => { + /** + * # [v1.0 (2025-09-03)](https://clumsy-seeder-416.notion.site/2501197c19ed806ea8cff29f9cd8695a?source=copy_link) +- 헤더로 호출합니다. +- 헤더: `RefreshToken: `. +- 처리: Refresh 검증/회전 후 신규 Access/Refresh 발급 및 저장. +- 성공 시 200(OK)과 신규 토큰 반환. + +**Headers:** + - `RefreshToken` (String, required): 리프레시 토큰 + +**Response:** + - 성공 시 200(OK)과 `RefreshResponseDTO` 객체 반환 + - `memberId` (Long): 회원 ID + - `newAccess` (String): 새로운 액세스 토큰 + - `newRefresh` (String): 새로운 리프레시 토큰 + * @summary Access Token 갱신 API + */ + const refreshToken = () => { + return customInstance({ + url: `/auth/tokens/refresh`, + method: "POST", + }); + }; + + return { refreshToken }; +}; +export type RefreshTokenResult = NonNullable< + Awaited["refreshToken"]>> +>; diff --git a/src/shared/api/auth.ts b/src/shared/api/auth.ts new file mode 100644 index 0000000..4dfb4da --- /dev/null +++ b/src/shared/api/auth.ts @@ -0,0 +1,70 @@ +import { type UserRole, useAuthStore } from "@/shared/lib/auth/authStore"; +import { apiInstance } from "./instance"; +import { + deleteRefreshToken, + deleteUserRole, + getRefreshToken, + getUserRole, + setRefreshToken, + setUserRole, +} from "./token-storage"; + +export function getHomeRouteByRole(role: UserRole | string | null): string { + switch (role) { + case "ADMIN": + return "/(protected)/admin/(tabs)/home"; + case "PARTNER": + return "/(protected)/partner/(tabs)/home"; + default: + return "/(protected)/student/(tabs)/home"; + } +} + +export async function saveTokens( + accessToken: string, + refreshToken: string, + role?: UserRole | string | null, +) { + useAuthStore.getState().setAccessToken(accessToken); + await setRefreshToken(refreshToken); + if (role) { + useAuthStore.getState().setRole(role as UserRole); + await setUserRole(role); + } +} + +export async function clearTokens() { + useAuthStore.getState().clearAccessToken(); + await deleteRefreshToken(); + await deleteUserRole(); +} + +/** 앱 시작 시 SecureStore의 refreshToken으로 자동 로그인 시도. 역할 포함 반환 */ +export async function initAuth(): Promise<{ + isLoggedIn: boolean; + role: UserRole | null; +}> { + const storedRefresh = await getRefreshToken(); + if (!storedRefresh) return { isLoggedIn: false, role: null }; + + try { + const res = await apiInstance.post( + "/auth/tokens/refresh", + {}, + { headers: { RefreshToken: storedRefresh } }, + ); + const { newAccess, newRefresh } = res.data.result ?? {}; + if (!newAccess || !newRefresh) return { isLoggedIn: false, role: null }; + + const role = (await getUserRole()) as UserRole | null; + useAuthStore.getState().setAccessToken(newAccess); + if (role) useAuthStore.getState().setRole(role); + await setRefreshToken(newRefresh); + + return { isLoggedIn: true, role }; + } catch { + await deleteRefreshToken(); + await deleteUserRole(); + return { isLoggedIn: false, role: null }; + } +} diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 4fc0d15..70e99b3 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -1,6 +1,22 @@ import "./interceptors"; export type { ApiError, ApiResponse } from "@/shared/types/api"; +export type { + BaseResponseLoginResponseDTO, + LoginResponseDTO, +} from "./_generated/auth/loginStudent"; +// loginStudent +export { + getAssuApi as getLoginStudentApi, + LoginResponseDTORole, + LoginResponseDTOStatus, +} from "./_generated/auth/loginStudent"; +export type { + BaseResponseRefreshResponseDTO, + RefreshResponseDTO, +} from "./_generated/auth/refreshToken"; +// refreshToken +export { getAssuApi as getRefreshTokenApi } from "./_generated/auth/refreshToken"; export type { BaseResponseSignUpResponseDTO, SignUpResponseDTO, diff --git a/src/shared/api/interceptors.ts b/src/shared/api/interceptors.ts index 87c7cfc..a5d725c 100644 --- a/src/shared/api/interceptors.ts +++ b/src/shared/api/interceptors.ts @@ -1,10 +1,15 @@ +import { useAuthStore } from "@/shared/lib/auth/authStore"; +import { clearTokens } from "./auth"; import { apiInstance } from "./instance"; +import { getRefreshToken, setRefreshToken } from "./token-storage"; -// TODO: 로그인 구현 후 실제 토큰 저장소(SecureStore 등)에서 읽도록 교체 -const getToken = (): string | null => null; +const REFRESH_URL = "/auth/tokens/refresh"; + +let isRefreshing = false; +let refreshSubscribers: ((token: string) => void)[] = []; apiInstance.interceptors.request.use((config) => { - const token = getToken(); + const token = useAuthStore.getState().accessToken; if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -27,7 +32,7 @@ apiInstance.interceptors.response.use( ); return response; }, - (error) => { + async (error) => { const status = error.response?.status; console.log( "[API ERR]", @@ -36,10 +41,64 @@ apiInstance.interceptors.response.use( JSON.stringify(error.response?.data), ); - if (status === 401) { - // TODO: 로그아웃 처리 (토큰 저장소 연동 후 구현) + const originalRequest = error.config; + + // refresh 엔드포인트나 이미 재시도한 요청은 인터셉터 제외 → 무한루프 방지 + if (originalRequest.url === REFRESH_URL || originalRequest._retry) { + await clearTokens(); + return Promise.reject(error); + } + + if (status !== 401) { + return Promise.reject(error); + } + + // 이미 refresh 중이면 완료될 때까지 대기 후 재시도 + if (isRefreshing) { + return new Promise((resolve, reject) => { + refreshSubscribers.push((newToken) => { + if (!newToken) { + reject(error); + return; + } + originalRequest.headers.Authorization = `Bearer ${newToken}`; + resolve(apiInstance(originalRequest)); + }); + }); } - return Promise.reject(error); + originalRequest._retry = true; + isRefreshing = true; + + try { + const storedRefresh = await getRefreshToken(); + if (!storedRefresh) { + await clearTokens(); + return Promise.reject(error); + } + + const res = await apiInstance.post( + REFRESH_URL, + {}, + { headers: { RefreshToken: storedRefresh } }, + ); + + const { newAccess, newRefresh } = res.data.result ?? {}; + if (!newAccess || !newRefresh) throw new Error("토큰 갱신 실패"); + + useAuthStore.getState().setAccessToken(newAccess); + await setRefreshToken(newRefresh); + + for (const cb of refreshSubscribers) cb(newAccess); + originalRequest.headers.Authorization = `Bearer ${newAccess}`; + return apiInstance(originalRequest); + } catch { + await clearTokens(); + for (const cb of refreshSubscribers) cb(""); + return Promise.reject(error); + } finally { + isRefreshing = false; + refreshSubscribers = []; + } }, ); diff --git a/src/shared/api/token-storage.ts b/src/shared/api/token-storage.ts new file mode 100644 index 0000000..43ea52d --- /dev/null +++ b/src/shared/api/token-storage.ts @@ -0,0 +1,28 @@ +import * as SecureStore from "expo-secure-store"; + +const REFRESH_TOKEN_KEY = "refreshToken"; +const USER_ROLE_KEY = "userRole"; + +export async function setRefreshToken(token: string) { + await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, token); +} + +export async function getRefreshToken() { + return SecureStore.getItemAsync(REFRESH_TOKEN_KEY); +} + +export async function deleteRefreshToken() { + await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY); +} + +export async function setUserRole(role: string) { + await SecureStore.setItemAsync(USER_ROLE_KEY, role); +} + +export async function getUserRole() { + return SecureStore.getItemAsync(USER_ROLE_KEY); +} + +export async function deleteUserRole() { + await SecureStore.deleteItemAsync(USER_ROLE_KEY); +} diff --git a/src/shared/lib/auth/authStore.ts b/src/shared/lib/auth/authStore.ts new file mode 100644 index 0000000..8406add --- /dev/null +++ b/src/shared/lib/auth/authStore.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; + +export type UserRole = "STUDENT" | "ADMIN" | "PARTNER"; + +interface AuthState { + accessToken: string | null; + role: UserRole | null; + setAccessToken: (token: string) => void; + setRole: (role: UserRole) => void; + clearAccessToken: () => void; +} + +export const useAuthStore = create((set) => ({ + accessToken: null, + role: null, + setAccessToken: (token) => set({ accessToken: token }), + setRole: (role) => set({ role }), + clearAccessToken: () => set({ accessToken: null, role: null }), +})); diff --git a/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx b/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx index cbf3c02..39e7660 100644 --- a/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx +++ b/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx @@ -14,8 +14,15 @@ import { BottomActionSheet } from "@/shared/ui/bottom-sheet"; import { MediumButton } from "@/shared/ui/buttons/SubmitButton"; export function SignupUserFlowWidget() { - const { formMethods, flow, login, flowUi, overlays, studentAuthWebView } = - useSignupFlowController(); + const { + formMethods, + flow, + login, + flowUi, + overlays, + studentAuthWebView, + loginWebView, + } = useSignupFlowController(); if (flow.step === "login1") { return ( @@ -30,14 +37,23 @@ export function SignupUserFlowWidget() { if (flow.step === "loginForm") { return ( - + <> + + + ); } diff --git a/yarn.lock b/yarn.lock index 65ab4c3..5311d5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4210,6 +4210,11 @@ expo-router@~6.0.23: use-latest-callback "^0.2.1" vaul "^1.1.2" +expo-secure-store@~15.0.8: + version "15.0.8" + resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-15.0.8.tgz#678065599bb76061b5a85b15b9426bf7a11089ae" + integrity sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw== + expo-server@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-1.0.5.tgz#2d52002199a2af99c2c8771d0657916004345ca9"