Merge pull request #24 from trails-cool/journal-auth
Implement Journal auth: passkeys + magic links
This commit is contained in:
commit
9ce90eb550
17 changed files with 1224 additions and 34 deletions
288
apps/journal/app/lib/auth.server.ts
Normal file
288
apps/journal/app/lib/auth.server.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
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;
|
||||
}
|
||||
|
||||
// --- Add Passkey to Existing Account ---
|
||||
|
||||
export async function addPasskeyStart(userId: string) {
|
||||
const db = getDb();
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: RP_NAME,
|
||||
rpID: RP_ID,
|
||||
userID: new TextEncoder().encode(userId),
|
||||
userName: user.username,
|
||||
userDisplayName: user.displayName ?? user.username,
|
||||
attestationType: "none",
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
},
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function addPasskeyFinish(
|
||||
userId: 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("Passkey verification failed");
|
||||
}
|
||||
|
||||
const { credential } = verification.registrationInfo;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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,12 @@
|
|||
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("api/auth/register", "routes/api.auth.register.ts"),
|
||||
route("api/auth/login", "routes/api.auth.login.ts"),
|
||||
route("users/:username", "routes/users.$username.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
|
|
|||
38
apps/journal/app/routes/api.auth.login.ts
Normal file
38
apps/journal/app/routes/api.auth.login.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.auth.login";
|
||||
import { startAuthentication, finishAuthentication, createMagicToken, createSession } from "~/lib/auth.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const body = await request.json();
|
||||
const { step, response, challenge, email } = body;
|
||||
|
||||
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 data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
||||
}
|
||||
|
||||
if (step === "magic-link") {
|
||||
const token = await createMagicToken(email);
|
||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||
const link = `${origin}/auth/verify?token=${token}`;
|
||||
console.log(`[Magic Link] ${email}: ${link}`);
|
||||
|
||||
// In dev, return the link directly so the client can auto-redirect
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return data({ step: "magic-link-sent", devLink: 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 });
|
||||
}
|
||||
}
|
||||
35
apps/journal/app/routes/api.auth.register.ts
Normal file
35
apps/journal/app/routes/api.auth.register.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.auth.register";
|
||||
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const body = await request.json();
|
||||
const { step, email, username, response, challenge, userId } = body;
|
||||
|
||||
try {
|
||||
if (step === "start") {
|
||||
const result = await startRegistration(email, username);
|
||||
return data({ step: "challenge", options: result.options, userId: result.userId });
|
||||
}
|
||||
|
||||
if (step === "finish") {
|
||||
const newUserId = await finishRegistration(userId, email, username, response, challenge);
|
||||
const cookie = await createSession(newUserId, request);
|
||||
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
||||
}
|
||||
|
||||
if (step === "add-passkey") {
|
||||
const options = await addPasskeyStart(userId);
|
||||
return data({ step: "challenge", options });
|
||||
}
|
||||
|
||||
if (step === "finish-add-passkey") {
|
||||
await addPasskeyFinish(userId, response, challenge);
|
||||
return data({ step: "done" });
|
||||
}
|
||||
|
||||
return data({ error: "Invalid step" }, { status: 400 });
|
||||
} catch (e) {
|
||||
return data({ error: (e as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
169
apps/journal/app/routes/auth.login.tsx
Normal file
169
apps/journal/app/routes/auth.login.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { useState } from "react";
|
||||
|
||||
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("/api/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("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "finish-passkey",
|
||||
response: webAuthnResp,
|
||||
challenge: startData.options.challenge,
|
||||
}),
|
||||
});
|
||||
|
||||
const finishData = await finishResp.json();
|
||||
if (finishData.error) {
|
||||
setError(finishData.error);
|
||||
} else if (finishData.step === "done") {
|
||||
window.location.href = "/";
|
||||
}
|
||||
} 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("/api/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 if (result.devLink) {
|
||||
// Dev mode: auto-redirect to magic link
|
||||
window.location.href = result.devLink;
|
||||
} 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 } });
|
||||
}
|
||||
121
apps/journal/app/routes/auth.register.tsx
Normal file
121
apps/journal/app/routes/auth.register.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useState } from "react";
|
||||
|
||||
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("/api/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("/api/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,
|
||||
}),
|
||||
});
|
||||
|
||||
const finishData = await finishResp.json();
|
||||
if (finishData.error) {
|
||||
setError(finishData.error);
|
||||
} else if (finishData.step === "done") {
|
||||
window.location.href = "/";
|
||||
}
|
||||
} 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
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
|
||||
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,7 @@
|
|||
import { useState, useCallback } from "react";
|
||||
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 +10,119 @@ export function meta(_args: Route.MetaArgs) {
|
|||
];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
const url = new URL(request.url);
|
||||
const showAddPasskey = url.searchParams.get("add-passkey") === "1" && user !== null;
|
||||
return data({
|
||||
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null,
|
||||
showAddPasskey,
|
||||
});
|
||||
}
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const { user, showAddPasskey } = loaderData;
|
||||
const [addingPasskey, setAddingPasskey] = useState(false);
|
||||
const [passkeyDone, setPasskeyDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAddPasskey = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setAddingPasskey(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get registration options for existing user
|
||||
const startResp = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step: "add-passkey", userId: user.id }),
|
||||
});
|
||||
const startData = await startResp.json();
|
||||
|
||||
if (startData.error) {
|
||||
setError(startData.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const { startRegistration } = await import("@simplewebauthn/browser");
|
||||
const webAuthnResp = await startRegistration(startData.options);
|
||||
|
||||
const finishResp = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "finish-add-passkey",
|
||||
userId: user.id,
|
||||
response: webAuthnResp,
|
||||
challenge: startData.options.challenge,
|
||||
}),
|
||||
});
|
||||
|
||||
const finishData = await finishResp.json();
|
||||
if (finishData.error) {
|
||||
setError(finishData.error);
|
||||
} else {
|
||||
setPasskeyDone(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setAddingPasskey(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
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>
|
||||
|
||||
{showAddPasskey && !passkeyDone && (
|
||||
<div className="mt-6 rounded-md bg-blue-50 p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
Add a passkey for faster sign-in on this device.
|
||||
</p>
|
||||
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
onClick={handleAddPasskey}
|
||||
disabled={addingPasskey}
|
||||
className="mt-3 rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{addingPasskey ? "Setting up..." : "Add Passkey"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{passkeyDone && (
|
||||
<div className="mt-6 rounded-md bg-green-50 p-4">
|
||||
<p className="text-sm text-green-800">
|
||||
Passkey added! You can now sign in instantly on this device.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,26 +1,56 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: User registration
|
||||
The Journal SHALL allow new users to create an account with email and password.
|
||||
### Requirement: Passkey registration
|
||||
The Journal SHALL allow new users to register using a passkey (WebAuthn). No password is required.
|
||||
|
||||
#### Scenario: Successful registration
|
||||
- **WHEN** a user submits a valid email and password on the registration page
|
||||
- **THEN** a new user account is created and the user is logged in
|
||||
#### Scenario: Successful passkey registration
|
||||
- **WHEN** a user enters an email and username and creates a passkey via the browser prompt
|
||||
- **THEN** a new user account is created, the passkey credential is stored, and the user is logged in
|
||||
|
||||
#### Scenario: Duplicate email
|
||||
- **WHEN** a user submits an email that is already registered
|
||||
- **THEN** the system displays an error indicating the email is already in use
|
||||
|
||||
### Requirement: User login
|
||||
The Journal SHALL allow existing users to log in with email and password.
|
||||
#### Scenario: Duplicate username
|
||||
- **WHEN** a user submits a username that is already taken
|
||||
- **THEN** the system displays an error indicating the username is not available
|
||||
|
||||
#### Scenario: Successful login
|
||||
- **WHEN** a user submits valid credentials
|
||||
### Requirement: Passkey login
|
||||
The Journal SHALL allow returning users to log in using a stored passkey.
|
||||
|
||||
#### Scenario: Successful passkey login
|
||||
- **WHEN** a user clicks "Sign in" and selects a passkey from the browser prompt
|
||||
- **THEN** the user is authenticated and redirected to their activity feed
|
||||
|
||||
#### Scenario: Invalid credentials
|
||||
- **WHEN** a user submits invalid credentials
|
||||
- **THEN** the system displays an error without revealing which field is wrong
|
||||
#### Scenario: No passkey available
|
||||
- **WHEN** a user has no passkey on the current device
|
||||
- **THEN** the system offers magic link login as a fallback
|
||||
|
||||
### Requirement: Magic link login (fallback)
|
||||
The Journal SHALL allow users to log in via a magic link sent to their email. This serves as a fallback for devices without passkey support or for logging in on a new device.
|
||||
|
||||
#### Scenario: Request magic link
|
||||
- **WHEN** a user enters their email and clicks "Send magic link"
|
||||
- **THEN** an email with a single-use login link is sent to their address
|
||||
|
||||
#### Scenario: Valid magic link
|
||||
- **WHEN** a user clicks a valid, non-expired magic link
|
||||
- **THEN** the user is logged in and the link is invalidated
|
||||
|
||||
#### Scenario: Expired magic link
|
||||
- **WHEN** a user clicks a magic link older than 15 minutes
|
||||
- **THEN** the system displays an error and prompts them to request a new link
|
||||
|
||||
#### Scenario: Rate limiting
|
||||
- **WHEN** a user requests more than 5 magic links in 10 minutes
|
||||
- **THEN** subsequent requests are rejected with a rate limit message
|
||||
|
||||
### Requirement: Add passkey from new device
|
||||
The Journal SHALL allow logged-in users to register additional passkeys for new devices.
|
||||
|
||||
#### Scenario: Add passkey after magic link login
|
||||
- **WHEN** a user logs in via magic link on a new device
|
||||
- **THEN** the system prompts them to register a passkey for that device
|
||||
|
||||
### Requirement: User profile page
|
||||
Each user SHALL have a public profile page displaying their username and routes.
|
||||
|
|
@ -50,3 +80,10 @@ The Journal SHALL maintain authenticated sessions using secure HTTP-only cookies
|
|||
#### Scenario: Logout
|
||||
- **WHEN** a user clicks "Log out"
|
||||
- **THEN** their session is invalidated and they are redirected to the login page
|
||||
|
||||
### Requirement: No passwords
|
||||
The Journal SHALL NOT support password-based authentication. All authentication is via passkeys or magic links.
|
||||
|
||||
#### Scenario: No password field
|
||||
- **WHEN** a user views the registration or login page
|
||||
- **THEN** there is no password field
|
||||
|
|
|
|||
|
|
@ -63,13 +63,16 @@
|
|||
|
||||
## 7. Journal — Auth
|
||||
|
||||
- [ ] 7.1 Set up PostgreSQL schema (journal.users table with id, email, password_hash, username, bio, created_at)
|
||||
- [ ] 7.2 Implement registration page and API (POST /api/auth/register)
|
||||
- [ ] 7.3 Implement login page and API (POST /api/auth/login, session cookie)
|
||||
- [ ] 7.4 Implement logout (POST /api/auth/logout, invalidate session)
|
||||
- [ ] 7.5 Implement session middleware (validate cookie, load user in loader context)
|
||||
- [ ] 7.6 Implement user profile page (GET /users/:username)
|
||||
- [ ] 7.7 Store federated identity format (@user@domain) in user record
|
||||
- [x] 7.1 Update DB schema: remove password_hash from users, add credentials table (WebAuthn) and magic_tokens table
|
||||
- [x] 7.2 Implement passkey registration flow (email + username → WebAuthn create → account created)
|
||||
- [x] 7.3 Implement passkey login flow (WebAuthn get → session created)
|
||||
- [x] 7.4 Implement magic link request (POST /api/auth/magic-link → send email with token)
|
||||
- [x] 7.5 Implement magic link verification (GET /auth/verify?token=... → session created)
|
||||
- [x] 7.6 Implement "Add passkey" prompt after magic link login on new device
|
||||
- [x] 7.7 Implement session middleware (validate cookie, load user in loader context)
|
||||
- [x] 7.8 Implement logout (POST /api/auth/logout, invalidate session)
|
||||
- [x] 7.9 Implement user profile page (GET /users/:username)
|
||||
- [x] 7.10 Store federated identity format (@user@domain) in user record
|
||||
|
||||
## 8. Journal — Route Management
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ export const journalSchema = pgSchema("journal");
|
|||
export const users = journalSchema.table("users", {
|
||||
id: text("id").primaryKey(),
|
||||
email: text("email").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
username: text("username").notNull().unique(),
|
||||
displayName: text("display_name"),
|
||||
bio: text("bio"),
|
||||
|
|
@ -33,6 +32,28 @@ export const users = journalSchema.table("users", {
|
|||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const credentials = journalSchema.table("credentials", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
credentialId: bytea("credential_id").notNull(),
|
||||
publicKey: bytea("public_key").notNull(),
|
||||
counter: integer("counter").notNull().default(0),
|
||||
deviceType: text("device_type"),
|
||||
transports: jsonb("transports").$type<string[]>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const magicTokens = journalSchema.table("magic_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
email: text("email").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const routes = journalSchema.table("routes", {
|
||||
id: text("id").primaryKey(),
|
||||
ownerId: text("owner_id")
|
||||
|
|
|
|||
224
pnpm-lock.yaml
generated
224
pnpm-lock.yaml
generated
|
|
@ -167,6 +167,12 @@ importers:
|
|||
'@react-router/serve':
|
||||
specifier: 'catalog:'
|
||||
version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
||||
'@simplewebauthn/browser':
|
||||
specifier: ^13.3.0
|
||||
version: 13.3.0
|
||||
'@simplewebauthn/server':
|
||||
specifier: ^13.3.0
|
||||
version: 13.3.0
|
||||
'@trails-cool/db':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db
|
||||
|
|
@ -204,6 +210,9 @@ importers:
|
|||
'@react-router/dev':
|
||||
specifier: 'catalog:'
|
||||
version: 7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.21.0)(typescript@5.9.3)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
|
||||
'@simplewebauthn/types':
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.0
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.2(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
|
||||
|
|
@ -911,6 +920,9 @@ packages:
|
|||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@hexagon/base64@1.1.28':
|
||||
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
|
@ -943,9 +955,49 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@levischuck/tiny-cbor@0.2.11':
|
||||
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
|
||||
|
||||
'@mjackson/node-fetch-server@0.2.0':
|
||||
resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==}
|
||||
|
||||
'@peculiar/asn1-android@2.6.0':
|
||||
resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==}
|
||||
|
||||
'@peculiar/asn1-cms@2.6.1':
|
||||
resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==}
|
||||
|
||||
'@peculiar/asn1-csr@2.6.1':
|
||||
resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==}
|
||||
|
||||
'@peculiar/asn1-ecc@2.6.1':
|
||||
resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==}
|
||||
|
||||
'@peculiar/asn1-pfx@2.6.1':
|
||||
resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==}
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.6.1':
|
||||
resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==}
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.6.1':
|
||||
resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==}
|
||||
|
||||
'@peculiar/asn1-rsa@2.6.1':
|
||||
resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==}
|
||||
|
||||
'@peculiar/asn1-schema@2.6.0':
|
||||
resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==}
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.6.1':
|
||||
resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==}
|
||||
|
||||
'@peculiar/asn1-x509@2.6.1':
|
||||
resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==}
|
||||
|
||||
'@peculiar/x509@1.14.3':
|
||||
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@playwright/test@1.58.2':
|
||||
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -1138,6 +1190,17 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@simplewebauthn/browser@13.3.0':
|
||||
resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==}
|
||||
|
||||
'@simplewebauthn/server@13.3.0':
|
||||
resolution: {integrity: sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@simplewebauthn/types@12.0.0':
|
||||
resolution: {integrity: sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
|
|
@ -1448,6 +1511,10 @@ packages:
|
|||
array-flatten@1.1.1:
|
||||
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
||||
|
||||
asn1js@3.0.7:
|
||||
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -2427,6 +2494,13 @@ packages:
|
|||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
||||
|
||||
pvutils@1.1.5:
|
||||
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
qs@6.14.2:
|
||||
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
|
@ -2496,6 +2570,9 @@ packages:
|
|||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -2649,11 +2726,21 @@ packages:
|
|||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsx@4.21.0:
|
||||
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tsyringe@4.10.0:
|
||||
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
turbo@2.8.20:
|
||||
resolution: {integrity: sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ==}
|
||||
hasBin: true
|
||||
|
|
@ -3360,6 +3447,8 @@ snapshots:
|
|||
|
||||
'@exodus/bytes@1.15.0': {}
|
||||
|
||||
'@hexagon/base64@1.1.28': {}
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.7':
|
||||
|
|
@ -3390,8 +3479,106 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@levischuck/tiny-cbor@0.2.11': {}
|
||||
|
||||
'@mjackson/node-fetch-server@0.2.0': {}
|
||||
|
||||
'@peculiar/asn1-android@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-cms@2.6.1':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.1
|
||||
'@peculiar/asn1-x509-attr': 2.6.1
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-csr@2.6.1':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.1
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-ecc@2.6.1':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.1
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pfx@2.6.1':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.1
|
||||
'@peculiar/asn1-pkcs8': 2.6.1
|
||||
'@peculiar/asn1-rsa': 2.6.1
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.6.1':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.1
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.6.1':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.1
|
||||
'@peculiar/asn1-pfx': 2.6.1
|
||||
'@peculiar/asn1-pkcs8': 2.6.1
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.1
|
||||
'@peculiar/asn1-x509-attr': 2.6.1
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-rsa@2.6.1':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.1
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-schema@2.6.0':
|
||||
dependencies:
|
||||
asn1js: 3.0.7
|
||||
pvtsutils: 1.3.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.6.1':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.1
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509@2.6.1':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
pvtsutils: 1.3.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/x509@1.14.3':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.1
|
||||
'@peculiar/asn1-csr': 2.6.1
|
||||
'@peculiar/asn1-ecc': 2.6.1
|
||||
'@peculiar/asn1-pkcs9': 2.6.1
|
||||
'@peculiar/asn1-rsa': 2.6.1
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.1
|
||||
pvtsutils: 1.3.6
|
||||
reflect-metadata: 0.2.2
|
||||
tslib: 2.8.1
|
||||
tsyringe: 4.10.0
|
||||
|
||||
'@playwright/test@1.58.2':
|
||||
dependencies:
|
||||
playwright: 1.58.2
|
||||
|
|
@ -3559,6 +3746,21 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@simplewebauthn/browser@13.3.0': {}
|
||||
|
||||
'@simplewebauthn/server@13.3.0':
|
||||
dependencies:
|
||||
'@hexagon/base64': 1.1.28
|
||||
'@levischuck/tiny-cbor': 0.2.11
|
||||
'@peculiar/asn1-android': 2.6.0
|
||||
'@peculiar/asn1-ecc': 2.6.1
|
||||
'@peculiar/asn1-rsa': 2.6.1
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.1
|
||||
'@peculiar/x509': 1.14.3
|
||||
|
||||
'@simplewebauthn/types@12.0.0': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tailwindcss/node@4.2.2':
|
||||
|
|
@ -3878,6 +4080,12 @@ snapshots:
|
|||
|
||||
array-flatten@1.1.1: {}
|
||||
|
||||
asn1js@3.0.7:
|
||||
dependencies:
|
||||
pvtsutils: 1.3.6
|
||||
pvutils: 1.1.5
|
||||
tslib: 2.8.1
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
babel-dead-code-elimination@1.0.12:
|
||||
|
|
@ -4678,6 +4886,12 @@ snapshots:
|
|||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
pvutils@1.1.5: {}
|
||||
|
||||
qs@6.14.2:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
|
@ -4735,6 +4949,8 @@ snapshots:
|
|||
indent-string: 4.0.0
|
||||
strip-indent: 3.0.0
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
|
@ -4909,6 +5125,10 @@ snapshots:
|
|||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
dependencies:
|
||||
esbuild: 0.27.4
|
||||
|
|
@ -4916,6 +5136,10 @@ snapshots:
|
|||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tsyringe@4.10.0:
|
||||
dependencies:
|
||||
tslib: 1.14.1
|
||||
|
||||
turbo@2.8.20:
|
||||
optionalDependencies:
|
||||
'@turbo/darwin-64': 2.8.20
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue