← back to CelebritySignatures
apps/mobile/screens/AccountScreen.tsx
617 lines
// AccountScreen — auth + account management.
// Apple App Store Review requirement 5.1.1(v): apps that support account creation
// MUST also offer in-app account deletion. The "Delete my account" button at the
// bottom of the signed-in view satisfies this requirement. The backend endpoint
// POST /api/auth/delete performs the irreversible deletion server-side.
//
// Usage: <AccountScreen /> (no props — self-contained)
// Rendered inside App's GlassScene; does NOT wrap itself in one.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import * as Haptics from 'expo-haptics';
import { api, User, appleSignIn } from '../api';
import { GLASS, GlassButton, GlassPanel } from '../ui/glass';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type AuthMode = 'signin' | 'signup';
type CheckState = 'checking' | 'done';
// ---------------------------------------------------------------------------
// Segmented toggle: "Sign in" | "Create account"
// ---------------------------------------------------------------------------
function ModeToggle({
mode,
onChange,
}: {
mode: AuthMode;
onChange: (m: AuthMode) => void;
}) {
return (
<View style={styles.toggleRow}>
{(['signin', 'signup'] as const).map((m) => {
const active = mode === m;
const label = m === 'signin' ? 'Sign in' : 'Create account';
return (
<View key={m} style={[styles.toggleSegment, active && styles.toggleSegmentActive]}>
<Text
onPress={() => {
if (!active) {
Haptics.selectionAsync();
onChange(m);
}
}}
style={[styles.toggleLabel, active && styles.toggleLabelActive]}
>
{label}
</Text>
</View>
);
})}
</View>
);
}
// ---------------------------------------------------------------------------
// Dark-styled TextInput wrapper matching the gallery search bar aesthetic
// ---------------------------------------------------------------------------
function Field({
value,
onChangeText,
placeholder,
secureTextEntry = false,
autoCapitalize = 'none',
returnKeyType = 'next',
onSubmitEditing,
inputRef,
}: {
value: string;
onChangeText: (v: string) => void;
placeholder: string;
secureTextEntry?: boolean;
autoCapitalize?: 'none' | 'words';
returnKeyType?: 'next' | 'done';
onSubmitEditing?: () => void;
inputRef?: React.RefObject<TextInput | null>;
}) {
return (
<View style={styles.fieldWrap}>
<TextInput
ref={inputRef}
style={styles.fieldInput}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={GLASS.textDim}
secureTextEntry={secureTextEntry}
autoCapitalize={autoCapitalize}
autoCorrect={false}
returnKeyType={returnKeyType}
onSubmitEditing={onSubmitEditing}
blurOnSubmit={returnKeyType === 'done'}
/>
</View>
);
}
// ---------------------------------------------------------------------------
// Auth view — sign-in / sign-up form
// ---------------------------------------------------------------------------
function AuthView({ onSuccess }: { onSuccess: (user: User) => void }) {
const [mode, setMode] = useState<AuthMode>('signin');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [appleBusy, setAppleBusy] = useState(false);
const [appleAvailable, setAppleAvailable] = useState(false);
const passwordRef = useRef<TextInput>(null);
const nameRef = useRef<TextInput>(null);
// Show the Apple button only once its creds are configured server-side.
useEffect(() => {
api.authConfig().then((c) => setAppleAvailable(!!c.apple)).catch(() => setAppleAvailable(false));
}, []);
const handleApple = useCallback(async () => {
setAppleBusy(true);
setError(null);
try {
const r = await appleSignIn();
if (r.ok) {
const me = await api.me();
if (me.ok && me.user) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
onSuccess(me.user);
return;
}
setError('Signed in, but could not load your account. Please try again.');
} else if (r.error && r.error !== 'canceled') {
setError(r.error);
}
} catch {
setError('Network error. Please check your connection.');
} finally {
setAppleBusy(false);
}
}, [onSuccess]);
// Reset error when switching mode
const handleModeChange = useCallback((m: AuthMode) => {
setMode(m);
setError(null);
}, []);
const handleSubmit = useCallback(async () => {
const trimmedEmail = email.trim();
const trimmedName = name.trim();
if (!trimmedEmail || !password) {
setError('Email and password are required.');
return;
}
setBusy(true);
setError(null);
try {
const result =
mode === 'signin'
? await api.login(trimmedEmail, password)
: await api.signup(trimmedEmail, password, trimmedName || undefined);
if (result.ok && result.user) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
onSuccess(result.user);
} else {
setError(result.error ?? 'Something went wrong. Please try again.');
}
} catch {
setError('Network error. Please check your connection.');
} finally {
setBusy(false);
}
}, [mode, email, password, name, onSuccess]);
const submitLabel = mode === 'signin' ? 'Sign in' : 'Create account';
return (
<View style={styles.authRoot}>
<Text style={styles.title}>Your Account</Text>
<Text style={styles.subtitle}>
One account for the app and celebsignatures.com.
</Text>
<ModeToggle mode={mode} onChange={handleModeChange} />
<View style={styles.fields}>
<Field
value={email}
onChangeText={setEmail}
placeholder="Email"
autoCapitalize="none"
returnKeyType="next"
onSubmitEditing={() => {
if (mode === 'signup') {
nameRef.current?.focus();
} else {
passwordRef.current?.focus();
}
}}
/>
{mode === 'signup' && (
<Field
inputRef={nameRef}
value={name}
onChangeText={setName}
placeholder="Name (optional)"
autoCapitalize="words"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
/>
)}
<Field
inputRef={passwordRef}
value={password}
onChangeText={setPassword}
placeholder="Password"
secureTextEntry
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
</View>
{error !== null && (
<Text style={styles.errorText}>{error}</Text>
)}
<View style={styles.submitBtn}>
<GlassButton
label={submitLabel}
onPress={handleSubmit}
solid
busy={busy}
disabled={busy}
/>
</View>
{appleAvailable && (
<View style={styles.appleWrap}>
<Text style={styles.orText}>or</Text>
<GlassButton
label={' Sign in with Apple'}
onPress={handleApple}
busy={appleBusy}
disabled={appleBusy || busy}
/>
</View>
)}
</View>
);
}
// ---------------------------------------------------------------------------
// Signed-in view — greeting + sign out + delete account
// ---------------------------------------------------------------------------
function SignedInView({
user,
onSignOut,
}: {
user: NonNullable<User>;
onSignOut: () => void;
}) {
const [signOutBusy, setSignOutBusy] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deleted, setDeleted] = useState(false);
const handleSignOut = useCallback(async () => {
setSignOutBusy(true);
try {
await api.logout();
} finally {
// Always return to auth view regardless of server response
setSignOutBusy(false);
onSignOut();
}
}, [onSignOut]);
// Apple 5.1.1(v) — in-app account deletion with explicit confirmation.
const handleDeletePress = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
Alert.alert(
'Delete Account',
'This permanently deletes your account, saved placements, and uploads. This cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
setDeleteError(null);
setDeleteBusy(true);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
try {
const result = await api.deleteAccount();
if (result.ok) {
setDeleted(true);
// Brief "Account deleted" display then return to auth
setTimeout(() => onSignOut(), 1800);
} else {
setDeleteError(result.error ?? 'Deletion failed. Please try again.');
}
} catch {
setDeleteError('Network error. Please check your connection.');
} finally {
setDeleteBusy(false);
}
},
},
],
);
}, [onSignOut]);
if (deleted) {
return (
<View style={styles.center}>
<Text style={styles.deletedText}>Account deleted.</Text>
</View>
);
}
return (
<View style={styles.signedInRoot}>
{/* Greeting panel */}
<GlassPanel style={styles.greetingPanel}>
<Text style={styles.greetingLine}>Signed in as</Text>
<Text style={styles.greetingEmail} numberOfLines={1}>
{user.email}
</Text>
{user.name ? (
<Text style={styles.greetingName}>{user.name}</Text>
) : null}
</GlassPanel>
{/* Sign out */}
<View style={styles.actionBtn}>
<GlassButton
label="Sign out"
onPress={handleSignOut}
busy={signOutBusy}
disabled={signOutBusy || deleteBusy}
/>
</View>
{/* Spacer pushes delete to bottom */}
<View style={styles.spacer} />
{/* Delete account — Apple 5.1.1(v) required control */}
<View style={styles.deleteSection}>
{deleteError !== null && (
<Text style={styles.errorText}>{deleteError}</Text>
)}
<GlassButton
label={deleteBusy ? 'Deleting…' : 'Delete my account'}
onPress={handleDeletePress}
busy={deleteBusy}
disabled={deleteBusy || signOutBusy}
/>
<Text style={styles.deleteCaption}>
Permanently removes your account, placements, and uploads.
</Text>
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// Root screen — resolves auth state then delegates
// ---------------------------------------------------------------------------
export default function AccountScreen() {
const [checkState, setCheckState] = useState<CheckState>('checking');
const [user, setUser] = useState<User>(null);
useEffect(() => {
let cancelled = false;
api.me().then((res) => {
if (!cancelled) {
setUser(res.ok ? res.user : null);
setCheckState('done');
}
}).catch(() => {
if (!cancelled) {
setUser(null);
setCheckState('done');
}
});
return () => { cancelled = true; };
}, []);
const handleAuthSuccess = useCallback((u: User) => {
setUser(u);
}, []);
const handleSignOut = useCallback(() => {
setUser(null);
}, []);
// While checking session
if (checkState === 'checking') {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color={GLASS.accent} />
</View>
);
}
return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
style={styles.flex}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{user !== null ? (
<SignedInView user={user} onSignOut={handleSignOut} />
) : (
<AuthView onSuccess={handleAuthSuccess} />
)}
</ScrollView>
</KeyboardAvoidingView>
);
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
flex: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: 20,
paddingTop: 32,
paddingBottom: 48,
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 32,
},
// Auth view
authRoot: {
flex: 1,
},
title: {
fontSize: 28,
fontWeight: '700',
color: GLASS.accentSoft,
letterSpacing: 0.2,
marginBottom: 8,
},
subtitle: {
fontSize: 15,
color: GLASS.textDim,
marginBottom: 28,
lineHeight: 21,
},
// Mode toggle
toggleRow: {
flexDirection: 'row',
borderRadius: 12,
overflow: 'hidden',
borderWidth: StyleSheet.hairlineWidth,
borderColor: GLASS.edge,
backgroundColor: '#ffffff',
marginBottom: 24,
},
toggleSegment: {
flex: 1,
paddingVertical: 12,
alignItems: 'center',
},
toggleSegmentActive: {
backgroundColor: 'rgba(201,168,106,0.18)',
borderBottomWidth: 2,
borderBottomColor: GLASS.accent,
},
toggleLabel: {
fontSize: 15,
fontWeight: '600',
color: GLASS.textDim,
},
toggleLabelActive: {
color: GLASS.accent,
},
// Fields
fields: {
gap: 12,
marginBottom: 16,
},
fieldWrap: {
borderRadius: 12,
overflow: 'hidden',
backgroundColor: '#ffffff',
borderWidth: StyleSheet.hairlineWidth,
borderColor: GLASS.edgeFaint,
},
fieldInput: {
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 16,
color: GLASS.accentSoft,
},
errorText: {
color: '#e05c5c',
fontSize: 14,
marginBottom: 14,
lineHeight: 19,
},
submitBtn: {
marginTop: 8,
},
appleWrap: {
marginTop: 14,
gap: 8,
},
orText: {
textAlign: 'center',
color: GLASS.textDim,
fontSize: 13,
letterSpacing: 0.3,
},
// Signed-in view
signedInRoot: {
flex: 1,
minHeight: 420,
},
greetingPanel: {
marginBottom: 20,
},
greetingLine: {
fontSize: 13,
color: GLASS.textDim,
letterSpacing: 0.3,
marginBottom: 4,
textTransform: 'uppercase',
},
greetingEmail: {
fontSize: 18,
fontWeight: '700',
color: GLASS.accentSoft,
marginBottom: 2,
},
greetingName: {
fontSize: 15,
color: GLASS.textDim,
marginTop: 4,
},
actionBtn: {
marginBottom: 12,
},
spacer: {
flex: 1,
minHeight: 32,
},
// Delete section
deleteSection: {
paddingTop: 20,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(20,18,16,0.1)',
},
deleteCaption: {
fontSize: 12,
color: GLASS.textDim,
textAlign: 'center',
marginTop: 10,
lineHeight: 17,
},
// Post-delete confirmation
deletedText: {
fontSize: 18,
fontWeight: '700',
color: GLASS.accentSoft,
textAlign: 'center',
},
});