|
| 1 | +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; |
| 2 | +import { z } from "zod"; |
| 3 | +import { prisma } from "~/db.server"; |
| 4 | +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; |
| 5 | +import { |
| 6 | + ALLOWED_SESSION_DURATION_VALUES, |
| 7 | + isAllowedSessionDuration, |
| 8 | +} from "~/services/sessionDuration.server"; |
| 9 | + |
| 10 | +const ParamsSchema = z.object({ |
| 11 | + organizationId: z.string(), |
| 12 | +}); |
| 13 | + |
| 14 | +const RequestBodySchema = z.object({ |
| 15 | + /** |
| 16 | + * Maximum session lifetime (seconds) for members of this organization, or |
| 17 | + * null to remove the cap. When set, this caps each member's |
| 18 | + * `User.sessionDuration` and is enforced on the user's next request. |
| 19 | + * |
| 20 | + * Must be one of the values in `SESSION_DURATION_OPTIONS` so the cap always |
| 21 | + * maps to a labeled dropdown option for users — otherwise users see fallback |
| 22 | + * labels like "7200 seconds" in the UI. To allow a new value, add it to |
| 23 | + * `SESSION_DURATION_OPTIONS`. |
| 24 | + */ |
| 25 | + maxSessionDuration: z |
| 26 | + .number() |
| 27 | + .int() |
| 28 | + .positive() |
| 29 | + .nullable() |
| 30 | + .refine((v) => v === null || isAllowedSessionDuration(v), { |
| 31 | + message: `maxSessionDuration must be one of: ${[...ALLOWED_SESSION_DURATION_VALUES] |
| 32 | + .sort((a, b) => a - b) |
| 33 | + .join(", ")}`, |
| 34 | + }), |
| 35 | +}); |
| 36 | + |
| 37 | +export async function action({ request, params }: ActionFunctionArgs) { |
| 38 | + await requireAdminApiRequest(request); |
| 39 | + |
| 40 | + const { organizationId } = ParamsSchema.parse(params); |
| 41 | + let rawBody: unknown; |
| 42 | + try { |
| 43 | + rawBody = await request.json(); |
| 44 | + } catch { |
| 45 | + return json( |
| 46 | + { success: false, errors: { formErrors: ["Invalid JSON body"], fieldErrors: {} } }, |
| 47 | + { status: 400 } |
| 48 | + ); |
| 49 | + } |
| 50 | + const parseResult = RequestBodySchema.safeParse(rawBody); |
| 51 | + if (!parseResult.success) { |
| 52 | + return json({ success: false, errors: parseResult.error.flatten() }, { status: 400 }); |
| 53 | + } |
| 54 | + const body = parseResult.data; |
| 55 | + |
| 56 | + const organization = await prisma.organization.update({ |
| 57 | + where: { id: organizationId }, |
| 58 | + data: { maxSessionDuration: body.maxSessionDuration }, |
| 59 | + select: { id: true, slug: true, maxSessionDuration: true }, |
| 60 | + }); |
| 61 | + |
| 62 | + // Propagate the new cap to currently-logged-in members by shortening their |
| 63 | + // `nextSessionEnd`. We only ever shorten (`LEAST`): raising or removing the |
| 64 | + // cap leaves existing sessions alone — the larger window applies on next |
| 65 | + // login. If a member is in another org with a tighter cap that other cap |
| 66 | + // remains in effect via their existing `nextSessionEnd` (LEAST keeps it). |
| 67 | + if (body.maxSessionDuration !== null) { |
| 68 | + await prisma.$executeRaw` |
| 69 | + UPDATE "User" |
| 70 | + SET "nextSessionEnd" = LEAST( |
| 71 | + COALESCE("nextSessionEnd", 'infinity'::timestamp), |
| 72 | + NOW() + (LEAST("sessionDuration", ${body.maxSessionDuration}) * INTERVAL '1 second') |
| 73 | + ) |
| 74 | + WHERE "id" IN (SELECT "userId" FROM "OrgMember" WHERE "organizationId" = ${organizationId}) |
| 75 | + `; |
| 76 | + } |
| 77 | + |
| 78 | + return json({ success: true, organization }); |
| 79 | +} |
0 commit comments