}
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 (
- <>
- }
+
);
};
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.
-
+
## 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.
+
:::