Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .claude/napkin.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
## Corrections
| Date | Source | What Went Wrong | What To Do Instead |
|------|--------|----------------|-------------------|
| 2026-02-25 | self | Ran `git checkout` + `git add` + `git commit` in parallel; `git add` hit index lock and commit ran with nothing staged | Never parallelize dependent git state-changing commands; run branch/switch/add/commit sequentially |
| 2026-02-25 | self | Ran `sed` on `apps/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx` without quoting and zsh globbing failed | Always single-quote paths with `[]` and `()` in shell commands |
| 2026-02-25 | self | Deleted `apps/web/app/api/auth/[...all]/route.ts` and hit Next generated validator import errors from `.next/dev/types` during typecheck | Keep a minimal stub route (404 handler) until Next type artifacts are regenerated cleanly |
| 2026-02-25 | self | Clerk `clerkMiddleware()` wrapper in `apps/web/proxy.ts` was first typed with `Request`, causing TS mismatch against `NextRequest` | Type proxy handlers with `NextRequest` when delegating to Next middleware helpers |
| 2026-02-25 | self | Tried a targeted `apply_patch` edit on `components/auth/user-button.tsx` that failed due stale context after prior edits | When a patch fails on a heavily edited file, replace the file in a single add/delete patch to avoid partial-context drift |
| 2026-02-21 | self | New `scorePreview` map callback in `activity-log-dialog.tsx` failed TS strict mode due implicit `any` params | Add explicit callback parameter types when rendering arrays from loosely-typed query payloads |
| 2026-02-21 | self | Web tests used `activityPointsAggregate/public/insertIfDoesNotExist`, but the aggregate component exposes `public/insert` and `public/replaceOrInsert` only | Use `aggregateInsertActivity`/`insertTestActivity` or `public/replaceOrInsert` instead of calling a non-existent module |
| 2026-02-21 | self | Component registration for convex-test pointed at `packages/backend/node_modules` and missed `_generated`, so component modules were unresolved | Register aggregate component from `node_modules/@convex-dev/aggregate/dist/component/**/*.{js,ts}` so `_generated` is included |
Expand Down Expand Up @@ -62,6 +67,7 @@

## Domain Notes
- Scoring configs have types: distance, duration, count, variant
- As of 2026-02-25, this repo has no first-class Clerk integration yet; only lockfile transitive refs and `.gitignore` entries exist.
- `page-with-header` CSS class = `pt-16` to offset fixed navbar
- Dashboard layout uses `h-dvh` + `overflow-hidden` shell with an internal `main` scroller (`overflow-y-auto`); mobile browser chrome hide behavior is tied to this choice.
- Seed data lives in `packages/backend/actions/seed.ts`
Expand Down
13 changes: 5 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
# Better Auth
BETTER_AUTH_SECRET="generate-a-long-secret"
BETTER_AUTH_URL="http://localhost:3000"
NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000/api/auth"

# Google OAuth (optional)
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_..."
CLERK_SECRET_KEY="sk_test_..."
CLERK_JWT_ISSUER_DOMAIN="https://your-instance.clerk.accounts.dev"
CLERK_CONVEX_AUDIENCE="convex"

# Convex (local Docker)
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:3210"
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ jobs:
env:
NEXT_PUBLIC_CONVEX_URL: "https://example.convex.cloud"
NEXT_PUBLIC_CONVEX_SITE_URL: "https://example.convex.site"
NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000/api/auth"
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_test_example"
CLERK_SECRET_KEY: "sk_test_example"
NEXT_PUBLIC_APP_URL: "http://localhost:3000"
NEXT_PUBLIC_STRAVA_CLIENT_ID: "12345"
run: pnpm build
Expand Down
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Instructions for AI code agents (OpenAI Codex, Claude Code, etc.) working on thi
This is a fitness challenge platform with:
- **Frontend**: Next.js 15 app in `apps/web/`
- **Backend**: Convex serverless functions in `packages/backend/`
- **Auth**: Better Auth via `@convex-dev/better-auth`
- **Auth**: Clerk

## Date Handling (Local Date Semantics)

Expand Down Expand Up @@ -206,9 +206,11 @@ convex-test is a mock - always manually test against real Convex before shipping
# Required for Convex
NEXT_PUBLIC_CONVEX_URL="https://your-project.convex.cloud"

# For Better Auth
BETTER_AUTH_SECRET="generate-a-long-secret"
NEXT_PUBLIC_AUTH_PROVIDER="better-auth"
# For Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_..."
CLERK_SECRET_KEY="sk_test_..."
CLERK_JWT_ISSUER_DOMAIN="https://your-instance.clerk.accounts.dev"
CLERK_CONVEX_AUDIENCE="convex"
```

## Verification Checklist
Expand All @@ -222,4 +224,4 @@ NEXT_PUBLIC_AUTH_PROVIDER="better-auth"

- [Convex Docs](https://docs.convex.dev/)
- [convex-test](https://docs.convex.dev/testing/convex-test)
- [Better Auth](https://www.better-auth.com/)
- [Clerk Docs](https://clerk.com/docs)
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,12 @@ pnpm dlx shadcn@latest add input # Example: add input component inside apps/we
This is a fitness challenge platform built as a Turborepo monorepo with:

**Core Structure:**
- `apps/web/` - Next.js 15 frontend with Better Auth and shadcn/ui components
- `apps/web/` - Next.js 15 frontend with Clerk and shadcn/ui components
- `packages/backend/` - Convex backend with schema, queries, mutations, and actions
- `e2e/` - Playwright E2E tests that run nightly against production

**Key Data Models:**
- **Users** - Managed by Better Auth with Convex adapter
- **Users** - Managed by Clerk identity and synced into Convex `users`
- **Challenges** - Fitness challenges with start/end dates, scoring rules
- **Activities** - User fitness activities logged to challenges (with points, streaks)
- **Categories** - Activity categorization system
Expand All @@ -100,7 +100,7 @@ This is a fitness challenge platform built as a Turborepo monorepo with:
- **Template Activity Types** - Reusable activity type templates

**Authentication & Multi-tenancy:**
- Better Auth handles authentication via `@convex-dev/better-auth`
- Clerk handles authentication
- Challenge-based multi-tenancy (users can participate in multiple challenges)
- Convex queries/mutations handle data operations with auth checks

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This repository contains the full source (web app + backend). Contributions are

- **Frontend** — Next.js 15, React 19, Tailwind CSS, shadcn/ui
- **Backend** — [Convex](https://convex.dev) (serverless database + functions)
- **Auth** — Better Auth with `@convex-dev/better-auth` adapter
- **Auth** — Clerk
- **Monorepo** — pnpm workspaces + Turborepo

## Quick Start
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Near Term

- Complete Better Auth migration and user sync
- Complete Clerk auth rollout and user sync hardening
- Harden integration flows (Strava, Stripe)
- Improve admin tooling for challenges and moderation

Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/(auth)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ForgotPassword } from "@/components/auth/forgot-password";
import { redirect } from "next/navigation";

export default function ForgotPasswordPage() {
return <ForgotPassword />;
redirect("/sign-in");
}
4 changes: 2 additions & 2 deletions apps/web/app/(auth)/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ResetPassword } from "@/components/auth/reset-password";
import { redirect } from "next/navigation";

export default function ResetPasswordPage() {
return <ResetPassword />;
redirect("/sign-in");
}
4 changes: 2 additions & 2 deletions apps/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BetterAuthSignIn } from "@/components/auth/better-auth-sign-in";
import { ClerkSignIn } from "@/components/auth/clerk-auth";

export default function SignInPage() {
return <BetterAuthSignIn />;
return <ClerkSignIn />;
}
4 changes: 2 additions & 2 deletions apps/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BetterAuthSignUp } from "@/components/auth/better-auth-sign-up";
import { ClerkSignUp } from "@/components/auth/clerk-auth";

export default function SignUpPage() {
return <BetterAuthSignUp />;
return <ClerkSignUp />;
}
9 changes: 6 additions & 3 deletions apps/web/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { betterAuthHandler } from "@/lib/server-auth";

export const runtime = "nodejs";

export const { GET, POST } = betterAuthHandler;
async function notFoundHandler() {
return Response.json({ error: "Not Found" }, { status: 404 });
}

export const GET = notFoundHandler;
export const POST = notFoundHandler;
171 changes: 9 additions & 162 deletions apps/web/app/challenges/[id]/settings/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { useMutation, useQuery } from "convex/react";
import { api } from "@repo/backend";
import type { Id, Doc } from "@repo/backend/_generated/dataModel";
import { Loader2, User, List, Check, Shield } from "lucide-react";
import { betterAuthClient } from "@/lib/better-auth/client";

import { UserAvatar } from "@/components/user-avatar";
import { Button } from "@/components/ui/button";
Expand All @@ -20,7 +19,6 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";

interface SettingsContentProps {
currentUser: {
Expand Down Expand Up @@ -271,174 +269,23 @@ export function SettingsContent({
}

function AccountSecurityCard({ email }: { email: string }) {
const [isSending, setIsSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);

// Change password state
const [showChangePassword, setShowChangePassword] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [changeError, setChangeError] = useState<string | null>(null);
const [changeSuccess, setChangeSuccess] = useState(false);
const [isChanging, setIsChanging] = useState(false);

async function handleSendResetLink() {
setIsSending(true);
setError(null);
try {
const result = await betterAuthClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
if (result.error) {
setError("Failed to send reset link. Please try again.");
return;
}
setSent(true);
} catch {
setError("Something went wrong. Please try again.");
} finally {
setIsSending(false);
}
}

async function handleChangePassword() {
if (newPassword.length < 8) {
setChangeError("New password must be at least 8 characters.");
return;
}
setIsChanging(true);
setChangeError(null);
try {
const result = await betterAuthClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: false,
});
if (result.error) {
const msg = result.error.message?.toLowerCase() ?? "";
if (msg.includes("invalid") || msg.includes("incorrect"))
setChangeError("Current password is incorrect.");
else setChangeError("Failed to change password. Please try again.");
return;
}
setChangeSuccess(true);
setCurrentPassword("");
setNewPassword("");
setShowChangePassword(false);
} catch {
setChangeError("Something went wrong. Please try again.");
} finally {
setIsChanging(false);
}
}

return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Account Security
</CardTitle>
<CardDescription>Manage your password and login methods</CardDescription>
<CardDescription>Managed by Clerk</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{sent ? (
<p className="text-sm text-green-600">
Password reset link sent to {email}. Check your inbox.
</p>
) : (
<>
<p className="text-sm text-muted-foreground">
Set or reset your password to enable email + password login.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="outline"
onClick={handleSendResetLink}
disabled={isSending}
className="w-full sm:w-auto"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
"Send Password Reset Link"
)}
</Button>
{!showChangePassword && (
<Button
variant="ghost"
onClick={() => setShowChangePassword(true)}
className="w-full sm:w-auto"
>
Change Password
</Button>
)}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</>
)}

{showChangePassword && (
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-2">
<Label htmlFor="currentPassword">Current Password</Label>
<PasswordInput
id="currentPassword"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New Password</Label>
<PasswordInput
id="newPassword"
minLength={8}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Must be at least 8 characters
</p>
</div>
{changeError && (
<p className="text-sm text-red-600">{changeError}</p>
)}
<div className="flex gap-2">
<Button
onClick={handleChangePassword}
disabled={isChanging || !currentPassword || !newPassword}
className="w-full sm:w-auto"
>
{isChanging ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Changing...
</>
) : (
"Update Password"
)}
</Button>
<Button
variant="ghost"
onClick={() => {
setShowChangePassword(false);
setChangeError(null);
}}
>
Cancel
</Button>
</div>
</div>
)}

{changeSuccess && (
<p className="text-sm text-green-600">Password changed successfully!</p>
)}
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Password and login method management is handled by Clerk.
</p>
<p className="text-sm text-muted-foreground">{email}</p>
<Button asChild variant="outline">
<Link href="/sign-in">Open sign-in</Link>
</Button>
</CardContent>
</Card>
);
Expand Down
15 changes: 6 additions & 9 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,24 +42,18 @@ export default async function RootLayout({
process.env.NODE_ENV === "development" &&
process.env.NEXT_PUBLIC_ENABLE_REACT_GRAB === "1";
const canUseAuth = Boolean(process.env.NEXT_PUBLIC_CONVEX_URL);

// Only fetch the token if a session cookie exists. getToken() makes a network
// call to Convex (/api/auth/convex/token) — when there's no session cookie it
// always 401s and spams Convex logs with Better Auth ERROR entries.
const cookieStore = await cookies();
const hasSession = cookieStore.has("better-auth.session_token")
|| cookieStore.has("__Secure-better-auth.session_token");
await cookies();

let token: string | null = null;
if (canUseAuth && hasSession) {
if (canUseAuth) {
try {
token = (await getToken()) ?? null;
} catch (error) {
console.error("[layout] failed to preload auth token:", error);
}
}

return (
const appTree = (
<ConvexProviderWrapper initialToken={token ?? null}>
<html lang="en">
<head>
Expand Down Expand Up @@ -97,4 +91,7 @@ export default async function RootLayout({
</html>
</ConvexProviderWrapper>
);

const { ClerkProvider } = await import("@clerk/nextjs");
return <ClerkProvider>{appTree}</ClerkProvider>;
}
Loading