1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-04 13:48:56 +02:00

Feat/invite user (#2061)

* refactor: user creation screen cleanup

* feat: deprecation notice for google sso

* fix: docs openid typo

* invite link bar

* invite link page

* fix prettier docs

* regenerated openapi

* hooks for invite page api

* update openapi

* feat: invite link update

* feat: add public signup token soft-delete

* public signup frontend feature flag

* fix: new user api issues

* feat: allow for creating new user from invite link

* Feat/invite user public controller (#2106)

* added PublicInviteController for public urls

* added PublicInviteController for public urls

* added PublicInviteController for public urls

* added PublicInviteController for public urls

* fix test

* fix test

* update openapi

* refactor: password reset props

* fix: public invite schema and validation

Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>

* user invite frontend

Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>

* invite link delete confirmation dialog

* refactor: password reset action

* fix: new user invite loading state

* fix: run ts check with ci

* revert openapi changes

* fix: invite token api interface

* fix: openapi schema index

* fix: update test snapshots

* update frontend snapshot

* fix: prettier ci

* fix: updates after review

Co-authored-by: andreas-unleash <104830839+andreas-unleash@users.noreply.github.com>
Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
This commit is contained in:
Tymoteusz Czech 2022-09-30 13:01:32 +02:00 committed by GitHub
parent 5e2d96593a
commit 47152cf05b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1615 additions and 583 deletions

View File

@ -23,3 +23,4 @@ jobs:
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
- run: yarn run test - run: yarn run test
- run: yarn run fmt:check - run: yarn run fmt:check
- run: yarn run ts:check # TODO: optimize

View File

@ -20,6 +20,7 @@
"test:watch": "vitest watch", "test:watch": "vitest watch",
"fmt": "prettier src --write --loglevel warn", "fmt": "prettier src --write --loglevel warn",
"fmt:check": "prettier src --check", "fmt:check": "prettier src --check",
"ts:check": "tsc",
"e2e": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=admin,AUTH_PASSWORD=unleash4all", "e2e": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=admin,AUTH_PASSWORD=unleash4all",
"e2e:heroku": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=example@example.com", "e2e:heroku": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=example@example.com",
"prepare": "yarn run build" "prepare": "yarn run build"

View File

@ -1,11 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()({
iconContainer: {
width: '100%',
textAlign: 'center',
},
emailIcon: {
margin: '2rem auto',
},
});

View File

@ -1,9 +1,8 @@
import { Typography } from '@mui/material'; import { Box, Typography } from '@mui/material';
import { Dialogue } from 'component/common/Dialogue/Dialogue'; import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { ReactComponent as EmailIcon } from 'assets/icons/email.svg'; import { ReactComponent as EmailIcon } from 'assets/icons/email.svg';
import { useStyles } from './ConfirmUserEmail.styles'; import { LinkField } from '../../LinkField/LinkField';
import UserInviteLink from '../ConfirmUserLink/UserInviteLink/UserInviteLink';
interface IConfirmUserEmailProps { interface IConfirmUserEmailProps {
open: boolean; open: boolean;
@ -15,32 +14,29 @@ const ConfirmUserEmail = ({
open, open,
closeConfirm, closeConfirm,
inviteLink, inviteLink,
}: IConfirmUserEmailProps) => { }: IConfirmUserEmailProps) => (
const { classes: styles } = useStyles(); <Dialogue
return ( open={open}
<Dialogue title="Team member added"
open={open} primaryButtonText="Close"
title="Team member added" onClick={closeConfirm}
primaryButtonText="Close" >
onClick={closeConfirm} <Typography>
> A new team member has been added. Weve sent an email on your behalf
<Typography> to inform them of their new account and role. No further steps are
A new team member has been added. Weve sent an email on your required.
behalf to inform them of their new account and role. No further </Typography>
steps are required. <Box sx={{ width: '100%', textAlign: 'center', px: 'auto', py: 4 }}>
</Typography> <EmailIcon />
<div className={styles.iconContainer}> </Box>
<EmailIcon className={styles.emailIcon} /> <Typography style={{ fontWeight: 'bold' }} variant="subtitle1">
</div> In a rush?
<Typography style={{ fontWeight: 'bold' }} variant="subtitle1"> </Typography>
In a rush? <Typography>
</Typography> You may also copy the invite link and send it to the user.
<Typography> </Typography>
You may also copy the invite link and send it to the user. <LinkField inviteLink={inviteLink} />
</Typography> </Dialogue>
<UserInviteLink inviteLink={inviteLink} /> );
</Dialogue>
);
};
export default ConfirmUserEmail; export default ConfirmUserEmail;

View File

@ -2,7 +2,7 @@ import { Typography } from '@mui/material';
import { Alert } from '@mui/material'; import { Alert } from '@mui/material';
import { useThemeStyles } from 'themes/themeStyles'; import { useThemeStyles } from 'themes/themeStyles';
import { Dialogue } from 'component/common/Dialogue/Dialogue'; import { Dialogue } from 'component/common/Dialogue/Dialogue';
import UserInviteLink from './UserInviteLink/UserInviteLink'; import { LinkField } from '../../LinkField/LinkField';
interface IConfirmUserLink { interface IConfirmUserLink {
open: boolean; open: boolean;
@ -28,7 +28,7 @@ const ConfirmUserLink = ({
A new team member has been added. Please provide them with A new team member has been added. Please provide them with
the following link to get started: the following link to get started:
</Typography> </Typography>
<UserInviteLink inviteLink={inviteLink} /> <LinkField inviteLink={inviteLink} />
<Typography variant="body1"> <Typography variant="body1">
Copy the link and send it to the user. This will allow them Copy the link and send it to the user. This will allow them

View 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>
);
};

View File

@ -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>
);
};

View File

@ -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. Weve 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;

View File

@ -1,12 +1,13 @@
import { IconButton, Tooltip } from '@mui/material'; import { Box, IconButton, Tooltip } from '@mui/material';
import CopyIcon from '@mui/icons-material/FileCopy'; import CopyIcon from '@mui/icons-material/FileCopy';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
interface IInviteLinkProps { interface ILinkFieldProps {
inviteLink: string; inviteLink: string;
small?: boolean;
} }
const UserInviteLink = ({ inviteLink }: IInviteLinkProps) => { export const LinkField = ({ inviteLink, small }: ILinkFieldProps) => {
const { setToastData } = useToast(); const { setToastData } = useToast();
const handleCopy = () => { const handleCopy = () => {
@ -34,26 +35,38 @@ const UserInviteLink = ({ inviteLink }: IInviteLinkProps) => {
}); });
return ( return (
<div <Box
style={{ sx={{
backgroundColor: '#efefef', backgroundColor: theme => theme.palette.secondaryContainer,
padding: '2rem', py: 4,
borderRadius: '3px', px: 4,
margin: '1rem 0', borderRadius: theme => `${theme.shape.borderRadius}px`,
my: 2,
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
wordBreak: 'break-all', wordBreak: 'break-all',
...(small
? {
my: 0,
py: 0.5,
pl: 1.5,
pr: 0.5,
fontSize: theme => theme.typography.body2.fontSize,
}
: {}),
}} }}
> >
{inviteLink} {inviteLink}
<Tooltip title="Copy link" arrow> <Tooltip title="Copy link" arrow>
<IconButton onClick={handleCopy} size="large"> <IconButton
<CopyIcon /> onClick={handleCopy}
size={small ? 'small' : 'large'}
sx={small ? { ml: 0.5 } : {}}
>
<CopyIcon sx={{ fontSize: small ? 20 : undefined }} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</div> </Box>
); );
}; };
export default UserInviteLink;

View File

@ -5,13 +5,20 @@ import AccessContext from 'contexts/AccessContext';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
import { InviteLinkBar } from './InviteLinkBar/InviteLinkBar';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
const UsersAdmin = () => { const UsersAdmin = () => {
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const { uiConfig } = useUiConfig();
return ( return (
<div> <div>
<AdminMenu /> <AdminMenu />
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.publicSignup)}
show={<InviteLinkBar />}
/>
<ConditionallyRender <ConditionallyRender
condition={hasAccess(ADMIN)} condition={hasAccess(ADMIN)}
show={<UsersList />} show={<UsersList />}

View File

@ -246,7 +246,7 @@ const UsersList = () => {
color="primary" color="primary"
onClick={() => navigate('/admin/create-user')} onClick={() => navigate('/admin/create-user')}
> >
New user Add new user
</Button> </Button>
</> </>
} }

View File

@ -42,19 +42,6 @@ const GeneralSelect: React.FC<IGeneralSelectProps> = ({
fullWidth, fullWidth,
...rest ...rest
}) => { }) => {
const renderSelectItems = () =>
options.map(option => (
<MenuItem
key={option.key}
value={option.key}
title={option.title || ''}
data-testid={`${SELECT_ITEM_ID}-${option.label}`}
disabled={option.disabled}
>
{option.label}
</MenuItem>
));
const onSelectChange = (event: SelectChangeEvent) => { const onSelectChange = (event: SelectChangeEvent) => {
event.preventDefault(); event.preventDefault();
onChange(String(event.target.value)); onChange(String(event.target.value));
@ -79,7 +66,17 @@ const GeneralSelect: React.FC<IGeneralSelectProps> = ({
IconComponent={KeyboardArrowDownOutlined} IconComponent={KeyboardArrowDownOutlined}
{...rest} {...rest}
> >
{renderSelectItems()} {options.map(option => (
<MenuItem
key={option.key}
value={option.key}
title={option.title || ''}
data-testid={`${SELECT_ITEM_ID}-${option.label}`}
disabled={option.disabled}
>
{option.label}
</MenuItem>
))}
</Select> </Select>
</FormControl> </FormControl>
); );

View File

@ -385,6 +385,14 @@ exports[`returns all baseRoutes 1`] = `
"title": "Users", "title": "Users",
"type": "protected", "type": "protected",
}, },
{
"component": [Function],
"menu": {},
"parent": "/admin",
"path": "/admin/invite-link",
"title": "Invite link",
"type": "protected",
},
{ {
"component": [Function], "component": [Function],
"flag": "UG", "flag": "UG",

View File

@ -57,6 +57,7 @@ import { CreateGroup } from 'component/admin/groups/CreateGroup/CreateGroup';
import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup'; import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup';
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground'; import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
import { CorsAdmin } from 'component/admin/cors'; import { CorsAdmin } from 'component/admin/cors';
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
export const routes: IRoute[] = [ export const routes: IRoute[] = [
// Splash // Splash
@ -434,6 +435,14 @@ export const routes: IRoute[] = [
type: 'protected', type: 'protected',
menu: {}, menu: {},
}, },
{
path: '/admin/invite-link',
parent: '/admin',
title: 'Invite link',
component: InviteLink,
type: 'protected',
menu: {},
},
{ {
path: '/admin/groups', path: '/admin/groups',
parent: '/admin', parent: '/admin',

View File

@ -1,13 +1,13 @@
import { Navigate } from 'react-router-dom';
import { Alert, AlertTitle } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useStyles } from 'component/user/Login/Login.styles'; import { useStyles } from 'component/user/Login/Login.styles';
import useQueryParams from 'hooks/useQueryParams'; import useQueryParams from 'hooks/useQueryParams';
import ResetPasswordSuccess from '../common/ResetPasswordSuccess/ResetPasswordSuccess';
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout'; import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
import { DEMO_TYPE } from 'constants/authTypes'; import { DEMO_TYPE } from 'constants/authTypes';
import Authentication from '../Authentication/Authentication'; import Authentication from '../Authentication/Authentication';
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails'; import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser'; import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import { Navigate } from 'react-router-dom';
import { parseRedirectParam } from 'component/user/Login/parseRedirectParam'; import { parseRedirectParam } from 'component/user/Login/parseRedirectParam';
const Login = () => { const Login = () => {
@ -16,6 +16,7 @@ const Login = () => {
const { user } = useAuthUser(); const { user } = useAuthUser();
const query = useQueryParams(); const query = useQueryParams();
const resetPassword = query.get('reset') === 'true'; const resetPassword = query.get('reset') === 'true';
const invited = query.get('invited') === 'true';
const redirect = query.get('redirect') || '/'; const redirect = query.get('redirect') || '/';
if (user) { if (user) {
@ -25,6 +26,24 @@ const Login = () => {
return ( return (
<StandaloneLayout> <StandaloneLayout>
<div className={styles.loginFormContainer}> <div className={styles.loginFormContainer}>
<ConditionallyRender
condition={resetPassword}
show={
<Alert severity="success" sx={{ mb: 4 }}>
<AlertTitle>Success</AlertTitle>
You successfully reset your password.
</Alert>
}
/>
<ConditionallyRender
condition={invited}
show={
<Alert severity="success" sx={{ mb: 4 }}>
<AlertTitle>Success</AlertTitle>
Your account has been created.
</Alert>
}
/>
<ConditionallyRender <ConditionallyRender
condition={authDetails?.type !== DEMO_TYPE} condition={authDetails?.type !== DEMO_TYPE}
show={ show={
@ -33,11 +52,6 @@ const Login = () => {
</h2> </h2>
} }
/> />
<ConditionallyRender
condition={resetPassword}
show={<ResetPasswordSuccess />}
/>
<Authentication redirect={redirect} /> <Authentication redirect={redirect} />
</div> </div>
</StandaloneLayout> </StandaloneLayout>

View File

@ -1,27 +1,82 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, TextField, Typography } from '@mui/material'; import { Box, TextField, Typography } from '@mui/material';
import { CREATED, OK } from 'constants/statusCodes';
import useToast from 'hooks/useToast';
import useResetPassword from 'hooks/api/getters/useResetPassword/useResetPassword'; import useResetPassword from 'hooks/api/getters/useResetPassword/useResetPassword';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useUserInvite } from 'hooks/api/getters/useUserInvite/useUserInvite';
import { useInviteTokenApi } from 'hooks/api/actions/useInviteTokenApi/useInviteTokenApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useAuthResetPasswordApi } from 'hooks/api/actions/useAuthResetPasswordApi/useAuthResetPasswordApi';
import AuthOptions from '../common/AuthOptions/AuthOptions'; import AuthOptions from '../common/AuthOptions/AuthOptions';
import DividerText from 'component/common/DividerText/DividerText'; import DividerText from 'component/common/DividerText/DividerText';
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails'; import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
import { useInviteUserToken } from 'hooks/api/getters/useInviteUserToken/useInviteUserToken';
import ResetPasswordForm from '../common/ResetPasswordForm/ResetPasswordForm'; import ResetPasswordForm from '../common/ResetPasswordForm/ResetPasswordForm';
import InvalidToken from '../common/InvalidToken/InvalidToken'; import InvalidToken from '../common/InvalidToken/InvalidToken';
import { NewUserWrapper } from './NewUserWrapper/NewUserWrapper'; import { NewUserWrapper } from './NewUserWrapper/NewUserWrapper';
import ResetPasswordError from '../common/ResetPasswordError/ResetPasswordError';
export const NewUser = () => { export const NewUser = () => {
const { authDetails } = useAuthDetails(); const { authDetails } = useAuthDetails();
const { setToastApiError } = useToast();
const navigate = useNavigate();
const [apiError, setApiError] = useState(false);
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const { const {
token, token,
data, data: passwordResetData,
loading: resetLoading, loading: resetLoading,
setLoading, isValidToken,
invalidToken,
} = useResetPassword(); } = useResetPassword();
const { invite, loading: inviteLoading } = useInviteUserToken(); const {
secret,
loading: inviteLoading,
isValid: isValidInvite,
} = useUserInvite();
const { addUser, loading: isUserSubmitting } = useInviteTokenApi();
const { resetPassword, loading: isPasswordSubmitting } =
useAuthResetPasswordApi();
const passwordDisabled = authDetails?.defaultHidden === true; const passwordDisabled = authDetails?.defaultHidden === true;
if (invalidToken && !invite) { const onSubmitInvitedUser = async (password: string) => {
try {
const res = await addUser(secret, { name, email, password });
if (res.status === CREATED) {
navigate('/login?invited=true');
} else {
setToastApiError(
"Couldn't create user. Check if your invite link is valid."
);
}
} catch (error) {
setToastApiError(formatUnknownError(error));
}
};
const onSubmitPasswordReset = async (password: string) => {
try {
const res = await resetPassword({ token, password });
if (res.status === OK) {
navigate('/login?reset=true');
} else {
setApiError(true);
}
} catch (e) {
setApiError(true);
}
};
const onSubmit = (password: string) => {
if (isValidInvite) {
onSubmitInvitedUser(password);
} else {
onSubmitPasswordReset(password);
}
};
if (isValidToken === false && isValidInvite == false) {
return ( return (
<NewUserWrapper loading={resetLoading || inviteLoading}> <NewUserWrapper loading={resetLoading || inviteLoading}>
<InvalidToken /> <InvalidToken />
@ -31,7 +86,12 @@ export const NewUser = () => {
return ( return (
<NewUserWrapper <NewUserWrapper
loading={resetLoading || inviteLoading} loading={
resetLoading ||
inviteLoading ||
isUserSubmitting ||
isPasswordSubmitting
}
title={ title={
passwordDisabled passwordDisabled
? 'Connect your account and start your journey' ? 'Connect your account and start your journey'
@ -39,14 +99,14 @@ export const NewUser = () => {
} }
> >
<ConditionallyRender <ConditionallyRender
condition={data?.createdBy} condition={passwordResetData?.createdBy}
show={ show={
<Typography <Typography
variant="body1" variant="body1"
data-loading data-loading
sx={{ textAlign: 'center', mb: 2 }} sx={{ textAlign: 'center', mb: 2 }}
> >
{data?.createdBy} {passwordResetData?.createdBy}
<br /> has invited you to join Unleash. <br /> has invited you to join Unleash.
</Typography> </Typography>
} }
@ -82,7 +142,7 @@ export const NewUser = () => {
show={ show={
<> <>
<ConditionallyRender <ConditionallyRender
condition={data?.email} condition={passwordResetData?.email}
show={() => ( show={() => (
<Typography <Typography
data-loading data-loading
@ -96,22 +156,32 @@ export const NewUser = () => {
<TextField <TextField
data-loading data-loading
type="email" type="email"
value={data?.email || ''} value={
id="username" isValidToken
? passwordResetData?.email || ''
: email
}
id="email"
label="Email" label="Email"
variant="outlined" variant="outlined"
size="small" size="small"
sx={{ my: 1 }} sx={{ my: 1 }}
disabled={Boolean(data?.email)} disabled={isValidToken}
fullWidth fullWidth
required required
onChange={e => {
if (isValidToken) {
return;
}
setEmail(e.target.value);
}}
/> />
<ConditionallyRender <ConditionallyRender
condition={Boolean(invite)} condition={Boolean(isValidInvite)}
show={() => ( show={() => (
<TextField <TextField
data-loading data-loading
value="" value={name}
id="username" id="username"
label="Full name" label="Full name"
variant="outlined" variant="outlined"
@ -119,16 +189,20 @@ export const NewUser = () => {
sx={{ my: 1 }} sx={{ my: 1 }}
fullWidth fullWidth
required required
onChange={e => {
setName(e.target.value);
}}
/> />
)} )}
/> />
<Typography variant="body1" data-loading sx={{ mt: 2 }}> <Typography variant="body1" data-loading sx={{ mt: 2 }}>
Set a password for your account. Set a password for your account.
</Typography> </Typography>
<ResetPasswordForm <ConditionallyRender
token={token} condition={apiError && isValidToken}
setLoading={setLoading} show={<ResetPasswordError />}
/> />
<ResetPasswordForm onSubmit={onSubmit} />
</> </>
} }
/> />

View File

@ -1,3 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { OK } from 'constants/statusCodes';
import useLoading from 'hooks/useLoading'; import useLoading from 'hooks/useLoading';
import { useStyles } from './ResetPassword.styles'; import { useStyles } from './ResetPassword.styles';
import { Typography } from '@mui/material'; import { Typography } from '@mui/material';
@ -6,18 +9,37 @@ import InvalidToken from '../common/InvalidToken/InvalidToken';
import useResetPassword from 'hooks/api/getters/useResetPassword/useResetPassword'; import useResetPassword from 'hooks/api/getters/useResetPassword/useResetPassword';
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout'; import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
import ResetPasswordForm from '../common/ResetPasswordForm/ResetPasswordForm'; import ResetPasswordForm from '../common/ResetPasswordForm/ResetPasswordForm';
import ResetPasswordError from '../common/ResetPasswordError/ResetPasswordError';
import { useAuthResetPasswordApi } from 'hooks/api/actions/useAuthResetPasswordApi/useAuthResetPasswordApi';
const ResetPassword = () => { const ResetPassword = () => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const { token, loading, setLoading, invalidToken } = useResetPassword(); const { token, loading, setLoading, isValidToken } = useResetPassword();
const ref = useLoading(loading); const { resetPassword, loading: actionLoading } = useAuthResetPasswordApi();
const ref = useLoading(loading || actionLoading);
const navigate = useNavigate();
const [hasApiError, setHasApiError] = useState(false);
const onSubmit = async (password: string) => {
try {
const res = await resetPassword({ token, password });
if (res.status === OK) {
navigate('/login?reset=true');
setHasApiError(false);
} else {
setHasApiError(true);
}
} catch (e) {
setHasApiError(true);
}
};
return ( return (
<div ref={ref}> <div ref={ref}>
<StandaloneLayout> <StandaloneLayout>
<div className={styles.resetPassword}> <div className={styles.resetPassword}>
<ConditionallyRender <ConditionallyRender
condition={invalidToken} condition={!isValidToken}
show={<InvalidToken />} show={<InvalidToken />}
elseShow={ elseShow={
<> <>
@ -29,10 +51,11 @@ const ResetPassword = () => {
Reset password Reset password
</Typography> </Typography>
<ResetPasswordForm <ConditionallyRender
token={token} condition={hasApiError}
setLoading={setLoading} show={<ResetPasswordError />}
/> />
<ResetPasswordForm onSubmit={onSubmit} />
</> </>
} }
/> />

View File

@ -6,11 +6,13 @@ import { useThemeStyles } from 'themes/themeStyles';
import classnames from 'classnames'; import classnames from 'classnames';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails'; import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
import { useUserInvite } from 'hooks/api/getters/useUserInvite/useUserInvite';
const InvalidToken: VFC = () => { const InvalidToken: VFC = () => {
const { authDetails } = useAuthDetails(); const { authDetails } = useAuthDetails();
const { classes: themeStyles } = useThemeStyles(); const { classes: themeStyles } = useThemeStyles();
const passwordDisabled = authDetails?.defaultHidden === true; const passwordDisabled = authDetails?.defaultHidden === true;
const { secret } = useUserInvite(); // NOTE: can be enhanced with "expired token"
return ( return (
<div <div
@ -43,22 +45,35 @@ const InvalidToken: VFC = () => {
</> </>
} }
elseShow={ elseShow={
<> <ConditionallyRender
<Typography variant="subtitle1"> condition={Boolean(secret)}
Your token has either been used to reset your show={
password, or it has expired. Please request a new <Typography variant="subtitle1">
reset password URL in order to reset your password. Provided invite link is invalid or expired.
</Typography> Please request a new URL in order to create your
<Button account.
variant="contained" </Typography>
color="primary" }
component={Link} elseShow={
to="/forgotten-password" <>
data-testid={INVALID_TOKEN_BUTTON} <Typography variant="subtitle1">
> Your token has either been used to reset
Reset password your password, or it has expired. Please
</Button> request a new reset password URL in order to
</> reset your password.
</Typography>
<Button
variant="contained"
color="primary"
component={Link}
to="/forgotten-password"
data-testid={INVALID_TOKEN_BUTTON}
>
Reset password
</Button>
</>
}
/>
} }
/> />
</div> </div>

View File

@ -10,30 +10,24 @@ import React, {
} from 'react'; } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useThemeStyles } from 'themes/themeStyles'; import { useThemeStyles } from 'themes/themeStyles';
import { OK } from 'constants/statusCodes';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import ResetPasswordError from '../ResetPasswordError/ResetPasswordError';
import PasswordChecker from './PasswordChecker/PasswordChecker'; import PasswordChecker from './PasswordChecker/PasswordChecker';
import PasswordMatcher from './PasswordMatcher/PasswordMatcher'; import PasswordMatcher from './PasswordMatcher/PasswordMatcher';
import { useStyles } from './ResetPasswordForm.styles'; import { useStyles } from './ResetPasswordForm.styles';
import { formatApiPath } from 'utils/formatPath';
import PasswordField from 'component/common/PasswordField/PasswordField'; import PasswordField from 'component/common/PasswordField/PasswordField';
interface IResetPasswordProps { interface IResetPasswordProps {
token: string; onSubmit: (password: string) => void;
setLoading: Dispatch<SetStateAction<boolean>>;
} }
const ResetPasswordForm = ({ token, setLoading }: IResetPasswordProps) => { const ResetPasswordForm = ({ onSubmit }: IResetPasswordProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const { classes: themeStyles } = useThemeStyles(); const { classes: themeStyles } = useThemeStyles();
const [apiError, setApiError] = useState(false);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPasswordChecker, setShowPasswordChecker] = useState(false); const [showPasswordChecker, setShowPasswordChecker] = useState(false);
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [matchingPasswords, setMatchingPasswords] = useState(false); const [matchingPasswords, setMatchingPasswords] = useState(false);
const [validOwaspPassword, setValidOwaspPassword] = useState(false); const [validOwaspPassword, setValidOwaspPassword] = useState(false);
const navigate = useNavigate();
const submittable = matchingPasswords && validOwaspPassword; const submittable = matchingPasswords && validOwaspPassword;
@ -53,107 +47,69 @@ const ResetPasswordForm = ({ token, setLoading }: IResetPasswordProps) => {
} }
}, [password, confirmPassword]); }, [password, confirmPassword]);
const makeResetPasswordReq = () => {
const path = formatApiPath('auth/reset/password');
return fetch(path, {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
token,
password,
}),
});
};
const submitResetPassword = async () => {
setLoading(true);
try {
const res = await makeResetPasswordReq();
setLoading(false);
if (res.status === OK) {
navigate('/login?reset=true');
setApiError(false);
} else {
setApiError(true);
}
} catch (e) {
setApiError(true);
setLoading(false);
}
};
const handleSubmit = (e: SyntheticEvent) => { const handleSubmit = (e: SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
if (submittable) { if (submittable) {
submitResetPassword(); onSubmit(password);
} }
}; };
const started = Boolean(password && confirmPassword); const started = Boolean(password && confirmPassword);
return ( return (
<> <form
<ConditionallyRender onSubmit={handleSubmit}
condition={apiError} className={classnames(
show={<ResetPasswordError />} themeStyles.contentSpacingY,
styles.container
)}
>
<PasswordField
placeholder="Password"
value={password || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPassword(e.target.value)
}
onFocus={() => setShowPasswordChecker(true)}
autoComplete="new-password"
data-loading
/>
<PasswordField
value={confirmPassword || ''}
placeholder="Confirm password"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setConfirmPassword(e.target.value)
}
autoComplete="new-password"
data-loading
/>
<ConditionallyRender
condition={showPasswordChecker}
show={
<PasswordChecker
password={password}
callback={setValidOwaspPasswordMemo}
style={{ marginBottom: '1rem' }}
/>
}
/> />
<form
onSubmit={handleSubmit}
className={classnames(
themeStyles.contentSpacingY,
styles.container
)}
>
<PasswordField
placeholder="Password"
value={password || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPassword(e.target.value)
}
onFocus={() => setShowPasswordChecker(true)}
autoComplete="new-password"
data-loading
/>
<PasswordField
value={confirmPassword || ''}
placeholder="Confirm password"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setConfirmPassword(e.target.value)
}
autoComplete="new-password"
data-loading
/>
<ConditionallyRender
condition={showPasswordChecker}
show={
<PasswordChecker
password={password}
callback={setValidOwaspPasswordMemo}
style={{ marginBottom: '1rem' }}
/>
}
/>
<PasswordMatcher <PasswordMatcher
started={started} started={started}
matchingPasswords={matchingPasswords} matchingPasswords={matchingPasswords}
/> />
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
type="submit" type="submit"
className={styles.button} className={styles.button}
data-loading data-loading
disabled={!submittable} disabled={!submittable}
> >
Submit Submit
</Button> </Button>
</form> </form>
</>
); );
}; };

View File

@ -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;

View File

@ -1,5 +1,6 @@
export const BAD_REQUEST = 400; export const BAD_REQUEST = 400;
export const OK = 200; export const OK = 200;
export const CREATED = 201;
export const NOT_FOUND = 404; export const NOT_FOUND = 404;
export const FORBIDDEN = 403; export const FORBIDDEN = 403;
export const UNAUTHORIZED = 401; export const UNAUTHORIZED = 401;

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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 };
};

View File

@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
const getFetcher = (token: string) => () => { const getFetcher = (token: string) => () => {
if (!token) return Promise.resolve({ name: INVALID_TOKEN_ERROR });
const path = formatApiPath(`auth/reset/validate?token=${token}`); const path = formatApiPath(`auth/reset/validate?token=${token}`);
// Don't use handleErrorResponses here, because we need to read the error. // Don't use handleErrorResponses here, because we need to read the error.
return fetch(path, { return fetch(path, {
@ -34,11 +35,21 @@ const useResetPassword = (options: SWRConfiguration = {}) => {
setLoading(!error && !data); setLoading(!error && !data);
}, [data, error]); }, [data, error]);
const invalidToken = const isValidToken =
(!loading && data?.name === INVALID_TOKEN_ERROR) || (!loading && data?.name === INVALID_TOKEN_ERROR) ||
data?.name === USED_TOKEN_ERROR; data?.name === USED_TOKEN_ERROR
? false
: true;
return { token, data, error, loading, setLoading, invalidToken, retry }; return {
token,
data,
error,
loading,
isValidToken,
setLoading,
retry,
};
}; };
export default useResetPassword; export default useResetPassword;

View File

@ -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,
};
};

View 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;
}

View File

@ -40,6 +40,7 @@ export interface IFlags {
UG?: boolean; UG?: boolean;
ENABLE_DARK_MODE_SUPPORT?: boolean; ENABLE_DARK_MODE_SUPPORT?: boolean;
embedProxyFrontend?: boolean; embedProxyFrontend?: boolean;
publicSignup?: boolean;
} }
export interface IVersionInfo { export interface IVersionInfo {

View File

@ -49,6 +49,18 @@ export default mergeConfig(
target: UNLEASH_API, target: UNLEASH_API,
changeOrigin: true, changeOrigin: true,
}, },
[`${UNLEASH_BASE_PATH}health`]: {
target: UNLEASH_API,
changeOrigin: true,
},
[`${UNLEASH_BASE_PATH}invite`]: {
target: UNLEASH_API,
changeOrigin: true,
},
[`${UNLEASH_BASE_PATH}edge`]: {
target: UNLEASH_API,
changeOrigin: true,
},
}, },
}, },
plugins: [react(), tsconfigPaths(), svgr(), envCompatible()], plugins: [react(), tsconfigPaths(), svgr(), envCompatible()],

View File

@ -32,19 +32,28 @@ interface ITokenUserRow {
} }
const tokenRowReducer = (acc, tokenRow) => { const tokenRowReducer = (acc, tokenRow) => {
const { userId, userName, userUsername, roleId, roleName, ...token } = const {
tokenRow; userId,
userName,
userUsername,
roleId,
roleName,
roleType,
...token
} = tokenRow;
if (!acc[tokenRow.secret]) { if (!acc[tokenRow.secret]) {
acc[tokenRow.secret] = { acc[tokenRow.secret] = {
secret: token.secret, secret: token.secret,
name: token.name, name: token.name,
url: token.url, url: token.url,
expiresAt: token.expires_at, expiresAt: token.expires_at,
enabled: token.enabled,
createdAt: token.created_at, createdAt: token.created_at,
createdBy: token.created_by, createdBy: token.created_by,
role: { role: {
id: roleId, id: roleId,
name: roleName, name: roleName,
type: roleType,
}, },
users: [], users: [],
}; };
@ -113,6 +122,7 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
'tokens.secret', 'tokens.secret',
'tokens.name', 'tokens.name',
'tokens.expires_at', 'tokens.expires_at',
'tokens.enabled',
'tokens.created_at', 'tokens.created_at',
'tokens.created_by', 'tokens.created_by',
'tokens.url', 'tokens.url',
@ -121,6 +131,7 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
'users.username as userUsername', 'users.username as userUsername',
'roles.id as roleId', 'roles.id as roleId',
'roles.name as roleName', 'roles.name as roleName',
'roles.type as roleType',
); );
} }
@ -159,7 +170,7 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
async isValid(secret: string): Promise<boolean> { async isValid(secret: string): Promise<boolean> {
const result = await this.db.raw( const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE secret = ? AND expires_at::date > ?) AS valid`, `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE secret = ? AND expires_at::date > ? AND enabled = true) AS valid`,
[secret, new Date()], [secret, new Date()],
); );
const { valid } = result.rows[0]; const { valid } = result.rows[0];
@ -197,12 +208,12 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore {
return this.db<ITokenInsert>(TABLE).del(); return this.db<ITokenInsert>(TABLE).del();
} }
async setExpiry( async update(
secret: string, secret: string,
expiresAt: Date, { expiresAt, enabled }: { expiresAt?: Date; enabled?: boolean },
): Promise<PublicSignupTokenSchema> { ): Promise<PublicSignupTokenSchema> {
const rows = await this.makeTokenUsersQuery() const rows = await this.makeTokenUsersQuery()
.update({ expires_at: expiresAt }) .update({ expires_at: expiresAt, enabled })
.where('secret', secret) .where('secret', secret)
.returning('*'); .returning('*');
if (rows.length > 0) { if (rows.length > 0) {

View File

@ -20,8 +20,10 @@ import { contextFieldsSchema } from './spec/context-fields-schema';
import { createApiTokenSchema } from './spec/create-api-token-schema'; import { createApiTokenSchema } from './spec/create-api-token-schema';
import { createFeatureSchema } from './spec/create-feature-schema'; import { createFeatureSchema } from './spec/create-feature-schema';
import { createFeatureStrategySchema } from './spec/create-feature-strategy-schema'; import { createFeatureStrategySchema } from './spec/create-feature-strategy-schema';
import { createInvitedUserSchema } from './spec/create-invited-user-schema';
import { createUserSchema } from './spec/create-user-schema'; import { createUserSchema } from './spec/create-user-schema';
import { dateSchema } from './spec/date-schema'; import { dateSchema } from './spec/date-schema';
import { edgeTokenSchema } from './spec/edge-token-schema';
import { emailSchema } from './spec/email-schema'; import { emailSchema } from './spec/email-schema';
import { environmentSchema } from './spec/environment-schema'; import { environmentSchema } from './spec/environment-schema';
import { environmentsSchema } from './spec/environments-schema'; import { environmentsSchema } from './spec/environments-schema';
@ -41,36 +43,54 @@ import { featureTypesSchema } from './spec/feature-types-schema';
import { featureUsageSchema } from './spec/feature-usage-schema'; import { featureUsageSchema } from './spec/feature-usage-schema';
import { featureVariantsSchema } from './spec/feature-variants-schema'; import { featureVariantsSchema } from './spec/feature-variants-schema';
import { feedbackSchema } from './spec/feedback-schema'; import { feedbackSchema } from './spec/feedback-schema';
import { groupSchema } from './spec/group-schema';
import { groupsSchema } from './spec/groups-schema';
import { groupUserModelSchema } from './spec/group-user-model-schema';
import { healthCheckSchema } from './spec/health-check-schema'; import { healthCheckSchema } from './spec/health-check-schema';
import { healthOverviewSchema } from './spec/health-overview-schema'; import { healthOverviewSchema } from './spec/health-overview-schema';
import { healthReportSchema } from './spec/health-report-schema'; import { healthReportSchema } from './spec/health-report-schema';
import { idSchema } from './spec/id-schema'; import { idSchema } from './spec/id-schema';
import { IServerOption } from '../types';
import { legalValueSchema } from './spec/legal-value-schema'; import { legalValueSchema } from './spec/legal-value-schema';
import { loginSchema } from './spec/login-schema'; import { loginSchema } from './spec/login-schema';
import { mapValues } from '../util/map-values'; import { mapValues } from '../util/map-values';
import { meSchema } from './spec/me-schema'; import { meSchema } from './spec/me-schema';
import { nameSchema } from './spec/name-schema'; import { nameSchema } from './spec/name-schema';
import { omitKeys } from '../util/omit-keys'; import { omitKeys } from '../util/omit-keys';
import { openApiTags } from './util/openapi-tags';
import { overrideSchema } from './spec/override-schema'; import { overrideSchema } from './spec/override-schema';
import { parametersSchema } from './spec/parameters-schema'; import { parametersSchema } from './spec/parameters-schema';
import { passwordSchema } from './spec/password-schema'; import { passwordSchema } from './spec/password-schema';
import { patchesSchema } from './spec/patches-schema'; import { patchesSchema } from './spec/patches-schema';
import { patchSchema } from './spec/patch-schema'; import { patchSchema } from './spec/patch-schema';
import { patSchema } from './spec/pat-schema';
import { patsSchema } from './spec/pats-schema';
import { permissionSchema } from './spec/permission-schema'; import { permissionSchema } from './spec/permission-schema';
import { playgroundFeatureSchema } from './spec/playground-feature-schema';
import { playgroundStrategySchema } from './spec/playground-strategy-schema';
import { playgroundConstraintSchema } from './spec/playground-constraint-schema'; import { playgroundConstraintSchema } from './spec/playground-constraint-schema';
import { playgroundSegmentSchema } from './spec/playground-segment-schema'; import { playgroundFeatureSchema } from './spec/playground-feature-schema';
import { playgroundRequestSchema } from './spec/playground-request-schema'; import { playgroundRequestSchema } from './spec/playground-request-schema';
import { playgroundResponseSchema } from './spec/playground-response-schema'; import { playgroundResponseSchema } from './spec/playground-response-schema';
import { playgroundSegmentSchema } from './spec/playground-segment-schema';
import { playgroundStrategySchema } from './spec/playground-strategy-schema';
import { profileSchema } from './spec/profile-schema';
import { projectEnvironmentSchema } from './spec/project-environment-schema'; import { projectEnvironmentSchema } from './spec/project-environment-schema';
import { projectSchema } from './spec/project-schema'; import { projectSchema } from './spec/project-schema';
import { projectsSchema } from './spec/projects-schema'; import { projectsSchema } from './spec/projects-schema';
import { proxyClientSchema } from './spec/proxy-client-schema';
import { proxyFeatureSchema } from './spec/proxy-feature-schema';
import { proxyFeaturesSchema } from './spec/proxy-features-schema';
import { proxyMetricsSchema } from './spec/proxy-metrics-schema';
import { publicSignupTokenCreateSchema } from './spec/public-signup-token-create-schema';
import { publicSignupTokenSchema } from './spec/public-signup-token-schema';
import { publicSignupTokensSchema } from './spec/public-signup-tokens-schema';
import { publicSignupTokenUpdateSchema } from './spec/public-signup-token-update-schema';
import { resetPasswordSchema } from './spec/reset-password-schema'; import { resetPasswordSchema } from './spec/reset-password-schema';
import { roleSchema } from './spec/role-schema'; import { roleSchema } from './spec/role-schema';
import { sdkContextSchema } from './spec/sdk-context-schema'; import { sdkContextSchema } from './spec/sdk-context-schema';
import { searchEventsSchema } from './spec/search-events-schema';
import { segmentSchema } from './spec/segment-schema'; import { segmentSchema } from './spec/segment-schema';
import { setStrategySortOrderSchema } from './spec/set-strategy-sort-order-schema'; import { setStrategySortOrderSchema } from './spec/set-strategy-sort-order-schema';
import { setUiConfigSchema } from './spec/set-ui-config-schema';
import { sortOrderSchema } from './spec/sort-order-schema'; import { sortOrderSchema } from './spec/sort-order-schema';
import { splashSchema } from './spec/splash-schema'; import { splashSchema } from './spec/splash-schema';
import { stateSchema } from './spec/state-schema'; import { stateSchema } from './spec/state-schema';
@ -90,37 +110,18 @@ import { updateTagTypeSchema } from './spec/update-tag-type-schema';
import { updateUserSchema } from './spec/update-user-schema'; import { updateUserSchema } from './spec/update-user-schema';
import { upsertContextFieldSchema } from './spec/upsert-context-field-schema'; import { upsertContextFieldSchema } from './spec/upsert-context-field-schema';
import { upsertStrategySchema } from './spec/upsert-strategy-schema'; import { upsertStrategySchema } from './spec/upsert-strategy-schema';
import { URL } from 'url';
import { userSchema } from './spec/user-schema'; import { userSchema } from './spec/user-schema';
import { usersGroupsBaseSchema } from './spec/users-groups-base-schema';
import { usersSchema } from './spec/users-schema'; import { usersSchema } from './spec/users-schema';
import { usersSearchSchema } from './spec/users-search-schema'; import { usersSearchSchema } from './spec/users-search-schema';
import { validateEdgeTokensSchema } from './spec/validate-edge-tokens-schema';
import { validatePasswordSchema } from './spec/validate-password-schema'; import { validatePasswordSchema } from './spec/validate-password-schema';
import { validateTagTypeSchema } from './spec/validate-tag-type-schema'; import { validateTagTypeSchema } from './spec/validate-tag-type-schema';
import { variantSchema } from './spec/variant-schema'; import { variantSchema } from './spec/variant-schema';
import { variantsSchema } from './spec/variants-schema'; import { variantsSchema } from './spec/variants-schema';
import { versionSchema } from './spec/version-schema'; import { versionSchema } from './spec/version-schema';
import { IServerOption } from '../types';
import { URL } from 'url';
import { groupSchema } from './spec/group-schema';
import { groupsSchema } from './spec/groups-schema';
import { groupUserModelSchema } from './spec/group-user-model-schema';
import { usersGroupsBaseSchema } from './spec/users-groups-base-schema';
import { openApiTags } from './util/openapi-tags';
import { searchEventsSchema } from './spec/search-events-schema';
import { proxyFeaturesSchema } from './spec/proxy-features-schema';
import { proxyFeatureSchema } from './spec/proxy-feature-schema';
import { proxyClientSchema } from './spec/proxy-client-schema';
import { proxyMetricsSchema } from './spec/proxy-metrics-schema';
import { setUiConfigSchema } from './spec/set-ui-config-schema';
import { edgeTokenSchema } from './spec/edge-token-schema';
import { validateEdgeTokensSchema } from './spec/validate-edge-tokens-schema';
import { patsSchema } from './spec/pats-schema';
import { patSchema } from './spec/pat-schema';
import { publicSignupTokenCreateSchema } from './spec/public-signup-token-create-schema';
import { publicSignupTokenSchema } from './spec/public-signup-token-schema';
import { publicSignupTokensSchema } from './spec/public-signup-tokens-schema';
import { publicSignupTokenUpdateSchema } from './spec/public-signup-token-update-schema';
import apiVersion from '../util/version'; import apiVersion from '../util/version';
import { profileSchema } from './spec/profile-schema';
// All schemas in `openapi/spec` should be listed here. // All schemas in `openapi/spec` should be listed here.
export const schemas = { export const schemas = {
@ -145,10 +146,11 @@ export const schemas = {
createApiTokenSchema, createApiTokenSchema,
createFeatureSchema, createFeatureSchema,
createFeatureStrategySchema, createFeatureStrategySchema,
createInvitedUserSchema,
createUserSchema, createUserSchema,
dateSchema, dateSchema,
emailSchema,
edgeTokenSchema, edgeTokenSchema,
emailSchema,
environmentSchema, environmentSchema,
environmentsSchema, environmentsSchema,
eventSchema, eventSchema,
@ -181,29 +183,29 @@ export const schemas = {
overrideSchema, overrideSchema,
parametersSchema, parametersSchema,
passwordSchema, passwordSchema,
patSchema,
patsSchema,
patchesSchema, patchesSchema,
patchSchema, patchSchema,
patSchema,
patsSchema,
permissionSchema, permissionSchema,
playgroundFeatureSchema,
playgroundStrategySchema,
playgroundConstraintSchema, playgroundConstraintSchema,
playgroundSegmentSchema, playgroundFeatureSchema,
playgroundRequestSchema, playgroundRequestSchema,
playgroundResponseSchema, playgroundResponseSchema,
projectEnvironmentSchema, playgroundSegmentSchema,
publicSignupTokenCreateSchema, playgroundStrategySchema,
publicSignupTokenUpdateSchema,
publicSignupTokensSchema,
publicSignupTokenSchema,
profileSchema, profileSchema,
proxyClientSchema, projectEnvironmentSchema,
proxyFeaturesSchema,
proxyFeatureSchema,
proxyMetricsSchema,
projectSchema, projectSchema,
projectsSchema, projectsSchema,
proxyClientSchema,
proxyFeatureSchema,
proxyFeaturesSchema,
proxyMetricsSchema,
publicSignupTokenCreateSchema,
publicSignupTokenSchema,
publicSignupTokensSchema,
publicSignupTokenUpdateSchema,
resetPasswordSchema, resetPasswordSchema,
roleSchema, roleSchema,
sdkContextSchema, sdkContextSchema,
@ -230,16 +232,16 @@ export const schemas = {
updateUserSchema, updateUserSchema,
upsertContextFieldSchema, upsertContextFieldSchema,
upsertStrategySchema, upsertStrategySchema,
usersGroupsBaseSchema,
userSchema, userSchema,
usersGroupsBaseSchema,
usersSchema, usersSchema,
usersSearchSchema, usersSearchSchema,
validateEdgeTokensSchema,
validatePasswordSchema, validatePasswordSchema,
validateTagTypeSchema, validateTagTypeSchema,
variantSchema, variantSchema,
variantsSchema, variantsSchema,
versionSchema, versionSchema,
validateEdgeTokensSchema,
}; };
// Schemas must have an $id property on the form "#/components/schemas/mySchema". // Schemas must have an $id property on the form "#/components/schemas/mySchema".

View 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
>;

View File

@ -11,6 +11,7 @@ test('publicSignupTokenSchema', () => {
role: { name: 'Viewer ', type: 'type', id: 1 }, role: { name: 'Viewer ', type: 'type', id: 1 },
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
createdBy: 'someone', createdBy: 'someone',
enabled: true,
}; };
expect( expect(

View File

@ -13,6 +13,7 @@ export const publicSignupTokenSchema = {
'expiresAt', 'expiresAt',
'createdAt', 'createdAt',
'createdBy', 'createdBy',
'enabled',
'role', 'role',
], ],
properties: { properties: {
@ -25,6 +26,9 @@ export const publicSignupTokenSchema = {
name: { name: {
type: 'string', type: 'string',
}, },
enabled: {
type: 'boolean',
},
expiresAt: { expiresAt: {
type: 'string', type: 'string',
format: 'date-time', format: 'date-time',

View File

@ -4,12 +4,14 @@ export const publicSignupTokenUpdateSchema = {
$id: '#/components/schemas/publicSignupTokenUpdateSchema', $id: '#/components/schemas/publicSignupTokenUpdateSchema',
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['expiresAt'],
properties: { properties: {
expiresAt: { expiresAt: {
type: 'string', type: 'string',
format: 'date-time', format: 'date-time',
}, },
enabled: {
type: 'boolean',
},
}, },
components: {}, components: {},
} as const; } as const;

View File

@ -5,7 +5,6 @@ import getApp from '../../app';
import supertest from 'supertest'; import supertest from 'supertest';
import permissions from '../../../test/fixtures/permissions'; import permissions from '../../../test/fixtures/permissions';
import { RoleName, RoleType } from '../../types/model'; import { RoleName, RoleType } from '../../types/model';
import { CreateUserSchema } from '../../openapi/spec/create-user-schema';
describe('Public Signup API', () => { describe('Public Signup API', () => {
async function getSetup() { async function getSetup() {
@ -51,6 +50,13 @@ describe('Public Signup API', () => {
let request; let request;
let destroy; let destroy;
const user = {
username: 'some-username',
email: 'someEmail@example.com',
name: 'some-name',
password: 'password',
};
beforeEach(async () => { beforeEach(async () => {
const setup = await getSetup(); const setup = await getSetup();
stores = setup.stores; stores = setup.stores;
@ -132,6 +138,30 @@ describe('Public Signup API', () => {
}); });
test('should expire token', async () => { test('should expire token', async () => {
expect.assertions(2);
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
stores.publicSignupTokenStore.create({
name: 'some-name',
expiresAt: expireAt(),
});
const expireNow = expireAt(0);
return request
.put('/api/admin/invite-link/tokens/some-secret')
.send({ expiresAt: expireNow.toISOString() })
.expect(200)
.expect(async (res) => {
const token = res.body;
expect(token.expiresAt).toBe(expireNow.toISOString());
const eventCount = await stores.eventStore.count();
expect(eventCount).toBe(1); // PUBLIC_SIGNUP_TOKEN_TOKEN_UPDATED
});
});
test('should disable the token', async () => {
expect.assertions(1); expect.assertions(1);
const appName = '123!23'; const appName = '123!23';
@ -142,47 +172,16 @@ describe('Public Signup API', () => {
}); });
return request return request
.delete('/api/admin/invite-link/tokens/some-secret') .put('/api/admin/invite-link/tokens/some-secret')
.send({ enabled: false })
.expect(200) .expect(200)
.expect(async () => {
const eventCount = await stores.eventStore.count();
expect(eventCount).toBe(1); // PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED
});
});
test('should create user and add to token', async () => {
expect.assertions(3);
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
stores.publicSignupTokenStore.create({
name: 'some-name',
expiresAt: expireAt(),
});
const user: CreateUserSchema = {
username: 'some-username',
email: 'someEmail@example.com',
name: 'some-name',
password: null,
rootRole: 1,
sendEmail: false,
};
return request
.post('/api/admin/invite-link/tokens/some-secret/signup')
.send(user)
.expect(201)
.expect(async (res) => { .expect(async (res) => {
const count = await stores.userStore.count(); const token = res.body;
expect(count).toBe(1); expect(token.enabled).toBe(false);
const eventCount = await stores.eventStore.count();
expect(eventCount).toBe(2); //USER_CREATED && PUBLIC_SIGNUP_TOKEN_USER_ADDED
expect(res.body.username).toBe(user.username);
}); });
}); });
test('should return 200 if token is valid', async () => { test('should not allow a user to register disabled token', async () => {
const appName = '123!23'; const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName }); stores.clientApplicationsStore.upsert({ appName });
@ -190,19 +189,11 @@ describe('Public Signup API', () => {
name: 'some-name', name: 'some-name',
expiresAt: expireAt(), expiresAt: expireAt(),
}); });
stores.publicSignupTokenStore.update('some-secret', { enabled: false });
return request return request
.post('/api/admin/invite-link/tokens/some-secret/validate') .post('/invite/some-secret/signup')
.expect(200); .send(user)
}); .expect(400);
test('should return 401 if token is invalid', async () => {
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
return request
.post('/api/admin/invite-link/tokens/some-invalid-secret/validate')
.expect(401);
}); });
}); });

View File

@ -1,7 +1,7 @@
import { Response } from 'express'; import { Response } from 'express';
import Controller from '../controller'; import Controller from '../controller';
import { ADMIN, NONE } from '../../types/permissions'; import { ADMIN } from '../../types/permissions';
import { Logger } from '../../logger'; import { Logger } from '../../logger';
import { AccessService } from '../../services/access-service'; import { AccessService } from '../../services/access-service';
import { IAuthRequest } from '../unleash-types'; import { IAuthRequest } from '../unleash-types';
@ -13,10 +13,6 @@ import {
resourceCreatedResponseSchema, resourceCreatedResponseSchema,
} from '../../openapi/util/create-response-schema'; } from '../../openapi/util/create-response-schema';
import { serializeDates } from '../../types/serialize-dates'; import { serializeDates } from '../../types/serialize-dates';
import {
emptyResponse,
getStandardResponses,
} from '../../openapi/util/standard-responses';
import { PublicSignupTokenService } from '../../services/public-signup-token-service'; import { PublicSignupTokenService } from '../../services/public-signup-token-service';
import UserService from '../../services/user-service'; import UserService from '../../services/user-service';
import { import {
@ -29,8 +25,6 @@ import {
} from '../../openapi/spec/public-signup-tokens-schema'; } from '../../openapi/spec/public-signup-tokens-schema';
import { PublicSignupTokenCreateSchema } from '../../openapi/spec/public-signup-token-create-schema'; import { PublicSignupTokenCreateSchema } from '../../openapi/spec/public-signup-token-create-schema';
import { PublicSignupTokenUpdateSchema } from '../../openapi/spec/public-signup-token-update-schema'; import { PublicSignupTokenUpdateSchema } from '../../openapi/spec/public-signup-token-update-schema';
import { CreateUserSchema } from '../../openapi/spec/create-user-schema';
import { UserSchema, userSchema } from '../../openapi/spec/user-schema';
import { extractUsername } from '../../util/extract-user'; import { extractUsername } from '../../util/extract-user';
interface TokenParam { interface TokenParam {
@ -107,24 +101,6 @@ export class PublicSignupController extends Controller {
], ],
}); });
this.route({
method: 'post',
path: '/tokens/:token/signup',
handler: this.addTokenUser,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Public signup tokens'],
operationId: 'addPublicSignupTokenUser',
requestBody: createRequestSchema('createUserSchema'),
responses: {
200: createResponseSchema('userSchema'),
...getStandardResponses(409),
},
}),
],
});
this.route({ this.route({
method: 'get', method: 'get',
path: '/tokens/:token', path: '/tokens/:token',
@ -154,42 +130,7 @@ export class PublicSignupController extends Controller {
'publicSignupTokenUpdateSchema', 'publicSignupTokenUpdateSchema',
), ),
responses: { responses: {
200: emptyResponse, 200: createResponseSchema('publicSignupTokenSchema'),
},
}),
],
});
this.route({
method: 'delete',
path: '/tokens/:token',
handler: this.deletePublicSignupToken,
acceptAnyContentType: true,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['Public signup tokens'],
operationId: 'deletePublicSignupToken',
responses: {
200: emptyResponse,
},
}),
],
});
this.route({
method: 'post',
path: '/tokens/:token/validate',
handler: this.validate,
acceptAnyContentType: true,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Public signup tokens'],
operationId: 'validatePublicSignupToken',
responses: {
200: emptyResponse,
401: emptyResponse,
}, },
}), }),
], ],
@ -223,33 +164,6 @@ export class PublicSignupController extends Controller {
); );
} }
async validate(
req: IAuthRequest<TokenParam, void, CreateUserSchema>,
res: Response,
): Promise<void> {
const { token } = req.params;
const valid = await this.publicSignupTokenService.validate(token);
if (valid) return res.status(200).end();
else return res.status(401).end();
}
async addTokenUser(
req: IAuthRequest<TokenParam, void, CreateUserSchema>,
res: Response<UserSchema>,
): Promise<void> {
const { token } = req.params;
const user = await this.publicSignupTokenService.addTokenUser(
token,
req.body,
);
this.openApiService.respondWithValidation(
201,
res,
userSchema.$id,
serializeDates(user),
);
}
async createPublicSignupToken( async createPublicSignupToken(
req: IAuthRequest<void, void, PublicSignupTokenCreateSchema>, req: IAuthRequest<void, void, PublicSignupTokenCreateSchema>,
res: Response<PublicSignupTokenSchema>, res: Response<PublicSignupTokenSchema>,
@ -274,28 +188,27 @@ export class PublicSignupController extends Controller {
res: Response, res: Response,
): Promise<any> { ): Promise<any> {
const { token } = req.params; const { token } = req.params;
const { expiresAt } = req.body; const { expiresAt, enabled } = req.body;
if (!expiresAt) { if (!expiresAt && enabled === undefined) {
this.logger.error(req.body); this.logger.error(req.body);
return res.status(400).send(); return res.status(400).send();
} }
await this.publicSignupTokenService.setExpiry( const result = await this.publicSignupTokenService.update(
token, token,
new Date(expiresAt), {
...(enabled === undefined ? {} : { enabled }),
...(expiresAt ? { expiresAt: new Date(expiresAt) } : {}),
},
extractUsername(req),
); );
return res.status(200).end();
}
async deletePublicSignupToken( this.openApiService.respondWithValidation(
req: IAuthRequest<TokenParam>, 200,
res: Response, res,
): Promise<void> { publicSignupTokenSchema.$id,
const { token } = req.params; serializeDates(result),
const username = extractUsername(req); );
await this.publicSignupTokenService.delete(token, username);
res.status(200).end();
} }
} }

View File

@ -12,12 +12,17 @@ import { HealthCheckController } from './health-check';
import ProxyController from './proxy-api'; import ProxyController from './proxy-api';
import { conditionalMiddleware } from '../middleware/conditional-middleware'; import { conditionalMiddleware } from '../middleware/conditional-middleware';
import EdgeController from './edge-api'; import EdgeController from './edge-api';
import { PublicInviteController } from './public-invite';
class IndexRouter extends Controller { class IndexRouter extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices) { constructor(config: IUnleashConfig, services: IUnleashServices) {
super(config); super(config);
this.use('/health', new HealthCheckController(config, services).router); this.use('/health', new HealthCheckController(config, services).router);
this.use(
'/invite',
new PublicInviteController(config, services).router,
);
this.use('/internal-backstage', new BackstageController(config).router); this.use('/internal-backstage', new BackstageController(config).router);
this.use('/logout', new LogoutController(config, services).router); this.use('/logout', new LogoutController(config, services).router);
this.useWithMiddleware( this.useWithMiddleware(

View 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);
});
});

View 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),
);
}
}

View File

@ -6,14 +6,15 @@ import { PublicSignupTokenSchema } from '../openapi/spec/public-signup-token-sch
import { IRoleStore } from '../types/stores/role-store'; import { IRoleStore } from '../types/stores/role-store';
import { IPublicSignupTokenCreate } from '../types/models/public-signup-token'; import { IPublicSignupTokenCreate } from '../types/models/public-signup-token';
import { PublicSignupTokenCreateSchema } from '../openapi/spec/public-signup-token-create-schema'; import { PublicSignupTokenCreateSchema } from '../openapi/spec/public-signup-token-create-schema';
import { CreateInvitedUserSchema } from 'lib/openapi/spec/create-invited-user-schema';
import { RoleName } from '../types/model'; import { RoleName } from '../types/model';
import { IEventStore } from '../types/stores/event-store'; import { IEventStore } from '../types/stores/event-store';
import { import {
PublicSignupTokenCreatedEvent, PublicSignupTokenCreatedEvent,
PublicSignupTokenManuallyExpiredEvent, PublicSignupTokenUpdatedEvent,
PublicSignupTokenUserAddedEvent, PublicSignupTokenUserAddedEvent,
} from '../types/events'; } from '../types/events';
import UserService, { ICreateUser } from './user-service'; import UserService from './user-service';
import { IUser } from '../types/user'; import { IUser } from '../types/user';
import { URL } from 'url'; import { URL } from 'url';
@ -56,7 +57,7 @@ export class PublicSignupTokenService {
private getUrl(secret: string): string { private getUrl(secret: string): string {
return new URL( return new URL(
`${this.unleashBase}/invite-link/${secret}/signup`, `${this.unleashBase}/new-user?invite=${secret}`,
).toString(); ).toString();
} }
@ -76,20 +77,30 @@ export class PublicSignupTokenService {
return this.store.isValid(secret); return this.store.isValid(secret);
} }
public async setExpiry( public async update(
secret: string, secret: string,
expireAt: Date, { expiresAt, enabled }: { expiresAt?: Date; enabled?: boolean },
createdBy: string,
): Promise<PublicSignupTokenSchema> { ): Promise<PublicSignupTokenSchema> {
return this.store.setExpiry(secret, expireAt); const result = await this.store.update(secret, { expiresAt, enabled });
await this.eventStore.store(
new PublicSignupTokenUpdatedEvent({
createdBy,
data: { secret, enabled, expiresAt },
}),
);
return result;
} }
public async addTokenUser( public async addTokenUser(
secret: string, secret: string,
createUser: ICreateUser, createUser: CreateInvitedUserSchema,
): Promise<IUser> { ): Promise<IUser> {
const token = await this.get(secret); const token = await this.get(secret);
createUser.rootRole = token.role.id; const user = await this.userService.createUser({
const user = await this.userService.createUser(createUser); ...createUser,
rootRole: token.role.id,
});
await this.store.addTokenUser(secret, user.id); await this.store.addTokenUser(secret, user.id);
await this.eventStore.store( await this.eventStore.store(
new PublicSignupTokenUserAddedEvent({ new PublicSignupTokenUserAddedEvent({
@ -100,22 +111,6 @@ export class PublicSignupTokenService {
return user; return user;
} }
public async delete(secret: string, expiredBy: string): Promise<void> {
await this.expireToken(secret);
await this.eventStore.store(
new PublicSignupTokenManuallyExpiredEvent({
createdBy: expiredBy,
data: { secret },
}),
);
}
private async expireToken(
secret: string,
): Promise<PublicSignupTokenSchema> {
return this.store.setExpiry(secret, new Date());
}
public async createNewPublicSignupToken( public async createNewPublicSignupToken(
tokenCreate: PublicSignupTokenCreateSchema, tokenCreate: PublicSignupTokenCreateSchema,
createdBy: string, createdBy: string,

View File

@ -80,8 +80,7 @@ export const PAT_CREATED = 'pat-created';
export const PUBLIC_SIGNUP_TOKEN_CREATED = 'public-signup-token-created'; export const PUBLIC_SIGNUP_TOKEN_CREATED = 'public-signup-token-created';
export const PUBLIC_SIGNUP_TOKEN_USER_ADDED = 'public-signup-token-user-added'; export const PUBLIC_SIGNUP_TOKEN_USER_ADDED = 'public-signup-token-user-added';
export const PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED = export const PUBLIC_SIGNUP_TOKEN_TOKEN_UPDATED = 'public-signup-token-updated';
'public-signup-token-manually-expired';
export interface IBaseEvent { export interface IBaseEvent {
type: string; type: string;
@ -548,11 +547,11 @@ export class PublicSignupTokenCreatedEvent extends BaseEvent {
} }
} }
export class PublicSignupTokenManuallyExpiredEvent extends BaseEvent { export class PublicSignupTokenUpdatedEvent extends BaseEvent {
readonly data: any; readonly data: any;
constructor(eventData: { createdBy: string; data: any }) { constructor(eventData: { createdBy: string; data: any }) {
super(PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED, eventData.createdBy); super(PUBLIC_SIGNUP_TOKEN_TOKEN_UPDATED, eventData.createdBy);
this.data = eventData.data; this.data = eventData.data;
} }
} }

View File

@ -10,9 +10,9 @@ export interface IPublicSignupTokenStore
): Promise<PublicSignupTokenSchema>; ): Promise<PublicSignupTokenSchema>;
addTokenUser(secret: string, userId: number): Promise<void>; addTokenUser(secret: string, userId: number): Promise<void>;
isValid(secret): Promise<boolean>; isValid(secret): Promise<boolean>;
setExpiry( update(
secret: string, secret: string,
expiresAt: Date, value: { expiresAt?: Date; enabled?: boolean },
): Promise<PublicSignupTokenSchema>; ): Promise<PublicSignupTokenSchema>;
delete(secret: string): Promise<void>; delete(secret: string): Promise<void>;
count(): Promise<number>; count(): Promise<number>;

View File

@ -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,
);
};

View File

@ -3,7 +3,6 @@ import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger'; import getLogger from '../../../fixtures/no-logger';
import { RoleName } from '../../../../lib/types/model'; import { RoleName } from '../../../../lib/types/model';
import { PublicSignupTokenCreateSchema } from '../../../../lib/openapi/spec/public-signup-token-create-schema'; import { PublicSignupTokenCreateSchema } from '../../../../lib/openapi/spec/public-signup-token-create-schema';
import { CreateUserSchema } from '../../../../lib/openapi/spec/create-user-schema';
let stores; let stores;
let db; let db;
@ -99,14 +98,12 @@ test('no permission to validate a token', async () => {
createdBy: 'admin@example.com', createdBy: 'admin@example.com',
roleId: 3, roleId: 3,
}); });
await request await request.get('/invite/some-secret/validate').expect(200);
.post('/api/admin/invite-link/tokens/some-secret/validate')
.expect(200);
await destroy(); await destroy();
}); });
test('should return 401 if token can not be validate', async () => { test('should return 400 if token can not be validate', async () => {
const preHook = (app, config, { userService, accessService }) => { const preHook = (app, config, { userService, accessService }) => {
app.use('/api/admin/', async (req, res, next) => { app.use('/api/admin/', async (req, res, next) => {
const admin = await accessService.getRootRole(RoleName.ADMIN); const admin = await accessService.getRootRole(RoleName.ADMIN);
@ -121,9 +118,7 @@ test('should return 401 if token can not be validate', async () => {
const { request, destroy } = await setupAppWithCustomAuth(stores, preHook); const { request, destroy } = await setupAppWithCustomAuth(stores, preHook);
await request await request.get('/invite/some-invalid-secret/validate').expect(400);
.post('/api/admin/invite-link/tokens/some-invalid-secret/validate')
.expect(401);
await destroy(); await destroy();
}); });
@ -149,27 +144,25 @@ test('users can signup with invite-link', async () => {
name: 'some-name', name: 'some-name',
expiresAt: expireAt(), expiresAt: expireAt(),
secret: 'some-secret', secret: 'some-secret',
url: 'http://localhost:4242/invite-lint/some-secret/signup', url: 'http://localhost:4242/invite/some-secret/signup',
createAt: new Date(), createAt: new Date(),
createdBy: 'admin@example.com', createdBy: 'admin@example.com',
roleId: 3, roleId: 3,
}); });
const createUser: CreateUserSchema = { const createUser = {
username: 'some-username', name: 'some-username',
email: 'some@example.com', email: 'some@example.com',
password: 'eweggwEG', password: 'eweggwEG',
sendEmail: false,
rootRole: 1,
}; };
await request await request
.post('/api/admin/invite-link/tokens/some-secret/signup') .post('/invite/some-secret/signup')
.send(createUser) .send(createUser)
.expect(201) .expect(201)
.expect((res) => { .expect((res) => {
const user = res.body; const user = res.body;
expect(user.username).toBe('some-username'); expect(user.name).toBe('some-username');
}); });
await destroy(); await destroy();

View File

@ -724,6 +724,29 @@ exports[`should serve the OpenAPI spec 1`] = `
], ],
"type": "object", "type": "object",
}, },
"createInvitedUserSchema": {
"additionalProperties": false,
"properties": {
"email": {
"type": "string",
},
"name": {
"type": "string",
},
"password": {
"type": "string",
},
"username": {
"type": "string",
},
},
"required": [
"email",
"name",
"password",
],
"type": "object",
},
"createUserSchema": { "createUserSchema": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@ -2452,6 +2475,9 @@ exports[`should serve the OpenAPI spec 1`] = `
"nullable": true, "nullable": true,
"type": "string", "type": "string",
}, },
"enabled": {
"type": "boolean",
},
"expiresAt": { "expiresAt": {
"format": "date-time", "format": "date-time",
"type": "string", "type": "string",
@ -2483,6 +2509,7 @@ exports[`should serve the OpenAPI spec 1`] = `
"expiresAt", "expiresAt",
"createdAt", "createdAt",
"createdBy", "createdBy",
"enabled",
"role", "role",
], ],
"type": "object", "type": "object",
@ -2490,14 +2517,14 @@ exports[`should serve the OpenAPI spec 1`] = `
"publicSignupTokenUpdateSchema": { "publicSignupTokenUpdateSchema": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"enabled": {
"type": "boolean",
},
"expiresAt": { "expiresAt": {
"format": "date-time", "format": "date-time",
"type": "string", "type": "string",
}, },
}, },
"required": [
"expiresAt",
],
"type": "object", "type": "object",
}, },
"publicSignupTokensSchema": { "publicSignupTokensSchema": {
@ -4600,27 +4627,6 @@ If the provided project does not exist, the list of events will be empty.",
}, },
}, },
"/api/admin/invite-link/tokens/{token}": { "/api/admin/invite-link/tokens/{token}": {
"delete": {
"operationId": "deletePublicSignupToken",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"Public signup tokens",
],
},
"get": { "get": {
"operationId": "getPublicSignupToken", "operationId": "getPublicSignupToken",
"parameters": [ "parameters": [
@ -4672,79 +4678,16 @@ If the provided project does not exist, the list of events will be empty.",
"description": "publicSignupTokenUpdateSchema", "description": "publicSignupTokenUpdateSchema",
"required": true, "required": true,
}, },
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"Public signup tokens",
],
},
},
"/api/admin/invite-link/tokens/{token}/signup": {
"post": {
"operationId": "addPublicSignupTokenUser",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/createUserSchema",
},
},
},
"description": "createUserSchema",
"required": true,
},
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/userSchema", "$ref": "#/components/schemas/publicSignupTokenSchema",
}, },
}, },
}, },
"description": "userSchema", "description": "publicSignupTokenSchema",
},
"409": {
"description": "The provided resource can not be created or updated because it would conflict with the current state of the resource or with an already existing resource, respectively.",
},
},
"tags": [
"Public signup tokens",
],
},
},
"/api/admin/invite-link/tokens/{token}/validate": {
"post": {
"operationId": "validatePublicSignupToken",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"description": "This response has no body.",
},
"401": {
"description": "This response has no body.",
}, },
}, },
"tags": [ "tags": [
@ -7491,6 +7434,79 @@ If the provided project does not exist, the list of events will be empty.",
], ],
}, },
}, },
"/invite/{token}/signup": {
"post": {
"operationId": "addPublicSignupTokenUser",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/createInvitedUserSchema",
},
},
},
"description": "createInvitedUserSchema",
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/userSchema",
},
},
},
"description": "userSchema",
},
"400": {
"description": "The request data does not match what we expect.",
},
"409": {
"description": "The provided resource can not be created or updated because it would conflict with the current state of the resource or with an already existing resource, respectively.",
},
},
"tags": [
"Public signup tokens",
],
},
},
"/invite/{token}/validate": {
"get": {
"operationId": "validatePublicSignupToken",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"description": "This response has no body.",
},
"400": {
"description": "The request data does not match what we expect.",
},
},
"tags": [
"Public signup tokens",
],
},
},
}, },
"security": [ "security": [
{ {

View File

@ -17,7 +17,9 @@ export default class FakePublicSignupStore implements IPublicSignupTokenStore {
async isValid(secret: string): Promise<boolean> { async isValid(secret: string): Promise<boolean> {
const token = this.tokens.find((t) => t.secret === secret); const token = this.tokens.find((t) => t.secret === secret);
return Promise.resolve(token && new Date(token.expiresAt) > new Date()); return Promise.resolve(
token && new Date(token.expiresAt) > new Date() && token.enabled,
);
} }
async count(): Promise<number> { async count(): Promise<number> {
@ -54,18 +56,26 @@ export default class FakePublicSignupStore implements IPublicSignupTokenStore {
type: '', type: '',
id: 1, id: 1,
}, },
enabled: true,
createdBy: newToken.createdBy, createdBy: newToken.createdBy,
}; };
this.tokens.push(token); this.tokens.push(token);
return Promise.resolve(token); return Promise.resolve(token);
} }
async setExpiry( async update(
secret: string, secret: string,
expiresAt: Date, { expiresAt, enabled }: { expiresAt?: Date; enabled?: boolean },
): Promise<PublicSignupTokenSchema> { ): Promise<PublicSignupTokenSchema> {
const token = await this.get(secret); const token = await this.get(secret);
token.expiresAt = expiresAt.toISOString(); if (expiresAt) {
token.expiresAt = expiresAt.toISOString();
}
if (enabled !== undefined) {
token.enabled = enabled;
}
const index = this.tokens.findIndex((t) => t.secret === secret);
this.tokens[index] = token;
return Promise.resolve(token); return Promise.resolve(token);
} }

View File

@ -23,7 +23,11 @@ export default class FakeRoleStore implements IRoleStore {
} }
async create(role: ICustomRoleInsert): Promise<ICustomRole> { async create(role: ICustomRoleInsert): Promise<ICustomRole> {
const roleCreated = { ...role, id: 1, type: 'some-type' }; const roleCreated = {
...role,
type: 'some-type',
id: this.roles.length,
};
this.roles.push(roleCreated); this.roles.push(roleCreated);
return Promise.resolve(roleCreated); return Promise.resolve(roleCreated);
} }

View File

@ -12,7 +12,9 @@ Please refer to [_how to create API tokens_](../user_guide/api-token) on how to
Please note that it may take up to 60 seconds for the new key to propagate to all Unleash instances due to eager caching. Please note that it may take up to 60 seconds for the new key to propagate to all Unleash instances due to eager caching.
:::note :::note
If you need an API token to use in a client SDK you should create a "client token" as these have fewer access rights. If you need an API token to use in a client SDK you should create a "client token" as these have fewer access rights.
::: :::
## Step 2: Use Admin API {#step-2-use-admin-api} ## Step 2: Use Admin API {#step-2-use-admin-api}
@ -29,7 +31,7 @@ curl -X POST -H "Content-Type: application/json" \
**Great success!** We have now enabled the feature toggle. We can also verify that it was actually changed by the API user by navigating to the Event log (history) for this feature toggle. **Great success!** We have now enabled the feature toggle. We can also verify that it was actually changed by the API user by navigating to the Event log (history) for this feature toggle.
![A feature toggle's event log showing that it was last updated by \"admin-api\".](/img/api_access_history.png) ![A feature toggle's event log showing that it was last updated by "admin-api".](/img/api_access_history.png)
## API overview {#api-overview} ## API overview {#api-overview}

View File

@ -1,10 +1,13 @@
--- ---
title: How to create and assign custom project roles title: How to create and assign custom project roles
--- ---
import VideoContent from '@site/src/components/VideoContent.jsx' import VideoContent from '@site/src/components/VideoContent.jsx'
:::info availability :::info availability
Custom project roles were introduced in **Unleash 4.6** and are only available in Unleash Enterprise. Custom project roles were introduced in **Unleash 4.6** and are only available in Unleash Enterprise.
::: :::