Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
// Monorepo ESLint (flat config). Run the extension per-package so each file
// is linted with its nearest eslint.config.mjs AND from that package's cwd —
// the latter is what lets import/tsconfig resolvers see `@/` path aliases.
// Without this the extension runs at repo root, applies the root base config
// to apps/mobile/**, and errors "rule react-hooks/* not found".
"eslint.workingDirectories": [{ "mode": "auto" }],
// Opt into ESLint v10's file-based config lookup early (default in v10).
// Redundant once workingDirectories scopes cwd, but keeps a root-level run
// resolving each file's nearest config too, and eases the v10 bump.
"eslint.options": {
"flags": ["v10_config_lookup_from_file"]
}
}
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ the scale primitives are documented with the concrete signal that triggers each.

- **Group-native E2EE** — OpenMLS / RFC 9420 TreeKEM, a Rust core (`engine.rs`,
~1.1k lines) exposed to React Native through UniFFI. 1:1 and group chats share
one crypto path.
one crypto path.
- **Realtime transport** — a Cloudflare Worker fronting two Durable Object
classes: `UserInbox` (one per user, holds device sockets) and `Chat` (one per
chat, owns fanout + ordering). WebSocket Hibernation → idle sockets cost ~\$0.
Expand Down Expand Up @@ -174,11 +174,10 @@ packages/
chat-calls 1:1 voice/video (placeholder — M7)
db-edge Worker-runtime Neon HTTP client for the persist hot path (ADR-0010)
database Prisma schema authority + migrations + seed
api tRPC client bindings for the app
identity identity/verification helpers
identity tier permission map (hasPermission)
schemas shared zod schemas
ui shared React UI
eslint-config / typescript-config / tailwind-config shared configs
eslint-config / typescript-config shared configs

infra/stacks/ SST stack modules (see below)
```
Expand Down
96 changes: 96 additions & 0 deletions apps/mobile/app/(auth)/forgot-password.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useState } from "react";
import { Feather } from "@expo/vector-icons";
import { Spinner, YStack, useTheme } from "tamagui";
import { Button } from "@repo/ui/glacier/button";
import { TextField } from "@repo/ui/glacier/text-field";
import { BodyMd, BodySm, Title } from "@repo/ui/glacier/typography";
import { authClient } from "@/lib/auth/client";
import { AuthShell, AuthFooter } from "@/lib/auth/ui";

export default function ForgotPassword() {
const theme = useTheme();
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);

const iconColor = theme.onSurfaceVariant.val;

async function handleRequestReset() {
if (!email) return;
setLoading(true);
try {
// Fire-and-forget. We show the confirmation regardless of the result so
// the screen never signals whether an account exists (no user
// enumeration). Works end-to-end once an email provider is wired
// server-side (login/DESIGN.md — no sendResetPassword handler yet).
await authClient.requestPasswordReset({ email });
} catch {
// swallowed — same confirmation either way
} finally {
setLoading(false);
setSent(true);
}
}

const footer = (
<AuthFooter prompt="Remember it?" action="Sign In" href="/(auth)/sign-in" />
);

if (sent) {
return (
<AuthShell subtitle="We'll email you a reset link" footer={footer}>
<YStack ai="center" gap="$sm" py="$sm">
<Feather name="mail" size={40} color={theme.primary.val} />
<Title>Check your inbox</Title>
<BodySm color="$onSurfaceVariant" textAlign="center">
If an account exists for {email}, a link to reset your password is
on its way.
</BodySm>
</YStack>
</AuthShell>
);
}

return (
<AuthShell subtitle="We'll email you a reset link" footer={footer}>
<YStack gap="$sm">
<BodyMd color="$onSurfaceVariant">
Enter the email tied to your account to receive a reset link.
</BodyMd>

<TextField
icon={<Feather name="mail" size={18} color={iconColor} />}
placeholder="Email Address"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
inputMode="email"
autoCapitalize="none"
autoComplete="email"
enterKeyHint="send"
onSubmitEditing={handleRequestReset}
/>

<Button
size="lg"
alignSelf="stretch"
mt="$xs"
onPress={handleRequestReset}
disabled={loading}
icon={loading ? <Spinner color="$onPrimary" /> : undefined}
iconAfter={
loading ? undefined : (
<Feather
name="arrow-right"
size={18}
color={theme.onPrimary.val}
/>
)
}
>
Send Reset Link
</Button>
</YStack>
</AuthShell>
);
}
129 changes: 129 additions & 0 deletions apps/mobile/app/(auth)/reset-password.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useState } from "react";
import { router, useLocalSearchParams } from "expo-router";
import { Feather } from "@expo/vector-icons";
import { Spinner, YStack, useTheme } from "tamagui";
import { Button } from "@repo/ui/glacier/button";
import { TextField } from "@repo/ui/glacier/text-field";
import { BodyMd, BodySm, Title } from "@repo/ui/glacier/typography";
import { authClient } from "@/lib/auth/client";
import { AuthShell, AuthFooter } from "@/lib/auth/ui";

// Mirrors the server rule (services/api/src/lib/auth.ts minPasswordLength).
const MIN_PASSWORD = 8;

export default function ResetPassword() {
const theme = useTheme();
// Deep link: mortstack-chatapp://reset-password?token=…
const { token } = useLocalSearchParams<{ token?: string }>();
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);

const iconColor = theme.onSurfaceVariant.val;
const passwordTooShort =
password.length > 0 && password.length < MIN_PASSWORD;

async function handleReset() {
if (!token) {
setError("This reset link is invalid or has expired.");
return;
}
if (password.length < MIN_PASSWORD) {
setError(`Password must be at least ${MIN_PASSWORD} characters`);
return;
}
setLoading(true);
setError(null);
try {
const result = await authClient.resetPassword({
newPassword: password,
token,
});
if (result.error) throw new Error(result.error.message);
setDone(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Reset failed");
} finally {
setLoading(false);
}
}

const footer = (
<AuthFooter
prompt="Remembered it?"
action="Sign In"
href="/(auth)/sign-in"
/>
);

if (done) {
return (
<AuthShell subtitle="Password updated" footer={footer}>
<YStack ai="center" gap="$sm" py="$sm">
<Feather name="check-circle" size={40} color={theme.primary.val} />
<Title>All set</Title>
<BodySm color="$onSurfaceVariant" textAlign="center">
Your password has been reset. Sign in with your new password.
</BodySm>
<Button
size="lg"
alignSelf="stretch"
mt="$sm"
onPress={() => router.replace("/(auth)/sign-in")}
>
Go to Sign In
</Button>
</YStack>
</AuthShell>
);
}

return (
<AuthShell subtitle="Choose a new password" footer={footer}>
<YStack gap="$sm">
<BodyMd color="$onSurfaceVariant">
Enter a new password for your account.
</BodyMd>

<TextField
icon={<Feather name="lock" size={18} color={iconColor} />}
placeholder="New Password"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="new-password"
enterKeyHint="go"
onSubmitEditing={handleReset}
error={!!error || passwordTooShort}
/>

{(error || passwordTooShort) && (
<BodySm color="$error">
{error ?? `Password must be at least ${MIN_PASSWORD} characters`}
</BodySm>
)}

<Button
size="lg"
alignSelf="stretch"
mt="$xs"
onPress={handleReset}
disabled={loading}
icon={loading ? <Spinner color="$onPrimary" /> : undefined}
iconAfter={
loading ? undefined : (
<Feather
name="arrow-right"
size={18}
color={theme.onPrimary.val}
/>
)
}
>
Reset Password
</Button>
</YStack>
</AuthShell>
);
}
Loading
Loading