From ed4b027bcc49c865b0efee1a8e3c96ecdb96772d Mon Sep 17 00:00:00 2001 From: Timofey Boyko Date: Fri, 27 Oct 2023 11:23:41 +0300 Subject: [PATCH] Login: add support oauth2 --- packages/login/index.d.ts | 23 +- packages/login/src/client/App.tsx | 10 + .../login/src/client/components/Login.tsx | 145 ++++--- .../components/sub-components/Consent.tsx | 180 +++++++++ .../components/sub-components/LoginForm.tsx | 355 +++++++++--------- .../sub-components/oauth-client-info.tsx | 86 ++++- packages/login/src/server/i18n.ts | 2 +- packages/login/src/server/index.ts | 18 +- .../login/src/server/lib/helpers/index.ts | 38 +- 9 files changed, 577 insertions(+), 280 deletions(-) create mode 100644 packages/login/src/client/components/sub-components/Consent.tsx diff --git a/packages/login/index.d.ts b/packages/login/index.d.ts index 6e104207d8..1919628e16 100644 --- a/packages/login/index.d.ts +++ b/packages/login/index.d.ts @@ -1,3 +1,8 @@ +import { + IClientProps, + INoAuthClientProps, + IScope, +} from "@docspace/common/utils/oauth/interfaces"; import { Request } from "express"; type WindowI18nType = { @@ -28,7 +33,8 @@ declare global { messageKey?: string; authError?: string; type?: string; - clientId?: string; + client_id?: string; + state?: string; }; type PasswordHashType = { @@ -130,18 +136,13 @@ declare global { email: string; } - interface IOAuthClient { - name: string; - logo: string; - privacyURL: string; - termsURL: string; - scopes: string[]; - clientId: string; - } - interface IOAuthState { - client: IOAuthClient; + client: IClientProps | INoAuthClientProps; + clientId: string; + state: string; self?: ISelf; + scopes?: IScope[]; + isConsent: boolean; } interface IInitialState { diff --git a/packages/login/src/client/App.tsx b/packages/login/src/client/App.tsx index 4ce50689aa..d28bab49d1 100644 --- a/packages/login/src/client/App.tsx +++ b/packages/login/src/client/App.tsx @@ -7,6 +7,7 @@ import initLoginStore from "../store"; import { Provider as MobxProvider } from "mobx-react"; import SimpleNav from "../client/components/sub-components/SimpleNav"; import { wrongPortalNameUrl } from "@docspace/common/constants"; +import Consent from "./components/Consent"; interface ILoginProps extends IInitialState { isDesktopEditor?: boolean; @@ -35,6 +36,15 @@ const App: React.FC = (props) => { + + } + /> } /> } /> } /> diff --git a/packages/login/src/client/components/Login.tsx b/packages/login/src/client/components/Login.tsx index 464da05851..f0fc70d5f3 100644 --- a/packages/login/src/client/components/Login.tsx +++ b/packages/login/src/client/components/Login.tsx @@ -26,6 +26,8 @@ import { getBgPattern } from "@docspace/common/utils"; import useIsomorphicLayoutEffect from "../hooks/useIsomorphicLayoutEffect"; import { getLogoFromPath, getSystemTheme } from "@docspace/common/utils"; import { TenantStatus } from "@docspace/common/constants"; +import Consent from "./sub-components/Consent"; +import { IClientProps, IScope } from "@docspace/common/utils/oauth/interfaces"; const themes = { Dark: Dark, @@ -37,6 +39,7 @@ interface ILoginProps extends IInitialState { theme: IUserTheme; setTheme: (theme: IUserTheme) => void; isBaseTheme: boolean; + isConsent?: boolean; } const Login: React.FC = ({ @@ -51,6 +54,7 @@ const Login: React.FC = ({ logoUrls, isBaseTheme, oauth, + isConsent, }) => { const isOAuthPage = !!oauth?.client.name; @@ -60,9 +64,18 @@ const Login: React.FC = ({ useEffect(() => { isRestoringPortal && window.location.replace("/preparation-portal"); }, []); + const [isLoading, setIsLoading] = useState(false); const [moreAuthVisible, setMoreAuthVisible] = useState(false); const [recoverDialogVisible, setRecoverDialogVisible] = useState(false); + const [isConsentPage, setIsConsentPage] = useState( + isConsent || oauth?.isConsent + ); + const [scopes, setScopes] = useState(oauth?.scopes || ([] as IScope[])); + const [oauthClient, setOAuthClient] = useState( + oauth?.client || ({} as IClientProps) + ); + const [self, setSelf] = useState(oauth?.self || ({} as ISelf)); const { enabledJoin, @@ -243,67 +256,79 @@ const Login: React.FC = ({ > {greetingSettings} - - {ssoExists() && !isOAuthPage && ( - {ssoButton()} - )} - {oauthDataExists() && !isOAuthPage && ( - <> - {providerButtons()} - {providers && providers.length > 2 && ( - - {t("Common:ShowMore")} - - )} - - )} - {(oauthDataExists() || ssoExists()) && !isOAuthPage && ( -
- {t("Or")} -
- )} - -
- - - - - + ) : ( + <> + + {ssoExists() && !isOAuthPage && ( + {ssoButton()} + )} + {oauthDataExists() && !isOAuthPage && ( + <> + {providerButtons()} + {providers && providers.length > 2 && ( + + {t("Common:ShowMore")} + + )} + + )} + {(oauthDataExists() || ssoExists()) && !isOAuthPage && ( +
+ {t("Or")} +
+ )} + +
+ + + + + )} diff --git a/packages/login/src/client/components/sub-components/Consent.tsx b/packages/login/src/client/components/sub-components/Consent.tsx new file mode 100644 index 0000000000..3740fc239b --- /dev/null +++ b/packages/login/src/client/components/sub-components/Consent.tsx @@ -0,0 +1,180 @@ +import React from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import styled from "styled-components"; +import { useTranslation, Trans } from "react-i18next"; + +//@ts-ignore +import api from "@docspace/common/api"; + +import ScopeList from "@docspace/common/utils/oauth/ScopeList"; + +//@ts-ignore +import FormWrapper from "@docspace/components/form-wrapper"; +//@ts-ignore +import Button from "@docspace/components/button"; +//@ts-ignore +import Text from "@docspace/components/text"; +//@ts-ignore +import Link from "@docspace/components/link"; +//@ts-ignore +import Avatar from "@docspace/components/avatar"; +//@ts-ignore +import { Base } from "@docspace/components/themes"; + +import OAuthClientInfo from "./oauth-client-info"; +import { getCookie } from "@docspace/common/utils"; + +const StyledFormWrapper = styled(FormWrapper)` + width: 416px; + max-width: 416px; + + .button-container { + margin-top: 32px; + margin-bottom: 16px; + + width: 100%; + display: flex; + gap: 8px; + } + + .description-container { + width: 100%; + + margin-bottom: 16px; + + p { + width: 100%; + } + } + + .user-container { + width: 100%; + + padding-top: 16px; + + border-top: 1px solid ${(props) => props.theme.separatorColor}; + + .block { + height: 40px; + + display: flex; + align-items: center; + gap: 8px; + } + } +`; + +StyledFormWrapper.defaultProps = { theme: Base }; + +interface IConsentProps { + oauth: IOAuthState; + theme: IUserTheme; + setIsConsentScreen: (value: boolean) => void; +} + +const Consent = ({ oauth, theme, setIsConsentScreen }: IConsentProps) => { + const navigate = useNavigate(); + const location = useLocation(); + + const { t } = useTranslation(["Consent", "Common"]); + + const onAllowClick = () => { + const clientId = oauth.clientId; + const clientState = oauth.state || getCookie("client_state"); + const scope = oauth.client.scopes; + + api.oauth.onOAuthSubmit(clientId, clientState, scope); + }; + + const onDenyClick = () => { + window.location.href = oauth.client.websiteUrl; + }; + + const onChangeUserClick = () => { + api.user.logout(); + setIsConsentScreen(false); + navigate(`/login/${location.search}`); + }; + + return ( + + + + + +
+
+
+ + + Data shared with {{ displayName: oauth.self?.displayName }} will be + governed by {{ nameApp: oauth.client.name }} + + privacy policy + + and + + terms of service + + . You can revoke this consent at any time in your DocSpace account + settings. + + +
+
+
+ +
+ + {t("SignedInAs")} {oauth.self?.email} + + + {t("NotYou")} + +
+
+
+
+ ); +}; + +export default Consent; diff --git a/packages/login/src/client/components/sub-components/LoginForm.tsx b/packages/login/src/client/components/sub-components/LoginForm.tsx index b63e80d921..367fec80a2 100644 --- a/packages/login/src/client/components/sub-components/LoginForm.tsx +++ b/packages/login/src/client/components/sub-components/LoginForm.tsx @@ -10,7 +10,11 @@ import Link from "@docspace/components/link"; import { useTranslation } from "react-i18next"; import ForgotPasswordModalDialog from "./forgot-password-modal-dialog"; import Button from "@docspace/components/button"; -import { checkIsSSR, createPasswordHash } from "@docspace/common/utils"; +import { + checkIsSSR, + createPasswordHash, + setCookie, +} from "@docspace/common/utils"; import { checkPwd } from "@docspace/common/desktop"; import { login } from "@docspace/common/utils/loginUtils"; import toastr from "@docspace/components/toast/toastr"; @@ -21,6 +25,14 @@ import ReCAPTCHA from "react-google-recaptcha"; import { OAuthLinksContainer, StyledCaptcha } from "../StyledLogin"; import OAuthClientInfo from "./oauth-client-info"; import SelectUser from "./SelectUser"; +import { + getClient, + getScopeList, + loginWithOAuth, +} from "@docspace/common/api/oauth"; +import { getUser } from "@docspace/common/api/people"; +import { onOAuthLogin } from "@docspace/common/api/oauth"; +import { IClientProps, IScope } from "@docspace/common/utils/oauth/interfaces"; interface ILoginFormProps { isLoading: boolean; @@ -34,6 +46,10 @@ interface ILoginFormProps { isBaseTheme: boolean; oauth?: IOAuthState; isOAuthPage?: boolean; + setIsConsentPage?: (val: boolean) => void; + setScopes: (val: IScope[]) => void; + setOAuthClient: (val: IClientProps) => void; + setSelf: (val: ISelf) => void; } const settings = { @@ -56,15 +72,15 @@ const LoginForm: React.FC = ({ isBaseTheme, oauth, isOAuthPage, + setIsConsentPage, + setScopes, + setOAuthClient, + setSelf, }) => { const navigate = useNavigate(); const captchaRef = useRef(null); - const [isOAuthSelectUser, setIsOAuthSelectUser] = useState( - isOAuthPage && !!oauth?.self - ); - const [isEmailErrorShow, setIsEmailErrorShow] = useState(false); const [errorText, setErrorText] = useState(""); const [identifier, setIdentifier] = useState(""); @@ -83,7 +99,7 @@ const LoginForm: React.FC = ({ const inputRef = useRef(null); - const { t, ready } = useTranslation(["Login", "Common"]); + const { t, ready } = useTranslation(["Login", "Common", "Consent"]); const { message, confirmedEmail, authError } = match || { message: "", @@ -91,12 +107,6 @@ const LoginForm: React.FC = ({ authError: "", }; - const showLoginForm = () => setIsOAuthSelectUser(false); - - const onOAuthLogin = () => { - navigate(`/login/consent?clientId=${oauth?.client.clientId}`); - }; - const authCallback = (profile: string) => { localStorage.removeItem("profile"); localStorage.removeItem("code"); @@ -219,18 +229,34 @@ const LoginForm: React.FC = ({ login(user, hash, session, captchaToken) .then((res: string | object) => { if (isOAuthPage) { - return onOAuthLogin(); - } - const isConfirm = typeof res === "string" && res.includes("confirm"); - const redirectPath = sessionStorage.getItem("referenceUrl"); - if (redirectPath && !isConfirm) { - sessionStorage.removeItem("referenceUrl"); - window.location.href = redirectPath; - return; - } + const requests = []; - if (typeof res === "string") window.location.replace(res); - else window.location.replace("/"); //TODO: save { user, hash } for tfa + setCookie("client_id", oauth?.clientId); + + requests.push(getClient(oauth?.clientId || "")); + requests.push(getScopeList()); + requests.push(getUser()); + requests.push(onOAuthLogin()); + + Promise.all(requests).then(([client, scopes, self]) => { + setOAuthClient(client); + setScopes(scopes); + setSelf(self); + setIsConsentPage && setIsConsentPage(true); + setIsLoading(false); + }); + } else { + const isConfirm = typeof res === "string" && res.includes("confirm"); + const redirectPath = sessionStorage.getItem("referenceUrl"); + if (redirectPath && !isConfirm) { + sessionStorage.removeItem("referenceUrl"); + window.location.href = redirectPath; + return; + } + + if (typeof res === "string") window.location.replace(res); + else window.location.replace("/"); //TODO: save { user, hash } for tfa + } }) .catch((error) => { let errorMessage = ""; @@ -313,161 +339,149 @@ const LoginForm: React.FC = ({
{oauth?.client && isOAuthPage && ( )} - - {isOAuthSelectUser && oauth?.self ? ( - - ) : ( - <> - + + - - - {(!IS_ROOMS_MODE || !isWithoutPasswordLogin) && ( - <> - - - - -
-
-
- {!cookieSettingsEnabled && ( - - {t("RememberHelper")} - - } - tooltipMaxWidth={isMobileOnly ? "240px" : "340px"} - /> - ) - } - /> - )} -
- - - {t("ForgotPassword")} - -
-
- - {isDialogVisible && ( - - )} - {recaptchaPublicKey && isCaptcha && ( - -
- -
- {isCaptchaError && ( - {t("Errors:LoginWithBruteForceCaptcha")} - )} -
- )} - - )} - -