Skip to content

Commit

Permalink
feat: add group member invite modal (#375)
Browse files Browse the repository at this point in the history
* feat: add `enter` key support in email login

* feat: filter org and group roles in invite memeber modal

* fix: show project roles in project team invite modal

* fix: add title to table headers

* feat: add modal to add user to the team

* chore: disable team dropdown if org role is selected.

* fix: add max height to members dropdown

* feat: add enter support in otp verify screen

* chore: add group policy if role is other than member
  • Loading branch information
rsbh authored Oct 6, 2023
1 parent 3effdc0 commit ef611ab
Show file tree
Hide file tree
Showing 16 changed files with 478 additions and 246 deletions.
2 changes: 1 addition & 1 deletion sdks/js/packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
},
"dependencies": {
"@hookform/resolvers": "^3.3.1",
"@raystack/apsara": "0.11.2",
"@raystack/apsara": "0.11.3",
"@tanstack/react-router": "0.0.1-beta.174",
"axios": "^1.5.0",
"class-variance-authority": "^0.7.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { hasWindow } from '~/utils/index';
// @ts-ignore
import styles from './onboarding.module.css';


type MagicLinkVerifyProps = ComponentPropsWithRef<typeof Container> & {
logo?: React.ReactNode;
title?: string;
Expand Down Expand Up @@ -51,32 +50,36 @@ export const MagicLinkVerify = ({
codeParam && setCodeParam(codeParam);
}, []);

const OTPVerifyClickHandler = useCallback(async () => {
setLoading(true);
try {
if (!client) return;
const OTPVerifyHandler = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
if (!client) return;

await client.frontierServiceAuthCallback({
strategyName: 'mailotp',
code: otp,
state: stateParam
});
await client.frontierServiceAuthCallback({
strategyName: 'mailotp',
code: otp,
state: stateParam
});

const searchParams = new URLSearchParams(
hasWindow() ? window.location.search : ``
);
const redirectURL =
searchParams.get('redirect_uri') || searchParams.get('redirectURL');
const searchParams = new URLSearchParams(
hasWindow() ? window.location.search : ``
);
const redirectURL =
searchParams.get('redirect_uri') || searchParams.get('redirectURL');

// @ts-ignore
window.location = redirectURL ? redirectURL : window.location.origin;
} catch (error) {
console.log(error);
setSubmitError('Please enter a valid verification code');
} finally {
setLoading(false);
}
}, [otp]);
// @ts-ignore
window.location = redirectURL ? redirectURL : window.location.origin;
} catch (error) {
console.log(error);
setSubmitError('Please enter a valid verification code');
} finally {
setLoading(false);
}
},
[otp]
);

return (
<Container {...props}>
Expand All @@ -99,7 +102,11 @@ export const MagicLinkVerify = ({
<Text>Enter code manually</Text>
</Button>
) : (
<Flex direction={'column'} className={styles.container80} gap="medium">
<form
onSubmit={OTPVerifyHandler}
className={styles.container80}
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
>
<Flex direction="column">
<TextField
// @ts-ignore
Expand All @@ -117,11 +124,13 @@ export const MagicLinkVerify = ({
variant="primary"
className={styles.container}
disabled={!otp}
onClick={OTPVerifyClickHandler}
type="submit"
>
<Text className={styles.continue}>Continue with login code</Text>
<Text className={styles.continue}>
{loading ? 'Submitting...' : 'Continue with login code'}
</Text>
</Button>
</Flex>
</form>
)}
<Link href={config.redirectLogin}>
<Text size={2}>Back to login</Text>
Expand Down
53 changes: 30 additions & 23 deletions sdks/js/packages/core/react/components/onboarding/magiclink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,32 @@ export const MagicLink = ({ children, ...props }: MagicLinkProps) => {
const [email, setEmail] = useState<string>('');
const [state, setState] = useState<string>('');

const magicLinkClickHandler = useCallback(async () => {
setLoading(true);
try {
if (!client) return;
const magicLinkHandler = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
if (!client) return;

const {
data: { state = '' }
} = await client.frontierServiceAuthenticate('mailotp', {
email,
callbackUrl: config.callbackUrl
});
const {
data: { state = '' }
} = await client.frontierServiceAuthenticate('mailotp', {
email,
callbackUrl: config.callbackUrl
});

const searchParams = new URLSearchParams({ state, email });
const searchParams = new URLSearchParams({ state, email });

// @ts-ignore
window.location = `${
config.redirectMagicLinkVerify
}?${searchParams.toString()}`;
} finally {
setLoading(false);
}
}, [client, config.callbackUrl, config.redirectMagicLinkVerify, email]);
// @ts-ignore
window.location = `${
config.redirectMagicLinkVerify
}?${searchParams.toString()}`;
} finally {
setLoading(false);
}
},
[client, config.callbackUrl, config.redirectMagicLinkVerify, email]
);

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.target.value);
Expand All @@ -66,7 +70,10 @@ export const MagicLink = ({ children, ...props }: MagicLinkProps) => {
);

return (
<div style={{ ...styles.container, flexDirection: 'column' }}>
<form
style={{ ...styles.container, flexDirection: 'column' }}
onSubmit={magicLinkHandler}
>
<Separator />
<TextField
// @ts-ignore
Expand All @@ -85,12 +92,12 @@ export const MagicLink = ({ children, ...props }: MagicLinkProps) => {
...(!email ? styles.disabled : {})
}}
disabled={!email}
onClick={magicLinkClickHandler}
type="submit"
>
<Text style={{ color: 'var(--foreground-inverted)' }}>
{loading ? 'loading...' : 'Continue with Emails'}
{loading ? 'loading...' : 'Continue with Email'}
</Text>
</Button>
</div>
</form>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const getColumns: (
isLoading?: boolean
) => ColumnDef<V1Beta1Domain, any>[] = (canCreateDomain, isLoading) => [
{
header: 'Name',
accessorKey: 'name',
meta: {
style: {
Expand All @@ -29,6 +30,7 @@ export const getColumns: (
}
},
{
header: 'Created at',
accessorKey: 'created_at',
cell: isLoading
? () => <Skeleton />
Expand Down
107 changes: 68 additions & 39 deletions sdks/js/packages/core/react/components/organization/members/invite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,19 @@ import {

import { yupResolver } from '@hookform/resolvers/yup';
import { useNavigate } from '@tanstack/react-router';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import Skeleton from 'react-loading-skeleton';
import { toast } from 'sonner';
import * as yup from 'yup';
import cross from '~/react/assets/cross.svg';
import { useFrontier } from '~/react/contexts/FrontierContext';
import { V1Beta1Group, V1Beta1Role } from '~/src';
import { PERMISSIONS } from '~/utils';

const inviteSchema = yup.object({
type: yup.string().required(),
team: yup.string().required(),
team: yup.string(),
emails: yup.string().required()
});

Expand All @@ -44,35 +45,45 @@ export const InviteMember = () => {
const navigate = useNavigate({ from: '/members/modal' });
const { client, activeOrganization: organization } = useFrontier();

async function onSubmit({ emails, type, team }: InviteSchemaType) {
const emailList = emails
.split(',')
.map(e => e.trim())
.filter(str => str.length > 0);

if (!organization?.id) return;
if (!emailList.length) return;
if (!type) return;
if (!team) return;

try {
await client?.frontierServiceCreateOrganizationInvitation(
organization?.id,
{
userIds: emailList,
groupIds: [team],
roleIds: [type]
}
);
toast.success('memebers added');

navigate({ to: '/members' });
} catch ({ error }: any) {
toast.error('Something went wrong', {
description: error.message
});
}
}
const values = watch(['emails', 'team', 'type']);

const isGroupRole = useMemo(() => {
const role = values[2] && roles.find(r => r.id === values[2]);
return role && role.scopes?.includes(PERMISSIONS.GroupNamespace);
}, [roles, values]);

const onSubmit = useCallback(
async ({ emails, type, team }: InviteSchemaType) => {
const emailList = emails
.split(',')
.map(e => e.trim())
.filter(str => str.length > 0);

if (!organization?.id) return;
if (!emailList.length) return;
if (!type) return;
if (isGroupRole && !team) return;

try {
await client?.frontierServiceCreateOrganizationInvitation(
organization?.id,
{
userIds: emailList,
groupIds: isGroupRole && team ? [team] : undefined,
roleIds: [type]
}
);
toast.success('memebers added');

navigate({ to: '/members' });
} catch ({ error }: any) {
toast.error('Something went wrong', {
description: error.message
});
}
},
[client, navigate, organization?.id, isGroupRole]
);

useEffect(() => {
async function getInformation() {
Expand All @@ -83,11 +94,24 @@ export const InviteMember = () => {
const {
// @ts-ignore
data: { roles: orgRoles }
} = await client?.frontierServiceListOrganizationRoles(organization.id);
} = await client?.frontierServiceListOrganizationRoles(
organization.id,
{
scopes: [
PERMISSIONS.OrganizationNamespace,
PERMISSIONS.GroupNamespace
]
}
);
const {
// @ts-ignore
data: { roles }
} = await client?.frontierServiceListRoles();
} = await client?.frontierServiceListRoles({
scopes: [
PERMISSIONS.OrganizationNamespace,
PERMISSIONS.GroupNamespace
]
});
const {
// @ts-ignore
data: { groups }
Expand All @@ -105,17 +129,17 @@ export const InviteMember = () => {
getInformation();
}, [client, organization?.id]);

const values = watch(['emails', 'team', 'type']);

const isDisabled = useMemo(() => {
const [emails, team, type] = values;
const emailList =
emails
?.split(',')
.map((e: string) => e.trim())
.filter(str => str.length > 0) || [];
return emailList.length <= 0 || !team || !type || isSubmitting;
}, [isSubmitting, values]);
return (
emailList.length <= 0 || !type || isSubmitting || (isGroupRole && !team)
);
}, [isGroupRole, isSubmitting, values]);

return (
<Dialog open={true}>
Expand All @@ -141,7 +165,7 @@ export const InviteMember = () => {
gap="medium"
style={{ padding: '24px 32px' }}
>
<InputField label="Invite as">
<InputField label="Email">
<Controller
render={({ field }) => (
<textarea
Expand Down Expand Up @@ -211,8 +235,13 @@ export const InviteMember = () => {
<Skeleton height={'25px'} />
) : (
<Controller
rules={{ required: isGroupRole }}
render={({ field }) => (
<Select {...field} onValueChange={field.onChange}>
<Select
{...field}
onValueChange={field.onChange}
disabled={!isGroupRole}
>
<Select.Trigger className="w-[180px]">
<Select.Value placeholder="Select a team" />
</Select.Trigger>
Expand Down
Loading

0 comments on commit ef611ab

Please sign in to comment.