feature: add ability to join org as super admin

This commit is contained in:
Scott Wilson
2025-09-02 13:33:28 -07:00
parent 4d6e12d6b2
commit 435bcd03d3
4 changed files with 154 additions and 12 deletions
@@ -896,4 +896,34 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
return { organizationMembership }; return { organizationMembership };
} }
}); });
server.route({
method: "POST",
url: "/organization-management/organizations/:organizationId/access",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
organizationId: z.string()
}),
response: {
200: z.object({
organizationMembership: OrgMembershipsSchema
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async (req) => {
const organizationMembership = await server.services.superAdmin.joinOrganization(
req.params.organizationId,
req.permission
);
return { organizationMembership };
}
});
}; };
@@ -934,6 +934,36 @@ export const superAdminServiceFactory = ({
return organizationMembership; return organizationMembership;
}; };
const joinOrganization = async (orgId: string, actor: OrgServiceActor) => {
const serverAdmin = await userDAL.findById(actor.id);
if (!serverAdmin) {
throw new NotFoundError({ message: "Could not find server admin user" });
}
const org = await orgDAL.findById(orgId);
if (!org) {
throw new NotFoundError({ message: `Could not organization with ID "${orgId}"` });
}
const existingOrgMembership = await orgMembershipDAL.findOne({ userId: serverAdmin.id, orgId });
if (existingOrgMembership) {
throw new BadRequestError({ message: `You are already a part of the organization with ID ${orgId}` });
}
const orgMembership = await orgDAL.createMembership({
userId: serverAdmin.id,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted,
isActive: true
});
return orgMembership;
};
const resendOrgInvite = async ({ organizationId, membershipId }: TResendOrgInviteDTO, actor: OrgServiceActor) => { const resendOrgInvite = async ({ organizationId, membershipId }: TResendOrgInviteDTO, actor: OrgServiceActor) => {
const orgMembership = await orgMembershipDAL.findOne({ id: membershipId, orgId: organizationId }); const orgMembership = await orgMembershipDAL.findOne({ id: membershipId, orgId: organizationId });
@@ -1124,6 +1154,7 @@ export const superAdminServiceFactory = ({
getEnvOverridesOrganized, getEnvOverridesOrganized,
deleteUsers, deleteUsers,
createOrganization, createOrganization,
joinOrganization,
resendOrgInvite resendOrgInvite
}; };
}; };
+17
View File
@@ -223,3 +223,20 @@ export const useServerAdminResendOrgInvite = () => {
} }
}); });
}; };
export const useServerAdminAccessOrg = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (orgId: string) => {
const { data } = await apiRequest.post(
`/api/v1/admin/organization-management/organizations/${orgId}/access`
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: organizationKeys.getUserOrganizations });
queryClient.invalidateQueries({ queryKey: adminQueryKeys.getOrganizations() });
}
});
};
@@ -10,12 +10,15 @@ import {
faMagnifyingGlass, faMagnifyingGlass,
faPlus, faPlus,
faTrash, faTrash,
faUserCheck,
faUserMinus, faUserMinus,
faUserPlus,
faUsers, faUsers,
faUserXmark, faUserXmark,
faWarning faWarning
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import {
@@ -42,6 +45,7 @@ import {
Tooltip, Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useUser } from "@app/context";
import { OrgMembershipRole } from "@app/helpers/roles"; import { OrgMembershipRole } from "@app/helpers/roles";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { import {
@@ -49,6 +53,7 @@ import {
useAdminDeleteOrganizationMembership, useAdminDeleteOrganizationMembership,
useAdminDeleteUser, useAdminDeleteUser,
useAdminGetOrganizations, useAdminGetOrganizations,
useServerAdminAccessOrg,
useServerAdminResendOrgInvite useServerAdminResendOrgInvite
} from "@app/hooks/api"; } from "@app/hooks/api";
import { OrganizationWithProjects } from "@app/hooks/api/admin/types"; import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
@@ -429,6 +434,9 @@ const OrganizationsPanelTable = ({
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState(""); const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500); const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
const { user } = useUser();
const navigate = useNavigate();
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
useAdminGetOrganizations({ useAdminGetOrganizations({
limit: 20, limit: 20,
@@ -437,6 +445,31 @@ const OrganizationsPanelTable = ({
const isEmpty = !isPending && !data?.pages?.[0].length; const isEmpty = !isPending && !data?.pages?.[0].length;
const { mutateAsync: accessOrganization } = useServerAdminAccessOrg();
const handleAccessOrg = async (orgId: string) => {
try {
await accessOrganization(orgId);
navigate({
to: "/login/select-organization",
search: {
org_id: orgId
}
});
createNotification({
text: "Successfully joined organization",
type: "success"
});
} catch {
createNotification({
text: "Failed to join organization",
type: "error"
});
}
};
return ( return (
<> <>
<Input <Input
@@ -451,9 +484,9 @@ const OrganizationsPanelTable = ({
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th className="w-5/12">Name</Th> <Th className="w-1/2">Name</Th>
<Th className="w-5/12">Members</Th> <Th className="w-1/3">Members</Th>
<Th className="w-5/12">Projects</Th> <Th className="w-1/3">Projects</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
@@ -462,19 +495,39 @@ const OrganizationsPanelTable = ({
{!isPending && {!isPending &&
data?.pages?.map((orgs) => data?.pages?.map((orgs) =>
orgs.map((org) => { orgs.map((org) => {
const isMember = org.members.find((member) => member.user.id === user.id);
return ( return (
<Tr key={`org-${org.id}`} className="w-full"> <Tr key={`org-${org.id}`} className="w-full">
<Td className="w-5/12"> <Td className="w-1/2 max-w-0">
{org.name ? ( <div className="flex items-center gap-x-1.5">
org.name {org.name ? (
) : ( <p className="truncate">{org.name}</p>
<span className="text-mineshaft-400">Not Set</span> ) : (
)} <span className="text-mineshaft-400">Not Set</span>
)}
{isMember && (
<Tooltip
className="text-center"
content="You are a member of this organization"
>
<div>
<Badge
variant="success"
className="flex items-center gap-x-1 whitespace-nowrap"
>
<FontAwesomeIcon icon={faUserCheck} />
<span>Member</span>
</Badge>
</div>
</Tooltip>
)}
</div>
</Td> </Td>
<Td className="w-5/12"> <Td className="w-1/3">
<div className="flex items-center"> <div className="flex items-center">
{org.members.length} {org.members.length === 1 ? "Member" : "Members"} {org.members.length} {org.members.length === 1 ? "Member" : "Members"}
<Tooltip content="View Members"> <Tooltip className="text-center" content="View Members">
<IconButton <IconButton
ariaLabel="View Members" ariaLabel="View Members"
variant="plain" variant="plain"
@@ -502,7 +555,7 @@ const OrganizationsPanelTable = ({
)} )}
</div> </div>
</Td> </Td>
<Td className="w-5/12"> <Td className="w-1/3">
{org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"} {org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"}
</Td> </Td>
<Td> <Td>
@@ -519,6 +572,17 @@ const OrganizationsPanelTable = ({
</IconButton> </IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end"> <DropdownMenuContent sideOffset={2} align="end">
{!isMember && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleAccessOrg(org.id);
}}
icon={<FontAwesomeIcon icon={faUserPlus} />}
>
Join Organization
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();