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
10 changes: 10 additions & 0 deletions api/members.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {
authenticatedApiRequest,
type ClerkTokenGetter,
} from '@/api/api-client';

export function withdrawMember(getToken: ClerkTokenGetter) {
return authenticatedApiRequest<void>(getToken, '/api/v1/members/me', {
method: 'DELETE',
});
}
96 changes: 78 additions & 18 deletions app/(auth)/login.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<LoginNotice, { title: string; message: string }> = {
'withdrawal-complete': {
title: '회원탈퇴 완료',
message: '회원탈퇴가 완료되었습니다.',
},
'session-expired': {
title: '세션 만료',
message: '현재 세션이 유효하지 않아 로그인 화면으로 이동합니다.',
},
};

function delay(ms: number) {
return new Promise<void>((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<LoginNotice | null>(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;
Expand All @@ -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);

Expand All @@ -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 (
<View style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View style={styles.logoSection}>
Expand Down
81 changes: 78 additions & 3 deletions app/(tabs)/(home)/settings.tsx
Original file line number Diff line number Diff line change
@@ -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 <Text style={styles.sectionLabel}>{label}</Text>;
Expand Down Expand Up @@ -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('로그아웃', '로그아웃하시겠습니까?', [
Expand All @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

재시도 로직에서 마지막 401도 성공 처리

현재 코드:

  // syncAuthenticatedMemberAfterSso가 401로 throw한 경우도 성공으로 처리
  if (error instanceof ApiError && error.status === 401) {
    navigateAfterSuccessfulLogin();
    return;
  } 

3번 재시도 후에도 401이면 member 동기화 실패 상태로 홈 화면 이동합니다.
탈퇴 후 재가입 케이스를 의도한 것으로 보이는데, 이 케이스가 정말 의도된 동작인지 확인 부탁드립니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

확인했습니다! 해당 분기는 제가 테스트 중 계정 탈퇴 후 같은 계정으로 바로 재로그인했을 때 /auth/me에서 401이 발생해, 사용자가 로그인을 두 번 시도해야 하는 불편함을 줄이기 위해 추가했던 처리였습니다.

다만 현재는 syncAuthenticatedMemberAfterSso() 내부에서 401에 대해 3번 재시도하도록 보완되어 있으므로, 재시도 이후에도 계속 401이 발생한다면 더 이상 성공 로그인으로 간주하기 어렵다고 판단했습니다.

따라서 최종 401을 성공처럼 처리하던 분기는 제거했습니다.
이제 401은 재시도 대상까지만 허용하고, 마지막까지 실패하면 기존 로그인 실패 흐름으로 처리됩니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

계정 탈퇴 이후 바로 재로그인을 시도 하면 애초에 막아야 한다고 생각이 됩니다.
물론 이부분은 회의를 통해서 30일 이전 복구 정책에 대해서 논의를 해봐야 하지만
탈퇴 이후 해당 계정으로 로그인을 하지 못하거나 복구 페이지로 가는 등의 결정이 필요해 보입니다.

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 (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.header}>
Expand Down Expand Up @@ -113,7 +183,12 @@ export default function SettingsScreen() {
<View style={styles.group}>
<SettingRow label="로그아웃" onPress={handleLogout} />
<View style={styles.divider} />
<SettingRow label="회원탈퇴" destructive />
<SettingRow
label="회원탈퇴"
destructive
rightText={isWithdrawing ? '처리 중' : undefined}
onPress={isWithdrawing ? undefined : handleWithdraw}
/>
</View>
</ScrollView>
</SafeAreaView>
Expand Down
2 changes: 1 addition & 1 deletion app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export default function TabLayout() {
if (isMounted) {
syncedSessionIdRef.current = null;
setHasSyncedMember(false);
router.replace('/login' as any);
router.replace('/(auth)/login');
}
});

Expand Down
Loading