diff --git a/web/src/components/CreateIdentityProviderDialog.tsx b/web/src/components/CreateIdentityProviderDialog.tsx new file mode 100644 index 00000000..76bbced0 --- /dev/null +++ b/web/src/components/CreateIdentityProviderDialog.tsx @@ -0,0 +1,279 @@ +import { useEffect, useState } from "react"; +import { Button, Divider, Input, List, Radio, RadioGroup, Typography } from "@mui/joy"; +import * as api from "../helpers/api"; +import { generateDialog } from "./Dialog"; +import Icon from "./Icon"; +import toastHelper from "./Toast"; + +interface Props extends DialogProps { + identityProvider?: IdentityProvider; + confirmCallback?: () => void; +} + +const CreateIdentityProviderDialog: React.FC = (props: Props) => { + const { confirmCallback, destroy, identityProvider } = props; + const [basicInfo, setBasicInfo] = useState({ + name: "", + identifierFilter: "", + }); + const [type, setType] = useState("OAUTH2"); + const [oauth2Config, setOAuth2Config] = useState({ + clientId: "", + clientSecret: "", + authUrl: "", + tokenUrl: "", + userInfoUrl: "", + scopes: [], + fieldMapping: { + identifier: "", + displayName: "", + email: "", + }, + }); + const [oauth2Scopes, setOAuth2Scopes] = useState(""); + const isCreating = identityProvider === undefined; + + useEffect(() => { + if (identityProvider) { + setBasicInfo({ + name: identityProvider.name, + identifierFilter: identityProvider.identifierFilter, + }); + setType(identityProvider.type); + if (identityProvider.type === "OAUTH2") { + setOAuth2Config(identityProvider.config.oauth2Config); + setOAuth2Scopes(identityProvider.config.oauth2Config.scopes.join(" ")); + } + } + }, []); + + const handleCloseBtnClick = () => { + destroy(); + }; + + const allowConfirmAction = () => { + if (basicInfo.name === "") { + return false; + } + if (type === "OAUTH2") { + if ( + oauth2Config.clientId === "" || + oauth2Config.clientSecret === "" || + oauth2Config.authUrl === "" || + oauth2Config.tokenUrl === "" || + oauth2Config.userInfoUrl === "" || + oauth2Scopes === "" || + oauth2Config.fieldMapping.identifier === "" + ) { + return false; + } + } + return true; + }; + + const handleConfirmBtnClick = async () => { + try { + if (isCreating) { + await api.createIdentityProvider({ + ...basicInfo, + type: type, + config: { + oauth2Config: { + ...oauth2Config, + scopes: oauth2Scopes.split(" "), + }, + }, + }); + } else { + await api.patchIdentityProvider({ + id: identityProvider?.id, + type: type, + ...basicInfo, + config: { + oauth2Config: { + ...oauth2Config, + scopes: oauth2Scopes.split(" "), + }, + }, + }); + } + } catch (error: any) { + console.error(error); + toastHelper.error(error.response.data.message); + } + destroy(); + + if (confirmCallback) { + confirmCallback(); + } + }; + + const setPartialOAuth2Config = (state: Partial) => { + setOAuth2Config({ + ...oauth2Config, + ...state, + }); + }; + + return ( + <> +
+

{isCreating ? "Create SSO" : "Update SSO"}

+ +
+
+ + Type + + + + + + + + Name* + + + setBasicInfo({ + ...basicInfo, + name: e.target.value, + }) + } + fullWidth + /> + + Identifier filter + + + setBasicInfo({ + ...basicInfo, + identifierFilter: e.target.value, + }) + } + fullWidth + /> + + {type === "OAUTH2" && ( + <> + + Client ID* + + setPartialOAuth2Config({ clientId: e.target.value })} + fullWidth + /> + + Client secret* + + setPartialOAuth2Config({ clientSecret: e.target.value })} + fullWidth + /> + + Authorization endpoint* + + setPartialOAuth2Config({ authUrl: e.target.value })} + fullWidth + /> + + Token endpoint* + + setPartialOAuth2Config({ tokenUrl: e.target.value })} + fullWidth + /> + + User info endpoint* + + setPartialOAuth2Config({ userInfoUrl: e.target.value })} + fullWidth + /> + + Scopes* + + setOAuth2Scopes(e.target.value)} fullWidth /> + + + Identifier* + + setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, identifier: e.target.value } })} + fullWidth + /> + + Display name + + setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, displayName: e.target.value } })} + fullWidth + /> + + Email + + setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, email: e.target.value } })} + fullWidth + /> + + )} +
+ + +
+
+ + ); +}; + +function showCreateIdentityProviderDialog(identityProvider?: IdentityProvider, confirmCallback?: () => void) { + generateDialog( + { + className: "create-identity-provider-dialog", + dialogName: "create-identity-provider-dialog", + }, + CreateIdentityProviderDialog, + { identityProvider, confirmCallback } + ); +} + +export default showCreateIdentityProviderDialog; diff --git a/web/src/components/SettingDialog.tsx b/web/src/components/SettingDialog.tsx index 98d53ed9..bb8dd489 100644 --- a/web/src/components/SettingDialog.tsx +++ b/web/src/components/SettingDialog.tsx @@ -3,17 +3,18 @@ import { useTranslation } from "react-i18next"; import { useGlobalStore, useUserStore } from "../store/module"; import Icon from "./Icon"; import { generateDialog } from "./Dialog"; +import BetaBadge from "./BetaBadge"; import MyAccountSection from "./Settings/MyAccountSection"; import PreferencesSection from "./Settings/PreferencesSection"; import MemberSection from "./Settings/MemberSection"; import SystemSection from "./Settings/SystemSection"; import StorageSection from "./Settings/StorageSection"; -import BetaBadge from "./BetaBadge"; +import SSOSection from "./Settings/SSOSection"; import "../less/setting-dialog.less"; type Props = DialogProps; -type SettingSection = "my-account" | "preferences" | "storage" | "member" | "system"; +type SettingSection = "my-account" | "preferences" | "member" | "system" | "storage" | "sso"; interface State { selectedSection: SettingSection; @@ -47,13 +48,13 @@ const SettingDialog: React.FC = (props: Props) => { onClick={() => handleSectionSelectorItemClick("my-account")} className={`section-item ${state.selectedSection === "my-account" ? "selected" : ""}`} > - 🤠 {t("setting.my-account")} + {t("setting.my-account")} handleSectionSelectorItemClick("preferences")} className={`section-item ${state.selectedSection === "preferences" ? "selected" : ""}`} > - 🏟 {t("setting.preference")} + {t("setting.preference")} {user?.role === "HOST" ? ( @@ -64,20 +65,28 @@ const SettingDialog: React.FC = (props: Props) => { onClick={() => handleSectionSelectorItemClick("member")} className={`section-item ${state.selectedSection === "member" ? "selected" : ""}`} > - 👤 {t("setting.member")} + {t("setting.member")} handleSectionSelectorItemClick("system")} className={`section-item ${state.selectedSection === "system" ? "selected" : ""}`} > - 🛠️ {t("setting.system")} + {t("setting.system")} {globalStore.isDev() && ( handleSectionSelectorItemClick("storage")} className={`section-item ${state.selectedSection === "storage" ? "selected" : ""}`} > - 💾 {t("setting.storage")} + {t("setting.storage")} + + )} + {globalStore.isDev() && ( + handleSectionSelectorItemClick("sso")} + className={`section-item ${state.selectedSection === "sso" ? "selected" : ""}`} + > + SSO )} @@ -89,12 +98,14 @@ const SettingDialog: React.FC = (props: Props) => { ) : state.selectedSection === "preferences" ? ( - ) : state.selectedSection === "storage" ? ( - ) : state.selectedSection === "member" ? ( ) : state.selectedSection === "system" ? ( + ) : state.selectedSection === "storage" ? ( + + ) : state.selectedSection === "sso" ? ( + ) : null} diff --git a/web/src/components/Settings/SSOSection.tsx b/web/src/components/Settings/SSOSection.tsx new file mode 100644 index 00000000..8006cd15 --- /dev/null +++ b/web/src/components/Settings/SSOSection.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as api from "../../helpers/api"; +import showCreateIdentityProviderDialog from "../CreateIdentityProviderDialog"; +import Dropdown from "../common/Dropdown"; +import { showCommonDialog } from "../Dialog/CommonDialog"; +import toastHelper from "../Toast"; + +const SSOSection = () => { + const { t } = useTranslation(); + const [identityProviderList, setIdentityProviderList] = useState([]); + + useEffect(() => { + fetchIdentityProviderList(); + }, []); + + const fetchIdentityProviderList = async () => { + const { + data: { data: identityProviderList }, + } = await api.getIdentityProviderList(); + setIdentityProviderList(identityProviderList); + }; + + const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => { + showCommonDialog({ + title: "Confirm delete", + content: "Are you sure to delete this SSO? THIS ACTION IS IRREVERSIABLE❗", + style: "warning", + dialogName: "delete-identity-provider-dialog", + onConfirm: async () => { + try { + await api.deleteIdentityProvider(identityProvider.id); + } catch (error: any) { + console.error(error); + toastHelper.error(error.response.data.message); + } + await fetchIdentityProviderList(); + }, + }); + }; + + return ( +
+
+ SSO List + +
+
+ {identityProviderList.map((identityProvider) => ( +
+
+

+ {identityProvider.name} + ({identityProvider.type}) +

+
+
+ + + + + } + /> +
+
+ ))} +
+
+ ); +}; + +export default SSOSection; diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index 90852c6f..5aa675a4 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -222,6 +222,22 @@ export function deleteStorage(storageId: StorageId) { return axios.delete(`/api/storage/${storageId}`); } +export function getIdentityProviderList() { + return axios.get>(`/api/idp`); +} + +export function createIdentityProvider(identityProviderCreate: IdentityProviderCreate) { + return axios.post>(`/api/idp`, identityProviderCreate); +} + +export function patchIdentityProvider(identityProviderPatch: IdentityProviderPatch) { + return axios.patch>(`/api/idp/${identityProviderPatch.id}`, identityProviderPatch); +} + +export function deleteIdentityProvider(id: IdentityProviderId) { + return axios.delete(`/api/idp/${id}`); +} + export async function getRepoStarCount() { const { data } = await axios.get(`https://api.github.com/repos/usememos/memos`, { headers: { diff --git a/web/src/types/modules/idp.d.ts b/web/src/types/modules/idp.d.ts new file mode 100644 index 00000000..dd49a32a --- /dev/null +++ b/web/src/types/modules/idp.d.ts @@ -0,0 +1,46 @@ +type IdentityProviderId = number; + +type IdentityProviderType = "OAUTH2"; + +interface FieldMapping { + identifier: string; + displayName: string; + email: string; +} + +interface IdentityProviderOAuth2Config { + clientId: string; + clientSecret: string; + authUrl: string; + tokenUrl: string; + userInfoUrl: string; + scopes: string[]; + fieldMapping: FieldMapping; +} + +interface IdentityProviderConfig { + oauth2Config: IdentityProviderOAuth2Config; +} + +interface IdentityProvider { + id: IdentityProviderId; + name: string; + type: IdentityProviderType; + identifierFilter: string; + config: IdentityProviderConfig; +} + +interface IdentityProviderCreate { + name: string; + type: IdentityProviderType; + identifierFilter: string; + config: IdentityProviderConfig; +} + +interface IdentityProviderPatch { + id: IdentityProviderId; + type: IdentityProviderType; + name?: string; + identifierFilter?: string; + config?: IdentityProviderConfig; +}