Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2ee9047
Implements new session logout
samejr Apr 28, 2026
0783ff7
Submit the form without a save button
samejr Apr 28, 2026
62fecbc
Better settings layout
samejr Apr 28, 2026
6082525
Show friendly labels for capped duration values
samejr Apr 28, 2026
4237c5d
Bug fix: When getting logged out, logging back in redirected you to /…
samejr Apr 28, 2026
fa06144
Fixes bug where session logout returned error.
samejr Apr 28, 2026
31595ee
Remove login page toast message logic
samejr Apr 28, 2026
d7d6b2b
Cloudwatch picks up session logout events
samejr Apr 28, 2026
dffeec0
small classname tweak
samejr Apr 28, 2026
c294c50
Aggregate the session length values
samejr Apr 28, 2026
05095c5
Merge the 2 db migrations
samejr Apr 28, 2026
abd7562
userId param is no longer used
samejr Apr 28, 2026
9234800
Fix for ensuring a DB change updates the availble duration options
samejr Apr 29, 2026
bad8895
Code review fixes and improvements
samejr Apr 30, 2026
309742e
Code review improvements
samejr Apr 30, 2026
7ca42aa
Merge branch 'main' into feat(webapp)-auto-app-logout
samejr Apr 30, 2026
44d55cd
Code review fix
samejr Apr 30, 2026
2e0d248
code comment update
samejr Apr 30, 2026
b2ca91e
Merge branch 'main' into feat(webapp)-auto-app-logout
samejr May 1, 2026
bca5ec3
Merge branch 'main' into feat(webapp)-auto-app-logout
samejr May 1, 2026
4fa011d
perf(webapp): make auto-logout enforcement zero-query on the read path
matt-aitken May 3, 2026
411ad27
fix(webapp): restore admin verification on impersonation in getUserId
matt-aitken May 3, 2026
723b13f
fix(webapp): prevent /logout redirect loop on auto-logout
matt-aitken May 3, 2026
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
6 changes: 6 additions & 0 deletions .server-changes/app-auto-session-logout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

App auto session logout. Users can configure their own session duration; org admins can set a `maxSessionDuration` cap that takes the tightest value across an account's orgs. Sessions exceeding their effective duration are redirected to `/logout` with a HIPAA audit trail emitted to CloudWatch (`event: session.auto_logout`). Enforcement reads `User.nextSessionEnd` — written at login and bulk-updated when admins change the cap — so the auth path adds no per-request DB queries.
9 changes: 7 additions & 2 deletions apps/webapp/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,14 @@ 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));

return typedjson(
{
user: await getUser(request),
user,
toastMessage,
posthogProjectKey,
features,
Expand All @@ -70,7 +75,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,70 @@
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 });
}
Comment on lines +41 to +44
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 | ⚡ Quick win

Malformed JSON currently escapes as a 500.

request.json() can throw before Zod runs, so a syntactically invalid body never reaches the 400 response path.

🛡️ Proposed fix
-  const parseResult = RequestBodySchema.safeParse(await request.json());
+  let rawBody: unknown;
+  try {
+    rawBody = await request.json();
+  } catch {
+    return json(
+      { success: false, errors: { formErrors: ["Request body must be valid JSON"], fieldErrors: {} } },
+      { status: 400 }
+    );
+  }
+
+  const parseResult = RequestBodySchema.safeParse(rawBody);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parseResult = RequestBodySchema.safeParse(await request.json());
if (!parseResult.success) {
return json({ success: false, errors: parseResult.error.flatten() }, { status: 400 });
}
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return json(
{ success: false, errors: { formErrors: ["Request body must be valid JSON"], fieldErrors: {} } },
{ status: 400 }
);
}
const parseResult = RequestBodySchema.safeParse(rawBody);
if (!parseResult.success) {
return json({ success: false, errors: parseResult.error.flatten() }, { status: 400 });
}
🤖 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 41 - 44, The handler currently calls await request.json() directly
so syntactically invalid JSON throws before RequestBodySchema.safeParse runs;
wrap the request.json() call in a try/catch, and if parsing throws (e.g.,
SyntaxError) return the same 400-style JSON response (e.g., json({ success:
false, errors: ... }, { status: 400 })); on success continue to call
RequestBodySchema.safeParse(parseResult) as before. Ensure you reference
RequestBodySchema and the existing parseResult variable flow so the error path
mirrors the validation failure response.

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.

// Propagate the new cap to currently-logged-in members by shortening their
// `nextSessionEnd`. We only ever shorten (`LEAST`): raising or removing the
// cap leaves existing sessions alone — the larger window applies on next
// login. If a member is in another org with a tighter cap that other cap
// remains in effect via their existing `nextSessionEnd` (LEAST keeps it).
if (body.maxSessionDuration !== null) {
await prisma.$executeRaw`
UPDATE "User"
SET "nextSessionEnd" = LEAST(
COALESCE("nextSessionEnd", 'infinity'::timestamp),
NOW() + (LEAST("sessionDuration", ${body.maxSessionDuration}) * INTERVAL '1 second')
)
WHERE "id" IN (SELECT "userId" FROM "OrgMember" WHERE "organizationId" = ${organizationId})
`;
Comment on lines +58 to +66
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 | 🏗️ Heavy lift

Recompute the tightest cap across all memberships in this bulk update.

This query only uses body.maxSessionDuration. For users in multiple orgs, updating a non-tightest org can still stamp a later nextSessionEnd than their actual effective cap when the current value is NULL (legacy sessions) or otherwise stale/looser. The runtime path uses getOrganizationSessionCap() to take the minimum cap across all active orgs; this propagation query needs to mirror that rule.

🤖 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 58 - 66, The bulk UPDATE must recompute each user's effective cap
across all their org memberships like getOrganizationSessionCap() does: change
the prisma.$executeRaw query that updates "User"."nextSessionEnd" to compute a
per-user minimum cap via a subquery joining "OrgMember" -> "Org" (e.g. SELECT
MIN(COALESCE("Org"."sessionDuration",'infinity')) FROM "Org" JOIN "OrgMember"
WHERE "OrgMember"."userId" = "User"."id") and then take LEAST between that
per-user min and the incoming body.maxSessionDuration before computing NOW() +
(...) so the update uses the tightest cap across all memberships (use the same
column names "User", "OrgMember", "Org", nextSessionEnd, sessionDuration and
keep body.maxSessionDuration in the LEAST).

}

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, auth.userId));
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, auth.userId));
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, userId));

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, auth.userId));
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