feat: implement identity provider settings

This commit is contained in:
Steven
2024-08-11 23:30:58 +08:00
parent 61dd989df4
commit 768af5b096
16 changed files with 791 additions and 181 deletions

View File

@ -0,0 +1,302 @@
import { Button, DialogActions, DialogContent, DialogTitle, Divider, Drawer, Input, ModalClose } from "@mui/joy";
import { isUndefined } from "lodash-es";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { workspaceServiceClient } from "@/grpcweb";
import { absolutifyLink } from "@/helpers/utils";
import useLoading from "@/hooks/useLoading";
import { useWorkspaceStore } from "@/stores";
import { IdentityProvider, IdentityProvider_Type, IdentityProviderConfig_OAuth2Config } from "@/types/proto/api/v1/workspace_service";
interface Props {
identityProvider?: IdentityProvider;
onClose: () => void;
onConfirm?: () => void;
}
interface State {
identityProviderCreate: IdentityProvider;
}
const CreateIdentityProviderDrawer: React.FC<Props> = (props: Props) => {
const { onClose, onConfirm, identityProvider } = props;
const { t } = useTranslation();
const workspaceStore = useWorkspaceStore();
const [state, setState] = useState<State>({
identityProviderCreate: IdentityProvider.fromPartial(
identityProvider || {
type: IdentityProvider_Type.OAUTH2,
config: {
oauth2: IdentityProviderConfig_OAuth2Config.fromPartial({
scopes: [],
fieldMapping: {},
}),
},
},
),
});
const isCreating = isUndefined(identityProvider);
const requestState = useLoading(false);
const setPartialState = (partialState: Partial<State>) => {
setState({
...state,
...partialState,
});
};
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
identityProviderCreate: Object.assign(state.identityProviderCreate, {
name: e.target.value,
}),
});
};
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
identityProviderCreate: Object.assign(state.identityProviderCreate, {
title: e.target.value,
}),
});
};
const handleOAuth2ConfigChange = (e: React.ChangeEvent<HTMLInputElement>, field: string) => {
if (!state.identityProviderCreate.config || !state.identityProviderCreate.config.oauth2) {
return;
}
const value = field === "scopes" ? e.target.value.split(" ") : e.target.value;
setPartialState({
identityProviderCreate: Object.assign(state.identityProviderCreate, {
config: Object.assign(state.identityProviderCreate.config, {
oauth2: Object.assign(state.identityProviderCreate.config.oauth2, {
[field]: value,
}),
}),
}),
});
};
const handleFieldMappingChange = (e: React.ChangeEvent<HTMLInputElement>, field: string) => {
if (
!state.identityProviderCreate.config ||
!state.identityProviderCreate.config.oauth2 ||
!state.identityProviderCreate.config.oauth2.fieldMapping
) {
return;
}
setPartialState({
identityProviderCreate: Object.assign(state.identityProviderCreate, {
config: Object.assign(state.identityProviderCreate.config, {
oauth2: Object.assign(state.identityProviderCreate.config.oauth2, {
fieldMapping: Object.assign(state.identityProviderCreate.config.oauth2.fieldMapping, {
[field]: e.target.value,
}),
}),
}),
}),
});
};
const onSave = async () => {
if (!state.identityProviderCreate.name || !state.identityProviderCreate.title) {
toast.error("Please fill in required fields.");
return;
}
try {
if (!isCreating) {
await workspaceServiceClient.updateWorkspaceSetting({
setting: {
identityProviders: workspaceStore.setting.identityProviders.map((idp) =>
idp.name === state.identityProviderCreate.name ? state.identityProviderCreate : idp,
),
},
updateMask: ["identity_providers"],
});
} else {
await workspaceServiceClient.updateWorkspaceSetting({
setting: {
identityProviders: [...workspaceStore.setting.identityProviders, state.identityProviderCreate],
},
updateMask: ["identity_providers"],
});
}
if (onConfirm) {
onConfirm();
} else {
onClose();
}
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
};
return (
<Drawer anchor="right" open={true} onClose={onClose}>
<DialogTitle>{isCreating ? "Create Identity Provider" : "Edit Identity Provider"}</DialogTitle>
<ModalClose />
<DialogContent className="w-full max-w-full">
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Name <span className="text-red-600">*</span>
</span>
<Input
className="w-full"
type="text"
placeholder="The unique name of your identity provider"
value={state.identityProviderCreate.name}
onChange={handleNameInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Title <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="A short title will be displayed in the UI"
value={state.identityProviderCreate.title}
onChange={handleTitleInputChange}
/>
</div>
</div>
{isCreating && (
<p className="border rounded-md p-2 text-sm w-full mb-2 break-all">Redirect URL: {absolutifyLink("/auth/callback")}</p>
)}
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Client ID <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="Client ID of the OAuth2 provider"
value={state.identityProviderCreate.config?.oauth2?.clientId}
onChange={(e) => handleOAuth2ConfigChange(e, "clientId")}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Client Secret <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="Client Secret of the OAuth2 provider"
value={state.identityProviderCreate.config?.oauth2?.clientSecret}
onChange={(e) => handleOAuth2ConfigChange(e, "clientSecret")}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Authorization endpoint <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="Authorization endpoint of the OAuth2 provider"
value={state.identityProviderCreate.config?.oauth2?.authUrl}
onChange={(e) => handleOAuth2ConfigChange(e, "authUrl")}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Token endpoint <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="Token endpoint of the OAuth2 provider"
value={state.identityProviderCreate.config?.oauth2?.tokenUrl}
onChange={(e) => handleOAuth2ConfigChange(e, "tokenUrl")}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
User endpoint <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="User endpoint of the OAuth2 provider"
value={state.identityProviderCreate.config?.oauth2?.userInfoUrl}
onChange={(e) => handleOAuth2ConfigChange(e, "userInfoUrl")}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Scopes <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="Scopes of the OAuth2 provider, separated by space"
value={state.identityProviderCreate.config?.oauth2?.scopes.join(" ")}
onChange={(e) => handleOAuth2ConfigChange(e, "scopes")}
/>
</div>
</div>
<Divider className="!mb-3" />
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Identifier <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="The field in the user info response to identify the user"
value={state.identityProviderCreate.config?.oauth2?.fieldMapping?.identifier}
onChange={(e) => handleFieldMappingChange(e, "identifier")}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start">
<span className="mb-2">Display name</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="The field in the user info response to display the user"
value={state.identityProviderCreate.config?.oauth2?.fieldMapping?.displayName}
onChange={(e) => handleFieldMappingChange(e, "displayName")}
/>
</div>
</div>
</div>
</DialogContent>
<DialogActions>
<div className="w-full flex flex-row justify-end items-center px-3 py-4 space-x-2">
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
{t("common.cancel")}
</Button>
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onSave}>
{t("common.save")}
</Button>
</div>
</DialogActions>
</Drawer>
);
};
export default CreateIdentityProviderDrawer;

View File

@ -0,0 +1,141 @@
import { Button, IconButton } from "@mui/joy";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { workspaceServiceClient } from "@/grpcweb";
import { useWorkspaceStore } from "@/stores";
import { IdentityProvider } from "@/types/proto/api/v1/workspace_service";
import CreateIdentityProviderDrawer from "../CreateIdentityProviderDrawer";
import Icon from "../Icon";
interface EditState {
open: boolean;
// Leave empty to create new identity provider.
identityProvider: IdentityProvider | undefined;
}
const SSOSection = () => {
const { t } = useTranslation();
const workspaceStore = useWorkspaceStore();
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
const [editState, setEditState] = useState<EditState>({ open: false, identityProvider: undefined });
useEffect(() => {
fetchIdentityProviderList();
}, []);
const fetchIdentityProviderList = async () => {
const setting = workspaceStore.fetchWorkspaceSetting();
setIdentityProviderList((await setting).identityProviders);
};
const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => {
const confirmed = window.confirm(`Are you sure you want to delete ${identityProvider.title}?`);
if (confirmed) {
try {
await workspaceServiceClient.updateWorkspaceSetting({
setting: {
identityProviders: identityProviderList.filter((idp) => idp.name !== identityProvider.name),
},
updateMask: ["identity_providers"],
});
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
await fetchIdentityProviderList();
}
};
return (
<>
<div className="w-full flex flex-col gap-2 pt-2 pb-4">
<div className="w-full flex flex-row justify-between items-center gap-1">
<div className="flex flex-row items-center gap-1">
<span className="font-medium dark:text-gray-400">SSO</span>
</div>
<Button
variant="outlined"
color="neutral"
onClick={() =>
setEditState({
open: true,
identityProvider: undefined,
})
}
>
{t("common.create")}
</Button>
</div>
{identityProviderList.length > 0 && (
<div className="mt-2 flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full py-2 align-middle">
<table className="min-w-full divide-y divide-gray-300 dark:divide-zinc-700">
<thead>
<tr>
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-500">
ID
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-500">
Title
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4">
<span className="sr-only">{t("common.edit")}</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-zinc-800">
{identityProviderList.map((identityProvider) => (
<tr key={identityProvider.name}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900 dark:text-gray-500">
{identityProvider.name}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{identityProvider.title}</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm">
<IconButton
size="sm"
variant="plain"
onClick={() =>
setEditState({
open: true,
identityProvider: identityProvider,
})
}
>
<Icon.PenBox className="w-4 h-auto" />
</IconButton>
<IconButton
size="sm"
color="danger"
variant="plain"
onClick={() => handleDeleteIdentityProvider(identityProvider)}
>
<Icon.Trash className="w-4 h-auto" />
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
{editState.open && (
<CreateIdentityProviderDrawer
identityProvider={editState.identityProvider}
onClose={() => setEditState({ open: false, identityProvider: undefined })}
onConfirm={() => {
setEditState({ open: false, identityProvider: undefined });
fetchIdentityProviderList();
}}
/>
)}
</>
);
};
export default SSOSection;

View File

@ -1,4 +1,4 @@
import { Button, Option, Select, Textarea } from "@mui/joy";
import { Button, Divider, Option, Select, Textarea } from "@mui/joy";
import { head, isEqual } from "lodash-es";
import { useRef, useState } from "react";
import toast from "react-hot-toast";
@ -10,6 +10,7 @@ import { PlanType } from "@/types/proto/api/v1/subscription_service";
import { WorkspaceSetting } from "@/types/proto/api/v1/workspace_service";
import FeatureBadge from "../FeatureBadge";
import Icon from "../Icon";
import SSOSection from "./SSOSection";
const getDefaultVisibility = (visibility?: Visibility) => {
if (!visibility || [Visibility.VISIBILITY_UNSPECIFIED, Visibility.UNRECOGNIZED].includes(visibility)) {
@ -158,6 +159,10 @@ const WorkspaceSection = () => {
{t("common.save")}
</Button>
</div>
<Divider />
<SSOSection />
</div>
</div>
);

View File

@ -0,0 +1,81 @@
import { last } from "lodash-es";
import { ClientError } from "nice-grpc-web";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import Icon from "@/components/Icon";
import { authServiceClient } from "@/grpcweb";
import { absolutifyLink } from "@/helpers/utils";
import useNavigateTo from "@/hooks/useNavigateTo";
import { useUserStore } from "@/stores";
interface State {
loading: boolean;
errorMessage: string;
}
const AuthCallback = () => {
const navigateTo = useNavigateTo();
const [searchParams] = useSearchParams();
const userStore = useUserStore();
const [state, setState] = useState<State>({
loading: true,
errorMessage: "",
});
useEffect(() => {
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code || !state) {
setState({
loading: false,
errorMessage: "Failed to authorize. Invalid state passed to the auth callback.",
});
return;
}
const idpName = last(state.split("-"));
if (!idpName) {
setState({
loading: false,
errorMessage: "No identity provider name found in the state parameter.",
});
return;
}
const redirectUri = absolutifyLink("/auth/callback");
(async () => {
try {
await authServiceClient.signInWithSSO({
idpName,
code,
redirectUri,
});
setState({
loading: false,
errorMessage: "",
});
await userStore.fetchCurrentUser();
navigateTo("/");
} catch (error: any) {
console.error(error);
setState({
loading: false,
errorMessage: (error as ClientError).details,
});
}
})();
}, [searchParams]);
return (
<div className="p-4 py-24 w-full h-full flex justify-center items-center">
{state.loading ? (
<Icon.Loader className="animate-spin dark:text-gray-200" />
) : (
<div className="max-w-lg font-mono whitespace-pre-wrap opacity-80">{state.errorMessage}</div>
)}
</div>
);
};
export default AuthCallback;

View File

@ -1,13 +1,15 @@
import { Button, Input } from "@mui/joy";
import { Button, Divider, Input } from "@mui/joy";
import React, { FormEvent, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import Logo from "@/components/Logo";
import { authServiceClient } from "@/grpcweb";
import { absolutifyLink } from "@/helpers/utils";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { useUserStore, useWorkspaceStore } from "@/stores";
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/workspace_service";
const SignIn: React.FC = () => {
const { t } = useTranslation();
@ -59,6 +61,24 @@ const SignIn: React.FC = () => {
actionBtnLoadingState.setFinish();
};
const handleSignInWithIdentityProvider = async (identityProvider: IdentityProvider) => {
const stateQueryParameter = `auth.signin.${identityProvider.title}-${identityProvider.name}`;
if (identityProvider.type === IdentityProvider_Type.OAUTH2) {
const redirectUri = absolutifyLink("/auth/callback");
const oauth2Config = identityProvider.config?.oauth2;
if (!oauth2Config) {
toast.error("Identity provider configuration is invalid.");
return;
}
const authUrl = `${oauth2Config.authUrl}?client_id=${
oauth2Config.clientId
}&redirect_uri=${redirectUri}&state=${stateQueryParameter}&response_type=code&scope=${encodeURIComponent(
oauth2Config.scopes.join(" "),
)}`;
window.location.href = authUrl;
}
};
return (
<div className="flex flex-row justify-center items-center w-full h-auto pt-12 sm:pt-24 bg-white dark:bg-zinc-900">
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
@ -105,6 +125,25 @@ const SignIn: React.FC = () => {
</Link>
</p>
)}
{workspaceStore.setting.identityProviders.length > 0 && (
<>
<Divider className="!my-4">{t("common.or")}</Divider>
<div className="w-full flex flex-col space-y-2">
{workspaceStore.setting.identityProviders.map((identityProvider) => (
<Button
key={identityProvider.name}
variant="outlined"
color="neutral"
className="w-full"
size="md"
onClick={() => handleSignInWithIdentityProvider(identityProvider)}
>
{t("auth.sign-in-with", { provider: identityProvider.title })}
</Button>
))}
</div>
</>
)}
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { createBrowserRouter } from "react-router-dom";
import App from "@/App";
import Root from "@/layouts/Root";
import AuthCallback from "@/pages/AuthCallback";
import CollectionDashboard from "@/pages/CollectionDashboard";
import CollectionSpace from "@/pages/CollectionSpace";
import Home from "@/pages/Home";
@ -21,11 +22,20 @@ const router = createBrowserRouter([
children: [
{
path: "/auth",
element: <SignIn />,
},
{
path: "/auth/signup",
element: <SignUp />,
children: [
{
path: "",
element: <SignIn />,
},
{
path: "signup",
element: <SignUp />,
},
{
path: "callback",
element: <AuthCallback />,
},
],
},
{
path: "",