Merge pull request #587 from ONLYOFFICE/feature/oauth2-rotation

Feature/oauth2 rotation
This commit is contained in:
Alexey Safronov 2024-08-15 17:55:07 +04:00 committed by GitHub
commit a06cb5159f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 498 additions and 19 deletions

View File

@ -112,7 +112,7 @@ const Layout = ({
<DetailsNavigationHeader />
) : currentPath === oauthCreatePath ||
currentPath === oauthEditPath ? (
<OAuthSectionHeader />
<OAuthSectionHeader isEdit={currentPath === oauthEditPath} />
) : (
<SectionHeaderContent />
)}

View File

@ -18,6 +18,9 @@ export interface OAuthProps {
previewDialogVisible?: boolean;
disableDialogVisible?: boolean;
deleteDialogVisible?: boolean;
generateDeveloperTokenDialogVisible?: boolean;
revokeDeveloperTokenDialogVisible?: boolean;
isInit: boolean;
setIsInit: (value: boolean) => void;
}

View File

@ -28,17 +28,15 @@ const OAuthSectionHeader = ({ isEdit }: { isEdit: boolean }) => {
<HeaderContainer>
<Headline type="content" truncate>
<div className="settings-section_header">
<div className="header">
<IconButton
iconName={ArrowPathReactSvgUrl}
size={17}
isFill
onClick={onBack}
className="arrow-button"
/>
<IconButton
iconName={ArrowPathReactSvgUrl}
size={17}
isFill
onClick={onBack}
className="arrow-button"
/>
{isEdit ? t("EditApp") : t("NewApp")}
</div>
{isEdit ? t("EditApp") : t("NewApp")}
</div>
</Headline>
</HeaderContainer>

View File

@ -18,6 +18,8 @@ import DisableDialog from "./sub-components/DisableDialog";
import DeleteDialog from "./sub-components/DeleteDialog";
import OAuthEmptyScreen from "./sub-components/EmptyScreen";
import List from "./sub-components/List";
import GenerateDeveloperTokenDialog from "./sub-components/GenerateDeveloperTokenDialog";
import RevokeDeveloperTokenDialog from "./sub-components/RevokeDeveloperTokenDialog";
const MIN_LOADER_TIME = 500;
@ -35,6 +37,8 @@ const OAuth = ({
setIsInit,
disableDialogVisible,
deleteDialogVisible,
generateDeveloperTokenDialogVisible,
revokeDeveloperTokenDialogVisible,
}: OAuthProps) => {
const { t } = useTranslation(["OAuth"]);
@ -102,6 +106,8 @@ const OAuth = ({
{disableDialogVisible && <DisableDialog />}
{previewDialogVisible && <PreviewDialog visible={previewDialogVisible} />}
{deleteDialogVisible && <DeleteDialog />}
{generateDeveloperTokenDialogVisible && <GenerateDeveloperTokenDialog />}
{revokeDeveloperTokenDialogVisible && <RevokeDeveloperTokenDialog />}
</OAuthContainer>
);
};
@ -128,6 +134,8 @@ export default inject(
setIsInit,
disableDialogVisible,
deleteDialogVisible,
generateDeveloperTokenDialogVisible,
revokeDeveloperTokenDialogVisible,
} = oauthStore;
return {
viewAs,
@ -143,6 +151,8 @@ export default inject(
setIsInit,
disableDialogVisible,
deleteDialogVisible,
generateDeveloperTokenDialogVisible,
revokeDeveloperTokenDialogVisible,
};
},
)(observer(OAuth));

View File

@ -2,6 +2,7 @@ import React from "react";
import { Text } from "@docspace/shared/components/text";
import { SelectorAddButton } from "@docspace/shared/components/selector-add-button";
import { globalColors } from "@docspace/shared/themes";
import { StyledInputGroup } from "../ClientForm.styled";
@ -55,7 +56,8 @@ const SelectGroup = ({
color=""
textAlign=""
>
{label} *
{label}{" "}
<span style={{ color: globalColors.lightErrorStatus }}> *</span>
</Text>
</div>
<div className="select">

View File

@ -0,0 +1,211 @@
import React from "react";
import { inject, observer } from "mobx-react";
import styled, { useTheme } from "styled-components";
import { i18n } from "i18next";
import { useTranslation } from "react-i18next";
import copy from "copy-to-clipboard";
import moment from "moment-timezone";
import api from "@docspace/shared/api";
import { IClientProps } from "@docspace/shared/utils/oauth/types";
import {
ModalDialog,
ModalDialogType,
} from "@docspace/shared/components/modal-dialog";
import { Button, ButtonSize } from "@docspace/shared/components/button";
import { Text } from "@docspace/shared/components/text";
import { toastr } from "@docspace/shared/components/toast";
import { TData } from "@docspace/shared/components/toast/Toast.type";
import { InputBlock } from "@docspace/shared/components/input-block";
import { InputSize, InputType } from "@docspace/shared/components/text-input";
import CopyReactSvgUrl from "PUBLIC_DIR/images/copy.react.svg?url";
import { OAuthStoreProps } from "SRC_DIR/store/OAuthStore";
import { UserStore } from "@docspace/shared/store/UserStore";
import { globalColors } from "@docspace/shared/themes";
const StyledContainer = styled.div`
p {
margin-bottom: 16px;
}
.dates {
margin-top: 16px;
margin-bottom: 0;
}
`;
type GenerateDeveloperTokenDialogProps = {
client?: IClientProps;
email?: string;
setGenerateDeveloperTokenDialogVisible?: (value: boolean) => void;
};
const getDate = (date: Date, i18nArg: i18n) => {
return moment(date)
.locale(i18nArg.language)
.tz(window.timezone)
.format("MMM D, YYYY, h:mm:ss A");
};
const GenerateDeveloperTokenDialog = ({
client,
email,
setGenerateDeveloperTokenDialogVisible,
}: GenerateDeveloperTokenDialogProps) => {
const { i18n: i18nParam } = useTranslation(["OAuth", "Common"]);
const theme = useTheme();
const [token, setToken] = React.useState("");
const [dates, setDates] = React.useState({
created: getDate(new Date(), i18nParam),
expires: getDate(new Date(), i18nParam),
});
const [requestRunning, setRequestRunning] = React.useState(false);
const onGenerate = async () => {
if (token || !client || requestRunning) return;
try {
const { clientId, clientSecret, scopes } = client;
setRequestRunning(true);
const data = await api.oauth.generateDevelopToken(
clientId,
clientSecret,
scopes,
);
setRequestRunning(false);
if (!data) return;
const { access_token: accessToken, expires_in: expiresIn } = data;
const created = new Date();
// convert sec to ms
const expires = new Date(created.getTime() + expiresIn * 1000);
if (accessToken) {
setToken(accessToken);
copy(accessToken);
setDates({
created: getDate(created, i18nParam),
expires: getDate(expires, i18nParam),
});
toastr.success("Copied");
}
} catch (e) {
toastr.error(e as TData);
}
};
const onCopyClick = async () => {
copy(token);
toastr.success("Copied");
};
const onClose = () => {
if (requestRunning) return;
setGenerateDeveloperTokenDialogVisible?.(false);
};
return (
<ModalDialog
visible
onClose={onClose}
displayType={ModalDialogType.modal}
autoMaxHeight
scale
>
<ModalDialog.Header>Generate developer token</ModalDialog.Header>
<ModalDialog.Body>
<StyledContainer>
<Text>
By generating an developer access token, you will be able to make
API calls for your own account without going through the
authorization flow. To obtain access tokens for other users, use the
standard OAuth flow.
</Text>
<Text>
For scoped apps, the token will have the same scope as the app.
</Text>
{token ? (
<>
<Text
color={
theme.isBase
? globalColors.lightErrorStatus
: globalColors.darkErrorStatus
}
>
This access token can be used to access your account ({email})
via the API. Don`t share your access token with anyone.
</Text>
<InputBlock
value={token}
scale
isReadOnly
isDisabled
size={InputSize.base}
iconName={CopyReactSvgUrl}
onIconClick={onCopyClick}
type={InputType.text}
/>
<Text className="dates">
Created: {dates.created}
<br />
Expires: {dates.expires}{" "}
</Text>
</>
) : null}
</StyledContainer>
</ModalDialog.Body>
<ModalDialog.Footer>
<Button
label="Generate developer token"
primary
scale
onClick={onGenerate}
isDisabled={!!token}
isLoading={requestRunning}
size={ButtonSize.small}
/>
<Button
label="Cancel"
scale
onClick={onClose}
size={ButtonSize.small}
isDisabled={requestRunning}
/>
</ModalDialog.Footer>
</ModalDialog>
);
};
export default inject(
({
oauthStore,
userStore,
}: {
oauthStore: OAuthStoreProps;
userStore: UserStore;
}) => {
const { setGenerateDeveloperTokenDialogVisible, bufferSelection } =
oauthStore;
const { user } = userStore;
return {
setGenerateDeveloperTokenDialogVisible,
client: bufferSelection,
email: user?.email,
};
},
)(observer(GenerateDeveloperTokenDialog));

View File

@ -0,0 +1,141 @@
import React from "react";
import { inject, observer } from "mobx-react";
import styled from "styled-components";
import api from "@docspace/shared/api";
import { IClientProps } from "@docspace/shared/utils/oauth/types";
import {
ModalDialog,
ModalDialogType,
} from "@docspace/shared/components/modal-dialog";
import { Button, ButtonSize } from "@docspace/shared/components/button";
import { Text } from "@docspace/shared/components/text";
import { toastr } from "@docspace/shared/components/toast";
import { TData } from "@docspace/shared/components/toast/Toast.type";
import { InputBlock } from "@docspace/shared/components/input-block";
import { InputSize, InputType } from "@docspace/shared/components/text-input";
import { OAuthStoreProps } from "SRC_DIR/store/OAuthStore";
import { UserStore } from "@docspace/shared/store/UserStore";
const StyledContainer = styled.div`
p {
margin-bottom: 16px;
}
`;
type GenerateDeveloperTokenDialogProps = {
client?: IClientProps;
setRevokeDeveloperTokenDialogVisible?: (value: boolean) => void;
};
const GenerateDeveloperTokenDialog = ({
client,
setRevokeDeveloperTokenDialogVisible,
}: GenerateDeveloperTokenDialogProps) => {
// const {} = useTranslation(["OAuth", "Common"]);
const [token, setToken] = React.useState("");
const [requestRunning, setRequestRunning] = React.useState(false);
const onRevoke = async () => {
if (!token || !client || requestRunning) return;
try {
const { clientId, clientSecret } = client;
setRequestRunning(true);
await api.oauth.revokeDeveloperToken(token, clientId, clientSecret);
setRequestRunning(false);
setToken("");
setRevokeDeveloperTokenDialogVisible?.(false);
toastr.success("Revoked");
} catch (e) {
toastr.error(e as TData);
}
};
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setToken(value);
};
const onClose = () => {
if (requestRunning) return;
setRevokeDeveloperTokenDialogVisible?.(false);
};
return (
<ModalDialog
visible
onClose={onClose}
displayType={ModalDialogType.modal}
autoMaxHeight
scale
>
<ModalDialog.Header>Revoke developer token</ModalDialog.Header>
<ModalDialog.Body>
<StyledContainer>
<Text>Warning text</Text>
<InputBlock
value={token}
scale
placeholder="Enter developer token"
type={InputType.text}
size={InputSize.base}
onChange={onChange}
/>
</StyledContainer>
</ModalDialog.Body>
<ModalDialog.Footer>
<Button
label="Revoke"
primary
scale
onClick={onRevoke}
isDisabled={!token}
isLoading={requestRunning}
size={ButtonSize.small}
/>
<Button
label="Cancel"
scale
onClick={onClose}
size={ButtonSize.small}
isDisabled={requestRunning}
/>
</ModalDialog.Footer>
</ModalDialog>
);
};
export default inject(
({
oauthStore,
userStore,
}: {
oauthStore: OAuthStoreProps;
userStore: UserStore;
}) => {
const { setRevokeDeveloperTokenDialogVisible, bufferSelection } =
oauthStore;
const { user } = userStore;
return {
setRevokeDeveloperTokenDialogVisible,
client: bufferSelection,
email: user?.email,
};
},
)(observer(GenerateDeveloperTokenDialog));

View File

@ -58,6 +58,12 @@ export interface OAuthStoreProps {
resetDialogVisible: boolean;
setResetDialogVisible: (value: boolean) => void;
generateDeveloperTokenDialogVisible: boolean;
setGenerateDeveloperTokenDialogVisible: (value: boolean) => void;
revokeDeveloperTokenDialogVisible: boolean;
setRevokeDeveloperTokenDialogVisible: (value: boolean) => void;
deleteDialogVisible: boolean;
setDeleteDialogVisible: (value: boolean) => void;
@ -157,6 +163,10 @@ class OAuthStore implements OAuthStoreProps {
resetDialogVisible: boolean = false;
generateDeveloperTokenDialogVisible: boolean = false;
revokeDeveloperTokenDialogVisible: boolean = false;
selection: string[] = [];
bufferSelection: IClientProps | null = null;
@ -216,6 +226,14 @@ class OAuthStore implements OAuthStoreProps {
this.resetDialogVisible = value;
};
setGenerateDeveloperTokenDialogVisible = (value: boolean) => {
this.generateDeveloperTokenDialogVisible = value;
};
setRevokeDeveloperTokenDialogVisible = (value: boolean) => {
this.revokeDeveloperTokenDialogVisible = value;
};
setClientSecret = (value: string) => {
this.clientSecret = value;
};
@ -526,6 +544,8 @@ class OAuthStore implements OAuthStoreProps {
this.setInfoDialogVisible(true);
this.setDisableDialogVisible(false);
this.setDeleteDialogVisible(false);
this.setGenerateDeveloperTokenDialogVisible(false);
this.setRevokeDeveloperTokenDialogVisible(false);
};
const onRevoke = () => {
@ -535,6 +555,8 @@ class OAuthStore implements OAuthStoreProps {
this.setRevokeDialogVisible(true);
this.setDisableDialogVisible(false);
this.setDeleteDialogVisible(false);
this.setGenerateDeveloperTokenDialogVisible(false);
this.setRevokeDeveloperTokenDialogVisible(false);
};
const onDisable = () => {
@ -544,6 +566,30 @@ class OAuthStore implements OAuthStoreProps {
this.setRevokeDialogVisible(false);
this.setDisableDialogVisible(true);
this.setDeleteDialogVisible(false);
this.setGenerateDeveloperTokenDialogVisible(false);
this.setRevokeDeveloperTokenDialogVisible(false);
};
const onGenerateDeveloperToken = () => {
this.setBufferSelection(clientId);
this.setPreviewDialogVisible(false);
this.setInfoDialogVisible(false);
this.setRevokeDialogVisible(false);
this.setDisableDialogVisible(false);
this.setDeleteDialogVisible(false);
this.setGenerateDeveloperTokenDialogVisible(true);
this.setRevokeDeveloperTokenDialogVisible(false);
};
const onRevokeDeveloperToken = () => {
this.setBufferSelection(clientId);
this.setPreviewDialogVisible(false);
this.setInfoDialogVisible(false);
this.setRevokeDialogVisible(false);
this.setDisableDialogVisible(false);
this.setDeleteDialogVisible(false);
this.setGenerateDeveloperTokenDialogVisible(false);
this.setRevokeDeveloperTokenDialogVisible(true);
};
const openOption = {
@ -598,6 +644,8 @@ class OAuthStore implements OAuthStoreProps {
this.setRevokeDialogVisible(false);
this.setDisableDialogVisible(false);
this.setDeleteDialogVisible(true);
this.setGenerateDeveloperTokenDialogVisible(false);
this.setRevokeDeveloperTokenDialogVisible(false);
};
const onShowPreview = () => {
@ -607,6 +655,8 @@ class OAuthStore implements OAuthStoreProps {
this.setRevokeDialogVisible(false);
this.setDisableDialogVisible(false);
this.setDeleteDialogVisible(false);
this.setGenerateDeveloperTokenDialogVisible(false);
this.setRevokeDeveloperTokenDialogVisible(false);
};
const onEnable = async (status: boolean) => {
@ -615,6 +665,8 @@ class OAuthStore implements OAuthStoreProps {
this.setRevokeDialogVisible(false);
this.setDisableDialogVisible(false);
this.setDeleteDialogVisible(false);
this.setGenerateDeveloperTokenDialogVisible(false);
this.setRevokeDeveloperTokenDialogVisible(false);
if (isGroupContext) {
try {
@ -673,6 +725,20 @@ class OAuthStore implements OAuthStoreProps {
onClick: onDisable,
};
const generateDeveloperTokenOption = {
key: "generate-token",
icon: EnableReactSvgUrl,
label: "Generate developer token",
onClick: onGenerateDeveloperToken,
};
const revokeDeveloperTokenOption = {
key: "revoke-token",
icon: EnableReactSvgUrl,
label: "Revoke developer token",
onClick: onRevokeDeveloperToken,
};
const contextOptions = [
{
key: "Separator dropdownItem",
@ -708,6 +774,9 @@ class OAuthStore implements OAuthStoreProps {
contextOptions.unshift(enableOption);
}
contextOptions.unshift(revokeDeveloperTokenOption);
contextOptions.unshift(generateDeveloperTokenOption);
if (!isInfo) contextOptions.unshift(infoOption);
contextOptions.unshift(authButtonOption);
contextOptions.unshift(editOption);

View File

@ -33,12 +33,12 @@ export const initSSR = (headers: Record<string, string>) => {
client.initSSR(headers);
};
export const request = (
export const request = <T>(
options: TReqOption & AxiosRequestConfig,
skipRedirect = false,
isOAuth = false,
) => {
return client.request(options, skipRedirect, isOAuth);
): Promise<T> | undefined => {
return client.request<T>(options, skipRedirect, isOAuth);
};
export const setWithCredentialsStatus = (state: boolean) => {

View File

@ -11,6 +11,7 @@ import {
IClientReqDTO,
TConsentData,
TConsentList,
TGenerateDeveloperToken,
} from "../../utils/oauth/types";
export const getClient = async (clientId: string): Promise<IClientProps> => {
@ -237,3 +238,38 @@ export const onOAuthCancel = (clientId: string, clientState: string) => {
true,
);
};
export const generateDevelopToken = (
client_id: string,
client_secret: string,
scopes: string[],
): Promise<TGenerateDeveloperToken> | undefined => {
const params = new URLSearchParams();
params.append("grant_type", "personal_access_token");
params.append("client_id", client_id);
params.append("client_secret", client_secret);
params.append("scope", scopes.join(" "));
return request<TGenerateDeveloperToken>(
{ method: "post", url: "/oauth2/token", data: params },
false,
true,
);
};
export const revokeDeveloperToken = (
token: string,
client_id: string,
client_secret: string,
) => {
const params = new URLSearchParams();
params.append("token", token);
params.append("client_id", client_id);
params.append("client_secret", client_secret);
return request(
{ method: "post", url: "/oauth2/revoke", data: params },
false,
true,
);
};

View File

@ -663,7 +663,7 @@ const Dark: TTheme = {
input: {
color: white,
disableColor: grayDarkStrong,
disableColor: grayDarkText,
backgroundColor: black,
disableBackgroundColor: grayDarkStrong,

View File

@ -182,11 +182,11 @@ class AxiosClient {
}
};
request = (
request = <T>(
options: TReqOption & AxiosRequestConfig,
skipRedirect = false,
isOAuth = false,
) => {
): Promise<T> | undefined => {
const onSuccess = (response: TRes) => {
const error = this.getResponseError(response);
@ -295,7 +295,9 @@ class AxiosClient {
return Promise.reject(error);
};
return this.client?.(options).then(onSuccess).catch(onError);
return this.client?.(options).then(onSuccess).catch(onError) as
| Promise<T>
| undefined;
};
}

View File

@ -150,3 +150,10 @@ export type IClientListProps = List<IClientProps>;
export type IClientListDTO = List<IClientResDTO>;
export type TConsentList = List<TConsentData>;
export type TGenerateDeveloperToken = {
access_token: string;
expires_in: number;
scope: string;
token_type: string;
};