feat: add SSO related UI (#1118)

* feat: add SSO related UI

* chore: update
This commit is contained in:
boojack 2023-02-18 22:57:45 +08:00 committed by GitHub
parent 65aa51d525
commit 708049bb89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 451 additions and 9 deletions

View File

@ -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: Props) => {
const { confirmCallback, destroy, identityProvider } = props;
const [basicInfo, setBasicInfo] = useState({
name: "",
identifierFilter: "",
});
const [type, setType] = useState<IdentityProviderType>("OAUTH2");
const [oauth2Config, setOAuth2Config] = useState<IdentityProviderOAuth2Config>({
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
userInfoUrl: "",
scopes: [],
fieldMapping: {
identifier: "",
displayName: "",
email: "",
},
});
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
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<IdentityProviderOAuth2Config>) => {
setOAuth2Config({
...oauth2Config,
...state,
});
};
return (
<>
<div className="dialog-header-container !w-96">
<p className="title-text">{isCreating ? "Create SSO" : "Update SSO"}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<Typography className="!mb-1" level="body2">
Type
</Typography>
<RadioGroup className="mb-2" value={type}>
<List>
<Radio value="OAUTH2" label="OAuth 2.0" />
</List>
</RadioGroup>
<Typography className="!mb-1" level="body2">
Name<span className="text-red-600">*</span>
</Typography>
<Input
className="mb-2"
placeholder="Name"
value={basicInfo.name}
onChange={(e) =>
setBasicInfo({
...basicInfo,
name: e.target.value,
})
}
fullWidth
/>
<Typography className="!mb-1" level="body2">
Identifier filter
</Typography>
<Input
className="mb-2"
placeholder="Identifier filter"
value={basicInfo.identifierFilter}
onChange={(e) =>
setBasicInfo({
...basicInfo,
identifierFilter: e.target.value,
})
}
fullWidth
/>
<Divider className="!my-2" />
{type === "OAUTH2" && (
<>
<Typography className="!mb-1" level="body2">
Client ID<span className="text-red-600">*</span>
</Typography>
<Input
className="mb-2"
placeholder="Client ID"
value={oauth2Config.clientId}
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
fullWidth
/>
<Typography className="!mb-1" level="body2">
Client secret<span className="text-red-600">*</span>
</Typography>
<Input
className="mb-2"
placeholder="Client secret"
value={oauth2Config.clientSecret}
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
fullWidth
/>
<Typography className="!mb-1" level="body2">
Authorization endpoint<span className="text-red-600">*</span>
</Typography>
<Input
className="mb-2"
placeholder="Authorization endpoint"
value={oauth2Config.authUrl}
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
fullWidth
/>
<Typography className="!mb-1" level="body2">
Token endpoint<span className="text-red-600">*</span>
</Typography>
<Input
className="mb-2"
placeholder="Token endpoint"
value={oauth2Config.tokenUrl}
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
fullWidth
/>
<Typography className="!mb-1" level="body2">
User info endpoint<span className="text-red-600">*</span>
</Typography>
<Input
className="mb-2"
placeholder="User info endpoint"
value={oauth2Config.userInfoUrl}
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
fullWidth
/>
<Typography className="!mb-1" level="body2">
Scopes<span className="text-red-600">*</span>
</Typography>
<Input className="mb-2" placeholder="Scopes" value={oauth2Scopes} onChange={(e) => setOAuth2Scopes(e.target.value)} fullWidth />
<Divider className="!my-2" />
<Typography className="!mb-1" level="body2">
Identifier<span className="text-red-600">*</span>
</Typography>
<Input
className="mb-2"
placeholder="User ID key"
value={oauth2Config.fieldMapping.identifier}
onChange={(e) => setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, identifier: e.target.value } })}
fullWidth
/>
<Typography className="!mb-1" level="body2">
Display name
</Typography>
<Input
className="mb-2"
placeholder="User name key"
value={oauth2Config.fieldMapping.displayName}
onChange={(e) => setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, displayName: e.target.value } })}
fullWidth
/>
<Typography className="!mb-1" level="body2">
Email
</Typography>
<Input
className="mb-2"
placeholder="User email key"
value={oauth2Config.fieldMapping.email}
onChange={(e) => setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, email: e.target.value } })}
fullWidth
/>
</>
)}
<div className="mt-2 w-full flex flex-row justify-end items-center space-x-1">
<Button variant="plain" color="neutral" onClick={handleCloseBtnClick}>
Cancel
</Button>
<Button onClick={handleConfirmBtnClick} disabled={!allowConfirmAction()}>
{isCreating ? "Create" : "Update"}
</Button>
</div>
</div>
</>
);
};
function showCreateIdentityProviderDialog(identityProvider?: IdentityProvider, confirmCallback?: () => void) {
generateDialog(
{
className: "create-identity-provider-dialog",
dialogName: "create-identity-provider-dialog",
},
CreateIdentityProviderDialog,
{ identityProvider, confirmCallback }
);
}
export default showCreateIdentityProviderDialog;

View File

@ -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: Props) => {
onClick={() => handleSectionSelectorItemClick("my-account")}
className={`section-item ${state.selectedSection === "my-account" ? "selected" : ""}`}
>
<span className="icon-text">🤠</span> {t("setting.my-account")}
<Icon.User className="w-4 h-auto mr-2 opacity-80" /> {t("setting.my-account")}
</span>
<span
onClick={() => handleSectionSelectorItemClick("preferences")}
className={`section-item ${state.selectedSection === "preferences" ? "selected" : ""}`}
>
<span className="icon-text">🏟</span> {t("setting.preference")}
<Icon.Cog className="w-4 h-auto mr-2 opacity-80" /> {t("setting.preference")}
</span>
</div>
{user?.role === "HOST" ? (
@ -64,20 +65,28 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
onClick={() => handleSectionSelectorItemClick("member")}
className={`section-item ${state.selectedSection === "member" ? "selected" : ""}`}
>
<span className="icon-text">👤</span> {t("setting.member")}
<Icon.Users className="w-4 h-auto mr-2 opacity-80" /> {t("setting.member")}
</span>
<span
onClick={() => handleSectionSelectorItemClick("system")}
className={`section-item ${state.selectedSection === "system" ? "selected" : ""}`}
>
<span className="icon-text">🛠</span> {t("setting.system")}
<Icon.Settings2 className="w-4 h-auto mr-2 opacity-80" /> {t("setting.system")}
</span>
{globalStore.isDev() && (
<span
onClick={() => handleSectionSelectorItemClick("storage")}
className={`section-item ${state.selectedSection === "storage" ? "selected" : ""}`}
>
<span className="icon-text">💾</span> {t("setting.storage")} <BetaBadge />
<Icon.Database className="w-4 h-auto mr-2 opacity-80" /> {t("setting.storage")} <BetaBadge />
</span>
)}
{globalStore.isDev() && (
<span
onClick={() => handleSectionSelectorItemClick("sso")}
className={`section-item ${state.selectedSection === "sso" ? "selected" : ""}`}
>
<Icon.Key className="w-4 h-auto mr-2 opacity-80" /> SSO <BetaBadge />
</span>
)}
</div>
@ -89,12 +98,14 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
<MyAccountSection />
) : state.selectedSection === "preferences" ? (
<PreferencesSection />
) : state.selectedSection === "storage" ? (
<StorageSection />
) : state.selectedSection === "member" ? (
<MemberSection />
) : state.selectedSection === "system" ? (
<SystemSection />
) : state.selectedSection === "storage" ? (
<StorageSection />
) : state.selectedSection === "sso" ? (
<SSOSection />
) : null}
</div>
</div>

View File

@ -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<IdentityProvider[]>([]);
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 (
<div className="section-container">
<div className="mt-4 mb-2 w-full flex flex-row justify-start items-center">
<span className="font-mono text-sm text-gray-400 mr-2">SSO List</span>
<button
className="btn-normal px-2 py-0 leading-7"
onClick={() => showCreateIdentityProviderDialog(undefined, fetchIdentityProviderList)}
>
{t("common.create")}
</button>
</div>
<div className="mt-2 w-full flex flex-col">
{identityProviderList.map((identityProvider) => (
<div key={identityProvider.id} className="py-2 w-full border-t last:border-b flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<p className="ml-2">
{identityProvider.name}
<span className="text-sm ml-1 opacity-40">({identityProvider.type})</span>
</p>
</div>
<div className="flex flex-row items-center">
<Dropdown
actionsClassName="!w-28"
actions={
<>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => showCreateIdentityProviderDialog(identityProvider, fetchIdentityProviderList)}
>
Edit
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => handleDeleteIdentityProvider(identityProvider)}
>
{t("common.delete")}
</button>
</>
}
/>
</div>
</div>
))}
</div>
</div>
);
};
export default SSOSection;

View File

@ -222,6 +222,22 @@ export function deleteStorage(storageId: StorageId) {
return axios.delete(`/api/storage/${storageId}`);
}
export function getIdentityProviderList() {
return axios.get<ResponseObject<IdentityProvider[]>>(`/api/idp`);
}
export function createIdentityProvider(identityProviderCreate: IdentityProviderCreate) {
return axios.post<ResponseObject<IdentityProvider>>(`/api/idp`, identityProviderCreate);
}
export function patchIdentityProvider(identityProviderPatch: IdentityProviderPatch) {
return axios.patch<ResponseObject<IdentityProvider>>(`/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: {

46
web/src/types/modules/idp.d.ts vendored Normal file
View File

@ -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;
}