diff --git a/app.json b/app.json index a2c492b..1d9ae33 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 b641523..4e78c6f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,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", @@ -54,6 +55,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", "uuid": "^13.0.0", "zod": "^4.3.6", diff --git a/scripts/generate-api.js b/scripts/generate-api.js index 8ebcec9..fb6dc3a 100644 --- a/scripts/generate-api.js +++ b/scripts/generate-api.js @@ -10,299 +10,297 @@ const args = process.argv.slice(2); const opsArgIndex = args.indexOf("--operations"); const opsArg = opsArgIndex !== -1 ? args[opsArgIndex + 1] : null; const targetOperationIds = opsArg - ? opsArg.split(",").map((s) => s.trim()) - : null; + ? opsArg.split(",").map((s) => s.trim()) + : null; const featureArgIndex = args.indexOf("--feature"); const featureArg = featureArgIndex !== -1 ? args[featureArgIndex + 1] : null; function collectSchemaRefs(node, allSchemas, visited = new Set()) { - if (!node || typeof node !== "object") return visited; - if (Array.isArray(node)) { - for (const item of node) { - collectSchemaRefs(item, allSchemas, visited); - } - return visited; - } - const ref = node["$ref"]; - if (ref) { - const name = ref.split("/").pop(); - if (!visited.has(name)) { - visited.add(name); - if (allSchemas[name]) { - collectSchemaRefs(allSchemas[name], allSchemas, visited); - } - } - } - for (const v of Object.values(node)) { - collectSchemaRefs(v, allSchemas, visited); - } - return visited; + if (!node || typeof node !== "object") return visited; + if (Array.isArray(node)) { + for (const item of node) { + collectSchemaRefs(item, allSchemas, visited); + } + return visited; + } + const ref = node["$ref"]; + if (ref) { + const name = ref.split("/").pop(); + if (!visited.has(name)) { + visited.add(name); + if (allSchemas[name]) { + collectSchemaRefs(allSchemas[name], allSchemas, visited); + } + } + } + for (const v of Object.values(node)) { + collectSchemaRefs(v, allSchemas, visited); + } + return visited; } function buildFilteredSpec(spec, operationIds) { - const allSchemas = spec.components?.schemas ?? {}; - const filteredPaths = {}; - const usedSchemaNames = new Set(); - - for (const [pathKey, pathItem] of Object.entries(spec.paths ?? {})) { - const filteredMethods = {}; - for (const [method, operation] of Object.entries(pathItem)) { - if ( - typeof operation === "object" && - operationIds.includes(operation.operationId) - ) { - filteredMethods[method] = operation; - collectSchemaRefs(operation, allSchemas, usedSchemaNames); - } - } - if (Object.keys(filteredMethods).length > 0) { - filteredPaths[pathKey] = filteredMethods; - } - } - - if (Object.keys(filteredPaths).length === 0) { - console.error(`❌ 찾을 수 없는 operationId: ${operationIds.join(", ")}`); - console.error("사용 가능한 목록: node scripts/generate-api.js --list"); - process.exit(1); - } - - const filteredSchemas = {}; - for (const name of usedSchemaNames) { - if (allSchemas[name]) filteredSchemas[name] = allSchemas[name]; - } - - return { - ...spec, - paths: filteredPaths, - components: { ...spec.components, schemas: filteredSchemas }, - }; + const allSchemas = spec.components?.schemas ?? {}; + const filteredPaths = {}; + const usedSchemaNames = new Set(); + + for (const [pathKey, pathItem] of Object.entries(spec.paths ?? {})) { + const filteredMethods = {}; + for (const [method, operation] of Object.entries(pathItem)) { + if ( + typeof operation === "object" && + operationIds.includes(operation.operationId) + ) { + filteredMethods[method] = operation; + collectSchemaRefs(operation, allSchemas, usedSchemaNames); + } + } + if (Object.keys(filteredMethods).length > 0) { + filteredPaths[pathKey] = filteredMethods; + } + } + + if (Object.keys(filteredPaths).length === 0) { + console.error(`❌ 찾을 수 없는 operationId: ${operationIds.join(", ")}`); + console.error("사용 가능한 목록: node scripts/generate-api.js --list"); + process.exit(1); + } + + const filteredSchemas = {}; + for (const name of usedSchemaNames) { + if (allSchemas[name]) filteredSchemas[name] = allSchemas[name]; + } + + return { + ...spec, + paths: filteredPaths, + components: { ...spec.components, schemas: filteredSchemas }, + }; } function listOperations(spec) { - console.log("\n사용 가능한 operationId 목록:\n"); - for (const [pathKey, pathItem] of Object.entries(spec.paths ?? {})) { - for (const [method, operation] of Object.entries(pathItem)) { - if (typeof operation === "object" && operation.operationId) { - console.log( - ` ${operation.operationId.padEnd(45)} ${method.toUpperCase()} ${pathKey}`, - ); - } - } - } + console.log("\n사용 가능한 operationId 목록:\n"); + for (const [pathKey, pathItem] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(pathItem)) { + if (typeof operation === "object" && operation.operationId) { + console.log( + ` ${operation.operationId.padEnd(45)} ${method.toUpperCase()} ${pathKey}`, + ); + } + } + } } function getOperationMethods(spec, operationIds) { - const methods = {}; - for (const [, pathItem] of Object.entries(spec.paths ?? {})) { - for (const [method, operation] of Object.entries(pathItem)) { - if ( - typeof operation === "object" && - operationIds.includes(operation.operationId) - ) { - methods[operation.operationId] = method.toUpperCase(); - } - } - } - return methods; + const methods = {}; + for (const [, pathItem] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(pathItem)) { + if ( + typeof operation === "object" && + operationIds.includes(operation.operationId) + ) { + methods[operation.operationId] = method.toUpperCase(); + } + } + } + return methods; } function runOrval(inputSpecPath, outputTarget) { - const tmpConfigPath = path.join(ROOT, ".orval.tmp.config.js"); - const config = { - api: { - input: { target: inputSpecPath }, - output: { - target: outputTarget, - mode: "single", - client: "axios", - mock: false, - override: { - mutator: { - path: path.join(ROOT, "src/shared/api/orvalMutator.ts"), - name: "customInstance", - }, - }, - }, - }, - }; - - fs.writeFileSync( - tmpConfigPath, - `module.exports = ${JSON.stringify(config, null, 2)}`, - ); - - try { - execSync(`npx orval --config ${tmpConfigPath}`, { stdio: "inherit" }); - } finally { - fs.unlinkSync(tmpConfigPath); - } + const tmpConfigPath = path.join(ROOT, ".orval.tmp.config.js"); + const config = { + api: { + input: { target: inputSpecPath }, + output: { + target: outputTarget, + mode: "single", + client: "axios", + mock: false, + override: { + mutator: { + path: path.join(ROOT, "src/shared/api/orvalMutator.ts"), + name: "customInstance", + }, + }, + }, + }, + }; + + fs.writeFileSync( + tmpConfigPath, + `module.exports = ${JSON.stringify(config, null, 2)}`, + ); + + try { + execSync(`npx orval --config ${tmpConfigPath}`, { stdio: "inherit" }); + } finally { + fs.unlinkSync(tmpConfigPath); + } } function toPascalCase(str) { - return str.charAt(0).toUpperCase() + str.slice(1); + return str.charAt(0).toUpperCase() + str.slice(1); } function toApiAliasName(operationId) { - return `get${toPascalCase(operationId)}Api`; + return `get${toPascalCase(operationId)}Api`; } function toHookName(operationId, method) { - const suffix = method === "GET" ? "Query" : "Mutation"; - return `use${toPascalCase(operationId)}${suffix}`; + const suffix = method === "GET" ? "Query" : "Mutation"; + return `use${toPascalCase(operationId)}${suffix}`; } function parseGeneratedFile(filePath) { - const content = fs.readFileSync(filePath, "utf-8"); - - const interfaces = [...content.matchAll(/^export interface (\w+)/gm)].map( - (m) => m[1], - ); - - // "export type X = typeof X[keyof typeof X]" 패턴이 enum - const enumNames = [ - ...content.matchAll( - /^export type (\w+) = typeof \1\[keyof typeof \1\]/gm, - ), - ].map((m) => m[1]); - - const allTypeAliases = [...content.matchAll(/^export type (\w+) =/gm)].map( - (m) => m[1], - ); - - // enum과 orval 내부 Result 타입 제외 - const typeAliases = allTypeAliases.filter( - (name) => !enumNames.includes(name) && !name.endsWith("Result"), - ); - - const typeNames = [...interfaces, ...typeAliases]; - - // "return {operationFnName}};" 에서 함수명 추출 - const returnMatch = content.match(/return \{(\w+)\}\}/); - const operationFnName = returnMatch ? returnMatch[1] : null; - - // 함수 파라미터 타입 추출 (GET useQuery 템플릿에 사용) - let paramType = null; - if (operationFnName) { - const paramPattern = new RegExp( - `const ${operationFnName} = \\(\\s*\\w+:\\s*(\\w+)`, - "s", - ); - const paramMatch = content.match(paramPattern); - if (paramMatch) { - paramType = paramMatch[1]; - } - } - - return { typeNames, enumNames, operationFnName, paramType }; + const content = fs.readFileSync(filePath, "utf-8"); + + const interfaces = [...content.matchAll(/^export interface (\w+)/gm)].map( + (m) => m[1], + ); + + // "export type X = typeof X[keyof typeof X]" 패턴이 enum + const enumNames = [ + ...content.matchAll(/^export type (\w+) = typeof \1\[keyof typeof \1\]/gm), + ].map((m) => m[1]); + + const allTypeAliases = [...content.matchAll(/^export type (\w+) =/gm)].map( + (m) => m[1], + ); + + // enum과 orval 내부 Result 타입 제외 + const typeAliases = allTypeAliases.filter( + (name) => !enumNames.includes(name) && !name.endsWith("Result"), + ); + + const typeNames = [...interfaces, ...typeAliases]; + + // "return {operationFnName}};" 에서 함수명 추출 + const returnMatch = content.match(/return \{(\w+)\}\}/); + const operationFnName = returnMatch ? returnMatch[1] : null; + + // 함수 파라미터 타입 추출 (GET useQuery 템플릿에 사용) + let paramType = null; + if (operationFnName) { + const paramPattern = new RegExp( + `const ${operationFnName} = \\(\\s*\\w+:\\s*(\\w+)`, + "s", + ); + const paramMatch = content.match(paramPattern); + if (paramMatch) { + paramType = paramMatch[1]; + } + } + + return { typeNames, enumNames, operationFnName, paramType }; } // index.ts에 이미 export된 타입/enum 이름 수집 (중복 방지용) function getAlreadyExportedNames(indexContent) { - const names = new Set(); - - // export type { A, B, C } from (멀티라인 포함) - for (const match of indexContent.matchAll(/export type \{([^}]+)\}/gs)) { - for (const part of match[1].split(",")) { - const name = part.trim(); - if (name && /^\w+$/.test(name)) names.add(name); - } - } - - // export { A, B as C, ... } from (멀티라인 + as 별칭 포함) - for (const match of indexContent.matchAll(/export \{([^}]+)\} from/gs)) { - for (const part of match[1].split(",")) { - const trimmed = part.trim(); - if (!trimmed) continue; - const asMatch = trimmed.match(/^(\w+)\s+as\s+(\w+)$/); - if (asMatch) { - names.add(asMatch[1]); // 원본 이름 - names.add(asMatch[2]); // 별칭 - } else if (/^\w+$/.test(trimmed)) { - names.add(trimmed); - } - } - } - - return names; + const names = new Set(); + + // export type { A, B, C } from (멀티라인 포함) + for (const match of indexContent.matchAll(/export type \{([^}]+)\}/gs)) { + for (const part of match[1].split(",")) { + const name = part.trim(); + if (name && /^\w+$/.test(name)) names.add(name); + } + } + + // export { A, B as C, ... } from (멀티라인 + as 별칭 포함) + for (const match of indexContent.matchAll(/export \{([^}]+)\} from/gs)) { + for (const part of match[1].split(",")) { + const trimmed = part.trim(); + if (!trimmed) continue; + const asMatch = trimmed.match(/^(\w+)\s+as\s+(\w+)$/); + if (asMatch) { + names.add(asMatch[1]); // 원본 이름 + names.add(asMatch[2]); // 별칭 + } else if (/^\w+$/.test(trimmed)) { + names.add(trimmed); + } + } + } + + return names; } function updateIndexTs(operationId, generatedFilePath) { - const indexPath = path.join(ROOT, "src/shared/api/index.ts"); - let indexContent = fs.readFileSync(indexPath, "utf-8"); - - if (indexContent.includes(`_generated/auth/${operationId}`)) { - console.log(`ℹ️ index.ts에 이미 등록됨: ${operationId}`); - return; - } - - const { typeNames, enumNames } = parseGeneratedFile(generatedFilePath); - const alreadyExported = getAlreadyExportedNames(indexContent); - - // 이미 다른 API에서 export된 타입은 제외 - const newTypeNames = typeNames.filter((name) => !alreadyExported.has(name)); - const newEnumNames = enumNames.filter((name) => !alreadyExported.has(name)); - - const skipped = [ - ...typeNames.filter((n) => alreadyExported.has(n)), - ...enumNames.filter((n) => alreadyExported.has(n)), - ]; - if (skipped.length > 0) { - console.log(`ℹ️ 중복 타입 스킵: ${skipped.join(", ")}`); - } - - const aliasName = toApiAliasName(operationId); - const relativePath = `./_generated/auth/${operationId}`; - - let newExports = `\n// ${operationId}\n`; - newExports += `export { getAssuApi as ${aliasName} } from "${relativePath}";\n`; - - if (newTypeNames.length > 0) { - newExports += `export type {\n`; - for (const name of newTypeNames) { - newExports += `\t${name},\n`; - } - newExports += `} from "${relativePath}";\n`; - } - - for (const name of newEnumNames) { - newExports += `export { ${name} } from "${relativePath}";\n`; - } - - indexContent = indexContent.trimEnd() + "\n" + newExports; - fs.writeFileSync(indexPath, indexContent); - console.log(`✅ index.ts 등록 완료`); + const indexPath = path.join(ROOT, "src/shared/api/index.ts"); + let indexContent = fs.readFileSync(indexPath, "utf-8"); + + if (indexContent.includes(`_generated/auth/${operationId}`)) { + console.log(`ℹ️ index.ts에 이미 등록됨: ${operationId}`); + return; + } + + const { typeNames, enumNames } = parseGeneratedFile(generatedFilePath); + const alreadyExported = getAlreadyExportedNames(indexContent); + + // 이미 다른 API에서 export된 타입은 제외 + const newTypeNames = typeNames.filter((name) => !alreadyExported.has(name)); + const newEnumNames = enumNames.filter((name) => !alreadyExported.has(name)); + + const skipped = [ + ...typeNames.filter((n) => alreadyExported.has(n)), + ...enumNames.filter((n) => alreadyExported.has(n)), + ]; + if (skipped.length > 0) { + console.log(`ℹ️ 중복 타입 스킵: ${skipped.join(", ")}`); + } + + const aliasName = toApiAliasName(operationId); + const relativePath = `./_generated/auth/${operationId}`; + + let newExports = `\n// ${operationId}\n`; + newExports += `export { getAssuApi as ${aliasName} } from "${relativePath}";\n`; + + if (newTypeNames.length > 0) { + newExports += `export type {\n`; + for (const name of newTypeNames) { + newExports += `\t${name},\n`; + } + newExports += `} from "${relativePath}";\n`; + } + + for (const name of newEnumNames) { + newExports += `export { ${name} } from "${relativePath}";\n`; + } + + indexContent = indexContent.trimEnd() + "\n" + newExports; + fs.writeFileSync(indexPath, indexContent); + console.log(`✅ index.ts 등록 완료`); } function createHookFile(operationId, generatedFilePath, method, feature) { - const { operationFnName, paramType } = parseGeneratedFile(generatedFilePath); - if (!operationFnName) { - console.warn(`⚠️ operationFnName을 찾지 못해 훅 생성을 건너뜁니다.`); - return; - } - - const hookName = toHookName(operationId, method); - const aliasName = toApiAliasName(operationId); - const isQuery = method === "GET"; - - const featureDir = path.join(ROOT, `src/features/${feature}/api`); - fs.mkdirSync(featureDir, { recursive: true }); - - const fileName = `${hookName}.ts`; - const filePath = path.join(featureDir, fileName); - - if (fs.existsSync(filePath)) { - console.log( - `ℹ️ 훅 파일 이미 존재: src/features/${feature}/api/${fileName}`, - ); - return; - } - - let content; - - if (isQuery && paramType) { - // GET + 파라미터 있음 - content = `import { useQuery } from "@tanstack/react-query"; + const { operationFnName, paramType } = parseGeneratedFile(generatedFilePath); + if (!operationFnName) { + console.warn(`⚠️ operationFnName을 찾지 못해 훅 생성을 건너뜁니다.`); + return; + } + + const hookName = toHookName(operationId, method); + const aliasName = toApiAliasName(operationId); + const isQuery = method === "GET"; + + const featureDir = path.join(ROOT, `src/features/${feature}/api`); + fs.mkdirSync(featureDir, { recursive: true }); + + const fileName = `${hookName}.ts`; + const filePath = path.join(featureDir, fileName); + + if (fs.existsSync(filePath)) { + console.log( + `ℹ️ 훅 파일 이미 존재: src/features/${feature}/api/${fileName}`, + ); + return; + } + + let content; + + if (isQuery && paramType) { + // GET + 파라미터 있음 + content = `import { useQuery } from "@tanstack/react-query"; import { ${aliasName} } from "@/shared/api"; import type { ${paramType} } from "@/shared/api"; @@ -315,9 +313,9 @@ export function ${hookName}(params: ${paramType}) { \t}); } `; - } else if (isQuery) { - // GET + 파라미터 없음 - content = `import { useQuery } from "@tanstack/react-query"; + } else if (isQuery) { + // GET + 파라미터 없음 + content = `import { useQuery } from "@tanstack/react-query"; import { ${aliasName} } from "@/shared/api"; const { ${operationFnName} } = ${aliasName}(); @@ -329,9 +327,9 @@ export function ${hookName}() { \t}); } `; - } else { - // POST / PUT / DELETE - content = `import { useMutation } from "@tanstack/react-query"; + } else { + // POST / PUT / DELETE + content = `import { useMutation } from "@tanstack/react-query"; import { ${aliasName} } from "@/shared/api"; const { ${operationFnName} } = ${aliasName}(); @@ -340,111 +338,113 @@ export function ${hookName}() { \treturn useMutation({ mutationFn: ${operationFnName} }); } `; - } + } - fs.writeFileSync(filePath, content); - console.log(`✅ 훅 생성: src/features/${feature}/api/${fileName}`); + fs.writeFileSync(filePath, content); + console.log(`✅ 훅 생성: src/features/${feature}/api/${fileName}`); } function getAvailableFeatures() { - const featuresDir = path.join(ROOT, "src/features"); - return fs - .readdirSync(featuresDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); + const featuresDir = path.join(ROOT, "src/features"); + return fs + .readdirSync(featuresDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); } function promptFeatureSelection(features) { - return new Promise((resolve) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - console.log("\n❓ --feature 가 없습니다. 어느 feature에 훅을 생성할까요?\n"); - features.forEach((name, i) => { - console.log(` ${i + 1}. ${name}`); - }); - console.log(` ${features.length + 1}. 훅 생성 건너뜀\n`); - - rl.question(`선택 (1-${features.length + 1}): `, (answer) => { - rl.close(); - const index = parseInt(answer, 10) - 1; - if (index >= 0 && index < features.length) { - resolve(features[index]); - } else { - resolve(null); - } - }); - }); + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + console.log( + "\n❓ --feature 가 없습니다. 어느 feature에 훅을 생성할까요?\n", + ); + features.forEach((name, i) => { + console.log(` ${i + 1}. ${name}`); + }); + console.log(` ${features.length + 1}. 훅 생성 건너뜀\n`); + + rl.question(`선택 (1-${features.length + 1}): `, (answer) => { + rl.close(); + const index = parseInt(answer, 10) - 1; + if (index >= 0 && index < features.length) { + resolve(features[index]); + } else { + resolve(null); + } + }); + }); } async function run() { - const spec = JSON.parse(fs.readFileSync(SPEC_PATH, "utf-8")); - - if (args.includes("--list")) { - listOperations(spec); - return; - } - - const operationsToProcess = targetOperationIds - ? targetOperationIds - : Object.values(spec.paths ?? {}).flatMap((pathItem) => - Object.values(pathItem) - .filter((op) => typeof op === "object" && op.operationId) - .map((op) => op.operationId), - ); - - console.log(`\n🔍 생성 대상: ${operationsToProcess.join(", ")}`); - - const operationMethods = getOperationMethods(spec, operationsToProcess); - - // --feature 없으면 대화형 선택 - let selectedFeature = featureArg; - if (!selectedFeature) { - const features = getAvailableFeatures(); - selectedFeature = await promptFeatureSelection(features); - } - - console.log(""); - const failed = []; - - for (const operationId of operationsToProcess) { - const tmpSpecPath = path.join(ROOT, `.orval.tmp.${operationId}.json`); - const outputTarget = path.join( - ROOT, - `src/shared/api/_generated/auth/${operationId}.ts`, - ); - - try { - const filteredSpec = buildFilteredSpec(spec, [operationId]); - fs.mkdirSync(path.dirname(outputTarget), { recursive: true }); - fs.writeFileSync(tmpSpecPath, JSON.stringify(filteredSpec, null, 2)); - - console.log(`⚙️ ${operationId} → _generated/auth/${operationId}.ts`); - runOrval(tmpSpecPath, outputTarget); - - updateIndexTs(operationId, outputTarget); - - if (selectedFeature) { - const method = operationMethods[operationId] ?? "POST"; - createHookFile(operationId, outputTarget, method, selectedFeature); - } - } catch (err) { - console.error(`❌ ${operationId} 처리 실패: ${err.message}`); - failed.push(operationId); - } finally { - if (fs.existsSync(tmpSpecPath)) { - fs.unlinkSync(tmpSpecPath); - } - } - } - - if (failed.length > 0) { - console.log(`\n⚠️ 실패한 항목: ${failed.join(", ")}\n`); - } else { - console.log("\n✅ 완료\n"); - } + const spec = JSON.parse(fs.readFileSync(SPEC_PATH, "utf-8")); + + if (args.includes("--list")) { + listOperations(spec); + return; + } + + const operationsToProcess = targetOperationIds + ? targetOperationIds + : Object.values(spec.paths ?? {}).flatMap((pathItem) => + Object.values(pathItem) + .filter((op) => typeof op === "object" && op.operationId) + .map((op) => op.operationId), + ); + + console.log(`\n🔍 생성 대상: ${operationsToProcess.join(", ")}`); + + const operationMethods = getOperationMethods(spec, operationsToProcess); + + // --feature 없으면 대화형 선택 + let selectedFeature = featureArg; + if (!selectedFeature) { + const features = getAvailableFeatures(); + selectedFeature = await promptFeatureSelection(features); + } + + console.log(""); + const failed = []; + + for (const operationId of operationsToProcess) { + const tmpSpecPath = path.join(ROOT, `.orval.tmp.${operationId}.json`); + const outputTarget = path.join( + ROOT, + `src/shared/api/_generated/auth/${operationId}.ts`, + ); + + try { + const filteredSpec = buildFilteredSpec(spec, [operationId]); + fs.mkdirSync(path.dirname(outputTarget), { recursive: true }); + fs.writeFileSync(tmpSpecPath, JSON.stringify(filteredSpec, null, 2)); + + console.log(`⚙️ ${operationId} → _generated/auth/${operationId}.ts`); + runOrval(tmpSpecPath, outputTarget); + + updateIndexTs(operationId, outputTarget); + + if (selectedFeature) { + const method = operationMethods[operationId] ?? "POST"; + createHookFile(operationId, outputTarget, method, selectedFeature); + } + } catch (err) { + console.error(`❌ ${operationId} 처리 실패: ${err.message}`); + failed.push(operationId); + } finally { + if (fs.existsSync(tmpSpecPath)) { + fs.unlinkSync(tmpSpecPath); + } + } + } + + if (failed.length > 0) { + console.log(`\n⚠️ 실패한 항목: ${failed.join(", ")}\n`); + } else { + console.log("\n✅ 완료\n"); + } } run(); 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/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/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 f8869aa..b99c845 100644 --- a/src/features/signup-user-flow/model/useSignupFlowController.ts +++ b/src/features/signup-user-flow/model/useSignupFlowController.ts @@ -1,11 +1,16 @@ import { router } from "expo-router"; -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; +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; const { step, form, @@ -19,8 +24,12 @@ export function useSignupFlowController() { setPhone, setVerificationCode, sendVerificationCode, + setIdentityVerified, setRole, setSchool, + setStudentMajor, + setStudentId, + setProfileName, setPartnerEmail, setPartnerPassword, setAdminEmail, @@ -46,10 +55,55 @@ export function useSignupFlowController() { agreementHandlers, } = useSignupFlowPresentation(); + const [studentAuthPayload, setStudentAuthPayload] = useState<{ + sIdno: string; + sToken: string; + } | null>(null); + + 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"); + }, + [goTo, setProfileName, setStudentId, setStudentMajor], + ); + + const { handlePressVerify, webView: studentAuthWebView } = + useStudentVerificationAction(handleVerified); + + const handleSignupFailure = useCallback(() => goTo("loginForm"), [goTo]); + + const { signup: handleStudentSignup } = useStudentSignupAction({ + studentAuthPayload, + agreePrivacy: form.agreements.agreePrivacy, + agreeMarketing: form.agreements.agreeMarketing, + onSuccess: goNext, + onFailure: handleSignupFailure, + }); + + const { handlePressLmsLogin, loginWebView } = useStudentLoginAction(); + + const sendIdentityVerificationCode = () => sendVerificationCode(); + const overlays = useSignupOverlays({ adminOfficeAddressId: form.admin.officeAddressId, partnerOfficeAddressId: form.partner.officeAddressId, - onSendVerificationCode: sendVerificationCode, + onSendVerificationCode: sendIdentityVerificationCode, onSelectAdminOfficeAddress: setAdminOfficeAddress, onSelectPartnerOfficeAddress: setPartnerOfficeAddress, }); @@ -65,11 +119,12 @@ export function useSignupFlowController() { identity: { setPhone, setVerificationCode, - sendVerificationCode, + sendVerificationCode: sendIdentityVerificationCode, }, student: { setRole, setSchool, + onPressStudentVerify: handlePressVerify, }, partner: { setPartnerEmail, @@ -101,25 +156,36 @@ 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) - : goNext, }), [ buttonLabel, currentProgressIndex, goNext, goTo, + handleStudentSignup, isBottomDisabled, progress, progressSteps, + setIdentityVerified, showBottomButton, showProgress, step, @@ -132,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 { @@ -152,5 +224,7 @@ export function useSignupFlowController() { actions: stepContentActions, } satisfies SignupFlowUiContextValue, overlays, + studentAuthWebView, + loginWebView, }; } 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/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/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/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/_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/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 8a0b5ab..70e99b3 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -1,4 +1,40 @@ 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, + 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..a5d725c 100644 --- a/src/shared/api/interceptors.ts +++ b/src/shared/api/interceptors.ts @@ -1,25 +1,104 @@ +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}`; } + console.log( + "[API REQ]", + config.method?.toUpperCase(), + config.url, + JSON.stringify(config.data), + ); return config; }); apiInstance.interceptors.response.use( - (response) => response, - (error) => { + (response) => { + console.log( + "[API RES]", + response.status, + response.config.url, + JSON.stringify(response.data), + ); + return response; + }, + async (error) => { const status = error.response?.status; + console.log( + "[API ERR]", + status, + error.config?.url, + JSON.stringify(error.response?.data), + ); + + 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); + } - if (status === 401) { - // TODO: 로그아웃 처리 (토큰 저장소 연동 후 구현) + // 이미 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/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/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/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..39e7660 100644 --- a/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx +++ b/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx @@ -8,14 +8,21 @@ 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 } = - 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 ( - + <> + + + ); } @@ -96,6 +112,13 @@ export function SignupUserFlowWidget() { onAction={overlays.resendSheet.action} /> + +