Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/twelvecash"
# Secret value for signing JWTs
JWT_SECRET="abc123"

# Better Auth secret (min 32 chars, high entropy)
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET="your_secret_here_at_least_32_chars"
BETTER_AUTH_URL="http://localhost:3000"

# App URL (for MDK success/cancel redirects)
# In production, set this to your domain (e.g., https://twelve.cash)
NEXT_PUBLIC_APP_URL="http://localhost:3000"
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@trpc/server": "^11.0.0-rc.446",
"axios": "^1.6.2",
"bech32": "^2.0.0",
"better-auth": "^1.4.18",
"cookie": "^0.6.0",
"jsonwebtoken": "^9.0.2",
"next": "^15.1.0",
Expand Down
341 changes: 318 additions & 23 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,60 @@ model User {
apiKey String @default(uuid())
payCode PayCode[]
Invoice Invoice[]
// Better Auth fields
name String?
email String? @unique
emailVerified Boolean @default(false)
image String?
sessions Session[]
accounts Account[]
}

// Better Auth: Session model
model Session {
id String @id @default(uuid())
expiresAt DateTime
token String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@map("session")
}

// Better Auth: Account model (for OAuth/email providers)
model Account {
id String @id @default(uuid())
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@map("account")
}

// Better Auth: Verification tokens (email verification, password reset)
model Verification {
id String @id @default(uuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@map("verification")
}

model UserAuth {
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);
99 changes: 99 additions & 0 deletions src/app/auth/email/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"use client";

import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "@/lib/auth-client";
import Button from "@/app/components/Button";

export default function EmailLogin() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);

try {
const result = await signIn.email({
email,
password,
});

if (result.error) {
setError(result.error.message || "Failed to sign in");
setLoading(false);
return;
}

router.push("/account");
router.refresh();
} catch (err) {
setError("An unexpected error occurred");
setLoading(false);
}
};

return (
<div className="flex flex-col gap-4 max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold">Sign in with Email</h1>

<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="you@example.com"
required
/>
</div>

<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="••••••••"
required
/>
</div>

{error && (
<p className="text-red-500 text-sm">{error}</p>
)}

<Button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>

<p className="text-sm text-gray-500 text-center">
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="text-blue-500 hover:underline">
Sign up
</Link>
</p>

<p className="text-sm text-gray-500 text-center">
<Link href="/auth" className="text-blue-500 hover:underline">
← Back to all sign in options
</Link>
</p>
</div>
);
}
15 changes: 12 additions & 3 deletions src/app/auth/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Link from "next/link";
import Button from "../components/Button";
import getUserServer from "../components/getUserServer";

Expand All @@ -12,9 +13,17 @@ export default async function Auth() {
}
return (
<div className="flex flex-col gap-4 max-w-xl mx-auto p-6">
<p className="text-lg">Login with your Nostr key so that you can keep track of your user names.</p>
<Button href="/auth/nostr">Authenticate with Nostr</Button>
<Button href="#" format="outline" disabled>Authenticate with Lightning</Button>
<p className="text-lg">Sign in to keep track of your pay codes.</p>

<div className="flex flex-col gap-3">
<Button href="/auth/email">Sign in with Email</Button>
<Button href="/auth/nostr" format="outline">Sign in with Nostr</Button>
<Button href="#" format="outline" disabled>Sign in with Lightning</Button>
</div>

<p className="text-sm text-gray-500 text-center mt-4">
New here? <Link href="/auth/signup" className="text-blue-500 hover:underline">Create an account</Link>
</p>
</div>
);
}
143 changes: 143 additions & 0 deletions src/app/auth/signup/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"use client";

import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { signUp } from "@/lib/auth-client";
import Button from "@/app/components/Button";

export default function SignUp() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");

if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}

if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}

setLoading(true);

try {
const result = await signUp.email({
email,
password,
name,
});

if (result.error) {
setError(result.error.message || "Failed to create account");
setLoading(false);
return;
}

router.push("/account");
router.refresh();
} catch (err) {
setError("An unexpected error occurred");
setLoading(false);
}
};

return (
<div className="flex flex-col gap-4 max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold">Create an Account</h1>

<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name (optional)
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Your name"
/>
</div>

<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="you@example.com"
required
/>
</div>

<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="••••••••"
required
minLength={8}
/>
</div>

<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="••••••••"
required
/>
</div>

{error && (
<p className="text-red-500 text-sm">{error}</p>
)}

<Button type="submit" disabled={loading}>
{loading ? "Creating account..." : "Create account"}
</Button>
</form>

<p className="text-sm text-gray-500 text-center">
Already have an account?{" "}
<Link href="/auth/email" className="text-blue-500 hover:underline">
Sign in
</Link>
</p>

<p className="text-sm text-gray-500 text-center">
<Link href="/auth" className="text-blue-500 hover:underline">
← Back to all sign in options
</Link>
</p>
</div>
);
}
2 changes: 2 additions & 0 deletions src/app/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface ButtonProps {
active?: boolean;
id?: string;
className?: string;
type?: "button" | "submit" | "reset";
}

export default function Button(props:ButtonProps){
Expand All @@ -35,6 +36,7 @@ export default function Button(props:ButtonProps){
className={className}
disabled={props.disabled}
onClick={props.onClick}
type={props.type || "button"}
>
{props.children || "Click Here"}
</button>
Expand Down
10 changes: 5 additions & 5 deletions src/app/components/ClientUserProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"use client";

import { TokenUser } from "@/server/api/trpc";
import type { AppUser } from "@/lib/current-user";
import React, { createContext, useContext, useState, useEffect } from "react";

interface UserContextType {
user?: TokenUser;
setUser: React.Dispatch<React.SetStateAction<TokenUser | undefined>>;
user?: AppUser;
setUser: React.Dispatch<React.SetStateAction<AppUser | undefined>>;
}

const UserContext = createContext<UserContextType | undefined>(undefined);
Expand All @@ -15,9 +15,9 @@ export default function ClientUserProvider({
initialUser,
}: {
children: React.ReactNode;
initialUser: TokenUser | undefined;
initialUser: AppUser | undefined;
}) {
const [user, setUser] = useState<TokenUser | undefined>(initialUser);
const [user, setUser] = useState<AppUser | undefined>(initialUser);

useEffect(() => {
if (initialUser) {
Expand Down
Loading