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:
1 parent
a890904b6c
commit
330cfdeac2
3 files changed
+150
-5
No files matched your search
+14
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
Reference in new issue
Block a user