diff --git a/projects/app/src/app/dev/auth.tsx b/projects/app/src/app/dev/auth.tsx
new file mode 100644
index 0000000000..653575ef31
--- /dev/null
+++ b/projects/app/src/app/dev/auth.tsx
@@ -0,0 +1,229 @@
+import { useAuth } from "@/client/auth";
+import { useRizzleQuery } from "@/client/hooks/useRizzleQuery";
+import { RectButton } from "@/client/ui/RectButton";
+import { SessionStoreProvider } from "@/client/ui/SessionStoreProvider";
+import { SignInWithAppleButton } from "@/client/ui/SignInWithAppleButton";
+import { TextInputSingle } from "@/client/ui/TextInputSingle";
+import { invariant } from "@pinyinly/lib/invariant";
+import * as AppleAuthentication from "expo-apple-authentication";
+import { Link } from "expo-router";
+import { useState } from "react";
+import { Platform, Text, View } from "react-native";
+import z from "zod/v4";
+
+export default function DevAuthPage() {
+ return (
+
+
+ Authentication Development Tools
+
+ Manage sessions, debug authentication flows, and test login functionality.
+
+
+
+
+
+ );
+}
+
+function DeveloperDebugSection() {
+ const auth = useAuth();
+
+ return (
+
+ {/* Existing Sessions */}
+ {auth.data?.allDeviceSessions && auth.data.allDeviceSessions.length > 0 && (
+
+ Existing Sessions
+ {auth.data.allDeviceSessions.map((session, i) => (
+
+
+
+
+ Session: {session.serverSessionId ?? `Anonymous`}
+
+
+ Skills:
+
+
+ DB: {session.replicacheDbName}
+
+
+ {
+ auth.logInToExistingDeviceSession(
+ (s) => s.replicacheDbName === session.replicacheDbName,
+ );
+ }}
+ >
+ Use Session
+
+
+
+ ))}
+
+ )}
+
+ {/* Current Session Info */}
+
+ Current Session
+
+
+ Session ID: {auth.data?.activeDeviceSession.serverSessionId ?? `Anonymous`}
+
+
+ Database: {auth.data?.activeDeviceSession.replicacheDbName}
+
+
+
+
+ {/* Session ID Login */}
+
+ Session ID Login
+
+
+
+ {/* Dev Actions */}
+
+ Development Actions
+
+ { auth.signOut(); }}
+ >
+ Sign Out
+
+
+ UI Components
+
+
+ API Debug
+
+
+
+
+ {/* Deprecated Apple Sign-in */}
+
+ Deprecated Features
+
+ Apple Sign-in (deprecated - for development/testing only)
+
+
+
+
+ );
+}
+
+function ServerSessionIdLoginForm() {
+ const auth = useAuth();
+ const [input, setInput] = useState(``);
+
+ return (
+
+
+ Enter a server session ID to log in directly
+
+
+
+ {
+ if (e.nativeEvent.key === `Enter`) {
+ auth.logInWithServerSessionId(input);
+ e.preventDefault();
+ }
+ }}
+ value={input}
+ onChangeText={(text) => {
+ setInput(text);
+ }}
+ />
+
+ {
+ auth.logInWithServerSessionId(input);
+ }}
+ disabled={!input.trim()}
+ >
+ Login
+
+
+
+ );
+}
+
+function SkillCount() {
+ const result = useRizzleQuery([`wordCount`], async (r, tx) => {
+ const skillStates = await r.query.skillState.scan(tx).toArray();
+ return skillStates.length;
+ });
+
+ return result.isPending ? (
+ Loading…
+ ) : (
+ {result.data} words
+ );
+}
+
+function AppleSignInSection() {
+ const auth = useAuth();
+
+ return (
+
+ {/* Apple Sign In */}
+ {Platform.OS === `web` ? (
+ {
+ void auth.logInWithApple(data.authorization.id_token);
+ }}
+ redirectUri={`https://${location.hostname}/api/auth/login/apple/callback`}
+ />
+ ) : Platform.OS === `ios` ? (
+ {
+ let credential;
+ try {
+ credential = await AppleAuthentication.signInAsync({
+ requestedScopes: [
+ AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
+ AppleAuthentication.AppleAuthenticationScope.EMAIL,
+ ],
+ });
+ } catch (error) {
+ const err = z.object({ code: z.string() }).safeParse(error);
+ if (err.success) {
+ switch (err.data.code) {
+ case `ERR_REQUEST_CANCELED`: {
+ console.error(`Apple sign-in canceled`);
+ break;
+ }
+ default: {
+ console.error(`Apple sign-in error:`, err.data);
+ }
+ }
+ } else {
+ console.error(`Unknown Apple sign-in error:`, error);
+ }
+ return;
+ }
+
+ invariant(credential.identityToken != null);
+ void auth.logInWithApple(credential.identityToken);
+ }}
+ />
+ ) : Platform.OS === `android` ? (
+ Apple Sign-in not available on Android
+ ) : (
+ Apple Sign-in not available on this platform
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/projects/app/src/app/dev/index.tsx b/projects/app/src/app/dev/index.tsx
new file mode 100644
index 0000000000..82a1a6f7a5
--- /dev/null
+++ b/projects/app/src/app/dev/index.tsx
@@ -0,0 +1,53 @@
+import { RectButton } from "@/client/ui/RectButton";
+import { Link } from "expo-router";
+import { Text, View } from "react-native";
+
+export default function DevIndexPage() {
+ return (
+
+
+ Development Tools
+
+ Access development utilities, debugging tools, and component demos.
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function DevToolCard({ title, description, href }: {
+ title: string;
+ description: string;
+ href: `/dev/auth` | `/dev/ui` | `/dev/api`;
+}) {
+ return (
+
+
+
+ {title}
+ {description}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/projects/app/src/app/login.tsx b/projects/app/src/app/login.tsx
index a09658fe51..133c574835 100644
--- a/projects/app/src/app/login.tsx
+++ b/projects/app/src/app/login.tsx
@@ -1,194 +1,149 @@
import { useAuth } from "@/client/auth";
-import { useRizzleQuery } from "@/client/hooks/useRizzleQuery";
import { RectButton } from "@/client/ui/RectButton";
-import { SessionStoreProvider } from "@/client/ui/SessionStoreProvider";
-import { SignInWithAppleButton } from "@/client/ui/SignInWithAppleButton";
import { TextInputSingle } from "@/client/ui/TextInputSingle";
-import { invariant } from "@pinyinly/lib/invariant";
-import * as AppleAuthentication from "expo-apple-authentication";
import { Link } from "expo-router";
import { useState } from "react";
import { Platform, Text, View } from "react-native";
-import z from "zod/v4";
export default function LoginPage() {
- const auth = useAuth();
-
- const [name, setName] = useState(``);
-
return (
-
- Passkey
-
-
- {
- auth.logInWithPasskey().catch((error: unknown) => {
- console.error(`failed to log in with passkey`, error);
- });
- }}
- >
- Log in with Passkey
-
-
-
- {
- auth.logInWithPasskey().catch((error: unknown) => {
- console.error(`failed to log in with passkey`, error);
- });
- }}
- >
- Log in with Passkey (conditional UI)
-
-
-
-
- {
- setName(text);
- }}
- value={name}
- />
- {
- auth.signUpWithPasskey({ name }).catch((error: unknown) => {
- console.error(`failed to log in with passkey`, error);
- });
- }}
- >
- Sign up with Passkey
-
-
+
+ {/* Header */}
+
+ Welcome to Pinyinly
+
+ Sign in securely with your passkey or create a new account
+
- Login
-
- {auth.data?.allDeviceSessions.map((x, i) => (
-
-
-
-
- Skill count:
+ {/* Main Content */}
+
+ {/* Passkey Authentication Section */}
+
+
+
+ {/* Back to App Button */}
+
+
+ {__DEV__ && (
+
+
+
+
+ Development Tools
- Session ID: {x.serverSessionId}
- DB name: {x.replicacheDbName}
-
- {
- auth.logInToExistingDeviceSession(
- (s) => s.replicacheDbName === x.replicacheDbName,
- );
- }}
- >
- Log in
-
-
- ))}
+
+
+ )}
-
- Session ID: {auth.data?.activeDeviceSession.serverSessionId}
-
-
- DB name: {auth.data?.activeDeviceSession.replicacheDbName}
+
+ );
+}
+
+function PasskeyAuthSection() {
+ const auth = useAuth();
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [mode, setMode] = useState<`signin` | `signup`>(`signin`);
+ const [name, setName] = useState(``);
+
+ const handlePasskeySignIn = async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ await auth.logInWithPasskey();
+ } catch (error_) {
+ console.error(`Passkey sign-in failed:`, error_);
+ // Automatically switch to signup mode instead of showing an error
+ setMode(`signup`);
+ setError(null);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handlePasskeySignUp = async () => {
+ if (!name.trim()) {
+ setError(`Please enter your name to create an account.`);
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+ try {
+ await auth.signUpWithPasskey({ name: name.trim() });
+ } catch (error_) {
+ console.error(`Passkey sign-up failed:`, error_);
+ setError(`Account creation failed. Please try again.`);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ {mode === `signin` ? `Sign In` : `Create Account`}
+ {error != null && error.length > 0 && (
+
+ {error}
+
+ )}
+
+ {mode === `signup` && (
+
+ )}
+
{
- auth.signOut();
+ variant="filled"
+ onPress={() => {
+ void (mode === `signin` ? handlePasskeySignIn() : handlePasskeySignUp());
}}
+ disabled={isLoading || (mode === `signup` && !name.trim())}
>
- Logout
+ {isLoading
+ ? (mode === `signin` ? `Signing in...` : `Creating account...`)
+ : (mode === `signin` ? `🔐 Sign in with Passkey` : `🔐 Create account with Passkey`)
+ }
- {__DEV__ ? : null}
- {Platform.OS === `web` ? (
- {
- void auth.logInWithApple(data.authorization.id_token);
- }}
- redirectUri={`https://${location.hostname}/api/auth/login/apple/callback`}
+ {Platform.OS === `web` && (
+
- ) : null}
-
- {Platform.OS === `ios` ? (
- {
- let credential;
- try {
- credential = await AppleAuthentication.signInAsync({
- requestedScopes: [
- AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
- AppleAuthentication.AppleAuthenticationScope.EMAIL,
- ],
- });
- } catch (error) {
- const err = z.object({ code: z.string() }).safeParse(error);
- if (err.success) {
- switch (err.data.code) {
- case `ERR_REQUEST_CANCELED`: {
- // handle that the user canceled the sign-in flow
- console.error(`request canceled`);
- break;
- }
- default: {
- console.error(
- `unknown error code=${err.data.code}, error=`,
- err.data,
- );
- }
- }
- } else {
- console.error(`unknown error (no code), error=`, error);
- }
-
- return;
- }
-
- invariant(credential.identityToken != null);
-
- void auth.logInWithApple(credential.identityToken);
+ )}
+
+
+
+ {mode === `signin` ? `Don't have an account?` : `Already have an account?`}
+
+ {
+ setMode(mode === `signin` ? `signup` : `signin`);
+ setError(null);
+ setName(``);
}}
- />
- ) : null}
-
- UI
-
-
-
+ >
+
+ {mode === `signin` ? `Create one` : `Sign in`}
+
+
+
);
}
-function ServerSessionIdLoginForm() {
- const auth = useAuth();
- const [input, setInput] = useState(``);
-
- return (
- {
- if (e.nativeEvent.key === `Enter`) {
- auth.logInWithServerSessionId(input);
- e.preventDefault();
- }
- }}
- value={input}
- onChangeText={(text) => {
- setInput(text);
- }}
- />
- );
-}
-
const GoHomeButton = () => (
@@ -197,15 +152,4 @@ const GoHomeButton = () => (
);
-function SkillCount() {
- const result = useRizzleQuery([`wordCount`], async (r, tx) => {
- const skillStates = await r.query.skillState.scan(tx).toArray();
- return skillStates.length;
- });
- return result.isPending ? (
- Loading…
- ) : (
- {result.data} words
- );
-}