diff --git a/.github/workflows/build_frontend_prs.yml b/.github/workflows/build_frontend_prs.yml index caf5ca3610..f0948a8154 100644 --- a/.github/workflows/build_frontend_prs.yml +++ b/.github/workflows/build_frontend_prs.yml @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 2c6e391665..b3ef97cdd3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.styles.ts b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.styles.ts deleted file mode 100644 index 5245049494..0000000000 --- a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.styles.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { makeStyles } from 'tss-react/mui'; - -export const useStyles = makeStyles()({ - iconContainer: { - width: '100%', - textAlign: 'center', - }, - emailIcon: { - margin: '2rem auto', - }, -}); diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.tsx b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.tsx index 0bae5c0514..326993a6e3 100644 --- a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.tsx +++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.tsx @@ -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 ( - - - A new team member has been added. We’ve sent an email on your - behalf to inform them of their new account and role. No further - steps are required. - -
- -
- - In a rush? - - - You may also copy the invite link and send it to the user. - - -
- ); -}; +}: IConfirmUserEmailProps) => ( + + + A new team member has been added. We’ve sent an email on your behalf + to inform them of their new account and role. No further steps are + required. + + + + + + In a rush? + + + You may also copy the invite link and send it to the user. + + + +); export default ConfirmUserEmail; diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/ConfirmUserLink.tsx b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/ConfirmUserLink.tsx index 7471d056a9..614f3d6351 100644 --- a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/ConfirmUserLink.tsx +++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/ConfirmUserLink.tsx @@ -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: - + Copy the link and send it to the user. This will allow them diff --git a/frontend/src/component/admin/users/InviteLink/InviteLink.tsx b/frontend/src/component/admin/users/InviteLink/InviteLink.tsx new file mode 100644 index 0000000000..d7937a93b0 --- /dev/null +++ b/frontend/src/component/admin/users/InviteLink/InviteLink.tsx @@ -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 = () => { + 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 = 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 ( + + + + + + Expiration period for the invite link + + + + + People using this link will be invited as: + + + `${theme.shape.borderRadiusMedium}px`, + backgroundColor: theme => + theme.palette.secondaryContainer, + }} + > + + Viewer + + + 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. + + + + + + {isUpdating + ? 'Update invite link' + : 'Create invite link'} + + + Delete link + + } + /> + + + + + + + New team members now sign-up to Unleash. Please provide + them with the following link to get started: + + + + + 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. + + + + setDisableDialogue(false)} + onClick={onDisableConfirmed} + title="Are you sure you want to delete your invite link?" + /> + + ); +}; diff --git a/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBar.tsx b/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBar.tsx new file mode 100644 index 0000000000..25ff0122f6 --- /dev/null +++ b/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBar.tsx @@ -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 = ( + + {expiresIn} + + ); + + return ( + `${theme.shape.borderRadiusLarge}px`, + display: 'flex', + flexDirection: { xs: 'column', md: 'row' }, + border: '2px solid', + borderColor: 'primary.main', + }} + ref={ref} + > + + + + {`You have an invite link created on ${formatDateYMD( + createdAt, + locationSettings.locale + )} `} + + that expired {expireDateComponent}{' '} + ago + + } + elseShow={ + <> + that will expire in{' '} + {expireDateComponent} + + } + /> + + + + } + elseShow={ + + You can easily create an invite link here that you + can share and use to invite people from your company + to your Unleash setup. + + } + /> + + + + + + ); +}; diff --git a/frontend/src/component/admin/users/LinkField/ConfirmUserEmail/ConfirmUserEmail.tsx b/frontend/src/component/admin/users/LinkField/ConfirmUserEmail/ConfirmUserEmail.tsx new file mode 100644 index 0000000000..2bcfc3d46d --- /dev/null +++ b/frontend/src/component/admin/users/LinkField/ConfirmUserEmail/ConfirmUserEmail.tsx @@ -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) => ( + + + A new team member has been added. We’ve sent an email on your behalf + to inform them of their new account and role. No further steps are + required. + + + + + + In a rush? + + + You may also copy the invite link and send it to the user. + + + +); + +export default ConfirmUserEmail; diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/UserInviteLink/UserInviteLink.tsx b/frontend/src/component/admin/users/LinkField/LinkField.tsx similarity index 52% rename from frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/UserInviteLink/UserInviteLink.tsx rename to frontend/src/component/admin/users/LinkField/LinkField.tsx index 391a436da1..f0ab1c0dc4 100644 --- a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/UserInviteLink/UserInviteLink.tsx +++ b/frontend/src/component/admin/users/LinkField/LinkField.tsx @@ -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 ( -
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} - - + + -
+ ); }; - -export default UserInviteLink; diff --git a/frontend/src/component/admin/users/UsersAdmin.tsx b/frontend/src/component/admin/users/UsersAdmin.tsx index e34f238537..596695eedb 100644 --- a/frontend/src/component/admin/users/UsersAdmin.tsx +++ b/frontend/src/component/admin/users/UsersAdmin.tsx @@ -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 (
+ } + /> } diff --git a/frontend/src/component/admin/users/UsersList/UsersList.tsx b/frontend/src/component/admin/users/UsersList/UsersList.tsx index f38287e716..1e488ab5ff 100644 --- a/frontend/src/component/admin/users/UsersList/UsersList.tsx +++ b/frontend/src/component/admin/users/UsersList/UsersList.tsx @@ -246,7 +246,7 @@ const UsersList = () => { color="primary" onClick={() => navigate('/admin/create-user')} > - New user + Add new user } diff --git a/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx index 90dca9cc59..55457ad96a 100644 --- a/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx +++ b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx @@ -42,19 +42,6 @@ const GeneralSelect: React.FC = ({ fullWidth, ...rest }) => { - const renderSelectItems = () => - options.map(option => ( - - {option.label} - - )); - const onSelectChange = (event: SelectChangeEvent) => { event.preventDefault(); onChange(String(event.target.value)); @@ -79,7 +66,17 @@ const GeneralSelect: React.FC = ({ IconComponent={KeyboardArrowDownOutlined} {...rest} > - {renderSelectItems()} + {options.map(option => ( + + {option.label} + + ))} ); diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index b152a94c95..5d1d1de41a 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -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", diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 3aed1e0935..237c88f0f4 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -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', diff --git a/frontend/src/component/user/Login/Login.tsx b/frontend/src/component/user/Login/Login.tsx index 06db1c02c7..d5745f4189 100644 --- a/frontend/src/component/user/Login/Login.tsx +++ b/frontend/src/component/user/Login/Login.tsx @@ -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 (
+ + Success + You successfully reset your password. + + } + /> + + Success + Your account has been created. + + } + /> { } /> - - } - />
diff --git a/frontend/src/component/user/NewUser/NewUser.tsx b/frontend/src/component/user/NewUser/NewUser.tsx index 6106d37a7b..0ccb0516d5 100644 --- a/frontend/src/component/user/NewUser/NewUser.tsx +++ b/frontend/src/component/user/NewUser/NewUser.tsx @@ -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 ( @@ -31,7 +86,12 @@ export const NewUser = () => { return ( { } > - {data?.createdBy} + {passwordResetData?.createdBy}
has invited you to join Unleash. } @@ -82,7 +142,7 @@ export const NewUser = () => { show={ <> ( { { + if (isValidToken) { + return; + } + setEmail(e.target.value); + }} /> ( { sx={{ my: 1 }} fullWidth required + onChange={e => { + setName(e.target.value); + }} /> )} /> Set a password for your account. - } /> + } /> diff --git a/frontend/src/component/user/ResetPassword/ResetPassword.tsx b/frontend/src/component/user/ResetPassword/ResetPassword.tsx index 1abf33fca7..8b57d85876 100644 --- a/frontend/src/component/user/ResetPassword/ResetPassword.tsx +++ b/frontend/src/component/user/ResetPassword/ResetPassword.tsx @@ -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 (
} elseShow={ <> @@ -29,10 +51,11 @@ const ResetPassword = () => { Reset password - } /> + } /> diff --git a/frontend/src/component/user/common/InvalidToken/InvalidToken.tsx b/frontend/src/component/user/common/InvalidToken/InvalidToken.tsx index b14ca0e19a..295d3e3824 100644 --- a/frontend/src/component/user/common/InvalidToken/InvalidToken.tsx +++ b/frontend/src/component/user/common/InvalidToken/InvalidToken.tsx @@ -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 (
{ } elseShow={ - <> - - 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. - - - + + Provided invite link is invalid or expired. + Please request a new URL in order to create your + account. + + } + elseShow={ + <> + + 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. + + + + } + /> } />
diff --git a/frontend/src/component/user/common/ResetPasswordForm/ResetPasswordForm.tsx b/frontend/src/component/user/common/ResetPasswordForm/ResetPasswordForm.tsx index 0a6b33c2e2..7759d3addb 100644 --- a/frontend/src/component/user/common/ResetPasswordForm/ResetPasswordForm.tsx +++ b/frontend/src/component/user/common/ResetPasswordForm/ResetPasswordForm.tsx @@ -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>; + 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 ( - <> - } +
+ ) => + setPassword(e.target.value) + } + onFocus={() => setShowPasswordChecker(true)} + autoComplete="new-password" + data-loading + /> + ) => + setConfirmPassword(e.target.value) + } + autoComplete="new-password" + data-loading + /> + + } /> - - ) => - setPassword(e.target.value) - } - onFocus={() => setShowPasswordChecker(true)} - autoComplete="new-password" - data-loading - /> - ) => - setConfirmPassword(e.target.value) - } - autoComplete="new-password" - data-loading - /> - - } - /> - - - - + + + ); }; diff --git a/frontend/src/component/user/common/ResetPasswordSuccess/ResetPasswordSuccess.tsx b/frontend/src/component/user/common/ResetPasswordSuccess/ResetPasswordSuccess.tsx deleted file mode 100644 index 8cdb25c580..0000000000 --- a/frontend/src/component/user/common/ResetPasswordSuccess/ResetPasswordSuccess.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Alert, AlertTitle } from '@mui/material'; - -const ResetPasswordSuccess = () => { - return ( - - Success - You successfully reset your password. - - ); -}; - -export default ResetPasswordSuccess; diff --git a/frontend/src/constants/statusCodes.ts b/frontend/src/constants/statusCodes.ts index a36b13e062..ebe1b608a4 100644 --- a/frontend/src/constants/statusCodes.ts +++ b/frontend/src/constants/statusCodes.ts @@ -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; diff --git a/frontend/src/hooks/api/actions/useAuthResetPasswordApi/useAuthResetPasswordApi.ts b/frontend/src/hooks/api/actions/useAuthResetPasswordApi/useAuthResetPasswordApi.ts new file mode 100644 index 0000000000..fe1b9dbc78 --- /dev/null +++ b/frontend/src/hooks/api/actions/useAuthResetPasswordApi/useAuthResetPasswordApi.ts @@ -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, + }; +}; diff --git a/frontend/src/hooks/api/actions/useInviteTokenApi/useInviteTokenApi.ts b/frontend/src/hooks/api/actions/useInviteTokenApi/useInviteTokenApi.ts new file mode 100644 index 0000000000..229dc2d0cd --- /dev/null +++ b/frontend/src/hooks/api/actions/useInviteTokenApi/useInviteTokenApi.ts @@ -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, + }; +}; diff --git a/frontend/src/hooks/api/getters/useInviteTokens/useInviteTokens.ts b/frontend/src/hooks/api/getters/useInviteTokens/useInviteTokens.ts new file mode 100644 index 0000000000..2866dac412 --- /dev/null +++ b/frontend/src/hooks/api/getters/useInviteTokens/useInviteTokens.ts @@ -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(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, + }; +}; diff --git a/frontend/src/hooks/api/getters/useInviteUserToken/useInviteUserToken.ts b/frontend/src/hooks/api/getters/useInviteUserToken/useInviteUserToken.ts deleted file mode 100644 index 20fcfb8434..0000000000 --- a/frontend/src/hooks/api/getters/useInviteUserToken/useInviteUserToken.ts +++ /dev/null @@ -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 }; -}; diff --git a/frontend/src/hooks/api/getters/useResetPassword/useResetPassword.ts b/frontend/src/hooks/api/getters/useResetPassword/useResetPassword.ts index c8bbcb46d3..72217cc830 100644 --- a/frontend/src/hooks/api/getters/useResetPassword/useResetPassword.ts +++ b/frontend/src/hooks/api/getters/useResetPassword/useResetPassword.ts @@ -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; diff --git a/frontend/src/hooks/api/getters/useUserInvite/useUserInvite.ts b/frontend/src/hooks/api/getters/useUserInvite/useUserInvite.ts new file mode 100644 index 0000000000..385c559961 --- /dev/null +++ b/frontend/src/hooks/api/getters/useUserInvite/useUserInvite.ts @@ -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( + url, + getFetcher(secret, url), + options + ); + const [loading, setLoading] = useState(!error && !data); + + useEffect(() => { + setLoading(!error && data === undefined); + }, [data, error]); + + return { + secret, + isValid: data, + error, + loading, + }; +}; diff --git a/frontend/src/interfaces/publicSignupTokens.ts b/frontend/src/interfaces/publicSignupTokens.ts new file mode 100644 index 0000000000..391ce1d2ef --- /dev/null +++ b/frontend/src/interfaces/publicSignupTokens.ts @@ -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; +} diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index c1dbef472a..8cf52c0a64 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -40,6 +40,7 @@ export interface IFlags { UG?: boolean; ENABLE_DARK_MODE_SUPPORT?: boolean; embedProxyFrontend?: boolean; + publicSignup?: boolean; } export interface IVersionInfo { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 95abedad6e..ba90142891 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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()], diff --git a/src/lib/db/public-signup-token-store.ts b/src/lib/db/public-signup-token-store.ts index b4fb152827..7e773d63ed 100644 --- a/src/lib/db/public-signup-token-store.ts +++ b/src/lib/db/public-signup-token-store.ts @@ -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 { 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(TABLE).del(); } - async setExpiry( + async update( secret: string, - expiresAt: Date, + { expiresAt, enabled }: { expiresAt?: Date; enabled?: boolean }, ): Promise { const rows = await this.makeTokenUsersQuery() - .update({ expires_at: expiresAt }) + .update({ expires_at: expiresAt, enabled }) .where('secret', secret) .returning('*'); if (rows.length > 0) { diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 4966494f24..afe319e858 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -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". diff --git a/src/lib/openapi/spec/create-invited-user-schema.ts b/src/lib/openapi/spec/create-invited-user-schema.ts new file mode 100644 index 0000000000..dd64ebcac3 --- /dev/null +++ b/src/lib/openapi/spec/create-invited-user-schema.ts @@ -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 +>; diff --git a/src/lib/openapi/spec/public-signup-schema.test.ts b/src/lib/openapi/spec/public-signup-schema.test.ts index 0cee5825c4..56bf0b10a2 100644 --- a/src/lib/openapi/spec/public-signup-schema.test.ts +++ b/src/lib/openapi/spec/public-signup-schema.test.ts @@ -11,6 +11,7 @@ test('publicSignupTokenSchema', () => { role: { name: 'Viewer ', type: 'type', id: 1 }, createdAt: new Date().toISOString(), createdBy: 'someone', + enabled: true, }; expect( diff --git a/src/lib/openapi/spec/public-signup-token-schema.ts b/src/lib/openapi/spec/public-signup-token-schema.ts index e819658ce6..afc6294dc5 100644 --- a/src/lib/openapi/spec/public-signup-token-schema.ts +++ b/src/lib/openapi/spec/public-signup-token-schema.ts @@ -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', diff --git a/src/lib/openapi/spec/public-signup-token-update-schema.ts b/src/lib/openapi/spec/public-signup-token-update-schema.ts index 65b59f0f58..abadbe7e1c 100644 --- a/src/lib/openapi/spec/public-signup-token-update-schema.ts +++ b/src/lib/openapi/spec/public-signup-token-update-schema.ts @@ -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; diff --git a/src/lib/routes/admin-api/public-signup.test.ts b/src/lib/routes/admin-api/public-signup.test.ts index 4d88bb0d85..945f36711e 100644 --- a/src/lib/routes/admin-api/public-signup.test.ts +++ b/src/lib/routes/admin-api/public-signup.test.ts @@ -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); }); }); diff --git a/src/lib/routes/admin-api/public-signup.ts b/src/lib/routes/admin-api/public-signup.ts index e230cc9fdc..de87028676 100644 --- a/src/lib/routes/admin-api/public-signup.ts +++ b/src/lib/routes/admin-api/public-signup.ts @@ -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, - res: Response, - ): Promise { - 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, - res: Response, - ): Promise { - 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, res: Response, @@ -274,28 +188,27 @@ export class PublicSignupController extends Controller { res: Response, ): Promise { 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, - res: Response, - ): Promise { - 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), + ); } } diff --git a/src/lib/routes/index.ts b/src/lib/routes/index.ts index e0ba06f194..a0aa8024a9 100644 --- a/src/lib/routes/index.ts +++ b/src/lib/routes/index.ts @@ -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( diff --git a/src/lib/routes/public-invite.test.ts b/src/lib/routes/public-invite.test.ts new file mode 100644 index 0000000000..1e4412394f --- /dev/null +++ b/src/lib/routes/public-invite.test.ts @@ -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); + }); +}); diff --git a/src/lib/routes/public-invite.ts b/src/lib/routes/public-invite.ts new file mode 100644 index 0000000000..d9b0ad6593 --- /dev/null +++ b/src/lib/routes/public-invite.ts @@ -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, + res: Response, + ): Promise { + 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, + res: Response, + ): Promise { + 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), + ); + } +} diff --git a/src/lib/services/public-signup-token-service.ts b/src/lib/services/public-signup-token-service.ts index c1fab5d1a5..9d24e951a2 100644 --- a/src/lib/services/public-signup-token-service.ts +++ b/src/lib/services/public-signup-token-service.ts @@ -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 { - 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 { 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 { - await this.expireToken(secret); - await this.eventStore.store( - new PublicSignupTokenManuallyExpiredEvent({ - createdBy: expiredBy, - data: { secret }, - }), - ); - } - - private async expireToken( - secret: string, - ): Promise { - return this.store.setExpiry(secret, new Date()); - } - public async createNewPublicSignupToken( tokenCreate: PublicSignupTokenCreateSchema, createdBy: string, diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index d9a54f4870..79ea3f0259 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -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; } } diff --git a/src/lib/types/stores/public-signup-token-store.ts b/src/lib/types/stores/public-signup-token-store.ts index d72a4007e9..06d53b1023 100644 --- a/src/lib/types/stores/public-signup-token-store.ts +++ b/src/lib/types/stores/public-signup-token-store.ts @@ -10,9 +10,9 @@ export interface IPublicSignupTokenStore ): Promise; addTokenUser(secret: string, userId: number): Promise; isValid(secret): Promise; - setExpiry( + update( secret: string, - expiresAt: Date, + value: { expiresAt?: Date; enabled?: boolean }, ): Promise; delete(secret: string): Promise; count(): Promise; diff --git a/src/migrations/20220927110212-add-enabled-to-public-signup-tokens.js b/src/migrations/20220927110212-add-enabled-to-public-signup-tokens.js new file mode 100644 index 0000000000..51945cd78c --- /dev/null +++ b/src/migrations/20220927110212-add-enabled-to-public-signup-tokens.js @@ -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, + ); +}; diff --git a/src/test/e2e/api/admin/public-signup-token.e2e.test.ts b/src/test/e2e/api/admin/public-signup-token.e2e.test.ts index edddf713a2..e14643e08e 100644 --- a/src/test/e2e/api/admin/public-signup-token.e2e.test.ts +++ b/src/test/e2e/api/admin/public-signup-token.e2e.test.ts @@ -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(); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 1d5b5c6aa4..84343706b5 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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": [ { diff --git a/src/test/fixtures/fake-public-signup-store.ts b/src/test/fixtures/fake-public-signup-store.ts index 84645d19eb..58ca6746a9 100644 --- a/src/test/fixtures/fake-public-signup-store.ts +++ b/src/test/fixtures/fake-public-signup-store.ts @@ -17,7 +17,9 @@ export default class FakePublicSignupStore implements IPublicSignupTokenStore { async isValid(secret: string): Promise { 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 { @@ -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 { 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); } diff --git a/src/test/fixtures/fake-role-store.ts b/src/test/fixtures/fake-role-store.ts index fc5fbe245e..4165644a62 100644 --- a/src/test/fixtures/fake-role-store.ts +++ b/src/test/fixtures/fake-role-store.ts @@ -23,7 +23,11 @@ export default class FakeRoleStore implements IRoleStore { } async create(role: ICustomRoleInsert): Promise { - 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); } diff --git a/website/docs/advanced/api_access.md b/website/docs/advanced/api_access.md index 39e9a4dd66..59a1062562 100644 --- a/website/docs/advanced/api_access.md +++ b/website/docs/advanced/api_access.md @@ -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} diff --git a/website/docs/how-to/how-to-create-and-assign-custom-project-roles.md b/website/docs/how-to/how-to-create-and-assign-custom-project-roles.md index 4d520fbd0c..405cc8e44f 100644 --- a/website/docs/how-to/how-to-create-and-assign-custom-project-roles.md +++ b/website/docs/how-to/how-to-create-and-assign-custom-project-roles.md @@ -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. + :::