1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

Feat/invite user (#2061)

* refactor: user creation screen cleanup

* feat: deprecation notice for google sso

* fix: docs openid typo

* invite link bar

* invite link page

* fix prettier docs

* regenerated openapi

* hooks for invite page api

* update openapi

* feat: invite link update

* feat: add public signup token soft-delete

* public signup frontend feature flag

* fix: new user api issues

* feat: allow for creating new user from invite link

* Feat/invite user public controller (#2106)

* added PublicInviteController for public urls

* added PublicInviteController for public urls

* added PublicInviteController for public urls

* added PublicInviteController for public urls

* fix test

* fix test

* update openapi

* refactor: password reset props

* fix: public invite schema and validation

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

* user invite frontend

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

* invite link delete confirmation dialog

* refactor: password reset action

* fix: new user invite loading state

* fix: run ts check with ci

* revert openapi changes

* fix: invite token api interface

* fix: openapi schema index

* fix: update test snapshots

* update frontend snapshot

* fix: prettier ci

* fix: updates after review

Co-authored-by: andreas-unleash <104830839+andreas-unleash@users.noreply.github.com>
Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
This commit is contained in:
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 run test
- run: yarn run fmt:check
- run: yarn run ts:check # TODO: optimize

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

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 OK = 200;
export const CREATED = 201;
export const NOT_FOUND = 404;
export const FORBIDDEN = 403;
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';
const getFetcher = (token: string) => () => {
if (!token) return Promise.resolve({ name: INVALID_TOKEN_ERROR });
const path = formatApiPath(`auth/reset/validate?token=${token}`);
// Don't use handleErrorResponses here, because we need to read the error.
return fetch(path, {
@ -34,11 +35,21 @@ const useResetPassword = (options: SWRConfiguration = {}) => {
setLoading(!error && !data);
}, [data, error]);
const invalidToken =
const isValidToken =
(!loading && data?.name === INVALID_TOKEN_ERROR) ||
data?.name === USED_TOKEN_ERROR;
data?.name === USED_TOKEN_ERROR
? false
: true;
return { token, data, error, loading, setLoading, invalidToken, retry };
return {
token,
data,
error,
loading,
isValidToken,
setLoading,
retry,
};
};
export default useResetPassword;

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;
ENABLE_DARK_MODE_SUPPORT?: boolean;
embedProxyFrontend?: boolean;
publicSignup?: boolean;
}
export interface IVersionInfo {

View File

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

View File

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

View File

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

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 },
createdAt: new Date().toISOString(),
createdBy: 'someone',
enabled: true,
};
expect(

View File

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

View File

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

View File

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

View File

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

View File

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

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

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_USER_ADDED = 'public-signup-token-user-added';
export const PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED =
'public-signup-token-manually-expired';
export const PUBLIC_SIGNUP_TOKEN_TOKEN_UPDATED = 'public-signup-token-updated';
export interface IBaseEvent {
type: string;
@ -548,11 +547,11 @@ export class PublicSignupTokenCreatedEvent extends BaseEvent {
}
}
export class PublicSignupTokenManuallyExpiredEvent extends BaseEvent {
export class PublicSignupTokenUpdatedEvent extends BaseEvent {
readonly data: any;
constructor(eventData: { createdBy: string; data: any }) {
super(PUBLIC_SIGNUP_TOKEN_MANUALLY_EXPIRED, eventData.createdBy);
super(PUBLIC_SIGNUP_TOKEN_TOKEN_UPDATED, eventData.createdBy);
this.data = eventData.data;
}
}

View File

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

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

View File

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

View File

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

View File

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

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.
:::note
If you need an API token to use in a client SDK you should create a "client token" as these have fewer access rights.
:::
## Step 2: Use Admin API {#step-2-use-admin-api}
@ -29,7 +31,7 @@ curl -X POST -H "Content-Type: application/json" \
**Great success!** We have now enabled the feature toggle. We can also verify that it was actually changed by the API user by navigating to the Event log (history) for this feature toggle.
![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}

View File

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