feat: init

This commit is contained in:
montaghanmy
2023-03-23 11:03:16 +01:00
commit 10fe6f78d1
11518 changed files with 509786 additions and 0 deletions
@@ -0,0 +1,125 @@
import React, { useState } from 'react';
import { Button } from 'app/atoms/button/button';
import { Modal, ModalContent } from 'app/atoms/modal';
import {
invitationActiveTab,
invitationEmailsState,
invitationSentState,
invitationState,
} from 'app/features/invitation/state/invitation';
import { useCurrentWorkspace } from 'app/features/workspaces/hooks/use-workspaces';
import Tab from 'app/molecules/tabs';
import { useRecoilState } from 'recoil';
import Bulk_invitation from './parts/bulk-invitation';
import Custom_role_invitation from './parts/custom-role-invitation';
import InvitationChannels from './parts/invitation-channels';
import InvitationSent from './parts/invitation-sent';
import { useInvitation } from 'app/features/invitation/hooks/use-invitation';
import Languages from 'app/features/global/services/languages-service';
import WorkspaceLink from './parts/workspace-link';
import InvitationTarget from './parts/invitation-target';
import AllowAnyoneByEmail from './parts/allow-anyone-by-email';
import { Info, Subtitle } from 'app/atoms/text';
enum InvitationTabs {
custom = 0,
bulk = 1,
}
export default (): React.ReactElement => {
const [isOpen, setOpen] = useRecoilState(invitationState);
const [activeTab, setInvitationTab] = useRecoilState(invitationActiveTab);
const [invitations] = useRecoilState(invitationEmailsState);
const workspace = useCurrentWorkspace();
const [isInvitationSent] = useRecoilState(invitationSentState);
const { send, reset } = useInvitation();
const [sending, setSending] = useState<boolean>(false);
const handleSend = async () => {
setSending(true);
try {
await send();
} catch (error) {
console.debug(error);
} finally {
setSending(false);
}
};
return (
<Modal
open={isOpen}
onClose={() => {
setOpen(false);
reset();
}}
className="sm:w-[60vw] sm:max-w-2xl"
style={{ minHeight: 'calc(70vh - 98px)' }}
>
<ModalContent
textCenter
title={
isInvitationSent
? ''
: Languages.t(
'components.invitation.title',
[workspace.workspace?.name],
`Invite users to ${workspace.workspace?.name}`,
)
}
>
{!isInvitationSent ? (
<>
<AllowAnyoneByEmail />
<Subtitle className="mt-6 block">Manual invitation</Subtitle>
<Info className="block">
You can configure exactly where the user will be invited and what role they will have.
</Info>
<InvitationTarget />
<Info className="block mt-2 mb-1">
Simply send this email to the users you want to invite.
</Info>
<WorkspaceLink />
<Info className="block mt-4">Or enter manually the emails here.</Info>
{/*<Tab
tabs={[
<div key="custom_role_invitation">
{Languages.t('components.invitation.custom_role_invitation')}
</div>,
<div key="bulk_invitation">
{Languages.t('components.invitation.bulk_invitation')}
</div>,
]}
selected={activeTab}
onClick={index => setInvitationTab(index)}
className="w-full"
parentClassName="basis-1/2 justify-center"
/>
{activeTab === InvitationTabs.custom && <Custom_role_invitation />}
{activeTab === InvitationTabs.bulk && <Bulk_invitation />}*/}
<Custom_role_invitation />
<Button
className="mt-2 justify-center w-full"
disabled={!invitations.length || sending}
onClick={() => handleSend()}
loading={sending}
>
{Languages.t('components.invitation.button', [], 'Send invitations')}
<div className="font-medium h-5 px-1.5 flex items-center justify-center text-sm rounded-full ml-1 bg-white text-blue-500">
{invitations.length}
</div>
</Button>
<InvitationChannels />
</>
) : (
<InvitationSent />
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,62 @@
import { Info, Subtitle } from 'app/atoms/text';
import Switch from 'app/components/inputs/switch';
import Languages from 'app/features/global/services/languages-service';
import { ToasterService } from 'app/features/global/services/toaster-service';
import { useInvitationUsers } from 'app/features/invitation/hooks/use-invitation-users';
import { useCurrentUser } from 'app/features/users/hooks/use-current-user';
import workspaceApiClient from 'app/features/workspaces/api/workspace-api-client';
import { useCurrentWorkspace } from 'app/features/workspaces/hooks/use-workspaces';
import React, { useState } from 'react';
export default (): React.ReactElement => {
const { user } = useCurrentUser();
const { members_limit_reached } = useInvitationUsers();
const { workspace } = useCurrentWorkspace();
const [allow, setAllow] = useState<boolean>(!!workspace?.preferences?.invite_domain);
const currentUserDomain = user?.email.split('@').pop();
const handleChange = async (value: boolean): Promise<void> => {
if (value) {
try {
await workspaceApiClient.setInvitationDomain(
workspace?.company_id as string,
workspace?.id as string,
currentUserDomain as string,
);
setAllow(true);
ToasterService.success('Invitation domain updated');
} catch (error) {
ToasterService.error('Failed to set invitation domain');
}
}
};
return !members_limit_reached ? (
<>
<Subtitle className="mt-4 block">Automatically invite my business</Subtitle>
<Info className="block">
If this is enabled, anyone creating a Twake account with your business email will
automatically be added to this company and this workspace.
</Info>
<div className="flex flex-row space-x-2 mt-1 bg-zinc-200 px-3 py-2 rounded-md border-transparent w-full h-9">
<div className="flex-1">
{Languages.t(
'components.invitation.allow_anyone_by_email.text',
[workspace?.preferences?.invite_domain || currentUserDomain],
`Let anyone with @${
workspace?.preferences?.invite_domain || currentUserDomain
} email join this workspace`,
)}
</div>
<Switch
checked={allow}
onChange={handleChange}
disabled={!!workspace?.preferences?.invite_domain}
/>
</div>
</>
) : (
<></>
);
};
@@ -0,0 +1,10 @@
import React from 'react';
import InvitationInputBulk from './invitation-input-bulk';
export default (): React.ReactElement => {
return (
<div className="flex flex-col">
<InvitationInputBulk />
</div>
);
};
@@ -0,0 +1,10 @@
import React from 'react';
import InvitationInputList from './invitation-input-list';
export default (): React.ReactElement => {
return (
<div className="flex flex-col mt-2">
<InvitationInputList />
</div>
);
};
@@ -0,0 +1,44 @@
import React from 'react';
import { Button } from 'app/atoms/button/button';
import { Modal, ModalContent } from 'app/atoms/modal';
import { ChannelSelector } from 'app/components/channels-selector';
import { ChannelType } from 'app/features/channels/types/channel';
import { useInvitationChannels } from 'app/features/invitation/hooks/use-invitation-channels';
import { uniqBy } from 'lodash';
import Languages from 'app/features/global/services/languages-service';
export default (): React.ReactElement => {
const { selectedChannels, closeSelection, setChannels, open } = useInvitationChannels();
const handleSelectionChange = (channels: ChannelType[]) => {
setChannels(uniqBy(channels, 'id'));
};
return (
<Modal open={open} onClose={() => closeSelection()} className="sm:w-[20vw] sm:max-w-xl">
<ModalContent textCenter title="Choose channels">
<ChannelSelector
initialChannels={selectedChannels}
onChange={handleSelectionChange}
lockDefaultChannels={true}
/>
<Button
theme="primary"
disabled={!selectedChannels.length}
onClick={() => closeSelection()}
className="w-full justify-center"
>
{Languages.t(
'components.invitation.invitation_channels.button',
[],
'Invite to channels',
)}
<div className="font-medium h-5 px-1.5 flex items-center justify-center text-sm rounded-full ml-1 bg-white text-blue-500">
{selectedChannels.length}
</div>
</Button>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,85 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react';
import {
invitationEmailsState,
invitationTypeState,
InvitedUser,
} from 'app/features/invitation/state/invitation';
import { useRecoilState } from 'recoil';
import ChipInput from 'material-ui-chip-input';
import { RemoveIcon } from 'app/atoms/icons-colored';
import PerfectScrollbar from 'react-perfect-scrollbar';
import { useCurrentUser } from 'app/features/users/hooks/use-current-user';
import { useInvitationUsers } from 'app/features/invitation/hooks/use-invitation-users';
import ReachedLimit from './reached-limit';
const emailRegex = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/;
export default (): React.ReactElement => {
const [invitations, setInvitations] = useRecoilState(invitationEmailsState);
const [role] = useRecoilState(invitationTypeState);
const { user } = useCurrentUser();
const { can_add_invitations, members_limit_reached } = useInvitationUsers();
const emails = (invitations || []).map(invitation => invitation.email);
const currentUserEmailDomain = user?.email.split('@').pop();
const handleChange = (chips: string[]) => {
const newInvitations: InvitedUser[] = chips
.filter(chip => !invitations.find(invitation => invitation.email === chip))
.map(chip => ({ email: chip, role }));
const updatedInvitations = [...invitations, ...newInvitations];
setInvitations(updatedInvitations);
};
const validate = (chip: string): boolean => {
return emailRegex.test(chip) && currentUserEmailDomain === chip.split('@').pop();
};
const handleDelete = (chip: string) => {
const updatedInvitations = invitations.filter(({ email }) => email !== chip);
setInvitations(updatedInvitations);
};
const rederChip = (
{ chip, handleDelete }: { chip: string; handleDelete: React.EventHandler<any> },
key: string,
): React.ReactElement => {
return (
<div
key={key}
className="flex justify-center items-center m-2 my-2 px-3 py-2 space-x-2 h-9 rounded-md bg-white"
>
<RemoveIcon onClick={handleDelete} className="cursor-pointer" />
<div className="flex-initial max-w-full leading-none text-xs font-normal">{chip}</div>
</div>
);
};
return (
<PerfectScrollbar
className="-mb-4 overflow-hidden px-2"
style={{ maxHeight: 'calc(42vh - 98px)', minHeight: 'calc(42vh - 98px)' }}
options={{ suppressScrollX: true, suppressScrollY: false }}
>
{ members_limit_reached && <ReachedLimit />}
<ChipInput
value={emails}
disableUnderline={true}
fullWidth={true}
blurBehavior="ignore"
newChipKeyCodes={[13, 188, 54, 190, 32]}
onBeforeAdd={validate}
variant="standard"
className="bg-zinc-200 border-none hover:border-none rounded-md py-1"
chipRenderer={rederChip}
onChange={handleChange}
onDelete={handleDelete}
clearInputValueOnChange={false}
disabled={!can_add_invitations}
/>
</PerfectScrollbar>
);
};
@@ -0,0 +1,150 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Button } from 'app/atoms/button/button';
import { RemoveIcon } from 'app/atoms/icons-colored';
import { Input } from 'app/atoms/input/input-text';
import {
invitationEmailsState,
InvitationType,
invitationTypeState,
} from 'app/features/invitation/state/invitation';
import React, { useState } from 'react';
import { useRecoilState } from 'recoil';
import PerfectScrollbar from 'react-perfect-scrollbar';
import { useCurrentUser } from 'app/features/users/hooks/use-current-user';
import { useInvitationUsers } from 'app/features/invitation/hooks/use-invitation-users';
import ReachedLimit from './reached-limit';
import Languages from 'app/features/global/services/languages-service';
import { PlusIcon } from '@heroicons/react/outline';
import Select from 'app/atoms/input/input-select';
export default (): React.ReactElement => {
const [invitations, setInvitations] = useRecoilState(invitationEmailsState);
const [currentInput, setCurrentInput] = useState<string>('');
const [notValidEmail, setNotValidEmail] = useState<boolean>(false);
const [invitationTargetType] = useRecoilState(invitationTypeState);
const { user } = useCurrentUser();
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
const currentUserEmailDomain = user?.email.split('@').pop();
const { can_add_invitations, members_limit_reached, allowed_members, allowed_guests } =
useInvitationUsers();
const emailExists = (target: string): boolean =>
!!invitations.find(invitation => invitation.email === target);
const handleEnter = (event: unknown): void => {
if ((event as KeyboardEvent).key === 'Enter') {
handleAdd();
}
};
const handleAdd = (): void => {
const role = invitationTargetType;
if (emailRegex.test(currentInput) && !emailExists(currentInput)) {
setInvitations([...invitations, { email: currentInput, role }]);
setCurrentInput('');
setNotValidEmail(false);
} else {
setNotValidEmail(true);
}
};
const removeEmail = (targetEmail: string): void => {
setInvitations(invitations.filter(invitation => invitation.email !== targetEmail));
};
const handleInput = (event: any): void => {
setCurrentInput(event.target.value);
setNotValidEmail(!emailRegex.test(event.target.value) || emailExists(event.target.value));
};
const handleRoleChange = (email: string, role: InvitationType): void => {
if (role === InvitationType.guest && allowed_guests <= 0) return;
if (role === InvitationType.member && allowed_members <= 0) return;
const changedInvitations = invitations.map(invitation => {
if (invitation.email === email) {
return {
email,
role,
};
}
return invitation;
});
setInvitations(changedInvitations);
};
return (
<div className="flex flex-col border-transparent w-full">
{can_add_invitations ? (
<div className="flex flex-row">
<Input
className="rounded-r-none grow"
theme="outline"
placeholder={Languages.t('components.invitation.invitation_input_list.placeholder')}
value={currentInput}
onKeyDown={handleEnter}
onChange={handleInput}
hasError={notValidEmail}
/>
<Button
theme="primary"
disabled={notValidEmail || !currentInput.length}
onClick={handleAdd}
className="justify-center rounded-l-none"
>
<PlusIcon className="h-4 w-4 mr-1.5 -ml-1" />
{Languages.t('components.invitation.invitation_input_list.add')}
</Button>
</div>
) : members_limit_reached ? (
<ReachedLimit />
) : (
<></>
)}
<PerfectScrollbar
className="-mb-2 py-2 overflow-hidden -mx-2 px-2"
style={{ maxHeight: 'calc(30vh - 100px)', minHeight: 'calc(30vh - 100px)' }}
options={{ suppressScrollX: true, suppressScrollY: false }}
>
{invitations.map(invitation => (
<div
className="flex flex-row border-transparent items-center rounded-md py-0.5 my-0.5"
key={invitation.email}
>
<RemoveIcon
className="cursor-pointer shrink-0 mr-2"
onClick={() => removeEmail(invitation.email)}
/>
<Input
theme="outline"
className="rounded-r-none grow w-full -mr-px"
disabled={true}
value={invitation.email}
/>
<Select
theme="outline"
className="rounded-l-none w-auto"
onChange={e =>
handleRoleChange(invitation.email, e.target.value as unknown as InvitationType)
}
value={invitation.role}
disabled={
(invitation.role === InvitationType.guest && allowed_members <= 0) ||
(invitation.role === InvitationType.member && allowed_guests <= 0)
}
>
<option value={InvitationType.member}>
{Languages.t('components.invitation.invitation_input_list.member', [], 'Member')}
</option>
<option value={InvitationType.guest}>
{Languages.t('components.invitation.invitation_input_list.guest', [], 'Guest')}
</option>
</Select>
</div>
))}
</PerfectScrollbar>
</div>
);
};
@@ -0,0 +1,53 @@
import { SentIcon } from 'app/atoms/icons-colored';
import { Title, Base } from 'app/atoms/text';
import Link from 'app/atoms/link';
import React from 'react';
import { Button } from 'app/atoms/button/button';
import { useInvitation } from 'app/features/invitation/hooks/use-invitation';
import Languages from 'app/features/global/services/languages-service';
export default (): React.ReactElement => {
const { reset } = useInvitation();
return (
<div className="flex flex-col text-center h-full" style={{ minHeight: 'calc(66vh - 92px)' }}>
<div className="flex justify-center text-center w-full">
<SentIcon className="h-52 w-52" />
</div>
<div className="text-center w-full py-2 mx-4 px-6">
<Title className="text-4xl">
{Languages.t(
'components.invitation.invitation_sent.title',
[],
'Invitations have successfully been sent',
)}
</Title>
</div>
<div className="text-center w-full py-3 mx-4 px-6">
<Base>
{Languages.t(
'components.invitation.invitation_sent.subtitle_status',
[],
'You can track invitaion status in:',
)}
{Languages.t(
'components.invitation.invitation_sent.subtitle_location',
[],
'Workspace settings > Member management',
)}
</Base>
</div>
<div className="text-center w-full py-3">
<Link>
{Languages.t('components.invitation.invitation_sent.link', [], 'Check invitation status')}
</Link>
</div>
<div className="flex grow h-full flex-col-reverse">
<Button className="mt-2 justify-center w-full" onClick={() => reset()}>
{Languages.t('components.invitation.invitation_sent.button', [], 'Send more invitations')}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,53 @@
import { BaseSmall } from 'app/atoms/text';
import Switch from 'app/components/inputs/switch';
import Languages from 'app/features/global/services/languages-service';
import { useInvitationChannels } from 'app/features/invitation/hooks/use-invitation-channels';
import { useInvitationUsers } from 'app/features/invitation/hooks/use-invitation-users';
import {
invitationEmailsState,
InvitationType,
invitationTypeState,
} from 'app/features/invitation/state/invitation';
import React from 'react';
import { useRecoilState } from 'recoil';
export default (): React.ReactElement => {
const [invitationType, setInvitationType] = useRecoilState(invitationTypeState);
const { openSelection, selectedChannels } = useInvitationChannels();
const [invitations, setInvitations] = useRecoilState(invitationEmailsState);
const { allowed_guests, allowed_members } = useInvitationUsers();
const changeInvitationType = (type: InvitationType) => {
setInvitationType(type);
if (type === InvitationType.guest && allowed_guests <= 0) return;
if (type === InvitationType.member && allowed_members <= 0) return;
setInvitations(invitations.map(({ email }) => ({ email, role: type })));
};
return (
<div className="flex flex-row space-x-3 rounded-md border-transparent w-full pt-2">
<div className="bg-zinc-200 rounded-md py-2 px-3 flex flex-row justify-center items-center">
<BaseSmall>
{Languages.t('components.invitation.invitation_target.invite_as_guests')}
</BaseSmall>
<Switch
checked={invitationType === InvitationType.guest}
className="ml-2"
onChange={e => changeInvitationType(e ? InvitationType.guest : InvitationType.member)}
/>
</div>
<div className="flex-grow bg-zinc-200 rounded-md py-2 ml-2 px-3 justify-center align-middle">
<div
className="cursor-pointer flex relative justify-center border-transparent hover:text-blue-600 transition-colors text-blue-500 font-semibold "
onClick={() => openSelection()}
>
{Languages.t('components.invitation.invitation_target.channels_button')}
<div className="font-medium h-5 px-1.5 flex items-center justify-center text-sm rounded-full ml-1 text-white bg-blue-500">
{selectedChannels.length}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,15 @@
import Text from 'app/atoms/text';
import Languages from 'app/features/global/services/languages-service';
import React from 'react';
export default (): React.ReactElement => {
return (
<Text type="base" className="text-red-500" noColor={true}>
{Languages.t(
'components.invitation.reached_limit.text',
[],
'you reached the maximum number of users inside your company. Increase your subscription or add these users as guests.',
)}
</Text>
);
};
@@ -0,0 +1,59 @@
import { Button } from 'app/atoms/button/button';
import React, { useState } from 'react';
import { CopyIcon } from '@atoms/icons-agnostic';
import { Base } from 'app/atoms/text';
import { useInvitation } from 'app/features/invitation/hooks/use-invitation';
import Languages from 'app/features/global/services/languages-service';
import MagicLinks from 'app/views/client/popup/AddUser/MagicLinks';
import { Input } from 'app/atoms/input/input-text';
import { InputDecorationIcon } from 'app/atoms/input/input-decoration-icon';
import { LinkIcon } from '@heroicons/react/outline';
import { copyToClipboard } from 'app/features/global/utils/CopyClipboard';
import { ToasterService } from 'app/features/global/services/toaster-service';
export default (): React.ReactElement => {
const { generateInvitationLink } = useInvitation();
const [loading, setLoading] = useState<boolean>(false);
const copyLink = async () => {
setLoading(true);
const link = await generateInvitationLink();
if (link) {
copyToClipboard(link);
ToasterService.success('Link copied to clipboard');
}
setLoading(false);
};
return (
<div className="flex flex-row w-full">
<InputDecorationIcon
className="grow grow w-full"
input={({ className }) => (
<Input
className={className + ' rounded-r-none pointer-events-none'}
theme="plain"
readOnly
value={Languages.t(
'components.invitation.workspace_link.text',
[],
'Workspace invitation link',
)}
/>
)}
prefix={LinkIcon}
/>
<Button
className="h-7 2 rounded-l-none -ml-px"
theme="primary"
icon={CopyIcon}
onClick={() => copyLink()}
loading={loading}
disabled={loading}
>
{Languages.t('components.invitation.workspace_link.copy', [], 'Copy')}
</Button>
</div>
);
};