feat(mobile): add sign-up screen

sign-in.tsx only ever covered login (per its own comment "sign-up screens
are follow-up work"). Adds the other half: email/password sign-up using
Clerk's Futures API (signUp.password() -> verifications.sendEmailCode()/
verifyEmailCode() -> finalize()), mirroring sign-in.tsx's two-step
credentials/verify shape. No invite-code step, since INVITE_REQUIRED is
globally off on the backend since launch.

Verified live on the Android emulator: real Clerk validation surfaces
correctly (breached-password rejection), and a valid submission correctly
sends a verification email and transitions to the code-entry step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-06 14:49:13 +02:00
1 parent a890904b6c
commit 330cfdeac2
3 files changed
+150 -5

No files matched your search

+14 -1
View File
@@ -31,7 +31,13 @@ account and come later (same codebase, no separate source tree — see
- **Auth:** `@clerk/expo` (the current SDK — `@clerk/clerk-expo` is
deprecated, don't reinstall it). Session JWT persists in the OS keystore
via `expo-secure-store` (`src/lib/tokenCache.ts`) instead of Clerk's
in-memory default.
in-memory default. Sign-in and sign-up (`app/(auth)/sign-in.tsx`,
`sign-up.tsx`) are both custom screens using Clerk's "Futures" API
(`signIn.password()`/`signUp.password()` + `verifications`/`finalize()`)
rather than the pre-built `<SignIn/>`/`<SignUp/>` web components, which
don't render in React Native. Sign-up has no invite-code step — unlike
web's `/join` gate, `INVITE_REQUIRED` is globally off on the backend
since launch.
- **API:** `src/api/trpc.ts` creates a `@trpc/client` pointed at
`${EXPO_PUBLIC_API_URL}/api/trpc`, with the Clerk session token attached
as `Authorization: Bearer <token>` on every request (`useAuth().getToken`,
@@ -155,6 +161,7 @@ mobile/
_layout.tsx # ClerkProvider + persisted QueryClient/tRPC + ActivePetProvider + auth redirect
(auth)/
sign-in.tsx # email/password + Clerk Device Trust email-code step
sign-up.tsx # email/password + Clerk email-verification-code step, no invite code
(app)/
_layout.tsx # Stack hosting the (tabs) group + the messages/search/story/comment screens
(tabs)/
@@ -229,6 +236,12 @@ and verified end-to-end against the live backend on a real Android
emulator. Still missing, roughly in the order they'd unblock the most:
- No i18n — the whole app is hardcoded German text, matching the sign-in screen
- **No onboarding/first-pet-creation flow.** Web redirects an owner with
zero pets to `/onboarding/pet`; mobile has no equivalent screen yet. A
freshly signed-up account (see sign-up.tsx above) lands in `(app)` with
`activePetId: null` and no way to create a pet from the app — every
pet-scoped screen expects a pet to already exist. Needed before sign-up
is actually useful end-to-end, not just reachable.
### Push notifications
+7 -4
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { View, Text, StyleSheet } from "react-native";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useSignIn } from "@clerk/expo";
import { useRouter } from "expo-router";
import { Button } from "../../src/components/Button";
@@ -7,9 +7,8 @@ import { TextField } from "../../src/components/TextField";
import { colors, typography } from "../../src/theme/tokens";
/**
* Minimal email/password sign-in — enough to prove the Clerk <-> backend
* auth flow end to end. Social sign-in / sign-up screens are follow-up work,
* not part of the initial scaffold.
* Minimal email/password sign-in. Social sign-in is still follow-up work;
* password sign-up now exists at sign-up.tsx.
*
* Uses Clerk's newer "Futures" API (signIn.password()/signIn.finalize()) —
* the classic signIn.create()/setActive() pattern from older Clerk docs
@@ -111,6 +110,9 @@ export default function SignInScreen() {
disabled={fetchStatus === "fetching"}
onPress={onSignInPress}
/>
<Pressable onPress={() => router.replace("/(auth)/sign-up")} hitSlop={8}>
<Text style={styles.link}>Noch kein Account? Registrieren</Text>
</Pressable>
</View>
);
}
@@ -126,4 +128,5 @@ const styles = StyleSheet.create({
title: { ...typography.headline, marginBottom: 16 },
hint: { ...typography.body, color: colors.quietGray, marginBottom: 8 },
error: { ...typography.body, color: colors.alertRed },
link: { ...typography.body, color: colors.marigoldDeep, textAlign: "center", marginTop: 8 },
});
+129
View File
@@ -0,0 +1,129 @@
import { useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useSignUp } from "@clerk/expo";
import { useRouter } from "expo-router";
import { Button } from "../../src/components/Button";
import { TextField } from "../../src/components/TextField";
import { colors, typography } from "../../src/theme/tokens";
/**
* Email/password sign-up — the mobile app's other half of sign-in.tsx (that
* screen only ever covered login, per its own comment "sign-up screens are
* follow-up work"). No invite-code step: INVITE_REQUIRED is globally off on
* the backend since launch (post-beta), unlike web's /join gate.
*
* Uses Clerk's "Futures" API (signUp.password(), signUp.verifications,
* signUp.finalize()), matching sign-in.tsx's pattern — see AGENTS.md before
* assuming an older signUp.create()/setActive() shape from training data.
*/
export default function SignUpScreen() {
const { signUp, fetchStatus } = useSignUp();
const router = useRouter();
const [emailAddress, setEmailAddress] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [step, setStep] = useState<"credentials" | "verify-email">("credentials");
const [error, setError] = useState<string | null>(null);
const finalizeIfComplete = async () => {
if (signUp.status !== "complete") {
setError(`Registrierung unvollständig — Status: ${signUp.status}`);
return;
}
await signUp.finalize();
router.replace("/(app)");
};
const onSignUpPress = async () => {
setError(null);
const { error: signUpError } = await signUp.password({ emailAddress, password });
if (signUpError) {
setError(signUpError.longMessage ?? signUpError.message);
return;
}
if (signUp.verifications.emailAddress.status !== "verified") {
const { error: sendError } = await signUp.verifications.sendEmailCode();
if (sendError) {
setError(sendError.longMessage ?? sendError.message);
return;
}
setStep("verify-email");
return;
}
await finalizeIfComplete();
};
const onVerifyPress = async () => {
setError(null);
const { error: verifyError } = await signUp.verifications.verifyEmailCode({ code });
if (verifyError) {
setError(verifyError.longMessage ?? verifyError.message);
return;
}
await finalizeIfComplete();
};
if (step === "verify-email") {
return (
<View style={styles.container}>
<Text style={styles.title}>E-Mail bestätigen</Text>
<Text style={styles.hint}>
Wir haben einen Code an {emailAddress} geschickt, um deine E-Mail-Adresse zu bestätigen.
</Text>
<TextField
autoCapitalize="none"
keyboardType="number-pad"
placeholder="Code"
value={code}
onChangeText={setCode}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title="Bestätigen" disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>PawFeed Registrierung</Text>
<TextField
autoCapitalize="none"
keyboardType="email-address"
placeholder="E-Mail"
value={emailAddress}
onChangeText={setEmailAddress}
/>
<TextField
placeholder="Passwort"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Registrieren"
disabled={fetchStatus === "fetching"}
onPress={onSignUpPress}
/>
<Pressable onPress={() => router.replace("/(auth)/sign-in")} hitSlop={8}>
<Text style={styles.link}>Schon registriert? Anmelden</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
padding: 24,
gap: 12,
backgroundColor: colors.porchWhite,
},
title: { ...typography.headline, marginBottom: 16 },
hint: { ...typography.body, color: colors.quietGray, marginBottom: 8 },
error: { ...typography.body, color: colors.alertRed },
link: { ...typography.body, color: colors.marigoldDeep, textAlign: "center", marginTop: 8 },
});