mirror of
https://github.com/usememos/memos.git
synced 2024-12-20 01:31:29 +03:00
feat: add SSO related UI (#1118)
* feat: add SSO related UI * chore: update
This commit is contained in:
parent
65aa51d525
commit
708049bb89
279
web/src/components/CreateIdentityProviderDialog.tsx
Normal file
279
web/src/components/CreateIdentityProviderDialog.tsx
Normal 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;
|
@ -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>
|
||||
|
90
web/src/components/Settings/SSOSection.tsx
Normal file
90
web/src/components/Settings/SSOSection.tsx
Normal 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;
|
@ -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
46
web/src/types/modules/idp.d.ts
vendored
Normal 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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user