Pass Okta SCIM 2.0 SPEC Test

This commit is contained in:
Tuan Dang
2024-02-14 09:22:23 -08:00
parent 3a7b697549
commit c73ee49425
29 changed files with 1098 additions and 136 deletions
+5 -3
View File
@@ -1,3 +1,5 @@
export {
useGetScimToken
} from "./queries";
export { useGetScimTokens } from "./queries";
export {
useCreateScimToken,
useDeleteScimToken
} from "./mutations";
+46
View File
@@ -0,0 +1,46 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import {
CreateScimTokenDTO,
CreateScimTokenRes,
DeleteScimTokenDTO
} from "./types";
import { scimKeys } from "./queries";
export const useCreateScimToken = () => {
const queryClient = useQueryClient();
return useMutation<CreateScimTokenRes, {}, CreateScimTokenDTO>({
mutationFn: async ({
organizationId,
description,
ttl
}) => {
const { data } = await apiRequest.post("/api/v1/scim/scim-tokens", {
organizationId,
description,
ttl
});
return data;
},
onSuccess: (_, { organizationId }) => {
queryClient.invalidateQueries(scimKeys.getScimTokens(organizationId));
}
});
};
export const useDeleteScimToken = () => {
const queryClient = useQueryClient();
return useMutation<CreateScimTokenRes, {}, DeleteScimTokenDTO>({
mutationFn: async ({
organizationId,
scimTokenId
}) => {
const { data } = await apiRequest.delete(`/api/v1/scim/scim-tokens/${scimTokenId}`);
return data;
},
onSuccess: (_, { organizationId }) => {
queryClient.invalidateQueries(scimKeys.getScimTokens(organizationId));
}
});
};
+9 -10
View File
@@ -1,23 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { ScimTokenData } from "./types";
import { GetScimTokenRes } from "./types";
const scimKeys = {
getScimToken: (orgId: string) => [{ orgId }, "organization-scim-token"] as const,
export const scimKeys = {
getScimTokens: (orgId: string) => [{ orgId }, "organization-scim-token"] as const,
};
export const useGetScimToken = (organizationId: string) => {
export const useGetScimTokens = (organizationId: string) => {
return useQuery({
queryKey: scimKeys.getScimToken(organizationId),
queryKey: scimKeys.getScimTokens(organizationId),
queryFn: async () => {
if (organizationId === "") {
return undefined;
}
const { data: { scimToken } } = await apiRequest.get<GetScimTokenRes>(`/api/v1/scim/token/organizations/${organizationId}`);
return scimToken;
const { data: { scimTokens } } = await apiRequest.get<{ scimTokens: ScimTokenData[] }>(`/api/v1/scim/scim-tokens?organizationId=${organizationId}`);
return scimTokens;
},
enabled: true
});
+23 -2
View File
@@ -1,3 +1,24 @@
export type GetScimTokenRes = {
export type ScimTokenData = {
id: string;
ttl: number;
description: string;
tokenSuffix: string;
orgId: string;
createdAt: string;
updatedAt: string;
};
export type CreateScimTokenDTO = {
organizationId: string;
description?: string;
ttl?: number;
}
export type DeleteScimTokenDTO = {
organizationId: string;
scimTokenId: string;
}
export type CreateScimTokenRes = {
scimToken: string;
};
}
@@ -18,6 +18,7 @@ export type SubscriptionPlan = {
workspacesUsed: number;
environmentLimit: number;
samlSSO: boolean;
scim: boolean;
status:
| "incomplete"
| "incomplete_expired"
@@ -7,52 +7,47 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
IconButton,
Switch
Switch,
UpgradePlanModal
} from "@app/components/v2";
import {
// OrgPermissionActions,
// OrgPermissionSubjects,
useOrganization,
// useSubscription
useSubscription
} from "@app/context";
import { useToggle } from "@app/hooks";
// import { usePopUp } from "@app/hooks/usePopUp";
import { useGetScimToken } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { ScimTokenModal } from "./ScimTokenModal";
// TODO: add permissioning for enteprise SCIM
export const OrgScimSection = () => {
const { currentOrg } = useOrganization();
// const { createNotification } = useNotificationContext();
// const { subscription } = useSubscription();
// const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
// "upgradePlan"
// ] as const);
const { data: scimToken } = useGetScimToken(currentOrg?.id ?? "");
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"scimToken",
"deleteScimToken",
"upgradePlan"
] as const);
const [scimEnabled, setScimEnabled] = useState(false); // sync this with backend
const [isAPIKeyCopied, setIsAPIKeyCopied] = useToggle(false);
// TODO: get SCIM stuf
const addScimTokenBtnClick = () => {
if (subscription?.scim) {
handlePopUpOpen("scimToken");
} else {
handlePopUpOpen("upgradePlan");
}
}
const handleSCIMToggle = (value: boolean) => {
// TODO
try {
setScimEnabled(value);
} catch (err) {
console.error(err);
}
}
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(scimToken ?? "");
setIsAPIKeyCopied.on();
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
@@ -64,7 +59,7 @@ export const OrgScimSection = () => {
// isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add SCIM Token
Manage SCIM Tokens
</Button>
</div>
<Switch
@@ -75,48 +70,16 @@ export const OrgScimSection = () => {
>
Enable SCIM Provisioning
</Switch>
{scimEnabled && (
<div>
<div className="mt-8 mb-8">
<h3 className="text-sm text-mineshaft-400">SCIM URL</h3>
<p className="text-md text-gray-400">{`${window.origin}/api/v1/scim`}</p>
</div>
{/* <h3 className="mb-2 mt-8 text-sm text-mineshaft-400">SCIM URL</h3> */}
{/* <div className="mb-8 max-w-xl flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{`${window.origin}/api/v1/scim`}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isAPIKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div> */}
{scimToken && (
<>
<h3 className="mb-2 text-sm text-mineshaft-400">SCIM Bearer Token</h3>
<div className="max-w-xl flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{scimToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isAPIKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
</>
)}
</div>
)}
<ScimTokenModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use SCIM Provisioning if you switch to Infisical's Pro plan."
/>
</div>
);
}
@@ -0,0 +1,353 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faCheck, faCopy, faKey, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import { format } from "date-fns";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import {
useGetScimTokens,
useCreateScimToken,
useDeleteScimToken
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { useOrganization } from "@app/context";
// TODO: turn TTL into a select component
const schema = yup.object({
description: yup.string(),
ttl: yup.string()
});
export type FormData = yup.InferType<typeof schema>;
type Props = {
popUp: UsePopUpState<["scimToken", "deleteScimToken"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteScimToken"]>,
data?: {
scimTokenId: string;
}
) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<
["scimToken", "deleteScimToken"]
>,
state?: boolean
) => void;
};
export const ScimTokenModal = ({
popUp,
handlePopUpOpen,
handlePopUpToggle
}: Props) => {
const { currentOrg } = useOrganization();
const { t } = useTranslation();
const { createNotification } = useNotificationContext();
const [token, setToken] = useState("");
const [isScimUrlCopied, setIsScimUrlCopied] = useToggle(false);
const [isScimTokenCopied, setIsScimTokenCopied] = useToggle(false);
const { data, isLoading } = useGetScimTokens(currentOrg?.id ?? "");
const { mutateAsync: createScimTokenMutateAsync } = useCreateScimToken();
const { mutateAsync: deleteScimTokenMutateAsync } = useDeleteScimToken();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema),
defaultValues: {
description: "",
ttl: ""
}
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isScimUrlCopied) {
timer = setTimeout(() => setIsScimUrlCopied.off(), 2000);
}
if (isScimTokenCopied) {
timer = setTimeout(() => setIsScimTokenCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isScimTokenCopied, isScimUrlCopied]);
const onFormSubmit = async ({ description, ttl }: FormData) => {
try {
if (!currentOrg?.id) return;
const { scimToken } = await createScimTokenMutateAsync({
organizationId: currentOrg.id,
description,
ttl: Number(ttl)
});
setToken(scimToken);
createNotification({
text: "Successfully created SCIM token",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create SCIM token",
type: "error"
});
}
};
const onDeleteScimTokenSubmit = async (scimTokenId: string) => {
try {
if (!currentOrg?.id) return;
await deleteScimTokenMutateAsync({
organizationId: currentOrg.id,
scimTokenId
});
// TODO: find alt way
// if (token.startsWith(clientSecretPrefix)) {
// reset();
// setToken("");
// }
handlePopUpToggle("deleteScimToken", false);
createNotification({
text: "Successfully deleted SCIM token",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete SCIM token",
type: "error"
});
}
};
const hasToken = Boolean(token);
const scimUrl = `${window.origin}/api/v1/scim`;
return (
<Modal
isOpen={popUp?.scimToken?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("scimToken", isOpen);
reset();
setToken("");
}}
>
<ModalContent title={`Manage SCIM credentials`}>
<h2 className="mb-4">SCIM URL</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{scimUrl}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(scimUrl);
setIsScimUrlCopied.on();
}}
>
<FontAwesomeIcon icon={isScimUrlCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
<h2 className="mb-4">New SCIM Token</h2>
{hasToken ? (
<div>
<div className="mb-4 flex items-center justify-between">
<p>We will only show this token once</p>
<Button
colorSchema="secondary"
type="submit"
onClick={() => {
reset();
setToken("");
}}
>
Got it
</Button>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{token}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(token);
setIsScimTokenCopied.on();
}}
>
<FontAwesomeIcon icon={isScimTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
</div>
) : (
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-8">
<Controller
control={control}
defaultValue=""
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description (optional)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Description" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL (seconds - optional)"
isError={Boolean(error)}
errorText={error?.message}
>
<div className="flex">
<Input {...field} placeholder="0" type="number" min="0" step="1" />
<Button
className="ml-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
</div>
</FormControl>
)}
/>
</form>
)}
<h2 className="mb-4">SCIM Tokens</h2>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Description</Th>
<Th>Expires At</Th>
<Th>Created At</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="org-scim-tokens" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map(
({
id,
description,
ttl,
createdAt
}) => {
let expiresAt;
if (ttl > 0) {
expiresAt = new Date(new Date(createdAt).getTime() + ttl * 1000);
}
return (
<Tr className="h-10 items-center" key={`mi-client-secret-${id}`}>
<Td>{description === "" ? "-" : description}</Td>
<Td>{expiresAt ? format(expiresAt, "yyyy-MM-dd") : "-"}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss")}</Td>
<Td>
<IconButton
onClick={() => {
handlePopUpOpen("deleteScimToken", {
scimTokenId: id
});
}}
size="lg"
colorSchema="primary"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
);
}
)}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState
title="No SCIM tokens have been created yet"
icon={faKey}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<DeleteActionModal
isOpen={popUp.deleteScimToken.isOpen}
title={"Are you sure want to delete the SCIM token?"}
onChange={(isOpen) => handlePopUpToggle("scimToken", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const deleteScimTokenData = popUp?.deleteScimToken?.data as {
scimTokenId: string;
};
return onDeleteScimTokenSubmit(deleteScimTokenData.scimTokenId);
}}
/>
</ModalContent>
</Modal>
);
};