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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -63,16 +63,16 @@
|
|||
|
||||
## 7. Journal — Auth
|
||||
|
||||
- [ ] 7.1 Update DB schema: remove password_hash from users, add credentials table (WebAuthn) and magic_tokens table
|
||||
- [ ] 7.2 Implement passkey registration flow (email + username → WebAuthn create → account created)
|
||||
- [ ] 7.3 Implement passkey login flow (WebAuthn get → session created)
|
||||
- [ ] 7.4 Implement magic link request (POST /api/auth/magic-link → send email with token)
|
||||
- [ ] 7.5 Implement magic link verification (GET /auth/verify?token=... → session created)
|
||||
- [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)
|
||||
- [ ] 7.6 Implement "Add passkey" prompt after magic link login on new device
|
||||
- [ ] 7.7 Implement session middleware (validate cookie, load user in loader context)
|
||||
- [ ] 7.8 Implement logout (POST /api/auth/logout, invalidate session)
|
||||
- [ ] 7.9 Implement user profile page (GET /users/:username)
|
||||
- [ ] 7.10 Store federated identity format (@user@domain) in user record
|
||||
- [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
|
||||
|
||||
|
|
|
|||
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