fd4f02b2f8
## What & why Two related fixes to how new cloud organizations get onboarded onto the Free plan. ### 1. Route new cloud orgs through plan selection New cloud organizations were created already activated, so they skipped the plan-selection step and went straight to creating projects — which meant their plan and usage limits were never set up. They're now created deactivated and routed through plan selection, which activates them once a plan is chosen. Self-hosted installs have no plan-selection step, so they're activated immediately on creation and are unaffected. The `Organization.v3Enabled` field is renamed to `isActivated` to better describe what it now gates. It's mapped to the existing `v3Enabled` column, so there's no data migration — only a schema/code rename. ### 2. Allow selecting the Free plan without GitHub verification Choosing the Free plan no longer requires connecting and verifying a GitHub account. The plan is applied immediately when selected. This removes: - the "Connect to GitHub" dialog and the GitHub-verified badge from the plan picker - the account-rejected state - the now-unreachable GitHub-connect return routes ## Notes - These changes pair with the corresponding change in the billing service that applies the Free plan directly; they should be released together. ## Testing Verified locally end to end: a new cloud org is routed to plan selection, the Free plan applies in one click with no GitHub step, the org is activated, its usage allowance is provisioned, and it lands on the new-project page. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
285 lines
6.3 KiB
TypeScript
285 lines
6.3 KiB
TypeScript
import { redirect } from "@remix-run/server-runtime";
|
|
import { prisma } from "~/db.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import type { SearchParams } from "~/routes/admin._index";
|
|
import {
|
|
clearImpersonationId,
|
|
commitImpersonationSession,
|
|
getImpersonationId,
|
|
setImpersonationId,
|
|
} from "~/services/impersonation.server";
|
|
import { authenticator } from "~/services/auth.server";
|
|
import { requireUser } from "~/services/session.server";
|
|
import { extractClientIp } from "~/utils/extractClientIp.server";
|
|
|
|
const pageSize = 20;
|
|
|
|
export async function adminGetUsers(userId: string, { page, search }: SearchParams) {
|
|
page = page || 1;
|
|
|
|
search = search ? decodeURIComponent(search) : undefined;
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: {
|
|
id: userId,
|
|
},
|
|
});
|
|
|
|
if (user?.admin !== true) {
|
|
throw new Error("Unauthorized");
|
|
}
|
|
|
|
const users = await prisma.user.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
admin: true,
|
|
createdAt: true,
|
|
displayName: true,
|
|
orgMemberships: {
|
|
select: {
|
|
organization: {
|
|
select: {
|
|
title: true,
|
|
slug: true,
|
|
deletedAt: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
where: search
|
|
? {
|
|
OR: [
|
|
{
|
|
name: {
|
|
contains: search,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
{
|
|
email: {
|
|
contains: search,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
{
|
|
orgMemberships: {
|
|
some: {
|
|
organization: {
|
|
title: {
|
|
contains: search,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
orgMemberships: {
|
|
some: {
|
|
organization: {
|
|
slug: {
|
|
contains: search,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}
|
|
: undefined,
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
take: pageSize,
|
|
skip: (page - 1) * pageSize,
|
|
});
|
|
|
|
const totalUsers = await prisma.user.count();
|
|
|
|
return {
|
|
users,
|
|
page,
|
|
pageCount: Math.ceil(totalUsers / pageSize),
|
|
filters: {
|
|
search,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function adminGetOrganizations(userId: string, { page, search }: SearchParams) {
|
|
page = page || 1;
|
|
|
|
search = search ? decodeURIComponent(search) : undefined;
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: {
|
|
id: userId,
|
|
},
|
|
});
|
|
|
|
if (user?.admin !== true) {
|
|
throw new Error("Unauthorized");
|
|
}
|
|
|
|
const organizations = await prisma.organization.findMany({
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
title: true,
|
|
v2Enabled: true,
|
|
isActivated: true,
|
|
deletedAt: true,
|
|
members: {
|
|
select: {
|
|
user: {
|
|
select: {
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
where: search
|
|
? {
|
|
OR: [
|
|
{
|
|
members: {
|
|
some: {
|
|
user: {
|
|
name: {
|
|
contains: search,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
members: {
|
|
some: {
|
|
user: {
|
|
email: {
|
|
contains: search,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
slug: {
|
|
contains: search,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
{
|
|
title: {
|
|
contains: search,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
{
|
|
id: {
|
|
contains: search,
|
|
mode: "insensitive",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
: undefined,
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
take: pageSize,
|
|
skip: (page - 1) * pageSize,
|
|
});
|
|
|
|
const totalOrgs = await prisma.organization.count();
|
|
|
|
return {
|
|
organizations,
|
|
page,
|
|
pageCount: Math.ceil(totalOrgs / pageSize),
|
|
filters: {
|
|
search,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function redirectWithImpersonation(
|
|
request: Request,
|
|
userId: string,
|
|
path: string,
|
|
currentUser?: { id: string; admin: boolean }
|
|
) {
|
|
const user = currentUser ?? (await requireUser(request));
|
|
if (!user.admin) {
|
|
throw new Error("Unauthorized");
|
|
}
|
|
|
|
const xff = request.headers.get("x-forwarded-for");
|
|
const ipAddress = extractClientIp(xff);
|
|
|
|
try {
|
|
await prisma.impersonationAuditLog.create({
|
|
data: {
|
|
action: "START",
|
|
adminId: user.id,
|
|
targetId: userId,
|
|
ipAddress,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logger.error("Failed to create impersonation audit log", {
|
|
error,
|
|
adminId: user.id,
|
|
targetId: userId,
|
|
});
|
|
}
|
|
|
|
const session = await setImpersonationId(userId, request);
|
|
|
|
return redirect(path, {
|
|
headers: { "Set-Cookie": await commitImpersonationSession(session) },
|
|
});
|
|
}
|
|
|
|
export async function clearImpersonation(request: Request, path: string) {
|
|
const authUser = await authenticator.isAuthenticated(request);
|
|
const targetId = await getImpersonationId(request);
|
|
|
|
if (targetId && authUser?.userId) {
|
|
const xff = request.headers.get("x-forwarded-for");
|
|
const ipAddress = extractClientIp(xff);
|
|
|
|
try {
|
|
await prisma.impersonationAuditLog.create({
|
|
data: {
|
|
action: "STOP",
|
|
adminId: authUser.userId,
|
|
targetId,
|
|
ipAddress,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logger.error("Failed to create impersonation audit log", {
|
|
error,
|
|
adminId: authUser.userId,
|
|
targetId,
|
|
});
|
|
}
|
|
}
|
|
|
|
const session = await clearImpersonationId(request);
|
|
|
|
return redirect(path, {
|
|
headers: {
|
|
"Set-Cookie": await commitImpersonationSession(session),
|
|
},
|
|
});
|
|
}
|