Login: add tenant-list route

This commit is contained in:
Timofey Boyko 2024-06-26 15:19:00 +03:00
parent ffe98ad175
commit d7914ad024
15 changed files with 327 additions and 91 deletions

View File

@ -24,7 +24,7 @@
// content are licensed under the terms of the Creative Commons Attribution-ShareAlike 4.0
// International. See the License terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode
import { IClientProps } from "@docspace/shared/utils/oauth/interfaces";
import { IClientProps } from "@docspace/shared/utils/oauth/types";
import Consent from "@/components/Consent";
import { getOAuthClient, getScopeList, getUser } from "@/utils/actions";
@ -36,7 +36,7 @@ async function Page({
}) {
const clientId = searchParams.clientId ?? searchParams.client_id;
const [client, scopes, user] = await Promise.all([
getOAuthClient(clientId, true),
getOAuthClient(clientId),
getScopeList(),
getUser(),
]);

View File

@ -83,7 +83,7 @@ export default async function Layout({
<GreetingContainer
greetingSettings={objectSettings?.greetingSettings}
/>
<FormWrapper id="login-form">{children}</FormWrapper>
{children}
</ColorTheme>
</LoginContent>
</Scrollbar>

View File

@ -24,58 +24,56 @@
// content are licensed under the terms of the Creative Commons Attribution-ShareAlike 4.0
// International. See the License terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode
import { INoAuthClientProps } from "@docspace/shared/utils/oauth/interfaces";
import { INoAuthClientProps } from "@docspace/shared/utils/oauth/types";
import { getConfig, getOAuthClient, getSettings } from "@/utils/actions";
import { getOAuthClient, getSettings } from "@/utils/actions";
import Login from "@/components/Login";
import LoginForm from "@/components/LoginForm";
import ThirdParty from "@/components/ThirdParty";
import RecoverAccess from "@/components/RecoverAccess";
import Register from "@/components/Register";
import { FormWrapper } from "@docspace/shared/components/form-wrapper";
async function Page({
searchParams,
}: {
searchParams: { [key: string]: string };
}) {
const clientId = searchParams.clientId;
const clientId = searchParams.client_id;
const [settings, client, config] = await Promise.all([
const [settings, client] = await Promise.all([
getSettings(),
clientId ? getOAuthClient(clientId, false) : undefined,
clientId ? getConfig() : undefined,
clientId ? getOAuthClient(clientId) : undefined,
]);
const isPublicOAuth = clientId && config.oauth2.publicClient;
console.log(isPublicOAuth);
return (
<Login>
{settings && typeof settings !== "string" && (
<>
<LoginForm
hashSettings={settings?.passwordHash}
cookieSettingsEnabled={settings?.cookieSettingsEnabled}
clientId={clientId}
client={client as INoAuthClientProps}
reCaptchaPublicKey={settings?.recaptchaPublicKey}
reCaptchaType={settings?.recaptchaType}
/>
{!clientId && <ThirdParty />}
{settings.enableAdmMess && <RecoverAccess />}
{settings.enabledJoin && !clientId && (
<Register
id="login_register"
enabledJoin
trustedDomains={settings.trustedDomains}
trustedDomainsType={settings.trustedDomainsType}
isAuthenticated={false}
<FormWrapper id="login-form">
<Login>
{settings && typeof settings !== "string" && (
<>
<LoginForm
hashSettings={settings?.passwordHash}
cookieSettingsEnabled={settings?.cookieSettingsEnabled}
clientId={clientId}
client={client}
reCaptchaPublicKey={settings?.recaptchaPublicKey}
reCaptchaType={settings?.recaptchaType}
/>
)}
</>
)}
</Login>
{!clientId && <ThirdParty />}
{settings.enableAdmMess && <RecoverAccess />}
{settings.enabledJoin && !clientId && (
<Register
id="login_register"
enabledJoin
trustedDomains={settings.trustedDomains}
trustedDomainsType={settings.trustedDomainsType}
isAuthenticated={false}
/>
)}
</>
)}
</Login>
</FormWrapper>
);
}

View File

@ -0,0 +1,23 @@
import TenantList from "@/components/TenantList";
import { getSettings } from "@/utils/actions";
export default async function Page({
searchParams,
}: {
searchParams: { [key: string]: string };
}) {
const settings = await getSettings();
const { portals } = JSON.parse(searchParams.portals);
const clientId = searchParams.clientId;
if (typeof settings !== "object") return;
return (
<TenantList
portals={portals}
clientId={clientId}
baseDomain={settings.baseDomain}
/>
);
}

View File

@ -41,12 +41,13 @@ import {
AvatarSize,
} from "@docspace/shared/components/avatar";
import { deleteCookie } from "@docspace/shared/utils/cookie";
import { IClientProps, IScope } from "@docspace/shared/utils/oauth/interfaces";
import { IClientProps, IScope } from "@docspace/shared/utils/oauth/types";
import { TUser } from "@docspace/shared/api/people/types";
import api from "@docspace/shared/api";
import OAuthClientInfo from "./ConsentInfo";
import { useRouter } from "next/navigation";
import { FormWrapper } from "@docspace/shared/components/form-wrapper";
const StyledButtonContainer = styled.div`
margin-top: 32px;
@ -158,11 +159,11 @@ const Consent = ({ client, scopes, user }: IConsentProps) => {
const onChangeUserClick = async () => {
await api.user.logout();
router.push(`/?clientId=${client.clientId}`);
router.push(`/?client_id=${client.clientId}&type=oauth2`);
};
return (
<>
<FormWrapper>
<OAuthClientInfo
name={client.name}
logo={client.logo}
@ -198,7 +199,7 @@ const Consent = ({ client, scopes, user }: IConsentProps) => {
<StyledDescriptionContainer>
<Text fontWeight={400} fontSize={"13px"} lineHeight={"20px"}>
<Trans t={t} i18nKey={"ConsentDescription"} ns="Consent">
Data shared with {{ displayName: self.displayName }} will be
Data shared with {{ displayName: user.displayName }} will be
governed by {{ nameApp: client.name }}
<Link
className={"login-link"}
@ -250,7 +251,7 @@ const Consent = ({ client, scopes, user }: IConsentProps) => {
</div>
</div>
</StyledUserContainer>
</>
</FormWrapper>
);
};

View File

@ -29,7 +29,7 @@
import React, { useLayoutEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { useTheme } from "styled-components";
import { Text } from "@docspace/shared/components/text";
@ -48,6 +48,7 @@ const GreetingContainer = ({ greetingSettings }: GreetingContainersProps) => {
const logoUrl = getLogoUrl(WhiteLabelLogoType.LoginPage, !theme.isBase);
const searchParams = useSearchParams();
const pathname = usePathname();
const [invitationLinkData, setInvitationLinkData] = useState({
email: "",
@ -84,7 +85,9 @@ const GreetingContainer = ({ greetingSettings }: GreetingContainersProps) => {
textAlign="center"
className="greeting-title"
>
{greetingSettings}
{pathname === "/tenant-list"
? "Choose your portal"
: greetingSettings}
</Text>
)}

View File

@ -51,9 +51,10 @@ import { setWithCredentialsStatus } from "@docspace/shared/api/client";
import { TValidate } from "@docspace/shared/components/email-input/EmailInput.types";
import api from "@docspace/shared/api";
import { RecaptchaType } from "@docspace/shared/enums";
import { getAvailablePortals } from "@docspace/shared/api/management";
import { LoginFormProps } from "@/types";
import { getEmailFromInvitation } from "@/utils";
import { generateOAuth2ReferenceURl, getEmailFromInvitation } from "@/utils";
import EmailContainer from "./sub-components/EmailContainer";
import PasswordContainer from "./sub-components/PasswordContainer";
@ -63,6 +64,7 @@ import LDAPContainer from "./sub-components/LDAPContainer";
import { StyledCaptcha } from "./LoginForm.styled";
import { LoginDispatchContext, LoginValueContext } from "../Login";
import OAuthClientInfo from "../ConsentInfo";
// import { gitAvailablePortals } from "@/utils/actions";
const LoginForm = ({
hashSettings,
@ -204,7 +206,7 @@ const LoginForm = ({
if (!passwordValid) setPasswordValid(true);
};
const onSubmit = useCallback(() => {
const onSubmit = useCallback(async () => {
//errorText && setErrorText("");
let captchaToken: string | undefined | null = "";
@ -254,6 +256,32 @@ const LoginForm = ({
isDesktop && checkPwd();
const session = !isChecked;
if (client?.isPublic && hash) {
const portals = await getAvailablePortals({
Email: user,
PasswordHash: hash,
});
// if (portals.length === 1) {
// const referenceUrl = generateOAuth2ReferenceURl(client.clientId);
// window.open(
// `${portals[0].portalLink}&referenceUrl=${referenceUrl}`,
// "_self",
// );
// }
const searchParams = new URLSearchParams();
const portalsString = JSON.stringify({ portals });
searchParams.set("portals", portalsString);
searchParams.set("clientId", client.clientId);
router.push(`/tenant-list?${searchParams.toString()}`);
setIsLoading(false);
return;
}
login(user, hash, pwd, session, captchaToken, currentCulture, reCaptchaType)
.then(async (res: string | object) => {
if (clientId) {
@ -307,17 +335,18 @@ const LoginForm = ({
password,
identifierValid,
setIsLoading,
isLdapLoginChecked,
hashSettings,
isDesktop,
isChecked,
isLdapLoginChecked,
client?.isPublic,
client?.clientId,
currentCulture,
reCaptchaType,
isCaptchaSuccessful,
router,
clientId,
referenceUrl,
currentCulture,
router,
reCaptchaType,
]);
const onBlurEmail = () => {

View File

@ -0,0 +1,78 @@
import { mobile } from "@docspace/shared/utils";
import styled from "styled-components";
const StyledTenantList = styled.div`
margin-top: -16px;
display: flex;
flex-direction: column;
align-items: center;
.more-accounts {
color: ${(props) => props.theme.text.disableColor};
text-align: center;
margin-bottom: 32px;
}
.items-list {
width: 100%;
max-width: 480px;
border: 1px solid ${(props) => props.theme.oauth.infoDialog.separatorColor};
border-radius: 6px;
div:last-child {
border: none !important;
}
@media ${mobile} {
maxwidth: 100%;
}
}
.item {
height: 59px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid
${(props) => props.theme.oauth.infoDialog.separatorColor};
:hover {
cursor: pointer;
background-color: ${(props) =>
props.theme.dropDownItem.hoverBackgroundColor};
}
.info {
display: flex;
align-items: center;
max-width: calc(100% - 64px);
}
.favicon {
width: 32px;
height: 32px;
margin-right: 12px;
}
.icon-button {
cursor: pointer;
}
}
.back-button {
margin: 32px auto 0;
}
`;
export default StyledTenantList;

View File

@ -0,0 +1,13 @@
type TPortal = { portalLink: string; portalName: string };
export type TenantListProps = {
baseDomain: string;
clientId: string;
portals: TPortal[];
};
export type ItemProps = {
portal: TPortal;
baseDomain: string;
clientId: string;
};

View File

@ -0,0 +1,43 @@
"use client";
import { Text } from "@docspace/shared/components/text";
import Item from "./sub-components/Item";
import StyledTenantList from "./TenantList.styled";
import { TenantListProps } from "./TenantList.types";
import { Button } from "@docspace/shared/components/button";
import { useRouter } from "next/navigation";
const TenantList = ({ portals, clientId, baseDomain }: TenantListProps) => {
const router = useRouter();
const goToLogin = () => {
router.push(`/?type=oauth2&client_id=${clientId}`);
};
return (
<StyledTenantList>
<Text className="more-accounts">
You have more than one accounts. Please choose one of them
</Text>
<div className="items-list">
{portals.map((item) => (
<Item
portal={item}
key={item.portalName}
clientId={clientId}
baseDomain={baseDomain}
/>
))}
</div>
<Button
onClick={goToLogin}
label="Back to sign in"
className="back-button"
/>
</StyledTenantList>
);
};
export default TenantList;

View File

@ -0,0 +1,44 @@
/* eslint-disable @next/next/no-img-element */
import { Text } from "@docspace/shared/components/text";
import ArrowRightSvrUrl from "PUBLIC_DIR/images/arrow.right.react.svg?url";
import { ItemProps } from "../TenantList.types";
import { IconButton } from "@docspace/shared/components/icon-button";
import { generateOAuth2ReferenceURl } from "@/utils";
const Item = ({ clientId, portal, baseDomain }: ItemProps) => {
console.log(portal);
const name = portal.portalName.includes(baseDomain)
? portal.portalName
: `${portal.portalName}.${baseDomain}`;
const onClick = () => {
const referenceUrl = generateOAuth2ReferenceURl(clientId);
window.open(`${portal.portalLink}&referenceUrl=${referenceUrl}`, "_self");
};
return (
<div className="item" onClick={onClick}>
<div className="info">
<img
className="favicon"
alt="Portal favicon"
src={`${name}/logo.ashx?logotype=3`}
/>
<Text fontWeight={600} fontSize="14px" lineHeight="16px" truncate>
{name.replace("http://", "").replace("https://", "")}
</Text>
</div>
<IconButton
iconName={ArrowRightSvrUrl}
size={16}
className="icon-button"
/>
</div>
);
};
export default Item;

View File

@ -40,29 +40,26 @@ export function middleware(request: NextRequest) {
}
const isAuth = !!request.cookies.get("asc_auth_key")?.value;
const isOAuth = request.nextUrl.searchParams.get("type") === "oauth2";
if (isOAuth) {
const oauthClientId =
request.nextUrl.searchParams.get("client_id") ??
request.nextUrl.searchParams.get("clientId");
const oauthClientId =
request.nextUrl.searchParams.get("client_id") ??
request.nextUrl.searchParams.get("clientId");
if (isOAuth || oauthClientId) {
if (oauthClientId === "error")
return NextResponse.redirect(`${redirectUrl}/login/error`);
if (isAuth) {
if (request.nextUrl.pathname === "/consent") return;
if (isAuth && !request.nextUrl.pathname.includes("consent")) {
return NextResponse.redirect(
`${redirectUrl}/login/consent${request.nextUrl.search}`,
);
}
} else {
const url = request.nextUrl.clone();
url.pathname = "/";
if (isAuth && redirectUrl) return NextResponse.redirect(redirectUrl);
}
const url = request.nextUrl.clone();
url.pathname = "/";
if (isAuth && redirectUrl) return NextResponse.redirect(redirectUrl);
}
// See "Matching Paths" below to learn more

View File

@ -33,7 +33,7 @@ import {
TThirdPartyProvider,
} from "@docspace/shared/api/settings/types";
import { TValidate } from "@docspace/shared/components/email-input/EmailInput.types";
import { INoAuthClientProps } from "@docspace/shared/utils/oauth/interfaces";
import { IClientProps } from "@docspace/shared/utils/oauth/types";
import { RecaptchaType, ThemeKeys } from "@docspace/shared/enums";
export type TDataContext = {
@ -88,7 +88,7 @@ export type LoginFormProps = {
reCaptchaType?: RecaptchaType;
cookieSettingsEnabled: boolean;
clientId?: string;
client?: INoAuthClientProps;
client?: IClientProps;
};
export type ForgotPasswordModalDialogProps = {

View File

@ -42,10 +42,7 @@ import {
TThirdPartyProvider,
TVersionBuild,
} from "@docspace/shared/api/settings/types";
import {
INoAuthClientProps,
IScope,
} from "@docspace/shared/utils/oauth/interfaces";
import { IScope } from "@docspace/shared/utils/oauth/types";
import { transformToClientProps } from "@docspace/shared/utils/oauth";
export const checkIsAuthenticated = async () => {
@ -182,27 +179,9 @@ export async function getScopeList() {
return scopes as IScope[];
}
export async function getOAuthClient(clientId: string, isAuth = true) {
if (!isAuth) {
const [getOAuthClient] = createRequest(
[`/clients/${clientId}/info`],
[["", ""]],
"GET",
);
const oauthClient = await fetch(getOAuthClient);
console.log(oauthClient);
if (!oauthClient.ok) return;
const client = (await oauthClient.json()) as INoAuthClientProps;
return client;
}
export async function getOAuthClient(clientId: string) {
const [getOAuthClient] = createRequest(
[`/clients/${clientId}`],
[`/clients/${clientId}/public/info`],
[["", ""]],
"GET",
);
@ -232,6 +211,30 @@ export async function getPortalCultures() {
return cultures.response as TPortalCultures;
}
export async function gitAvailablePortals(data: {
email: string;
passwordHash: string;
}) {
const [gitAvailablePortals] = createRequest(
[`/portal/signin`],
[["Content-Type", "application/json"]],
"POST",
JSON.stringify(data),
true,
);
console.log(gitAvailablePortals.url);
const response = await fetch(gitAvailablePortals);
if (!response.ok) return null;
const { response: portals } = await response.json();
console.log(portals);
// return config;
}
export async function getConfig() {
const baseUrl = getBaseUrl();
const config = await (

View File

@ -138,3 +138,7 @@ export const getEmailFromInvitation = (encodeString: Nullable<string>) => {
return queryParams.email;
};
export const generateOAuth2ReferenceURl = (clientId: string) => {
return `/login/consent?clientId=${clientId}`;
};