Compare commits

...

7 Commits

Author SHA1 Message Date
a.bouhuolia
883c5dcb41 Merge branch 'signup-restrictions' into develop 2023-05-08 00:36:50 +02:00
a.bouhuolia
be10b8934d fix(webapp): change the error code handler 2023-05-08 00:35:44 +02:00
a.bouhuolia
ce38c71fa7 fix(server): should allowed email addresses and domain be irrespective. 2023-05-08 00:35:28 +02:00
Ahmed Bouhuolia
1162fbc7c3 Merge pull request #117 from bigcapitalhq/signup-restrictions
Sign-up restrictions for self-hosted
2023-05-08 00:18:56 +02:00
a.bouhuolia
18b9e25f2b chore: update .env.example 2023-05-07 23:59:41 +02:00
a.bouhuolia
dd26bdc482 feat(webapp): sign-up restrictions 2023-05-07 23:54:42 +02:00
a.bouhuolia
ad3c9ebfe9 feat(server): sign-up restrictions for self-hosted 2023-05-07 17:22:18 +02:00
20 changed files with 296 additions and 39 deletions

View File

@@ -32,3 +32,8 @@ CONTACT_US_MAIL=support@bigcapital.ly
# Agendash
AGENDASH_AUTH_USER=agendash
AGENDASH_AUTH_PASSWORD=123123
# Sign-up restrictions
SIGNUP_DISABLED=true
SIGNUP_ALLOWED_DOMAINS=
SIGNUP_ALLOWED_EMAILS=

View File

@@ -72,6 +72,11 @@ services:
- AGENDASH_AUTH_USER=${AGENDASH_AUTH_USER}
- AGENDASH_AUTH_PASSWORD=${AGENDASH_AUTH_PASSWORD}
# Sign-up restrictions
- SIGNUP_DISABLED=${SIGNUP_DISABLED}
- SIGNUP_ALLOWED_DOMAINS=${SIGNUP_ALLOWED_DOMAINS}
- SIGNUP_ALLOWED_EMAILS=${SIGNUP_ALLOWED_EMAILS}
database_migration:
container_name: bigcapital-database-migration
build:

View File

@@ -34,7 +34,11 @@ ARG MAIL_HOST= \
BASE_URL= \
# Agendash
AGENDASH_AUTH_USER=agendash \
AGENDASH_AUTH_PASSWORD=123123
AGENDASH_AUTH_PASSWORD=123123 \
# Sign-up restriction
SIGNUP_DISABLED= \
SIGNUP_ALLOWED_DOMAINS= \
SIGNUP_ALLOWED_EMAILS=
ENV MAIL_HOST=$MAIL_HOST \
MAIL_USERNAME=$MAIL_USERNAME \
@@ -68,7 +72,11 @@ ENV MAIL_HOST=$MAIL_HOST \
# MongoDB
MONGODB_DATABASE_URL=$MONGODB_DATABASE_URL \
# Application
BASE_URL=$BASE_URL
BASE_URL=$BASE_URL \
# Sign-up restriction
SIGNUP_DISABLED=$SIGNUP_DISABLED \
SIGNUP_ALLOWED_DOMAINS=$SIGNUP_ALLOWED_DOMAINS \
SIGNUP_ALLOWED_EMAILS=$SIGNUP_ALLOWED_EMAILS
# Create app directory.
WORKDIR /app

View File

@@ -49,6 +49,7 @@ export default class AuthenticationController extends BaseController {
asyncMiddleware(this.resetPassword.bind(this)),
this.handlerErrors
);
router.get('/meta', asyncMiddleware(this.getAuthMeta.bind(this)));
return router;
}
@@ -207,6 +208,23 @@ export default class AuthenticationController extends BaseController {
}
}
/**
* Retrieves the authentication meta for SPA.
* @param {Request} req
* @param {Response} res
* @param {Function} next
* @returns {Response|void}
*/
private async getAuthMeta(req: Request, res: Response, next: Function) {
try {
const meta = await this.authApplication.getAuthMeta();
return res.status(200).send({ meta });
} catch (error) {
next(error);
}
}
/**
* Handles the service errors.
*/
@@ -247,6 +265,30 @@ export default class AuthenticationController extends BaseController {
errors: [{ type: 'EMAIL.EXISTS', code: 600 }],
});
}
if (error.errorType === 'SIGNUP_RESTRICTED') {
return res.status(400).send({
errors: [
{
type: 'SIGNUP_RESTRICTED',
message:
'Sign-up is restricted no one can sign-up to the system.',
code: 700,
},
],
});
}
if (error.errorType === 'SIGNUP_RESTRICTED_NOT_ALLOWED') {
return res.status(400).send({
errors: [
{
type: 'SIGNUP_RESTRICTED_NOT_ALLOWED',
message:
'Sign-up is restricted the given email address is not allowed to sign-up.',
code: 710,
},
],
});
}
}
next(error);
}

View File

@@ -1,5 +1,6 @@
import dotenv from 'dotenv';
import path from 'path';
import { castCommaListEnvVarToArray, parseBoolean } from '@/utils';
dotenv.config();
@@ -146,6 +147,19 @@ module.exports = {
},
},
/**
* Sign-up restrictions
*/
signupRestrictions: {
disabled: parseBoolean<boolean>(process.env.SIGNUP_DISABLED, false),
allowedDomains: castCommaListEnvVarToArray(
process.env.SIGNUP_ALLOWED_DOMAINS
),
allowedEmails: castCommaListEnvVarToArray(
process.env.SIGNUP_ALLOWED_EMAILS
),
},
/**
* Puppeteer remote browserless connection.
*/

View File

@@ -74,4 +74,8 @@ export interface IAuthSendingResetPassword {
export interface IAuthSendedResetPassword {
user: ISystemUser,
token: string;
}
export interface IAuthGetMetaPOJO {
signupDisabled: boolean;
}

View File

@@ -1,8 +1,14 @@
import { Service, Inject, Container } from 'typedi';
import { IRegisterDTO, ISystemUser, IPasswordReset } from '@/interfaces';
import {
IRegisterDTO,
ISystemUser,
IPasswordReset,
IAuthGetMetaPOJO,
} from '@/interfaces';
import { AuthSigninService } from './AuthSignin';
import { AuthSignupService } from './AuthSignup';
import { AuthSendResetPassword } from './AuthSendResetPassword';
import { GetAuthMeta } from './GetAuthMeta';
@Service()
export default class AuthenticationApplication {
@@ -15,6 +21,9 @@ export default class AuthenticationApplication {
@Inject()
private authResetPasswordService: AuthSendResetPassword;
@Inject()
private authGetMeta: GetAuthMeta;
/**
* Signin and generates JWT token.
* @throws {ServiceError}
@@ -53,4 +62,12 @@ export default class AuthenticationApplication {
public async resetPassword(token: string, password: string): Promise<void> {
return this.authResetPasswordService.resetPassword(token, password);
}
/**
* Retrieves the authentication meta for SPA.
* @returns {Promise<IAuthGetMetaPOJO>}
*/
public async getAuthMeta(): Promise<IAuthGetMetaPOJO> {
return this.authGetMeta.getAuthMeta();
}
}

View File

@@ -1,4 +1,4 @@
import { omit } from 'lodash';
import { isEmpty, omit } from 'lodash';
import moment from 'moment';
import { ServiceError } from '@/exceptions';
import {
@@ -13,6 +13,7 @@ import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import TenantsManagerService from '../Tenancy/TenantsManager';
import events from '@/subscribers/events';
import { hashPassword } from '@/utils';
import config from '@/config';
export class AuthSignupService {
@Inject()
@@ -33,6 +34,9 @@ export class AuthSignupService {
public async signUp(signupDTO: IRegisterDTO): Promise<ISystemUser> {
const { systemUserRepository } = this.sysRepositories;
// Validates the signup disable restrictions.
await this.validateSignupRestrictions(signupDTO.email);
// Validates the given email uniqiness.
await this.validateEmailUniqiness(signupDTO.email);
@@ -74,4 +78,34 @@ export class AuthSignupService {
throw new ServiceError(ERRORS.EMAIL_EXISTS);
}
}
/**
* Validate sign-up disable restrictions.
* @param {string} email
*/
private async validateSignupRestrictions(email: string) {
// Can't continue if the signup is not disabled.
if (!config.signupRestrictions.disabled) return;
// Validate the allowed email addresses and domains.
if (
!isEmpty(config.signupRestrictions.allowedEmails) ||
!isEmpty(config.signupRestrictions.allowedDomains)
) {
const emailDomain = email.split('@').pop();
const isAllowedEmail =
config.signupRestrictions.allowedEmails.indexOf(email) !== -1;
const isAllowedDomain = config.signupRestrictions.allowedDomains.some(
(domain) => emailDomain === domain
);
if (!isAllowedEmail && !isAllowedDomain) {
throw new ServiceError(ERRORS.SIGNUP_RESTRICTED_NOT_ALLOWED);
}
// Throw error if the signup is disabled with no exceptions.
} else {
throw new ServiceError(ERRORS.SIGNUP_RESTRICTED);
}
}
}

View File

@@ -0,0 +1,16 @@
import { Service } from 'typedi';
import { IAuthGetMetaPOJO } from '@/interfaces';
import config from '@/config';
@Service()
export class GetAuthMeta {
/**
* Retrieves the authentication meta for SPA.
* @returns {Promise<IAuthGetMetaPOJO>}
*/
public async getAuthMeta(): Promise<IAuthGetMetaPOJO> {
return {
signupDisabled: config.signupRestrictions.disabled,
};
}
}

View File

@@ -7,4 +7,6 @@ export const ERRORS = {
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_EXISTS',
EMAIL_EXISTS: 'EMAIL_EXISTS',
SIGNUP_RESTRICTED_NOT_ALLOWED: 'SIGNUP_RESTRICTED_NOT_ALLOWED',
SIGNUP_RESTRICTED: 'SIGNUP_RESTRICTED',
};

View File

@@ -467,6 +467,10 @@ const assocDepthLevelToObjectTree = (
return objects;
};
const castCommaListEnvVarToArray = (envVar: string): Array<string> => {
return envVar ? envVar?.split(',')?.map(_.trim) : [];
};
export {
templateRender,
accumSum,
@@ -499,4 +503,5 @@ export {
mergeObjectsBykey,
nestedArrayToFlatten,
assocDepthLevelToObjectTree,
castCommaListEnvVarToArray
};

View File

@@ -0,0 +1,36 @@
// @ts-nocheck
import React, { createContext } from 'react';
import { useAuthMetadata } from '@/hooks/query';
import { Spinner } from '@blueprintjs/core';
import styled from 'styled-components';
const AuthMetaBootContext = createContext();
/**
* Boots the authentication page metadata.
*/
function AuthMetaBootProvider({ ...props }) {
const { isLoading: isAuthMetaLoading, data: authMeta } = useAuthMetadata();
const state = {
isAuthMetaLoading,
signupDisabled: authMeta?.meta?.signup_disabled,
};
if (isAuthMetaLoading) {
return (
<SpinnerRoot>
<Spinner size={30} value={null} />
</SpinnerRoot>
);
}
return <AuthMetaBootContext.Provider value={state} {...props} />;
}
const useAuthMetaBoot = () => React.useContext(AuthMetaBootContext);
export { AuthMetaBootContext, AuthMetaBootProvider, useAuthMetaBoot };
const SpinnerRoot = styled.div`
margin-top: 5rem;
`;

View File

@@ -10,12 +10,11 @@ import { Icon, FormattedMessage as T } from '@/components';
import { useIsAuthenticated } from '@/hooks/state';
import '@/style/pages/Authentication/Auth.scss';
import { AuthMetaBootProvider } from './AuthMetaBoot';
export function Authentication() {
const to = { pathname: '/' };
const location = useLocation();
const isAuthenticated = useIsAuthenticated();
const locationKey = location.pathname;
if (isAuthenticated) {
return <Redirect to={to} />;
@@ -28,30 +27,41 @@ export function Authentication() {
<Icon icon="bigcapital" height={37} width={214} />
</AuthLogo>
<TransitionGroup>
<CSSTransition
timeout={500}
key={locationKey}
classNames="authTransition"
>
<Switch>
{authenticationRoutes.map((route, index) => (
<Route
key={index}
path={route.path}
exact={route.exact}
component={route.component}
/>
))}
</Switch>
</CSSTransition>
</TransitionGroup>
<AuthMetaBootProvider>
<AuthenticationRoutes />
</AuthMetaBootProvider>
</AuthInsider>
</AuthPage>
</BodyClassName>
);
}
function AuthenticationRoutes() {
const location = useLocation();
const locationKey = location.pathname;
return (
<TransitionGroup>
<CSSTransition
timeout={500}
key={locationKey}
classNames="authTransition"
>
<Switch>
{authenticationRoutes.map((route, index) => (
<Route
key={index}
path={route.path}
exact={route.exact}
component={route.component}
/>
))}
</Switch>
</CSSTransition>
</TransitionGroup>
);
}
const AuthPage = styled.div``;
const AuthInsider = styled.div`
width: 384px;

View File

@@ -14,11 +14,12 @@ import {
AuthFooterLink,
AuthInsiderCard,
} from './_components';
import { useAuthMetaBoot } from './AuthMetaBoot';
const initialValues = {
crediential: '',
password: '',
keepLoggedIn: false
keepLoggedIn: false,
};
/**
@@ -64,12 +65,15 @@ export default function Login() {
}
function LoginFooterLinks() {
const { signupDisabled } = useAuthMetaBoot();
return (
<AuthFooterLinks>
<AuthFooterLink>
Don't have an account? <Link to={'/auth/register'}>Sign up</Link>
</AuthFooterLink>
{!signupDisabled && (
<AuthFooterLink>
Don't have an account? <Link to={'/auth/register'}>Sign up</Link>
</AuthFooterLink>
)}
<AuthFooterLink>
<Link to={'/auth/send_reset_password'}>
<T id={'forget_my_password'} />

View File

@@ -10,7 +10,7 @@ import AuthInsider from '@/containers/Authentication/AuthInsider';
import { useAuthLogin, useAuthRegister } from '@/hooks/query/authentication';
import RegisterForm from './RegisterForm';
import { RegisterSchema, transformRegisterErrorsToForm } from './utils';
import { RegisterSchema, transformRegisterErrorsToForm, transformRegisterToastMessages } from './utils';
import {
AuthFooterLinks,
AuthFooterLink,
@@ -57,7 +57,11 @@ export default function RegisterUserForm() {
},
}) => {
const formErrors = transformRegisterErrorsToForm(errors);
const toastMessages = transformRegisterToastMessages(errors);
toastMessages.forEach((toastMessage) => {
AppToaster.show(toastMessage);
});
setErrors(formErrors);
setSubmitting(false);
},

View File

@@ -16,6 +16,7 @@ import {
} from './_components';
import ResetPasswordForm from './ResetPasswordForm';
import { ResetPasswordSchema } from './utils';
import { useAuthMetaBoot } from './AuthMetaBoot';
const initialValues = {
password: '',
@@ -79,12 +80,15 @@ export default function ResetPassword() {
}
function ResetPasswordFooterLinks() {
const { signupDisabled } = useAuthMetaBoot();
return (
<AuthFooterLinks>
<AuthFooterLink>
Don't have an account? <Link to={'/auth/register'}>Sign up</Link>
</AuthFooterLink>
{!signupDisabled && (
<AuthFooterLink>
Don't have an account? <Link to={'/auth/register'}>Sign up</Link>
</AuthFooterLink>
)}
<AuthFooterLink>
Return to <Link to={'/auth/login'}>Sign In</Link>
</AuthFooterLink>

View File

@@ -19,6 +19,7 @@ import {
transformSendResetPassErrorsToToasts,
} from './utils';
import AuthInsider from '@/containers/Authentication/AuthInsider';
import { useAuthMetaBoot } from './AuthMetaBoot';
const initialValues = {
crediential: '',
@@ -27,7 +28,7 @@ const initialValues = {
/**
* Send reset password page.
*/
export default function SendResetPassword({ requestSendResetPassword }) {
export default function SendResetPassword() {
const history = useHistory();
const { mutateAsync: sendResetPasswordMutate } = useAuthSendResetPassword();
@@ -75,12 +76,15 @@ export default function SendResetPassword({ requestSendResetPassword }) {
}
function SendResetPasswordFooterLinks() {
const { signupDisabled } = useAuthMetaBoot();
return (
<AuthFooterLinks>
<AuthFooterLink>
Don't have an account? <Link to={'/auth/register'}>Sign up</Link>
</AuthFooterLink>
{!signupDisabled && (
<AuthFooterLink>
Don't have an account? <Link to={'/auth/register'}>Sign up</Link>
</AuthFooterLink>
)}
<AuthFooterLink>
Return to <Link to={'/auth/login'}>Sign In</Link>
</AuthFooterLink>

View File

@@ -94,3 +94,21 @@ export const transformRegisterErrorsToForm = (errors) => {
}
return formErrors;
};
export const transformRegisterToastMessages = (errors) => {
const toastErrors = [];
if (errors.some((e) => e.type === 'SIGNUP_RESTRICTED_NOT_ALLOWED')) {
toastErrors.push({
message:
'The sign-up is restricted, the given email address is not allowed to sign-up.',
intent: Intent.DANGER,
});
} else if (errors.find((e) => e.type === 'SIGNUP_RESTRICTED')) {
toastErrors.push({
message: 'Sign-up is disabled, and no new accounts can be created.',
intent: Intent.DANGER,
});
}
return toastErrors;
};

View File

@@ -2,6 +2,8 @@
import { useMutation } from 'react-query';
import useApiRequest from '../useRequest';
import { setCookie } from '../../utils';
import { useRequestQuery } from '../useQueryRequest';
import t from './types';
/**
* Saves the response data to cookies.
@@ -70,3 +72,21 @@ export const useAuthResetPassword = (props) => {
props,
);
};
/**
* Fetches the authentication page metadata.
*/
export const useAuthMetadata = (props) => {
return useRequestQuery(
[t.AUTH_METADATA_PAGE,],
{
method: 'get',
url: `auth/meta`,
},
{
select: (res) => res.data,
defaultData: {},
...props,
},
);
}

View File

@@ -1,4 +1,8 @@
// @ts-nocheck
const Authentication = {
AUTH_METADATA_PAGE: 'AUTH_META_PAGE'
}
const ACCOUNTS = {
ACCOUNT: 'ACCOUNT',
ACCOUNT_TRANSACTION: 'ACCOUNT_TRANSACTION',
@@ -217,6 +221,7 @@ const DASHBOARD = {
};
export default {
...Authentication,
...ACCOUNTS,
...BILLS,
...VENDORS,