Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/mobile/api/performAppleLogin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as AppleAuthentication from 'expo-apple-authentication';

/**
* expo-apple-authentication으로 Apple 로그인 실행
* authorizationCode를 반환하며, 백엔드 API 호출은 Web에서 처리
*/
export const performAppleLogin = async (): Promise<{
authorizationCode: string;
}> => {
const result = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});

if (!result.authorizationCode) {
throw new Error('Apple로부터 인증 코드를 받지 못했습니다.');
}

return { authorizationCode: result.authorizationCode };
};
17 changes: 17 additions & 0 deletions apps/mobile/api/performKakaoLogin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { login } from '@react-native-kakao/user';

/**
* Kakao native SDK로 로그인 실행
* accessToken을 반환하며, 백엔드 API 호출은 Web에서 처리
*/
export const performKakaoLogin = async (): Promise<{
accessToken: string;
}> => {
const result = await login();

if (!result.accessToken) {
throw new Error('카카오 액세스 토큰을 받지 못했습니다.');
}

return { accessToken: result.accessToken };
};
15 changes: 15 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ExpoConfig } from 'expo/config';
import appJson from './app.json';

const config: ExpoConfig = {
...appJson.expo,
plugins: [
...(appJson.expo.plugins ?? []),
[
'@react-native-kakao/core',
{ nativeAppKey: process.env.KAKAO_NATIVE_APP_KEY ?? '' },
Comment thread
yummjin marked this conversation as resolved.
],
],
};

export default config;
6 changes: 5 additions & 1 deletion apps/mobile/app.json
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우선순위가 높은 작업은 아니지만, app.config.ts에서 app.json 오버라이딩 대신 app.config.ts로 합치고 app.json 제거해도 좋을거 같아요!

Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@
"favicon": "./assets/images/favicon.png"
},
"plugins": [
["expo-build-properties", { "android": { "usesCleartextTraffic": true } }]
[
"expo-build-properties",
{ "android": { "usesCleartextTraffic": true } }
],
["expo-apple-authentication"]
],
"experiments": {
"typedRoutes": true,
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import { WebView } from '@/shared/lib/bridge';
import { WebView } from '@/bridge';
import { SafeAreaView, SafeAreaProvider } from 'react-native-safe-area-context';
import type { WebView as WebViewType } from 'react-native-webview';
import { BackHandler, StyleSheet } from 'react-native';
import * as Linking from 'expo-linking';
import { WEBVIEW_URL } from '@/shared/constants/url';
import { WEBVIEW_URL } from '@/constants/url';
import CustomAnimatedSplash from './splash-screen';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import {
NAVER_MAP_ANDROID_APP_STORE_URL,
NAVER_MAP_APP_STORE_URL,
WEBVIEW_URL,
} from '@/shared/constants/url';
} from '@/constants/url';
import { performKakaoLogin } from '@/api/performKakaoLogin';
import { performAppleLogin } from '@/api/performAppleLogin';

/**
* Web -> Native 브릿지 설정
Expand Down Expand Up @@ -78,6 +80,31 @@ export const appBridge = bridge<AppBridge>({
async openLocationSettings(): Promise<void> {
await RNLinking.openSettings();
},

// 인증 메서드
async socialLogin(type) {
try {
if (type === 'kakao') {
const credentials = await performKakaoLogin();
return {
success: true,
accessToken: credentials.accessToken,
};
} else {
const credentials = await performAppleLogin();
return {
success: true,
authorizationCode: credentials.authorizationCode,
};
}
} catch (error) {
return {
success: false,
message:
error instanceof Error ? error.message : '로그인에 실패했습니다.',
};
}
},
});

/**
Expand Down
File renamed without changes.
4 changes: 4 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
},
"dependencies": {
"@azit/bridge": "workspace:*",
"@react-native-kakao/core": "^1.1.0",
"@react-native-kakao/user": "^1.1.0",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
Expand All @@ -31,6 +33,8 @@
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-apple-authentication": "~7.2.4",
"expo-secure-store": "~14.0.1",
"expo-web-browser": "~15.0.10",
"react": "catalog:react19.1",
"react-dom": "catalog:react19.1",
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/providers/AuthInitializer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ export function AuthInitializer({ children }: AuthInitializerProps) {
const initAuth = async () => {
try {
const response = await postReissueToken();

const { accessToken, status, crewId } = response.result;

setAccessToken(accessToken);

switch (status) {
Expand All @@ -55,7 +55,6 @@ export function AuthInitializer({ children }: AuthInitializerProps) {
if (inactiveActivities.includes(currentActivity)) {
redirectTargetRef.current = 'HomePage';
replace('HomePage', {}, { animate: false });
// 심사 위해 임시로 스토어 페이지를 홈페이지로 사용
}
break;
case 'WAITING_FOR_APPROVE':
Expand Down Expand Up @@ -86,6 +85,8 @@ export function AuthInitializer({ children }: AuthInitializerProps) {
};

initAuth();
// isInitialized 변경 시에만 실행 (의도적)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isInitialized]);
Comment thread
yummjin marked this conversation as resolved.

const isRedirecting =
Expand Down
109 changes: 57 additions & 52 deletions apps/web/src/pages/auth/hooks/useSocialLogin.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,72 @@
import { useCallback, useRef } from 'react';
import { useCallback } from 'react';

import { useKakaoLogin } from '@/features/auth/model';
import { useFlow } from '@/app/routes/stackflow';

import { postSocialLogin } from '@/features/auth/api/postSocialLogin';
import { useKakaoLogin } from '@/features/auth/model/useKakaoLogin';

import type { AuthProvider } from '@/shared/api/models/auth';
import { AUTH_PROVIDER } from '@/shared/constants/auth';
import {
APPLE_AUTHORIZE_URL,
// KAKAO_AUTHORIZE_URL,
// KAKAO_REST_API_KEY,
} from '@/shared/constants/url';
import { APPLE_AUTHORIZE_URL } from '@/shared/constants/url';
import { bridge } from '@/shared/lib/bridge';
import { isWebView } from '@/shared/lib/env';
import { useAuthStore } from '@/shared/store/auth';

export const useSocialLogin = () => {
const { handleKakaoLogin } = useKakaoLogin({
onSuccess: () => {},
onError: (loginError) => {
console.error(`로그인 실패 ${loginError.message}`);
},
});

const loginWithKakao = () => {
handleKakaoLogin();
const { replace } = useFlow();
const { setAccessToken } = useAuthStore();
const { handleKakaoLogin } = useKakaoLogin();

// const isAndroid = /Android/i.test(navigator.userAgent);
const loginWith = useCallback(
async (provider: AuthProvider) => {
if (!isWebView()) {
if (provider === AUTH_PROVIDER.KAKAO) {
handleKakaoLogin();
} else if (provider === AUTH_PROVIDER.APPLE) {
window.location.href = APPLE_AUTHORIZE_URL;
}
return;
}

// if (isAndroid) {
// window.location.href = `https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${KAKAO_REST_API_KEY}&redirect_uri=${KAKAO_AUTHORIZE_URL}`;
// } else {
// handleKakaoLogin();
// }
};
const type = provider === AUTH_PROVIDER.KAKAO ? 'kakao' : 'apple';

const isDisabledRef = useRef(false);
const timeoutRef = useRef<number | null>(null);
const authResult = await bridge.socialLogin(type);
if (!authResult.success) {
console.error(`OAuth 실패: ${authResult.message}`);
return;
}

const preventMultipleClicks = (ms: number) => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
const request =
provider === AUTH_PROVIDER.KAKAO
? { accessToken: authResult.accessToken }
: { authorizationCode: authResult.authorizationCode };
const response = await postSocialLogin(provider, request);

isDisabledRef.current = true;
timeoutRef.current = window.setTimeout(() => {
isDisabledRef.current = false;
timeoutRef.current = null;
}, ms);
};
const { accessToken, status, crewId } = response.result;
setAccessToken(accessToken);

const loginWithApple = () => {
if (isDisabledRef.current) return;
preventMultipleClicks(2000);
window.location.href = `${APPLE_AUTHORIZE_URL}&state=${window.location.origin}`;
};

const loginWith = useCallback(async (provider: AuthProvider) => {
switch (provider) {
case AUTH_PROVIDER.KAKAO:
return loginWithKakao();
case AUTH_PROVIDER.APPLE:
return loginWithApple();
default:
throw new Error('Invalid provider');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
switch (status) {
case 'PENDING_TERMS':
replace('TermAgreePage', {}, { animate: false });
break;
case 'PENDING_ONBOARDING':
replace('OnboardingPage', {}, { animate: false });
break;
case 'ACTIVE':
replace('HomePage', {}, { animate: false });
break;
case 'WAITING_FOR_APPROVE':
case 'APPROVED_PENDING_CONFIRM':
case 'REJECTED_PENDING_CONFIRM':
replace('CrewJoinStatusPage', { crewId }, { animate: false });
break;
case 'KICKED_PENDING_CONFIRM':
replace('CrewBannedStatusPage', {}, { animate: false });
break;
}
},
[handleKakaoLogin]

Check warning on line 68 in apps/web/src/pages/auth/hooks/useSocialLogin.ts

View workflow job for this annotation

GitHub Actions / ci / ci

React Hook useCallback has missing dependencies: 'replace' and 'setAccessToken'. Either include them or remove the dependency array
);

return { loginWith };
};
22 changes: 17 additions & 5 deletions apps/web/src/pages/auth/ui/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { useKakaoSDK } from '@/features/auth/model';

import type { AuthProvider } from '@/shared/api/models/auth';
import { AUTH_PROVIDER } from '@/shared/constants/auth';
import { isWebView } from '@/shared/lib/env';

export function LoginPage() {
useKakaoSDK();
const { loginWith } = useSocialLogin();
const { isLoaded: isKakaoReady } = useKakaoSDK();

const handleLogin = async (provider: AuthProvider) => {
await loginWith(provider);
Expand All @@ -36,17 +37,26 @@ export function LoginPage() {
className={styles.loginImage}
/>
<div className={styles.buttonWrapper}>
<KakaoLogin onClick={() => handleLogin(AUTH_PROVIDER.KAKAO)} />
<KakaoLogin
onClick={() => handleLogin(AUTH_PROVIDER.KAKAO)}
disabled={!isWebView() && !isKakaoReady}
/>
<AppleLogin onClick={() => handleLogin(AUTH_PROVIDER.APPLE)} />
</div>
</section>
</AppScreen>
);
}

function KakaoLogin({ onClick }: { onClick: () => void }) {
function KakaoLogin({
onClick,
disabled,
}: {
onClick: () => void;
disabled?: boolean;
}) {
return (
<Button state="kakao" onClick={onClick}>
<Button state="kakao" onClick={onClick} disabled={disabled}>
<div className={styles.textWrapper}>
<img
className={styles.kakaoIcon}
Expand All @@ -62,7 +72,9 @@ function KakaoLogin({ onClick }: { onClick: () => void }) {
function AppleLogin({ onClick }: { onClick: () => void }) {
const ua = navigator.userAgent;

if (!/iPhone|iPad|iPod/.test(ua)) {
// WebView: 네이티브 SDK 사용 → iOS 기기에서만 노출
// 웹 브라우저: Apple OAuth 리다이렉트 사용 → 항상 노출
if (isWebView() && !/iPhone|iPad|iPod/.test(ua)) {
return null;
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/shared/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const authApi = baseApi.extend({
},
],
afterResponse: [
// 401 에러 발생시, 새 토큰으로 재시도
// 401 에러 발생시, Native Bridge를 통해 토큰 재발급 후 재시도
async (request, _options, response, state) => {
if (response.status === 401 && state.retryCount === 0) {
if (!refreshPromise) {
Expand Down
Loading
Loading