Implement Journal auth: passkeys + magic links, no passwords
Auth flows: - Passkey registration: email + username → WebAuthn create → account + session - Passkey login: WebAuthn get → session (instant, no form) - Magic link fallback: email → token → verify link → session - Logout via POST /auth/logout Infrastructure: - auth.server.ts: WebAuthn (SimpleWebAuthn), magic tokens, cookie sessions - DB schema: credentials (WebAuthn), magic_tokens tables, no password_hash - Session middleware via getSessionUser() Pages: - /auth/register — email + username + passkey creation - /auth/login — passkey button + magic link fallback - /auth/verify — magic link token verification - /users/:username — public profile page - Home page shows auth state (register/signin or welcome) Dynamic imports for @simplewebauthn/browser to avoid SSR issues. Magic links logged to console in dev (email integration later). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3604a2d066
commit
b65b61cbc5
13 changed files with 1004 additions and 23 deletions
234
apps/journal/app/lib/auth.server.ts
Normal file
234
apps/journal/app/lib/auth.server.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { randomUUID, randomBytes } from "node:crypto";
|
||||
import { eq, and, gt, isNull } from "drizzle-orm";
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import type {
|
||||
RegistrationResponseJSON,
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
} from "@simplewebauthn/types";
|
||||
import { getDb } from "./db";
|
||||
import { users, credentials, magicTokens } from "@trails-cool/db/schema/journal";
|
||||
|
||||
const RP_NAME = "trails.cool";
|
||||
const RP_ID = process.env.DOMAIN ?? "localhost";
|
||||
const ORIGIN = process.env.ORIGIN ?? `http://localhost:3000`;
|
||||
|
||||
// --- Registration ---
|
||||
|
||||
export async function startRegistration(email: string, username: string) {
|
||||
const db = getDb();
|
||||
|
||||
// Check for existing email/username
|
||||
const [existingEmail] = await db.select().from(users).where(eq(users.email, email));
|
||||
if (existingEmail) throw new Error("Email already in use");
|
||||
|
||||
const [existingUsername] = await db.select().from(users).where(eq(users.username, username));
|
||||
if (existingUsername) throw new Error("Username already taken");
|
||||
|
||||
const userId = randomUUID();
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: RP_NAME,
|
||||
rpID: RP_ID,
|
||||
userID: new TextEncoder().encode(userId),
|
||||
userName: username,
|
||||
userDisplayName: username,
|
||||
attestationType: "none",
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
},
|
||||
});
|
||||
|
||||
return { options, userId };
|
||||
}
|
||||
|
||||
export async function finishRegistration(
|
||||
userId: string,
|
||||
email: string,
|
||||
username: string,
|
||||
response: RegistrationResponseJSON,
|
||||
challenge: string,
|
||||
) {
|
||||
const db = getDb();
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: ORIGIN,
|
||||
expectedRPID: RP_ID,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw new Error("Registration verification failed");
|
||||
}
|
||||
|
||||
const { credential } = verification.registrationInfo;
|
||||
const domain = process.env.DOMAIN ?? "localhost";
|
||||
|
||||
// Create user and credential in a transaction
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
domain,
|
||||
});
|
||||
|
||||
await db.insert(credentials).values({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
credentialId: Buffer.from(credential.id),
|
||||
publicKey: Buffer.from(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: response.response.transports,
|
||||
});
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
// --- Passkey Login ---
|
||||
|
||||
export async function startAuthentication() {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: RP_ID,
|
||||
userVerification: "preferred",
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function finishAuthentication(
|
||||
response: AuthenticationResponseJSON,
|
||||
challenge: string,
|
||||
) {
|
||||
const db = getDb();
|
||||
|
||||
const credentialIdBuffer = Buffer.from(response.rawId, "base64url");
|
||||
|
||||
// Find the credential
|
||||
const [cred] = await db
|
||||
.select()
|
||||
.from(credentials)
|
||||
.where(eq(credentials.credentialId, credentialIdBuffer));
|
||||
|
||||
if (!cred) throw new Error("Credential not found");
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: ORIGIN,
|
||||
expectedRPID: RP_ID,
|
||||
credential: {
|
||||
id: Buffer.from(cred.credentialId).toString("base64url"),
|
||||
publicKey: new Uint8Array(cred.publicKey),
|
||||
counter: cred.counter,
|
||||
transports: cred.transports as AuthenticatorTransportFuture[] | undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new Error("Authentication verification failed");
|
||||
}
|
||||
|
||||
// Update counter
|
||||
await db
|
||||
.update(credentials)
|
||||
.set({ counter: verification.authenticationInfo.newCounter })
|
||||
.where(eq(credentials.id, cred.id));
|
||||
|
||||
return cred.userId;
|
||||
}
|
||||
|
||||
// --- Magic Links ---
|
||||
|
||||
export async function createMagicToken(email: string): Promise<string> {
|
||||
const db = getDb();
|
||||
|
||||
// Check user exists
|
||||
const [user] = await db.select().from(users).where(eq(users.email, email));
|
||||
if (!user) throw new Error("No account found for this email");
|
||||
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
|
||||
|
||||
await db.insert(magicTokens).values({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
token,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function verifyMagicToken(token: string): Promise<string> {
|
||||
const db = getDb();
|
||||
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(magicTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(magicTokens.token, token),
|
||||
gt(magicTokens.expiresAt, new Date()),
|
||||
isNull(magicTokens.usedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!record) throw new Error("Invalid or expired magic link");
|
||||
|
||||
// Mark as used
|
||||
await db
|
||||
.update(magicTokens)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(magicTokens.id, record.id));
|
||||
|
||||
// Find user
|
||||
const [user] = await db.select().from(users).where(eq(users.email, record.email));
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
// --- Sessions ---
|
||||
|
||||
import { createCookieSessionStorage } from "react-router";
|
||||
|
||||
const sessionSecret = process.env.SESSION_SECRET ?? "dev-secret-change-in-production";
|
||||
|
||||
export const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__session",
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
secrets: [sessionSecret],
|
||||
},
|
||||
});
|
||||
|
||||
export async function createSession(userId: string, request: Request) {
|
||||
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
|
||||
session.set("userId", userId);
|
||||
return sessionStorage.commitSession(session);
|
||||
}
|
||||
|
||||
export async function getSessionUser(request: Request) {
|
||||
const db = getDb();
|
||||
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
|
||||
const userId = session.get("userId");
|
||||
if (!userId) return null;
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
export async function destroySession(request: Request) {
|
||||
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
|
||||
return sessionStorage.destroySession(session);
|
||||
}
|
||||
10
apps/journal/app/lib/db.ts
Normal file
10
apps/journal/app/lib/db.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { createDb, type Database } from "@trails-cool/db";
|
||||
|
||||
let _db: Database | null = null;
|
||||
|
||||
export function getDb(): Database {
|
||||
if (!_db) {
|
||||
_db = createDb();
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
|
@ -1,3 +1,10 @@
|
|||
import { type RouteConfig, index } from "@react-router/dev/routes";
|
||||
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [index("routes/home.tsx")] satisfies RouteConfig;
|
||||
export default [
|
||||
index("routes/home.tsx"),
|
||||
route("auth/register", "routes/auth.register.tsx"),
|
||||
route("auth/login", "routes/auth.login.tsx"),
|
||||
route("auth/verify", "routes/auth.verify.tsx"),
|
||||
route("auth/logout", "routes/auth.logout.tsx"),
|
||||
route("users/:username", "routes/users.$username.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
|
|
|||
200
apps/journal/app/routes/auth.login.tsx
Normal file
200
apps/journal/app/routes/auth.login.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { useState } from "react";
|
||||
import { data, redirect } from "react-router";
|
||||
import type { Route } from "./+types/auth.login";
|
||||
import { startAuthentication, finishAuthentication, createMagicToken, createSession } from "~/lib/auth.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.json();
|
||||
const { step, response, challenge, email } = formData;
|
||||
|
||||
try {
|
||||
if (step === "start-passkey") {
|
||||
const options = await startAuthentication();
|
||||
return data({ step: "challenge", options });
|
||||
}
|
||||
|
||||
if (step === "finish-passkey") {
|
||||
const userId = await finishAuthentication(response, challenge);
|
||||
const cookie = await createSession(userId, request);
|
||||
return redirect("/", { headers: { "Set-Cookie": cookie } });
|
||||
}
|
||||
|
||||
if (step === "magic-link") {
|
||||
const token = await createMagicToken(email);
|
||||
// In production, send email. For dev, log the link.
|
||||
const link = `${process.env.ORIGIN ?? "http://localhost:3000"}/auth/verify?token=${token}`;
|
||||
console.log(`[Magic Link] ${email}: ${link}`);
|
||||
return data({ step: "magic-link-sent" });
|
||||
}
|
||||
|
||||
return data({ error: "Invalid step" }, { status: 400 });
|
||||
} catch (e) {
|
||||
return data({ error: (e as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const startResp = await fetch("/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step: "start-passkey" }),
|
||||
});
|
||||
const startData = await startResp.json();
|
||||
|
||||
if (startData.error) {
|
||||
setError(startData.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { startAuthentication: startWebAuthn } = await import("@simplewebauthn/browser");
|
||||
const webAuthnResp = await startWebAuthn(startData.options);
|
||||
|
||||
const finishResp = await fetch("/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "finish-passkey",
|
||||
response: webAuthnResp,
|
||||
challenge: startData.options.challenge,
|
||||
}),
|
||||
});
|
||||
|
||||
if (finishResp.redirected) {
|
||||
window.location.href = finishResp.url;
|
||||
return;
|
||||
}
|
||||
|
||||
const finishData = await finishResp.json();
|
||||
if (finishData.error) setError(finishData.error);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMagicLink = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const resp = await fetch("/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step: "magic-link", email }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setMagicLinkSent(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-16">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Sign In</h1>
|
||||
|
||||
{mode === "passkey" && (
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-3 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Authenticating..." : "Sign in with Passkey"}
|
||||
</button>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => setMode("magic-link")}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
No passkey on this device? Use a magic link instead
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "magic-link" && !magicLinkSent && (
|
||||
<form onSubmit={handleMagicLink} className="mt-8 space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
We'll send a login link to your email.
|
||||
</p>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-gray-800 px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Sending..." : "Send Magic Link"}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Back to passkey login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{magicLinkSent && (
|
||||
<div className="mt-8 rounded-md bg-green-50 p-4">
|
||||
<p className="text-sm text-green-800">
|
||||
Check your email! We sent a login link to <strong>{email}</strong>.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-green-600">
|
||||
The link expires in 15 minutes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-4 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-500">
|
||||
Don't have an account?{" "}
|
||||
<a href="/auth/register" className="text-blue-600 hover:underline">
|
||||
Register
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
apps/journal/app/routes/auth.logout.tsx
Normal file
8
apps/journal/app/routes/auth.logout.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { redirect } from "react-router";
|
||||
import type { Route } from "./+types/auth.logout";
|
||||
import { destroySession } from "~/lib/auth.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const cookie = await destroySession(request);
|
||||
return redirect("/auth/login", { headers: { "Set-Cookie": cookie } });
|
||||
}
|
||||
150
apps/journal/app/routes/auth.register.tsx
Normal file
150
apps/journal/app/routes/auth.register.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { useState } from "react";
|
||||
import { data, redirect } from "react-router";
|
||||
import type { Route } from "./+types/auth.register";
|
||||
import { startRegistration, finishRegistration, createSession } from "~/lib/auth.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.json();
|
||||
const { step, email, username, response, challenge, userId } = formData;
|
||||
|
||||
try {
|
||||
if (step === "start") {
|
||||
const { options, userId } = await startRegistration(email, username);
|
||||
return data({ step: "challenge", options, userId });
|
||||
}
|
||||
|
||||
if (step === "finish") {
|
||||
const newUserId = await finishRegistration(userId, email, username, response, challenge);
|
||||
const cookie = await createSession(newUserId, request);
|
||||
return redirect("/", { headers: { "Set-Cookie": cookie } });
|
||||
}
|
||||
|
||||
return data({ error: "Invalid step" }, { status: 400 });
|
||||
} catch (e) {
|
||||
return data({ error: (e as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Step 1: Get registration options from server
|
||||
const startResp = await fetch("/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step: "start", email, username }),
|
||||
});
|
||||
const startData = await startResp.json();
|
||||
|
||||
if (startData.error) {
|
||||
setError(startData.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Create passkey via browser
|
||||
const { startRegistration: startWebAuthn } = await import("@simplewebauthn/browser");
|
||||
const webAuthnResp = await startWebAuthn(startData.options);
|
||||
|
||||
// Step 3: Send response to server
|
||||
const finishResp = await fetch("/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "finish",
|
||||
email,
|
||||
username,
|
||||
response: webAuthnResp,
|
||||
challenge: startData.options.challenge,
|
||||
userId: startData.userId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (finishResp.redirected) {
|
||||
window.location.href = finishResp.url;
|
||||
return;
|
||||
}
|
||||
|
||||
const finishData = await finishResp.json();
|
||||
if (finishData.error) {
|
||||
setError(finishData.error);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-16">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Create Account</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Register with a passkey — no password needed.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleRegister} className="mt-8 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
pattern="[a-z0-9_-]+"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.toLowerCase())}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="alice"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Your handle will be @{username || "..."}@{typeof window !== "undefined" ? window.location.hostname : "trails.cool"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Creating passkey..." : "Register with Passkey"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<a href="/auth/login" className="text-blue-600 hover:underline">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
apps/journal/app/routes/auth.verify.tsx
Normal file
32
apps/journal/app/routes/auth.verify.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/auth.verify";
|
||||
import { verifyMagicToken, createSession } from "~/lib/auth.server";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
if (!token) {
|
||||
return data({ error: "Missing token" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = await verifyMagicToken(token);
|
||||
const cookie = await createSession(userId, request);
|
||||
// Redirect to home with prompt to add passkey
|
||||
return redirect("/?add-passkey=1", { headers: { "Set-Cookie": cookie } });
|
||||
} catch (e) {
|
||||
return data({ error: (e as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
||||
<p className="text-red-600">Invalid or expired magic link.</p>
|
||||
<a href="/auth/login" className="mt-4 inline-block text-blue-600 hover:underline">
|
||||
Request a new one
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/home";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
|
||||
export function meta(_args: Route.MetaArgs) {
|
||||
return [
|
||||
|
|
@ -7,11 +9,41 @@ export function meta(_args: Route.MetaArgs) {
|
|||
];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
return data({ user: user ? { username: user.username, displayName: user.displayName } : null });
|
||||
}
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const { user } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold text-gray-900">trails.cool</h1>
|
||||
<p className="mt-4 text-lg text-gray-600">Your outdoor activity journal</p>
|
||||
|
||||
{user ? (
|
||||
<div className="mt-8">
|
||||
<p className="text-gray-700">
|
||||
Welcome, <a href={`/users/${user.username}`} className="text-blue-600 hover:underline">{user.displayName ?? user.username}</a>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 flex gap-4">
|
||||
<a
|
||||
href="/auth/register"
|
||||
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
Register
|
||||
</a>
|
||||
<a
|
||||
href="/auth/login"
|
||||
className="rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
75
apps/journal/app/routes/users.$username.tsx
Normal file
75
apps/journal/app/routes/users.$username.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/users.$username";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { users } from "@trails-cool/db/schema/journal";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const db = getDb();
|
||||
const [user] = await db.select().from(users).where(eq(users.username, params.username));
|
||||
|
||||
if (!user) {
|
||||
throw data({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentUser = await getSessionUser(request);
|
||||
const isOwn = currentUser?.id === user.id;
|
||||
|
||||
return data({
|
||||
user: {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
bio: user.bio,
|
||||
domain: user.domain,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
isOwn,
|
||||
});
|
||||
}
|
||||
|
||||
export function meta({ data: loaderData }: Route.MetaArgs) {
|
||||
const username = (loaderData as { user: { username: string } })?.user?.username ?? "User";
|
||||
return [{ title: `@${username} — trails.cool` }];
|
||||
}
|
||||
|
||||
export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
|
||||
const { user, isOwn } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 text-2xl font-bold text-blue-600">
|
||||
{user.username[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{user.displayName ?? user.username}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
@{user.username}@{user.domain}
|
||||
</p>
|
||||
{user.bio && <p className="mt-2 text-gray-700">{user.bio}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOwn && (
|
||||
<div className="mt-6 flex gap-2">
|
||||
<form action="/auth/logout" method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Routes</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">No routes yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,22 +11,25 @@
|
|||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@trails-cool/db": "workspace:*",
|
||||
"@trails-cool/ui": "workspace:*",
|
||||
"@trails-cool/types": "workspace:*",
|
||||
"@trails-cool/map": "workspace:*",
|
||||
"@trails-cool/gpx": "workspace:*",
|
||||
"@trails-cool/i18n": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "catalog:",
|
||||
"@react-router/node": "catalog:",
|
||||
"@react-router/serve": "catalog:",
|
||||
"isbot": "^5.1.0"
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@trails-cool/db": "workspace:*",
|
||||
"@trails-cool/gpx": "workspace:*",
|
||||
"@trails-cool/i18n": "workspace:*",
|
||||
"@trails-cool/map": "workspace:*",
|
||||
"@trails-cool/types": "workspace:*",
|
||||
"@trails-cool/ui": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"isbot": "^5.1.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "catalog:",
|
||||
"@simplewebauthn/types": "^12.0.0",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "./app"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue