Merge branch 'main' into dependabot/npm_and_yarn/production-69e9f3c6a7
This commit is contained in:
commit
6cd779ae02
59 changed files with 670 additions and 99 deletions
|
|
@ -396,23 +396,10 @@ export async function verifyEmailChange(token: string, userId: string): Promise<
|
|||
return newEmail;
|
||||
}
|
||||
|
||||
// --- 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],
|
||||
},
|
||||
});
|
||||
// Cookie session storage lives at `./auth/session.ts` (see ADR-0004 + the
|
||||
// `auth/completion.ts` chokepoint). Import session helpers directly from
|
||||
// there; this file owns only per-method identity verification (passkey
|
||||
// ceremony + magic-token lifecycle) and Terms recording.
|
||||
|
||||
/**
|
||||
* A row that carries the minimum a visibility check needs.
|
||||
|
|
@ -466,23 +453,3 @@ export async function recordTermsAcceptance(userId: string, termsVersion: string
|
|||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
112
apps/journal/app/lib/auth/completion.server.test.ts
Normal file
112
apps/journal/app/lib/auth/completion.server.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// Contract tests for the completeAuth chokepoint. See ADR-0004 +
|
||||
// docs/adr/0005-no-authmethod-polymorphism.md for why this exists.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { completeAuth } from "./completion.server.ts";
|
||||
|
||||
function reqWith(headers: Record<string, string> = {}) {
|
||||
return new Request("https://localhost/whatever", { headers });
|
||||
}
|
||||
|
||||
describe("completeAuth (mode=redirect)", () => {
|
||||
it("redirects to returnTo when it's a same-origin path", async () => {
|
||||
const res = await completeAuth({
|
||||
userId: "u1",
|
||||
request: reqWith(),
|
||||
returnTo: "/profile",
|
||||
});
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("Location")).toBe("/profile");
|
||||
});
|
||||
|
||||
it("redirects to / when returnTo is missing", async () => {
|
||||
const res = await completeAuth({ userId: "u1", request: reqWith() });
|
||||
expect(res.headers.get("Location")).toBe("/");
|
||||
});
|
||||
|
||||
it("redirects to / when returnTo is null", async () => {
|
||||
const res = await completeAuth({
|
||||
userId: "u1",
|
||||
request: reqWith(),
|
||||
returnTo: null,
|
||||
});
|
||||
expect(res.headers.get("Location")).toBe("/");
|
||||
});
|
||||
|
||||
it("rejects protocol-relative returnTo (//evil.com/x) → /", async () => {
|
||||
const res = await completeAuth({
|
||||
userId: "u1",
|
||||
request: reqWith(),
|
||||
returnTo: "//evil.com/x",
|
||||
});
|
||||
expect(res.headers.get("Location")).toBe("/");
|
||||
});
|
||||
|
||||
it("rejects absolute-URL returnTo (https://evil.com) → /", async () => {
|
||||
const res = await completeAuth({
|
||||
userId: "u1",
|
||||
request: reqWith(),
|
||||
returnTo: "https://evil.com",
|
||||
});
|
||||
expect(res.headers.get("Location")).toBe("/");
|
||||
});
|
||||
|
||||
it("rejects returnTo that doesn't start with / → /", async () => {
|
||||
const res = await completeAuth({
|
||||
userId: "u1",
|
||||
request: reqWith(),
|
||||
returnTo: "javascript:alert(1)",
|
||||
});
|
||||
expect(res.headers.get("Location")).toBe("/");
|
||||
});
|
||||
|
||||
it("attaches a __session Set-Cookie carrying the userId", async () => {
|
||||
const res = await completeAuth({
|
||||
userId: "u1",
|
||||
request: reqWith(),
|
||||
returnTo: "/",
|
||||
});
|
||||
const setCookie = res.headers.get("Set-Cookie");
|
||||
expect(setCookie).not.toBeNull();
|
||||
expect(setCookie!).toMatch(/^__session=/);
|
||||
// HttpOnly is required for a session cookie.
|
||||
expect(setCookie!.toLowerCase()).toContain("httponly");
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeAuth (mode=json)", () => {
|
||||
it("returns 200 JSON with sanitized redirectTo and Set-Cookie", async () => {
|
||||
const res = await completeAuth({
|
||||
userId: "u1",
|
||||
request: reqWith(),
|
||||
returnTo: "/profile",
|
||||
mode: "json",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toContain("application/json");
|
||||
expect(res.headers.get("Set-Cookie")).toMatch(/^__session=/);
|
||||
const body = (await res.json()) as { ok: boolean; step: string; redirectTo: string };
|
||||
expect(body).toEqual({ ok: true, step: "done", redirectTo: "/profile" });
|
||||
});
|
||||
|
||||
it("falls back to / when returnTo is unsafe (json mode)", async () => {
|
||||
const res = await completeAuth({
|
||||
userId: "u1",
|
||||
request: reqWith(),
|
||||
returnTo: "https://evil.com",
|
||||
mode: "json",
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; redirectTo: string };
|
||||
expect(body.redirectTo).toBe("/");
|
||||
});
|
||||
|
||||
it("redirectTo defaults to / when no returnTo (json mode)", async () => {
|
||||
const res = await completeAuth({
|
||||
userId: "u1",
|
||||
request: reqWith(),
|
||||
mode: "json",
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; redirectTo: string };
|
||||
expect(body.redirectTo).toBe("/");
|
||||
});
|
||||
});
|
||||
76
apps/journal/app/lib/auth/completion.server.ts
Normal file
76
apps/journal/app/lib/auth/completion.server.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// completeAuth — the single chokepoint that every successful web auth
|
||||
// flow uses to mint the cookie session and produce the response. See
|
||||
// ADR-0004 + CONTEXT.md (Authentication section).
|
||||
//
|
||||
// Two response shapes, picked via `mode`:
|
||||
// - 'redirect' (loader callers / direct browser navigation):
|
||||
// 302 redirect to the sanitized target, Set-Cookie attached.
|
||||
// - 'json' (action callers from imperative fetch in client forms):
|
||||
// 200 JSON { ok: true, redirectTo } with Set-Cookie. Client reads
|
||||
// redirectTo and does window.location = redirectTo.
|
||||
//
|
||||
// Both modes share identity-agnostic behaviour: createSession + same-
|
||||
// origin sanitization + Set-Cookie. Per ADR-0005, this function knows
|
||||
// nothing about how identity was proved (passkey, magic-link, etc.) —
|
||||
// the caller does its own per-method verification first.
|
||||
//
|
||||
// Terms recording is NOT here: both registration paths (finishRegistration
|
||||
// for passkey, registerWithMagicLink for magic-link) record terms at
|
||||
// user-creation time, before any path can reach completeAuth.
|
||||
|
||||
import { redirect } from "react-router";
|
||||
import { createSession } from "./session.server.ts";
|
||||
|
||||
export type CompleteAuthMode = "redirect" | "json";
|
||||
|
||||
export interface CompleteAuthInput {
|
||||
userId: string;
|
||||
request: Request;
|
||||
/**
|
||||
* Optional caller-supplied redirect target. Validated as a same-origin
|
||||
* path; anything else falls back to "/". Pass through whatever you
|
||||
* received from the client — sanitization is centralized here.
|
||||
*/
|
||||
returnTo?: string | null;
|
||||
/**
|
||||
* Response shape:
|
||||
* - 'redirect' (default): 302 redirect Response; right for loaders
|
||||
* (direct browser navigation, e.g. auth.verify.tsx loader).
|
||||
* - 'json': 200 JSON `{ ok: true, redirectTo }`; right for action
|
||||
* handlers called by imperative fetch from client form code
|
||||
* (e.g. api.auth.register, api.auth.login). Client navigates
|
||||
* using `data.redirectTo`.
|
||||
*/
|
||||
mode?: CompleteAuthMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-origin path check. Accepts only paths that start with a single
|
||||
* "/" — rejects:
|
||||
* - Empty / null / undefined
|
||||
* - Protocol-relative ("//evil.com/x")
|
||||
* - Absolute URLs ("https://evil.com")
|
||||
* - Anything not starting with "/"
|
||||
*/
|
||||
function safeReturnTo(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string" || value.length === 0) return null;
|
||||
if (!value.startsWith("/")) return null;
|
||||
if (value.startsWith("//")) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function completeAuth(input: CompleteAuthInput): Promise<Response> {
|
||||
const cookie = await createSession(input.userId, input.request);
|
||||
const target = safeReturnTo(input.returnTo) ?? "/";
|
||||
const headers = { "Set-Cookie": cookie };
|
||||
if (input.mode === "json") {
|
||||
// `step: "done"` retained for compatibility with existing client
|
||||
// form checks (auth.register.tsx, auth.login.tsx). New code should
|
||||
// read `redirectTo` and navigate there.
|
||||
return Response.json(
|
||||
{ ok: true, step: "done", redirectTo: target },
|
||||
{ headers },
|
||||
);
|
||||
}
|
||||
return redirect(target, { headers });
|
||||
}
|
||||
46
apps/journal/app/lib/auth/session.server.ts
Normal file
46
apps/journal/app/lib/auth/session.server.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Cookie session storage. Lives here (separate from auth.server.ts) so
|
||||
// the post-verify chokepoint (./completion.ts) can compose it without
|
||||
// dragging the entire auth surface in.
|
||||
//
|
||||
// The legacy import path `~/lib/auth.server` continues to re-export
|
||||
// these symbols for backwards compat — see auth.server.ts.
|
||||
|
||||
import { createCookieSessionStorage } from "react-router";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { users } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "../db.ts";
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
oauthCodes,
|
||||
oauthTokens,
|
||||
} from "@trails-cool/db/schema/journal";
|
||||
import { getSessionUser } from "./auth.server.ts";
|
||||
import { getSessionUser } from "./auth/session.server.ts";
|
||||
|
||||
const CODE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const ACCESS_TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { Route } from "./+types/root";
|
|||
import * as Sentry from "@sentry/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { detectLocale } from "@trails-cool/i18n";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { LocaleProvider } from "~/components/LocaleContext";
|
||||
import { AlphaBanner } from "~/components/AlphaBanner";
|
||||
import { useUnreadNotifications } from "~/hooks/useUnreadNotifications";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/activities.$id";
|
||||
import { canView, getSessionUser } from "~/lib/auth.server";
|
||||
import { canView } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getActivity, deleteActivity, linkActivityToRoute, createRouteFromActivity, updateActivityVisibility } from "~/lib/activities.server";
|
||||
import { deleteImportByActivity } from "~/lib/sync/imports.server";
|
||||
import { listRoutes } from "~/lib/routes.server";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/activities._index";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { listActivities } from "~/lib/activities.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
import { ClientMap } from "~/components/ClientMap";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import type { Route } from "./+types/activities.new";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { createActivity } from "~/lib/activities.server";
|
||||
import { listRoutes } from "~/lib/routes.server";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.auth.login";
|
||||
import { startAuthentication, finishAuthentication, createMagicToken, verifyLoginCode, createSession } from "~/lib/auth.server";
|
||||
import { startAuthentication, finishAuthentication, createMagicToken, verifyLoginCode } from "~/lib/auth.server";
|
||||
import { completeAuth } from "~/lib/auth/completion.server";
|
||||
import { sendMagicLink } from "~/lib/email.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const body = await request.json();
|
||||
const { step, response, challenge, email, code } = body;
|
||||
const { step, response, challenge, email, code, returnTo } = body;
|
||||
|
||||
try {
|
||||
if (step === "start-passkey") {
|
||||
|
|
@ -15,8 +16,7 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
|
||||
if (step === "finish-passkey") {
|
||||
const userId = await finishAuthentication(response, challenge);
|
||||
const cookie = await createSession(userId, request);
|
||||
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
||||
return completeAuth({ userId, request, returnTo, mode: "json" });
|
||||
}
|
||||
|
||||
if (step === "magic-link") {
|
||||
|
|
@ -38,8 +38,7 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
return data({ error: "Email and code are required" }, { status: 400 });
|
||||
}
|
||||
const userId = await verifyLoginCode(email, code);
|
||||
const cookie = await createSession(userId, request);
|
||||
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
||||
return completeAuth({ userId, request, returnTo, mode: "json" });
|
||||
}
|
||||
|
||||
return data({ error: "Invalid step" }, { status: 400 });
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.auth.register";
|
||||
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish, registerWithMagicLink } from "~/lib/auth.server";
|
||||
import { startRegistration, finishRegistration, addPasskeyStart, addPasskeyFinish, registerWithMagicLink } from "~/lib/auth.server";
|
||||
import { completeAuth } from "~/lib/auth/completion.server";
|
||||
import { sendWelcome, sendMagicLink } from "~/lib/email.server";
|
||||
import { logger } from "~/lib/logger.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const body = await request.json();
|
||||
const { step, email, username, response, challenge, userId, termsAccepted, termsVersion } = body;
|
||||
const { step, email, username, response, challenge, userId, termsAccepted, termsVersion, returnTo } = body;
|
||||
const origin = process.env.ORIGIN ?? `http://localhost:3000`;
|
||||
|
||||
// Registration steps require terms acceptance + the version the client
|
||||
|
|
@ -27,11 +28,10 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
|
||||
if (step === "finish") {
|
||||
const newUserId = await finishRegistration(userId, email, username, response, challenge, termsVersion);
|
||||
const cookie = await createSession(newUserId, request);
|
||||
sendWelcome(email, username).catch((err) =>
|
||||
logger.error({ err }, "Failed to send welcome email"),
|
||||
);
|
||||
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
||||
return completeAuth({ userId: newUserId, request, returnTo, mode: "json" });
|
||||
}
|
||||
|
||||
if (step === "register-magic-link") {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Route } from "./+types/api.events";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { register } from "~/lib/events.server";
|
||||
import { countUnread } from "~/lib/notifications.server";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.follows.$id.approve";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { approveFollowRequest } from "~/lib/follow.server";
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.follows.$id.reject";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { rejectFollowRequest } from "~/lib/follow.server";
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.notifications.$id.read";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { markRead } from "~/lib/notifications.server";
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.notifications.read-all";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { markAllRead } from "~/lib/notifications.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.routes.$id.edit-in-planner";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getRouteWithVersions } from "~/lib/routes.server";
|
||||
import { createRouteToken } from "~/lib/jwt.server";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { redirect } from "react-router";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Route } from "./+types/api.settings.delete-account";
|
||||
import { getSessionUser, destroySession } from "~/lib/auth.server";
|
||||
import { getSessionUser, destroySession } from "~/lib/auth/session.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { users } from "@trails-cool/db/schema/journal";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import type { Route } from "./+types/api.settings.email";
|
||||
import { getSessionUser, initiateEmailChange } from "~/lib/auth.server";
|
||||
import { initiateEmailChange } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { sendMagicLink } from "~/lib/email.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import type { Route } from "./+types/api.settings.passkey.delete";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { credentials } from "@trails-cool/db/schema/journal";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Route } from "./+types/api.settings.profile";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { users } from "@trails-cool/db/schema/journal";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/api.sync.callback.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getManifest, link } from "~/lib/connected-services";
|
||||
import {
|
||||
decodeOAuthState,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/api.sync.connect.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getManifest } from "~/lib/connected-services";
|
||||
import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/api.sync.disconnect.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getManifest, unlinkByUserProvider } from "~/lib/connected-services";
|
||||
|
||||
export async function action({ params, request }: Route.ActionArgs) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/api.sync.push.$provider.$routeId";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getManifest } from "~/lib/connected-services";
|
||||
import { pushRouteToProvider } from "~/lib/connected-services/push-action.server";
|
||||
import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.users.$username.follow";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { followUser, FollowError } from "~/lib/follow.server";
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.users.$username.unfollow";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { unfollowUser, FollowError } from "~/lib/follow.server";
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { useState } from "react";
|
|||
import { Form, data, redirect, useLoaderData, useSearchParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/auth.accept-terms";
|
||||
import { getSessionUser, recordTermsAcceptance } from "~/lib/auth.server";
|
||||
import { recordTermsAcceptance } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { TERMS_VERSION } from "~/lib/legal";
|
||||
|
||||
export function meta() {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export default function LoginPage() {
|
|||
step: "finish-passkey",
|
||||
response: webAuthnResp,
|
||||
challenge: startData.options.challenge,
|
||||
returnTo,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -57,7 +58,8 @@ export default function LoginPage() {
|
|||
if (finishData.error) {
|
||||
setError(finishData.error);
|
||||
} else if (finishData.step === "done") {
|
||||
window.location.href = returnTo ?? "/";
|
||||
// Server-sanitized destination (see completeAuth in auth/completion.ts).
|
||||
window.location.href = finishData.redirectTo ?? "/";
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
|
|
@ -80,14 +82,14 @@ export default function LoginPage() {
|
|||
const resp = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step: "verify-code", email, code: loginCode }),
|
||||
body: JSON.stringify({ step: "verify-code", email, code: loginCode, returnTo }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else if (result.step === "done") {
|
||||
window.location.href = returnTo ?? "/";
|
||||
window.location.href = result.redirectTo ?? "/";
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { redirect } from "react-router";
|
||||
import type { Route } from "./+types/auth.logout";
|
||||
import { destroySession } from "~/lib/auth.server";
|
||||
import { destroySession } from "~/lib/auth/session.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const cookie = await destroySession(request);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export default function RegisterPage() {
|
|||
if (finishData.error) {
|
||||
setError(finishData.error);
|
||||
} else if (finishData.step === "done") {
|
||||
window.location.href = "/";
|
||||
window.location.href = finishData.redirectTo ?? "/";
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
|
|
@ -134,13 +134,21 @@ export default function RegisterPage() {
|
|||
const resp = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step: "verify-code", email, code: verifyCode }),
|
||||
body: JSON.stringify({
|
||||
step: "verify-code",
|
||||
email,
|
||||
code: verifyCode,
|
||||
// Just-registered user: prompt to set up a passkey so next time
|
||||
// they can use it directly. completeAuth's safeReturnTo accepts
|
||||
// this as a same-origin path.
|
||||
returnTo: "/?add-passkey=1",
|
||||
}),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else if (result.step === "done") {
|
||||
window.location.href = "/?add-passkey=1";
|
||||
window.location.href = result.redirectTo ?? "/?add-passkey=1";
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/auth.verify";
|
||||
import { verifyMagicToken, verifyEmailChange, createSession, getSessionUser } from "~/lib/auth.server";
|
||||
import { verifyMagicToken, verifyEmailChange } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { completeAuth } from "~/lib/auth/completion.server";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
|
|
@ -20,10 +22,13 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
}
|
||||
|
||||
const userId = await verifyMagicToken(token);
|
||||
const cookie = await createSession(userId, request);
|
||||
const returnTo = url.searchParams.get("returnTo");
|
||||
const destination = returnTo?.startsWith("/") ? returnTo : "/?add-passkey=1";
|
||||
return redirect(destination, { headers: { "Set-Cookie": cookie } });
|
||||
// Default destination after magic-link sign-in is "/?add-passkey=1"
|
||||
// (prompt to set up a passkey now that they're in). If the link
|
||||
// carried a returnTo, completeAuth's safeReturnTo will honor any
|
||||
// same-origin path and otherwise fall back to "/" — handle the
|
||||
// add-passkey default before delegating.
|
||||
const returnTo = url.searchParams.get("returnTo") ?? "/?add-passkey=1";
|
||||
return completeAuth({ userId, request, returnTo, mode: "redirect" });
|
||||
} catch (e) {
|
||||
return data({ error: (e as Error).message }, { status: 400 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { data } from "react-router";
|
|||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/explore";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import {
|
||||
EXPLORE_DEFAULT_PAGE_SIZE,
|
||||
countFollowersBatch,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { data, redirect } from "react-router";
|
|||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/feed";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { listSocialFeed, listRecentPublicActivities } from "~/lib/activities.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
import { ClientMap } from "~/components/ClientMap";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { data } from "react-router";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { eq, count } from "drizzle-orm";
|
||||
import type { Route } from "./+types/home";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { credentials } from "@trails-cool/db/schema/journal";
|
||||
import { listActivities, listRecentPublicActivities } from "~/lib/activities.server";
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { inArray, eq, and } from "drizzle-orm";
|
||||
import type { Route } from "./+types/notifications";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { listForUser } from "~/lib/notifications.server";
|
||||
import { linkFor } from "~/lib/notifications/link-for";
|
||||
import { readPayload } from "~/lib/notifications/payload";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Route } from "./+types/oauth.authorize";
|
||||
import { redirect } from "react-router";
|
||||
import { getSessionUser } from "../lib/auth.server.ts";
|
||||
import { getSessionUser } from "../lib/auth/session.server.ts";
|
||||
import {
|
||||
getOAuthClient,
|
||||
validateRedirectUri,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/routes.$id.edit";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getRoute, updateRoute } from "~/lib/routes.server";
|
||||
import type { Visibility } from "@trails-cool/db/schema/journal";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { data, redirect } from "react-router";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/routes.$id";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { canView, getSessionUser } from "~/lib/auth.server";
|
||||
import { canView } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { syncPushes } from "@trails-cool/db/schema/journal";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/routes._index";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { listRoutes } from "~/lib/routes.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
import { ClientMap } from "~/components/ClientMap";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
import { data, redirect } from "react-router";
|
||||
import type { Route } from "./+types/routes.new";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { createRoute } from "~/lib/routes.server";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
|
|||
import { data, redirect, useFetcher } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/settings.account";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
|
||||
export function meta() {
|
||||
return [{ title: "Account — Settings — trails.cool" }];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { data, redirect, useSearchParams } from "react-router";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Route } from "./+types/settings.connections";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { connectedServices } from "@trails-cool/db/schema/journal";
|
||||
import { getAllManifests } from "~/lib/connected-services";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
|
|||
import { data, redirect, useFetcher } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/settings.profile";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
|
||||
export function meta() {
|
||||
return [{ title: "Profile — Settings — trails.cool" }];
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { data, redirect, useFetcher } from "react-router";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Route } from "./+types/settings.security";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { credentials } from "@trails-cool/db/schema/journal";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Outlet, redirect } from "react-router";
|
|||
import { Link, useLocation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/settings";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
|
||||
export function meta() {
|
||||
return [{ title: "Settings — trails.cool" }];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||
import { data, redirect, useFetcher } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/sync.import.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import {
|
||||
getManifest,
|
||||
getService,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Route } from "./+types/users.$username.followers";
|
|||
import { getDb } from "~/lib/db";
|
||||
import { users } from "@trails-cool/db/schema/journal";
|
||||
import { listFollowers, countFollowers, getFollowState } from "~/lib/follow.server";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { CollectionPage } from "~/components/CollectionPage";
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Route } from "./+types/users.$username.following";
|
|||
import { getDb } from "~/lib/db";
|
||||
import { users } from "@trails-cool/db/schema/journal";
|
||||
import { listFollowing, countFollowing, getFollowState } from "~/lib/follow.server";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { CollectionPage } from "~/components/CollectionPage";
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { getDb } from "~/lib/db";
|
||||
import { users } from "@trails-cool/db/schema/journal";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getSessionUser } from "~/lib/auth/session.server";
|
||||
import { listPublicRoutesForOwner } from "~/lib/routes.server";
|
||||
import { listPublicActivitiesForOwner } from "~/lib/activities.server";
|
||||
import { loadPersona } from "~/lib/demo-bot.server";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue