From a889ea0a043825aece6ab499d75ea78521dd61ee Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Fri, 22 May 2026 19:30:01 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/members.ts | 10 +++++ app/(auth)/login.tsx | 68 ++++++++++++++++++++++------- app/(tabs)/(home)/settings.tsx | 80 +++++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 api/members.ts diff --git a/api/members.ts b/api/members.ts new file mode 100644 index 0000000..527ff76 --- /dev/null +++ b/api/members.ts @@ -0,0 +1,10 @@ +import { + authenticatedApiRequest, + type ClerkTokenGetter, +} from '@/api/api-client'; + +export function withdrawMember(getToken: ClerkTokenGetter) { + return authenticatedApiRequest(getToken, '/api/v1/members/me', { + method: 'DELETE', + }); +} diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index 7245f1d..4acfd52 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -8,6 +8,7 @@ import { useState } from 'react'; import { Alert, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ApiError } from '@/api/api-client'; import { SocialLoginButton } from '@/components/ui/social-login-button'; import { Colors, Typography } from '@/constants/theme'; import { syncAuthenticatedMember } from '@/services/auth-api'; @@ -18,6 +19,7 @@ const CLERK_REDIRECT_URL = AuthSession.makeRedirectUri({ scheme: 'linclean', path: 'sso-callback', }); +const MEMBER_SYNC_RETRY_DELAYS_MS = [200, 500, 1000]; WebBrowser.maybeCompleteAuthSession(); @@ -48,25 +50,16 @@ export default function LoginScreen() { await setActive({ session: createdSessionId }); sessionActivated = true; - await syncAuthenticatedMember(getToken); - - const sharedUrl = hasShareIntent ? getSharedUrlFromIntent(shareIntent) : null; - - if (hasShareIntent) { - resetShareIntent(true); - } - - if (sharedUrl) { - router.replace({ - pathname: '/(tabs)/(home)/add-link', - params: { sharedUrl }, - }); - } else { - router.replace('/(tabs)/(home)'); - } + await syncAuthenticatedMemberAfterSso(); + navigateAfterSuccessfulLogin(); } catch (error) { console.error(error); + if (error instanceof ApiError && error.status === 401) { + navigateAfterSuccessfulLogin(); + return; + } + if (sessionActivated) { try { await signOut(); @@ -84,6 +77,49 @@ export default function LoginScreen() { } }; + function navigateAfterSuccessfulLogin() { + const sharedUrl = hasShareIntent ? getSharedUrlFromIntent(shareIntent) : null; + + if (hasShareIntent) { + resetShareIntent(true); + } + + if (sharedUrl) { + router.replace({ + pathname: '/(tabs)/(home)/add-link', + params: { sharedUrl }, + }); + } else { + router.replace('/(tabs)/(home)'); + } + } + + async function syncAuthenticatedMemberAfterSso() { + let lastError: unknown; + + for (const retryDelayMs of MEMBER_SYNC_RETRY_DELAYS_MS) { + await delay(retryDelayMs); + + try { + return await syncAuthenticatedMember(() => getToken({ skipCache: true })); + } catch (error) { + lastError = error; + + if (!(error instanceof ApiError) || error.status !== 401) { + throw error; + } + } + } + + throw lastError; + } + + function delay(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } + return ( diff --git a/app/(tabs)/(home)/settings.tsx b/app/(tabs)/(home)/settings.tsx index 00363f6..4678acc 100644 --- a/app/(tabs)/(home)/settings.tsx +++ b/app/(tabs)/(home)/settings.tsx @@ -1,9 +1,12 @@ import { useAuth } from '@clerk/expo'; import Constants from 'expo-constants'; import { router } from 'expo-router'; +import { useRef, useState } from 'react'; import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { ApiError } from '@/api/api-client'; +import { withdrawMember } from '@/api/members'; import { AppIcon } from '@/components/ui/app-icon'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors, Typography } from '@/constants/theme'; @@ -42,7 +45,9 @@ function SettingRow({ label, onPress, rightText, destructive = false, showChevro } export default function SettingsScreen() { - const { signOut } = useAuth(); + const { getToken, signOut } = useAuth(); + const [isWithdrawing, setIsWithdrawing] = useState(false); + const isWithdrawingRef = useRef(false); function handleLogout() { Alert.alert('로그아웃', '로그아웃하시겠습니까?', [ @@ -58,6 +63,72 @@ export default function SettingsScreen() { ]); } + function handleWithdraw() { + if (isWithdrawingRef.current) { + return; + } + + Alert.alert( + '회원탈퇴', + '회원탈퇴하시겠습니까? 탈퇴 후에는 현재 계정으로 서비스를 이용할 수 없습니다.', + [ + { text: '취소', style: 'cancel' }, + { + text: '회원탈퇴', + style: 'destructive', + onPress: withdrawAccount, + }, + ], + ); + } + + async function withdrawAccount() { + if (isWithdrawingRef.current) { + return; + } + + isWithdrawingRef.current = true; + setIsWithdrawing(true); + + try { + await withdrawMember(getToken); + await signOutSafely(); + showLoginAlert('회원탈퇴 완료', '회원탈퇴가 완료되었습니다.'); + } catch (error) { + console.error(error); + + if (error instanceof ApiError && error.status === 401) { + await signOutSafely(); + showLoginAlert('세션 만료', '현재 세션이 유효하지 않아 로그인 화면으로 이동합니다.'); + return; + } + + isWithdrawingRef.current = false; + setIsWithdrawing(false); + Alert.alert( + '회원탈퇴 실패', + '회원탈퇴 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.', + ); + } + } + + async function signOutSafely() { + try { + await signOut(); + } catch (signOutError) { + console.error(signOutError); + } + } + + function showLoginAlert(title: string, message: string) { + Alert.alert(title, message, [ + { + text: '확인', + onPress: () => router.replace('/login' as any), + }, + ]); + } + return ( @@ -113,7 +184,12 @@ export default function SettingsScreen() { - + From 5bfdd8260113de1b1278b97681c416aa4e4d905e Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Fri, 22 May 2026 19:47:32 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=99=84=EB=A3=8C=20=EC=95=8C=EB=A6=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=ED=9D=90=EB=A6=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/settings.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/app/(tabs)/(home)/settings.tsx b/app/(tabs)/(home)/settings.tsx index 4678acc..e553625 100644 --- a/app/(tabs)/(home)/settings.tsx +++ b/app/(tabs)/(home)/settings.tsx @@ -93,13 +93,13 @@ export default function SettingsScreen() { try { await withdrawMember(getToken); await signOutSafely(); - showLoginAlert('회원탈퇴 완료', '회원탈퇴가 완료되었습니다.'); + goToLoginWithAlert('회원탈퇴 완료', '회원탈퇴가 완료되었습니다.'); } catch (error) { console.error(error); if (error instanceof ApiError && error.status === 401) { await signOutSafely(); - showLoginAlert('세션 만료', '현재 세션이 유효하지 않아 로그인 화면으로 이동합니다.'); + goToLoginWithAlert('세션 만료', '현재 세션이 유효하지 않아 로그인 화면으로 이동합니다.'); return; } @@ -120,13 +120,11 @@ export default function SettingsScreen() { } } - function showLoginAlert(title: string, message: string) { - Alert.alert(title, message, [ - { - text: '확인', - onPress: () => router.replace('/login' as any), - }, - ]); + function goToLoginWithAlert(title: string, message: string) { + router.replace('/login' as any); + setTimeout(() => { + Alert.alert(title, message); + }, 0); } return ( From f89ec2cad9d7581643f14224517ba4981a5e8c8b Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 23 May 2026 02:44:29 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=ED=83=80=EC=9E=85=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/settings.tsx | 4 ++-- app/(tabs)/_layout.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/(tabs)/(home)/settings.tsx b/app/(tabs)/(home)/settings.tsx index e553625..8c94471 100644 --- a/app/(tabs)/(home)/settings.tsx +++ b/app/(tabs)/(home)/settings.tsx @@ -57,7 +57,7 @@ export default function SettingsScreen() { style: 'destructive', onPress: async () => { await signOut(); - router.replace('/login' as any); + router.replace('/(auth)/login'); }, }, ]); @@ -121,7 +121,7 @@ export default function SettingsScreen() { } function goToLoginWithAlert(title: string, message: string) { - router.replace('/login' as any); + router.replace('/(auth)/login'); setTimeout(() => { Alert.alert(title, message); }, 0); diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 2005dfa..3ca89c6 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -98,7 +98,7 @@ export default function TabLayout() { if (isMounted) { syncedSessionIdRef.current = null; setHasSyncedMember(false); - router.replace('/login' as any); + router.replace('/(auth)/login'); } }); From ccc57f66cc42a63bab9a016aadde184b966655cb Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 23 May 2026 02:45:08 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=ED=91=9C=EC=8B=9C=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(auth)/login.tsx | 33 +++++++++++++++++++++++++++++++-- app/(tabs)/(home)/settings.tsx | 15 ++++++++------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index 4acfd52..9195839 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -1,10 +1,10 @@ import { useAuth, useSSO } from '@clerk/expo'; import * as AuthSession from 'expo-auth-session'; import { Image } from 'expo-image'; -import { router } from 'expo-router'; +import { router, useLocalSearchParams } from 'expo-router'; import { useShareIntentContext } from 'expo-share-intent'; import * as WebBrowser from 'expo-web-browser'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Alert, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -23,13 +23,42 @@ const MEMBER_SYNC_RETRY_DELAYS_MS = [200, 500, 1000]; WebBrowser.maybeCompleteAuthSession(); +type LoginNotice = 'withdrawal-complete' | 'session-expired'; + +const LOGIN_NOTICE_ALERTS: Record = { + 'withdrawal-complete': { + title: '회원탈퇴 완료', + message: '회원탈퇴가 완료되었습니다.', + }, + 'session-expired': { + title: '세션 만료', + message: '현재 세션이 유효하지 않아 로그인 화면으로 이동합니다.', + }, +}; + export default function LoginScreen() { const insets = useSafeAreaInsets(); const { getToken, signOut } = useAuth(); const { startSSOFlow } = useSSO(); const { hasShareIntent, resetShareIntent, shareIntent } = useShareIntentContext(); + const { notice } = useLocalSearchParams<{ notice?: LoginNotice }>(); + const shownNoticeRef = useRef(null); const [isSigningIn, setIsSigningIn] = useState(false); + useEffect(() => { + if (!notice || shownNoticeRef.current === notice) { + return; + } + + const alert = LOGIN_NOTICE_ALERTS[notice]; + if (!alert) { + return; + } + + shownNoticeRef.current = notice; + Alert.alert(alert.title, alert.message); + }, [notice]); + const handleGoogleLogin = async () => { if (isSigningIn) { return; diff --git a/app/(tabs)/(home)/settings.tsx b/app/(tabs)/(home)/settings.tsx index 8c94471..27642ef 100644 --- a/app/(tabs)/(home)/settings.tsx +++ b/app/(tabs)/(home)/settings.tsx @@ -12,6 +12,7 @@ import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors, Typography } from '@/constants/theme'; const version = Constants.expoConfig?.version ?? '—'; +type LoginNotice = 'withdrawal-complete' | 'session-expired'; function SectionLabel({ label }: { label: string }) { return {label}; @@ -93,13 +94,13 @@ export default function SettingsScreen() { try { await withdrawMember(getToken); await signOutSafely(); - goToLoginWithAlert('회원탈퇴 완료', '회원탈퇴가 완료되었습니다.'); + goToLoginWithNotice('withdrawal-complete'); } catch (error) { console.error(error); if (error instanceof ApiError && error.status === 401) { await signOutSafely(); - goToLoginWithAlert('세션 만료', '현재 세션이 유효하지 않아 로그인 화면으로 이동합니다.'); + goToLoginWithNotice('session-expired'); return; } @@ -120,11 +121,11 @@ export default function SettingsScreen() { } } - function goToLoginWithAlert(title: string, message: string) { - router.replace('/(auth)/login'); - setTimeout(() => { - Alert.alert(title, message); - }, 0); + function goToLoginWithNotice(notice: LoginNotice) { + router.replace({ + pathname: '/(auth)/login', + params: { notice }, + }); } return ( From 9515d67ef2a485659ff05b3c6d2740d27159349a Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 23 May 2026 02:56:46 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EC=B5=9C=EC=A2=85=20401=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=84=B1=EA=B3=B5=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(auth)/login.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index 9195839..e2ce382 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -84,11 +84,6 @@ export default function LoginScreen() { } catch (error) { console.error(error); - if (error instanceof ApiError && error.status === 401) { - navigateAfterSuccessfulLogin(); - return; - } - if (sessionActivated) { try { await signOut(); From 9ff48d29d4580f6079bfe4bdd854feb36ad76501 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 23 May 2026 03:01:08 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20delay=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(auth)/login.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index e2ce382..e2d67dc 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -36,6 +36,12 @@ const LOGIN_NOTICE_ALERTS: Record((resolve) => { + setTimeout(resolve, ms); + }); +} + export default function LoginScreen() { const insets = useSafeAreaInsets(); const { getToken, signOut } = useAuth(); @@ -138,12 +144,6 @@ export default function LoginScreen() { throw lastError; } - function delay(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); - } - return (