mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +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 run test
|
||||
- run: yarn run fmt:check
|
||||
- run: yarn run ts:check # TODO: optimize
|
||||
|
@ -20,6 +20,7 @@
|
||||
"test:watch": "vitest watch",
|
||||
"fmt": "prettier src --write --loglevel warn",
|
||||
"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:heroku": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=example@example.com",
|
||||
"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 { ReactComponent as EmailIcon } from 'assets/icons/email.svg';
|
||||
import { useStyles } from './ConfirmUserEmail.styles';
|
||||
import UserInviteLink from '../ConfirmUserLink/UserInviteLink/UserInviteLink';
|
||||
import { LinkField } from '../../LinkField/LinkField';
|
||||
|
||||
interface IConfirmUserEmailProps {
|
||||
open: boolean;
|
||||
@ -15,32 +14,29 @@ const ConfirmUserEmail = ({
|
||||
open,
|
||||
closeConfirm,
|
||||
inviteLink,
|
||||
}: IConfirmUserEmailProps) => {
|
||||
const { classes: styles } = useStyles();
|
||||
return (
|
||||
<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>
|
||||
<div className={styles.iconContainer}>
|
||||
<EmailIcon className={styles.emailIcon} />
|
||||
</div>
|
||||
<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>
|
||||
<UserInviteLink inviteLink={inviteLink} />
|
||||
</Dialogue>
|
||||
);
|
||||
};
|
||||
}: 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;
|
||||
|
@ -2,7 +2,7 @@ import { Typography } from '@mui/material';
|
||||
import { Alert } from '@mui/material';
|
||||
import { useThemeStyles } from 'themes/themeStyles';
|
||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||
import UserInviteLink from './UserInviteLink/UserInviteLink';
|
||||
import { LinkField } from '../../LinkField/LinkField';
|
||||
|
||||
interface IConfirmUserLink {
|
||||
open: boolean;
|
||||
@ -28,7 +28,7 @@ const ConfirmUserLink = ({
|
||||
A new team member has been added. Please provide them with
|
||||
the following link to get started:
|
||||
</Typography>
|
||||
<UserInviteLink inviteLink={inviteLink} />
|
||||
<LinkField inviteLink={inviteLink} />
|
||||
|
||||
<Typography variant="body1">
|
||||
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 useToast from 'hooks/useToast';
|
||||
|
||||
interface IInviteLinkProps {
|
||||
interface ILinkFieldProps {
|
||||
inviteLink: string;
|
||||
small?: boolean;
|
||||
}
|
||||
|
||||
const UserInviteLink = ({ inviteLink }: IInviteLinkProps) => {
|
||||
export const LinkField = ({ inviteLink, small }: ILinkFieldProps) => {
|
||||
const { setToastData } = useToast();
|
||||
|
||||
const handleCopy = () => {
|
||||
@ -34,26 +35,38 @@ const UserInviteLink = ({ inviteLink }: IInviteLinkProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#efefef',
|
||||
padding: '2rem',
|
||||
borderRadius: '3px',
|
||||
margin: '1rem 0',
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: theme => theme.palette.secondaryContainer,
|
||||
py: 4,
|
||||
px: 4,
|
||||
borderRadius: theme => `${theme.shape.borderRadius}px`,
|
||||
my: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
wordBreak: 'break-all',
|
||||
...(small
|
||||
? {
|
||||
my: 0,
|
||||
py: 0.5,
|
||||
pl: 1.5,
|
||||
pr: 0.5,
|
||||
fontSize: theme => theme.typography.body2.fontSize,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{inviteLink}
|
||||
<Tooltip title="Copy link" arrow>
|
||||
<IconButton onClick={handleCopy} size="large">
|
||||
<CopyIcon />
|
||||
<IconButton
|
||||
onClick={handleCopy}
|
||||
size={small ? 'small' : 'large'}
|
||||
sx={small ? { ml: 0.5 } : {}}
|
||||
>
|
||||
<CopyIcon sx={{ fontSize: small ? 20 : undefined }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInviteLink;
|
@ -5,13 +5,20 @@ import AccessContext from 'contexts/AccessContext';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
|
||||
import { InviteLinkBar } from './InviteLinkBar/InviteLinkBar';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
|
||||
const UsersAdmin = () => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminMenu />
|
||||
<ConditionallyRender
|
||||
condition={Boolean(uiConfig?.flags?.publicSignup)}
|
||||
show={<InviteLinkBar />}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={hasAccess(ADMIN)}
|
||||
show={<UsersList />}
|
||||
|
@ -246,7 +246,7 @@ const UsersList = () => {
|
||||
color="primary"
|
||||
onClick={() => navigate('/admin/create-user')}
|
||||
>
|
||||
New user
|
||||
Add new user
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
|
@ -42,19 +42,6 @@ const GeneralSelect: React.FC<IGeneralSelectProps> = ({
|
||||
fullWidth,
|
||||
...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) => {
|
||||
event.preventDefault();
|
||||
onChange(String(event.target.value));
|
||||
@ -79,7 +66,17 @@ const GeneralSelect: React.FC<IGeneralSelectProps> = ({
|
||||
IconComponent={KeyboardArrowDownOutlined}
|
||||
{...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>
|
||||
</FormControl>
|
||||
);
|
||||
|
@ -385,6 +385,14 @@ exports[`returns all baseRoutes 1`] = `
|
||||
"title": "Users",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"menu": {},
|
||||
"parent": "/admin",
|
||||
"path": "/admin/invite-link",
|
||||
"title": "Invite link",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"flag": "UG",
|
||||
|
@ -57,6 +57,7 @@ import { CreateGroup } from 'component/admin/groups/CreateGroup/CreateGroup';
|
||||
import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup';
|
||||
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
|
||||
import { CorsAdmin } from 'component/admin/cors';
|
||||
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
|
||||
|
||||
export const routes: IRoute[] = [
|
||||
// Splash
|
||||
@ -434,6 +435,14 @@ export const routes: IRoute[] = [
|
||||
type: 'protected',
|
||||
menu: {},
|
||||
},
|
||||
{
|
||||
path: '/admin/invite-link',
|
||||
parent: '/admin',
|
||||
title: 'Invite link',
|
||||
component: InviteLink,
|
||||
type: 'protected',
|
||||
menu: {},
|
||||
},
|
||||
{
|
||||
path: '/admin/groups',
|
||||
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 { useStyles } from 'component/user/Login/Login.styles';
|
||||
import useQueryParams from 'hooks/useQueryParams';
|
||||
import ResetPasswordSuccess from '../common/ResetPasswordSuccess/ResetPasswordSuccess';
|
||||
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
|
||||
import { DEMO_TYPE } from 'constants/authTypes';
|
||||
import Authentication from '../Authentication/Authentication';
|
||||
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
|
||||
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { parseRedirectParam } from 'component/user/Login/parseRedirectParam';
|
||||
|
||||
const Login = () => {
|
||||
@ -16,6 +16,7 @@ const Login = () => {
|
||||
const { user } = useAuthUser();
|
||||
const query = useQueryParams();
|
||||
const resetPassword = query.get('reset') === 'true';
|
||||
const invited = query.get('invited') === 'true';
|
||||
const redirect = query.get('redirect') || '/';
|
||||
|
||||
if (user) {
|
||||
@ -25,6 +26,24 @@ const Login = () => {
|
||||
return (
|
||||
<StandaloneLayout>
|
||||
<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
|
||||
condition={authDetails?.type !== DEMO_TYPE}
|
||||
show={
|
||||
@ -33,11 +52,6 @@ const Login = () => {
|
||||
</h2>
|
||||
}
|
||||
/>
|
||||
|
||||
<ConditionallyRender
|
||||
condition={resetPassword}
|
||||
show={<ResetPasswordSuccess />}
|
||||
/>
|
||||
<Authentication redirect={redirect} />
|
||||
</div>
|
||||
</StandaloneLayout>
|
||||
|
@ -1,27 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 { 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 DividerText from 'component/common/DividerText/DividerText';
|
||||
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
|
||||
import { useInviteUserToken } from 'hooks/api/getters/useInviteUserToken/useInviteUserToken';
|
||||
import ResetPasswordForm from '../common/ResetPasswordForm/ResetPasswordForm';
|
||||
import InvalidToken from '../common/InvalidToken/InvalidToken';
|
||||
import { NewUserWrapper } from './NewUserWrapper/NewUserWrapper';
|
||||
import ResetPasswordError from '../common/ResetPasswordError/ResetPasswordError';
|
||||
|
||||
export const NewUser = () => {
|
||||
const { authDetails } = useAuthDetails();
|
||||
const { setToastApiError } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [apiError, setApiError] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const {
|
||||
token,
|
||||
data,
|
||||
data: passwordResetData,
|
||||
loading: resetLoading,
|
||||
setLoading,
|
||||
invalidToken,
|
||||
isValidToken,
|
||||
} = 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;
|
||||
|
||||
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 (
|
||||
<NewUserWrapper loading={resetLoading || inviteLoading}>
|
||||
<InvalidToken />
|
||||
@ -31,7 +86,12 @@ export const NewUser = () => {
|
||||
|
||||
return (
|
||||
<NewUserWrapper
|
||||
loading={resetLoading || inviteLoading}
|
||||
loading={
|
||||
resetLoading ||
|
||||
inviteLoading ||
|
||||
isUserSubmitting ||
|
||||
isPasswordSubmitting
|
||||
}
|
||||
title={
|
||||
passwordDisabled
|
||||
? 'Connect your account and start your journey'
|
||||
@ -39,14 +99,14 @@ export const NewUser = () => {
|
||||
}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={data?.createdBy}
|
||||
condition={passwordResetData?.createdBy}
|
||||
show={
|
||||
<Typography
|
||||
variant="body1"
|
||||
data-loading
|
||||
sx={{ textAlign: 'center', mb: 2 }}
|
||||
>
|
||||
{data?.createdBy}
|
||||
{passwordResetData?.createdBy}
|
||||
<br /> has invited you to join Unleash.
|
||||
</Typography>
|
||||
}
|
||||
@ -82,7 +142,7 @@ export const NewUser = () => {
|
||||
show={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={data?.email}
|
||||
condition={passwordResetData?.email}
|
||||
show={() => (
|
||||
<Typography
|
||||
data-loading
|
||||
@ -96,22 +156,32 @@ export const NewUser = () => {
|
||||
<TextField
|
||||
data-loading
|
||||
type="email"
|
||||
value={data?.email || ''}
|
||||
id="username"
|
||||
value={
|
||||
isValidToken
|
||||
? passwordResetData?.email || ''
|
||||
: email
|
||||
}
|
||||
id="email"
|
||||
label="Email"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ my: 1 }}
|
||||
disabled={Boolean(data?.email)}
|
||||
disabled={isValidToken}
|
||||
fullWidth
|
||||
required
|
||||
onChange={e => {
|
||||
if (isValidToken) {
|
||||
return;
|
||||
}
|
||||
setEmail(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(invite)}
|
||||
condition={Boolean(isValidInvite)}
|
||||
show={() => (
|
||||
<TextField
|
||||
data-loading
|
||||
value=""
|
||||
value={name}
|
||||
id="username"
|
||||
label="Full name"
|
||||
variant="outlined"
|
||||
@ -119,16 +189,20 @@ export const NewUser = () => {
|
||||
sx={{ my: 1 }}
|
||||
fullWidth
|
||||
required
|
||||
onChange={e => {
|
||||
setName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Typography variant="body1" data-loading sx={{ mt: 2 }}>
|
||||
Set a password for your account.
|
||||
</Typography>
|
||||
<ResetPasswordForm
|
||||
token={token}
|
||||
setLoading={setLoading}
|
||||
<ConditionallyRender
|
||||
condition={apiError && isValidToken}
|
||||
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 { useStyles } from './ResetPassword.styles';
|
||||
import { Typography } from '@mui/material';
|
||||
@ -6,18 +9,37 @@ import InvalidToken from '../common/InvalidToken/InvalidToken';
|
||||
import useResetPassword from 'hooks/api/getters/useResetPassword/useResetPassword';
|
||||
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
|
||||
import ResetPasswordForm from '../common/ResetPasswordForm/ResetPasswordForm';
|
||||
import ResetPasswordError from '../common/ResetPasswordError/ResetPasswordError';
|
||||
import { useAuthResetPasswordApi } from 'hooks/api/actions/useAuthResetPasswordApi/useAuthResetPasswordApi';
|
||||
|
||||
const ResetPassword = () => {
|
||||
const { classes: styles } = useStyles();
|
||||
const { token, loading, setLoading, invalidToken } = useResetPassword();
|
||||
const ref = useLoading(loading);
|
||||
const { token, loading, setLoading, isValidToken } = useResetPassword();
|
||||
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 (
|
||||
<div ref={ref}>
|
||||
<StandaloneLayout>
|
||||
<div className={styles.resetPassword}>
|
||||
<ConditionallyRender
|
||||
condition={invalidToken}
|
||||
condition={!isValidToken}
|
||||
show={<InvalidToken />}
|
||||
elseShow={
|
||||
<>
|
||||
@ -29,10 +51,11 @@ const ResetPassword = () => {
|
||||
Reset password
|
||||
</Typography>
|
||||
|
||||
<ResetPasswordForm
|
||||
token={token}
|
||||
setLoading={setLoading}
|
||||
<ConditionallyRender
|
||||
condition={hasApiError}
|
||||
show={<ResetPasswordError />}
|
||||
/>
|
||||
<ResetPasswordForm onSubmit={onSubmit} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
@ -6,11 +6,13 @@ import { useThemeStyles } from 'themes/themeStyles';
|
||||
import classnames from 'classnames';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
|
||||
import { useUserInvite } from 'hooks/api/getters/useUserInvite/useUserInvite';
|
||||
|
||||
const InvalidToken: VFC = () => {
|
||||
const { authDetails } = useAuthDetails();
|
||||
const { classes: themeStyles } = useThemeStyles();
|
||||
const passwordDisabled = authDetails?.defaultHidden === true;
|
||||
const { secret } = useUserInvite(); // NOTE: can be enhanced with "expired token"
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -43,22 +45,35 @@ const InvalidToken: VFC = () => {
|
||||
</>
|
||||
}
|
||||
elseShow={
|
||||
<>
|
||||
<Typography variant="subtitle1">
|
||||
Your token has either been used to reset your
|
||||
password, or it has expired. Please 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>
|
||||
</>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(secret)}
|
||||
show={
|
||||
<Typography variant="subtitle1">
|
||||
Provided invite link is invalid or expired.
|
||||
Please request a new URL in order to create your
|
||||
account.
|
||||
</Typography>
|
||||
}
|
||||
elseShow={
|
||||
<>
|
||||
<Typography variant="subtitle1">
|
||||
Your token has either been used to reset
|
||||
your password, or it has expired. Please
|
||||
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>
|
||||
|
@ -10,30 +10,24 @@ import React, {
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useThemeStyles } from 'themes/themeStyles';
|
||||
import { OK } from 'constants/statusCodes';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import ResetPasswordError from '../ResetPasswordError/ResetPasswordError';
|
||||
import PasswordChecker from './PasswordChecker/PasswordChecker';
|
||||
import PasswordMatcher from './PasswordMatcher/PasswordMatcher';
|
||||
import { useStyles } from './ResetPasswordForm.styles';
|
||||
import { formatApiPath } from 'utils/formatPath';
|
||||
import PasswordField from 'component/common/PasswordField/PasswordField';
|
||||
|
||||
interface IResetPasswordProps {
|
||||
token: string;
|
||||
setLoading: Dispatch<SetStateAction<boolean>>;
|
||||
onSubmit: (password: string) => void;
|
||||
}
|
||||
|
||||
const ResetPasswordForm = ({ token, setLoading }: IResetPasswordProps) => {
|
||||
const ResetPasswordForm = ({ onSubmit }: IResetPasswordProps) => {
|
||||
const { classes: styles } = useStyles();
|
||||
const { classes: themeStyles } = useThemeStyles();
|
||||
const [apiError, setApiError] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPasswordChecker, setShowPasswordChecker] = useState(false);
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [matchingPasswords, setMatchingPasswords] = useState(false);
|
||||
const [validOwaspPassword, setValidOwaspPassword] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const submittable = matchingPasswords && validOwaspPassword;
|
||||
|
||||
@ -53,107 +47,69 @@ const ResetPasswordForm = ({ token, setLoading }: IResetPasswordProps) => {
|
||||
}
|
||||
}, [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) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (submittable) {
|
||||
submitResetPassword();
|
||||
onSubmit(password);
|
||||
}
|
||||
};
|
||||
|
||||
const started = Boolean(password && confirmPassword);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={apiError}
|
||||
show={<ResetPasswordError />}
|
||||
<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' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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
|
||||
started={started}
|
||||
matchingPasswords={matchingPasswords}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
className={styles.button}
|
||||
data-loading
|
||||
disabled={!submittable}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
<PasswordMatcher
|
||||
started={started}
|
||||
matchingPasswords={matchingPasswords}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
className={styles.button}
|
||||
data-loading
|
||||
disabled={!submittable}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</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 OK = 200;
|
||||
export const CREATED = 201;
|
||||
export const NOT_FOUND = 404;
|
||||
export const FORBIDDEN = 403;
|
||||
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';
|
||||
|
||||
const getFetcher = (token: string) => () => {
|
||||
if (!token) return Promise.resolve({ name: INVALID_TOKEN_ERROR });
|
||||
const path = formatApiPath(`auth/reset/validate?token=${token}`);
|
||||
// Don't use handleErrorResponses here, because we need to read the error.
|
||||
return fetch(path, {
|
||||
@ -34,11 +35,21 @@ const useResetPassword = (options: SWRConfiguration = {}) => {
|
||||
setLoading(!error && !data);
|
||||
}, [data, error]);
|
||||
|
||||
const invalidToken =
|
||||
const isValidToken =
|
||||
(!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;
|
||||
|
@ -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;
|
||||
ENABLE_DARK_MODE_SUPPORT?: boolean;
|
||||
embedProxyFrontend?: boolean;
|
||||
publicSignup?: boolean;
|
||||
}
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -49,6 +49,18 @@ export default mergeConfig(
|
||||
target: UNLEASH_API,
|
||||
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()],
|
||||
|
@ -32,19 +32,28 @@ interface ITokenUserRow {
|
||||
}
|
||||
|
||||
const tokenRowReducer = (acc, tokenRow) => {
|
||||
const { userId, userName, userUsername, roleId, roleName, ...token } =
|
||||
tokenRow;
|
||||
const {
|
||||
userId,
|
||||
userName,
|
||||
userUsername,
|
||||
roleId,
|
||||
roleName,
|
||||
roleType,
|
||||
...token
|
||||
} = tokenRow;
|
||||
if (!acc[tokenRow.secret]) {
|
||||
acc[tokenRow.secret] = {
|
||||
secret: token.secret,
|
||||
name: token.name,
|
||||
url: token.url,
|
||||
expiresAt: token.expires_at,
|
||||
enabled: token.enabled,
|
||||
createdAt: token.created_at,
|
||||
createdBy: token.created_by,
|
||||
role: {
|
||||
id: roleId,
|
||||
name: roleName,
|
||||
type: roleType,
|
||||
},
|
||||
users: [],
|
||||
};
|
||||
@ -113,6 +122,7 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
|
||||
'tokens.secret',
|
||||
'tokens.name',
|
||||
'tokens.expires_at',
|
||||
'tokens.enabled',
|
||||
'tokens.created_at',
|
||||
'tokens.created_by',
|
||||
'tokens.url',
|
||||
@ -121,6 +131,7 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
|
||||
'users.username as userUsername',
|
||||
'roles.id as roleId',
|
||||
'roles.name as roleName',
|
||||
'roles.type as roleType',
|
||||
);
|
||||
}
|
||||
|
||||
@ -159,7 +170,7 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
|
||||
|
||||
async isValid(secret: string): Promise<boolean> {
|
||||
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()],
|
||||
);
|
||||
const { valid } = result.rows[0];
|
||||
@ -197,12 +208,12 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
|
||||
return this.db<ITokenInsert>(TABLE).del();
|
||||
}
|
||||
|
||||
async setExpiry(
|
||||
async update(
|
||||
secret: string,
|
||||
expiresAt: Date,
|
||||
{ expiresAt, enabled }: { expiresAt?: Date; enabled?: boolean },
|
||||
): Promise<PublicSignupTokenSchema> {
|
||||
const rows = await this.makeTokenUsersQuery()
|
||||
.update({ expires_at: expiresAt })
|
||||
.update({ expires_at: expiresAt, enabled })
|
||||
.where('secret', secret)
|
||||
.returning('*');
|
||||
if (rows.length > 0) {
|
||||
|
@ -20,8 +20,10 @@ import { contextFieldsSchema } from './spec/context-fields-schema';
|
||||
import { createApiTokenSchema } from './spec/create-api-token-schema';
|
||||
import { createFeatureSchema } from './spec/create-feature-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 { dateSchema } from './spec/date-schema';
|
||||
import { edgeTokenSchema } from './spec/edge-token-schema';
|
||||
import { emailSchema } from './spec/email-schema';
|
||||
import { environmentSchema } from './spec/environment-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 { featureVariantsSchema } from './spec/feature-variants-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 { healthOverviewSchema } from './spec/health-overview-schema';
|
||||
import { healthReportSchema } from './spec/health-report-schema';
|
||||
import { idSchema } from './spec/id-schema';
|
||||
import { IServerOption } from '../types';
|
||||
import { legalValueSchema } from './spec/legal-value-schema';
|
||||
import { loginSchema } from './spec/login-schema';
|
||||
import { mapValues } from '../util/map-values';
|
||||
import { meSchema } from './spec/me-schema';
|
||||
import { nameSchema } from './spec/name-schema';
|
||||
import { omitKeys } from '../util/omit-keys';
|
||||
import { openApiTags } from './util/openapi-tags';
|
||||
import { overrideSchema } from './spec/override-schema';
|
||||
import { parametersSchema } from './spec/parameters-schema';
|
||||
import { passwordSchema } from './spec/password-schema';
|
||||
import { patchesSchema } from './spec/patches-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 { playgroundFeatureSchema } from './spec/playground-feature-schema';
|
||||
import { playgroundStrategySchema } from './spec/playground-strategy-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 { 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 { projectSchema } from './spec/project-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 { roleSchema } from './spec/role-schema';
|
||||
import { sdkContextSchema } from './spec/sdk-context-schema';
|
||||
import { searchEventsSchema } from './spec/search-events-schema';
|
||||
import { segmentSchema } from './spec/segment-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 { splashSchema } from './spec/splash-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 { upsertContextFieldSchema } from './spec/upsert-context-field-schema';
|
||||
import { upsertStrategySchema } from './spec/upsert-strategy-schema';
|
||||
import { URL } from 'url';
|
||||
import { userSchema } from './spec/user-schema';
|
||||
import { usersGroupsBaseSchema } from './spec/users-groups-base-schema';
|
||||
import { usersSchema } from './spec/users-schema';
|
||||
import { usersSearchSchema } from './spec/users-search-schema';
|
||||
import { validateEdgeTokensSchema } from './spec/validate-edge-tokens-schema';
|
||||
import { validatePasswordSchema } from './spec/validate-password-schema';
|
||||
import { validateTagTypeSchema } from './spec/validate-tag-type-schema';
|
||||
import { variantSchema } from './spec/variant-schema';
|
||||
import { variantsSchema } from './spec/variants-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 { profileSchema } from './spec/profile-schema';
|
||||
|
||||
// All schemas in `openapi/spec` should be listed here.
|
||||
export const schemas = {
|
||||
@ -145,10 +146,11 @@ export const schemas = {
|
||||
createApiTokenSchema,
|
||||
createFeatureSchema,
|
||||
createFeatureStrategySchema,
|
||||
createInvitedUserSchema,
|
||||
createUserSchema,
|
||||
dateSchema,
|
||||
emailSchema,
|
||||
edgeTokenSchema,
|
||||
emailSchema,
|
||||
environmentSchema,
|
||||
environmentsSchema,
|
||||
eventSchema,
|
||||
@ -181,29 +183,29 @@ export const schemas = {
|
||||
overrideSchema,
|
||||
parametersSchema,
|
||||
passwordSchema,
|
||||
patSchema,
|
||||
patsSchema,
|
||||
patchesSchema,
|
||||
patchSchema,
|
||||
patSchema,
|
||||
patsSchema,
|
||||
permissionSchema,
|
||||
playgroundFeatureSchema,
|
||||
playgroundStrategySchema,
|
||||
playgroundConstraintSchema,
|
||||
playgroundSegmentSchema,
|
||||
playgroundFeatureSchema,
|
||||
playgroundRequestSchema,
|
||||
playgroundResponseSchema,
|
||||
projectEnvironmentSchema,
|
||||
publicSignupTokenCreateSchema,
|
||||
publicSignupTokenUpdateSchema,
|
||||
publicSignupTokensSchema,
|
||||
publicSignupTokenSchema,
|
||||
playgroundSegmentSchema,
|
||||
playgroundStrategySchema,
|
||||
profileSchema,
|
||||
proxyClientSchema,
|
||||
proxyFeaturesSchema,
|
||||
proxyFeatureSchema,
|
||||
proxyMetricsSchema,
|
||||
projectEnvironmentSchema,
|
||||
projectSchema,
|
||||
projectsSchema,
|
||||
proxyClientSchema,
|
||||
proxyFeatureSchema,
|
||||
proxyFeaturesSchema,
|
||||
proxyMetricsSchema,
|
||||
publicSignupTokenCreateSchema,
|
||||
publicSignupTokenSchema,
|
||||
publicSignupTokensSchema,
|
||||
publicSignupTokenUpdateSchema,
|
||||
resetPasswordSchema,
|
||||
roleSchema,
|
||||
sdkContextSchema,
|
||||
@ -230,16 +232,16 @@ export const schemas = {
|
||||
updateUserSchema,
|
||||
upsertContextFieldSchema,
|
||||
upsertStrategySchema,
|
||||
usersGroupsBaseSchema,
|
||||
userSchema,
|
||||
usersGroupsBaseSchema,
|
||||
usersSchema,
|
||||
usersSearchSchema,
|
||||
validateEdgeTokensSchema,
|
||||
validatePasswordSchema,
|
||||
validateTagTypeSchema,
|
||||
variantSchema,
|
||||
variantsSchema,
|
||||
versionSchema,
|
||||
validateEdgeTokensSchema,
|
||||
};
|
||||
|
||||
// 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 },
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'someone',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
|
@ -13,6 +13,7 @@ export const publicSignupTokenSchema = {
|
||||
'expiresAt',
|
||||
'createdAt',
|
||||
'createdBy',
|
||||
'enabled',
|
||||
'role',
|
||||
],
|
||||
properties: {
|
||||
@ -25,6 +26,9 @@ export const publicSignupTokenSchema = {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
},
|
||||
expiresAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
|
@ -4,12 +4,14 @@ export const publicSignupTokenUpdateSchema = {
|
||||
$id: '#/components/schemas/publicSignupTokenUpdateSchema',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['expiresAt'],
|
||||
properties: {
|
||||
expiresAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
components: {},
|
||||
} as const;
|
||||
|
@ -5,7 +5,6 @@ import getApp from '../../app';
|
||||
import supertest from 'supertest';
|
||||
import permissions from '../../../test/fixtures/permissions';
|
||||
import { RoleName, RoleType } from '../../types/model';
|
||||
import { CreateUserSchema } from '../../openapi/spec/create-user-schema';
|
||||
|
||||
describe('Public Signup API', () => {
|
||||
async function getSetup() {
|
||||
@ -51,6 +50,13 @@ describe('Public Signup API', () => {
|
||||
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;
|
||||
@ -132,6 +138,30 @@ describe('Public Signup API', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
const appName = '123!23';
|
||||
|
||||
@ -142,47 +172,16 @@ describe('Public Signup API', () => {
|
||||
});
|
||||
|
||||
return request
|
||||
.delete('/api/admin/invite-link/tokens/some-secret')
|
||||
.put('/api/admin/invite-link/tokens/some-secret')
|
||||
.send({ enabled: false })
|
||||
.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) => {
|
||||
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);
|
||||
const token = res.body;
|
||||
expect(token.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 200 if token is valid', async () => {
|
||||
test('should not allow a user to register disabled token', async () => {
|
||||
const appName = '123!23';
|
||||
|
||||
stores.clientApplicationsStore.upsert({ appName });
|
||||
@ -190,19 +189,11 @@ describe('Public Signup API', () => {
|
||||
name: 'some-name',
|
||||
expiresAt: expireAt(),
|
||||
});
|
||||
stores.publicSignupTokenStore.update('some-secret', { enabled: false });
|
||||
|
||||
return request
|
||||
.post('/api/admin/invite-link/tokens/some-secret/validate')
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
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);
|
||||
.post('/invite/some-secret/signup')
|
||||
.send(user)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Response } from 'express';
|
||||
|
||||
import Controller from '../controller';
|
||||
import { ADMIN, NONE } from '../../types/permissions';
|
||||
import { ADMIN } from '../../types/permissions';
|
||||
import { Logger } from '../../logger';
|
||||
import { AccessService } from '../../services/access-service';
|
||||
import { IAuthRequest } from '../unleash-types';
|
||||
@ -13,10 +13,6 @@ import {
|
||||
resourceCreatedResponseSchema,
|
||||
} 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 UserService from '../../services/user-service';
|
||||
import {
|
||||
@ -29,8 +25,6 @@ import {
|
||||
} from '../../openapi/spec/public-signup-tokens-schema';
|
||||
import { PublicSignupTokenCreateSchema } from '../../openapi/spec/public-signup-token-create-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';
|
||||
|
||||
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({
|
||||
method: 'get',
|
||||
path: '/tokens/:token',
|
||||
@ -154,42 +130,7 @@ export class PublicSignupController extends Controller {
|
||||
'publicSignupTokenUpdateSchema',
|
||||
),
|
||||
responses: {
|
||||
200: emptyResponse,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
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,
|
||||
200: createResponseSchema('publicSignupTokenSchema'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
@ -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(
|
||||
req: IAuthRequest<void, void, PublicSignupTokenCreateSchema>,
|
||||
res: Response<PublicSignupTokenSchema>,
|
||||
@ -274,28 +188,27 @@ export class PublicSignupController extends Controller {
|
||||
res: Response,
|
||||
): Promise<any> {
|
||||
const { token } = req.params;
|
||||
const { expiresAt } = req.body;
|
||||
const { expiresAt, enabled } = req.body;
|
||||
|
||||
if (!expiresAt) {
|
||||
if (!expiresAt && enabled === undefined) {
|
||||
this.logger.error(req.body);
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
await this.publicSignupTokenService.setExpiry(
|
||||
const result = await this.publicSignupTokenService.update(
|
||||
token,
|
||||
new Date(expiresAt),
|
||||
{
|
||||
...(enabled === undefined ? {} : { enabled }),
|
||||
...(expiresAt ? { expiresAt: new Date(expiresAt) } : {}),
|
||||
},
|
||||
extractUsername(req),
|
||||
);
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
async deletePublicSignupToken(
|
||||
req: IAuthRequest<TokenParam>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { token } = req.params;
|
||||
const username = extractUsername(req);
|
||||
|
||||
await this.publicSignupTokenService.delete(token, username);
|
||||
res.status(200).end();
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
publicSignupTokenSchema.$id,
|
||||
serializeDates(result),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -12,12 +12,17 @@ import { HealthCheckController } from './health-check';
|
||||
import ProxyController from './proxy-api';
|
||||
import { conditionalMiddleware } from '../middleware/conditional-middleware';
|
||||
import EdgeController from './edge-api';
|
||||
import { PublicInviteController } from './public-invite';
|
||||
|
||||
class IndexRouter extends Controller {
|
||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
||||
super(config);
|
||||
|
||||
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('/logout', new LogoutController(config, services).router);
|
||||
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 { IPublicSignupTokenCreate } from '../types/models/public-signup-token';
|
||||
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 { IEventStore } from '../types/stores/event-store';
|
||||
import {
|
||||
PublicSignupTokenCreatedEvent,
|
||||
PublicSignupTokenManuallyExpiredEvent,
|
||||
PublicSignupTokenUpdatedEvent,
|
||||
PublicSignupTokenUserAddedEvent,
|
||||
} from '../types/events';
|
||||
import UserService, { ICreateUser } from './user-service';
|
||||
import UserService from './user-service';
|
||||
import { IUser } from '../types/user';
|
||||
import { URL } from 'url';
|
||||
|
||||
@ -56,7 +57,7 @@ export class PublicSignupTokenService {
|
||||
|
||||
private getUrl(secret: string): string {
|
||||
return new URL(
|
||||
`${this.unleashBase}/invite-link/${secret}/signup`,
|
||||
`${this.unleashBase}/new-user?invite=${secret}`,
|
||||
).toString();
|
||||
}
|
||||
|
||||
@ -76,20 +77,30 @@ export class PublicSignupTokenService {
|
||||
return this.store.isValid(secret);
|
||||
}
|
||||
|
||||
public async setExpiry(
|
||||
public async update(
|
||||
secret: string,
|
||||
expireAt: Date,
|
||||
{ expiresAt, enabled }: { expiresAt?: Date; enabled?: boolean },
|
||||
createdBy: string,
|
||||
): 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(
|
||||
secret: string,
|
||||
createUser: ICreateUser,
|
||||
createUser: CreateInvitedUserSchema,
|
||||
): Promise<IUser> {
|
||||
const token = await this.get(secret);
|
||||
createUser.rootRole = token.role.id;
|
||||
const user = await this.userService.createUser(createUser);
|
||||
const user = await this.userService.createUser({
|
||||
...createUser,
|
||||
rootRole: token.role.id,
|
||||
});
|
||||
await this.store.addTokenUser(secret, user.id);
|
||||
await this.eventStore.store(
|
||||
new PublicSignupTokenUserAddedEvent({
|
||||
@ -100,22 +111,6 @@ export class PublicSignupTokenService {
|
||||
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(
|
||||
tokenCreate: PublicSignupTokenCreateSchema,
|
||||
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_USER_ADDED = 'public-signup-token-user-added';
|
||||
export const PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED =
|
||||
'public-signup-token-manually-expired';
|
||||
export const PUBLIC_SIGNUP_TOKEN_TOKEN_UPDATED = 'public-signup-token-updated';
|
||||
|
||||
export interface IBaseEvent {
|
||||
type: string;
|
||||
@ -548,11 +547,11 @@ export class PublicSignupTokenCreatedEvent extends BaseEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicSignupTokenManuallyExpiredEvent extends BaseEvent {
|
||||
export class PublicSignupTokenUpdatedEvent extends BaseEvent {
|
||||
readonly 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;
|
||||
}
|
||||
}
|
||||
|
@ -10,9 +10,9 @@ export interface IPublicSignupTokenStore
|
||||
): Promise<PublicSignupTokenSchema>;
|
||||
addTokenUser(secret: string, userId: number): Promise<void>;
|
||||
isValid(secret): Promise<boolean>;
|
||||
setExpiry(
|
||||
update(
|
||||
secret: string,
|
||||
expiresAt: Date,
|
||||
value: { expiresAt?: Date; enabled?: boolean },
|
||||
): Promise<PublicSignupTokenSchema>;
|
||||
delete(secret: string): Promise<void>;
|
||||
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 { RoleName } from '../../../../lib/types/model';
|
||||
import { PublicSignupTokenCreateSchema } from '../../../../lib/openapi/spec/public-signup-token-create-schema';
|
||||
import { CreateUserSchema } from '../../../../lib/openapi/spec/create-user-schema';
|
||||
|
||||
let stores;
|
||||
let db;
|
||||
@ -99,14 +98,12 @@ test('no permission to validate a token', async () => {
|
||||
createdBy: 'admin@example.com',
|
||||
roleId: 3,
|
||||
});
|
||||
await request
|
||||
.post('/api/admin/invite-link/tokens/some-secret/validate')
|
||||
.expect(200);
|
||||
await request.get('/invite/some-secret/validate').expect(200);
|
||||
|
||||
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 }) => {
|
||||
app.use('/api/admin/', async (req, res, next) => {
|
||||
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);
|
||||
|
||||
await request
|
||||
.post('/api/admin/invite-link/tokens/some-invalid-secret/validate')
|
||||
.expect(401);
|
||||
await request.get('/invite/some-invalid-secret/validate').expect(400);
|
||||
|
||||
await destroy();
|
||||
});
|
||||
@ -149,27 +144,25 @@ test('users can signup with invite-link', async () => {
|
||||
name: 'some-name',
|
||||
expiresAt: expireAt(),
|
||||
secret: 'some-secret',
|
||||
url: 'http://localhost:4242/invite-lint/some-secret/signup',
|
||||
url: 'http://localhost:4242/invite/some-secret/signup',
|
||||
createAt: new Date(),
|
||||
createdBy: 'admin@example.com',
|
||||
roleId: 3,
|
||||
});
|
||||
|
||||
const createUser: CreateUserSchema = {
|
||||
username: 'some-username',
|
||||
const createUser = {
|
||||
name: 'some-username',
|
||||
email: 'some@example.com',
|
||||
password: 'eweggwEG',
|
||||
sendEmail: false,
|
||||
rootRole: 1,
|
||||
};
|
||||
|
||||
await request
|
||||
.post('/api/admin/invite-link/tokens/some-secret/signup')
|
||||
.post('/invite/some-secret/signup')
|
||||
.send(createUser)
|
||||
.expect(201)
|
||||
.expect((res) => {
|
||||
const user = res.body;
|
||||
expect(user.username).toBe('some-username');
|
||||
expect(user.name).toBe('some-username');
|
||||
});
|
||||
|
||||
await destroy();
|
||||
|
@ -724,6 +724,29 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
],
|
||||
"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": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@ -2452,6 +2475,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
"nullable": true,
|
||||
"type": "string",
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
},
|
||||
"expiresAt": {
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
@ -2483,6 +2509,7 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
"expiresAt",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"enabled",
|
||||
"role",
|
||||
],
|
||||
"type": "object",
|
||||
@ -2490,14 +2517,14 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
"publicSignupTokenUpdateSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
},
|
||||
"expiresAt": {
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"expiresAt",
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"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}": {
|
||||
"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": {
|
||||
"operationId": "getPublicSignupToken",
|
||||
"parameters": [
|
||||
@ -4672,79 +4678,16 @@ If the provided project does not exist, the list of events will be empty.",
|
||||
"description": "publicSignupTokenUpdateSchema",
|
||||
"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": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/userSchema",
|
||||
"$ref": "#/components/schemas/publicSignupTokenSchema",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "userSchema",
|
||||
},
|
||||
"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.",
|
||||
"description": "publicSignupTokenSchema",
|
||||
},
|
||||
},
|
||||
"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": [
|
||||
{
|
||||
|
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> {
|
||||
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> {
|
||||
@ -54,18 +56,26 @@ export default class FakePublicSignupStore implements IPublicSignupTokenStore {
|
||||
type: '',
|
||||
id: 1,
|
||||
},
|
||||
enabled: true,
|
||||
createdBy: newToken.createdBy,
|
||||
};
|
||||
this.tokens.push(token);
|
||||
return Promise.resolve(token);
|
||||
}
|
||||
|
||||
async setExpiry(
|
||||
async update(
|
||||
secret: string,
|
||||
expiresAt: Date,
|
||||
{ expiresAt, enabled }: { expiresAt?: Date; enabled?: boolean },
|
||||
): Promise<PublicSignupTokenSchema> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
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> {
|
||||
const roleCreated = { ...role, id: 1, type: 'some-type' };
|
||||
const roleCreated = {
|
||||
...role,
|
||||
type: 'some-type',
|
||||
id: this.roles.length,
|
||||
};
|
||||
this.roles.push(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.
|
||||
|
||||
:::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.
|
||||
|
||||
:::
|
||||
|
||||
## 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.
|
||||
|
||||

|
||||

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