Add Site settings > General (#133)

This commit is contained in:
Riccardo Graziosi
2022-07-18 10:47:54 +02:00
committed by GitHub
parent bdc4004e4a
commit 35831b9801
99 changed files with 2405 additions and 281 deletions

View File

@@ -9,8 +9,4 @@ SECRET_KEY_BASE=secretkeybasehere
POSTGRES_USER=yourusernamehere POSTGRES_USER=yourusernamehere
POSTGRES_PASSWORD=yourpasswordhere POSTGRES_PASSWORD=yourpasswordhere
APP_NAME=You App Name Here EMAIL_CONFIRMATION=false
SHOW_LOGO=yes
POSTS_PER_PAGE=15
EMAIL_CONFIRMATION=no

View File

@@ -65,6 +65,10 @@ The project is broadly structured as follows:
- `schema.rb`: database schema - `schema.rb`: database schema
- `spec`: RSpec tests - `spec`: RSpec tests
### Rails console
If you need to work with the Rails console, just attach a shell to the `web` container. From there, type `rails c` to run the console. You may notice that every query you run (e.g. `Post.all`) fails with error `Current::MissingCurrentTenant (Current tenant is not set)`: that's because Astuto implements multi tenancy at the database level. In order to fix this error, supposing you're in single tenant mode, just run `Current.tenant = Tenant.first` as the first command inside the Rails console. After that, everything should work as expected.
### Specs (tests) ### Specs (tests)
Tests are done using RSpec, a testing framework for Ruby on Rails: Tests are done using RSpec, a testing framework for Ruby on Rails:

View File

@@ -4,7 +4,7 @@ class ApplicationController < ActionController::Base
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
before_action :configure_permitted_parameters, if: :devise_controller? before_action :configure_permitted_parameters, if: :devise_controller?
before_action :load_boards prepend_before_action :load_tenant_data
protected protected
@@ -15,8 +15,36 @@ class ApplicationController < ActionController::Base
devise_parameter_sanitizer.permit(:account_update, keys: additional_permitted_parameters) devise_parameter_sanitizer.permit(:account_update, keys: additional_permitted_parameters)
end end
def load_boards def load_tenant_data
if Rails.application.multi_tenancy?
return if request.subdomain.blank? or RESERVED_SUBDOMAINS.include?(request.subdomain)
# Load the current tenant based on subdomain
current_tenant = Tenant.find_by(subdomain: request.subdomain)
if current_tenant.status == "pending" and controller_name != "confirmation" and action_name != "show"
redirect_to pending_tenant_path; return
end
if current_tenant.status == "blocked"
redirect_to blocked_tenant_path; return
end
redirect_to showcase_url unless current_tenant
else
# Load the one and only tenant
current_tenant = Tenant.first
end
return unless current_tenant
Current.tenant = current_tenant
# Load tenant data
@tenant = Current.tenant_or_raise!
@boards = Board.select(:id, :name).order(order: :asc) @boards = Board.select(:id, :name).order(order: :asc)
# Setup locale
I18n.locale = @tenant.locale
end end
private private

View File

@@ -1,4 +1,7 @@
class RegistrationsController < Devise::RegistrationsController class RegistrationsController < Devise::RegistrationsController
# Needed to have Current.tenant available in Devise's controllers
prepend_before_action :load_tenant_data
# Override destroy to soft delete # Override destroy to soft delete
def destroy def destroy
resource.status = "deleted" resource.status = "deleted"

View File

@@ -0,0 +1,4 @@
class SessionsController < Devise::SessionsController
# Needed to have Current.tenant available in Devise's controllers
prepend_before_action :load_tenant_data
end

View File

@@ -1,4 +1,6 @@
class StaticPagesController < ApplicationController class StaticPagesController < ApplicationController
skip_before_action :load_tenant_data, only: [:showcase, :pending_tenant, :blocked_tenant]
def roadmap def roadmap
@post_statuses = PostStatus @post_statuses = PostStatus
.find_roadmap .find_roadmap
@@ -8,4 +10,14 @@ class StaticPagesController < ApplicationController
.find_with_post_status_in(@post_statuses) .find_with_post_status_in(@post_statuses)
.select(:id, :title, :board_id, :post_status_id, :user_id, :created_at) .select(:id, :title, :board_id, :post_status_id, :user_id, :created_at)
end end
def showcase
render html: 'Showcase home page.'
end
def pending_tenant
end
def blocked_tenant
end
end end

View File

@@ -0,0 +1,63 @@
class TenantsController < ApplicationController
include ApplicationHelper
before_action :authenticate_admin, only: [:show, :update]
def new
@page_title = t('signup.page_title')
end
def show
render json: Current.tenant_or_raise!
end
def create
@tenant = Tenant.new
@tenant.assign_attributes(tenant_create_params)
authorize @tenant
ActiveRecord::Base.transaction do
@tenant.save!
Current.tenant = @tenant
@user = User.create!(
full_name: params[:user][:full_name],
email: params[:user][:email],
password: params[:user][:password],
role: "admin"
)
render json: @tenant, status: :created
rescue ActiveRecord::RecordInvalid => exception
render json: { error: exception }, status: :unprocessable_entity
end
end
def update
@tenant = Current.tenant_or_raise!
authorize @tenant
if @tenant.update(tenant_update_params)
render json: @tenant
else
render json: {
error: @tenant.errors.full_messages
}, status: :unprocessable_entity
end
end
private
def tenant_create_params
params
.require(:tenant)
.permit(policy(@tenant).permitted_attributes_for_create)
end
def tenant_update_params
params
.require(:tenant)
.permit(policy(@tenant).permitted_attributes_for_update)
end
end

View File

@@ -27,4 +27,10 @@ module ApplicationHelper
return return
end end
end end
def add_subdomain_to(url_helper, resource=nil, options={})
options[:subdomain] = Current.tenant_or_raise!.subdomain if Rails.application.multi_tenancy?
resource ? url_helper.call(resource, options) : url_helper.call(options)
end
end end

View File

@@ -0,0 +1,59 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import ITenantJSON from '../../interfaces/json/ITenant';
import { State } from '../../reducers/rootReducer';
export const TENANT_REQUEST_START = 'TENANT_REQUEST_START';
interface TenantRequestStartAction {
type: typeof TENANT_REQUEST_START;
}
export const TENANT_REQUEST_SUCCESS = 'TENANT_REQUEST_SUCCESS';
interface TenantRequestSuccessAction {
type: typeof TENANT_REQUEST_SUCCESS;
tenant: ITenantJSON;
}
export const TENANT_REQUEST_FAILURE = 'TENANT_REQUEST_FAILURE';
interface TenantRequestFailureAction {
type: typeof TENANT_REQUEST_FAILURE;
error: string;
}
export type TenantRequestActionTypes =
TenantRequestStartAction |
TenantRequestSuccessAction |
TenantRequestFailureAction;
const tenantRequestStart = (): TenantRequestActionTypes => ({
type: TENANT_REQUEST_START,
});
const tenantRequestSuccess = (
tenant: ITenantJSON
): TenantRequestActionTypes => ({
type: TENANT_REQUEST_SUCCESS,
tenant,
});
const tenantRequestFailure = (error: string): TenantRequestActionTypes => ({
type: TENANT_REQUEST_FAILURE,
error,
});
export const requestTenant = (): ThunkAction<void, State, null, Action<string>> => (
async (dispatch) => {
dispatch(tenantRequestStart());
try {
const response = await fetch('/tenants/0');
const json = await response.json();
dispatch(tenantRequestSuccess(json));
} catch (e) {
dispatch(tenantRequestFailure(e));
}
}
);

View File

@@ -0,0 +1,82 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import ITenantJSON from "../../interfaces/json/ITenant";
import { State } from "../../reducers/rootReducer";
export const TENANT_SUBMIT_START = 'TENANT_SUBMIT_START';
interface TenantSubmitStartAction {
type: typeof TENANT_SUBMIT_START;
}
export const TENANT_SUBMIT_SUCCESS = 'TENANT_SUBMIT_SUCCESS';
interface TenantSubmitSuccessAction {
type: typeof TENANT_SUBMIT_SUCCESS;
tenant: ITenantJSON;
}
export const TENANT_SUBMIT_FAILURE = 'TENANT_SUBMIT_FAILURE';
interface TenantSubmitFailureAction {
type: typeof TENANT_SUBMIT_FAILURE;
error: string;
}
export type TenantSubmitActionTypes =
TenantSubmitStartAction |
TenantSubmitSuccessAction |
TenantSubmitFailureAction;
const tenantSubmitStart = (): TenantSubmitStartAction => ({
type: TENANT_SUBMIT_START,
});
const tenantSubmitSuccess = (
tenantJSON: ITenantJSON,
): TenantSubmitSuccessAction => ({
type: TENANT_SUBMIT_SUCCESS,
tenant: tenantJSON,
});
const tenantSubmitFailure = (error: string): TenantSubmitFailureAction => ({
type: TENANT_SUBMIT_FAILURE,
error,
});
export const submitTenant = (
userFullName: string,
userEmail: string,
userPassword: string,
siteName: string,
subdomain: string,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(tenantSubmitStart());
try {
const res = await fetch(`/tenants`, {
method: 'POST',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
user: {
full_name: userFullName,
email: userEmail,
password: userPassword,
},
tenant: {
site_name: siteName,
subdomain: subdomain,
},
}),
});
const json = await res.json();
if (res.status === HttpStatus.Created) {
dispatch(tenantSubmitSuccess(json));
} else {
dispatch(tenantSubmitFailure(json.error));
}
} catch (e) {
dispatch(tenantSubmitFailure(e));
}
};

View File

@@ -0,0 +1,124 @@
export const TENANT_SIGN_UP_TOGGLE_EMAIL_AUTH = 'TENANT_SIGN_UP_TOGGLE_EMAIL_AUTH';
interface TenantSignUpToggleEmailAuth {
type: typeof TENANT_SIGN_UP_TOGGLE_EMAIL_AUTH,
}
export const toggleEmailAuthTenantSignUp = (
): TenantSignUpToggleEmailAuth => ({
type: TENANT_SIGN_UP_TOGGLE_EMAIL_AUTH,
});
// User full name
export const TENANT_SIGN_UP_CHANGE_USER_FULL_NAME = 'TENANT_SIGN_UP_CHANGE_USER_FULL_NAME';
interface TenantSignUpChangeUserFullName {
type: typeof TENANT_SIGN_UP_CHANGE_USER_FULL_NAME,
fullName: string,
}
export const changeUserFullNameTenantSignUp = (
fullName: string
): TenantSignUpChangeUserFullName => ({
type: TENANT_SIGN_UP_CHANGE_USER_FULL_NAME,
fullName,
});
// User email
export const TENANT_SIGN_UP_CHANGE_USER_EMAIL = 'TENANT_SIGN_UP_CHANGE_USER_EMAIL';
interface TenantSignUpChangeUserEmail {
type: typeof TENANT_SIGN_UP_CHANGE_USER_EMAIL,
email: string,
}
export const changeUserEmailTenantSignUp = (
email: string
): TenantSignUpChangeUserEmail => ({
type: TENANT_SIGN_UP_CHANGE_USER_EMAIL,
email,
});
// User password
export const TENANT_SIGN_UP_CHANGE_USER_PASSWORD = 'TENANT_SIGN_UP_CHANGE_USER_PASSWORD';
interface TenantSignUpChangeUserPassword {
type: typeof TENANT_SIGN_UP_CHANGE_USER_PASSWORD,
password: string,
}
export const changeUserPasswordTenantSignUp = (
password: string
): TenantSignUpChangeUserPassword => ({
type: TENANT_SIGN_UP_CHANGE_USER_PASSWORD,
password,
});
// User password confirmation
export const TENANT_SIGN_UP_CHANGE_USER_PASSWORD_CONFIRMATION = 'TENANT_SIGN_UP_CHANGE_USER_PASSWORD_CONFIRMATION';
interface TenantSignUpChangeUserPasswordConfirmation {
type: typeof TENANT_SIGN_UP_CHANGE_USER_PASSWORD_CONFIRMATION,
passwordConfirmation: string,
}
export const changeUserPasswordConfirmationTenantSignUp = (
passwordConfirmation: string
): TenantSignUpChangeUserPasswordConfirmation => ({
type: TENANT_SIGN_UP_CHANGE_USER_PASSWORD_CONFIRMATION,
passwordConfirmation,
});
// Confirm user data, proceed to step 2
export const TENANT_SIGN_UP_CONFIRM_USER_FORM = 'TENANT_SIGN_UP_CONFIRM_USER_FORM';
interface TenantSignUpConfirmUserForm {
type: typeof TENANT_SIGN_UP_CONFIRM_USER_FORM;
}
export const confirmUserFormTenantSignUp = (): TenantSignUpConfirmUserForm => ({
type: TENANT_SIGN_UP_CONFIRM_USER_FORM,
});
// Tenant site name
export const TENANT_SIGN_UP_CHANGE_TENANT_SITE_NAME = 'TENANT_SIGN_UP_CHANGE_TENANT_SITE_NAME';
interface TenantSignUpChangeTenantSiteName {
type: typeof TENANT_SIGN_UP_CHANGE_TENANT_SITE_NAME,
siteName: string,
}
export const changeTenantSiteNameTenantSignUp = (
siteName: string
): TenantSignUpChangeTenantSiteName => ({
type: TENANT_SIGN_UP_CHANGE_TENANT_SITE_NAME,
siteName,
});
// Tenant site name
export const TENANT_SIGN_UP_CHANGE_TENANT_SUBDOMAIN = 'TENANT_SIGN_UP_CHANGE_TENANT_SUBDOMAIN';
interface TenantSignUpChangeTenantSubdomain {
type: typeof TENANT_SIGN_UP_CHANGE_TENANT_SUBDOMAIN,
subdomain: string,
}
export const changeTenantSubdomainTenantSignUp = (
subdomain: string
): TenantSignUpChangeTenantSubdomain => ({
type: TENANT_SIGN_UP_CHANGE_TENANT_SUBDOMAIN,
subdomain,
});
export type TenantSignUpFormActions =
TenantSignUpToggleEmailAuth |
TenantSignUpChangeUserFullName |
TenantSignUpChangeUserEmail |
TenantSignUpChangeUserPassword |
TenantSignUpChangeUserPasswordConfirmation |
TenantSignUpConfirmUserForm |
TenantSignUpChangeTenantSiteName |
TenantSignUpChangeTenantSubdomain;

View File

@@ -0,0 +1,91 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import ITenantJSON from "../../interfaces/json/ITenant";
import { State } from "../../reducers/rootReducer";
export const TENANT_UPDATE_START = 'TENANT_UPDATE_START';
interface TenantUpdateStartAction {
type: typeof TENANT_UPDATE_START;
}
export const TENANT_UPDATE_SUCCESS = 'TENANT_UPDATE_SUCCESS';
interface TenantUpdateSuccessAction {
type: typeof TENANT_UPDATE_SUCCESS;
tenant: ITenantJSON;
}
export const TENANT_UPDATE_FAILURE = 'TENANT_UPDATE_FAILURE';
interface TenantUpdateFailureAction {
type: typeof TENANT_UPDATE_FAILURE;
error: string;
}
export type TenantUpdateActionTypes =
TenantUpdateStartAction |
TenantUpdateSuccessAction |
TenantUpdateFailureAction;
const tenantUpdateStart = (): TenantUpdateStartAction => ({
type: TENANT_UPDATE_START,
});
const tenantUpdateSuccess = (
tenantJSON: ITenantJSON,
): TenantUpdateSuccessAction => ({
type: TENANT_UPDATE_SUCCESS,
tenant: tenantJSON,
});
const tenantUpdateFailure = (error: string): TenantUpdateFailureAction => ({
type: TENANT_UPDATE_FAILURE,
error,
});
interface UpdateTenantParams {
siteName?: string;
siteLogo?: string;
brandDisplaySetting?: string;
locale?: string;
authenticityToken: string;
}
export const updateTenant = ({
siteName = null,
siteLogo = null,
brandDisplaySetting = null,
locale = null,
authenticityToken,
}: UpdateTenantParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(tenantUpdateStart());
const tenant = Object.assign({},
siteName !== null ? { site_name: siteName } : null,
siteLogo !== null ? { site_logo: siteLogo } : null,
brandDisplaySetting !== null ? { brand_display_setting: brandDisplaySetting } : null,
locale !== null ? { locale } : null
);
try {
const res = await fetch(`/tenants/0`, {
method: 'PATCH',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({ tenant }),
});
const json = await res.json();
if (res.status === HttpStatus.OK) {
dispatch(tenantUpdateSuccess(json));
} else {
dispatch(tenantUpdateFailure(json.error));
}
return Promise.resolve(res);
} catch (e) {
dispatch(tenantUpdateFailure(e));
return Promise.resolve(null);
}
};

View File

@@ -0,0 +1,65 @@
// siteName
export const SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_NAME = 'SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_NAME';
interface SiteSettingsChangeGeneralFormSiteName {
type: typeof SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_NAME,
siteName: string,
}
export const changeSiteSettingsGeneralFormSiteName = (
siteName: string
): SiteSettingsChangeGeneralFormSiteName => ({
type: SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_NAME,
siteName,
});
// siteLogo
export const SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_LOGO = 'SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_LOGO';
interface SiteSettingsChangeGeneralFormSiteLogo {
type: typeof SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_LOGO,
siteLogo: string,
}
export const changeSiteSettingsGeneralFormSiteLogo = (
siteLogo: string
): SiteSettingsChangeGeneralFormSiteLogo => ({
type: SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_LOGO,
siteLogo,
});
// brandDisplaySetting
export const SITE_SETTINGS_CHANGE_GENERAL_FORM_BRAND_SETTING = 'SITE_SETTINGS_CHANGE_GENERAL_FORM_BRAND_SETTING';
interface SiteSettingsChangeGeneralFormBrandSetting {
type: typeof SITE_SETTINGS_CHANGE_GENERAL_FORM_BRAND_SETTING,
brandDisplaySetting: string,
}
export const changeSiteSettingsGeneralFormBrandSetting = (
brandDisplaySetting: string
): SiteSettingsChangeGeneralFormBrandSetting => ({
type: SITE_SETTINGS_CHANGE_GENERAL_FORM_BRAND_SETTING,
brandDisplaySetting,
});
// locale
export const SITE_SETTINGS_CHANGE_GENERAL_FORM_LOCALE = 'SITE_SETTINGS_CHANGE_GENERAL_FORM_LOCALE';
interface SiteSettingsChangeGeneralFormLocale {
type: typeof SITE_SETTINGS_CHANGE_GENERAL_FORM_LOCALE,
locale: string,
}
export const changeSiteSettingsGeneralFormLocale = (
locale: string
): SiteSettingsChangeGeneralFormLocale => ({
type: SITE_SETTINGS_CHANGE_GENERAL_FORM_LOCALE,
locale,
});
export type ChangeSiteSettingsGeneralFormActionTypes =
SiteSettingsChangeGeneralFormSiteName |
SiteSettingsChangeGeneralFormSiteLogo |
SiteSettingsChangeGeneralFormBrandSetting |
SiteSettingsChangeGeneralFormLocale;

View File

@@ -80,18 +80,22 @@ class BoardForm extends React.Component<Props, State> {
const {name, description} = this.state; const {name, description} = this.state;
return ( return (
<div className="boardForm"> <form className="boardForm">
<div className="boardMandatoryForm"> <div className="boardMandatoryForm">
<input <input
type="text" type="text"
placeholder={I18n.t('site_settings.boards.form.name')} placeholder={I18n.t('site_settings.boards.form.name')}
value={name} value={name}
onChange={e => this.onNameChange(e.target.value)} onChange={e => this.onNameChange(e.target.value)}
autoFocus
className="form-control" className="form-control"
/> />
<Button <Button
onClick={this.onSubmit} onClick={e => {
e.preventDefault();
this.onSubmit();
}}
className="newBoardButton" className="newBoardButton"
disabled={!this.isFormValid()} disabled={!this.isFormValid()}
> >
@@ -110,7 +114,7 @@ class BoardForm extends React.Component<Props, State> {
onChange={e => this.onDescriptionChange(e.target.value)} onChange={e => this.onDescriptionChange(e.target.value)}
className="form-control" className="form-control"
/> />
</div> </form>
); );
} }
} }

View File

@@ -0,0 +1,166 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
import { ISiteSettingsGeneralForm } from '../../../reducers/SiteSettings/generalReducer';
import Button from '../../common/Button';
import HttpStatus from '../../../constants/http_status';
import {
TENANT_BRAND_NAME_AND_LOGO,
TENANT_BRAND_NAME_ONLY,
TENANT_BRAND_LOGO_ONLY,
TENANT_BRAND_NONE,
} from '../../../interfaces/ITenant';
interface Props {
originForm: ISiteSettingsGeneralForm;
authenticityToken: string;
form: ISiteSettingsGeneralForm;
areDirty: boolean;
areLoading: boolean;
areUpdating: boolean;
error: string;
requestTenant(): void;
updateTenant(
siteName: string,
siteLogo: string,
brandDisplaySetting: string,
locale: string,
authenticityToken: string
): Promise<any>;
handleChangeSiteName(siteName: string): void;
handleChangeSiteLogo(siteLogo: string): void;
handleChangeBrandDisplaySetting(brandDisplaySetting: string)
handleChangeLocale(locale: string): void;
}
class GeneralSiteSettingsP extends React.Component<Props> {
constructor(props: Props) {
super(props);
this._handleUpdateTenant = this._handleUpdateTenant.bind(this);
}
componentDidMount() {
this.props.requestTenant();
}
_handleUpdateTenant() {
const { siteName, siteLogo, brandDisplaySetting, locale } = this.props.form;
this.props.updateTenant(
siteName,
siteLogo,
brandDisplaySetting,
locale,
this.props.authenticityToken,
).then(res => {
if (res?.status !== HttpStatus.OK) return;
window.location.reload();
});
}
render() {
const {
originForm,
form,
areDirty,
areLoading,
areUpdating,
error,
handleChangeSiteName,
handleChangeSiteLogo,
handleChangeBrandDisplaySetting,
handleChangeLocale,
} = this.props;
return (
<>
<Box>
<h2>{ I18n.t('site_settings.general.title') }</h2>
<form>
<div className="formRow">
<div className="formGroup col-4">
<label htmlFor="siteName">{ I18n.t('site_settings.general.site_name') }</label>
<input
type="text"
value={areLoading ? originForm.siteName : form.siteName}
onChange={e => handleChangeSiteName(e.target.value)}
id="siteName"
className="formControl"
/>
</div>
<div className="formGroup col-4">
<label htmlFor="siteLogo">{ I18n.t('site_settings.general.site_logo') }</label>
<input
type="text"
value={areLoading ? originForm.siteLogo : form.siteLogo}
onChange={e => handleChangeSiteLogo(e.target.value)}
id="siteLogo"
className="formControl"
/>
</div>
<div className="formGroup col-4">
<label htmlFor="brandSetting">{ I18n.t('site_settings.general.brand_setting') }</label>
<select
value={form.brandDisplaySetting || originForm.brandDisplaySetting}
onChange={e => handleChangeBrandDisplaySetting(e.target.value)}
id="brandSetting"
className="selectPicker"
>
<option value={TENANT_BRAND_NAME_AND_LOGO}>
{ I18n.t('site_settings.general.brand_setting_both') }
</option>
<option value={TENANT_BRAND_NAME_ONLY}>
{ I18n.t('site_settings.general.brand_setting_name') }
</option>
<option value={TENANT_BRAND_LOGO_ONLY}>
{ I18n.t('site_settings.general.brand_setting_logo') }
</option>
<option value={TENANT_BRAND_NONE}>
{ I18n.t('site_settings.general.brand_setting_none') }
</option>
</select>
</div>
</div>
<div className="formGroup">
<label htmlFor="locale">{ I18n.t('site_settings.general.locale') }</label>
<select
value={form.locale || originForm.locale}
onChange={e => handleChangeLocale(e.target.value)}
id="locale"
className="selectPicker"
>
<option value="en">🇬🇧 English</option>
<option value="it">🇮🇹 Italiano</option>
</select>
</div>
</form>
<br />
<Button
onClick={this._handleUpdateTenant}
disabled={!areDirty}
>
{ I18n.t('common.buttons.update') }
</Button>
</Box>
<SiteSettingsInfoBox areUpdating={areLoading || areUpdating} error={error} areDirty={areDirty} />
</>
);
}
}
export default GeneralSiteSettingsP;

View File

@@ -0,0 +1,36 @@
import * as React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import GeneralSiteSettings from '../../../containers/GeneralSiteSettings';
import createStoreHelper from '../../../helpers/createStore';
import { State } from '../../../reducers/rootReducer';
import { ISiteSettingsGeneralForm } from '../../../reducers/SiteSettings/generalReducer';
interface Props {
originForm: ISiteSettingsGeneralForm;
authenticityToken: string;
}
class GeneralSiteSettingsRoot extends React.Component<Props> {
store: Store<State, any>;
constructor(props: Props) {
super(props);
this.store = createStoreHelper();
}
render() {
return (
<Provider store={this.store}>
<GeneralSiteSettings
originForm={this.props.originForm}
authenticityToken={this.props.authenticityToken}
/>
</Provider>
);
}
}
export default GeneralSiteSettingsRoot;

View File

@@ -87,12 +87,13 @@ class PostStatusForm extends React.Component<Props, State> {
const {name, color} = this.state; const {name, color} = this.state;
return ( return (
<div className="postStatusForm"> <form className="postStatusForm">
<input <input
type="text" type="text"
placeholder={I18n.t('site_settings.post_statuses.form.name')} placeholder={I18n.t('site_settings.post_statuses.form.name')}
value={name} value={name}
onChange={e => this.onNameChange(e.target.value)} onChange={e => this.onNameChange(e.target.value)}
autoFocus
className="form-control" className="form-control"
/> />
@@ -104,7 +105,10 @@ class PostStatusForm extends React.Component<Props, State> {
/> />
<Button <Button
onClick={this.onSubmit} onClick={e => {
e.preventDefault();
this.onSubmit();
}}
className="newPostStatusButton" className="newPostStatusButton"
disabled={!this.isFormValid()} disabled={!this.isFormValid()}
> >
@@ -115,7 +119,7 @@ class PostStatusForm extends React.Component<Props, State> {
I18n.t('common.buttons.update') I18n.t('common.buttons.update')
} }
</Button> </Button>
</div> </form>
); );
} }
} }

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../common/Box';
interface Props {
subdomain: string;
userEmail: string;
}
const ConfirmSignUpPage = ({
subdomain,
userEmail,
}: Props) => (
<Box>
<h3>{ I18n.t('signup.step3.title') }</h3>
<p>{ I18n.t('signup.step3.message', { email: userEmail, subdomain: `${subdomain}.astuto.io` }) }</p>
</Box>
);
export default ConfirmSignUpPage;

View File

@@ -0,0 +1,92 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../common/Box';
import { TenantSignUpTenantFormState } from '../../reducers/tenantSignUpReducer';
import Button from '../common/Button';
import Spinner from '../common/Spinner';
import { DangerText } from '../common/CustomTexts';
interface Props {
tenantForm: TenantSignUpTenantFormState;
handleChangeTenantSiteName(siteName: string): void;
handleChangeTenantSubdomain(subdomain: string): void;
isSubmitting: boolean;
error: string;
handleSubmit(): void;
}
class TenantSignUpForm extends React.Component<Props> {
form: any;
constructor(props: Props) {
super(props);
this.form = React.createRef();
}
render() {
const {
tenantForm,
handleChangeTenantSiteName,
handleChangeTenantSubdomain,
isSubmitting,
error,
handleSubmit,
} = this.props;
return (
<Box customClass="tenantSignUpStep2">
<h3>{ I18n.t('signup.step2.title') }</h3>
<form ref={this.form}>
<div className="formRow">
<input
type="text"
autoFocus
value={tenantForm.siteName}
onChange={e => handleChangeTenantSiteName(e.target.value)}
placeholder={I18n.t('signup.step2.site_name')}
required
id="tenantSiteName"
className="formControl"
/>
</div>
<div className="formRow">
<div className="input-group">
<input
type="text"
value={tenantForm.subdomain}
onChange={e => handleChangeTenantSubdomain(e.target.value)}
placeholder={I18n.t('signup.step2.subdomain')}
required
id="tenantSubdomain"
className="formControl"
/>
<div className="input-group-append">
<div className="input-group-text">.astuto.io</div>
</div>
</div>
</div>
<Button
onClick={e => {
e.preventDefault();
handleSubmit();
}}
className="tenantConfirm"
>
{ isSubmitting ? <Spinner /> : I18n.t('signup.step2.create_button') }
</Button>
{ error !== '' && <DangerText>{ error }</DangerText> }
</form>
</Box>
);
}
}
export default TenantSignUpForm;

View File

@@ -0,0 +1,124 @@
import * as React from 'react';
import { TenantSignUpTenantFormState, TenantSignUpUserFormState } from '../../reducers/tenantSignUpReducer';
import ConfirmSignUpPage from './ConfirmSignUpPage';
import TenantSignUpForm from './TenantSignUpForm';
import UserSignUpForm from './UserSignUpForm';
interface Props {
authenticityToken: string;
currentStep: number;
emailAuth: boolean;
isSubmitting: boolean;
error: string;
toggleEmailAuth(): void;
userForm: TenantSignUpUserFormState;
handleChangeUserFullName(fullName: string): void;
handleChangeUserEmail(email: string): void;
handleChangeUserPassword(password: string): void;
handleChangeUserPasswordConfirmation(passwordConfirmation: string): void;
handleUserFormConfirm(): void;
tenantForm: TenantSignUpTenantFormState;
handleChangeTenantSiteName(siteName: string): void;
handleChangeTenantSubdomain(subdomain: string): void;
handleSubmit(
userFullName: string,
userEmail: string,
userPassword: string,
siteName: string,
subdomain: string,
authenticityToken: string,
): void;
}
class TenantSignUpP extends React.Component<Props> {
constructor(props: Props) {
super(props);
this._handleSubmit = this._handleSubmit.bind(this);
}
_handleSubmit() {
const { userForm, tenantForm, handleSubmit } = this.props;
handleSubmit(
userForm.fullName,
userForm.email,
userForm.password,
tenantForm.siteName,
tenantForm.subdomain,
this.props.authenticityToken,
);
}
render() {
const {
currentStep,
emailAuth,
toggleEmailAuth,
userForm,
handleChangeUserFullName,
handleChangeUserEmail,
handleChangeUserPassword,
handleChangeUserPasswordConfirmation,
handleUserFormConfirm,
tenantForm,
handleChangeTenantSiteName,
handleChangeTenantSubdomain,
isSubmitting,
error,
} = this.props;
return (
<div className="tenantSignUpContainer">
{
(currentStep === 1 || currentStep === 2) &&
<UserSignUpForm
currentStep={currentStep}
emailAuth={emailAuth}
toggleEmailAuth={toggleEmailAuth}
userForm={userForm}
handleChangeUserFullName={handleChangeUserFullName}
handleChangeUserEmail={handleChangeUserEmail}
handleChangeUserPassword={handleChangeUserPassword}
handleChangeUserPasswordConfirmation={handleChangeUserPasswordConfirmation}
handleUserFormConfirm={handleUserFormConfirm}
/>
}
{
currentStep === 2 &&
<TenantSignUpForm
tenantForm={tenantForm}
handleChangeTenantSiteName={handleChangeTenantSiteName}
handleChangeTenantSubdomain={handleChangeTenantSubdomain}
isSubmitting={isSubmitting}
error={error}
handleSubmit={this._handleSubmit}
/>
}
{
currentStep === 3 &&
<ConfirmSignUpPage
subdomain={tenantForm.subdomain}
userEmail={userForm.email}
/>
}
</div>
);
}
}
export default TenantSignUpP;

View File

@@ -0,0 +1,147 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../common/Box';
import Button from '../common/Button';
import { TenantSignUpUserFormState } from '../../reducers/tenantSignUpReducer';
interface Props {
currentStep: number;
emailAuth: boolean;
toggleEmailAuth(): void;
userForm: TenantSignUpUserFormState;
handleChangeUserFullName(fullName: string): void;
handleChangeUserEmail(email: string): void;
handleChangeUserPassword(password: string): void;
handleChangeUserPasswordConfirmation(passwordConfirmation: string): void;
handleUserFormConfirm(): void;
}
class UserSignUpForm extends React.Component<Props> {
form: any;
constructor(props: Props) {
super(props);
this.form = React.createRef();
}
validateUserForm(): boolean {
let isValid: boolean = this.form.current.reportValidity();
if (this.validateUserPasswordConfirmation() === false)
isValid = false;
return isValid;
}
validateUserPasswordConfirmation(): boolean {
const isValid = this.props.userForm.password === this.props.userForm.passwordConfirmation;
return isValid;
}
render() {
const {
currentStep,
emailAuth,
toggleEmailAuth,
userForm,
handleChangeUserFullName,
handleChangeUserEmail,
handleChangeUserPassword,
handleChangeUserPasswordConfirmation,
handleUserFormConfirm,
} = this.props;
return (
<Box customClass="tenantSignUpStep1">
<h3>{ I18n.t('signup.step1.title') }</h3>
{
currentStep === 1 && !emailAuth &&
<Button className="emailAuth" onClick={toggleEmailAuth}>
{ I18n.t('signup.step1.email_auth') }
</Button>
}
{
currentStep === 1 && emailAuth &&
<form ref={this.form}>
<div className="formRow">
<input
type="text"
autoFocus
value={userForm.fullName}
onChange={e => handleChangeUserFullName(e.target.value)}
placeholder={I18n.t('common.forms.auth.full_name')}
required
id="userFullName"
className="formControl"
/>
</div>
<div className="formRow">
<input
type="email"
value={userForm.email}
onChange={e => handleChangeUserEmail(e.target.value)}
placeholder={I18n.t('common.forms.auth.email')}
required
id="userEmail"
className="formControl"
/>
</div>
<div className="formRow">
<div className="formGroup col-6">
<input
type="password"
value={userForm.password}
onChange={e => handleChangeUserPassword(e.target.value)}
placeholder={I18n.t('common.forms.auth.password')}
required
minLength={6}
maxLength={128}
id="userPassword"
className="formControl"
/>
</div>
<div className="formGroup col-6">
<input
type="password"
value={userForm.passwordConfirmation}
onChange={e => handleChangeUserPasswordConfirmation(e.target.value)}
placeholder={I18n.t('common.forms.auth.password_confirmation')}
required
minLength={6}
maxLength={128}
id="userPasswordConfirmation"
className={`formControl${userForm.passwordConfirmationError ? ' invalid' : ''}`}
/>
</div>
</div>
<Button
onClick={e => {
e.preventDefault();
this.validateUserForm() && handleUserFormConfirm();
}}
className="userConfirm"
>
{ I18n.t('common.buttons.confirm') }
</Button>
</form>
}
{
currentStep === 2 &&
<p><b>{userForm.fullName}</b> ({userForm.email})</p>
}
</Box>
);
}
}
export default UserSignUpForm;

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import { Provider } from 'react-redux';
import createStoreHelper from '../../helpers/createStore';
import TenantSignUp from '../../containers/TenantSignUp';
import { Store } from 'redux';
import { State } from '../../reducers/rootReducer';
interface Props {
authenticityToken: string;
}
class TenantSignUpRoot extends React.Component<Props> {
store: Store<State, any>;
constructor(props: Props) {
super(props);
this.store = createStoreHelper();
}
render() {
const { authenticityToken } = this.props;
return (
<Provider store={this.store}>
<TenantSignUp
authenticityToken={authenticityToken}
/>
</Provider>
);
}
}
export default TenantSignUpRoot;

View File

@@ -7,9 +7,10 @@ import Box from './Box';
interface Props { interface Props {
areUpdating: boolean; areUpdating: boolean;
error: string; error: string;
areDirty?: boolean;
} }
const SiteSettingsInfoBox = ({ areUpdating, error }: Props) => ( const SiteSettingsInfoBox = ({ areUpdating, error, areDirty = false }: Props) => (
<Box customClass="siteSettingsInfo"> <Box customClass="siteSettingsInfo">
{ {
areUpdating ? areUpdating ?
@@ -17,10 +18,13 @@ const SiteSettingsInfoBox = ({ areUpdating, error }: Props) => (
: :
error ? error ?
<span className="error"> <span className="error">
{I18n.t('site_settings.info_box.error', { message: JSON.stringify(error) })} { I18n.t('site_settings.info_box.error', { message: JSON.stringify(error) }) }
</span> </span>
: :
<span>{I18n.t('site_settings.info_box.up_to_date')}</span> areDirty ?
<span className="warning">{ I18n.t('site_settings.info_box.dirty') }</span>
:
<span>{ I18n.t('site_settings.info_box.up_to_date') }</span>
} }
</Box> </Box>
); );

View File

@@ -1 +1 @@
export const POSTS_PER_PAGE = parseInt(process.env.POSTS_PER_PAGE); export const POSTS_PER_PAGE = 15;

View File

@@ -0,0 +1,63 @@
import { connect } from "react-redux";
import { requestTenant } from "../actions/Tenant/requestTenant";
import {
changeSiteSettingsGeneralFormBrandSetting,
changeSiteSettingsGeneralFormLocale,
changeSiteSettingsGeneralFormSiteLogo,
changeSiteSettingsGeneralFormSiteName
} from "../actions/changeSiteSettingsGeneralForm";
import GeneralSiteSettingsP from "../components/SiteSettings/General/GeneralSiteSettingsP";
import { State } from "../reducers/rootReducer";
import { updateTenant } from "../actions/Tenant/updateTenant";
const mapStateToProps = (state: State) => ({
form: state.siteSettings.general.form,
areDirty: state.siteSettings.general.areDirty,
areLoading: state.siteSettings.general.areLoading,
areUpdating: state.siteSettings.general.areUpdating,
error: state.siteSettings.general.error,
});
const mapDispatchToProps = (dispatch: any) => ({
requestTenant() {
dispatch(requestTenant());
},
updateTenant(
siteName: string,
siteLogo: string,
brandDisplaySetting: string,
locale: string,
authenticityToken: string
): Promise<any> {
return dispatch(updateTenant({
siteName,
siteLogo,
brandDisplaySetting,
locale,
authenticityToken,
}));
},
handleChangeSiteName(siteName: string) {
dispatch(changeSiteSettingsGeneralFormSiteName(siteName));
},
handleChangeSiteLogo(siteLogo: string) {
dispatch(changeSiteSettingsGeneralFormSiteLogo(siteLogo));
},
handleChangeBrandDisplaySetting(brandDisplaySetting: string) {
dispatch(changeSiteSettingsGeneralFormBrandSetting(brandDisplaySetting));
},
handleChangeLocale(locale: string) {
dispatch(changeSiteSettingsGeneralFormLocale(locale));
},
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(GeneralSiteSettingsP);

View File

@@ -0,0 +1,82 @@
import { connect } from "react-redux";
import TenantSignUpP from "../components/TenantSignUp/TenantSignUpP";
import { State } from "../reducers/rootReducer";
import {
changeTenantSiteNameTenantSignUp,
changeTenantSubdomainTenantSignUp,
changeUserEmailTenantSignUp,
changeUserFullNameTenantSignUp,
changeUserPasswordConfirmationTenantSignUp,
changeUserPasswordTenantSignUp,
confirmUserFormTenantSignUp,
toggleEmailAuthTenantSignUp
} from "../actions/Tenant/tenantSignUpFormActions";
import { submitTenant } from "../actions/Tenant/submitTenant";
const mapStateToProps = (state: State) => ({
currentStep: state.tenantSignUp.currentStep,
emailAuth: state.tenantSignUp.emailAuth,
isSubmitting: state.tenantSignUp.isSubmitting,
error: state.tenantSignUp.error,
userForm: state.tenantSignUp.userForm,
tenantForm: state.tenantSignUp.tenantForm,
});
const mapDispatchToProps = (dispatch: any) => ({
toggleEmailAuth() {
dispatch(toggleEmailAuthTenantSignUp());
},
handleChangeUserFullName(fullName: string) {
dispatch(changeUserFullNameTenantSignUp(fullName));
},
handleChangeUserEmail(email: string) {
dispatch(changeUserEmailTenantSignUp(email));
},
handleChangeUserPassword(password: string) {
dispatch(changeUserPasswordTenantSignUp(password));
},
handleChangeUserPasswordConfirmation(passwordConfirmation: string) {
dispatch(changeUserPasswordConfirmationTenantSignUp(passwordConfirmation));
},
handleUserFormConfirm() {
dispatch(confirmUserFormTenantSignUp());
},
handleChangeTenantSiteName(siteName: string) {
dispatch(changeTenantSiteNameTenantSignUp(siteName));
},
handleChangeTenantSubdomain(subdomain: string) {
dispatch(changeTenantSubdomainTenantSignUp(subdomain));
},
handleSubmit(
userFullName: string,
userEmail: string,
userPassword: string,
siteName: string,
subdomain: string,
authenticityToken: string,
) {
dispatch(submitTenant(
userFullName,
userEmail,
userPassword,
siteName,
subdomain,
authenticityToken,
));
}
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(TenantSignUpP);

View File

@@ -0,0 +1,21 @@
// Brand display setting
export const TENANT_BRAND_NAME_AND_LOGO = 'name_and_logo';
export const TENANT_BRAND_NAME_ONLY = 'name_only';
export const TENANT_BRAND_LOGO_ONLY = 'logo_only';
export const TENANT_BRAND_NONE = 'no_name_no_logo';
export type TenantBrandDisplaySetting =
typeof TENANT_BRAND_NAME_AND_LOGO |
typeof TENANT_BRAND_NAME_ONLY |
typeof TENANT_BRAND_LOGO_ONLY |
typeof TENANT_BRAND_NONE;
interface ITenant {
id: number;
siteName: string;
siteLogo: string;
brandDisplaySetting: TenantBrandDisplaySetting;
locale: string;
}
export default ITenant;

View File

@@ -0,0 +1,9 @@
interface ITenantJSON {
id: number;
site_name: string;
site_logo: string;
brand_display_setting: string;
locale: string;
}
export default ITenantJSON;

View File

@@ -0,0 +1,140 @@
import {
TenantRequestActionTypes,
TENANT_REQUEST_START,
TENANT_REQUEST_SUCCESS,
TENANT_REQUEST_FAILURE,
} from "../../actions/Tenant/requestTenant";
import {
TenantUpdateActionTypes,
TENANT_UPDATE_START,
TENANT_UPDATE_SUCCESS,
TENANT_UPDATE_FAILURE,
} from '../../actions/Tenant/updateTenant';
import {
ChangeSiteSettingsGeneralFormActionTypes,
SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_NAME,
SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_LOGO,
SITE_SETTINGS_CHANGE_GENERAL_FORM_BRAND_SETTING,
SITE_SETTINGS_CHANGE_GENERAL_FORM_LOCALE,
} from '../../actions/changeSiteSettingsGeneralForm';
export interface ISiteSettingsGeneralForm {
siteName: string;
siteLogo: string;
brandDisplaySetting: string;
locale: string;
}
export interface SiteSettingsGeneralState {
form: ISiteSettingsGeneralForm,
areDirty: boolean;
areLoading: boolean;
areUpdating: boolean;
error: string;
}
const initialState: SiteSettingsGeneralState = {
form: {
siteName: '',
siteLogo: '',
brandDisplaySetting: '',
locale: '',
},
areDirty: false,
areLoading: false,
areUpdating: false,
error: '',
};
const siteSettingsGeneralReducer = (
state = initialState,
action:
TenantRequestActionTypes |
TenantUpdateActionTypes |
ChangeSiteSettingsGeneralFormActionTypes
) => {
switch (action.type) {
case TENANT_REQUEST_START:
return {
...state,
areLoading: true,
};
case TENANT_UPDATE_START:
return {
...state,
areUpdating: true,
};
case TENANT_REQUEST_SUCCESS:
return {
...state,
form: {
siteName: action.tenant.site_name,
siteLogo: action.tenant.site_logo,
brandDisplaySetting: action.tenant.brand_display_setting,
locale: action.tenant.locale,
},
areDirty: false,
areLoading: false,
error: '',
};
case TENANT_UPDATE_SUCCESS:
return {
...state,
areDirty: false,
areUpdating: false,
error: '',
};
case TENANT_REQUEST_FAILURE:
return {
...state,
areLoading: false,
error: action.error,
};
case TENANT_UPDATE_FAILURE:
return {
...state,
areUpdating: false,
error: action.error,
};
case SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_NAME:
return {
...state,
form: { ...state.form, siteName: action.siteName },
areDirty: true,
};
case SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_LOGO:
return {
...state,
form: { ...state.form, siteLogo: action.siteLogo },
areDirty: true,
};
case SITE_SETTINGS_CHANGE_GENERAL_FORM_BRAND_SETTING:
return {
...state,
form: { ...state.form, brandDisplaySetting: action.brandDisplaySetting },
areDirty: true,
};
case SITE_SETTINGS_CHANGE_GENERAL_FORM_LOCALE:
return {
...state,
form: { ...state.form, locale: action.locale },
areDirty: true,
};
default:
return state;
}
}
export default siteSettingsGeneralReducer;

View File

@@ -1,3 +1,10 @@
import {
PostStatusesRequestActionTypes,
POST_STATUSES_REQUEST_START,
POST_STATUSES_REQUEST_SUCCESS,
POST_STATUSES_REQUEST_FAILURE,
} from '../../actions/PostStatus/requestPostStatuses';
import { import {
PostStatusOrderUpdateActionTypes, PostStatusOrderUpdateActionTypes,
POSTSTATUS_ORDER_UPDATE_START, POSTSTATUS_ORDER_UPDATE_START,
@@ -38,12 +45,15 @@ const initialState: SiteSettingsPostStatusesState = {
const siteSettingsPostStatusesReducer = ( const siteSettingsPostStatusesReducer = (
state = initialState, state = initialState,
action: PostStatusOrderUpdateActionTypes | action:
PostStatusesRequestActionTypes |
PostStatusOrderUpdateActionTypes |
PostStatusDeleteActionTypes | PostStatusDeleteActionTypes |
PostStatusSubmitActionTypes | PostStatusSubmitActionTypes |
PostStatusUpdateActionTypes PostStatusUpdateActionTypes
): SiteSettingsPostStatusesState => { ): SiteSettingsPostStatusesState => {
switch (action.type) { switch (action.type) {
case POST_STATUSES_REQUEST_START:
case POSTSTATUS_SUBMIT_START: case POSTSTATUS_SUBMIT_START:
case POSTSTATUS_UPDATE_START: case POSTSTATUS_UPDATE_START:
case POSTSTATUS_ORDER_UPDATE_START: case POSTSTATUS_ORDER_UPDATE_START:
@@ -53,6 +63,7 @@ const siteSettingsPostStatusesReducer = (
areUpdating: true, areUpdating: true,
}; };
case POST_STATUSES_REQUEST_SUCCESS:
case POSTSTATUS_SUBMIT_SUCCESS: case POSTSTATUS_SUBMIT_SUCCESS:
case POSTSTATUS_UPDATE_SUCCESS: case POSTSTATUS_UPDATE_SUCCESS:
case POSTSTATUS_ORDER_UPDATE_SUCCESS: case POSTSTATUS_ORDER_UPDATE_SUCCESS:
@@ -63,6 +74,7 @@ const siteSettingsPostStatusesReducer = (
error: '', error: '',
}; };
case POST_STATUSES_REQUEST_FAILURE:
case POSTSTATUS_SUBMIT_FAILURE: case POSTSTATUS_SUBMIT_FAILURE:
case POSTSTATUS_UPDATE_FAILURE: case POSTSTATUS_UPDATE_FAILURE:
case POSTSTATUS_ORDER_UPDATE_FAILURE: case POSTSTATUS_ORDER_UPDATE_FAILURE:

View File

@@ -1,5 +1,7 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import tenantSignUpReducer from './tenantSignUpReducer';
import postsReducer from './postsReducer'; import postsReducer from './postsReducer';
import boardsReducer from './boardsReducer'; import boardsReducer from './boardsReducer';
import postStatusesReducer from './postStatusesReducer'; import postStatusesReducer from './postStatusesReducer';
@@ -8,6 +10,8 @@ import currentPostReducer from './currentPostReducer';
import siteSettingsReducer from './siteSettingsReducer'; import siteSettingsReducer from './siteSettingsReducer';
const rootReducer = combineReducers({ const rootReducer = combineReducers({
tenantSignUp: tenantSignUpReducer,
posts: postsReducer, posts: postsReducer,
boards: boardsReducer, boards: boardsReducer,
postStatuses: postStatusesReducer, postStatuses: postStatusesReducer,

View File

@@ -1,3 +1,25 @@
import {
TenantRequestActionTypes,
TENANT_REQUEST_START,
TENANT_REQUEST_SUCCESS,
TENANT_REQUEST_FAILURE,
} from '../actions/Tenant/requestTenant';
import {
TenantUpdateActionTypes,
TENANT_UPDATE_START,
TENANT_UPDATE_SUCCESS,
TENANT_UPDATE_FAILURE,
} from '../actions/Tenant/updateTenant';
import {
ChangeSiteSettingsGeneralFormActionTypes,
SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_NAME,
SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_LOGO,
SITE_SETTINGS_CHANGE_GENERAL_FORM_BRAND_SETTING,
SITE_SETTINGS_CHANGE_GENERAL_FORM_LOCALE,
} from '../actions/changeSiteSettingsGeneralForm';
import { import {
BoardsRequestActionTypes, BoardsRequestActionTypes,
BOARDS_REQUEST_START, BOARDS_REQUEST_START,
@@ -33,6 +55,13 @@ import {
BOARD_DELETE_FAILURE, BOARD_DELETE_FAILURE,
} from '../actions/Board/deleteBoard'; } from '../actions/Board/deleteBoard';
import {
PostStatusesRequestActionTypes,
POST_STATUSES_REQUEST_START,
POST_STATUSES_REQUEST_SUCCESS,
POST_STATUSES_REQUEST_FAILURE,
} from '../actions/PostStatus/requestPostStatuses';
import { import {
PostStatusOrderUpdateActionTypes, PostStatusOrderUpdateActionTypes,
POSTSTATUS_ORDER_UPDATE_START, POSTSTATUS_ORDER_UPDATE_START,
@@ -75,12 +104,14 @@ import {
USER_UPDATE_FAILURE, USER_UPDATE_FAILURE,
} from '../actions/User/updateUser'; } from '../actions/User/updateUser';
import siteSettingsGeneralReducer, { SiteSettingsGeneralState } from './SiteSettings/generalReducer';
import siteSettingsBoardsReducer, { SiteSettingsBoardsState } from './SiteSettings/boardsReducer'; import siteSettingsBoardsReducer, { SiteSettingsBoardsState } from './SiteSettings/boardsReducer';
import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer'; import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer';
import siteSettingsRoadmapReducer, { SiteSettingsRoadmapState } from './SiteSettings/roadmapReducer'; import siteSettingsRoadmapReducer, { SiteSettingsRoadmapState } from './SiteSettings/roadmapReducer';
import siteSettingsUsersReducer, { SiteSettingsUsersState } from './SiteSettings/usersReducer'; import siteSettingsUsersReducer, { SiteSettingsUsersState } from './SiteSettings/usersReducer';
interface SiteSettingsState { interface SiteSettingsState {
general: SiteSettingsGeneralState;
boards: SiteSettingsBoardsState; boards: SiteSettingsBoardsState;
postStatuses: SiteSettingsPostStatusesState; postStatuses: SiteSettingsPostStatusesState;
roadmap: SiteSettingsRoadmapState; roadmap: SiteSettingsRoadmapState;
@@ -88,6 +119,7 @@ interface SiteSettingsState {
} }
const initialState: SiteSettingsState = { const initialState: SiteSettingsState = {
general: siteSettingsGeneralReducer(undefined, {} as any),
boards: siteSettingsBoardsReducer(undefined, {} as any), boards: siteSettingsBoardsReducer(undefined, {} as any),
postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any), postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any),
roadmap: siteSettingsRoadmapReducer(undefined, {} as any), roadmap: siteSettingsRoadmapReducer(undefined, {} as any),
@@ -97,11 +129,15 @@ const initialState: SiteSettingsState = {
const siteSettingsReducer = ( const siteSettingsReducer = (
state = initialState, state = initialState,
action: action:
TenantRequestActionTypes |
TenantUpdateActionTypes |
ChangeSiteSettingsGeneralFormActionTypes |
BoardsRequestActionTypes | BoardsRequestActionTypes |
BoardSubmitActionTypes | BoardSubmitActionTypes |
BoardUpdateActionTypes | BoardUpdateActionTypes |
BoardOrderUpdateActionTypes | BoardOrderUpdateActionTypes |
BoardDeleteActionTypes | BoardDeleteActionTypes |
PostStatusesRequestActionTypes |
PostStatusOrderUpdateActionTypes | PostStatusOrderUpdateActionTypes |
PostStatusDeleteActionTypes | PostStatusDeleteActionTypes |
PostStatusSubmitActionTypes | PostStatusSubmitActionTypes |
@@ -110,6 +146,21 @@ const siteSettingsReducer = (
UserUpdateActionTypes UserUpdateActionTypes
): SiteSettingsState => { ): SiteSettingsState => {
switch (action.type) { switch (action.type) {
case TENANT_REQUEST_START:
case TENANT_REQUEST_SUCCESS:
case TENANT_REQUEST_FAILURE:
case TENANT_UPDATE_START:
case TENANT_UPDATE_SUCCESS:
case TENANT_UPDATE_FAILURE:
case SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_NAME:
case SITE_SETTINGS_CHANGE_GENERAL_FORM_SITE_LOGO:
case SITE_SETTINGS_CHANGE_GENERAL_FORM_BRAND_SETTING:
case SITE_SETTINGS_CHANGE_GENERAL_FORM_LOCALE:
return {
...state,
general: siteSettingsGeneralReducer(state.general, action),
};
case BOARDS_REQUEST_START: case BOARDS_REQUEST_START:
case BOARDS_REQUEST_SUCCESS: case BOARDS_REQUEST_SUCCESS:
case BOARDS_REQUEST_FAILURE: case BOARDS_REQUEST_FAILURE:
@@ -130,6 +181,9 @@ const siteSettingsReducer = (
boards: siteSettingsBoardsReducer(state.boards, action), boards: siteSettingsBoardsReducer(state.boards, action),
}; };
case POST_STATUSES_REQUEST_START:
case POST_STATUSES_REQUEST_SUCCESS:
case POST_STATUSES_REQUEST_FAILURE:
case POSTSTATUS_SUBMIT_START: case POSTSTATUS_SUBMIT_START:
case POSTSTATUS_SUBMIT_SUCCESS: case POSTSTATUS_SUBMIT_SUCCESS:
case POSTSTATUS_SUBMIT_FAILURE: case POSTSTATUS_SUBMIT_FAILURE:

View File

@@ -0,0 +1,145 @@
import {
TenantSignUpFormActions,
TENANT_SIGN_UP_TOGGLE_EMAIL_AUTH,
TENANT_SIGN_UP_CHANGE_USER_FULL_NAME,
TENANT_SIGN_UP_CHANGE_USER_EMAIL,
TENANT_SIGN_UP_CHANGE_USER_PASSWORD,
TENANT_SIGN_UP_CHANGE_USER_PASSWORD_CONFIRMATION,
TENANT_SIGN_UP_CONFIRM_USER_FORM,
TENANT_SIGN_UP_CHANGE_TENANT_SITE_NAME,
TENANT_SIGN_UP_CHANGE_TENANT_SUBDOMAIN,
} from '../actions/Tenant/tenantSignUpFormActions';
import {
TenantSubmitActionTypes,
TENANT_SUBMIT_START,
TENANT_SUBMIT_SUCCESS,
TENANT_SUBMIT_FAILURE,
} from '../actions/Tenant/submitTenant';
export interface TenantSignUpUserFormState {
fullName: string;
email: string;
password: string;
passwordConfirmation: string;
passwordConfirmationError: boolean;
}
export interface TenantSignUpTenantFormState {
siteName: string;
subdomain: string;
}
export interface TenantSignUpState {
currentStep: number;
emailAuth: boolean;
isSubmitting: boolean;
error: string;
userForm: TenantSignUpUserFormState;
tenantForm: TenantSignUpTenantFormState;
}
const initialState: TenantSignUpState = {
currentStep: 1,
emailAuth: false,
isSubmitting: false,
error: '',
userForm: {
fullName: '',
email: '',
password: '',
passwordConfirmation: '',
passwordConfirmationError: false,
},
tenantForm: {
siteName: '',
subdomain: '',
},
};
const tenantSignUpReducer = (
state = initialState,
action: TenantSignUpFormActions | TenantSubmitActionTypes,
) => {
switch (action.type) {
case TENANT_SIGN_UP_TOGGLE_EMAIL_AUTH:
return {
...state,
emailAuth: !state.emailAuth,
};
case TENANT_SIGN_UP_CHANGE_USER_FULL_NAME:
return {
...state,
userForm: { ...state.userForm, fullName: action.fullName },
};
case TENANT_SIGN_UP_CHANGE_USER_EMAIL:
return {
...state,
userForm: { ...state.userForm, email: action.email },
};
case TENANT_SIGN_UP_CHANGE_USER_PASSWORD:
return {
...state,
userForm: { ...state.userForm, password: action.password },
};
case TENANT_SIGN_UP_CHANGE_USER_PASSWORD_CONFIRMATION:
return {
...state,
userForm: {
...state.userForm,
passwordConfirmation: action.passwordConfirmation,
passwordConfirmationError: state.userForm.password !== action.passwordConfirmation,
},
};
case TENANT_SIGN_UP_CONFIRM_USER_FORM:
return {
...state,
currentStep: 2,
};
case TENANT_SIGN_UP_CHANGE_TENANT_SITE_NAME:
return {
...state,
tenantForm: { ...state.tenantForm, siteName: action.siteName },
};
case TENANT_SIGN_UP_CHANGE_TENANT_SUBDOMAIN:
return {
...state,
tenantForm: { ...state.tenantForm, subdomain: action.subdomain },
};
case TENANT_SUBMIT_START:
return {
...state,
isSubmitting: true,
};
case TENANT_SUBMIT_SUCCESS:
return {
...state,
currentStep: 3,
isSubmitting: false,
error: '',
};
case TENANT_SUBMIT_FAILURE:
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
}
export default tenantSignUpReducer;

View File

@@ -36,6 +36,25 @@
a { color: $primary-color; } a { color: $primary-color; }
} }
.formRow {
@extend .form-row;
margin-top: 10px;
margin-bottom: 10px;
}
.formGroup {
@extend .form-group;
}
.formControl {
@extend .form-control;
&.invalid {
border-color: red;
}
}
.switch { .switch {
@extend @extend
.custom-control-input; .custom-control-input;

View File

@@ -30,7 +30,7 @@
.align-top, .align-top,
.mr-2; .mr-2;
width: 36px; height: 36px;
} }
} }

View File

@@ -142,6 +142,11 @@
max-width: 960px; max-width: 960px;
} }
.smallContainer {
max-width: 540px;
margin: 16px auto;
}
.turbolinks-progress-bar { .turbolinks-progress-bar {
background-color: $primary-color; background-color: $primary-color;
height: 2px; height: 2px;

View File

@@ -26,6 +26,7 @@
.postStatusForm { .postStatusForm {
@extend @extend
.d-flex, .d-flex,
.flex-grow-1,
.m-2; .m-2;
column-gap: 8px; column-gap: 8px;

View File

@@ -4,4 +4,8 @@
.error { .error {
color: red; color: red;
} }
.warning {
color: #fd7e14;
}
} }

View File

@@ -0,0 +1,3 @@
.tenantSignUpContainer {
@extend .smallContainer;
}

View File

@@ -9,6 +9,7 @@
@import 'common/scroll_shadows'; @import 'common/scroll_shadows';
/* Components */ /* Components */
@import 'components/TenantSignUp';
@import 'components/Board'; @import 'components/Board';
@import 'components/Comments'; @import 'components/Comments';
@import 'components/LikeButton'; @import 'components/LikeButton';

View File

@@ -1,4 +1,5 @@
class ApplicationMailer < ActionMailer::Base class ApplicationMailer < ActionMailer::Base
default from: "notifications@example.com" default from: "notifications@astuto.io"
layout 'mailer' layout 'mailer'
helper :application
end end

View File

@@ -2,6 +2,8 @@ class UserMailer < ApplicationMailer
layout 'user_mailer' layout 'user_mailer'
def notify_post_owner(comment:) def notify_post_owner(comment:)
@tenant = comment.tenant
Current.tenant = @tenant
@comment = comment @comment = comment
@user = comment.post.user @user = comment.post.user
@@ -12,6 +14,8 @@ class UserMailer < ApplicationMailer
end end
def notify_comment_owner(comment:) def notify_comment_owner(comment:)
@tenant = comment.tenant
Current.tenant = @tenant
@comment = comment @comment = comment
@user = comment.parent.user @user = comment.parent.user
@@ -22,6 +26,8 @@ class UserMailer < ApplicationMailer
end end
def notify_followers_of_post_update(comment:) def notify_followers_of_post_update(comment:)
@tenant = comment.tenant
Current.tenant = @tenant
@comment = comment @comment = comment
mail( mail(
@@ -31,6 +37,8 @@ class UserMailer < ApplicationMailer
end end
def notify_followers_of_post_status_change(post:) def notify_followers_of_post_status_change(post:)
@tenant = post.tenant
Current.tenant = @tenant
@post = post @post = post
mail( mail(
@@ -42,6 +50,6 @@ class UserMailer < ApplicationMailer
private private
def app_name def app_name
ENV.fetch('APP_NAME') Current.tenant_or_raise!.site_name
end end
end end

View File

@@ -1,8 +1,9 @@
class Board < ApplicationRecord class Board < ApplicationRecord
include TenantOwnable
include Orderable include Orderable
has_many :posts, dependent: :destroy has_many :posts, dependent: :destroy
validates :name, presence: true, uniqueness: true validates :name, presence: true, uniqueness: { scope: :tenant_id }
validates :description, length: { in: 0..1024 }, allow_nil: true validates :description, length: { in: 0..1024 }, allow_nil: true
end end

View File

@@ -1,4 +1,6 @@
class Comment < ApplicationRecord class Comment < ApplicationRecord
include TenantOwnable
belongs_to :user belongs_to :user
belongs_to :post belongs_to :post
belongs_to :parent, class_name: 'Comment', optional: true belongs_to :parent, class_name: 'Comment', optional: true

View File

@@ -20,7 +20,7 @@ module Orderable
def set_order_to_last def set_order_to_last
return unless new_record? return unless new_record?
return unless order.nil? return unless order.nil?
order_last = self.class.maximum(:order) || -1 order_last = self.class.maximum(:order) || -1
self.order = order_last + 1 self.order = order_last + 1
end end

View File

@@ -0,0 +1,16 @@
# A TenantOwnable model belongs to a tenant
# A TenantOwnable model must have a tenant_id column
module TenantOwnable
extend ActiveSupport::Concern
included do
# Tenant is actually not optional, but we not do want
# to generate a SELECT query to verify the tenant is
# there every time. We get this protection for free
# through database FK constraints
belongs_to :tenant, optional: true
default_scope { where(tenant: Current.tenant_or_raise!) }
end
end

13
app/models/current.rb Normal file
View File

@@ -0,0 +1,13 @@
class Current < ActiveSupport::CurrentAttributes
attribute :tenant
class MissingCurrentTenant < StandardError; end
class CurrentTenantNotActive < StandardError; end
def tenant_or_raise!
raise MissingCurrentTenant, "Current tenant is not set" unless tenant
raise CurrentTenantBlocked, "Current tenant is blocked" unless tenant.status != "blocked"
tenant
end
end

View File

@@ -1,4 +1,6 @@
class Follow < ApplicationRecord class Follow < ApplicationRecord
include TenantOwnable
belongs_to :user belongs_to :user
belongs_to :post belongs_to :post

View File

@@ -1,4 +1,6 @@
class Like < ApplicationRecord class Like < ApplicationRecord
include TenantOwnable
belongs_to :user belongs_to :user
belongs_to :post belongs_to :post

View File

@@ -1,4 +1,6 @@
class Post < ApplicationRecord class Post < ApplicationRecord
include TenantOwnable
belongs_to :board belongs_to :board
belongs_to :user belongs_to :user
belongs_to :post_status, optional: true belongs_to :post_status, optional: true

View File

@@ -1,9 +1,10 @@
class PostStatus < ApplicationRecord class PostStatus < ApplicationRecord
include TenantOwnable
include Orderable include Orderable
has_many :posts, dependent: :nullify has_many :posts, dependent: :nullify
validates :name, presence: true, uniqueness: true validates :name, presence: true, uniqueness: { scope: :tenant_id }
validates :color, format: { with: /\A#(?:[0-9a-fA-F]{3}){1,2}\z/ } validates :color, format: { with: /\A#(?:[0-9a-fA-F]{3}){1,2}\z/ }
class << self class << self

View File

@@ -1,4 +1,6 @@
class PostStatusChange < ApplicationRecord class PostStatusChange < ApplicationRecord
include TenantOwnable
belongs_to :user belongs_to :user
belongs_to :post belongs_to :post
belongs_to :post_status, optional: true belongs_to :post_status, optional: true

24
app/models/tenant.rb Normal file
View File

@@ -0,0 +1,24 @@
class Tenant < ApplicationRecord
has_many :boards
has_many :post_statuses
has_many :posts
has_many :users
enum brand_display_setting: [
:name_and_logo,
:name_only,
:logo_only,
:no_name_no_logo
]
enum status: [:active, :pending, :blocked]
after_initialize :set_default_status, if: :new_record?
validates :site_name, presence: true
validates :subdomain, presence: true, uniqueness: true
def set_default_status
self.status ||= :pending
end
end

View File

@@ -1,7 +1,8 @@
class User < ApplicationRecord class User < ApplicationRecord
include TenantOwnable
devise :database_authenticatable, :registerable, devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :recoverable, :rememberable, :confirmable
:confirmable
has_many :posts, dependent: :destroy has_many :posts, dependent: :destroy
has_many :likes, dependent: :destroy has_many :likes, dependent: :destroy
@@ -16,6 +17,12 @@ class User < ApplicationRecord
before_save :skip_confirmation before_save :skip_confirmation
validates :full_name, presence: true, length: { in: 2..32 } validates :full_name, presence: true, length: { in: 2..32 }
validates :email,
presence: true,
uniqueness: { scope: :tenant_id, case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, allow_blank: true, length: { in: 6..128 }
validates :password, presence: true, on: :create
def set_default_role def set_default_role
self.role ||= :user self.role ||= :user
@@ -33,6 +40,17 @@ class User < ApplicationRecord
active? ? super : :blocked_or_deleted active? ? super : :blocked_or_deleted
end end
# Override Devise::Confirmable#after_confirmation
# Used to change tenant status from pending to active on owner email confirmation
def after_confirmation
tenant = self.tenant
if tenant.status == "pending" and tenant.users.count == 1
tenant.status = "active"
tenant.save
end
end
def skip_confirmation def skip_confirmation
return if Rails.application.email_confirmation? return if Rails.application.email_confirmation?
skip_confirmation! skip_confirmation!

View File

@@ -0,0 +1,21 @@
class TenantPolicy < ApplicationPolicy
def permitted_attributes_for_create
[:site_name, :subdomain]
end
def permitted_attributes_for_update
if user.admin?
[:site_name, :site_logo, :brand_display_setting, :locale]
else
[]
end
end
def create?
true
end
def update?
user.admin? and user.tenant_id == record.id
end
end

View File

@@ -1,5 +1,9 @@
<p>Welcome <%= @email %>!</p> <p><%= t('mailers.devise.welcome_greeting', { email: @email, site_name: Current.tenant_or_raise!.site_name }) %></p>
<p>You can confirm your account email through the link below:</p> <p><%= t('mailers.devise.confirmation_instructions.body') %></p>
<p><%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p> <p>
<%= link_to t('mailers.devise.confirmation_instructions.action'),
add_subdomain_to(method(:confirmation_url), @resource, { confirmation_token: @token })
%>
</p>

View File

@@ -1,7 +1,7 @@
<p>Hello <%= @email %>!</p> <p><%= t('mailers.devise.opening_greeting', { email: @email }) %></p>
<% if @resource.try(:unconfirmed_email?) %> <% if @resource.try(:unconfirmed_email?) %>
<p>We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.</p> <p><%= t('mailers.devise.email_changed.body', { email: @resource.unconfirmed_email }) %></p>
<% else %> <% else %>
<p>We're contacting you to notify you that your email has been changed to <%= @resource.email %>.</p> <p><%= t('mailers.devise.email_changed.body', { email: @resource.email }) %></p>
<% end %> <% end %>

View File

@@ -1,3 +1,3 @@
<p>Hello <%= @resource.email %>!</p> <p><%= t('mailers.devise.opening_greeting', { email: @resource.email }) %></p>
<p>We're contacting you to notify you that your password has been changed.</p> <p><%= t('mailers.devise.password_change.body') %></p>

View File

@@ -1,8 +1,12 @@
<p>Hello <%= @resource.email %>!</p> <p><%= t('mailers.devise.opening_greeting', { email: @resource.email }) %></p>
<p>Someone has requested a link to change your password. You can do this through the link below.</p> <p><%= t('mailers.devise.reset_password.body') %></p>
<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p> <p>
<%= link_to t('mailers.devise.reset_password.action'),
add_subdomain_to(method(:edit_password_url), @resource, { reset_password_token: @token })
%>
</p>
<p>If you didn't request this, please ignore this email.</p> <p><%= t('mailers.devise.reset_password.body2') %></p>
<p>Your password won't change until you access the link above and create a new one.</p> <p><%= t('mailers.devise.reset_password.body3') %></p>

View File

@@ -4,4 +4,8 @@
<p>Click the link below to unlock your account:</p> <p>Click the link below to unlock your account:</p>
<p><%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %></p> <p>
<%= link_to 'Unlock my account',
add_subdomain_to(method(:unlock_url), @resource, { unlock_token: @token })
%>
</p>

View File

@@ -22,7 +22,7 @@
<% if devise_mapping.rememberable? %> <% if devise_mapping.rememberable? %>
<div class="form-group form-check"> <div class="form-group form-check">
<%= f.check_box :remember_me, class: "form-check-input" %> <%= f.check_box :remember_me, class: "form-check-input" %>
<%= f.label t('common.forms.auth.remember_me'), class: "form-check-label" %> <%= f.label :remember_me, t('common.forms.auth.remember_me'), class: "form-check-label" %>
</div> </div>
<% end %> <% end %>

View File

@@ -2,10 +2,16 @@
<div class="container"> <div class="container">
<%= <%=
link_to root_path, class: 'brand' do link_to root_path, class: 'brand' do
app_name = content_tag :span, Rails.application.name app_name = content_tag :span, @tenant.site_name
logo = image_tag(asset_pack_path('media/images/logo.png'), class: 'logo') logo = image_tag(@tenant.site_logo ? @tenant.site_logo : "", class: 'logo')
Rails.application.show_logo? ? logo + app_name : app_name if @tenant.brand_display_setting == "name_and_logo"
logo + app_name
elsif @tenant.brand_display_setting == "name_only"
app_name
elsif @tenant.brand_display_setting == "logo_only"
logo
end
end end
%> %>
@@ -37,7 +43,7 @@
<% if current_user.power_user? %> <% if current_user.power_user? %>
<%= <%=
link_to t('header.menu.site_settings'), link_to t('header.menu.site_settings'),
current_user.admin? ? site_settings_boards_path : site_settings_users_path, current_user.admin? ? site_settings_general_path : site_settings_users_path,
class: 'dropdown-item' class: 'dropdown-item'
%> %>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>

View File

@@ -1,7 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title><%= Rails.application.name %></title> <title><%= @tenant ? @tenant.site_name : @page_title %></title>
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>
@@ -17,7 +18,10 @@
</head> </head>
<body> <body>
<%= render 'layouts/header' %> <% if @tenant %>
<%= render 'layouts/header' %>
<% end %>
<%= render 'layouts/alerts' %> <%= render 'layouts/alerts' %>
<div class="container"> <div class="container">

View File

@@ -4,14 +4,19 @@
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head> </head>
<body> <body>
<p><%= t('user_mailer.opening_greeting') %></p> <p><%= t('mailers.user.opening_greeting') %></p>
<div><%= yield %></div> <div><%= yield %></div>
<p><%= t('user_mailer.closing_greeting') %></p> <p><%= t('mailers.user.closing_greeting') %></p>
<footer> <footer>
<%= link_to(t('user_mailer.unsubscribe'), edit_user_registration_url) %>. <%=
link_to(
t('mailers.user.unsubscribe'),
add_subdomain_to(method(:edit_user_registration_url))
)
%>.
</footer> </footer>
</body> </body>
</html> </html>

View File

@@ -4,6 +4,7 @@
<div class="verticalNavigation" role="tablist" aria-orientation="vertical"> <div class="verticalNavigation" role="tablist" aria-orientation="vertical">
<% if current_user.admin? %> <% if current_user.admin? %>
<%= render 'menu_link', label: t('site_settings.menu.general'), path: site_settings_general_path %>
<%= render 'menu_link', label: t('site_settings.menu.boards'), path: site_settings_boards_path %> <%= render 'menu_link', label: t('site_settings.menu.boards'), path: site_settings_boards_path %>
<%= render 'menu_link', label: t('site_settings.menu.post_statuses'), path: site_settings_post_statuses_path %> <%= render 'menu_link', label: t('site_settings.menu.post_statuses'), path: site_settings_post_statuses_path %>
<%= render 'menu_link', label: t('site_settings.menu.roadmap'), path: site_settings_roadmap_path %> <%= render 'menu_link', label: t('site_settings.menu.roadmap'), path: site_settings_roadmap_path %>

View File

@@ -1,8 +1,19 @@
<div class="twoColumnsContainer"> <div class="twoColumnsContainer">
<%= render 'menu' %> <%= render 'menu' %>
<div>
<div class="content"> <%=
<h2>General</h2> react_component(
<p>Under construction</p> 'SiteSettings/General',
{
originForm: {
siteName: @tenant.site_name,
siteLogo: @tenant.site_logo,
brandDisplaySetting: @tenant.brand_display_setting,
locale: @tenant.locale
},
authenticityToken: form_authenticity_token
}
)
%>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,5 @@
<div class="smallContainer">
<div class="box">
<h3><%= t('blocked_tenant.title') %></h3>
</div>
</div>

View File

@@ -0,0 +1,6 @@
<div class="smallContainer">
<div class="box">
<h3><%= t('pending_tenant.title') %></h3>
<p><%= t('pending_tenant.message') %></p>
</div>
</div>

View File

@@ -0,0 +1,8 @@
<%=
react_component(
'TenantSignUp',
{
authenticityToken: form_authenticity_token
}
)
%>

View File

@@ -1,5 +1,5 @@
<p> <p>
<%= t('user_mailer.notify_comment_owner.body', { user: @comment.user.full_name, post: @comment.post.title }) %> <%= t('mailers.user.notify_comment_owner.body', { user: @comment.user.full_name, post: @comment.post.title }) %>
</p> </p>
<p> <p>
@@ -7,5 +7,5 @@
</p> </p>
<p> <p>
<%= link_to t('user_mailer.learn_more'), post_url(@comment.post) %> <%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @comment.post) %>
</p> </p>

View File

@@ -1,10 +0,0 @@
<p>
<%= I18n.t('user_mailer.notify_followers_of_post_status_change.body', { post: @post }) %>
<span style='background-color: <%= @post.post_status.color %>; color: white;'%>>
<%= @post.post_status.name %>
</span>
</p>
<p>
<%= link_to I18n.t('user_mailer.learn_more'), post_url(@post) %>
</p>

View File

@@ -0,0 +1,10 @@
<p>
<%= t('mailers.user.notify_followers_of_post_status_change.body', { post: @post }) %>
<span style='background-color: <%= @post.post_status.color %>; color: white;'%>>
<%= @post.post_status.name %>
</span>
</p>
<p>
<%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @post) %>
</p>

View File

@@ -1,11 +0,0 @@
<p>
<%= I18n.t('user_mailer.notify_followers_of_post_update.body', { user: @comment.user.full_name, post: @comment.post.title }) %>
</p>
<p>
<i><%= @comment.body %></i>
</p>
<p>
<%= link_to I18n.t('user_mailer.learn_more'), post_url(@comment.post) %>
</p>

View File

@@ -0,0 +1,11 @@
<p>
<%= t('mailers.user.notify_followers_of_post_update.body', { user: @comment.user.full_name, post: @comment.post.title }) %>
</p>
<p>
<i><%= @comment.body %></i>
</p>
<p>
<%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @comment.post) %>
</p>

View File

@@ -1,5 +1,5 @@
<p> <p>
<%= t('user_mailer.notify_post_owner.body', { user: @comment.user.full_name, post: @comment.post.title }) %> <%= t('mailers.user.notify_post_owner.body', { user: @comment.user.full_name, post: @comment.post.title }) %>
</p> </p>
<p> <p>
@@ -7,5 +7,5 @@
</p> </p>
<p> <p>
<%= link_to t('user_mailer.learn_more'), post_url(@comment.post) %> <%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @comment.post) %>
</p> </p>

View File

@@ -15,9 +15,6 @@ env_vars=(
"POSTGRES_USER" \ "POSTGRES_USER" \
"POSTGRES_PASSWORD" \ "POSTGRES_PASSWORD" \
"EMAIL_CONFIRMATION" \ "EMAIL_CONFIRMATION" \
"APP_NAME" \
"SHOW_LOGO" \
"POSTS_PER_PAGE" \
) )
# Check each one # Check each one

View File

@@ -16,20 +16,16 @@ module App
# -- all .rb files in that directory are automatically loaded after loading # -- all .rb files in that directory are automatically loaded after loading
# the framework and any gems in your application. # the framework and any gems in your application.
def name def multi_tenancy?
ENV["APP_NAME"] ENV["MULTI_TENANCY"] == "true"
end end
def email_confirmation? def email_confirmation?
ENV["EMAIL_CONFIRMATION"] == "yes" ENV["EMAIL_CONFIRMATION"] == "true"
end
def show_logo?
ENV["SHOW_LOGO"] == "yes"
end end
def posts_per_page def posts_per_page
ENV["POSTS_PER_PAGE"].to_i 15
end end
end end
end end

View File

@@ -1,6 +1,9 @@
Rails.application.configure do Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
# For subdomains in localhost
config.action_dispatch.tld_length = 0
# For Devise # For Devise
config.action_mailer.default_url_options = { host: 'localhost:3000' } config.action_mailer.default_url_options = { host: 'localhost:3000' }

View File

@@ -5,7 +5,6 @@
# Set up default environment variables # Set up default environment variables
ENV["EMAIL_CONFIRMATION"] = "no" ENV["EMAIL_CONFIRMATION"] = "no"
ENV["POSTS_PER_PAGE"] = "15"
Rails.application.configure do Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.

View File

@@ -12,19 +12,19 @@ Devise.setup do |config|
# ==> Controller configuration # ==> Controller configuration
# Configure the parent class to the devise controllers. # Configure the parent class to the devise controllers.
# config.parent_controller = 'DeviseController' # config.parent_controller = 'ApplicationController'
# ==> Mailer Configuration # ==> Mailer Configuration
# Configure the e-mail address which will be shown in Devise::Mailer, # Configure the e-mail address which will be shown in Devise::Mailer,
# note that it will be overwritten if you use your own mailer class # note that it will be overwritten if you use your own mailer class
# with default "from" parameter. # with default "from" parameter.
config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com' config.mailer_sender = 'notifications@astuto.io'
# Configure the class responsible to send e-mails. # Configure the class responsible to send e-mails.
# config.mailer = 'Devise::Mailer' # config.mailer = 'Devise::Mailer'
# Configure the parent class responsible to send e-mails. # Configure the parent class responsible to send e-mails.
# config.parent_mailer = 'ActionMailer::Base' config.parent_mailer = 'ApplicationMailer'
# ==> ORM configuration # ==> ORM configuration
# Load and configure the ORM. Supports :active_record (default) and # Load and configure the ORM. Supports :active_record (default) and
@@ -166,12 +166,12 @@ Devise.setup do |config|
# ==> Configuration for :validatable # ==> Configuration for :validatable
# Range for password length. # Range for password length.
config.password_length = 6..128 # config.password_length = 6..128
# Email regex used to validate email formats. It simply asserts that # Email regex used to validate email formats. It simply asserts that
# one (and only one) @ exists in the given string. This is mainly # one (and only one) @ exists in the given string. This is mainly
# to give user feedback and not to assert the e-mail validity. # to give user feedback and not to assert the e-mail validity.
config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ # config.email_regexp = /\A[^@\s]+@[^@\s]+\z/
# ==> Configuration for :timeoutable # ==> Configuration for :timeoutable
# The time you want to timeout the user session without activity. After this # The time you want to timeout the user session without activity. After this

View File

@@ -0,0 +1,6 @@
RESERVED_SUBDOMAINS = [
'showcase',
'login',
'help',
'playground'
]

View File

@@ -2,7 +2,7 @@ en:
common: common:
forms: forms:
auth: auth:
email: 'Email address' email: 'Email'
full_name: 'Full name' full_name: 'Full name'
password: 'Password' password: 'Password'
password_confirmation: 'Password confirmation' password_confirmation: 'Password confirmation'
@@ -38,6 +38,7 @@ en:
cancel: 'Cancel' cancel: 'Cancel'
create: 'Create' create: 'Create'
update: 'Save' update: 'Save'
confirm: 'Confirm'
datetime: datetime:
now: 'just now' now: 'just now'
minutes: minutes:
@@ -49,6 +50,19 @@ en:
days: days:
one: '1 day ago' one: '1 day ago'
other: '%{count} days ago' other: '%{count} days ago'
signup:
page_title: 'Create your feedback space'
step1:
title: '1. Create user account'
email_auth: 'Register with email'
step2:
title: '2. Create feedback space'
site_name: 'Site name'
subdomain: 'Subdomain'
create_button: 'Create feedback space'
step3:
title: "You're almost done!"
message: "Check your email %{email} to activate your new feedback space %{subdomain}!"
header: header:
menu: menu:
site_settings: 'Site settings' site_settings: 'Site settings'
@@ -57,6 +71,11 @@ en:
log_in: 'Log in / Sign up' log_in: 'Log in / Sign up'
roadmap: roadmap:
title: 'Roadmap' title: 'Roadmap'
pending_tenant:
title: 'Verify your email address'
message: 'We''ve sent an email with an activation link to the email you provided during registration. Click on that link to activate this feedback space!'
blocked_tenant:
title: 'This feedback space has been blocked'
board: board:
new_post: new_post:
submit_button: 'Submit feedback' submit_button: 'Submit feedback'
@@ -103,6 +122,7 @@ en:
site_settings: site_settings:
menu: menu:
title: 'Site settings' title: 'Site settings'
general: 'General'
boards: 'Boards' boards: 'Boards'
post_statuses: 'Statuses' post_statuses: 'Statuses'
roadmap: 'Roadmap' roadmap: 'Roadmap'
@@ -110,6 +130,17 @@ en:
info_box: info_box:
up_to_date: 'All changes saved' up_to_date: 'All changes saved'
error: 'An error occurred: %{message}' error: 'An error occurred: %{message}'
dirty: 'Changes not saved'
general:
title: 'General'
site_name: 'Site name'
site_logo: 'Site logo'
brand_setting: 'Display'
brand_setting_both: 'Both name and logo'
brand_setting_name: 'Name only'
brand_setting_logo: 'Logo only'
brand_setting_none: 'None'
locale: 'Language'
boards: boards:
title: 'Boards' title: 'Boards'
empty: 'There are no boards. Create one below!' empty: 'There are no boards. Create one below!'
@@ -127,7 +158,7 @@ en:
title: 'Roadmap' title: 'Roadmap'
title2: 'Not in roadmap' title2: 'Not in roadmap'
empty: 'The roadmap is empty.' empty: 'The roadmap is empty.'
help: 'You can add statuses to the roadmap by dragging them from the section below. If you instead want to create a new status or change their order, go to Site settings -> Statuses.' help: 'You can add statuses to the roadmap by dragging them from the section below. If you instead want to create a new status or change their order, go to Site settings > Statuses.'
users: users:
title: 'Users' title: 'Users'
block: 'Block' block: 'Block'
@@ -141,23 +172,40 @@ en:
status_active: 'Active' status_active: 'Active'
status_blocked: 'Blocked' status_blocked: 'Blocked'
status_deleted: 'Deleted' status_deleted: 'Deleted'
user_mailer: mailers:
opening_greeting: 'Hello!' devise:
closing_greeting: 'Have a great day!' welcome_greeting: 'Welcome to %{site_name}, %{email}!'
learn_more: 'Click here to learn more' opening_greeting: 'Hello %{email}!'
unsubscribe: 'Annoyed? You can turn off notifications here' confirmation_instructions:
notify_post_owner: body: 'You can confirm your account email through the link below:'
subject: '[%{app_name}] New comment on %{post}' action: 'Confirm my account'
body: 'There is a new comment by %{user} on your post %{post}' email_changed:
notify_comment_owner: body: "We're contacting you to notify you that your email is being changed to %{email}."
subject: '[%{app_name}] New reply on your comment from %{post}' body2: "We're contacting you to notify you that your email has been changed to %{email}."
body: 'There is a new reply by %{user} on your comment from post %{post}' password_change:
notify_followers_of_post_update: body: "We're contacting you to notify you that your password has been changed."
subject: '[%{app_name}] New update for post %{post}' reset_password:
body: "There is a new update on the post you're following %{post}" body: 'Someone has requested a link to change your password. You can do this through the link below.'
notify_followers_of_post_status_change: body2: "If you didn't request this, please ignore this email."
subject: '[%{app_name}] Status change on post %{post}' body3: "Your password won't change until you access the link above and create a new one."
body: "The post you're following %{post} has a new status" action: 'Change my password'
user:
opening_greeting: 'Hello!'
closing_greeting: 'Have a great day!'
learn_more: 'Click here to learn more'
unsubscribe: 'Annoyed? You can turn off notifications here'
notify_post_owner:
subject: '[%{app_name}] New comment on %{post}'
body: 'There is a new comment by %{user} on your post %{post}'
notify_comment_owner:
subject: '[%{app_name}] New reply on your comment from %{post}'
body: 'There is a new reply by %{user} on your comment from post %{post}'
notify_followers_of_post_update:
subject: '[%{app_name}] New update for post %{post}'
body: "There is a new update on the post you're following %{post}"
notify_followers_of_post_status_change:
subject: '[%{app_name}] Status change on post %{post}'
body: "The post you're following %{post} has a new status"
backend: backend:
errors: errors:
unauthorized: 'You are not authorized' unauthorized: 'You are not authorized'

View File

@@ -2,7 +2,7 @@ it:
common: common:
forms: forms:
auth: auth:
email: 'Indirizzo email' email: 'Email'
full_name: 'Nome e cognome' full_name: 'Nome e cognome'
password: 'Password' password: 'Password'
password_confirmation: 'Conferma password' password_confirmation: 'Conferma password'
@@ -38,6 +38,7 @@ it:
cancel: 'Annulla' cancel: 'Annulla'
create: 'Crea' create: 'Crea'
update: 'Salva' update: 'Salva'
confirm: 'Conferma'
datetime: datetime:
now: 'adesso' now: 'adesso'
minutes: minutes:
@@ -49,6 +50,19 @@ it:
days: days:
one: '1 giorno fa' one: '1 giorno fa'
other: '%{count} giorni fa' other: '%{count} giorni fa'
signup:
page_title: 'Crea il tuo spazio di feedback'
step1:
title: '1. Crea account utente'
email_auth: 'Registrati con indirizzo email'
step2:
title: '2. Crea spazio di feedback'
site_name: 'Nome del sito'
subdomain: 'Sottodominio'
create_button: 'Crea spazio feedback'
step3:
title: 'Hai quasi finito!'
message: "Controlla la tua email %{email} per attivare il tuo nuovo spazio di feedback %{subdomain}!"
header: header:
menu: menu:
site_settings: 'Impostazioni sito' site_settings: 'Impostazioni sito'
@@ -57,6 +71,11 @@ it:
log_in: 'Accedi / Registrati' log_in: 'Accedi / Registrati'
roadmap: roadmap:
title: 'Roadmap' title: 'Roadmap'
pending_tenant:
title: 'Verifica il tuo indirizzo email'
message: 'Abbiamo mandato una email con un link di attivazione all''indirizzo email che hai specificato in fase di registrazione. Clicca su quel link per attivare questo spazio di feedback!'
blocked_tenant:
title: 'This feedback space has been blocked'
board: board:
new_post: new_post:
submit_button: 'Invia feedback' submit_button: 'Invia feedback'
@@ -103,6 +122,7 @@ it:
site_settings: site_settings:
menu: menu:
title: 'Impostazioni sito' title: 'Impostazioni sito'
general: 'Generali'
boards: 'Bacheche' boards: 'Bacheche'
post_statuses: 'Stati' post_statuses: 'Stati'
roadmap: 'Roadmap' roadmap: 'Roadmap'
@@ -110,6 +130,17 @@ it:
info_box: info_box:
up_to_date: 'Tutte le modifiche sono state salvate' up_to_date: 'Tutte le modifiche sono state salvate'
error: 'Si è verificato un errore: %{message}' error: 'Si è verificato un errore: %{message}'
dirty: 'Modifiche non salvate'
general:
title: 'Generale'
site_name: 'Nome del sito'
site_logo: 'Logo del sito'
brand_setting: 'Mostra'
brand_setting_both: 'Sia nome che logo'
brand_setting_name: 'Solo nome'
brand_setting_logo: 'Solo logo'
brand_setting_none: 'Nessuno'
locale: 'Lingua'
boards: boards:
title: 'Bacheche' title: 'Bacheche'
empty: 'Non ci sono bacheche. Creane una qua sotto!' empty: 'Non ci sono bacheche. Creane una qua sotto!'
@@ -141,23 +172,40 @@ it:
status_active: 'Attivo' status_active: 'Attivo'
status_blocked: 'Bloccato' status_blocked: 'Bloccato'
status_deleted: 'Eliminato' status_deleted: 'Eliminato'
user_mailer: mailers:
opening_greeting: 'Ciao!' devise:
closing_greeting: 'Buona giornata!' welcome_greeting: 'Benvenuto su %{site_name}, %{email}!'
learn_more: 'Clicca qui per saperne di più' opening_greeting: 'Ciao %{email}!'
unsubscribe: 'Non vuoi più ricevere notifiche? Clicca qui' confirmation_instructions:
notify_post_owner: body: 'Puoi confermare il tuo account cliccando il link qua sotto:'
subject: '[%{app_name}] Nuovo commento al tuo post %{post}' action: 'Conferma il mio account'
body: '%{user} ha commentato il tuo post %{post}' email_changed:
notify_comment_owner: body: "Ti contattiamo per segnalarti che la tua email sta per essere modificata in %{email}."
subject: '[%{app_name}] Risposta al tuo commento nel post %{post}' body2: "Ti contattiamo per segnalarti che la tua email è stata modificata in %{email}."
body: '%{user} ha risposto al tuo commento nel post %{post}' password_change:
notify_followers_of_post_update: body: "Ti contattiamo per segnalarti che la tua password è stata modificata."
subject: '[%{app_name}] Nuovo aggiornamento per il post %{post}' reset_password:
body: "C'è un nuovo aggiornamento sul post che stai seguendo %{post}" body: 'Qualcuno ha richiesto un link per modificare la tua password. Puoi modificare la tua password cliccando sul link qua sotto.'
notify_followers_of_post_status_change: body2: "Se non sei stato tu a richiedere la modifica, ti preghiamo di ignorare questa email."
subject: '[%{app_name}] Aggiornamento stato per il post %{post}' body3: "La tua password non sarà modificata finché non cliccherai sul link qua sopra e ne creerai una nuova."
body: "Il post che segui %{post} ha un nuovo stato" action: 'Cambia la mia password'
user:
opening_greeting: 'Ciao!'
closing_greeting: 'Buona giornata!'
learn_more: 'Clicca qui per saperne di più'
unsubscribe: 'Non vuoi più ricevere notifiche? Clicca qui'
notify_post_owner:
subject: '[%{app_name}] Nuovo commento al tuo post %{post}'
body: '%{user} ha commentato il tuo post %{post}'
notify_comment_owner:
subject: '[%{app_name}] Risposta al tuo commento nel post %{post}'
body: '%{user} ha risposto al tuo commento nel post %{post}'
notify_followers_of_post_update:
subject: '[%{app_name}] Nuovo aggiornamento per il post %{post}'
body: "C'è un nuovo aggiornamento sul post che stai seguendo %{post}"
notify_followers_of_post_status_change:
subject: '[%{app_name}] Aggiornamento stato per il post %{post}'
body: "Il post che segui %{post} ha un nuovo stato"
backend: backend:
errors: errors:
unauthorized: 'Non sei autorizzato' unauthorized: 'Non sei autorizzato'
@@ -166,4 +214,4 @@ it:
board: board:
update_order: 'Si è verificato un errore durante il riordinamento delle bacheche' update_order: 'Si è verificato un errore durante il riordinamento delle bacheche'
post_status: post_status:
update_order: 'Si è verificato un errore durante il riordinamento degli stati' update_order: 'Si è verificato un errore durante il riordinamento degli stati'

View File

@@ -1,32 +1,53 @@
Rails.application.routes.draw do Rails.application.routes.draw do
root to: 'static_pages#roadmap' if Rails.application.multi_tenancy?
get '/roadmap', to: 'static_pages#roadmap' constraints subdomain: 'showcase' do
root to: 'static_pages#showcase', as: :showcase
end
constraints subdomain: 'login' do
get '/signup', to: 'tenants#new'
resource :tenants, only: [:create]
end
end
constraints subdomain: /.*/ do
root to: 'static_pages#roadmap'
get '/roadmap', to: 'static_pages#roadmap'
get '/pending-tenant', to: 'static_pages#pending_tenant'
get '/blocked-tenant', to: 'static_pages#blocked_tenant'
devise_for :users, :controllers => {
:registrations => 'registrations',
:sessions => 'sessions'
}
resources :tenants, only: [:show, :update]
resources :users, only: [:index, :update]
devise_for :users, :controllers => { :registrations => 'registrations' } resources :posts, only: [:index, :create, :show, :update, :destroy] do
resources :users, only: [:index, :update] resource :follows, only: [:create, :destroy]
resources :follows, only: [:index]
resources :posts, only: [:index, :create, :show, :update, :destroy] do resource :likes, only: [:create, :destroy]
resource :follows, only: [:create, :destroy] resources :likes, only: [:index]
resources :follows, only: [:index] resources :comments, only: [:index, :create, :update, :destroy]
resource :likes, only: [:create, :destroy] resources :post_status_changes, only: [:index]
resources :likes, only: [:index] end
resources :comments, only: [:index, :create, :update, :destroy]
resources :post_status_changes, only: [:index] resources :boards, only: [:index, :create, :update, :destroy, :show] do
end patch 'update_order', on: :collection
end
resources :boards, only: [:index, :create, :update, :destroy, :show] do resources :post_statuses, only: [:index, :create, :update, :destroy] do
patch 'update_order', on: :collection patch 'update_order', on: :collection
end end
resources :post_statuses, only: [:index, :create, :update, :destroy] do namespace :site_settings do
patch 'update_order', on: :collection get 'general'
end get 'boards'
get 'post_statuses'
namespace :site_settings do get 'roadmap'
get 'general' get 'users'
get 'boards' end
get 'post_statuses'
get 'roadmap'
get 'users'
end end
end end

View File

@@ -0,0 +1,13 @@
class CreateTenants < ActiveRecord::Migration[6.0]
def change
create_table :tenants do |t|
t.string :site_name, null: false
t.string :site_logo
t.string :subdomain, null: false, unique: true
t.string :locale, default: "en"
t.string :custom_url
t.timestamps
end
end
end

View File

@@ -0,0 +1,23 @@
class AddTenantForeignKeyToModels < ActiveRecord::Migration[6.0]
def change
# Add tenant_id fk column to all models
add_reference :boards, :tenant, foreign_key: true, null: false
add_reference :post_statuses, :tenant, foreign_key: true, null: false
add_reference :posts, :tenant, foreign_key: true, null: false
add_reference :users, :tenant, foreign_key: true, null: false
add_reference :comments, :tenant, foreign_key: true, null: false
add_reference :likes, :tenant, foreign_key: true, null: false
add_reference :follows, :tenant, foreign_key: true, null: false
add_reference :post_status_changes, :tenant, foreign_key: true, null: false
# Change index of unique columns to double index with tenant_id
remove_index :boards, :name
add_index :boards, [:name, :tenant_id], unique: true
remove_index :post_statuses, :name
add_index :post_statuses, [:name, :tenant_id], unique: true
remove_index :users, :email
add_index :users, [:email, :tenant_id], unique: true
end
end

View File

@@ -0,0 +1,5 @@
class AddBrandDisplaySettingToTenants < ActiveRecord::Migration[6.0]
def change
add_column :tenants, :brand_display_setting, :integer, default: 0
end
end

View File

@@ -0,0 +1,5 @@
class AddStatusToTenants < ActiveRecord::Migration[6.0]
def change
add_column :tenants, :status, :integer
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_06_22_092039) do ActiveRecord::Schema.define(version: 2022_07_15_092725) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -21,7 +21,9 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.integer "order", null: false t.integer "order", null: false
t.index ["name"], name: "index_boards_on_name", unique: true t.bigint "tenant_id", null: false
t.index ["name", "tenant_id"], name: "index_boards_on_name_and_tenant_id", unique: true
t.index ["tenant_id"], name: "index_boards_on_tenant_id"
end end
create_table "comments", force: :cascade do |t| create_table "comments", force: :cascade do |t|
@@ -32,8 +34,10 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.boolean "is_post_update", default: false, null: false t.boolean "is_post_update", default: false, null: false
t.bigint "tenant_id", null: false
t.index ["parent_id"], name: "index_comments_on_parent_id" t.index ["parent_id"], name: "index_comments_on_parent_id"
t.index ["post_id"], name: "index_comments_on_post_id" t.index ["post_id"], name: "index_comments_on_post_id"
t.index ["tenant_id"], name: "index_comments_on_tenant_id"
t.index ["user_id"], name: "index_comments_on_user_id" t.index ["user_id"], name: "index_comments_on_user_id"
end end
@@ -42,7 +46,9 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.bigint "tenant_id", null: false
t.index ["post_id"], name: "index_follows_on_post_id" t.index ["post_id"], name: "index_follows_on_post_id"
t.index ["tenant_id"], name: "index_follows_on_tenant_id"
t.index ["user_id", "post_id"], name: "index_follows_on_user_id_and_post_id", unique: true t.index ["user_id", "post_id"], name: "index_follows_on_user_id_and_post_id", unique: true
t.index ["user_id"], name: "index_follows_on_user_id" t.index ["user_id"], name: "index_follows_on_user_id"
end end
@@ -52,7 +58,9 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.bigint "tenant_id", null: false
t.index ["post_id"], name: "index_likes_on_post_id" t.index ["post_id"], name: "index_likes_on_post_id"
t.index ["tenant_id"], name: "index_likes_on_tenant_id"
t.index ["user_id", "post_id"], name: "index_likes_on_user_id_and_post_id", unique: true t.index ["user_id", "post_id"], name: "index_likes_on_user_id_and_post_id", unique: true
t.index ["user_id"], name: "index_likes_on_user_id" t.index ["user_id"], name: "index_likes_on_user_id"
end end
@@ -63,8 +71,10 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do
t.bigint "post_status_id" t.bigint "post_status_id"
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.bigint "tenant_id", null: false
t.index ["post_id"], name: "index_post_status_changes_on_post_id" t.index ["post_id"], name: "index_post_status_changes_on_post_id"
t.index ["post_status_id"], name: "index_post_status_changes_on_post_status_id" t.index ["post_status_id"], name: "index_post_status_changes_on_post_status_id"
t.index ["tenant_id"], name: "index_post_status_changes_on_tenant_id"
t.index ["user_id"], name: "index_post_status_changes_on_user_id" t.index ["user_id"], name: "index_post_status_changes_on_user_id"
end end
@@ -75,7 +85,9 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.integer "order", null: false t.integer "order", null: false
t.boolean "show_in_roadmap", default: false, null: false t.boolean "show_in_roadmap", default: false, null: false
t.index ["name"], name: "index_post_statuses_on_name", unique: true t.bigint "tenant_id", null: false
t.index ["name", "tenant_id"], name: "index_post_statuses_on_name_and_tenant_id", unique: true
t.index ["tenant_id"], name: "index_post_statuses_on_tenant_id"
end end
create_table "posts", force: :cascade do |t| create_table "posts", force: :cascade do |t|
@@ -86,11 +98,25 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do
t.bigint "post_status_id" t.bigint "post_status_id"
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.bigint "tenant_id", null: false
t.index ["board_id"], name: "index_posts_on_board_id" t.index ["board_id"], name: "index_posts_on_board_id"
t.index ["post_status_id"], name: "index_posts_on_post_status_id" t.index ["post_status_id"], name: "index_posts_on_post_status_id"
t.index ["tenant_id"], name: "index_posts_on_tenant_id"
t.index ["user_id"], name: "index_posts_on_user_id" t.index ["user_id"], name: "index_posts_on_user_id"
end end
create_table "tenants", force: :cascade do |t|
t.string "site_name", null: false
t.string "site_logo"
t.string "subdomain", null: false
t.string "locale", default: "en"
t.string "custom_url"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.integer "brand_display_setting", default: 0
t.integer "status"
end
create_table "users", force: :cascade do |t| create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false t.string "encrypted_password", default: "", null: false
@@ -107,22 +133,32 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do
t.string "full_name" t.string "full_name"
t.boolean "notifications_enabled", default: true, null: false t.boolean "notifications_enabled", default: true, null: false
t.integer "status" t.integer "status"
t.bigint "tenant_id", null: false
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email", "tenant_id"], name: "index_users_on_email_and_tenant_id", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["tenant_id"], name: "index_users_on_tenant_id"
end end
add_foreign_key "boards", "tenants"
add_foreign_key "comments", "comments", column: "parent_id" add_foreign_key "comments", "comments", column: "parent_id"
add_foreign_key "comments", "posts" add_foreign_key "comments", "posts"
add_foreign_key "comments", "tenants"
add_foreign_key "comments", "users" add_foreign_key "comments", "users"
add_foreign_key "follows", "posts" add_foreign_key "follows", "posts"
add_foreign_key "follows", "tenants"
add_foreign_key "follows", "users" add_foreign_key "follows", "users"
add_foreign_key "likes", "posts" add_foreign_key "likes", "posts"
add_foreign_key "likes", "tenants"
add_foreign_key "likes", "users" add_foreign_key "likes", "users"
add_foreign_key "post_status_changes", "post_statuses" add_foreign_key "post_status_changes", "post_statuses"
add_foreign_key "post_status_changes", "posts" add_foreign_key "post_status_changes", "posts"
add_foreign_key "post_status_changes", "tenants"
add_foreign_key "post_status_changes", "users" add_foreign_key "post_status_changes", "users"
add_foreign_key "post_statuses", "tenants"
add_foreign_key "posts", "boards" add_foreign_key "posts", "boards"
add_foreign_key "posts", "post_statuses" add_foreign_key "posts", "post_statuses"
add_foreign_key "posts", "tenants"
add_foreign_key "posts", "users" add_foreign_key "posts", "users"
add_foreign_key "users", "tenants"
end end

View File

@@ -1,3 +1,11 @@
# Create tenant
tenant = Tenant.create(
site_name: 'Default site name',
subdomain: 'default',
status: 'active'
)
Current.tenant = tenant
# Create an admin user and confirm its email automatically # Create an admin user and confirm its email automatically
admin = User.create( admin = User.create(
full_name: 'Admin', full_name: 'Admin',
@@ -10,7 +18,7 @@ admin = User.create(
# Create some boards # Create some boards
feature_board = Board.create( feature_board = Board.create(
name: 'Feature Requests', name: 'Feature Requests',
description: 'This is a **board**! You can create as many as you want from **site settings** and they can be *Markdown formatted*.', description: 'This is a **board**! You can create as many as you want from **site settings** and their description can be *Markdown formatted*.',
order: 0 order: 0
) )
bug_board = Board.create( bug_board = Board.create(
@@ -47,30 +55,33 @@ rejected_post_status = PostStatus.create(
# Create some posts # Create some posts
post1 = Post.create( post1 = Post.create(
title: 'This is how users give you feedback', title: 'Users can submit feedback by publishing posts!',
description: 'They can also provide an extendend description like this... bla bla...', description: 'You can assign a **status** to each post: this one, for example, is marked as "Planned". Remember that you can customise post statuses from Site settings > Statuses',
board_id: feature_board.id,
user_id: admin.id
)
post2 = Post.create(
title: 'You can assign a status to each post',
description: 'This one, for example, is marked as "Planned"',
board_id: feature_board.id, board_id: feature_board.id,
user_id: admin.id, user_id: admin.id,
post_status_id: planned_post_status.id post_status_id: planned_post_status.id
) )
post3 = Post.create( PostStatusChange.create(
title: 'There are multiple boards', post_id: post1.id,
description: 'For now you have Feature Requests and Bug Reports, but you can add or remove as many as you want!',
board_id: bug_board.id,
user_id: admin.id, user_id: admin.id,
post_status_id: in_progress_post_status.id post_status_id: planned_post_status.id
) )
# Create some comments post2 = Post.create(
post1.comments.create(body: 'Users can comment to express their opinions!', user_id: admin.id) title: 'There are multiple boards',
description: 'For now you have Feature Requests and Bug Reports, but you can add or remove as many as you want! Just go to Site settings > Boards!',
board_id: bug_board.id,
user_id: admin.id
)
# # Create some comments
post1.comments.create(
body: 'Users can comment to express their opinions! As with posts and board descriptions, comments can be *Markdown* **formatted**',
user_id: admin.id
)
# Let the user know how to log in with admin account # Let the user know how to log in with admin account
puts "A default tenant has been created with name #{tenant.site_name}"
puts 'A default admin account has been created. Credentials:' puts 'A default admin account has been created. Credentials:'
puts "-> email: #{admin.email}" puts "-> email: #{admin.email}"
puts "-> password: #{admin.password}" puts "-> password: #{admin.password}"

View File

@@ -18,9 +18,7 @@ services:
- POSTGRES_USER - POSTGRES_USER
- POSTGRES_PASSWORD - POSTGRES_PASSWORD
- EMAIL_CONFIRMATION - EMAIL_CONFIRMATION
- APP_NAME - MULTI_TENANCY
- SHOW_LOGO
- POSTS_PER_PAGE
volumes: volumes:
- .:/astuto - .:/astuto
ports: ports:

View File

@@ -0,0 +1,9 @@
FactoryBot.define do
factory :tenant do
site_name { "MySiteName" }
site_logo { "" }
sequence(:subdomain) { |n| "mysubdomain#{n}" }
locale { "en" }
custom_url { "" }
end
end

View File

@@ -8,8 +8,8 @@ RSpec.describe UserMailer, type: :mailer do
let(:mail) { UserMailer.notify_post_owner(comment: comment) } let(:mail) { UserMailer.notify_post_owner(comment: comment) }
it "renders the headers" do it "renders the headers" do
expect(mail.to).to eq(["notified@example.com"]) expect(mail.to).to eq([user.email])
expect(mail.from).to eq(["notifications@example.com"]) expect(mail.from).to eq(["notifications@astuto.io"])
end end
it "renders the user name, post title, replier name and comment body" do it "renders the user name, post title, replier name and comment body" do

View File

@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Tenant, type: :model do
let(:tenant) { FactoryBot.build(:tenant) }
it 'should be valid' do
expect(tenant).to be_valid
end
it 'has status "pending" by default' do
expect(Tenant.new.status).to eq('pending')
end
it 'has a status of "active", "pending" or "blocked"' do
tenant.status = 'active'
expect(tenant).to be_valid
tenant.status = 'pending'
expect(tenant).to be_valid
tenant.status = 'blocked'
expect(tenant).to be_valid
end
it 'has a non-empty site name' do
tenant.site_name = ''
expect(tenant).to be_invalid
end
it 'has a non-empty and unique subdomain' do
tenant.subdomain = ''
expect(tenant).to be_invalid
tenant2 = FactoryBot.create(:tenant)
tenant.subdomain = tenant2.subdomain
expect(tenant).to be_invalid
end
end

View File

@@ -1,61 +0,0 @@
require 'rails_helper'
RSpec.describe 'requests to posts controller', type: :request do
let(:user) { FactoryBot.create(:user) }
let(:moderator) { FactoryBot.create(:moderator) }
let(:admin) { FactoryBot.create(:admin) }
let(:p) { FactoryBot.create(:post) }
let(:board) { FactoryBot.build_stubbed(:board) }
let(:headers) { headers = { "ACCEPT" => "application/json" } }
context 'when user is not logged in' do
it 'fulfills index action' do
get posts_path
expect(response).to have_http_status(:success)
end
it 'fulfills show action' do
get posts_path(p)
expect(response).to have_http_status(:success)
end
it 'blocks create action' do
post posts_path, params: { post: { title: p.title } }, headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'blocks update action' do
patch post_path(p), params: { post: { title: p.title } }, headers: headers
expect(response).to have_http_status(:unauthorized)
end
end
context 'user role' do
before(:each) do
user.confirm
sign_in user
end
# it 'fulfills create action' do
# post posts_path, params: { post: { title: p.title, board_id: board.id, user_id: user.id } }, headers: headers
# expect(response).to have_http_status(:success)
# end
it 'blocks update action if from different user' do
expect(user.id).not_to eq(p.user_id)
patch post_path(p), params: { post: { title: p.title } }, headers: headers
expect(response).to have_http_status(:unauthorized)
end
end
context 'moderator role' do
before(:each) do
moderator.confirm
sign_in moderator
end
it 'fulfills update action' do
expect(user.id).not_to eq(p.user_id)
patch post_path(p), params: { post: { title: p.title } }, headers: headers
expect(response).to have_http_status(:success)
end
end
end

View File

@@ -0,0 +1,35 @@
require 'rails_helper'
RSpec.describe 'site settings routing', :aggregate_failures, type: :routing do
let (:base_url) { '/site_settings' }
it 'routes general' do
expect(get: base_url + '/general').to route_to(
controller: 'site_settings', action: 'general'
)
end
it 'routes boards' do
expect(get: base_url + '/boards').to route_to(
controller: 'site_settings', action: 'boards'
)
end
it 'routes post statuses' do
expect(get: base_url + '/post_statuses').to route_to(
controller: 'site_settings', action: 'post_statuses'
)
end
it 'routes roadmap' do
expect(get: base_url + '/roadmap').to route_to(
controller: 'site_settings', action: 'roadmap'
)
end
it 'routes users' do
expect(get: base_url + '/users').to route_to(
controller: 'site_settings', action: 'users'
)
end
end

View File

@@ -10,4 +10,16 @@ RSpec.describe 'static pages routing', :aggregate_failures, type: :routing do
controller: 'static_pages', action: 'roadmap' controller: 'static_pages', action: 'roadmap'
) )
end end
it 'routes pending tenant page' do
expect(get: '/pending-tenant').to route_to(
controller: 'static_pages', action: 'pending_tenant'
)
end
it 'routes blocked tenant page' do
expect(get: '/blocked-tenant').to route_to(
controller: 'static_pages', action: 'blocked_tenant'
)
end
end end

View File

@@ -14,6 +14,16 @@
# #
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config| RSpec.configure do |config|
# Reset Current instance and delete all tenants from test db
# Create a new default tenant and set Current.tenant
config.before(:all) do
Current.reset
Tenant.delete_all
tenant = FactoryBot.create(:tenant)
Current.tenant = tenant
end
# rspec-expectations config goes here. You can use an alternate # rspec-expectations config goes here. You can use an alternate
# assertion/expectation library such as wrong or the stdlib/minitest # assertion/expectation library such as wrong or the stdlib/minitest
# assertions if you prefer. # assertions if you prefer.

View File

@@ -1,34 +0,0 @@
require 'rails_helper'
RSpec.describe 'header', type: :view do
let(:board1) { FactoryBot.create(:board) }
let(:board2) { FactoryBot.create(:board) }
def render_header
render partial: 'layouts/header'
end
it 'renders a logo' do
render_header
expect(rendered).to have_selector('.brand')
end
it 'renders a link for each board' do
@boards = [board1, board2]
render_header
expect(rendered).to have_content(board1.name)
expect(rendered).to have_content(board2.name)
end
it 'applies "active" class to the active board link' do
@boards = [board1, board2]
@board = board1 # active board is board1
render_header
expect(rendered).to have_selector('.active', count: 1)
end
end