diff --git a/.env-example b/.env-example index e58c8ff1..24f6b18e 100644 --- a/.env-example +++ b/.env-example @@ -9,8 +9,4 @@ SECRET_KEY_BASE=secretkeybasehere POSTGRES_USER=yourusernamehere POSTGRES_PASSWORD=yourpasswordhere -APP_NAME=You App Name Here -SHOW_LOGO=yes -POSTS_PER_PAGE=15 - -EMAIL_CONFIRMATION=no \ No newline at end of file +EMAIL_CONFIRMATION=false \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7cc9bbe4..594048bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,10 @@ The project is broadly structured as follows: - `schema.rb`: database schema - `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) Tests are done using RSpec, a testing framework for Ruby on Rails: diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3d32aed3..11f0694a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,7 +4,7 @@ class ApplicationController < ActionController::Base rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized before_action :configure_permitted_parameters, if: :devise_controller? - before_action :load_boards + prepend_before_action :load_tenant_data protected @@ -15,8 +15,36 @@ class ApplicationController < ActionController::Base devise_parameter_sanitizer.permit(:account_update, keys: additional_permitted_parameters) 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) + + # Setup locale + I18n.locale = @tenant.locale end private diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index d6833751..3ad9264e 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -1,4 +1,7 @@ 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 def destroy resource.status = "deleted" diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 00000000..876c25fc --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 4f02ca0c..bd05e8a0 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -1,4 +1,6 @@ class StaticPagesController < ApplicationController + skip_before_action :load_tenant_data, only: [:showcase, :pending_tenant, :blocked_tenant] + def roadmap @post_statuses = PostStatus .find_roadmap @@ -8,4 +10,14 @@ class StaticPagesController < ApplicationController .find_with_post_status_in(@post_statuses) .select(:id, :title, :board_id, :post_status_id, :user_id, :created_at) end + + def showcase + render html: 'Showcase home page.' + end + + def pending_tenant + end + + def blocked_tenant + end end \ No newline at end of file diff --git a/app/controllers/tenants_controller.rb b/app/controllers/tenants_controller.rb new file mode 100644 index 00000000..e16de276 --- /dev/null +++ b/app/controllers/tenants_controller.rb @@ -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 \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ce75b1b4..62912b1a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -27,4 +27,10 @@ module ApplicationHelper return 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 diff --git a/app/javascript/actions/Tenant/requestTenant.ts b/app/javascript/actions/Tenant/requestTenant.ts new file mode 100644 index 00000000..705a7592 --- /dev/null +++ b/app/javascript/actions/Tenant/requestTenant.ts @@ -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> => ( + async (dispatch) => { + dispatch(tenantRequestStart()); + + try { + const response = await fetch('/tenants/0'); + const json = await response.json(); + + dispatch(tenantRequestSuccess(json)); + } catch (e) { + dispatch(tenantRequestFailure(e)); + } + } +); \ No newline at end of file diff --git a/app/javascript/actions/Tenant/submitTenant.ts b/app/javascript/actions/Tenant/submitTenant.ts new file mode 100644 index 00000000..eb128662 --- /dev/null +++ b/app/javascript/actions/Tenant/submitTenant.ts @@ -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> => 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)); + } +}; \ No newline at end of file diff --git a/app/javascript/actions/Tenant/tenantSignUpFormActions.ts b/app/javascript/actions/Tenant/tenantSignUpFormActions.ts new file mode 100644 index 00000000..ea50d9a9 --- /dev/null +++ b/app/javascript/actions/Tenant/tenantSignUpFormActions.ts @@ -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; \ No newline at end of file diff --git a/app/javascript/actions/Tenant/updateTenant.ts b/app/javascript/actions/Tenant/updateTenant.ts new file mode 100644 index 00000000..9ff3ed14 --- /dev/null +++ b/app/javascript/actions/Tenant/updateTenant.ts @@ -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> => 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); + } +}; \ No newline at end of file diff --git a/app/javascript/actions/changeSiteSettingsGeneralForm.ts b/app/javascript/actions/changeSiteSettingsGeneralForm.ts new file mode 100644 index 00000000..0146612b --- /dev/null +++ b/app/javascript/actions/changeSiteSettingsGeneralForm.ts @@ -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; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Boards/BoardForm.tsx b/app/javascript/components/SiteSettings/Boards/BoardForm.tsx index 4c624b0d..39087b85 100644 --- a/app/javascript/components/SiteSettings/Boards/BoardForm.tsx +++ b/app/javascript/components/SiteSettings/Boards/BoardForm.tsx @@ -80,18 +80,22 @@ class BoardForm extends React.Component { const {name, description} = this.state; return ( -
+
this.onNameChange(e.target.value)} + autoFocus className="form-control" />
+
); } } diff --git a/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx b/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx new file mode 100644 index 00000000..e89da530 --- /dev/null +++ b/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx @@ -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; + + handleChangeSiteName(siteName: string): void; + handleChangeSiteLogo(siteLogo: string): void; + handleChangeBrandDisplaySetting(brandDisplaySetting: string) + handleChangeLocale(locale: string): void; +} + +class GeneralSiteSettingsP extends React.Component { + 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 ( + <> + +

{ I18n.t('site_settings.general.title') }

+ +
+
+
+ + handleChangeSiteName(e.target.value)} + id="siteName" + className="formControl" + /> +
+ +
+ + handleChangeSiteLogo(e.target.value)} + id="siteLogo" + className="formControl" + /> +
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ + + + ); + } +} + +export default GeneralSiteSettingsP; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/General/index.tsx b/app/javascript/components/SiteSettings/General/index.tsx new file mode 100644 index 00000000..1f838d97 --- /dev/null +++ b/app/javascript/components/SiteSettings/General/index.tsx @@ -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 { + store: Store; + + constructor(props: Props) { + super(props); + + this.store = createStoreHelper(); + } + + render() { + return ( + + + + ); + } +} + +export default GeneralSiteSettingsRoot; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/PostStatuses/PostStatusForm.tsx b/app/javascript/components/SiteSettings/PostStatuses/PostStatusForm.tsx index 5d226678..d596dadc 100644 --- a/app/javascript/components/SiteSettings/PostStatuses/PostStatusForm.tsx +++ b/app/javascript/components/SiteSettings/PostStatuses/PostStatusForm.tsx @@ -87,12 +87,13 @@ class PostStatusForm extends React.Component { const {name, color} = this.state; return ( -
+
this.onNameChange(e.target.value)} + autoFocus className="form-control" /> @@ -104,7 +105,10 @@ class PostStatusForm extends React.Component { /> -
+ ); } } diff --git a/app/javascript/components/TenantSignUp/ConfirmSignUpPage.tsx b/app/javascript/components/TenantSignUp/ConfirmSignUpPage.tsx new file mode 100644 index 00000000..f0b281f5 --- /dev/null +++ b/app/javascript/components/TenantSignUp/ConfirmSignUpPage.tsx @@ -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) => ( + +

{ I18n.t('signup.step3.title') }

+ +

{ I18n.t('signup.step3.message', { email: userEmail, subdomain: `${subdomain}.astuto.io` }) }

+
+); + +export default ConfirmSignUpPage; \ No newline at end of file diff --git a/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx b/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx new file mode 100644 index 00000000..d4a53a4e --- /dev/null +++ b/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx @@ -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 { + form: any; + + constructor(props: Props) { + super(props); + + this.form = React.createRef(); + } + + render() { + const { + tenantForm, + handleChangeTenantSiteName, + handleChangeTenantSubdomain, + + isSubmitting, + error, + handleSubmit, + } = this.props; + + return ( + +

{ I18n.t('signup.step2.title') }

+ +
+
+ handleChangeTenantSiteName(e.target.value)} + placeholder={I18n.t('signup.step2.site_name')} + required + id="tenantSiteName" + className="formControl" + /> +
+ +
+
+ handleChangeTenantSubdomain(e.target.value)} + placeholder={I18n.t('signup.step2.subdomain')} + required + id="tenantSubdomain" + className="formControl" + /> +
+
.astuto.io
+
+
+
+ + + + { error !== '' && { error } } +
+
+ ); + } +} + +export default TenantSignUpForm; \ No newline at end of file diff --git a/app/javascript/components/TenantSignUp/TenantSignUpP.tsx b/app/javascript/components/TenantSignUp/TenantSignUpP.tsx new file mode 100644 index 00000000..0d472693 --- /dev/null +++ b/app/javascript/components/TenantSignUp/TenantSignUpP.tsx @@ -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 { + 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 ( +
+ { + (currentStep === 1 || currentStep === 2) && + + } + + { + currentStep === 2 && + + } + + { + currentStep === 3 && + + } +
+ ); + } +} + +export default TenantSignUpP; \ No newline at end of file diff --git a/app/javascript/components/TenantSignUp/UserSignUpForm.tsx b/app/javascript/components/TenantSignUp/UserSignUpForm.tsx new file mode 100644 index 00000000..bac78c27 --- /dev/null +++ b/app/javascript/components/TenantSignUp/UserSignUpForm.tsx @@ -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 { + 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 ( + +

{ I18n.t('signup.step1.title') }

+ + { + currentStep === 1 && !emailAuth && + + } + + { + currentStep === 1 && emailAuth && +
+
+ handleChangeUserFullName(e.target.value)} + placeholder={I18n.t('common.forms.auth.full_name')} + required + id="userFullName" + className="formControl" + /> +
+ +
+ handleChangeUserEmail(e.target.value)} + placeholder={I18n.t('common.forms.auth.email')} + required + id="userEmail" + className="formControl" + /> +
+ +
+
+ handleChangeUserPassword(e.target.value)} + placeholder={I18n.t('common.forms.auth.password')} + required + minLength={6} + maxLength={128} + id="userPassword" + className="formControl" + /> +
+ +
+ handleChangeUserPasswordConfirmation(e.target.value)} + placeholder={I18n.t('common.forms.auth.password_confirmation')} + required + minLength={6} + maxLength={128} + id="userPasswordConfirmation" + className={`formControl${userForm.passwordConfirmationError ? ' invalid' : ''}`} + /> +
+
+ + +
+ } + + { + currentStep === 2 && +

{userForm.fullName} ({userForm.email})

+ } +
+ ); + } +} + +export default UserSignUpForm; \ No newline at end of file diff --git a/app/javascript/components/TenantSignUp/index.tsx b/app/javascript/components/TenantSignUp/index.tsx new file mode 100644 index 00000000..11183979 --- /dev/null +++ b/app/javascript/components/TenantSignUp/index.tsx @@ -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 { + store: Store; + + constructor(props: Props) { + super(props); + + this.store = createStoreHelper(); + } + + render() { + const { authenticityToken } = this.props; + + return ( + + + + ); + } +} + +export default TenantSignUpRoot; \ No newline at end of file diff --git a/app/javascript/components/common/SiteSettingsInfoBox.tsx b/app/javascript/components/common/SiteSettingsInfoBox.tsx index 684f4086..78d17c94 100644 --- a/app/javascript/components/common/SiteSettingsInfoBox.tsx +++ b/app/javascript/components/common/SiteSettingsInfoBox.tsx @@ -7,9 +7,10 @@ import Box from './Box'; interface Props { areUpdating: boolean; error: string; + areDirty?: boolean; } -const SiteSettingsInfoBox = ({ areUpdating, error }: Props) => ( +const SiteSettingsInfoBox = ({ areUpdating, error, areDirty = false }: Props) => ( { areUpdating ? @@ -17,10 +18,13 @@ const SiteSettingsInfoBox = ({ areUpdating, error }: Props) => ( : error ? - {I18n.t('site_settings.info_box.error', { message: JSON.stringify(error) })} + { I18n.t('site_settings.info_box.error', { message: JSON.stringify(error) }) } : - {I18n.t('site_settings.info_box.up_to_date')} + areDirty ? + { I18n.t('site_settings.info_box.dirty') } + : + { I18n.t('site_settings.info_box.up_to_date') } } ); diff --git a/app/javascript/constants/index.js b/app/javascript/constants/index.js index e0d254d4..9de6d3d9 100644 --- a/app/javascript/constants/index.js +++ b/app/javascript/constants/index.js @@ -1 +1 @@ -export const POSTS_PER_PAGE = parseInt(process.env.POSTS_PER_PAGE); \ No newline at end of file +export const POSTS_PER_PAGE = 15; \ No newline at end of file diff --git a/app/javascript/containers/GeneralSiteSettings.tsx b/app/javascript/containers/GeneralSiteSettings.tsx new file mode 100644 index 00000000..8ff1196f --- /dev/null +++ b/app/javascript/containers/GeneralSiteSettings.tsx @@ -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 { + 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); \ No newline at end of file diff --git a/app/javascript/containers/TenantSignUp.tsx b/app/javascript/containers/TenantSignUp.tsx new file mode 100644 index 00000000..ac3c7ff0 --- /dev/null +++ b/app/javascript/containers/TenantSignUp.tsx @@ -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); \ No newline at end of file diff --git a/app/javascript/interfaces/ITenant.ts b/app/javascript/interfaces/ITenant.ts new file mode 100644 index 00000000..369d691e --- /dev/null +++ b/app/javascript/interfaces/ITenant.ts @@ -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; \ No newline at end of file diff --git a/app/javascript/interfaces/json/ITenant.ts b/app/javascript/interfaces/json/ITenant.ts new file mode 100644 index 00000000..d39dd2ba --- /dev/null +++ b/app/javascript/interfaces/json/ITenant.ts @@ -0,0 +1,9 @@ +interface ITenantJSON { + id: number; + site_name: string; + site_logo: string; + brand_display_setting: string; + locale: string; +} + +export default ITenantJSON; \ No newline at end of file diff --git a/app/javascript/reducers/SiteSettings/generalReducer.ts b/app/javascript/reducers/SiteSettings/generalReducer.ts new file mode 100644 index 00000000..d990c9e6 --- /dev/null +++ b/app/javascript/reducers/SiteSettings/generalReducer.ts @@ -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; \ No newline at end of file diff --git a/app/javascript/reducers/SiteSettings/postStatusesReducer.ts b/app/javascript/reducers/SiteSettings/postStatusesReducer.ts index 9214e01c..1abb857f 100644 --- a/app/javascript/reducers/SiteSettings/postStatusesReducer.ts +++ b/app/javascript/reducers/SiteSettings/postStatusesReducer.ts @@ -1,3 +1,10 @@ +import { + PostStatusesRequestActionTypes, + POST_STATUSES_REQUEST_START, + POST_STATUSES_REQUEST_SUCCESS, + POST_STATUSES_REQUEST_FAILURE, +} from '../../actions/PostStatus/requestPostStatuses'; + import { PostStatusOrderUpdateActionTypes, POSTSTATUS_ORDER_UPDATE_START, @@ -38,12 +45,15 @@ const initialState: SiteSettingsPostStatusesState = { const siteSettingsPostStatusesReducer = ( state = initialState, - action: PostStatusOrderUpdateActionTypes | + action: + PostStatusesRequestActionTypes | + PostStatusOrderUpdateActionTypes | PostStatusDeleteActionTypes | PostStatusSubmitActionTypes | PostStatusUpdateActionTypes ): SiteSettingsPostStatusesState => { switch (action.type) { + case POST_STATUSES_REQUEST_START: case POSTSTATUS_SUBMIT_START: case POSTSTATUS_UPDATE_START: case POSTSTATUS_ORDER_UPDATE_START: @@ -53,6 +63,7 @@ const siteSettingsPostStatusesReducer = ( areUpdating: true, }; + case POST_STATUSES_REQUEST_SUCCESS: case POSTSTATUS_SUBMIT_SUCCESS: case POSTSTATUS_UPDATE_SUCCESS: case POSTSTATUS_ORDER_UPDATE_SUCCESS: @@ -63,6 +74,7 @@ const siteSettingsPostStatusesReducer = ( error: '', }; + case POST_STATUSES_REQUEST_FAILURE: case POSTSTATUS_SUBMIT_FAILURE: case POSTSTATUS_UPDATE_FAILURE: case POSTSTATUS_ORDER_UPDATE_FAILURE: diff --git a/app/javascript/reducers/rootReducer.ts b/app/javascript/reducers/rootReducer.ts index 2e3c5d5c..748d0f3d 100644 --- a/app/javascript/reducers/rootReducer.ts +++ b/app/javascript/reducers/rootReducer.ts @@ -1,5 +1,7 @@ import { combineReducers } from 'redux'; +import tenantSignUpReducer from './tenantSignUpReducer'; + import postsReducer from './postsReducer'; import boardsReducer from './boardsReducer'; import postStatusesReducer from './postStatusesReducer'; @@ -8,6 +10,8 @@ import currentPostReducer from './currentPostReducer'; import siteSettingsReducer from './siteSettingsReducer'; const rootReducer = combineReducers({ + tenantSignUp: tenantSignUpReducer, + posts: postsReducer, boards: boardsReducer, postStatuses: postStatusesReducer, diff --git a/app/javascript/reducers/siteSettingsReducer.ts b/app/javascript/reducers/siteSettingsReducer.ts index 76c12e24..e5e78a7b 100644 --- a/app/javascript/reducers/siteSettingsReducer.ts +++ b/app/javascript/reducers/siteSettingsReducer.ts @@ -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 { BoardsRequestActionTypes, BOARDS_REQUEST_START, @@ -33,6 +55,13 @@ import { BOARD_DELETE_FAILURE, } from '../actions/Board/deleteBoard'; +import { + PostStatusesRequestActionTypes, + POST_STATUSES_REQUEST_START, + POST_STATUSES_REQUEST_SUCCESS, + POST_STATUSES_REQUEST_FAILURE, +} from '../actions/PostStatus/requestPostStatuses'; + import { PostStatusOrderUpdateActionTypes, POSTSTATUS_ORDER_UPDATE_START, @@ -75,12 +104,14 @@ import { USER_UPDATE_FAILURE, } from '../actions/User/updateUser'; +import siteSettingsGeneralReducer, { SiteSettingsGeneralState } from './SiteSettings/generalReducer'; import siteSettingsBoardsReducer, { SiteSettingsBoardsState } from './SiteSettings/boardsReducer'; import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer'; import siteSettingsRoadmapReducer, { SiteSettingsRoadmapState } from './SiteSettings/roadmapReducer'; import siteSettingsUsersReducer, { SiteSettingsUsersState } from './SiteSettings/usersReducer'; interface SiteSettingsState { + general: SiteSettingsGeneralState; boards: SiteSettingsBoardsState; postStatuses: SiteSettingsPostStatusesState; roadmap: SiteSettingsRoadmapState; @@ -88,6 +119,7 @@ interface SiteSettingsState { } const initialState: SiteSettingsState = { + general: siteSettingsGeneralReducer(undefined, {} as any), boards: siteSettingsBoardsReducer(undefined, {} as any), postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any), roadmap: siteSettingsRoadmapReducer(undefined, {} as any), @@ -97,11 +129,15 @@ const initialState: SiteSettingsState = { const siteSettingsReducer = ( state = initialState, action: + TenantRequestActionTypes | + TenantUpdateActionTypes | + ChangeSiteSettingsGeneralFormActionTypes | BoardsRequestActionTypes | BoardSubmitActionTypes | BoardUpdateActionTypes | BoardOrderUpdateActionTypes | BoardDeleteActionTypes | + PostStatusesRequestActionTypes | PostStatusOrderUpdateActionTypes | PostStatusDeleteActionTypes | PostStatusSubmitActionTypes | @@ -110,6 +146,21 @@ const siteSettingsReducer = ( UserUpdateActionTypes ): SiteSettingsState => { 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_SUCCESS: case BOARDS_REQUEST_FAILURE: @@ -130,6 +181,9 @@ const siteSettingsReducer = ( 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_SUCCESS: case POSTSTATUS_SUBMIT_FAILURE: diff --git a/app/javascript/reducers/tenantSignUpReducer.ts b/app/javascript/reducers/tenantSignUpReducer.ts new file mode 100644 index 00000000..e3406c2d --- /dev/null +++ b/app/javascript/reducers/tenantSignUpReducer.ts @@ -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; \ No newline at end of file diff --git a/app/javascript/stylesheets/common/_form.scss b/app/javascript/stylesheets/common/_form.scss index 207ec568..14a0d082 100644 --- a/app/javascript/stylesheets/common/_form.scss +++ b/app/javascript/stylesheets/common/_form.scss @@ -36,6 +36,25 @@ 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 { @extend .custom-control-input; diff --git a/app/javascript/stylesheets/common/_header.scss b/app/javascript/stylesheets/common/_header.scss index b0fd3b08..8c715351 100644 --- a/app/javascript/stylesheets/common/_header.scss +++ b/app/javascript/stylesheets/common/_header.scss @@ -30,7 +30,7 @@ .align-top, .mr-2; - width: 36px; + height: 36px; } } diff --git a/app/javascript/stylesheets/common/_index.scss b/app/javascript/stylesheets/common/_index.scss index e9f153e9..bb00ec50 100644 --- a/app/javascript/stylesheets/common/_index.scss +++ b/app/javascript/stylesheets/common/_index.scss @@ -142,6 +142,11 @@ max-width: 960px; } +.smallContainer { + max-width: 540px; + margin: 16px auto; +} + .turbolinks-progress-bar { background-color: $primary-color; height: 2px; diff --git a/app/javascript/stylesheets/components/SiteSettings/PostStatuses/index.scss b/app/javascript/stylesheets/components/SiteSettings/PostStatuses/index.scss index b2ca7ccb..ca3d33f7 100644 --- a/app/javascript/stylesheets/components/SiteSettings/PostStatuses/index.scss +++ b/app/javascript/stylesheets/components/SiteSettings/PostStatuses/index.scss @@ -26,6 +26,7 @@ .postStatusForm { @extend .d-flex, + .flex-grow-1, .m-2; column-gap: 8px; diff --git a/app/javascript/stylesheets/components/SiteSettings/index.scss b/app/javascript/stylesheets/components/SiteSettings/index.scss index 9ca4c2f7..2c7ddad4 100644 --- a/app/javascript/stylesheets/components/SiteSettings/index.scss +++ b/app/javascript/stylesheets/components/SiteSettings/index.scss @@ -4,4 +4,8 @@ .error { color: red; } + + .warning { + color: #fd7e14; + } } \ No newline at end of file diff --git a/app/javascript/stylesheets/components/TenantSignUp.scss b/app/javascript/stylesheets/components/TenantSignUp.scss new file mode 100644 index 00000000..540ee519 --- /dev/null +++ b/app/javascript/stylesheets/components/TenantSignUp.scss @@ -0,0 +1,3 @@ +.tenantSignUpContainer { + @extend .smallContainer; +} \ No newline at end of file diff --git a/app/javascript/stylesheets/main.scss b/app/javascript/stylesheets/main.scss index ac29d6f7..6534b368 100644 --- a/app/javascript/stylesheets/main.scss +++ b/app/javascript/stylesheets/main.scss @@ -9,6 +9,7 @@ @import 'common/scroll_shadows'; /* Components */ +@import 'components/TenantSignUp'; @import 'components/Board'; @import 'components/Comments'; @import 'components/LikeButton'; diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index c3d20960..cb3b089b 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,5 @@ class ApplicationMailer < ActionMailer::Base - default from: "notifications@example.com" + default from: "notifications@astuto.io" layout 'mailer' + helper :application end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index f0373485..74c67079 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -2,6 +2,8 @@ class UserMailer < ApplicationMailer layout 'user_mailer' def notify_post_owner(comment:) + @tenant = comment.tenant + Current.tenant = @tenant @comment = comment @user = comment.post.user @@ -12,6 +14,8 @@ class UserMailer < ApplicationMailer end def notify_comment_owner(comment:) + @tenant = comment.tenant + Current.tenant = @tenant @comment = comment @user = comment.parent.user @@ -22,6 +26,8 @@ class UserMailer < ApplicationMailer end def notify_followers_of_post_update(comment:) + @tenant = comment.tenant + Current.tenant = @tenant @comment = comment mail( @@ -31,6 +37,8 @@ class UserMailer < ApplicationMailer end def notify_followers_of_post_status_change(post:) + @tenant = post.tenant + Current.tenant = @tenant @post = post mail( @@ -42,6 +50,6 @@ class UserMailer < ApplicationMailer private def app_name - ENV.fetch('APP_NAME') + Current.tenant_or_raise!.site_name end end \ No newline at end of file diff --git a/app/models/board.rb b/app/models/board.rb index ee8da75e..b1ac1b3f 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -1,8 +1,9 @@ class Board < ApplicationRecord + include TenantOwnable include Orderable 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 end diff --git a/app/models/comment.rb b/app/models/comment.rb index 03ae60e2..5b88820b 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,4 +1,6 @@ class Comment < ApplicationRecord + include TenantOwnable + belongs_to :user belongs_to :post belongs_to :parent, class_name: 'Comment', optional: true diff --git a/app/models/concerns/Orderable.rb b/app/models/concerns/Orderable.rb index 2114b5b8..b7c778b2 100644 --- a/app/models/concerns/Orderable.rb +++ b/app/models/concerns/Orderable.rb @@ -20,7 +20,7 @@ module Orderable def set_order_to_last return unless new_record? return unless order.nil? - + order_last = self.class.maximum(:order) || -1 self.order = order_last + 1 end diff --git a/app/models/concerns/TenantOwnable.rb b/app/models/concerns/TenantOwnable.rb new file mode 100644 index 00000000..e4349b46 --- /dev/null +++ b/app/models/concerns/TenantOwnable.rb @@ -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 \ No newline at end of file diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 00000000..1817cfda --- /dev/null +++ b/app/models/current.rb @@ -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 \ No newline at end of file diff --git a/app/models/follow.rb b/app/models/follow.rb index 026ac3b5..6227c61d 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -1,4 +1,6 @@ class Follow < ApplicationRecord + include TenantOwnable + belongs_to :user belongs_to :post diff --git a/app/models/like.rb b/app/models/like.rb index 8c97e474..3e6bb23a 100644 --- a/app/models/like.rb +++ b/app/models/like.rb @@ -1,4 +1,6 @@ class Like < ApplicationRecord + include TenantOwnable + belongs_to :user belongs_to :post diff --git a/app/models/post.rb b/app/models/post.rb index 7691fcb1..2593f5f6 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1,4 +1,6 @@ class Post < ApplicationRecord + include TenantOwnable + belongs_to :board belongs_to :user belongs_to :post_status, optional: true diff --git a/app/models/post_status.rb b/app/models/post_status.rb index b4ef1f3a..40180489 100644 --- a/app/models/post_status.rb +++ b/app/models/post_status.rb @@ -1,9 +1,10 @@ class PostStatus < ApplicationRecord + include TenantOwnable include Orderable 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/ } class << self diff --git a/app/models/post_status_change.rb b/app/models/post_status_change.rb index 4d28d888..82c8e927 100644 --- a/app/models/post_status_change.rb +++ b/app/models/post_status_change.rb @@ -1,4 +1,6 @@ class PostStatusChange < ApplicationRecord + include TenantOwnable + belongs_to :user belongs_to :post belongs_to :post_status, optional: true diff --git a/app/models/tenant.rb b/app/models/tenant.rb new file mode 100644 index 00000000..3bca9d13 --- /dev/null +++ b/app/models/tenant.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 90270746..72528a83 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,7 +1,8 @@ class User < ApplicationRecord + include TenantOwnable + devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :validatable, - :confirmable + :recoverable, :rememberable, :confirmable has_many :posts, dependent: :destroy has_many :likes, dependent: :destroy @@ -16,6 +17,12 @@ class User < ApplicationRecord before_save :skip_confirmation 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 self.role ||= :user @@ -33,6 +40,17 @@ class User < ApplicationRecord active? ? super : :blocked_or_deleted 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 return if Rails.application.email_confirmation? skip_confirmation! diff --git a/app/policies/tenant_policy.rb b/app/policies/tenant_policy.rb new file mode 100644 index 00000000..d17432b8 --- /dev/null +++ b/app/policies/tenant_policy.rb @@ -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 \ No newline at end of file diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb index dc55f64f..9162ac16 100644 --- a/app/views/devise/mailer/confirmation_instructions.html.erb +++ b/app/views/devise/mailer/confirmation_instructions.html.erb @@ -1,5 +1,9 @@ -

Welcome <%= @email %>!

+

<%= t('mailers.devise.welcome_greeting', { email: @email, site_name: Current.tenant_or_raise!.site_name }) %>

-

You can confirm your account email through the link below:

+

<%= t('mailers.devise.confirmation_instructions.body') %>

-

<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>

+

+ <%= link_to t('mailers.devise.confirmation_instructions.action'), + add_subdomain_to(method(:confirmation_url), @resource, { confirmation_token: @token }) + %> +

diff --git a/app/views/devise/mailer/email_changed.html.erb b/app/views/devise/mailer/email_changed.html.erb index 32f4ba80..aa19638b 100644 --- a/app/views/devise/mailer/email_changed.html.erb +++ b/app/views/devise/mailer/email_changed.html.erb @@ -1,7 +1,7 @@ -

Hello <%= @email %>!

+

<%= t('mailers.devise.opening_greeting', { email: @email }) %>

<% if @resource.try(:unconfirmed_email?) %> -

We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.

+

<%= t('mailers.devise.email_changed.body', { email: @resource.unconfirmed_email }) %>

<% else %> -

We're contacting you to notify you that your email has been changed to <%= @resource.email %>.

+

<%= t('mailers.devise.email_changed.body', { email: @resource.email }) %>

<% end %> diff --git a/app/views/devise/mailer/password_change.html.erb b/app/views/devise/mailer/password_change.html.erb index b41daf47..1209c695 100644 --- a/app/views/devise/mailer/password_change.html.erb +++ b/app/views/devise/mailer/password_change.html.erb @@ -1,3 +1,3 @@ -

Hello <%= @resource.email %>!

+

<%= t('mailers.devise.opening_greeting', { email: @resource.email }) %>

-

We're contacting you to notify you that your password has been changed.

+

<%= t('mailers.devise.password_change.body') %>

diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb index f667dc12..e61dd388 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -1,8 +1,12 @@ -

Hello <%= @resource.email %>!

+

<%= t('mailers.devise.opening_greeting', { email: @resource.email }) %>

-

Someone has requested a link to change your password. You can do this through the link below.

+

<%= t('mailers.devise.reset_password.body') %>

-

<%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>

+

+ <%= link_to t('mailers.devise.reset_password.action'), + add_subdomain_to(method(:edit_password_url), @resource, { reset_password_token: @token }) + %> +

-

If you didn't request this, please ignore this email.

-

Your password won't change until you access the link above and create a new one.

+

<%= t('mailers.devise.reset_password.body2') %>

+

<%= t('mailers.devise.reset_password.body3') %>

diff --git a/app/views/devise/mailer/unlock_instructions.html.erb b/app/views/devise/mailer/unlock_instructions.html.erb index 41e148bf..fa9d5747 100644 --- a/app/views/devise/mailer/unlock_instructions.html.erb +++ b/app/views/devise/mailer/unlock_instructions.html.erb @@ -4,4 +4,8 @@

Click the link below to unlock your account:

-

<%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>

+

+ <%= link_to 'Unlock my account', + add_subdomain_to(method(:unlock_url), @resource, { unlock_token: @token }) + %> +

diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 22239169..9e183d07 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -22,7 +22,7 @@ <% if devise_mapping.rememberable? %>
<%= 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" %>
<% end %> diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 4507daab..086062b6 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -2,10 +2,16 @@
<%= link_to root_path, class: 'brand' do - app_name = content_tag :span, Rails.application.name - logo = image_tag(asset_pack_path('media/images/logo.png'), class: 'logo') + app_name = content_tag :span, @tenant.site_name + 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 %> @@ -37,7 +43,7 @@ <% if current_user.power_user? %> <%= 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' %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 7a0acdf2..8fae9368 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,7 +1,8 @@ - <%= Rails.application.name %> + <%= @tenant ? @tenant.site_name : @page_title %> + <%= csrf_meta_tags %> <%= csp_meta_tag %> @@ -17,7 +18,10 @@ - <%= render 'layouts/header' %> + <% if @tenant %> + <%= render 'layouts/header' %> + <% end %> + <%= render 'layouts/alerts' %>
diff --git a/app/views/layouts/user_mailer.html.erb b/app/views/layouts/user_mailer.html.erb index 13ccd8cb..5bd1a135 100644 --- a/app/views/layouts/user_mailer.html.erb +++ b/app/views/layouts/user_mailer.html.erb @@ -4,14 +4,19 @@ -

<%= t('user_mailer.opening_greeting') %>

+

<%= t('mailers.user.opening_greeting') %>

<%= yield %>
-

<%= t('user_mailer.closing_greeting') %>

+

<%= t('mailers.user.closing_greeting') %>

- <%= link_to(t('user_mailer.unsubscribe'), edit_user_registration_url) %>. + <%= + link_to( + t('mailers.user.unsubscribe'), + add_subdomain_to(method(:edit_user_registration_url)) + ) + %>.
diff --git a/app/views/site_settings/_menu.html.erb b/app/views/site_settings/_menu.html.erb index 499663c6..b6b3cc89 100644 --- a/app/views/site_settings/_menu.html.erb +++ b/app/views/site_settings/_menu.html.erb @@ -4,6 +4,7 @@
<% 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.post_statuses'), path: site_settings_post_statuses_path %> <%= render 'menu_link', label: t('site_settings.menu.roadmap'), path: site_settings_roadmap_path %> diff --git a/app/views/site_settings/general.html.erb b/app/views/site_settings/general.html.erb index c6bb9953..1a24311c 100644 --- a/app/views/site_settings/general.html.erb +++ b/app/views/site_settings/general.html.erb @@ -1,8 +1,19 @@
<%= render 'menu' %> - -
-

General

-

Under construction

+
+ <%= + react_component( + 'SiteSettings/General', + { + originForm: { + siteName: @tenant.site_name, + siteLogo: @tenant.site_logo, + brandDisplaySetting: @tenant.brand_display_setting, + locale: @tenant.locale + }, + authenticityToken: form_authenticity_token + } + ) + %>
\ No newline at end of file diff --git a/app/views/static_pages/blocked_tenant.html.erb b/app/views/static_pages/blocked_tenant.html.erb new file mode 100644 index 00000000..1caa1e91 --- /dev/null +++ b/app/views/static_pages/blocked_tenant.html.erb @@ -0,0 +1,5 @@ +
+
+

<%= t('blocked_tenant.title') %>

+
+
\ No newline at end of file diff --git a/app/views/static_pages/pending_tenant.html.erb b/app/views/static_pages/pending_tenant.html.erb new file mode 100644 index 00000000..26ab7106 --- /dev/null +++ b/app/views/static_pages/pending_tenant.html.erb @@ -0,0 +1,6 @@ +
+
+

<%= t('pending_tenant.title') %>

+

<%= t('pending_tenant.message') %>

+
+
\ No newline at end of file diff --git a/app/views/tenants/new.html.erb b/app/views/tenants/new.html.erb new file mode 100644 index 00000000..c0f40739 --- /dev/null +++ b/app/views/tenants/new.html.erb @@ -0,0 +1,8 @@ +<%= + react_component( + 'TenantSignUp', + { + authenticityToken: form_authenticity_token + } + ) +%> \ No newline at end of file diff --git a/app/views/user_mailer/notify_comment_owner.html.erb b/app/views/user_mailer/notify_comment_owner.html.erb index ed4a5e4c..81318d5a 100644 --- a/app/views/user_mailer/notify_comment_owner.html.erb +++ b/app/views/user_mailer/notify_comment_owner.html.erb @@ -1,5 +1,5 @@

- <%= 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 }) %>

@@ -7,5 +7,5 @@

- <%= 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) %>

diff --git a/app/views/user_mailer/notify_followers_of_post_status_change.erb b/app/views/user_mailer/notify_followers_of_post_status_change.erb deleted file mode 100644 index 47eca4a4..00000000 --- a/app/views/user_mailer/notify_followers_of_post_status_change.erb +++ /dev/null @@ -1,10 +0,0 @@ -

- <%= I18n.t('user_mailer.notify_followers_of_post_status_change.body', { post: @post }) %> - > - <%= @post.post_status.name %> - -

- -

- <%= link_to I18n.t('user_mailer.learn_more'), post_url(@post) %> -

diff --git a/app/views/user_mailer/notify_followers_of_post_status_change.html.erb b/app/views/user_mailer/notify_followers_of_post_status_change.html.erb new file mode 100644 index 00000000..a77fd710 --- /dev/null +++ b/app/views/user_mailer/notify_followers_of_post_status_change.html.erb @@ -0,0 +1,10 @@ +

+ <%= t('mailers.user.notify_followers_of_post_status_change.body', { post: @post }) %> + > + <%= @post.post_status.name %> + +

+ +

+ <%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @post) %> +

diff --git a/app/views/user_mailer/notify_followers_of_post_update.erb b/app/views/user_mailer/notify_followers_of_post_update.erb deleted file mode 100644 index b0c17f2c..00000000 --- a/app/views/user_mailer/notify_followers_of_post_update.erb +++ /dev/null @@ -1,11 +0,0 @@ -

- <%= I18n.t('user_mailer.notify_followers_of_post_update.body', { user: @comment.user.full_name, post: @comment.post.title }) %> -

- -

- <%= @comment.body %> -

- -

- <%= link_to I18n.t('user_mailer.learn_more'), post_url(@comment.post) %> -

diff --git a/app/views/user_mailer/notify_followers_of_post_update.html.erb b/app/views/user_mailer/notify_followers_of_post_update.html.erb new file mode 100644 index 00000000..92ad5f96 --- /dev/null +++ b/app/views/user_mailer/notify_followers_of_post_update.html.erb @@ -0,0 +1,11 @@ +

+ <%= t('mailers.user.notify_followers_of_post_update.body', { user: @comment.user.full_name, post: @comment.post.title }) %> +

+ +

+ <%= @comment.body %> +

+ +

+ <%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @comment.post) %> +

diff --git a/app/views/user_mailer/notify_post_owner.html.erb b/app/views/user_mailer/notify_post_owner.html.erb index 059a3a8a..0873f98f 100644 --- a/app/views/user_mailer/notify_post_owner.html.erb +++ b/app/views/user_mailer/notify_post_owner.html.erb @@ -1,5 +1,5 @@

- <%= 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 }) %>

@@ -7,5 +7,5 @@

- <%= 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) %>

diff --git a/check-env.sh b/check-env.sh index e803e1df..64b63afd 100644 --- a/check-env.sh +++ b/check-env.sh @@ -15,9 +15,6 @@ env_vars=( "POSTGRES_USER" \ "POSTGRES_PASSWORD" \ "EMAIL_CONFIRMATION" \ - "APP_NAME" \ - "SHOW_LOGO" \ - "POSTS_PER_PAGE" \ ) # Check each one diff --git a/config/application.rb b/config/application.rb index 206bb6ee..0fb2fd83 100644 --- a/config/application.rb +++ b/config/application.rb @@ -16,20 +16,16 @@ module App # -- all .rb files in that directory are automatically loaded after loading # the framework and any gems in your application. - def name - ENV["APP_NAME"] + def multi_tenancy? + ENV["MULTI_TENANCY"] == "true" end def email_confirmation? - ENV["EMAIL_CONFIRMATION"] == "yes" - end - - def show_logo? - ENV["SHOW_LOGO"] == "yes" + ENV["EMAIL_CONFIRMATION"] == "true" end def posts_per_page - ENV["POSTS_PER_PAGE"].to_i + 15 end end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 53394ee3..d043314e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,6 +1,9 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. + # For subdomains in localhost + config.action_dispatch.tld_length = 0 + # For Devise config.action_mailer.default_url_options = { host: 'localhost:3000' } diff --git a/config/environments/test.rb b/config/environments/test.rb index e0027ca3..944e33de 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -5,7 +5,6 @@ # Set up default environment variables ENV["EMAIL_CONFIRMATION"] = "no" -ENV["POSTS_PER_PAGE"] = "15" Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 8c6e5b2f..91ea1850 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -12,19 +12,19 @@ Devise.setup do |config| # ==> Controller configuration # Configure the parent class to the devise controllers. - # config.parent_controller = 'DeviseController' + # config.parent_controller = 'ApplicationController' # ==> Mailer Configuration # 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 # 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. # config.mailer = 'Devise::Mailer' # Configure the parent class responsible to send e-mails. - # config.parent_mailer = 'ActionMailer::Base' + config.parent_mailer = 'ApplicationMailer' # ==> ORM configuration # Load and configure the ORM. Supports :active_record (default) and @@ -166,12 +166,12 @@ Devise.setup do |config| # ==> Configuration for :validatable # 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 # one (and only one) @ exists in the given string. This is mainly # 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 # The time you want to timeout the user session without activity. After this diff --git a/config/initializers/reserved_subdomains.rb b/config/initializers/reserved_subdomains.rb new file mode 100644 index 00000000..668c00c6 --- /dev/null +++ b/config/initializers/reserved_subdomains.rb @@ -0,0 +1,6 @@ +RESERVED_SUBDOMAINS = [ + 'showcase', + 'login', + 'help', + 'playground' +] \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 3185dcb2..79746344 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2,7 +2,7 @@ en: common: forms: auth: - email: 'Email address' + email: 'Email' full_name: 'Full name' password: 'Password' password_confirmation: 'Password confirmation' @@ -38,6 +38,7 @@ en: cancel: 'Cancel' create: 'Create' update: 'Save' + confirm: 'Confirm' datetime: now: 'just now' minutes: @@ -49,6 +50,19 @@ en: days: one: '1 day 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: menu: site_settings: 'Site settings' @@ -57,6 +71,11 @@ en: log_in: 'Log in / Sign up' 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: new_post: submit_button: 'Submit feedback' @@ -103,6 +122,7 @@ en: site_settings: menu: title: 'Site settings' + general: 'General' boards: 'Boards' post_statuses: 'Statuses' roadmap: 'Roadmap' @@ -110,6 +130,17 @@ en: info_box: up_to_date: 'All changes saved' 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: title: 'Boards' empty: 'There are no boards. Create one below!' @@ -127,7 +158,7 @@ en: title: 'Roadmap' title2: 'Not in roadmap' 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: title: 'Users' block: 'Block' @@ -141,23 +172,40 @@ en: status_active: 'Active' status_blocked: 'Blocked' status_deleted: 'Deleted' - user_mailer: - 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" + mailers: + devise: + welcome_greeting: 'Welcome to %{site_name}, %{email}!' + opening_greeting: 'Hello %{email}!' + confirmation_instructions: + body: 'You can confirm your account email through the link below:' + action: 'Confirm my account' + email_changed: + body: "We're contacting you to notify you that your email is being changed to %{email}." + body2: "We're contacting you to notify you that your email has been changed to %{email}." + password_change: + body: "We're contacting you to notify you that your password has been changed." + reset_password: + body: 'Someone has requested a link to change your password. You can do this through the link below.' + body2: "If you didn't request this, please ignore this email." + body3: "Your password won't change until you access the link above and create a new one." + 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: errors: unauthorized: 'You are not authorized' diff --git a/config/locales/it.yml b/config/locales/it.yml index ab09e19c..d52c2561 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -2,7 +2,7 @@ it: common: forms: auth: - email: 'Indirizzo email' + email: 'Email' full_name: 'Nome e cognome' password: 'Password' password_confirmation: 'Conferma password' @@ -38,6 +38,7 @@ it: cancel: 'Annulla' create: 'Crea' update: 'Salva' + confirm: 'Conferma' datetime: now: 'adesso' minutes: @@ -49,6 +50,19 @@ it: days: one: '1 giorno 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: menu: site_settings: 'Impostazioni sito' @@ -57,6 +71,11 @@ it: log_in: 'Accedi / Registrati' 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: new_post: submit_button: 'Invia feedback' @@ -103,6 +122,7 @@ it: site_settings: menu: title: 'Impostazioni sito' + general: 'Generali' boards: 'Bacheche' post_statuses: 'Stati' roadmap: 'Roadmap' @@ -110,6 +130,17 @@ it: info_box: up_to_date: 'Tutte le modifiche sono state salvate' 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: title: 'Bacheche' empty: 'Non ci sono bacheche. Creane una qua sotto!' @@ -141,23 +172,40 @@ it: status_active: 'Attivo' status_blocked: 'Bloccato' status_deleted: 'Eliminato' - user_mailer: - 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" + mailers: + devise: + welcome_greeting: 'Benvenuto su %{site_name}, %{email}!' + opening_greeting: 'Ciao %{email}!' + confirmation_instructions: + body: 'Puoi confermare il tuo account cliccando il link qua sotto:' + action: 'Conferma il mio account' + email_changed: + body: "Ti contattiamo per segnalarti che la tua email sta per essere modificata in %{email}." + body2: "Ti contattiamo per segnalarti che la tua email è stata modificata in %{email}." + password_change: + body: "Ti contattiamo per segnalarti che la tua password è stata modificata." + reset_password: + body: 'Qualcuno ha richiesto un link per modificare la tua password. Puoi modificare la tua password cliccando sul link qua sotto.' + body2: "Se non sei stato tu a richiedere la modifica, ti preghiamo di ignorare questa email." + body3: "La tua password non sarà modificata finché non cliccherai sul link qua sopra e ne creerai una nuova." + 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: errors: unauthorized: 'Non sei autorizzato' @@ -166,4 +214,4 @@ it: board: update_order: 'Si è verificato un errore durante il riordinamento delle bacheche' post_status: - update_order: 'Si è verificato un errore durante il riordinamento degli stati' + update_order: 'Si è verificato un errore durante il riordinamento degli stati' \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index ad0d6580..2ed3ca59 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,32 +1,53 @@ Rails.application.routes.draw do - root to: 'static_pages#roadmap' - get '/roadmap', to: 'static_pages#roadmap' + if Rails.application.multi_tenancy? + 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 :users, only: [:index, :update] - - resources :posts, only: [:index, :create, :show, :update, :destroy] do - resource :follows, only: [:create, :destroy] - resources :follows, only: [:index] - resource :likes, only: [:create, :destroy] - resources :likes, only: [:index] - resources :comments, only: [:index, :create, :update, :destroy] - resources :post_status_changes, only: [:index] - end + resources :posts, only: [:index, :create, :show, :update, :destroy] do + resource :follows, only: [:create, :destroy] + resources :follows, only: [:index] + resource :likes, only: [:create, :destroy] + resources :likes, only: [:index] + resources :comments, only: [:index, :create, :update, :destroy] + resources :post_status_changes, only: [:index] + end + + resources :boards, only: [:index, :create, :update, :destroy, :show] do + patch 'update_order', on: :collection + end - resources :boards, only: [:index, :create, :update, :destroy, :show] do - patch 'update_order', on: :collection - end - - resources :post_statuses, only: [:index, :create, :update, :destroy] do - patch 'update_order', on: :collection - end - - namespace :site_settings do - get 'general' - get 'boards' - get 'post_statuses' - get 'roadmap' - get 'users' + resources :post_statuses, only: [:index, :create, :update, :destroy] do + patch 'update_order', on: :collection + end + + namespace :site_settings do + get 'general' + get 'boards' + get 'post_statuses' + get 'roadmap' + get 'users' + end end end diff --git a/db/migrate/20220701090736_create_tenants.rb b/db/migrate/20220701090736_create_tenants.rb new file mode 100644 index 00000000..340f1fcf --- /dev/null +++ b/db/migrate/20220701090736_create_tenants.rb @@ -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 diff --git a/db/migrate/20220701091256_add_tenant_foreign_key_to_models.rb b/db/migrate/20220701091256_add_tenant_foreign_key_to_models.rb new file mode 100644 index 00000000..97042a11 --- /dev/null +++ b/db/migrate/20220701091256_add_tenant_foreign_key_to_models.rb @@ -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 diff --git a/db/migrate/20220705095817_add_brand_display_setting_to_tenants.rb b/db/migrate/20220705095817_add_brand_display_setting_to_tenants.rb new file mode 100644 index 00000000..f608492f --- /dev/null +++ b/db/migrate/20220705095817_add_brand_display_setting_to_tenants.rb @@ -0,0 +1,5 @@ +class AddBrandDisplaySettingToTenants < ActiveRecord::Migration[6.0] + def change + add_column :tenants, :brand_display_setting, :integer, default: 0 + end +end diff --git a/db/migrate/20220715092725_add_status_to_tenants.rb b/db/migrate/20220715092725_add_status_to_tenants.rb new file mode 100644 index 00000000..e1bf236e --- /dev/null +++ b/db/migrate/20220715092725_add_status_to_tenants.rb @@ -0,0 +1,5 @@ +class AddStatusToTenants < ActiveRecord::Migration[6.0] + def change + add_column :tenants, :status, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index ec5ec0b8..f5ede151 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 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 "updated_at", precision: 6, 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 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 "updated_at", precision: 6, 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 ["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" end @@ -42,7 +46,9 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do t.bigint "post_id", null: false t.datetime "created_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 ["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"], name: "index_follows_on_user_id" end @@ -52,7 +58,9 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do t.bigint "post_id", null: false t.datetime "created_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 ["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"], name: "index_likes_on_user_id" end @@ -63,8 +71,10 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do t.bigint "post_status_id" t.datetime "created_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_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" end @@ -75,7 +85,9 @@ ActiveRecord::Schema.define(version: 2022_06_22_092039) do t.datetime "updated_at", precision: 6, null: false t.integer "order", 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 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.datetime "created_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 ["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" 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| t.string "email", 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.boolean "notifications_enabled", default: true, null: false t.integer "status" + t.bigint "tenant_id", null: false 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 ["tenant_id"], name: "index_users_on_tenant_id" end + add_foreign_key "boards", "tenants" add_foreign_key "comments", "comments", column: "parent_id" add_foreign_key "comments", "posts" + add_foreign_key "comments", "tenants" add_foreign_key "comments", "users" add_foreign_key "follows", "posts" + add_foreign_key "follows", "tenants" add_foreign_key "follows", "users" add_foreign_key "likes", "posts" + add_foreign_key "likes", "tenants" add_foreign_key "likes", "users" add_foreign_key "post_status_changes", "post_statuses" 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_statuses", "tenants" add_foreign_key "posts", "boards" add_foreign_key "posts", "post_statuses" + add_foreign_key "posts", "tenants" add_foreign_key "posts", "users" + add_foreign_key "users", "tenants" end diff --git a/db/seeds.rb b/db/seeds.rb index da4356ea..7dfa9975 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -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 admin = User.create( full_name: 'Admin', @@ -10,7 +18,7 @@ admin = User.create( # Create some boards feature_board = Board.create( 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 ) bug_board = Board.create( @@ -47,30 +55,33 @@ rejected_post_status = PostStatus.create( # Create some posts post1 = Post.create( - title: 'This is how users give you feedback', - description: 'They can also provide an extendend description like this... bla bla...', - 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"', + title: 'Users can submit feedback by publishing posts!', + 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, post_status_id: planned_post_status.id ) -post3 = Post.create( - 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!', - board_id: bug_board.id, +PostStatusChange.create( + post_id: post1.id, user_id: admin.id, - post_status_id: in_progress_post_status.id + post_status_id: planned_post_status.id ) -# Create some comments -post1.comments.create(body: 'Users can comment to express their opinions!', user_id: admin.id) +post2 = Post.create( + 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 +puts "A default tenant has been created with name #{tenant.site_name}" puts 'A default admin account has been created. Credentials:' puts "-> email: #{admin.email}" puts "-> password: #{admin.password}" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 753b8de3..2b7cd1ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,9 +18,7 @@ services: - POSTGRES_USER - POSTGRES_PASSWORD - EMAIL_CONFIRMATION - - APP_NAME - - SHOW_LOGO - - POSTS_PER_PAGE + - MULTI_TENANCY volumes: - .:/astuto ports: diff --git a/spec/factories/tenants.rb b/spec/factories/tenants.rb new file mode 100644 index 00000000..91c39917 --- /dev/null +++ b/spec/factories/tenants.rb @@ -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 diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 7f160b92..73de9370 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -8,8 +8,8 @@ RSpec.describe UserMailer, type: :mailer do let(:mail) { UserMailer.notify_post_owner(comment: comment) } it "renders the headers" do - expect(mail.to).to eq(["notified@example.com"]) - expect(mail.from).to eq(["notifications@example.com"]) + expect(mail.to).to eq([user.email]) + expect(mail.from).to eq(["notifications@astuto.io"]) end it "renders the user name, post title, replier name and comment body" do diff --git a/spec/models/tenant_spec.rb b/spec/models/tenant_spec.rb new file mode 100644 index 00000000..bf79994c --- /dev/null +++ b/spec/models/tenant_spec.rb @@ -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 diff --git a/spec/requests/posts_controller_auth_spec.rb b/spec/requests/posts_controller_auth_spec.rb deleted file mode 100644 index 0ff86f40..00000000 --- a/spec/requests/posts_controller_auth_spec.rb +++ /dev/null @@ -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 \ No newline at end of file diff --git a/spec/routing/site_settings_routing_spec.rb b/spec/routing/site_settings_routing_spec.rb new file mode 100644 index 00000000..93a86681 --- /dev/null +++ b/spec/routing/site_settings_routing_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/routing/static_pages_routing_spec.rb b/spec/routing/static_pages_routing_spec.rb index 2f235c3c..0e528620 100644 --- a/spec/routing/static_pages_routing_spec.rb +++ b/spec/routing/static_pages_routing_spec.rb @@ -10,4 +10,16 @@ RSpec.describe 'static pages routing', :aggregate_failures, type: :routing do controller: 'static_pages', action: 'roadmap' ) 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 \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ce33d66d..b2e6a9dd 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -14,6 +14,16 @@ # # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 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 # assertion/expectation library such as wrong or the stdlib/minitest # assertions if you prefer. diff --git a/spec/views/header_spec.rb b/spec/views/header_spec.rb deleted file mode 100644 index 6691e225..00000000 --- a/spec/views/header_spec.rb +++ /dev/null @@ -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 \ No newline at end of file