Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions apps/webapp/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { env } from "./env.server";
import { featuresForRequest } from "./features.server";
import { usePostHog } from "./hooks/usePostHog";
import { getUser } from "./services/session.server";
import { commitAuthenticatedSessionLazy } from "./services/sessionDuration.server";
import { getUserSession } from "./services/sessionStorage.server";
import { getTimezonePreference } from "./services/preferences/uiPreferences.server";
import { appEnvTitleTag } from "./utils";

Expand Down Expand Up @@ -58,9 +60,23 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
websiteId: env.KAPA_AI_WEBSITE_ID,
};

const user = await getUser(request);

const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));

// Lazy-backfill the auth session's `issuedAt` for cookies issued before this
// feature shipped. Returns null (and does not commit) once issuedAt is set,
// so the cookie isn't re-written on every page load.
if (user) {
const authSession = await getUserSession(request);
const lazyCookie = await commitAuthenticatedSessionLazy(authSession);
if (lazyCookie) headers.append("Set-Cookie", lazyCookie);
}

return typedjson(
{
user: await getUser(request),
user,
toastMessage,
posthogProjectKey,
features,
Expand All @@ -70,7 +86,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
kapa,
timezone,
},
{ headers: { "Set-Cookie": await commitSession(session) } }
{ headers }
);
};

Expand Down
36 changes: 29 additions & 7 deletions apps/webapp/app/routes/account.security/route.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { type MetaFunction } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { MfaSetup } from "../resources.account.mfa.setup/route";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { $replica } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import {
getAllowedSessionOptions,
getEffectiveSessionDuration,
} from "~/services/sessionDuration.server";
import { MfaSetup } from "../resources.account.mfa.setup/route";
import { SessionDurationSetting } from "../resources.account.session-duration/SessionDurationSetting";

export const meta: MetaFunction = () => {
return [
Expand All @@ -22,13 +28,20 @@ export const meta: MetaFunction = () => {
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);

const { durationSeconds, orgCapSeconds } = await getEffectiveSessionDuration(user.id, $replica);
const sessionDurationOptions = getAllowedSessionOptions(orgCapSeconds, durationSeconds);
Comment thread
samejr marked this conversation as resolved.

return typedjson({
user,
sessionDuration: durationSeconds,
sessionDurationOptions,
orgCapSeconds,
});
}

export default function Page() {
const { user } = useTypedLoaderData<typeof loader>();
const { user, sessionDuration, sessionDurationOptions, orgCapSeconds } =
useTypedLoaderData<typeof loader>();

return (
<PageContainer>
Expand All @@ -37,11 +50,20 @@ export default function Page() {
</NavBar>

<PageBody>
<MainHorizontallyCenteredContainer className="grid place-items-center overflow-visible">
<div className="mb-3 w-full border-b border-grid-dimmed pb-3">
<MainHorizontallyCenteredContainer className="max-w-[37.5rem] overflow-visible">
<div className="w-full border-b border-grid-dimmed pb-3">
<Header2>Security</Header2>
</div>
<MfaSetup isEnabled={!!user.mfaEnabledAt} />
<div className="w-full border-b border-grid-dimmed py-4">
<MfaSetup isEnabled={!!user.mfaEnabledAt} />
</div>
<div className="w-full border-b border-grid-dimmed py-4">
<SessionDurationSetting
currentValue={sessionDuration}
options={sessionDurationOptions}
orgCapSeconds={orgCapSeconds}
/>
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
import {
ALLOWED_SESSION_DURATION_VALUES,
isAllowedSessionDuration,
} from "~/services/sessionDuration.server";

const ParamsSchema = z.object({
organizationId: z.string(),
});

const RequestBodySchema = z.object({
/**
* Maximum session lifetime (seconds) for members of this organization, or
* null to remove the cap. When set, this caps each member's
* `User.sessionDuration` and is enforced on the user's next request.
*
* Must be one of the values in `SESSION_DURATION_OPTIONS` so the cap always
* maps to a labeled dropdown option for users — otherwise users see fallback
* labels like "7200 seconds" in the UI. To allow a new value, add it to
* `SESSION_DURATION_OPTIONS`.
*/
maxSessionDuration: z
.number()
.int()
.positive()
.nullable()
.refine((v) => v === null || isAllowedSessionDuration(v), {
message: `maxSessionDuration must be one of: ${[...ALLOWED_SESSION_DURATION_VALUES]
.sort((a, b) => a - b)
.join(", ")}`,
}),
});

export async function action({ request, params }: ActionFunctionArgs) {
await requireAdminApiRequest(request);

const { organizationId } = ParamsSchema.parse(params);
const parseResult = RequestBodySchema.safeParse(await request.json());
if (!parseResult.success) {
return json({ success: false, errors: parseResult.error.flatten() }, { status: 400 });
}
const body = parseResult.data;

const organization = await prisma.organization.update({
where: { id: organizationId },
data: { maxSessionDuration: body.maxSessionDuration },
select: { id: true, slug: true, maxSessionDuration: true },
});
Comment thread
samejr marked this conversation as resolved.

return json({ success: true, organization });
}
9 changes: 5 additions & 4 deletions apps/webapp/app/routes/auth.github.callback.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { prisma } from "~/db.server";
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } from "~/services/sessionStorage.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
import { redirectCookie } from "./auth.github";
import { sanitizeRedirectPath } from "~/utils";
Expand All @@ -18,7 +19,7 @@ export let loader: LoaderFunction = async ({ request }) => {
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
});

const session = await getSession(request.headers.get("cookie"));
const session = await getUserSession(request);

const userRecord = await prisma.user.findFirst({
where: {
Expand Down Expand Up @@ -52,7 +53,7 @@ export let loader: LoaderFunction = async ({ request }) => {
session.set(authenticator.sessionKey, auth);

const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await commitAuthenticatedSession(session));
Comment thread
samejr marked this conversation as resolved.
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));

await trackAndClearReferralSource(request, auth.userId, headers);
Expand Down
9 changes: 5 additions & 4 deletions apps/webapp/app/routes/auth.google.callback.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { prisma } from "~/db.server";
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } from "~/services/sessionStorage.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
import { redirectCookie } from "./auth.google";
import { sanitizeRedirectPath } from "~/utils";
Expand All @@ -18,7 +19,7 @@ export let loader: LoaderFunction = async ({ request }) => {
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
});

const session = await getSession(request.headers.get("cookie"));
const session = await getUserSession(request);

const userRecord = await prisma.user.findFirst({
where: {
Expand Down Expand Up @@ -52,7 +53,7 @@ export let loader: LoaderFunction = async ({ request }) => {
session.set(authenticator.sessionKey, auth);

const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await commitAuthenticatedSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));

await trackAndClearReferralSource(request, auth.userId, headers);
Expand Down
10 changes: 7 additions & 3 deletions apps/webapp/app/routes/login.magic/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { TextLink } from "~/components/primitives/TextLink";
import { authenticator } from "~/services/auth.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { setRedirectTo, commitSession as commitRedirectSession } from "~/services/redirectTo.server";
import { sanitizeRedirectPath } from "~/utils";
import {
checkMagicLinkEmailRateLimit,
checkMagicLinkEmailDailyRateLimit,
Expand Down Expand Up @@ -60,11 +61,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
const session = await getUserSession(request);
const error = session.get("auth:error");

// Get redirectTo from URL params and store in session if present
// Get redirectTo from URL params and store in session if present.
// Sanitize to drop non-page paths (fetcher routes, callbacks) which would
// render blank if the user was sent there post-login.
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirectTo");
const sanitized = sanitizeRedirectPath(url.searchParams.get("redirectTo"));
const redirectTo = sanitized === "/" ? null : sanitized;
const headers = new Headers();

if (redirectTo) {
const redirectSession = await setRedirectTo(request, redirectTo);
headers.append("Set-Cookie", await commitRedirectSession(redirectSession));
Expand Down
3 changes: 2 additions & 1 deletion apps/webapp/app/routes/login.mfa/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { authenticator } from "~/services/auth.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
import { getSession as getMessageSession } from "~/models/message.server";
import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server";
import { redirectWithErrorMessage, redirectBackWithErrorMessage } from "~/models/message.server";
Expand Down Expand Up @@ -162,7 +163,7 @@ async function completeLogin(request: Request, session: Session, userId: string)
session.unset("pending-mfa-redirect-to");

const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await commitAuthenticatedSession(session));

await trackAndClearReferralSource(request, userId, headers);

Expand Down
9 changes: 7 additions & 2 deletions apps/webapp/app/routes/magic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { getRedirectTo } from "~/services/redirectTo.server";
import { commitSession, getSession } from "~/services/sessionStorage.server";
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
import { sanitizeRedirectPath } from "~/utils";

export async function loader({ request }: LoaderFunctionArgs) {
const redirectTo = await getRedirectTo(request);
// Defense-in-depth: sanitize the cookie value to drop non-page paths in case
// a stale cookie from before sanitization shipped is still in the browser.
const sanitized = sanitizeRedirectPath(await getRedirectTo(request));
const redirectTo = sanitized === "/" ? undefined : sanitized;

const auth = await authenticator.authenticate("email-link", request, {
failureRedirect: "/login/magic", // If auth fails, the failureRedirect will be thrown as a Response
Expand Down Expand Up @@ -51,7 +56,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
session.set(authenticator.sessionKey, auth);

const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await commitAuthenticatedSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));

await trackAndClearReferralSource(request, auth.userId, headers);
Expand Down
36 changes: 18 additions & 18 deletions apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,24 @@ interface MfaToggleProps {
export function MfaToggle({ isEnabled, onToggle }: MfaToggleProps) {
return (
<Form method="post" className="w-full">
<InputGroup className="mb-4">
<Label>Multi-factor authentication</Label>
<Paragraph variant="small">
Enable an extra layer of security by requiring a one-time code from your authenticator
app (TOTP) each time you log in.
</Paragraph>
</InputGroup>
<div className="flex items-center justify-between">
<Switch
id="mfa"
variant="medium"
label={isEnabled ? "Enabled" : "Enable"}
labelPosition="right"
className="-ml-2 w-fit pr-3"
checked={isEnabled}
onCheckedChange={onToggle}
/>
<div className="flex w-full items-center justify-between gap-4">
<InputGroup className="flex-1">
<Label htmlFor="mfa">Multi-factor authentication</Label>
<Paragraph variant="small">
Require a one-time code from your authenticator app (TOTP).
</Paragraph>
</InputGroup>
<div className="flex flex-none items-center">
<Switch
id="mfa"
variant="medium"
labelPosition="right"
className="w-fit pr-3"
checked={isEnabled}
onCheckedChange={onToggle}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
</div>
</Form>
);
}
}
18 changes: 10 additions & 8 deletions apps/webapp/app/routes/resources.account.mfa.setup/route.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
import { redirectWithSuccessMessage, redirectWithErrorMessage, typedJsonWithSuccessMessage } from "~/models/message.server";
import {
redirectWithSuccessMessage,
redirectWithErrorMessage,
typedJsonWithSuccessMessage,
} from "~/models/message.server";
import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server";
import { requireUserId } from "~/services/session.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
Expand Down Expand Up @@ -132,14 +136,15 @@ export async function action({ request }: ActionFunctionArgs) {
if (error instanceof ServiceValidationError) {
return redirectWithErrorMessage("/account/security", request, error.message);
}

// Re-throw unexpected errors
throw error;
}
}

export function MfaSetup({ isEnabled }: { isEnabled: boolean }) {
const { state, actions, isQrDialogOpen, isRecoveryDialogOpen, isDisableDialogOpen } = useMfaSetup(isEnabled);
const { state, actions, isQrDialogOpen, isRecoveryDialogOpen, isDisableDialogOpen } =
useMfaSetup(isEnabled);

const handleToggle = (enabled: boolean) => {
if (enabled && !state.isEnabled) {
Expand All @@ -151,10 +156,7 @@ export function MfaSetup({ isEnabled }: { isEnabled: boolean }) {

return (
<>
<MfaToggle
isEnabled={state.isEnabled}
onToggle={handleToggle}
/>
<MfaToggle isEnabled={state.isEnabled} onToggle={handleToggle} />

<MfaSetupDialog
isOpen={isQrDialogOpen}
Expand Down
Loading
Loading