mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-04 13:48:56 +02:00
Feat/invite user (#2061)
* refactor: user creation screen cleanup * feat: deprecation notice for google sso * fix: docs openid typo * invite link bar * invite link page * fix prettier docs * regenerated openapi * hooks for invite page api * update openapi * feat: invite link update * feat: add public signup token soft-delete * public signup frontend feature flag * fix: new user api issues * feat: allow for creating new user from invite link * Feat/invite user public controller (#2106) * added PublicInviteController for public urls * added PublicInviteController for public urls * added PublicInviteController for public urls * added PublicInviteController for public urls * fix test * fix test * update openapi * refactor: password reset props * fix: public invite schema and validation Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com> * user invite frontend Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com> * invite link delete confirmation dialog * refactor: password reset action * fix: new user invite loading state * fix: run ts check with ci * revert openapi changes * fix: invite token api interface * fix: openapi schema index * fix: update test snapshots * update frontend snapshot * fix: prettier ci * fix: updates after review Co-authored-by: andreas-unleash <104830839+andreas-unleash@users.noreply.github.com> Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
This commit is contained in:
parent
5e2d96593a
commit
47152cf05b
1
.github/workflows/build_frontend_prs.yml
vendored
1
.github/workflows/build_frontend_prs.yml
vendored
@ -23,3 +23,4 @@ jobs:
|
|||||||
- run: yarn --frozen-lockfile
|
- run: yarn --frozen-lockfile
|
||||||
- run: yarn run test
|
- run: yarn run test
|
||||||
- run: yarn run fmt:check
|
- run: yarn run fmt:check
|
||||||
|
- run: yarn run ts:check # TODO: optimize
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
"test:watch": "vitest watch",
|
"test:watch": "vitest watch",
|
||||||
"fmt": "prettier src --write --loglevel warn",
|
"fmt": "prettier src --write --loglevel warn",
|
||||||
"fmt:check": "prettier src --check",
|
"fmt:check": "prettier src --check",
|
||||||
|
"ts:check": "tsc",
|
||||||
"e2e": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=admin,AUTH_PASSWORD=unleash4all",
|
"e2e": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=admin,AUTH_PASSWORD=unleash4all",
|
||||||
"e2e:heroku": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=example@example.com",
|
"e2e:heroku": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=example@example.com",
|
||||||
"prepare": "yarn run build"
|
"prepare": "yarn run build"
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
import { makeStyles } from 'tss-react/mui';
|
|
||||||
|
|
||||||
export const useStyles = makeStyles()({
|
|
||||||
iconContainer: {
|
|
||||||
width: '100%',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
emailIcon: {
|
|
||||||
margin: '2rem auto',
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,9 +1,8 @@
|
|||||||
import { Typography } from '@mui/material';
|
import { Box, Typography } from '@mui/material';
|
||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
|
||||||
import { ReactComponent as EmailIcon } from 'assets/icons/email.svg';
|
import { ReactComponent as EmailIcon } from 'assets/icons/email.svg';
|
||||||
import { useStyles } from './ConfirmUserEmail.styles';
|
import { LinkField } from '../../LinkField/LinkField';
|
||||||
import UserInviteLink from '../ConfirmUserLink/UserInviteLink/UserInviteLink';
|
|
||||||
|
|
||||||
interface IConfirmUserEmailProps {
|
interface IConfirmUserEmailProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -15,32 +14,29 @@ const ConfirmUserEmail = ({
|
|||||||
open,
|
open,
|
||||||
closeConfirm,
|
closeConfirm,
|
||||||
inviteLink,
|
inviteLink,
|
||||||
}: IConfirmUserEmailProps) => {
|
}: IConfirmUserEmailProps) => (
|
||||||
const { classes: styles } = useStyles();
|
<Dialogue
|
||||||
return (
|
open={open}
|
||||||
<Dialogue
|
title="Team member added"
|
||||||
open={open}
|
primaryButtonText="Close"
|
||||||
title="Team member added"
|
onClick={closeConfirm}
|
||||||
primaryButtonText="Close"
|
>
|
||||||
onClick={closeConfirm}
|
<Typography>
|
||||||
>
|
A new team member has been added. We’ve sent an email on your behalf
|
||||||
<Typography>
|
to inform them of their new account and role. No further steps are
|
||||||
A new team member has been added. We’ve sent an email on your
|
required.
|
||||||
behalf to inform them of their new account and role. No further
|
</Typography>
|
||||||
steps are required.
|
<Box sx={{ width: '100%', textAlign: 'center', px: 'auto', py: 4 }}>
|
||||||
</Typography>
|
<EmailIcon />
|
||||||
<div className={styles.iconContainer}>
|
</Box>
|
||||||
<EmailIcon className={styles.emailIcon} />
|
<Typography style={{ fontWeight: 'bold' }} variant="subtitle1">
|
||||||
</div>
|
In a rush?
|
||||||
<Typography style={{ fontWeight: 'bold' }} variant="subtitle1">
|
</Typography>
|
||||||
In a rush?
|
<Typography>
|
||||||
</Typography>
|
You may also copy the invite link and send it to the user.
|
||||||
<Typography>
|
</Typography>
|
||||||
You may also copy the invite link and send it to the user.
|
<LinkField inviteLink={inviteLink} />
|
||||||
</Typography>
|
</Dialogue>
|
||||||
<UserInviteLink inviteLink={inviteLink} />
|
);
|
||||||
</Dialogue>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ConfirmUserEmail;
|
export default ConfirmUserEmail;
|
||||||
|
@ -2,7 +2,7 @@ import { Typography } from '@mui/material';
|
|||||||
import { Alert } from '@mui/material';
|
import { Alert } from '@mui/material';
|
||||||
import { useThemeStyles } from 'themes/themeStyles';
|
import { useThemeStyles } from 'themes/themeStyles';
|
||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
import UserInviteLink from './UserInviteLink/UserInviteLink';
|
import { LinkField } from '../../LinkField/LinkField';
|
||||||
|
|
||||||
interface IConfirmUserLink {
|
interface IConfirmUserLink {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -28,7 +28,7 @@ const ConfirmUserLink = ({
|
|||||||
A new team member has been added. Please provide them with
|
A new team member has been added. Please provide them with
|
||||||
the following link to get started:
|
the following link to get started:
|
||||||
</Typography>
|
</Typography>
|
||||||
<UserInviteLink inviteLink={inviteLink} />
|
<LinkField inviteLink={inviteLink} />
|
||||||
|
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
Copy the link and send it to the user. This will allow them
|
Copy the link and send it to the user. This will allow them
|
||||||
|
248
frontend/src/component/admin/users/InviteLink/InviteLink.tsx
Normal file
248
frontend/src/component/admin/users/InviteLink/InviteLink.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { FormEventHandler, useState, VFC } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useSWRConfig } from 'swr';
|
||||||
|
import { Box, Button, Typography } from '@mui/material';
|
||||||
|
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
|
||||||
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
|
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||||
|
import { url as inviteTokensUrlKey } from 'hooks/api/getters/useInviteTokens/useInviteTokens';
|
||||||
|
import { add } from 'date-fns';
|
||||||
|
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
||||||
|
import { GO_BACK } from 'constants/navigate';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
import { useInviteTokenApi } from 'hooks/api/actions/useInviteTokenApi/useInviteTokenApi';
|
||||||
|
import { useInviteTokens } from 'hooks/api/getters/useInviteTokens/useInviteTokens';
|
||||||
|
import { LinkField } from '../LinkField/LinkField';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
|
interface ICreateInviteLinkProps {}
|
||||||
|
|
||||||
|
const expiryOptions = [
|
||||||
|
{
|
||||||
|
key: add(new Date(), { hours: 48 }).toISOString(),
|
||||||
|
label: '48 hours',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: add(new Date(), { weeks: 1 }).toISOString(),
|
||||||
|
label: '1 week',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: add(new Date(), { months: 1 }).toISOString(),
|
||||||
|
label: '1 month',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useFormatApiCode = (isUpdating: boolean, expiry: string) => {
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
|
if (isUpdating) {
|
||||||
|
return () => `curl --location --request PUT '${
|
||||||
|
uiConfig.unleashUrl
|
||||||
|
}/api/admin/invite-link/tokens/default' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data-raw '${JSON.stringify({ expiresAt: expiry }, undefined, 2)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => `curl --location --request POST '${
|
||||||
|
uiConfig.unleashUrl
|
||||||
|
}/api/admin/invite-link/tokens' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data-raw '${JSON.stringify(
|
||||||
|
{ name: 'default', expiresAt: expiry },
|
||||||
|
undefined,
|
||||||
|
2
|
||||||
|
)}'`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InviteLink: VFC<ICreateInviteLinkProps> = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, loading } = useInviteTokens();
|
||||||
|
const [inviteLink, setInviteLink] = useState('');
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
const [expiry, setExpiry] = useState(expiryOptions[0].key);
|
||||||
|
const [showDisableDialog, setDisableDialogue] = useState(false);
|
||||||
|
const defaultToken = data?.tokens?.find(token => token.name === 'default');
|
||||||
|
const isUpdating = Boolean(defaultToken);
|
||||||
|
const formatApiCode = useFormatApiCode(isUpdating, expiry);
|
||||||
|
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const { setToastApiError } = useToast();
|
||||||
|
const { createToken, updateToken } = useInviteTokenApi();
|
||||||
|
|
||||||
|
const onSubmit: FormEventHandler<HTMLFormElement> = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSending(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isUpdating) {
|
||||||
|
await updateToken(defaultToken!.secret, {
|
||||||
|
expiresAt: expiry,
|
||||||
|
});
|
||||||
|
setInviteLink(defaultToken!.url);
|
||||||
|
} else {
|
||||||
|
const response = await createToken({
|
||||||
|
name: 'default',
|
||||||
|
expiresAt: expiry,
|
||||||
|
});
|
||||||
|
const newToken = await response.json();
|
||||||
|
setInviteLink(newToken.url);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
mutate(inviteTokensUrlKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDisableConfirmed = async () => {
|
||||||
|
setIsSending(true);
|
||||||
|
try {
|
||||||
|
await updateToken(defaultToken!.secret, {
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
navigate(GO_BACK);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDisableClick = async () => {
|
||||||
|
setDisableDialogue(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeConfirm = () => {
|
||||||
|
setInviteLink('');
|
||||||
|
navigate('/admin/users');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormTemplate
|
||||||
|
loading={loading || isSending}
|
||||||
|
title={isUpdating ? 'Update invite link' : 'Create invite link'}
|
||||||
|
description="When you send an invite link to a someone, they will be able to create an account and get access to Unleash. This new user will only have read access, until you change their assigned role."
|
||||||
|
documentationLink="https://docs.getunleash.io/user_guide/rbac#standard-roles" // FIXME: update
|
||||||
|
documentationLinkLabel="User management documentation"
|
||||||
|
formatApiCode={formatApiCode}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
data-testid="create-invite-link-form"
|
||||||
|
>
|
||||||
|
<Box sx={{ maxWidth: '400px' }}>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography sx={{ mb: 1 }}>
|
||||||
|
Expiration period for the invite link
|
||||||
|
</Typography>
|
||||||
|
<GeneralSelect
|
||||||
|
label="Link will expire in"
|
||||||
|
name="type"
|
||||||
|
options={expiryOptions}
|
||||||
|
value={expiry}
|
||||||
|
onChange={setExpiry}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ mb: 1 }}>
|
||||||
|
People using this link will be invited as:
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: theme =>
|
||||||
|
`${theme.shape.borderRadiusMedium}px`,
|
||||||
|
backgroundColor: theme =>
|
||||||
|
theme.palette.secondaryContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" fontWeight="bold">
|
||||||
|
Viewer
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Users with this role can only read root resources in
|
||||||
|
Unleash. The viewer can be added to specific
|
||||||
|
projects as project member. Viewers may not view API
|
||||||
|
tokens.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
mt: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PermissionButton
|
||||||
|
type="submit"
|
||||||
|
permission={ADMIN}
|
||||||
|
disabled={isSending}
|
||||||
|
>
|
||||||
|
{isUpdating
|
||||||
|
? 'Update invite link'
|
||||||
|
: 'Create invite link'}
|
||||||
|
</PermissionButton>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={isUpdating}
|
||||||
|
show={
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 2 }}
|
||||||
|
onClick={onDisableClick}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
Delete link
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 2 }}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(GO_BACK);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Dialogue
|
||||||
|
open={Boolean(inviteLink)}
|
||||||
|
onClick={closeConfirm}
|
||||||
|
primaryButtonText="Close"
|
||||||
|
title="Invite link created"
|
||||||
|
>
|
||||||
|
<Box sx={{ pt: 2 }}>
|
||||||
|
<Typography variant="body1">
|
||||||
|
New team members now sign-up to Unleash. Please provide
|
||||||
|
them with the following link to get started:
|
||||||
|
</Typography>
|
||||||
|
<LinkField inviteLink={inviteLink} />
|
||||||
|
|
||||||
|
<Typography variant="body1">
|
||||||
|
Copy the link and send it to the user. This will allow
|
||||||
|
them to set up their password and get started with their
|
||||||
|
Unleash account.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Dialogue>
|
||||||
|
<Dialogue
|
||||||
|
open={showDisableDialog}
|
||||||
|
onClose={() => setDisableDialogue(false)}
|
||||||
|
onClick={onDisableConfirmed}
|
||||||
|
title="Are you sure you want to delete your invite link?"
|
||||||
|
/>
|
||||||
|
</FormTemplate>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,122 @@
|
|||||||
|
import { VFC } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Box, Button, Typography } from '@mui/material';
|
||||||
|
import useLoading from 'hooks/useLoading';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { useInviteTokens } from 'hooks/api/getters/useInviteTokens/useInviteTokens';
|
||||||
|
import { LinkField } from '../LinkField/LinkField';
|
||||||
|
import { add, formatDistanceToNowStrict, isAfter, parseISO } from 'date-fns';
|
||||||
|
import { formatDateYMD } from 'utils/formatDate';
|
||||||
|
import { useLocationSettings } from 'hooks/useLocationSettings';
|
||||||
|
|
||||||
|
export const InviteLinkBar: VFC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, loading } = useInviteTokens();
|
||||||
|
const ref = useLoading(loading);
|
||||||
|
const inviteToken =
|
||||||
|
data?.tokens?.find(token => token.name === 'default') ?? null;
|
||||||
|
const inviteLink = inviteToken?.url;
|
||||||
|
const createdAt = data?.tokens?.[0]?.createdAt ?? '';
|
||||||
|
const expiresAt = data?.tokens?.[0]?.expiresAt ?? '';
|
||||||
|
const expires = expiresAt || false;
|
||||||
|
const isExpired = Boolean(
|
||||||
|
expires && isAfter(new Date(), parseISO(expires))
|
||||||
|
);
|
||||||
|
const willExpireSoon =
|
||||||
|
expires && isAfter(add(new Date(), { days: 14 }), parseISO(expires));
|
||||||
|
const expiresIn = expires
|
||||||
|
? formatDistanceToNowStrict(parseISO(expires))
|
||||||
|
: false;
|
||||||
|
const { locationSettings } = useLocationSettings();
|
||||||
|
|
||||||
|
const expireDateComponent = (
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="body2"
|
||||||
|
color={willExpireSoon ? 'warning.main' : 'inherit'}
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{expiresIn}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'tertiary.background',
|
||||||
|
py: 2,
|
||||||
|
px: 4,
|
||||||
|
mb: 2,
|
||||||
|
borderRadius: theme => `${theme.shape.borderRadiusLarge}px`,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', md: 'row' },
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
}}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: { xs: 1, md: 0 },
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(inviteLink)}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
{`You have an invite link created on ${formatDateYMD(
|
||||||
|
createdAt,
|
||||||
|
locationSettings.locale
|
||||||
|
)} `}
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={isExpired}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
that expired {expireDateComponent}{' '}
|
||||||
|
ago
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<>
|
||||||
|
that will expire in{' '}
|
||||||
|
{expireDateComponent}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
<LinkField small inviteLink={inviteLink!} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<Typography variant="body2" data-loading>
|
||||||
|
You can easily create an invite link here that you
|
||||||
|
can share and use to invite people from your company
|
||||||
|
to your Unleash setup.
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minWidth: 200,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: { xs: 'center', md: 'flex-end' },
|
||||||
|
alignItems: 'center',
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => navigate('/admin/invite-link')}
|
||||||
|
data-loading
|
||||||
|
>
|
||||||
|
{inviteLink ? 'Update' : 'Create'} invite link
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,42 @@
|
|||||||
|
import { Box, Typography } from '@mui/material';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
|
||||||
|
import { ReactComponent as EmailIcon } from 'assets/icons/email.svg';
|
||||||
|
import { LinkField } from '../LinkField';
|
||||||
|
|
||||||
|
interface IConfirmUserEmailProps {
|
||||||
|
open: boolean;
|
||||||
|
closeConfirm: () => void;
|
||||||
|
inviteLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmUserEmail = ({
|
||||||
|
open,
|
||||||
|
closeConfirm,
|
||||||
|
inviteLink,
|
||||||
|
}: IConfirmUserEmailProps) => (
|
||||||
|
<Dialogue
|
||||||
|
open={open}
|
||||||
|
title="Team member added"
|
||||||
|
primaryButtonText="Close"
|
||||||
|
onClick={closeConfirm}
|
||||||
|
>
|
||||||
|
<Typography>
|
||||||
|
A new team member has been added. We’ve sent an email on your behalf
|
||||||
|
to inform them of their new account and role. No further steps are
|
||||||
|
required.
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ width: '100%', textAlign: 'center', px: 'auto', py: 4 }}>
|
||||||
|
<EmailIcon />
|
||||||
|
</Box>
|
||||||
|
<Typography style={{ fontWeight: 'bold' }} variant="subtitle1">
|
||||||
|
In a rush?
|
||||||
|
</Typography>
|
||||||
|
<Typography>
|
||||||
|
You may also copy the invite link and send it to the user.
|
||||||
|
</Typography>
|
||||||
|
<LinkField inviteLink={inviteLink} />
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ConfirmUserEmail;
|
@ -1,12 +1,13 @@
|
|||||||
import { IconButton, Tooltip } from '@mui/material';
|
import { Box, IconButton, Tooltip } from '@mui/material';
|
||||||
import CopyIcon from '@mui/icons-material/FileCopy';
|
import CopyIcon from '@mui/icons-material/FileCopy';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
|
|
||||||
interface IInviteLinkProps {
|
interface ILinkFieldProps {
|
||||||
inviteLink: string;
|
inviteLink: string;
|
||||||
|
small?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserInviteLink = ({ inviteLink }: IInviteLinkProps) => {
|
export const LinkField = ({ inviteLink, small }: ILinkFieldProps) => {
|
||||||
const { setToastData } = useToast();
|
const { setToastData } = useToast();
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
@ -34,26 +35,38 @@ const UserInviteLink = ({ inviteLink }: IInviteLinkProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
backgroundColor: '#efefef',
|
backgroundColor: theme => theme.palette.secondaryContainer,
|
||||||
padding: '2rem',
|
py: 4,
|
||||||
borderRadius: '3px',
|
px: 4,
|
||||||
margin: '1rem 0',
|
borderRadius: theme => `${theme.shape.borderRadius}px`,
|
||||||
|
my: 2,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
|
...(small
|
||||||
|
? {
|
||||||
|
my: 0,
|
||||||
|
py: 0.5,
|
||||||
|
pl: 1.5,
|
||||||
|
pr: 0.5,
|
||||||
|
fontSize: theme => theme.typography.body2.fontSize,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{inviteLink}
|
{inviteLink}
|
||||||
<Tooltip title="Copy link" arrow>
|
<Tooltip title="Copy link" arrow>
|
||||||
<IconButton onClick={handleCopy} size="large">
|
<IconButton
|
||||||
<CopyIcon />
|
onClick={handleCopy}
|
||||||
|
size={small ? 'small' : 'large'}
|
||||||
|
sx={small ? { ml: 0.5 } : {}}
|
||||||
|
>
|
||||||
|
<CopyIcon sx={{ fontSize: small ? 20 : undefined }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UserInviteLink;
|
|
@ -5,13 +5,20 @@ import AccessContext from 'contexts/AccessContext';
|
|||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||||
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
|
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
|
||||||
|
import { InviteLinkBar } from './InviteLinkBar/InviteLinkBar';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
|
||||||
const UsersAdmin = () => {
|
const UsersAdmin = () => {
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AdminMenu />
|
<AdminMenu />
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(uiConfig?.flags?.publicSignup)}
|
||||||
|
show={<InviteLinkBar />}
|
||||||
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={hasAccess(ADMIN)}
|
condition={hasAccess(ADMIN)}
|
||||||
show={<UsersList />}
|
show={<UsersList />}
|
||||||
|
@ -246,7 +246,7 @@ const UsersList = () => {
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => navigate('/admin/create-user')}
|
onClick={() => navigate('/admin/create-user')}
|
||||||
>
|
>
|
||||||
New user
|
Add new user
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
@ -42,19 +42,6 @@ const GeneralSelect: React.FC<IGeneralSelectProps> = ({
|
|||||||
fullWidth,
|
fullWidth,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const renderSelectItems = () =>
|
|
||||||
options.map(option => (
|
|
||||||
<MenuItem
|
|
||||||
key={option.key}
|
|
||||||
value={option.key}
|
|
||||||
title={option.title || ''}
|
|
||||||
data-testid={`${SELECT_ITEM_ID}-${option.label}`}
|
|
||||||
disabled={option.disabled}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</MenuItem>
|
|
||||||
));
|
|
||||||
|
|
||||||
const onSelectChange = (event: SelectChangeEvent) => {
|
const onSelectChange = (event: SelectChangeEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onChange(String(event.target.value));
|
onChange(String(event.target.value));
|
||||||
@ -79,7 +66,17 @@ const GeneralSelect: React.FC<IGeneralSelectProps> = ({
|
|||||||
IconComponent={KeyboardArrowDownOutlined}
|
IconComponent={KeyboardArrowDownOutlined}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{renderSelectItems()}
|
{options.map(option => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.key}
|
||||||
|
value={option.key}
|
||||||
|
title={option.title || ''}
|
||||||
|
data-testid={`${SELECT_ITEM_ID}-${option.label}`}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
);
|
);
|
||||||
|
@ -385,6 +385,14 @@ exports[`returns all baseRoutes 1`] = `
|
|||||||
"title": "Users",
|
"title": "Users",
|
||||||
"type": "protected",
|
"type": "protected",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component": [Function],
|
||||||
|
"menu": {},
|
||||||
|
"parent": "/admin",
|
||||||
|
"path": "/admin/invite-link",
|
||||||
|
"title": "Invite link",
|
||||||
|
"type": "protected",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component": [Function],
|
"component": [Function],
|
||||||
"flag": "UG",
|
"flag": "UG",
|
||||||
|
@ -57,6 +57,7 @@ import { CreateGroup } from 'component/admin/groups/CreateGroup/CreateGroup';
|
|||||||
import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup';
|
import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup';
|
||||||
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
|
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
|
||||||
import { CorsAdmin } from 'component/admin/cors';
|
import { CorsAdmin } from 'component/admin/cors';
|
||||||
|
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
|
||||||
|
|
||||||
export const routes: IRoute[] = [
|
export const routes: IRoute[] = [
|
||||||
// Splash
|
// Splash
|
||||||
@ -434,6 +435,14 @@ export const routes: IRoute[] = [
|
|||||||
type: 'protected',
|
type: 'protected',
|
||||||
menu: {},
|
menu: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/invite-link',
|
||||||
|
parent: '/admin',
|
||||||
|
title: 'Invite link',
|
||||||
|
component: InviteLink,
|
||||||
|
type: 'protected',
|
||||||
|
menu: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/groups',
|
path: '/admin/groups',
|
||||||
parent: '/admin',
|
parent: '/admin',
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { Alert, AlertTitle } from '@mui/material';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { useStyles } from 'component/user/Login/Login.styles';
|
import { useStyles } from 'component/user/Login/Login.styles';
|
||||||
import useQueryParams from 'hooks/useQueryParams';
|
import useQueryParams from 'hooks/useQueryParams';
|
||||||
import ResetPasswordSuccess from '../common/ResetPasswordSuccess/ResetPasswordSuccess';
|
|
||||||
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
|
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
|
||||||
import { DEMO_TYPE } from 'constants/authTypes';
|
import { DEMO_TYPE } from 'constants/authTypes';
|
||||||
import Authentication from '../Authentication/Authentication';
|
import Authentication from '../Authentication/Authentication';
|
||||||
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
|
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
|
||||||
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
|
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
|
||||||
import { Navigate } from 'react-router-dom';
|
|
||||||
import { parseRedirectParam } from 'component/user/Login/parseRedirectParam';
|
import { parseRedirectParam } from 'component/user/Login/parseRedirectParam';
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
@ -16,6 +16,7 @@ const Login = () => {
|
|||||||
const { user } = useAuthUser();
|
const { user } = useAuthUser();
|
||||||
const query = useQueryParams();
|
const query = useQueryParams();
|
||||||
const resetPassword = query.get('reset') === 'true';
|
const resetPassword = query.get('reset') === 'true';
|
||||||
|
const invited = query.get('invited') === 'true';
|
||||||
const redirect = query.get('redirect') || '/';
|
const redirect = query.get('redirect') || '/';
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
@ -25,6 +26,24 @@ const Login = () => {
|
|||||||
return (
|
return (
|
||||||
<StandaloneLayout>
|
<StandaloneLayout>
|
||||||
<div className={styles.loginFormContainer}>
|
<div className={styles.loginFormContainer}>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={resetPassword}
|
||||||
|
show={
|
||||||
|
<Alert severity="success" sx={{ mb: 4 }}>
|
||||||
|
<AlertTitle>Success</AlertTitle>
|
||||||
|
You successfully reset your password.
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={invited}
|
||||||
|
show={
|
||||||
|
<Alert severity="success" sx={{ mb: 4 }}>
|
||||||
|
<AlertTitle>Success</AlertTitle>
|
||||||
|
Your account has been created.
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={authDetails?.type !== DEMO_TYPE}
|
condition={authDetails?.type !== DEMO_TYPE}
|
||||||
show={
|
show={
|
||||||
@ -33,11 +52,6 @@ const Login = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={resetPassword}
|
|
||||||
show={<ResetPasswordSuccess />}
|
|
||||||
/>
|
|
||||||
<Authentication redirect={redirect} />
|
<Authentication redirect={redirect} />
|
||||||
</div>
|
</div>
|
||||||
</StandaloneLayout>
|
</StandaloneLayout>
|
||||||
|
@ -1,27 +1,82 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Box, TextField, Typography } from '@mui/material';
|
import { Box, TextField, Typography } from '@mui/material';
|
||||||
|
import { CREATED, OK } from 'constants/statusCodes';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
import useResetPassword from 'hooks/api/getters/useResetPassword/useResetPassword';
|
import useResetPassword from 'hooks/api/getters/useResetPassword/useResetPassword';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { useUserInvite } from 'hooks/api/getters/useUserInvite/useUserInvite';
|
||||||
|
import { useInviteTokenApi } from 'hooks/api/actions/useInviteTokenApi/useInviteTokenApi';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
import { useAuthResetPasswordApi } from 'hooks/api/actions/useAuthResetPasswordApi/useAuthResetPasswordApi';
|
||||||
import AuthOptions from '../common/AuthOptions/AuthOptions';
|
import AuthOptions from '../common/AuthOptions/AuthOptions';
|
||||||
import DividerText from 'component/common/DividerText/DividerText';
|
import DividerText from 'component/common/DividerText/DividerText';
|
||||||
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
|
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
|
||||||
import { useInviteUserToken } from 'hooks/api/getters/useInviteUserToken/useInviteUserToken';
|
|
||||||
import ResetPasswordForm from '../common/ResetPasswordForm/ResetPasswordForm';
|
import ResetPasswordForm from '../common/ResetPasswordForm/ResetPasswordForm';
|
||||||
import InvalidToken from '../common/InvalidToken/InvalidToken';
|
import InvalidToken from '../common/InvalidToken/InvalidToken';
|
||||||
import { NewUserWrapper } from './NewUserWrapper/NewUserWrapper';
|
import { NewUserWrapper } from './NewUserWrapper/NewUserWrapper';
|
||||||
|
import ResetPasswordError from '../common/ResetPasswordError/ResetPasswordError';
|
||||||
|
|
||||||
export const NewUser = () => {
|
export const NewUser = () => {
|
||||||
const { authDetails } = useAuthDetails();
|
const { authDetails } = useAuthDetails();
|
||||||
|
const { setToastApiError } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [apiError, setApiError] = useState(false);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
const {
|
const {
|
||||||
token,
|
token,
|
||||||
data,
|
data: passwordResetData,
|
||||||
loading: resetLoading,
|
loading: resetLoading,
|
||||||
setLoading,
|
isValidToken,
|
||||||
invalidToken,
|
|
||||||
} = useResetPassword();
|
} = useResetPassword();
|
||||||
const { invite, loading: inviteLoading } = useInviteUserToken();
|
const {
|
||||||
|
secret,
|
||||||
|
loading: inviteLoading,
|
||||||
|
isValid: isValidInvite,
|
||||||
|
} = useUserInvite();
|
||||||
|
const { addUser, loading: isUserSubmitting } = useInviteTokenApi();
|
||||||
|
const { resetPassword, loading: isPasswordSubmitting } =
|
||||||
|
useAuthResetPasswordApi();
|
||||||
const passwordDisabled = authDetails?.defaultHidden === true;
|
const passwordDisabled = authDetails?.defaultHidden === true;
|
||||||
|
|
||||||
if (invalidToken && !invite) {
|
const onSubmitInvitedUser = async (password: string) => {
|
||||||
|
try {
|
||||||
|
const res = await addUser(secret, { name, email, password });
|
||||||
|
if (res.status === CREATED) {
|
||||||
|
navigate('/login?invited=true');
|
||||||
|
} else {
|
||||||
|
setToastApiError(
|
||||||
|
"Couldn't create user. Check if your invite link is valid."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmitPasswordReset = async (password: string) => {
|
||||||
|
try {
|
||||||
|
const res = await resetPassword({ token, password });
|
||||||
|
if (res.status === OK) {
|
||||||
|
navigate('/login?reset=true');
|
||||||
|
} else {
|
||||||
|
setApiError(true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setApiError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (password: string) => {
|
||||||
|
if (isValidInvite) {
|
||||||
|
onSubmitInvitedUser(password);
|
||||||
|
} else {
|
||||||
|
onSubmitPasswordReset(password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isValidToken === false && isValidInvite == false) {
|
||||||
return (
|
return (
|
||||||
<NewUserWrapper loading={resetLoading || inviteLoading}>
|
<NewUserWrapper loading={resetLoading || inviteLoading}>
|
||||||
<InvalidToken />
|
<InvalidToken />
|
||||||
@ -31,7 +86,12 @@ export const NewUser = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NewUserWrapper
|
<NewUserWrapper
|
||||||
loading={resetLoading || inviteLoading}
|
loading={
|
||||||
|
resetLoading ||
|
||||||
|
inviteLoading ||
|
||||||
|
isUserSubmitting ||
|
||||||
|
isPasswordSubmitting
|
||||||
|
}
|
||||||
title={
|
title={
|
||||||
passwordDisabled
|
passwordDisabled
|
||||||
? 'Connect your account and start your journey'
|
? 'Connect your account and start your journey'
|
||||||
@ -39,14 +99,14 @@ export const NewUser = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={data?.createdBy}
|
condition={passwordResetData?.createdBy}
|
||||||
show={
|
show={
|
||||||
<Typography
|
<Typography
|
||||||
variant="body1"
|
variant="body1"
|
||||||
data-loading
|
data-loading
|
||||||
sx={{ textAlign: 'center', mb: 2 }}
|
sx={{ textAlign: 'center', mb: 2 }}
|
||||||
>
|
>
|
||||||
{data?.createdBy}
|
{passwordResetData?.createdBy}
|
||||||
<br /> has invited you to join Unleash.
|
<br /> has invited you to join Unleash.
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
@ -82,7 +142,7 @@ export const NewUser = () => {
|
|||||||
show={
|
show={
|
||||||
<>
|
<>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={data?.email}
|
condition={passwordResetData?.email}
|
||||||
show={() => (
|
show={() => (
|
||||||
<Typography
|
<Typography
|
||||||
data-loading
|
data-loading
|
||||||
@ -96,22 +156,32 @@ export const NewUser = () => {
|
|||||||
<TextField
|
<TextField
|
||||||
data-loading
|
data-loading
|
||||||
type="email"
|
type="email"
|
||||||
value={data?.email || ''}
|
value={
|
||||||
id="username"
|
isValidToken
|
||||||
|
? passwordResetData?.email || ''
|
||||||
|
: email
|
||||||
|
}
|
||||||
|
id="email"
|
||||||
label="Email"
|
label="Email"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ my: 1 }}
|
sx={{ my: 1 }}
|
||||||
disabled={Boolean(data?.email)}
|
disabled={isValidToken}
|
||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
|
onChange={e => {
|
||||||
|
if (isValidToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmail(e.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(invite)}
|
condition={Boolean(isValidInvite)}
|
||||||
show={() => (
|
show={() => (
|
||||||
<TextField
|
<TextField
|
||||||
data-loading
|
data-loading
|
||||||
value=""
|
value={name}
|
||||||
id="username"
|
id="username"
|
||||||
label="Full name"
|
label="Full name"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -119,16 +189,20 @@ export const NewUser = () => {
|
|||||||
sx={{ my: 1 }}
|
sx={{ my: 1 }}
|
||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
|
onChange={e => {
|
||||||
|
setName(e.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Typography variant="body1" data-loading sx={{ mt: 2 }}>
|
<Typography variant="body1" data-loading sx={{ mt: 2 }}>
|
||||||
Set a password for your account.
|
Set a password for your account.
|
||||||
</Typography>
|
</Typography>
|
||||||
<ResetPasswordForm
|
<ConditionallyRender
|
||||||
token={token}
|
condition={apiError && isValidToken}
|
||||||
setLoading={setLoading}
|
show={<ResetPasswordError />}
|
||||||
/>
|
/>
|
||||||
|
<ResetPasswordForm onSubmit={onSubmit} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { OK } from 'constants/statusCodes';
|
||||||
import useLoading from 'hooks/useLoading';
|
import useLoading from 'hooks/useLoading';
|
||||||
import { useStyles } from './ResetPassword.styles';
|
import { useStyles } from './ResetPassword.styles';
|
||||||
import { Typography } from '@mui/material';
|
import { Typography } from '@mui/material';
|
||||||
@ -6,18 +9,37 @@ import InvalidToken from '../common/InvalidToken/InvalidToken';
|
|||||||
import useResetPassword from 'hooks/api/getters/useResetPassword/useResetPassword';
|
import useResetPassword from 'hooks/api/getters/useResetPassword/useResetPassword';
|
||||||
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
|
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
|
||||||
import ResetPasswordForm from '../common/ResetPasswordForm/ResetPasswordForm';
|
import ResetPasswordForm from '../common/ResetPasswordForm/ResetPasswordForm';
|
||||||
|
import ResetPasswordError from '../common/ResetPasswordError/ResetPasswordError';
|
||||||
|
import { useAuthResetPasswordApi } from 'hooks/api/actions/useAuthResetPasswordApi/useAuthResetPasswordApi';
|
||||||
|
|
||||||
const ResetPassword = () => {
|
const ResetPassword = () => {
|
||||||
const { classes: styles } = useStyles();
|
const { classes: styles } = useStyles();
|
||||||
const { token, loading, setLoading, invalidToken } = useResetPassword();
|
const { token, loading, setLoading, isValidToken } = useResetPassword();
|
||||||
const ref = useLoading(loading);
|
const { resetPassword, loading: actionLoading } = useAuthResetPasswordApi();
|
||||||
|
const ref = useLoading(loading || actionLoading);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [hasApiError, setHasApiError] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async (password: string) => {
|
||||||
|
try {
|
||||||
|
const res = await resetPassword({ token, password });
|
||||||
|
if (res.status === OK) {
|
||||||
|
navigate('/login?reset=true');
|
||||||
|
setHasApiError(false);
|
||||||
|
} else {
|
||||||
|
setHasApiError(true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setHasApiError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<StandaloneLayout>
|
<StandaloneLayout>
|
||||||
<div className={styles.resetPassword}>
|
<div className={styles.resetPassword}>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={invalidToken}
|
condition={!isValidToken}
|
||||||
show={<InvalidToken />}
|
show={<InvalidToken />}
|
||||||
elseShow={
|
elseShow={
|
||||||
<>
|
<>
|
||||||
@ -29,10 +51,11 @@ const ResetPassword = () => {
|
|||||||
Reset password
|
Reset password
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<ResetPasswordForm
|
<ConditionallyRender
|
||||||
token={token}
|
condition={hasApiError}
|
||||||
setLoading={setLoading}
|
show={<ResetPasswordError />}
|
||||||
/>
|
/>
|
||||||
|
<ResetPasswordForm onSubmit={onSubmit} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -6,11 +6,13 @@ import { useThemeStyles } from 'themes/themeStyles';
|
|||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
|
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
|
||||||
|
import { useUserInvite } from 'hooks/api/getters/useUserInvite/useUserInvite';
|
||||||
|
|
||||||
const InvalidToken: VFC = () => {
|
const InvalidToken: VFC = () => {
|
||||||
const { authDetails } = useAuthDetails();
|
const { authDetails } = useAuthDetails();
|
||||||
const { classes: themeStyles } = useThemeStyles();
|
const { classes: themeStyles } = useThemeStyles();
|
||||||
const passwordDisabled = authDetails?.defaultHidden === true;
|
const passwordDisabled = authDetails?.defaultHidden === true;
|
||||||
|
const { secret } = useUserInvite(); // NOTE: can be enhanced with "expired token"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -43,22 +45,35 @@ const InvalidToken: VFC = () => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
elseShow={
|
elseShow={
|
||||||
<>
|
<ConditionallyRender
|
||||||
<Typography variant="subtitle1">
|
condition={Boolean(secret)}
|
||||||
Your token has either been used to reset your
|
show={
|
||||||
password, or it has expired. Please request a new
|
<Typography variant="subtitle1">
|
||||||
reset password URL in order to reset your password.
|
Provided invite link is invalid or expired.
|
||||||
</Typography>
|
Please request a new URL in order to create your
|
||||||
<Button
|
account.
|
||||||
variant="contained"
|
</Typography>
|
||||||
color="primary"
|
}
|
||||||
component={Link}
|
elseShow={
|
||||||
to="/forgotten-password"
|
<>
|
||||||
data-testid={INVALID_TOKEN_BUTTON}
|
<Typography variant="subtitle1">
|
||||||
>
|
Your token has either been used to reset
|
||||||
Reset password
|
your password, or it has expired. Please
|
||||||
</Button>
|
request a new reset password URL in order to
|
||||||
</>
|
reset your password.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
component={Link}
|
||||||
|
to="/forgotten-password"
|
||||||
|
data-testid={INVALID_TOKEN_BUTTON}
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,30 +10,24 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useThemeStyles } from 'themes/themeStyles';
|
import { useThemeStyles } from 'themes/themeStyles';
|
||||||
import { OK } from 'constants/statusCodes';
|
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import ResetPasswordError from '../ResetPasswordError/ResetPasswordError';
|
|
||||||
import PasswordChecker from './PasswordChecker/PasswordChecker';
|
import PasswordChecker from './PasswordChecker/PasswordChecker';
|
||||||
import PasswordMatcher from './PasswordMatcher/PasswordMatcher';
|
import PasswordMatcher from './PasswordMatcher/PasswordMatcher';
|
||||||
import { useStyles } from './ResetPasswordForm.styles';
|
import { useStyles } from './ResetPasswordForm.styles';
|
||||||
import { formatApiPath } from 'utils/formatPath';
|
|
||||||
import PasswordField from 'component/common/PasswordField/PasswordField';
|
import PasswordField from 'component/common/PasswordField/PasswordField';
|
||||||
|
|
||||||
interface IResetPasswordProps {
|
interface IResetPasswordProps {
|
||||||
token: string;
|
onSubmit: (password: string) => void;
|
||||||
setLoading: Dispatch<SetStateAction<boolean>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResetPasswordForm = ({ token, setLoading }: IResetPasswordProps) => {
|
const ResetPasswordForm = ({ onSubmit }: IResetPasswordProps) => {
|
||||||
const { classes: styles } = useStyles();
|
const { classes: styles } = useStyles();
|
||||||
const { classes: themeStyles } = useThemeStyles();
|
const { classes: themeStyles } = useThemeStyles();
|
||||||
const [apiError, setApiError] = useState(false);
|
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPasswordChecker, setShowPasswordChecker] = useState(false);
|
const [showPasswordChecker, setShowPasswordChecker] = useState(false);
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [matchingPasswords, setMatchingPasswords] = useState(false);
|
const [matchingPasswords, setMatchingPasswords] = useState(false);
|
||||||
const [validOwaspPassword, setValidOwaspPassword] = useState(false);
|
const [validOwaspPassword, setValidOwaspPassword] = useState(false);
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const submittable = matchingPasswords && validOwaspPassword;
|
const submittable = matchingPasswords && validOwaspPassword;
|
||||||
|
|
||||||
@ -53,107 +47,69 @@ const ResetPasswordForm = ({ token, setLoading }: IResetPasswordProps) => {
|
|||||||
}
|
}
|
||||||
}, [password, confirmPassword]);
|
}, [password, confirmPassword]);
|
||||||
|
|
||||||
const makeResetPasswordReq = () => {
|
|
||||||
const path = formatApiPath('auth/reset/password');
|
|
||||||
return fetch(path, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
token,
|
|
||||||
password,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitResetPassword = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await makeResetPasswordReq();
|
|
||||||
setLoading(false);
|
|
||||||
if (res.status === OK) {
|
|
||||||
navigate('/login?reset=true');
|
|
||||||
setApiError(false);
|
|
||||||
} else {
|
|
||||||
setApiError(true);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setApiError(true);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: SyntheticEvent) => {
|
const handleSubmit = (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (submittable) {
|
if (submittable) {
|
||||||
submitResetPassword();
|
onSubmit(password);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const started = Boolean(password && confirmPassword);
|
const started = Boolean(password && confirmPassword);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<form
|
||||||
<ConditionallyRender
|
onSubmit={handleSubmit}
|
||||||
condition={apiError}
|
className={classnames(
|
||||||
show={<ResetPasswordError />}
|
themeStyles.contentSpacingY,
|
||||||
|
styles.container
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PasswordField
|
||||||
|
placeholder="Password"
|
||||||
|
value={password || ''}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setPassword(e.target.value)
|
||||||
|
}
|
||||||
|
onFocus={() => setShowPasswordChecker(true)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-loading
|
||||||
|
/>
|
||||||
|
<PasswordField
|
||||||
|
value={confirmPassword || ''}
|
||||||
|
placeholder="Confirm password"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setConfirmPassword(e.target.value)
|
||||||
|
}
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-loading
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={showPasswordChecker}
|
||||||
|
show={
|
||||||
|
<PasswordChecker
|
||||||
|
password={password}
|
||||||
|
callback={setValidOwaspPasswordMemo}
|
||||||
|
style={{ marginBottom: '1rem' }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className={classnames(
|
|
||||||
themeStyles.contentSpacingY,
|
|
||||||
styles.container
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PasswordField
|
|
||||||
placeholder="Password"
|
|
||||||
value={password || ''}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setPassword(e.target.value)
|
|
||||||
}
|
|
||||||
onFocus={() => setShowPasswordChecker(true)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
data-loading
|
|
||||||
/>
|
|
||||||
<PasswordField
|
|
||||||
value={confirmPassword || ''}
|
|
||||||
placeholder="Confirm password"
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setConfirmPassword(e.target.value)
|
|
||||||
}
|
|
||||||
autoComplete="new-password"
|
|
||||||
data-loading
|
|
||||||
/>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={showPasswordChecker}
|
|
||||||
show={
|
|
||||||
<PasswordChecker
|
|
||||||
password={password}
|
|
||||||
callback={setValidOwaspPasswordMemo}
|
|
||||||
style={{ marginBottom: '1rem' }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PasswordMatcher
|
<PasswordMatcher
|
||||||
started={started}
|
started={started}
|
||||||
matchingPasswords={matchingPasswords}
|
matchingPasswords={matchingPasswords}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
data-loading
|
data-loading
|
||||||
disabled={!submittable}
|
disabled={!submittable}
|
||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
import { Alert, AlertTitle } from '@mui/material';
|
|
||||||
|
|
||||||
const ResetPasswordSuccess = () => {
|
|
||||||
return (
|
|
||||||
<Alert severity="success">
|
|
||||||
<AlertTitle>Success</AlertTitle>
|
|
||||||
You successfully reset your password.
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ResetPasswordSuccess;
|
|
@ -1,5 +1,6 @@
|
|||||||
export const BAD_REQUEST = 400;
|
export const BAD_REQUEST = 400;
|
||||||
export const OK = 200;
|
export const OK = 200;
|
||||||
|
export const CREATED = 201;
|
||||||
export const NOT_FOUND = 404;
|
export const NOT_FOUND = 404;
|
||||||
export const FORBIDDEN = 403;
|
export const FORBIDDEN = 403;
|
||||||
export const UNAUTHORIZED = 401;
|
export const UNAUTHORIZED = 401;
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import useAPI from '../useApi/useApi';
|
||||||
|
|
||||||
|
export const useAuthResetPasswordApi = () => {
|
||||||
|
const { makeRequest, createRequest, errors, loading } = useAPI({
|
||||||
|
propagateErrors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetPassword = useCallback(
|
||||||
|
(value: { token: string; password: string }) => {
|
||||||
|
const req = createRequest('auth/reset/password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(value),
|
||||||
|
});
|
||||||
|
|
||||||
|
return makeRequest(req.caller, req.id);
|
||||||
|
},
|
||||||
|
[createRequest, makeRequest]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
resetPassword,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,64 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import useAPI from '../useApi/useApi';
|
||||||
|
import type {
|
||||||
|
ICreateInvitedUser,
|
||||||
|
IPublicSignupTokenCreate,
|
||||||
|
IPublicSignupTokenUpdate,
|
||||||
|
} from 'interfaces/publicSignupTokens';
|
||||||
|
|
||||||
|
const URI = 'api/admin/invite-link/tokens';
|
||||||
|
|
||||||
|
export const useInviteTokenApi = () => {
|
||||||
|
const { makeRequest, createRequest, errors, loading } = useAPI({
|
||||||
|
propagateErrors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createToken = useCallback(
|
||||||
|
async (request: IPublicSignupTokenCreate) => {
|
||||||
|
const req = createRequest(URI, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await makeRequest(req.caller, req.id);
|
||||||
|
},
|
||||||
|
[createRequest, makeRequest]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateToken = useCallback(
|
||||||
|
async (tokenName: string, value: IPublicSignupTokenUpdate) => {
|
||||||
|
const req = createRequest(`${URI}/${tokenName}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...(value.expiresAt ? { expiresAt: value.expiresAt } : {}),
|
||||||
|
...(value.enabled !== undefined
|
||||||
|
? { enabled: value.enabled }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await makeRequest(req.caller, req.id);
|
||||||
|
},
|
||||||
|
[createRequest, makeRequest]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addUser = useCallback(
|
||||||
|
async (secret: string, value: ICreateInvitedUser) => {
|
||||||
|
const req = createRequest(`/invite/${secret}/signup`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(value),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await makeRequest(req.caller, req.id);
|
||||||
|
},
|
||||||
|
[createRequest, makeRequest]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createToken,
|
||||||
|
updateToken,
|
||||||
|
addUser,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import useSWR, { SWRConfiguration } from 'swr';
|
||||||
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
|
import { IPublicSignupTokens } from 'interfaces/publicSignupTokens';
|
||||||
|
|
||||||
|
export const url = 'api/admin/invite-link/tokens';
|
||||||
|
|
||||||
|
const fetcher = () => {
|
||||||
|
const path = formatApiPath(url);
|
||||||
|
return fetch(path, {
|
||||||
|
method: 'GET',
|
||||||
|
}).then(res => res.json());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useInviteTokens = (options: SWRConfiguration = {}) => {
|
||||||
|
const { data, error } = useSWR<IPublicSignupTokens>(url, fetcher, options);
|
||||||
|
const [loading, setLoading] = useState(!error && !data);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(!error && !data);
|
||||||
|
}, [data, error]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data
|
||||||
|
? { tokens: data.tokens.filter(token => token.enabled) }
|
||||||
|
: undefined,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
};
|
@ -1,10 +0,0 @@
|
|||||||
import useQueryParams from 'hooks/useQueryParams';
|
|
||||||
|
|
||||||
export const useInviteUserToken = () => {
|
|
||||||
const query = useQueryParams();
|
|
||||||
const invite = query.get('invite') || '';
|
|
||||||
|
|
||||||
// TODO: Invite token API
|
|
||||||
|
|
||||||
return { invite, loading: false };
|
|
||||||
};
|
|
@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { formatApiPath } from 'utils/formatPath';
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
|
|
||||||
const getFetcher = (token: string) => () => {
|
const getFetcher = (token: string) => () => {
|
||||||
|
if (!token) return Promise.resolve({ name: INVALID_TOKEN_ERROR });
|
||||||
const path = formatApiPath(`auth/reset/validate?token=${token}`);
|
const path = formatApiPath(`auth/reset/validate?token=${token}`);
|
||||||
// Don't use handleErrorResponses here, because we need to read the error.
|
// Don't use handleErrorResponses here, because we need to read the error.
|
||||||
return fetch(path, {
|
return fetch(path, {
|
||||||
@ -34,11 +35,21 @@ const useResetPassword = (options: SWRConfiguration = {}) => {
|
|||||||
setLoading(!error && !data);
|
setLoading(!error && !data);
|
||||||
}, [data, error]);
|
}, [data, error]);
|
||||||
|
|
||||||
const invalidToken =
|
const isValidToken =
|
||||||
(!loading && data?.name === INVALID_TOKEN_ERROR) ||
|
(!loading && data?.name === INVALID_TOKEN_ERROR) ||
|
||||||
data?.name === USED_TOKEN_ERROR;
|
data?.name === USED_TOKEN_ERROR
|
||||||
|
? false
|
||||||
|
: true;
|
||||||
|
|
||||||
return { token, data, error, loading, setLoading, invalidToken, retry };
|
return {
|
||||||
|
token,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
isValidToken,
|
||||||
|
setLoading,
|
||||||
|
retry,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useResetPassword;
|
export default useResetPassword;
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import useSWR, { SWRConfiguration } from 'swr';
|
||||||
|
import { OK } from 'constants/statusCodes';
|
||||||
|
import useQueryParams from 'hooks/useQueryParams';
|
||||||
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
|
|
||||||
|
const getFetcher = (token: string, url: string) => () => {
|
||||||
|
if (!token) return Promise.resolve(false);
|
||||||
|
|
||||||
|
const path = formatApiPath(url);
|
||||||
|
return fetch(path, {
|
||||||
|
method: 'GET',
|
||||||
|
}).then(response => response.status === OK);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUserInvite = (options: SWRConfiguration = {}) => {
|
||||||
|
const query = useQueryParams();
|
||||||
|
const secret = query.get('invite') || '';
|
||||||
|
const url = `/invite/${secret}/validate`;
|
||||||
|
const { data, error } = useSWR<boolean>(
|
||||||
|
url,
|
||||||
|
getFetcher(secret, url),
|
||||||
|
options
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(!error && !data);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(!error && data === undefined);
|
||||||
|
}, [data, error]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
secret,
|
||||||
|
isValid: data,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
};
|
35
frontend/src/interfaces/publicSignupTokens.ts
Normal file
35
frontend/src/interfaces/publicSignupTokens.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import IRole from './role';
|
||||||
|
import { IUser } from './user';
|
||||||
|
|
||||||
|
export interface ICreateInvitedUser {
|
||||||
|
username?: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPublicSignupTokens {
|
||||||
|
tokens: IPublicSignupToken[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPublicSignupToken {
|
||||||
|
secret: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
expiresAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string | null;
|
||||||
|
users?: IUser[] | null;
|
||||||
|
role: IRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPublicSignupTokenCreate {
|
||||||
|
name: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPublicSignupTokenUpdate {
|
||||||
|
expiresAt?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
@ -40,6 +40,7 @@ export interface IFlags {
|
|||||||
UG?: boolean;
|
UG?: boolean;
|
||||||
ENABLE_DARK_MODE_SUPPORT?: boolean;
|
ENABLE_DARK_MODE_SUPPORT?: boolean;
|
||||||
embedProxyFrontend?: boolean;
|
embedProxyFrontend?: boolean;
|
||||||
|
publicSignup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -49,6 +49,18 @@ export default mergeConfig(
|
|||||||
target: UNLEASH_API,
|
target: UNLEASH_API,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
[`${UNLEASH_BASE_PATH}health`]: {
|
||||||
|
target: UNLEASH_API,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
[`${UNLEASH_BASE_PATH}invite`]: {
|
||||||
|
target: UNLEASH_API,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
[`${UNLEASH_BASE_PATH}edge`]: {
|
||||||
|
target: UNLEASH_API,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [react(), tsconfigPaths(), svgr(), envCompatible()],
|
plugins: [react(), tsconfigPaths(), svgr(), envCompatible()],
|
||||||
|
@ -32,19 +32,28 @@ interface ITokenUserRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tokenRowReducer = (acc, tokenRow) => {
|
const tokenRowReducer = (acc, tokenRow) => {
|
||||||
const { userId, userName, userUsername, roleId, roleName, ...token } =
|
const {
|
||||||
tokenRow;
|
userId,
|
||||||
|
userName,
|
||||||
|
userUsername,
|
||||||
|
roleId,
|
||||||
|
roleName,
|
||||||
|
roleType,
|
||||||
|
...token
|
||||||
|
} = tokenRow;
|
||||||
if (!acc[tokenRow.secret]) {
|
if (!acc[tokenRow.secret]) {
|
||||||
acc[tokenRow.secret] = {
|
acc[tokenRow.secret] = {
|
||||||
secret: token.secret,
|
secret: token.secret,
|
||||||
name: token.name,
|
name: token.name,
|
||||||
url: token.url,
|
url: token.url,
|
||||||
expiresAt: token.expires_at,
|
expiresAt: token.expires_at,
|
||||||
|
enabled: token.enabled,
|
||||||
createdAt: token.created_at,
|
createdAt: token.created_at,
|
||||||
createdBy: token.created_by,
|
createdBy: token.created_by,
|
||||||
role: {
|
role: {
|
||||||
id: roleId,
|
id: roleId,
|
||||||
name: roleName,
|
name: roleName,
|
||||||
|
type: roleType,
|
||||||
},
|
},
|
||||||
users: [],
|
users: [],
|
||||||
};
|
};
|
||||||
@ -113,6 +122,7 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
|
|||||||
'tokens.secret',
|
'tokens.secret',
|
||||||
'tokens.name',
|
'tokens.name',
|
||||||
'tokens.expires_at',
|
'tokens.expires_at',
|
||||||
|
'tokens.enabled',
|
||||||
'tokens.created_at',
|
'tokens.created_at',
|
||||||
'tokens.created_by',
|
'tokens.created_by',
|
||||||
'tokens.url',
|
'tokens.url',
|
||||||
@ -121,6 +131,7 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
|
|||||||
'users.username as userUsername',
|
'users.username as userUsername',
|
||||||
'roles.id as roleId',
|
'roles.id as roleId',
|
||||||
'roles.name as roleName',
|
'roles.name as roleName',
|
||||||
|
'roles.type as roleType',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,7 +170,7 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
|
|||||||
|
|
||||||
async isValid(secret: string): Promise<boolean> {
|
async isValid(secret: string): Promise<boolean> {
|
||||||
const result = await this.db.raw(
|
const result = await this.db.raw(
|
||||||
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE secret = ? AND expires_at::date > ?) AS valid`,
|
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE secret = ? AND expires_at::date > ? AND enabled = true) AS valid`,
|
||||||
[secret, new Date()],
|
[secret, new Date()],
|
||||||
);
|
);
|
||||||
const { valid } = result.rows[0];
|
const { valid } = result.rows[0];
|
||||||
@ -197,12 +208,12 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
|
|||||||
return this.db<ITokenInsert>(TABLE).del();
|
return this.db<ITokenInsert>(TABLE).del();
|
||||||
}
|
}
|
||||||
|
|
||||||
async setExpiry(
|
async update(
|
||||||
secret: string,
|
secret: string,
|
||||||
expiresAt: Date,
|
{ expiresAt, enabled }: { expiresAt?: Date; enabled?: boolean },
|
||||||
): Promise<PublicSignupTokenSchema> {
|
): Promise<PublicSignupTokenSchema> {
|
||||||
const rows = await this.makeTokenUsersQuery()
|
const rows = await this.makeTokenUsersQuery()
|
||||||
.update({ expires_at: expiresAt })
|
.update({ expires_at: expiresAt, enabled })
|
||||||
.where('secret', secret)
|
.where('secret', secret)
|
||||||
.returning('*');
|
.returning('*');
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
|
@ -20,8 +20,10 @@ import { contextFieldsSchema } from './spec/context-fields-schema';
|
|||||||
import { createApiTokenSchema } from './spec/create-api-token-schema';
|
import { createApiTokenSchema } from './spec/create-api-token-schema';
|
||||||
import { createFeatureSchema } from './spec/create-feature-schema';
|
import { createFeatureSchema } from './spec/create-feature-schema';
|
||||||
import { createFeatureStrategySchema } from './spec/create-feature-strategy-schema';
|
import { createFeatureStrategySchema } from './spec/create-feature-strategy-schema';
|
||||||
|
import { createInvitedUserSchema } from './spec/create-invited-user-schema';
|
||||||
import { createUserSchema } from './spec/create-user-schema';
|
import { createUserSchema } from './spec/create-user-schema';
|
||||||
import { dateSchema } from './spec/date-schema';
|
import { dateSchema } from './spec/date-schema';
|
||||||
|
import { edgeTokenSchema } from './spec/edge-token-schema';
|
||||||
import { emailSchema } from './spec/email-schema';
|
import { emailSchema } from './spec/email-schema';
|
||||||
import { environmentSchema } from './spec/environment-schema';
|
import { environmentSchema } from './spec/environment-schema';
|
||||||
import { environmentsSchema } from './spec/environments-schema';
|
import { environmentsSchema } from './spec/environments-schema';
|
||||||
@ -41,36 +43,54 @@ import { featureTypesSchema } from './spec/feature-types-schema';
|
|||||||
import { featureUsageSchema } from './spec/feature-usage-schema';
|
import { featureUsageSchema } from './spec/feature-usage-schema';
|
||||||
import { featureVariantsSchema } from './spec/feature-variants-schema';
|
import { featureVariantsSchema } from './spec/feature-variants-schema';
|
||||||
import { feedbackSchema } from './spec/feedback-schema';
|
import { feedbackSchema } from './spec/feedback-schema';
|
||||||
|
import { groupSchema } from './spec/group-schema';
|
||||||
|
import { groupsSchema } from './spec/groups-schema';
|
||||||
|
import { groupUserModelSchema } from './spec/group-user-model-schema';
|
||||||
import { healthCheckSchema } from './spec/health-check-schema';
|
import { healthCheckSchema } from './spec/health-check-schema';
|
||||||
import { healthOverviewSchema } from './spec/health-overview-schema';
|
import { healthOverviewSchema } from './spec/health-overview-schema';
|
||||||
import { healthReportSchema } from './spec/health-report-schema';
|
import { healthReportSchema } from './spec/health-report-schema';
|
||||||
import { idSchema } from './spec/id-schema';
|
import { idSchema } from './spec/id-schema';
|
||||||
|
import { IServerOption } from '../types';
|
||||||
import { legalValueSchema } from './spec/legal-value-schema';
|
import { legalValueSchema } from './spec/legal-value-schema';
|
||||||
import { loginSchema } from './spec/login-schema';
|
import { loginSchema } from './spec/login-schema';
|
||||||
import { mapValues } from '../util/map-values';
|
import { mapValues } from '../util/map-values';
|
||||||
import { meSchema } from './spec/me-schema';
|
import { meSchema } from './spec/me-schema';
|
||||||
import { nameSchema } from './spec/name-schema';
|
import { nameSchema } from './spec/name-schema';
|
||||||
import { omitKeys } from '../util/omit-keys';
|
import { omitKeys } from '../util/omit-keys';
|
||||||
|
import { openApiTags } from './util/openapi-tags';
|
||||||
import { overrideSchema } from './spec/override-schema';
|
import { overrideSchema } from './spec/override-schema';
|
||||||
import { parametersSchema } from './spec/parameters-schema';
|
import { parametersSchema } from './spec/parameters-schema';
|
||||||
import { passwordSchema } from './spec/password-schema';
|
import { passwordSchema } from './spec/password-schema';
|
||||||
import { patchesSchema } from './spec/patches-schema';
|
import { patchesSchema } from './spec/patches-schema';
|
||||||
import { patchSchema } from './spec/patch-schema';
|
import { patchSchema } from './spec/patch-schema';
|
||||||
|
import { patSchema } from './spec/pat-schema';
|
||||||
|
import { patsSchema } from './spec/pats-schema';
|
||||||
import { permissionSchema } from './spec/permission-schema';
|
import { permissionSchema } from './spec/permission-schema';
|
||||||
import { playgroundFeatureSchema } from './spec/playground-feature-schema';
|
|
||||||
import { playgroundStrategySchema } from './spec/playground-strategy-schema';
|
|
||||||
import { playgroundConstraintSchema } from './spec/playground-constraint-schema';
|
import { playgroundConstraintSchema } from './spec/playground-constraint-schema';
|
||||||
import { playgroundSegmentSchema } from './spec/playground-segment-schema';
|
import { playgroundFeatureSchema } from './spec/playground-feature-schema';
|
||||||
import { playgroundRequestSchema } from './spec/playground-request-schema';
|
import { playgroundRequestSchema } from './spec/playground-request-schema';
|
||||||
import { playgroundResponseSchema } from './spec/playground-response-schema';
|
import { playgroundResponseSchema } from './spec/playground-response-schema';
|
||||||
|
import { playgroundSegmentSchema } from './spec/playground-segment-schema';
|
||||||
|
import { playgroundStrategySchema } from './spec/playground-strategy-schema';
|
||||||
|
import { profileSchema } from './spec/profile-schema';
|
||||||
import { projectEnvironmentSchema } from './spec/project-environment-schema';
|
import { projectEnvironmentSchema } from './spec/project-environment-schema';
|
||||||
import { projectSchema } from './spec/project-schema';
|
import { projectSchema } from './spec/project-schema';
|
||||||
import { projectsSchema } from './spec/projects-schema';
|
import { projectsSchema } from './spec/projects-schema';
|
||||||
|
import { proxyClientSchema } from './spec/proxy-client-schema';
|
||||||
|
import { proxyFeatureSchema } from './spec/proxy-feature-schema';
|
||||||
|
import { proxyFeaturesSchema } from './spec/proxy-features-schema';
|
||||||
|
import { proxyMetricsSchema } from './spec/proxy-metrics-schema';
|
||||||
|
import { publicSignupTokenCreateSchema } from './spec/public-signup-token-create-schema';
|
||||||
|
import { publicSignupTokenSchema } from './spec/public-signup-token-schema';
|
||||||
|
import { publicSignupTokensSchema } from './spec/public-signup-tokens-schema';
|
||||||
|
import { publicSignupTokenUpdateSchema } from './spec/public-signup-token-update-schema';
|
||||||
import { resetPasswordSchema } from './spec/reset-password-schema';
|
import { resetPasswordSchema } from './spec/reset-password-schema';
|
||||||
import { roleSchema } from './spec/role-schema';
|
import { roleSchema } from './spec/role-schema';
|
||||||
import { sdkContextSchema } from './spec/sdk-context-schema';
|
import { sdkContextSchema } from './spec/sdk-context-schema';
|
||||||
|
import { searchEventsSchema } from './spec/search-events-schema';
|
||||||
import { segmentSchema } from './spec/segment-schema';
|
import { segmentSchema } from './spec/segment-schema';
|
||||||
import { setStrategySortOrderSchema } from './spec/set-strategy-sort-order-schema';
|
import { setStrategySortOrderSchema } from './spec/set-strategy-sort-order-schema';
|
||||||
|
import { setUiConfigSchema } from './spec/set-ui-config-schema';
|
||||||
import { sortOrderSchema } from './spec/sort-order-schema';
|
import { sortOrderSchema } from './spec/sort-order-schema';
|
||||||
import { splashSchema } from './spec/splash-schema';
|
import { splashSchema } from './spec/splash-schema';
|
||||||
import { stateSchema } from './spec/state-schema';
|
import { stateSchema } from './spec/state-schema';
|
||||||
@ -90,37 +110,18 @@ import { updateTagTypeSchema } from './spec/update-tag-type-schema';
|
|||||||
import { updateUserSchema } from './spec/update-user-schema';
|
import { updateUserSchema } from './spec/update-user-schema';
|
||||||
import { upsertContextFieldSchema } from './spec/upsert-context-field-schema';
|
import { upsertContextFieldSchema } from './spec/upsert-context-field-schema';
|
||||||
import { upsertStrategySchema } from './spec/upsert-strategy-schema';
|
import { upsertStrategySchema } from './spec/upsert-strategy-schema';
|
||||||
|
import { URL } from 'url';
|
||||||
import { userSchema } from './spec/user-schema';
|
import { userSchema } from './spec/user-schema';
|
||||||
|
import { usersGroupsBaseSchema } from './spec/users-groups-base-schema';
|
||||||
import { usersSchema } from './spec/users-schema';
|
import { usersSchema } from './spec/users-schema';
|
||||||
import { usersSearchSchema } from './spec/users-search-schema';
|
import { usersSearchSchema } from './spec/users-search-schema';
|
||||||
|
import { validateEdgeTokensSchema } from './spec/validate-edge-tokens-schema';
|
||||||
import { validatePasswordSchema } from './spec/validate-password-schema';
|
import { validatePasswordSchema } from './spec/validate-password-schema';
|
||||||
import { validateTagTypeSchema } from './spec/validate-tag-type-schema';
|
import { validateTagTypeSchema } from './spec/validate-tag-type-schema';
|
||||||
import { variantSchema } from './spec/variant-schema';
|
import { variantSchema } from './spec/variant-schema';
|
||||||
import { variantsSchema } from './spec/variants-schema';
|
import { variantsSchema } from './spec/variants-schema';
|
||||||
import { versionSchema } from './spec/version-schema';
|
import { versionSchema } from './spec/version-schema';
|
||||||
import { IServerOption } from '../types';
|
|
||||||
import { URL } from 'url';
|
|
||||||
import { groupSchema } from './spec/group-schema';
|
|
||||||
import { groupsSchema } from './spec/groups-schema';
|
|
||||||
import { groupUserModelSchema } from './spec/group-user-model-schema';
|
|
||||||
import { usersGroupsBaseSchema } from './spec/users-groups-base-schema';
|
|
||||||
import { openApiTags } from './util/openapi-tags';
|
|
||||||
import { searchEventsSchema } from './spec/search-events-schema';
|
|
||||||
import { proxyFeaturesSchema } from './spec/proxy-features-schema';
|
|
||||||
import { proxyFeatureSchema } from './spec/proxy-feature-schema';
|
|
||||||
import { proxyClientSchema } from './spec/proxy-client-schema';
|
|
||||||
import { proxyMetricsSchema } from './spec/proxy-metrics-schema';
|
|
||||||
import { setUiConfigSchema } from './spec/set-ui-config-schema';
|
|
||||||
import { edgeTokenSchema } from './spec/edge-token-schema';
|
|
||||||
import { validateEdgeTokensSchema } from './spec/validate-edge-tokens-schema';
|
|
||||||
import { patsSchema } from './spec/pats-schema';
|
|
||||||
import { patSchema } from './spec/pat-schema';
|
|
||||||
import { publicSignupTokenCreateSchema } from './spec/public-signup-token-create-schema';
|
|
||||||
import { publicSignupTokenSchema } from './spec/public-signup-token-schema';
|
|
||||||
import { publicSignupTokensSchema } from './spec/public-signup-tokens-schema';
|
|
||||||
import { publicSignupTokenUpdateSchema } from './spec/public-signup-token-update-schema';
|
|
||||||
import apiVersion from '../util/version';
|
import apiVersion from '../util/version';
|
||||||
import { profileSchema } from './spec/profile-schema';
|
|
||||||
|
|
||||||
// All schemas in `openapi/spec` should be listed here.
|
// All schemas in `openapi/spec` should be listed here.
|
||||||
export const schemas = {
|
export const schemas = {
|
||||||
@ -145,10 +146,11 @@ export const schemas = {
|
|||||||
createApiTokenSchema,
|
createApiTokenSchema,
|
||||||
createFeatureSchema,
|
createFeatureSchema,
|
||||||
createFeatureStrategySchema,
|
createFeatureStrategySchema,
|
||||||
|
createInvitedUserSchema,
|
||||||
createUserSchema,
|
createUserSchema,
|
||||||
dateSchema,
|
dateSchema,
|
||||||
emailSchema,
|
|
||||||
edgeTokenSchema,
|
edgeTokenSchema,
|
||||||
|
emailSchema,
|
||||||
environmentSchema,
|
environmentSchema,
|
||||||
environmentsSchema,
|
environmentsSchema,
|
||||||
eventSchema,
|
eventSchema,
|
||||||
@ -181,29 +183,29 @@ export const schemas = {
|
|||||||
overrideSchema,
|
overrideSchema,
|
||||||
parametersSchema,
|
parametersSchema,
|
||||||
passwordSchema,
|
passwordSchema,
|
||||||
patSchema,
|
|
||||||
patsSchema,
|
|
||||||
patchesSchema,
|
patchesSchema,
|
||||||
patchSchema,
|
patchSchema,
|
||||||
|
patSchema,
|
||||||
|
patsSchema,
|
||||||
permissionSchema,
|
permissionSchema,
|
||||||
playgroundFeatureSchema,
|
|
||||||
playgroundStrategySchema,
|
|
||||||
playgroundConstraintSchema,
|
playgroundConstraintSchema,
|
||||||
playgroundSegmentSchema,
|
playgroundFeatureSchema,
|
||||||
playgroundRequestSchema,
|
playgroundRequestSchema,
|
||||||
playgroundResponseSchema,
|
playgroundResponseSchema,
|
||||||
projectEnvironmentSchema,
|
playgroundSegmentSchema,
|
||||||
publicSignupTokenCreateSchema,
|
playgroundStrategySchema,
|
||||||
publicSignupTokenUpdateSchema,
|
|
||||||
publicSignupTokensSchema,
|
|
||||||
publicSignupTokenSchema,
|
|
||||||
profileSchema,
|
profileSchema,
|
||||||
proxyClientSchema,
|
projectEnvironmentSchema,
|
||||||
proxyFeaturesSchema,
|
|
||||||
proxyFeatureSchema,
|
|
||||||
proxyMetricsSchema,
|
|
||||||
projectSchema,
|
projectSchema,
|
||||||
projectsSchema,
|
projectsSchema,
|
||||||
|
proxyClientSchema,
|
||||||
|
proxyFeatureSchema,
|
||||||
|
proxyFeaturesSchema,
|
||||||
|
proxyMetricsSchema,
|
||||||
|
publicSignupTokenCreateSchema,
|
||||||
|
publicSignupTokenSchema,
|
||||||
|
publicSignupTokensSchema,
|
||||||
|
publicSignupTokenUpdateSchema,
|
||||||
resetPasswordSchema,
|
resetPasswordSchema,
|
||||||
roleSchema,
|
roleSchema,
|
||||||
sdkContextSchema,
|
sdkContextSchema,
|
||||||
@ -230,16 +232,16 @@ export const schemas = {
|
|||||||
updateUserSchema,
|
updateUserSchema,
|
||||||
upsertContextFieldSchema,
|
upsertContextFieldSchema,
|
||||||
upsertStrategySchema,
|
upsertStrategySchema,
|
||||||
usersGroupsBaseSchema,
|
|
||||||
userSchema,
|
userSchema,
|
||||||
|
usersGroupsBaseSchema,
|
||||||
usersSchema,
|
usersSchema,
|
||||||
usersSearchSchema,
|
usersSearchSchema,
|
||||||
|
validateEdgeTokensSchema,
|
||||||
validatePasswordSchema,
|
validatePasswordSchema,
|
||||||
validateTagTypeSchema,
|
validateTagTypeSchema,
|
||||||
variantSchema,
|
variantSchema,
|
||||||
variantsSchema,
|
variantsSchema,
|
||||||
versionSchema,
|
versionSchema,
|
||||||
validateEdgeTokensSchema,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
|
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
|
||||||
|
27
src/lib/openapi/spec/create-invited-user-schema.ts
Normal file
27
src/lib/openapi/spec/create-invited-user-schema.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
export const createInvitedUserSchema = {
|
||||||
|
$id: '#/components/schemas/createInvitedUserSchema',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['email', 'name', 'password'],
|
||||||
|
properties: {
|
||||||
|
username: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CreateInvitedUserSchema = FromSchema<
|
||||||
|
typeof createInvitedUserSchema
|
||||||
|
>;
|
@ -11,6 +11,7 @@ test('publicSignupTokenSchema', () => {
|
|||||||
role: { name: 'Viewer ', type: 'type', id: 1 },
|
role: { name: 'Viewer ', type: 'type', id: 1 },
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
createdBy: 'someone',
|
createdBy: 'someone',
|
||||||
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
@ -13,6 +13,7 @@ export const publicSignupTokenSchema = {
|
|||||||
'expiresAt',
|
'expiresAt',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'createdBy',
|
'createdBy',
|
||||||
|
'enabled',
|
||||||
'role',
|
'role',
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
@ -25,6 +26,9 @@ export const publicSignupTokenSchema = {
|
|||||||
name: {
|
name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
expiresAt: {
|
expiresAt: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
|
@ -4,12 +4,14 @@ export const publicSignupTokenUpdateSchema = {
|
|||||||
$id: '#/components/schemas/publicSignupTokenUpdateSchema',
|
$id: '#/components/schemas/publicSignupTokenUpdateSchema',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ['expiresAt'],
|
|
||||||
properties: {
|
properties: {
|
||||||
expiresAt: {
|
expiresAt: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
},
|
},
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {},
|
components: {},
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -5,7 +5,6 @@ import getApp from '../../app';
|
|||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import permissions from '../../../test/fixtures/permissions';
|
import permissions from '../../../test/fixtures/permissions';
|
||||||
import { RoleName, RoleType } from '../../types/model';
|
import { RoleName, RoleType } from '../../types/model';
|
||||||
import { CreateUserSchema } from '../../openapi/spec/create-user-schema';
|
|
||||||
|
|
||||||
describe('Public Signup API', () => {
|
describe('Public Signup API', () => {
|
||||||
async function getSetup() {
|
async function getSetup() {
|
||||||
@ -51,6 +50,13 @@ describe('Public Signup API', () => {
|
|||||||
let request;
|
let request;
|
||||||
let destroy;
|
let destroy;
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
username: 'some-username',
|
||||||
|
email: 'someEmail@example.com',
|
||||||
|
name: 'some-name',
|
||||||
|
password: 'password',
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const setup = await getSetup();
|
const setup = await getSetup();
|
||||||
stores = setup.stores;
|
stores = setup.stores;
|
||||||
@ -132,6 +138,30 @@ describe('Public Signup API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should expire token', async () => {
|
test('should expire token', async () => {
|
||||||
|
expect.assertions(2);
|
||||||
|
const appName = '123!23';
|
||||||
|
|
||||||
|
stores.clientApplicationsStore.upsert({ appName });
|
||||||
|
stores.publicSignupTokenStore.create({
|
||||||
|
name: 'some-name',
|
||||||
|
expiresAt: expireAt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const expireNow = expireAt(0);
|
||||||
|
|
||||||
|
return request
|
||||||
|
.put('/api/admin/invite-link/tokens/some-secret')
|
||||||
|
.send({ expiresAt: expireNow.toISOString() })
|
||||||
|
.expect(200)
|
||||||
|
.expect(async (res) => {
|
||||||
|
const token = res.body;
|
||||||
|
expect(token.expiresAt).toBe(expireNow.toISOString());
|
||||||
|
const eventCount = await stores.eventStore.count();
|
||||||
|
expect(eventCount).toBe(1); // PUBLIC_SIGNUP_TOKEN_TOKEN_UPDATED
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should disable the token', async () => {
|
||||||
expect.assertions(1);
|
expect.assertions(1);
|
||||||
const appName = '123!23';
|
const appName = '123!23';
|
||||||
|
|
||||||
@ -142,47 +172,16 @@ describe('Public Signup API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return request
|
return request
|
||||||
.delete('/api/admin/invite-link/tokens/some-secret')
|
.put('/api/admin/invite-link/tokens/some-secret')
|
||||||
|
.send({ enabled: false })
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.expect(async () => {
|
|
||||||
const eventCount = await stores.eventStore.count();
|
|
||||||
expect(eventCount).toBe(1); // PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create user and add to token', async () => {
|
|
||||||
expect.assertions(3);
|
|
||||||
const appName = '123!23';
|
|
||||||
|
|
||||||
stores.clientApplicationsStore.upsert({ appName });
|
|
||||||
stores.publicSignupTokenStore.create({
|
|
||||||
name: 'some-name',
|
|
||||||
expiresAt: expireAt(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const user: CreateUserSchema = {
|
|
||||||
username: 'some-username',
|
|
||||||
email: 'someEmail@example.com',
|
|
||||||
name: 'some-name',
|
|
||||||
password: null,
|
|
||||||
rootRole: 1,
|
|
||||||
sendEmail: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
return request
|
|
||||||
.post('/api/admin/invite-link/tokens/some-secret/signup')
|
|
||||||
.send(user)
|
|
||||||
.expect(201)
|
|
||||||
.expect(async (res) => {
|
.expect(async (res) => {
|
||||||
const count = await stores.userStore.count();
|
const token = res.body;
|
||||||
expect(count).toBe(1);
|
expect(token.enabled).toBe(false);
|
||||||
const eventCount = await stores.eventStore.count();
|
|
||||||
expect(eventCount).toBe(2); //USER_CREATED && PUBLIC_SIGNUP_TOKEN_USER_ADDED
|
|
||||||
expect(res.body.username).toBe(user.username);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return 200 if token is valid', async () => {
|
test('should not allow a user to register disabled token', async () => {
|
||||||
const appName = '123!23';
|
const appName = '123!23';
|
||||||
|
|
||||||
stores.clientApplicationsStore.upsert({ appName });
|
stores.clientApplicationsStore.upsert({ appName });
|
||||||
@ -190,19 +189,11 @@ describe('Public Signup API', () => {
|
|||||||
name: 'some-name',
|
name: 'some-name',
|
||||||
expiresAt: expireAt(),
|
expiresAt: expireAt(),
|
||||||
});
|
});
|
||||||
|
stores.publicSignupTokenStore.update('some-secret', { enabled: false });
|
||||||
|
|
||||||
return request
|
return request
|
||||||
.post('/api/admin/invite-link/tokens/some-secret/validate')
|
.post('/invite/some-secret/signup')
|
||||||
.expect(200);
|
.send(user)
|
||||||
});
|
.expect(400);
|
||||||
|
|
||||||
test('should return 401 if token is invalid', async () => {
|
|
||||||
const appName = '123!23';
|
|
||||||
|
|
||||||
stores.clientApplicationsStore.upsert({ appName });
|
|
||||||
|
|
||||||
return request
|
|
||||||
.post('/api/admin/invite-link/tokens/some-invalid-secret/validate')
|
|
||||||
.expect(401);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
import Controller from '../controller';
|
import Controller from '../controller';
|
||||||
import { ADMIN, NONE } from '../../types/permissions';
|
import { ADMIN } from '../../types/permissions';
|
||||||
import { Logger } from '../../logger';
|
import { Logger } from '../../logger';
|
||||||
import { AccessService } from '../../services/access-service';
|
import { AccessService } from '../../services/access-service';
|
||||||
import { IAuthRequest } from '../unleash-types';
|
import { IAuthRequest } from '../unleash-types';
|
||||||
@ -13,10 +13,6 @@ import {
|
|||||||
resourceCreatedResponseSchema,
|
resourceCreatedResponseSchema,
|
||||||
} from '../../openapi/util/create-response-schema';
|
} from '../../openapi/util/create-response-schema';
|
||||||
import { serializeDates } from '../../types/serialize-dates';
|
import { serializeDates } from '../../types/serialize-dates';
|
||||||
import {
|
|
||||||
emptyResponse,
|
|
||||||
getStandardResponses,
|
|
||||||
} from '../../openapi/util/standard-responses';
|
|
||||||
import { PublicSignupTokenService } from '../../services/public-signup-token-service';
|
import { PublicSignupTokenService } from '../../services/public-signup-token-service';
|
||||||
import UserService from '../../services/user-service';
|
import UserService from '../../services/user-service';
|
||||||
import {
|
import {
|
||||||
@ -29,8 +25,6 @@ import {
|
|||||||
} from '../../openapi/spec/public-signup-tokens-schema';
|
} from '../../openapi/spec/public-signup-tokens-schema';
|
||||||
import { PublicSignupTokenCreateSchema } from '../../openapi/spec/public-signup-token-create-schema';
|
import { PublicSignupTokenCreateSchema } from '../../openapi/spec/public-signup-token-create-schema';
|
||||||
import { PublicSignupTokenUpdateSchema } from '../../openapi/spec/public-signup-token-update-schema';
|
import { PublicSignupTokenUpdateSchema } from '../../openapi/spec/public-signup-token-update-schema';
|
||||||
import { CreateUserSchema } from '../../openapi/spec/create-user-schema';
|
|
||||||
import { UserSchema, userSchema } from '../../openapi/spec/user-schema';
|
|
||||||
import { extractUsername } from '../../util/extract-user';
|
import { extractUsername } from '../../util/extract-user';
|
||||||
|
|
||||||
interface TokenParam {
|
interface TokenParam {
|
||||||
@ -107,24 +101,6 @@ export class PublicSignupController extends Controller {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.route({
|
|
||||||
method: 'post',
|
|
||||||
path: '/tokens/:token/signup',
|
|
||||||
handler: this.addTokenUser,
|
|
||||||
permission: NONE,
|
|
||||||
middleware: [
|
|
||||||
openApiService.validPath({
|
|
||||||
tags: ['Public signup tokens'],
|
|
||||||
operationId: 'addPublicSignupTokenUser',
|
|
||||||
requestBody: createRequestSchema('createUserSchema'),
|
|
||||||
responses: {
|
|
||||||
200: createResponseSchema('userSchema'),
|
|
||||||
...getStandardResponses(409),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.route({
|
this.route({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/tokens/:token',
|
path: '/tokens/:token',
|
||||||
@ -154,42 +130,7 @@ export class PublicSignupController extends Controller {
|
|||||||
'publicSignupTokenUpdateSchema',
|
'publicSignupTokenUpdateSchema',
|
||||||
),
|
),
|
||||||
responses: {
|
responses: {
|
||||||
200: emptyResponse,
|
200: createResponseSchema('publicSignupTokenSchema'),
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.route({
|
|
||||||
method: 'delete',
|
|
||||||
path: '/tokens/:token',
|
|
||||||
handler: this.deletePublicSignupToken,
|
|
||||||
acceptAnyContentType: true,
|
|
||||||
permission: ADMIN,
|
|
||||||
middleware: [
|
|
||||||
openApiService.validPath({
|
|
||||||
tags: ['Public signup tokens'],
|
|
||||||
operationId: 'deletePublicSignupToken',
|
|
||||||
responses: {
|
|
||||||
200: emptyResponse,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.route({
|
|
||||||
method: 'post',
|
|
||||||
path: '/tokens/:token/validate',
|
|
||||||
handler: this.validate,
|
|
||||||
acceptAnyContentType: true,
|
|
||||||
permission: NONE,
|
|
||||||
middleware: [
|
|
||||||
openApiService.validPath({
|
|
||||||
tags: ['Public signup tokens'],
|
|
||||||
operationId: 'validatePublicSignupToken',
|
|
||||||
responses: {
|
|
||||||
200: emptyResponse,
|
|
||||||
401: emptyResponse,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@ -223,33 +164,6 @@ export class PublicSignupController extends Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(
|
|
||||||
req: IAuthRequest<TokenParam, void, CreateUserSchema>,
|
|
||||||
res: Response,
|
|
||||||
): Promise<void> {
|
|
||||||
const { token } = req.params;
|
|
||||||
const valid = await this.publicSignupTokenService.validate(token);
|
|
||||||
if (valid) return res.status(200).end();
|
|
||||||
else return res.status(401).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
async addTokenUser(
|
|
||||||
req: IAuthRequest<TokenParam, void, CreateUserSchema>,
|
|
||||||
res: Response<UserSchema>,
|
|
||||||
): Promise<void> {
|
|
||||||
const { token } = req.params;
|
|
||||||
const user = await this.publicSignupTokenService.addTokenUser(
|
|
||||||
token,
|
|
||||||
req.body,
|
|
||||||
);
|
|
||||||
this.openApiService.respondWithValidation(
|
|
||||||
201,
|
|
||||||
res,
|
|
||||||
userSchema.$id,
|
|
||||||
serializeDates(user),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPublicSignupToken(
|
async createPublicSignupToken(
|
||||||
req: IAuthRequest<void, void, PublicSignupTokenCreateSchema>,
|
req: IAuthRequest<void, void, PublicSignupTokenCreateSchema>,
|
||||||
res: Response<PublicSignupTokenSchema>,
|
res: Response<PublicSignupTokenSchema>,
|
||||||
@ -274,28 +188,27 @@ export class PublicSignupController extends Controller {
|
|||||||
res: Response,
|
res: Response,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const { token } = req.params;
|
const { token } = req.params;
|
||||||
const { expiresAt } = req.body;
|
const { expiresAt, enabled } = req.body;
|
||||||
|
|
||||||
if (!expiresAt) {
|
if (!expiresAt && enabled === undefined) {
|
||||||
this.logger.error(req.body);
|
this.logger.error(req.body);
|
||||||
return res.status(400).send();
|
return res.status(400).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.publicSignupTokenService.setExpiry(
|
const result = await this.publicSignupTokenService.update(
|
||||||
token,
|
token,
|
||||||
new Date(expiresAt),
|
{
|
||||||
|
...(enabled === undefined ? {} : { enabled }),
|
||||||
|
...(expiresAt ? { expiresAt: new Date(expiresAt) } : {}),
|
||||||
|
},
|
||||||
|
extractUsername(req),
|
||||||
);
|
);
|
||||||
return res.status(200).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deletePublicSignupToken(
|
this.openApiService.respondWithValidation(
|
||||||
req: IAuthRequest<TokenParam>,
|
200,
|
||||||
res: Response,
|
res,
|
||||||
): Promise<void> {
|
publicSignupTokenSchema.$id,
|
||||||
const { token } = req.params;
|
serializeDates(result),
|
||||||
const username = extractUsername(req);
|
);
|
||||||
|
|
||||||
await this.publicSignupTokenService.delete(token, username);
|
|
||||||
res.status(200).end();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,12 +12,17 @@ import { HealthCheckController } from './health-check';
|
|||||||
import ProxyController from './proxy-api';
|
import ProxyController from './proxy-api';
|
||||||
import { conditionalMiddleware } from '../middleware/conditional-middleware';
|
import { conditionalMiddleware } from '../middleware/conditional-middleware';
|
||||||
import EdgeController from './edge-api';
|
import EdgeController from './edge-api';
|
||||||
|
import { PublicInviteController } from './public-invite';
|
||||||
|
|
||||||
class IndexRouter extends Controller {
|
class IndexRouter extends Controller {
|
||||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
||||||
super(config);
|
super(config);
|
||||||
|
|
||||||
this.use('/health', new HealthCheckController(config, services).router);
|
this.use('/health', new HealthCheckController(config, services).router);
|
||||||
|
this.use(
|
||||||
|
'/invite',
|
||||||
|
new PublicInviteController(config, services).router,
|
||||||
|
);
|
||||||
this.use('/internal-backstage', new BackstageController(config).router);
|
this.use('/internal-backstage', new BackstageController(config).router);
|
||||||
this.use('/logout', new LogoutController(config, services).router);
|
this.use('/logout', new LogoutController(config, services).router);
|
||||||
this.useWithMiddleware(
|
this.useWithMiddleware(
|
||||||
|
207
src/lib/routes/public-invite.test.ts
Normal file
207
src/lib/routes/public-invite.test.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import createStores from '../../test/fixtures/store';
|
||||||
|
import { createTestConfig } from '../../test/config/test-config';
|
||||||
|
import { createServices } from '../services';
|
||||||
|
import getApp from '../app';
|
||||||
|
import supertest from 'supertest';
|
||||||
|
import permissions from '../../test/fixtures/permissions';
|
||||||
|
import { RoleName, RoleType } from '../types/model';
|
||||||
|
|
||||||
|
describe('Public Signup API', () => {
|
||||||
|
async function getSetup() {
|
||||||
|
const stores = createStores();
|
||||||
|
const perms = permissions();
|
||||||
|
const config = createTestConfig({
|
||||||
|
preRouterHook: perms.hook,
|
||||||
|
});
|
||||||
|
|
||||||
|
config.flagResolver = {
|
||||||
|
isEnabled: jest.fn().mockResolvedValue(true),
|
||||||
|
getAll: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
stores.accessStore = {
|
||||||
|
...stores.accessStore,
|
||||||
|
addUserToRole: jest.fn(),
|
||||||
|
removeRolesOfTypeForUser: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const services = createServices(stores, config);
|
||||||
|
const app = await getApp(config, stores, services);
|
||||||
|
|
||||||
|
await stores.roleStore.create({
|
||||||
|
name: RoleName.VIEWER,
|
||||||
|
roleType: RoleType.ROOT,
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await stores.roleStore.create({
|
||||||
|
name: RoleName.ADMIN,
|
||||||
|
roleType: RoleType.ROOT,
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
request: supertest(app),
|
||||||
|
stores,
|
||||||
|
perms,
|
||||||
|
destroy: () => {
|
||||||
|
services.versionService.destroy();
|
||||||
|
services.clientInstanceService.destroy();
|
||||||
|
services.publicSignupTokenService.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let stores;
|
||||||
|
let request;
|
||||||
|
let destroy;
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
username: 'some-username',
|
||||||
|
email: 'someEmail@example.com',
|
||||||
|
name: 'some-name',
|
||||||
|
password: 'password',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const setup = await getSetup();
|
||||||
|
stores = setup.stores;
|
||||||
|
request = setup.request;
|
||||||
|
destroy = setup.destroy;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
destroy();
|
||||||
|
});
|
||||||
|
const expireAt = (addDays: number = 7): Date => {
|
||||||
|
let now = new Date();
|
||||||
|
now.setDate(now.getDate() + addDays);
|
||||||
|
return now;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBody = () => ({
|
||||||
|
name: 'some-name',
|
||||||
|
expiresAt: expireAt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create user and add to token', async () => {
|
||||||
|
expect.assertions(3);
|
||||||
|
const appName = '123!23';
|
||||||
|
|
||||||
|
stores.clientApplicationsStore.upsert({ appName });
|
||||||
|
stores.publicSignupTokenStore.create({
|
||||||
|
name: 'some-name',
|
||||||
|
expiresAt: expireAt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post('/invite/some-secret/signup')
|
||||||
|
.send(user)
|
||||||
|
.expect(201)
|
||||||
|
.expect(async (res) => {
|
||||||
|
const count = await stores.userStore.count();
|
||||||
|
expect(count).toBe(1);
|
||||||
|
const eventCount = await stores.eventStore.count();
|
||||||
|
expect(eventCount).toBe(2); //USER_CREATED && PUBLIC_SIGNUP_TOKEN_USER_ADDED
|
||||||
|
expect(res.body.username).toBe(user.username);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should validate required fields', async () => {
|
||||||
|
const appName = '123!23';
|
||||||
|
|
||||||
|
stores.clientApplicationsStore.upsert({ appName });
|
||||||
|
stores.publicSignupTokenStore.create({
|
||||||
|
name: 'some-name',
|
||||||
|
expiresAt: expireAt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await request
|
||||||
|
.post('/invite/some-secret/signup')
|
||||||
|
.send({ name: 'test' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
await request
|
||||||
|
.post('/invite/some-secret/signup')
|
||||||
|
.send({ email: 'test@test.com' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
await request
|
||||||
|
.post('/invite/some-secret/signup')
|
||||||
|
.send({ ...user, rootRole: 1 })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
await request.post('/invite/some-secret/signup').send({}).expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not be able to send root role in signup request body', async () => {
|
||||||
|
const appName = '123!23';
|
||||||
|
|
||||||
|
stores.clientApplicationsStore.upsert({ appName });
|
||||||
|
stores.publicSignupTokenStore.create({
|
||||||
|
name: 'some-name',
|
||||||
|
expiresAt: expireAt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = await stores.roleStore.getAll();
|
||||||
|
const adminId = roles.find((role) => role.name === RoleName.ADMIN).id;
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post('/invite/some-secret/signup')
|
||||||
|
.send({ ...user, rootRole: adminId })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not allow a user to register with expired token', async () => {
|
||||||
|
const appName = '123!23';
|
||||||
|
|
||||||
|
stores.clientApplicationsStore.upsert({ appName });
|
||||||
|
stores.publicSignupTokenStore.create({
|
||||||
|
name: 'some-name',
|
||||||
|
expiresAt: expireAt(-1),
|
||||||
|
});
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post('/invite/some-secret/signup')
|
||||||
|
.send(user)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not allow a user to register disabled token', async () => {
|
||||||
|
const appName = '123!23';
|
||||||
|
|
||||||
|
stores.clientApplicationsStore.upsert({ appName });
|
||||||
|
stores.publicSignupTokenStore.create({
|
||||||
|
name: 'some-name',
|
||||||
|
expiresAt: expireAt(),
|
||||||
|
});
|
||||||
|
stores.publicSignupTokenStore.update('some-secret', { enabled: false });
|
||||||
|
|
||||||
|
return request
|
||||||
|
.post('/invite/some-secret/signup')
|
||||||
|
.send(user)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 200 if token is valid', async () => {
|
||||||
|
const appName = '123!23';
|
||||||
|
|
||||||
|
stores.clientApplicationsStore.upsert({ appName });
|
||||||
|
// Create a token
|
||||||
|
const res = await request
|
||||||
|
.post('/api/admin/invite-link/tokens')
|
||||||
|
.send(createBody())
|
||||||
|
.expect(201);
|
||||||
|
const { secret } = res.body;
|
||||||
|
|
||||||
|
return request.get(`/invite/${secret}/validate`).expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 400 if token is invalid', async () => {
|
||||||
|
const appName = '123!23';
|
||||||
|
|
||||||
|
stores.clientApplicationsStore.upsert({ appName });
|
||||||
|
|
||||||
|
return request.get('/invite/some-invalid-secret/validate').expect(400);
|
||||||
|
});
|
||||||
|
});
|
116
src/lib/routes/public-invite.ts
Normal file
116
src/lib/routes/public-invite.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
import Controller from './controller';
|
||||||
|
import { NONE } from '../types/permissions';
|
||||||
|
import { Logger } from '../logger';
|
||||||
|
import { IAuthRequest } from './unleash-types';
|
||||||
|
import { IUnleashConfig, IUnleashServices } from '../types';
|
||||||
|
import { OpenApiService } from '../services/openapi-service';
|
||||||
|
import { createRequestSchema } from '../openapi/util/create-request-schema';
|
||||||
|
import { createResponseSchema } from '../openapi/util/create-response-schema';
|
||||||
|
import { serializeDates } from '../types/serialize-dates';
|
||||||
|
import {
|
||||||
|
emptyResponse,
|
||||||
|
getStandardResponses,
|
||||||
|
} from '../openapi/util/standard-responses';
|
||||||
|
import { PublicSignupTokenService } from '../services/public-signup-token-service';
|
||||||
|
import { PublicSignupTokenSchema } from '../openapi/spec/public-signup-token-schema';
|
||||||
|
import { UserSchema, userSchema } from '../openapi/spec/user-schema';
|
||||||
|
import { CreateInvitedUserSchema } from '../openapi/spec/create-invited-user-schema';
|
||||||
|
|
||||||
|
interface TokenParam {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicInviteController extends Controller {
|
||||||
|
private publicSignupTokenService: PublicSignupTokenService;
|
||||||
|
|
||||||
|
private openApiService: OpenApiService;
|
||||||
|
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
config: IUnleashConfig,
|
||||||
|
{
|
||||||
|
publicSignupTokenService,
|
||||||
|
openApiService,
|
||||||
|
}: Pick<
|
||||||
|
IUnleashServices,
|
||||||
|
'publicSignupTokenService' | 'openApiService'
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
super(config);
|
||||||
|
this.publicSignupTokenService = publicSignupTokenService;
|
||||||
|
this.openApiService = openApiService;
|
||||||
|
this.logger = config.getLogger('validate-invite-token-controller.js');
|
||||||
|
|
||||||
|
this.route({
|
||||||
|
method: 'get',
|
||||||
|
path: '/:token/validate',
|
||||||
|
handler: this.validate,
|
||||||
|
permission: NONE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
tags: ['Public signup tokens'],
|
||||||
|
operationId: 'validatePublicSignupToken',
|
||||||
|
responses: {
|
||||||
|
200: emptyResponse,
|
||||||
|
...getStandardResponses(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.route({
|
||||||
|
method: 'post',
|
||||||
|
path: '/:token/signup',
|
||||||
|
handler: this.addTokenUser,
|
||||||
|
permission: NONE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
tags: ['Public signup tokens'],
|
||||||
|
operationId: 'addPublicSignupTokenUser',
|
||||||
|
requestBody: createRequestSchema('createInvitedUserSchema'),
|
||||||
|
responses: {
|
||||||
|
200: createResponseSchema('userSchema'),
|
||||||
|
...getStandardResponses(400, 409),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(
|
||||||
|
req: IAuthRequest<TokenParam, void>,
|
||||||
|
res: Response<PublicSignupTokenSchema>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { token } = req.params;
|
||||||
|
const valid = await this.publicSignupTokenService.validate(token);
|
||||||
|
if (valid) {
|
||||||
|
return res.status(200).end();
|
||||||
|
} else {
|
||||||
|
return res.status(400).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTokenUser(
|
||||||
|
req: IAuthRequest<TokenParam, void, CreateInvitedUserSchema>,
|
||||||
|
res: Response<UserSchema>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { token } = req.params;
|
||||||
|
const valid = await this.publicSignupTokenService.validate(token);
|
||||||
|
if (!valid) {
|
||||||
|
return res.status(400).end();
|
||||||
|
}
|
||||||
|
const user = await this.publicSignupTokenService.addTokenUser(
|
||||||
|
token,
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
this.openApiService.respondWithValidation(
|
||||||
|
201,
|
||||||
|
res,
|
||||||
|
userSchema.$id,
|
||||||
|
serializeDates(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -6,14 +6,15 @@ import { PublicSignupTokenSchema } from '../openapi/spec/public-signup-token-sch
|
|||||||
import { IRoleStore } from '../types/stores/role-store';
|
import { IRoleStore } from '../types/stores/role-store';
|
||||||
import { IPublicSignupTokenCreate } from '../types/models/public-signup-token';
|
import { IPublicSignupTokenCreate } from '../types/models/public-signup-token';
|
||||||
import { PublicSignupTokenCreateSchema } from '../openapi/spec/public-signup-token-create-schema';
|
import { PublicSignupTokenCreateSchema } from '../openapi/spec/public-signup-token-create-schema';
|
||||||
|
import { CreateInvitedUserSchema } from 'lib/openapi/spec/create-invited-user-schema';
|
||||||
import { RoleName } from '../types/model';
|
import { RoleName } from '../types/model';
|
||||||
import { IEventStore } from '../types/stores/event-store';
|
import { IEventStore } from '../types/stores/event-store';
|
||||||
import {
|
import {
|
||||||
PublicSignupTokenCreatedEvent,
|
PublicSignupTokenCreatedEvent,
|
||||||
PublicSignupTokenManuallyExpiredEvent,
|
PublicSignupTokenUpdatedEvent,
|
||||||
PublicSignupTokenUserAddedEvent,
|
PublicSignupTokenUserAddedEvent,
|
||||||
} from '../types/events';
|
} from '../types/events';
|
||||||
import UserService, { ICreateUser } from './user-service';
|
import UserService from './user-service';
|
||||||
import { IUser } from '../types/user';
|
import { IUser } from '../types/user';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
|
|
||||||
@ -56,7 +57,7 @@ export class PublicSignupTokenService {
|
|||||||
|
|
||||||
private getUrl(secret: string): string {
|
private getUrl(secret: string): string {
|
||||||
return new URL(
|
return new URL(
|
||||||
`${this.unleashBase}/invite-link/${secret}/signup`,
|
`${this.unleashBase}/new-user?invite=${secret}`,
|
||||||
).toString();
|
).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,20 +77,30 @@ export class PublicSignupTokenService {
|
|||||||
return this.store.isValid(secret);
|
return this.store.isValid(secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setExpiry(
|
public async update(
|
||||||
secret: string,
|
secret: string,
|
||||||
expireAt: Date,
|
{ expiresAt, enabled }: { expiresAt?: Date; enabled?: boolean },
|
||||||
|
createdBy: string,
|
||||||
): Promise<PublicSignupTokenSchema> {
|
): Promise<PublicSignupTokenSchema> {
|
||||||
return this.store.setExpiry(secret, expireAt);
|
const result = await this.store.update(secret, { expiresAt, enabled });
|
||||||
|
await this.eventStore.store(
|
||||||
|
new PublicSignupTokenUpdatedEvent({
|
||||||
|
createdBy,
|
||||||
|
data: { secret, enabled, expiresAt },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addTokenUser(
|
public async addTokenUser(
|
||||||
secret: string,
|
secret: string,
|
||||||
createUser: ICreateUser,
|
createUser: CreateInvitedUserSchema,
|
||||||
): Promise<IUser> {
|
): Promise<IUser> {
|
||||||
const token = await this.get(secret);
|
const token = await this.get(secret);
|
||||||
createUser.rootRole = token.role.id;
|
const user = await this.userService.createUser({
|
||||||
const user = await this.userService.createUser(createUser);
|
...createUser,
|
||||||
|
rootRole: token.role.id,
|
||||||
|
});
|
||||||
await this.store.addTokenUser(secret, user.id);
|
await this.store.addTokenUser(secret, user.id);
|
||||||
await this.eventStore.store(
|
await this.eventStore.store(
|
||||||
new PublicSignupTokenUserAddedEvent({
|
new PublicSignupTokenUserAddedEvent({
|
||||||
@ -100,22 +111,6 @@ export class PublicSignupTokenService {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(secret: string, expiredBy: string): Promise<void> {
|
|
||||||
await this.expireToken(secret);
|
|
||||||
await this.eventStore.store(
|
|
||||||
new PublicSignupTokenManuallyExpiredEvent({
|
|
||||||
createdBy: expiredBy,
|
|
||||||
data: { secret },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async expireToken(
|
|
||||||
secret: string,
|
|
||||||
): Promise<PublicSignupTokenSchema> {
|
|
||||||
return this.store.setExpiry(secret, new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createNewPublicSignupToken(
|
public async createNewPublicSignupToken(
|
||||||
tokenCreate: PublicSignupTokenCreateSchema,
|
tokenCreate: PublicSignupTokenCreateSchema,
|
||||||
createdBy: string,
|
createdBy: string,
|
||||||
|
@ -80,8 +80,7 @@ export const PAT_CREATED = 'pat-created';
|
|||||||
|
|
||||||
export const PUBLIC_SIGNUP_TOKEN_CREATED = 'public-signup-token-created';
|
export const PUBLIC_SIGNUP_TOKEN_CREATED = 'public-signup-token-created';
|
||||||
export const PUBLIC_SIGNUP_TOKEN_USER_ADDED = 'public-signup-token-user-added';
|
export const PUBLIC_SIGNUP_TOKEN_USER_ADDED = 'public-signup-token-user-added';
|
||||||
export const PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED =
|
export const PUBLIC_SIGNUP_TOKEN_TOKEN_UPDATED = 'public-signup-token-updated';
|
||||||
'public-signup-token-manually-expired';
|
|
||||||
|
|
||||||
export interface IBaseEvent {
|
export interface IBaseEvent {
|
||||||
type: string;
|
type: string;
|
||||||
@ -548,11 +547,11 @@ export class PublicSignupTokenCreatedEvent extends BaseEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PublicSignupTokenManuallyExpiredEvent extends BaseEvent {
|
export class PublicSignupTokenUpdatedEvent extends BaseEvent {
|
||||||
readonly data: any;
|
readonly data: any;
|
||||||
|
|
||||||
constructor(eventData: { createdBy: string; data: any }) {
|
constructor(eventData: { createdBy: string; data: any }) {
|
||||||
super(PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED, eventData.createdBy);
|
super(PUBLIC_SIGNUP_TOKEN_TOKEN_UPDATED, eventData.createdBy);
|
||||||
this.data = eventData.data;
|
this.data = eventData.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,9 +10,9 @@ export interface IPublicSignupTokenStore
|
|||||||
): Promise<PublicSignupTokenSchema>;
|
): Promise<PublicSignupTokenSchema>;
|
||||||
addTokenUser(secret: string, userId: number): Promise<void>;
|
addTokenUser(secret: string, userId: number): Promise<void>;
|
||||||
isValid(secret): Promise<boolean>;
|
isValid(secret): Promise<boolean>;
|
||||||
setExpiry(
|
update(
|
||||||
secret: string,
|
secret: string,
|
||||||
expiresAt: Date,
|
value: { expiresAt?: Date; enabled?: boolean },
|
||||||
): Promise<PublicSignupTokenSchema>;
|
): Promise<PublicSignupTokenSchema>;
|
||||||
delete(secret: string): Promise<void>;
|
delete(secret: string): Promise<void>;
|
||||||
count(): Promise<number>;
|
count(): Promise<number>;
|
||||||
|
@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
exports.up = function (db, callback) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
ALTER table public_signup_tokens
|
||||||
|
ADD COLUMN IF NOT EXISTS enabled boolean DEFAULT true
|
||||||
|
`,
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (db, callback) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
ALTER table public_signup_tokens
|
||||||
|
DROP COLUMN enabled
|
||||||
|
`,
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
};
|
@ -3,7 +3,6 @@ import dbInit from '../../helpers/database-init';
|
|||||||
import getLogger from '../../../fixtures/no-logger';
|
import getLogger from '../../../fixtures/no-logger';
|
||||||
import { RoleName } from '../../../../lib/types/model';
|
import { RoleName } from '../../../../lib/types/model';
|
||||||
import { PublicSignupTokenCreateSchema } from '../../../../lib/openapi/spec/public-signup-token-create-schema';
|
import { PublicSignupTokenCreateSchema } from '../../../../lib/openapi/spec/public-signup-token-create-schema';
|
||||||
import { CreateUserSchema } from '../../../../lib/openapi/spec/create-user-schema';
|
|
||||||
|
|
||||||
let stores;
|
let stores;
|
||||||
let db;
|
let db;
|
||||||
@ -99,14 +98,12 @@ test('no permission to validate a token', async () => {
|
|||||||
createdBy: 'admin@example.com',
|
createdBy: 'admin@example.com',
|
||||||
roleId: 3,
|
roleId: 3,
|
||||||
});
|
});
|
||||||
await request
|
await request.get('/invite/some-secret/validate').expect(200);
|
||||||
.post('/api/admin/invite-link/tokens/some-secret/validate')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
await destroy();
|
await destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return 401 if token can not be validate', async () => {
|
test('should return 400 if token can not be validate', async () => {
|
||||||
const preHook = (app, config, { userService, accessService }) => {
|
const preHook = (app, config, { userService, accessService }) => {
|
||||||
app.use('/api/admin/', async (req, res, next) => {
|
app.use('/api/admin/', async (req, res, next) => {
|
||||||
const admin = await accessService.getRootRole(RoleName.ADMIN);
|
const admin = await accessService.getRootRole(RoleName.ADMIN);
|
||||||
@ -121,9 +118,7 @@ test('should return 401 if token can not be validate', async () => {
|
|||||||
|
|
||||||
const { request, destroy } = await setupAppWithCustomAuth(stores, preHook);
|
const { request, destroy } = await setupAppWithCustomAuth(stores, preHook);
|
||||||
|
|
||||||
await request
|
await request.get('/invite/some-invalid-secret/validate').expect(400);
|
||||||
.post('/api/admin/invite-link/tokens/some-invalid-secret/validate')
|
|
||||||
.expect(401);
|
|
||||||
|
|
||||||
await destroy();
|
await destroy();
|
||||||
});
|
});
|
||||||
@ -149,27 +144,25 @@ test('users can signup with invite-link', async () => {
|
|||||||
name: 'some-name',
|
name: 'some-name',
|
||||||
expiresAt: expireAt(),
|
expiresAt: expireAt(),
|
||||||
secret: 'some-secret',
|
secret: 'some-secret',
|
||||||
url: 'http://localhost:4242/invite-lint/some-secret/signup',
|
url: 'http://localhost:4242/invite/some-secret/signup',
|
||||||
createAt: new Date(),
|
createAt: new Date(),
|
||||||
createdBy: 'admin@example.com',
|
createdBy: 'admin@example.com',
|
||||||
roleId: 3,
|
roleId: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createUser: CreateUserSchema = {
|
const createUser = {
|
||||||
username: 'some-username',
|
name: 'some-username',
|
||||||
email: 'some@example.com',
|
email: 'some@example.com',
|
||||||
password: 'eweggwEG',
|
password: 'eweggwEG',
|
||||||
sendEmail: false,
|
|
||||||
rootRole: 1,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await request
|
await request
|
||||||
.post('/api/admin/invite-link/tokens/some-secret/signup')
|
.post('/invite/some-secret/signup')
|
||||||
.send(createUser)
|
.send(createUser)
|
||||||
.expect(201)
|
.expect(201)
|
||||||
.expect((res) => {
|
.expect((res) => {
|
||||||
const user = res.body;
|
const user = res.body;
|
||||||
expect(user.username).toBe('some-username');
|
expect(user.name).toBe('some-username');
|
||||||
});
|
});
|
||||||
|
|
||||||
await destroy();
|
await destroy();
|
||||||
|
@ -724,6 +724,29 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
"createInvitedUserSchema": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"email",
|
||||||
|
"name",
|
||||||
|
"password",
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
"createUserSchema": {
|
"createUserSchema": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -2452,6 +2475,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
"expiresAt": {
|
"expiresAt": {
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -2483,6 +2509,7 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
"expiresAt",
|
"expiresAt",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"createdBy",
|
"createdBy",
|
||||||
|
"enabled",
|
||||||
"role",
|
"role",
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -2490,14 +2517,14 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
"publicSignupTokenUpdateSchema": {
|
"publicSignupTokenUpdateSchema": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
"expiresAt": {
|
"expiresAt": {
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"expiresAt",
|
|
||||||
],
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
"publicSignupTokensSchema": {
|
"publicSignupTokensSchema": {
|
||||||
@ -4600,27 +4627,6 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/admin/invite-link/tokens/{token}": {
|
"/api/admin/invite-link/tokens/{token}": {
|
||||||
"delete": {
|
|
||||||
"operationId": "deletePublicSignupToken",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "path",
|
|
||||||
"name": "token",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "This response has no body.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"Public signup tokens",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getPublicSignupToken",
|
"operationId": "getPublicSignupToken",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@ -4672,79 +4678,16 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
"description": "publicSignupTokenUpdateSchema",
|
"description": "publicSignupTokenUpdateSchema",
|
||||||
"required": true,
|
"required": true,
|
||||||
},
|
},
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "This response has no body.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"Public signup tokens",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"/api/admin/invite-link/tokens/{token}/signup": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "addPublicSignupTokenUser",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "path",
|
|
||||||
"name": "token",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/createUserSchema",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"description": "createUserSchema",
|
|
||||||
"required": true,
|
|
||||||
},
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/userSchema",
|
"$ref": "#/components/schemas/publicSignupTokenSchema",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"description": "userSchema",
|
"description": "publicSignupTokenSchema",
|
||||||
},
|
|
||||||
"409": {
|
|
||||||
"description": "The provided resource can not be created or updated because it would conflict with the current state of the resource or with an already existing resource, respectively.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"Public signup tokens",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"/api/admin/invite-link/tokens/{token}/validate": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "validatePublicSignupToken",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "path",
|
|
||||||
"name": "token",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "This response has no body.",
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "This response has no body.",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -7491,6 +7434,79 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/invite/{token}/signup": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "addPublicSignupTokenUser",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "token",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/createInvitedUserSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"description": "createInvitedUserSchema",
|
||||||
|
"required": true,
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/userSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"description": "userSchema",
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "The request data does not match what we expect.",
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "The provided resource can not be created or updated because it would conflict with the current state of the resource or with an already existing resource, respectively.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Public signup tokens",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/invite/{token}/validate": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "validatePublicSignupToken",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "token",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "This response has no body.",
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "The request data does not match what we expect.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Public signup tokens",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
|
18
src/test/fixtures/fake-public-signup-store.ts
vendored
18
src/test/fixtures/fake-public-signup-store.ts
vendored
@ -17,7 +17,9 @@ export default class FakePublicSignupStore implements IPublicSignupTokenStore {
|
|||||||
|
|
||||||
async isValid(secret: string): Promise<boolean> {
|
async isValid(secret: string): Promise<boolean> {
|
||||||
const token = this.tokens.find((t) => t.secret === secret);
|
const token = this.tokens.find((t) => t.secret === secret);
|
||||||
return Promise.resolve(token && new Date(token.expiresAt) > new Date());
|
return Promise.resolve(
|
||||||
|
token && new Date(token.expiresAt) > new Date() && token.enabled,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async count(): Promise<number> {
|
async count(): Promise<number> {
|
||||||
@ -54,18 +56,26 @@ export default class FakePublicSignupStore implements IPublicSignupTokenStore {
|
|||||||
type: '',
|
type: '',
|
||||||
id: 1,
|
id: 1,
|
||||||
},
|
},
|
||||||
|
enabled: true,
|
||||||
createdBy: newToken.createdBy,
|
createdBy: newToken.createdBy,
|
||||||
};
|
};
|
||||||
this.tokens.push(token);
|
this.tokens.push(token);
|
||||||
return Promise.resolve(token);
|
return Promise.resolve(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setExpiry(
|
async update(
|
||||||
secret: string,
|
secret: string,
|
||||||
expiresAt: Date,
|
{ expiresAt, enabled }: { expiresAt?: Date; enabled?: boolean },
|
||||||
): Promise<PublicSignupTokenSchema> {
|
): Promise<PublicSignupTokenSchema> {
|
||||||
const token = await this.get(secret);
|
const token = await this.get(secret);
|
||||||
token.expiresAt = expiresAt.toISOString();
|
if (expiresAt) {
|
||||||
|
token.expiresAt = expiresAt.toISOString();
|
||||||
|
}
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
token.enabled = enabled;
|
||||||
|
}
|
||||||
|
const index = this.tokens.findIndex((t) => t.secret === secret);
|
||||||
|
this.tokens[index] = token;
|
||||||
return Promise.resolve(token);
|
return Promise.resolve(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
6
src/test/fixtures/fake-role-store.ts
vendored
6
src/test/fixtures/fake-role-store.ts
vendored
@ -23,7 +23,11 @@ export default class FakeRoleStore implements IRoleStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(role: ICustomRoleInsert): Promise<ICustomRole> {
|
async create(role: ICustomRoleInsert): Promise<ICustomRole> {
|
||||||
const roleCreated = { ...role, id: 1, type: 'some-type' };
|
const roleCreated = {
|
||||||
|
...role,
|
||||||
|
type: 'some-type',
|
||||||
|
id: this.roles.length,
|
||||||
|
};
|
||||||
this.roles.push(roleCreated);
|
this.roles.push(roleCreated);
|
||||||
return Promise.resolve(roleCreated);
|
return Promise.resolve(roleCreated);
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,9 @@ Please refer to [_how to create API tokens_](../user_guide/api-token) on how to
|
|||||||
Please note that it may take up to 60 seconds for the new key to propagate to all Unleash instances due to eager caching.
|
Please note that it may take up to 60 seconds for the new key to propagate to all Unleash instances due to eager caching.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
If you need an API token to use in a client SDK you should create a "client token" as these have fewer access rights.
|
If you need an API token to use in a client SDK you should create a "client token" as these have fewer access rights.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Step 2: Use Admin API {#step-2-use-admin-api}
|
## Step 2: Use Admin API {#step-2-use-admin-api}
|
||||||
@ -29,7 +31,7 @@ curl -X POST -H "Content-Type: application/json" \
|
|||||||
|
|
||||||
**Great success!** We have now enabled the feature toggle. We can also verify that it was actually changed by the API user by navigating to the Event log (history) for this feature toggle.
|
**Great success!** We have now enabled the feature toggle. We can also verify that it was actually changed by the API user by navigating to the Event log (history) for this feature toggle.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## API overview {#api-overview}
|
## API overview {#api-overview}
|
||||||
|
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
title: How to create and assign custom project roles
|
title: How to create and assign custom project roles
|
||||||
---
|
---
|
||||||
|
|
||||||
import VideoContent from '@site/src/components/VideoContent.jsx'
|
import VideoContent from '@site/src/components/VideoContent.jsx'
|
||||||
|
|
||||||
:::info availability
|
:::info availability
|
||||||
|
|
||||||
Custom project roles were introduced in **Unleash 4.6** and are only available in Unleash Enterprise.
|
Custom project roles were introduced in **Unleash 4.6** and are only available in Unleash Enterprise.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user