chahinebrini 23cc147231 feat(auth/ios): native Apple Sign-In via expo-apple-authentication
Vorher: stores/auth.ts hatte TODO + fiel auf Supabase-Web-OAuth-Flow zurück,
was fehlschlug mit 400 'Unsupported provider: missing OAuth client ID' weil
der Supabase-Apple-OAuth-Provider nicht konfiguriert ist.

Jetzt: native Flow ohne Supabase-Provider-Config —
- expo-apple-authentication.signInAsync() → identityToken
- supabase.auth.signInWithIdToken({provider:'apple', token}) verifiziert direkt
  gegen Apple's Public-Keys (kein Client-Secret-JWT-Setup nötig)
- User-Cancel (ERR_REQUEST_CANCELED) → leeres Resultat statt Error
- Platform-Guard: Apple-Path nur auf iOS

app.config.ts: ios.usesAppleSignIn=true → Expo prebuild generiert das
com.apple.developer.applesignin-Entitlement in die .entitlements. Beim
ersten EAS-Build wird die Capability auto-registriert im Apple-Developer-
Portal für org.rebreak.app.

signin.tsx + signup.tsx: Apple-Button conditional auf Platform.OS==='ios'
gerendert. Android-User sehen nur Google-Button (auf Android gibt es kein
natives Apple Sign-In).

App-Store-Submission-Pflicht (Apple Guideline 4.8 — wer OAuth-Login mit
3rd-Party-Provider anbietet, muss auch Apple Sign-In bieten).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:13:45 +02:00

303 lines
12 KiB
TypeScript

import { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
Image,
ActivityIndicator,
Platform,
} from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Path } from 'react-native-svg';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars';
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
function GoogleIcon() {
return (
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
<Path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<Path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<Path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</Svg>
);
}
function AppleIcon() {
return (
<Svg width={20} height={20} viewBox="0 0 24 24" fill="#0a0a0a">
<Path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</Svg>
);
}
type OAuthProvider = 'google' | 'apple' | null;
const INPUT_STYLE = {
fontSize: 16,
lineHeight: 22,
paddingVertical: 14,
paddingHorizontal: 16,
color: '#0a0a0a',
fontFamily: 'Nunito_400Regular',
} as const;
export default function SignUpScreen() {
const router = useRouter();
const { t } = useTranslation();
const { signUp, signInWithOAuth } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [nickname, setNickname] = useState('');
const [avatarId, setAvatarId] = useState('spider');
const [termsAccepted, setTermsAccepted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [oauthLoading, setOauthLoading] = useState<OAuthProvider>(null);
const isLoading = submitting || oauthLoading !== null;
const onOAuth = async (provider: 'google' | 'apple') => {
setError(null);
setOauthLoading(provider);
const res = await signInWithOAuth(provider);
setOauthLoading(null);
if (res.error) {
setError(res.error);
return;
}
router.replace('/(app)');
};
const onSubmit = async () => {
if (!email.trim() || !password || !nickname.trim()) {
setError(t('auth.fillRequired'));
return;
}
if (password.length < 8) {
setError(t('auth.passwordMin8'));
return;
}
if (!termsAccepted) {
setError(t('auth.pleaseAcceptTerms'));
return;
}
setError(null);
setSubmitting(true);
const res = await signUp(email.trim(), password, {
username: nickname.trim(),
firstName: firstName.trim() || undefined,
lastName: lastName.trim() || undefined,
avatarId,
avatarUrl: getAvatarUrl(avatarId),
});
setSubmitting(false);
if (res.error) {
setError(res.error);
return;
}
router.push({ pathname: '/confirm-otp', params: { email: email.trim() } });
};
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAwareScreen
scrollable
contentContainerStyle={{ flexGrow: 1, paddingBottom: 32, paddingHorizontal: 24, paddingTop: 32 }}
>
<Text className="text-3xl text-neutral-900 mb-1" style={{ fontFamily: 'Nunito_700Bold' }}>{t('auth.signupTitle')}</Text>
<Text className="text-base text-neutral-500 mb-8" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.signupSubtitle')}
</Text>
{/* OAuth Buttons */}
<TouchableOpacity
onPress={() => onOAuth('google')}
disabled={isLoading}
activeOpacity={0.8}
className="flex-row items-center justify-center gap-3 bg-white border border-neutral-200 rounded-xl mb-3 disabled:opacity-40"
style={{ paddingVertical: 14 }}
>
{oauthLoading === 'google' ? (
<ActivityIndicator color="#0a0a0a" size="small" />
) : (
<GoogleIcon />
)}
<Text className="text-neutral-900 text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.googleSignup')}</Text>
</TouchableOpacity>
{Platform.OS === 'ios' ? (
<TouchableOpacity
onPress={() => onOAuth('apple')}
disabled={isLoading}
activeOpacity={0.8}
className="flex-row items-center justify-center gap-3 bg-neutral-900 rounded-xl mb-6 disabled:opacity-40"
style={{ paddingVertical: 14 }}
>
{oauthLoading === 'apple' ? (
<ActivityIndicator color="white" size="small" />
) : (
<AppleIcon />
)}
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.appleSignup')}</Text>
</TouchableOpacity>
) : null}
{/* Divider */}
<View className="flex-row items-center mb-6">
<View className="flex-1 h-px bg-neutral-200" />
<Text className="text-neutral-400 text-xs mx-3" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.orWithEmail')}</Text>
<View className="flex-1 h-px bg-neutral-200" />
</View>
{error && (
<View className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-4">
<Text className="text-red-600 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{error}</Text>
</View>
)}
<TextInput
className="bg-neutral-100 rounded-xl mb-3"
style={INPUT_STYLE}
placeholder={t('auth.emailRequired')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput
className="bg-neutral-100 rounded-xl mb-3"
style={INPUT_STYLE}
placeholder={t('auth.passwordRequired')}
placeholderTextColor="#a3a3a3"
secureTextEntry
autoComplete="new-password"
value={password}
onChangeText={setPassword}
/>
<View className="flex-row gap-3 mb-3">
<TextInput
className="flex-1 bg-neutral-100 rounded-xl"
style={INPUT_STYLE}
placeholder={t('auth.firstName')}
placeholderTextColor="#a3a3a3"
autoComplete="given-name"
value={firstName}
onChangeText={setFirstName}
/>
<TextInput
className="flex-1 bg-neutral-100 rounded-xl"
style={INPUT_STYLE}
placeholder={t('auth.lastName')}
placeholderTextColor="#a3a3a3"
autoComplete="family-name"
value={lastName}
onChangeText={setLastName}
/>
</View>
<TextInput
className="bg-neutral-100 rounded-xl mb-6"
style={INPUT_STYLE}
placeholder={t('auth.nicknamePlaceholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoComplete="username"
value={nickname}
onChangeText={setNickname}
/>
{/* Avatar Picker */}
<Text className="text-sm text-neutral-700 mb-3" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.chooseAvatar')}</Text>
<View className="flex-row flex-wrap gap-3 mb-6">
{HERO_AVATARS.map((avatar) => {
const selected = avatar.id === avatarId;
return (
<TouchableOpacity
key={avatar.id}
onPress={() => setAvatarId(avatar.id)}
disabled={isLoading}
activeOpacity={0.7}
className={`rounded-full ${selected ? 'opacity-100' : 'opacity-40'}`}
>
<Image
source={{ uri: avatar.url }}
className={`w-14 h-14 rounded-full border-2 ${selected ? avatar.color : 'border-transparent'}`}
style={{ width: 56, height: 56, borderRadius: 28 }}
/>
</TouchableOpacity>
);
})}
</View>
{/* Privacy notice */}
<View className="flex-row gap-3 bg-neutral-50 border border-neutral-200 rounded-xl p-4 mb-4">
<Text className="text-rebreak-500 text-base mt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>&#x1F6E1;</Text>
<Text className="flex-1 text-xs text-neutral-500 leading-5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.privacyNotice')}
</Text>
</View>
{/* Terms Checkbox */}
<TouchableOpacity
onPress={() => setTermsAccepted(!termsAccepted)}
disabled={isLoading}
activeOpacity={0.7}
className="flex-row items-start gap-3 mb-6"
>
<View
className={`w-5 h-5 rounded border-2 mt-0.5 items-center justify-center ${
termsAccepted ? 'bg-rebreak-500 border-rebreak-500' : 'border-neutral-300'
}`}
>
{termsAccepted && (
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}></Text>
)}
</View>
<Text className="flex-1 text-sm text-neutral-500 leading-5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.acceptTerms')}{' '}
<Text className="text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.termsLink')}</Text>
{t('auth.acceptTermsSuffix')}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onSubmit}
disabled={isLoading || !email || !password || !nickname || !termsAccepted}
activeOpacity={0.8}
className="bg-rebreak-500 rounded-xl items-center disabled:opacity-40"
style={{ paddingVertical: 16 }}
>
{submitting ? (
<ActivityIndicator color="white" size="small" />
) : (
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.signupTitle')}</Text>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={() => router.push('/signin')}
activeOpacity={0.7}
className="py-4 items-center mt-2"
>
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.alreadyRegistered')}{' '}
<Text className="text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.signin')}</Text>
</Text>
</TouchableOpacity>
</KeyboardAwareScreen>
</SafeAreaView>
);
}