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..e2d67dc 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -1,13 +1,14 @@ 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'; +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,16 +19,52 @@ const CLERK_REDIRECT_URL = AuthSession.makeRedirectUri({ scheme: 'linclean', path: 'sso-callback', }); +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: '현재 세션이 유효하지 않아 로그인 화면으로 이동합니다.', + }, +}; + +function delay(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + 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; @@ -48,22 +85,8 @@ 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); @@ -84,6 +107,43 @@ 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; + } + return ( diff --git a/app/(tabs)/(home)/settings.tsx b/app/(tabs)/(home)/settings.tsx index 00363f6..27642ef 100644 --- a/app/(tabs)/(home)/settings.tsx +++ b/app/(tabs)/(home)/settings.tsx @@ -1,14 +1,18 @@ 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'; const version = Constants.expoConfig?.version ?? '—'; +type LoginNotice = 'withdrawal-complete' | 'session-expired'; function SectionLabel({ label }: { label: string }) { return {label}; @@ -42,7 +46,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('로그아웃', '로그아웃하시겠습니까?', [ @@ -52,12 +58,76 @@ export default function SettingsScreen() { style: 'destructive', onPress: async () => { await signOut(); - router.replace('/login' as any); + router.replace('/(auth)/login'); }, }, ]); } + 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(); + goToLoginWithNotice('withdrawal-complete'); + } catch (error) { + console.error(error); + + if (error instanceof ApiError && error.status === 401) { + await signOutSafely(); + goToLoginWithNotice('session-expired'); + return; + } + + isWithdrawingRef.current = false; + setIsWithdrawing(false); + Alert.alert( + '회원탈퇴 실패', + '회원탈퇴 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.', + ); + } + } + + async function signOutSafely() { + try { + await signOut(); + } catch (signOutError) { + console.error(signOutError); + } + } + + function goToLoginWithNotice(notice: LoginNotice) { + router.replace({ + pathname: '/(auth)/login', + params: { notice }, + }); + } + return ( @@ -113,7 +183,12 @@ export default function SettingsScreen() { - + 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'); } });