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
19 changes: 17 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,22 @@ 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, and refresh the cookie's Max-Age to track the user's
// current effective session duration.
if (user) {
const authSession = await getUserSession(request);
headers.append("Set-Cookie", await commitAuthenticatedSessionLazy(authSession));
}

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

Expand Down
35 changes: 28 additions & 7 deletions apps/webapp/app/routes/account.security/route.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
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 { 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 +27,20 @@ export const meta: MetaFunction = () => {
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);

const { durationSeconds, orgCapSeconds } = await getEffectiveSessionDuration(user.id);
const sessionDurationOptions = getAllowedSessionOptions(orgCapSeconds, durationSeconds);

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 +49,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,32 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireAdminApiRequest } from "~/services/personalAccessToken.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.
*/
maxSessionDuration: z.number().int().positive().nullable(),
});

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

const { organizationId } = ParamsSchema.parse(params);
const body = RequestBodySchema.parse(await request.json());

const organization = await prisma.organization.update({
where: { id: organizationId },
data: { maxSessionDuration: body.maxSessionDuration },
select: { id: true, slug: true, maxSessionDuration: true },
});
Comment on lines +25 to +29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle non-existent organization gracefully.

If organizationId doesn't exist, prisma.organization.update throws a Prisma P2025 error, resulting in an unhandled 500. Consider catching this or checking existence first to return a proper 404.

🛡️ Proposed fix
+  const organization = await prisma.organization.findFirst({
+    where: { id: organizationId },
+  });
+
+  if (!organization) {
+    throw new Response(JSON.stringify({ error: "Organization not found" }), {
+      status: 404,
+      headers: { "Content-Type": "application/json" },
+    });
+  }
+
-  const organization = await prisma.organization.update({
+  const updated = await prisma.organization.update({
     where: { id: organizationId },
     data: { maxSessionDuration: body.maxSessionDuration },
     select: { id: true, slug: true, maxSessionDuration: true },
   });

-  return json({ success: true, organization });
+  return json({ success: true, organization: updated });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/routes/admin.api.v1.orgs`.$organizationId.session-duration.ts
around lines 25 - 29, The update call using prisma.organization.update (where: {
id: organizationId }) can throw Prisma P2025 if the organizationId doesn't
exist; wrap the update in a try/catch or pre-check existence with
prisma.organization.findUnique({ where: { id: organizationId } }) and if not
found return a 404 response instead of letting the exception bubble;
alternatively catch the error from prisma.organization.update, detect error.code
=== 'P2025' and return a 404 (otherwise rethrow or return 500), ensuring the
handler returns the selected fields (id, slug, maxSessionDuration) on success.


return json({ success: true, organization });
}
3 changes: 2 additions & 1 deletion apps/webapp/app/routes/auth.github.callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } 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 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 on lines 53 to +56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Auth callbacks commit a __message Session object via __session's commitSession

In auth.github.callback.tsx and auth.google.callback.tsx, getSession is imported from ~/models/message.server (which reads the __message cookie), but the resulting Session object is committed via commitAuthenticatedSession which wraps commitSession from ~/services/sessionStorage.server (which writes the __session cookie). This means the __session cookie is populated with the __message session's data plus the new auth key and issuedAt. This is a pre-existing pattern (the old code did the same with plain commitSession from sessionStorage), and it works because Remix's commitSession serializes whatever data is in the Session object to its associated cookie name regardless of which storage created the Session. However, it's fragile and non-obvious — any flash message data in __message leaks into __session.

(Refers to lines 22-56)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));

await trackAndClearReferralSource(request, auth.userId, headers);
Expand Down
3 changes: 2 additions & 1 deletion apps/webapp/app/routes/auth.google.callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } 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 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>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 on lines +23 to +30
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Switch may no longer have an accessible name.

On Line 23-30, label was removed from Switch, and the text label (Line 17) is not programmatically linked to the control. Please add aria-label/aria-labelledby (or restore a semantic label) to keep screen-reader support intact.

Suggested fix
-          <Label>Multi-factor authentication</Label>
+          <Label id="mfa-label">Multi-factor authentication</Label>
           <Paragraph variant="small">
             Require a one-time code from your authenticator app (TOTP).
           </Paragraph>
@@
           <Switch
             id="mfa"
+            aria-labelledby="mfa-label"
             variant="medium"
             labelPosition="right"
             className="w-fit pr-3"
             checked={isEnabled}
             onCheckedChange={onToggle}
           />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx` around
lines 23 - 30, The Switch component rendering (Switch with id "mfa", props
checked={isEnabled} and onCheckedChange={onToggle}) currently lacks a
programmatic label; add an accessible name by either restoring a semantic label
or adding aria-label or aria-labelledby pointing to the visible text label
element (e.g., ensure the visible label element has an id and set
aria-labelledby="that-id" on Switch) so screen readers can identify the control.

</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