3 Commits

Author SHA1 Message Date
Riccardo Graziosi
9176b96167 Update README and remove old links (#481)
- Remove links to demo and docs website from README
- Remove most links across the codebase referring to docs.astuto.io and astuto.io

Goodbye, Astuto 🥲
2025-10-31 15:20:05 +01:00
Riccardo Graziosi
d85bce76ad Delete FUNDING.yml (#462) 2025-03-07 16:33:36 +01:00
Riccardo Graziosi
fb5e6165c3 Disable tenant registration (#461)
* Disable tenant registration
* Disable Stripe subscription management
* Fix OAuth broken link
* Remove managed version info from README
2025-03-03 17:51:26 +01:00
134 changed files with 478 additions and 2148 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
github: riggraz

View File

@@ -42,7 +42,7 @@ RUN RSWAG_SWAGGERIZE=true RAILS_ENV=test bundle exec rake rswag:specs:swaggerize
# Compile assets if production
# SECRET_KEY_BASE=1 is a workaround (see https://github.com/rails/rails/issues/32947)
RUN if [ "$ENVIRONMENT" = "production" ]; then SECRET_KEY_BASE=1 ACTIVE_STORAGE_PRIVATE_SERVICE=test ./bin/rails assets:precompile; fi
RUN if [ "$ENVIRONMENT" = "production" ]; then SECRET_KEY_BASE=1 ./bin/rails assets:precompile; fi
###
### Dev stage ###

View File

@@ -70,12 +70,6 @@ gem 'sidekiq-cron', '2.0.1'
# Template language
gem 'liquid', '5.5.1'
# S3 for ActiveStorage
gem 'aws-sdk-s3', '1.176.1', require: false
# ActiveStorage validation
gem 'active_storage_validations', '1.4'
group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]

View File

@@ -39,12 +39,6 @@ GEM
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_storage_validations (1.4.0)
activejob (>= 6.1.4)
activemodel (>= 6.1.4)
activestorage (>= 6.1.4)
activesupport (>= 6.1.4)
marcel (>= 1.0.3)
activejob (6.1.7.9)
activesupport (= 6.1.7.9)
globalid (>= 0.3.6)
@@ -68,22 +62,6 @@ GEM
zeitwerk (~> 2.3)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
aws-eventstream (1.3.0)
aws-partitions (1.1030.0)
aws-sdk-core (3.214.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.176.1)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2)
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
@@ -147,7 +125,6 @@ GEM
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jmespath (1.6.2)
jsbundling-rails (1.1.1)
railties (>= 6.0.0)
json-schema (5.0.1)
@@ -342,8 +319,6 @@ PLATFORMS
ruby
DEPENDENCIES
active_storage_validations (= 1.4)
aws-sdk-s3 (= 1.176.1)
bootsnap (= 1.12.0)
byebug
capybara (= 3.40.0)

View File

@@ -1,23 +1,13 @@
<p align="center">
<a href="https://astuto.io/?utm_campaign=github_logo&utm_source=github.com">
<img width="400" src="./images/logo-and-name.png" />
</a>
<img width="400" src="./images/logo-and-name.png" />
</p>
<p align="center">
<a href="https://www.producthunt.com/posts/astuto?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-astuto" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=179870&theme=neutral&period=daily" alt="Astuto - An open source customer feedback tool 🦊 | Product Hunt Embed" style="width: 250px; height: 54px;" width="250px" height="54px" /></a>
<br>
<h3 align="center">
<a href="https://feedback.astuto.io/">✨ Try it out</a>
&nbsp;•&nbsp;
<a href="https://astuto.io/?utm_campaign=github_learnmore&utm_source=github.com">📖 Learn more</a>
</h3>
</p>
Astuto is an open source customer feedback tool. It helps you collect, manage and prioritize feedback from your customers, so you can build a better product.
<a href="https://feedback.astuto.io/">
<img src="./images/hero-image.png" />
</a>
<img src="./images/hero-image.png" />
## Features
@@ -29,28 +19,12 @@ Astuto is an open source customer feedback tool. It helps you collect, manage an
- **Anonymous Feedback**: enable unregistered users to publish feedback
- **... and more**: invitation system, brand customization, recap emails for administrators, private site settings, and more!
## Documentation
Documentation website is not online anymore. You can read Astuto's documentation from the [GitHub repository](https://github.com/astuto/astuto-docs).
## Get started
### Hosted
We offer a hosted solution, so you don't have to provision your own server. This is the easiest and fastest way to get started: you can sign up and start collecting feedback in a few minutes.
[Start your 7-day free trial](https://login.astuto.io/signup) without entering any payment method, then it's 15 €/month with annual subscription or 20 €/month with monthly subscription. [Learn more on astuto.io](https://astuto.io/?utm_campaign=github_getstarted&utm_source=github.com).
With the paid plan:
- You avoid deployment hassles like renting a server, issuing SSL certificates, configuring a mail server and managing updates
- You get some OAuth providers out of the box: Google, Facebook and GitHub are ready to log your users in, no configuration needed
- You get priority support
- You support open source and get our eternal gratitude :)
### Self-hosted
Read the [Deploy with Docker instructions](https://docs.astuto.io/deploy-docker) for the most comprehensive and up to date guide on installing and configuring Astuto.
What you find below are minimal instructions to get you started as quickly as possible:
0. Ensure you have Docker and Docker Compose installed
1. Create an empty folder
2. Inside that folder, create a `docker-compose.yml` file with the following content:
@@ -77,21 +51,17 @@ services:
volumes:
dbdata:
```
3. Edit the environment variables to fit your needs. You can find more information about env variables in the [documentation](https://docs.astuto.io/deploy-docker/#2-edit-environment-variables).
3. Edit the environment variables to fit your needs
4. Run `docker compose pull && docker compose up`
5. You should now have a running instance of Astuto on port 3000. A default user account has been created with credentials email: `admin@example.com`, password: `password`.
## Documentation
Check out [docs.astuto.io](https://docs.astuto.io/) to learn how to deploy Astuto, configure custom OAuth providers and webhooks, use our REST API and more!
## Contributing
There are many ways to contribute to Astuto, not just coding. Proposing features, reporting issues, translating to a new language or improving documentation are a few examples! Please read our [contributing guidelines](https://github.com/riggraz/astuto/blob/main/CONTRIBUTING.md) to learn more.
## Credits
Astuto logo and all image assets are credited [here](https://astuto.io/credits).
Astuto logo and all image assets are credited [here](https://github.com/astuto/astuto-io/blob/main/src/pages/Credits.jsx).
A huge thank you to code contributors

View File

@@ -189,7 +189,7 @@ body {
height: 2px;
}
.avatar {
.gravatar {
border-radius: 100%;
}
@@ -350,59 +350,4 @@ body {
position: relative;
bottom: 8px;
scale: 80%;
}
.dropzone {
border: 2px dashed var(--astuto-grey);
border-radius: 8px;
padding: 8px;
cursor: pointer;
}
.dropzone-disabled {
cursor: not-allowed;
}
.dropzone-accept {
border-color: rgb(0, 189, 0);
}
.dropzone-reject {
border-color: rgb(255, 0, 0);
}
.thumbnailsContainer {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 16px;
.thumbnail {
display: inline-flex;
border-radius: 2px;
border: 1px solid #eaeaea;
margin-bottom: 8px;
margin-right: 8px;
width: 80px;
height: 80px;
padding: 4px;
box-sizing: border-box;
.thumbnailInner {
display: flex;
min-width: 0;
overflow: hidden;
.thumbnailImage {
display: block;
width: auto;
height: 100%;
}
}
&.thumbnailToDelete {
border: 2px solid red;
.thumbnailInner { filter: grayscale(100%); }
}
}
}

View File

@@ -1,8 +1,6 @@
.commentsContainer {
@extend .mt-2;
.attachFilesSectionHidden { display: none; }
.commentForm {
@extend
.form-control,
@@ -24,42 +22,22 @@
.flex-column,
.mt-4;
.newCommentBodyForm {
.commentBodyForm {
@extend .d-flex;
}
.newCommentFooter {
@extend
.d-flex,
.justify-content-between,
.align-items-center;
.attachFilesSection {
margin-left: 58px;
margin-top: 1rem;
}
}
.commentIsUpdateForm {
@extend
.d-flex,
.flex-column,
.align-self-start,
.justify-content-between,
.mt-3;
&.commentIsUpdateFormWithAttachment {
margin-right: 108px;
.checkboxSwitch { align-self: flex-end; }
}
&.commentIsUpdateFormWithoutAttachment {
margin-left: 58px;
.checkboxSwitch { align-self: flex-start; }
}
margin-left: 58px;
}
.currentUserAvatar {
@extend
.avatar,
.gravatar,
.align-self-end,
.mr-2;
}
@@ -75,12 +53,7 @@
}
.editCommentForm {
@extend .my-3;
background-color: rgb(255 255 215);
padding: 16px;
.commentFormContainer, .editCommentFormAttachments { @extend .d-block; }
.commentFormContainer { @extend .d-block; }
textarea {
@extend .my-2;
@@ -92,13 +65,6 @@
.justify-content-between;
}
.editCommentFormFooter {
@extend
.d-flex,
.justify-content-between,
.align-items-center;
}
.editCommentFormActions { @extend .d-flex; }
}
@@ -150,17 +116,6 @@
p:nth-child(1) { @extend .m-0; }
}
.commentAttachments {
@extend .mb-4;
.commentAttachment {
@extend .mx-2, .p-2;
height: 60px;
border: thin solid var(--astuto-grey-light);
}
}
.commentFooter {
@extend .d-flex;

View File

@@ -15,7 +15,7 @@ ul.usersList {
.my-2,
.p-3;
.userAvatar {
.userGravatar {
@extend .mr-3, .align-self-center;
}

View File

@@ -71,7 +71,7 @@
.p-2,
.my-1;
.avatar {
.gravatar {
@extend .align-self-center;
}
@@ -149,24 +149,13 @@
}
}
.postAttachments {
@extend .mb-4;
.postAttachment {
@extend .mx-2, .p-2;
height: 80px;
border: thin solid var(--astuto-grey-light);
}
}
.postFooter {
.postAuthor {
@extend
.mutedText,
.mb-2;
.postAuthorAvatar { @extend .avatar; }
.postAuthorAvatar { @extend .gravatar; }
}
.postFooterActions { @extend .d-flex; }
@@ -184,7 +173,7 @@
}
.postEditFormButtons {
@extend .d-flex, .justify-content-end, .mt-3;
@extend .d-flex, .justify-content-end;
}
#selectPickerBoard { margin-right: 4px !important; }

View File

@@ -52,27 +52,4 @@
font-size: 18px;
}
.oAuthLogoPreview {
@extend .d-block, .my-2;
position: relative;
display: inline-block;
width: fit-content;
height: fit-content;
}
.oAuthLogoPreview .oAuthLogoPreviewImg {
display: block;
height: 50px;
width: auto;
}
.oAuthLogoPreview.oAuthLogoPreviewShouldDelete {
border: 2px solid red;
.oAuthLogoPreviewImg { filter: grayscale(100%); }
}
.oAuthLogoActions { @extend .d-flex; }
}

View File

@@ -8,29 +8,4 @@
.generalSiteSettingsSubmit {
@extend .mb-4;
}
.siteLogoPreview, .siteFaviconPreview {
@extend .d-block, .my-2;
position: relative;
display: inline-block;
width: fit-content; /* Container matches the image size */
height: fit-content; /* Adjusts height to match the image */
}
.siteLogoPreview .siteLogoPreviewImg, .siteFaviconPreview .siteFaviconPreviewImg {
display: block;
height: 50px; /* Fixed height for the image */
width: auto; /* Maintain the aspect ratio */
}
.siteLogoPreview.siteLogoPreviewShouldDelete, .siteFaviconPreview.siteFaviconPreviewShouldDelete {
border: 2px solid red;
.siteLogoPreviewImg, .siteFaviconPreviewImg {
filter: grayscale(100%);
}
}
.siteLogoActions, .siteFaviconActions { @extend .d-flex; }
}

View File

@@ -39,8 +39,7 @@ class ApplicationController < ActionController::Base
:full_name,
:notifications_enabled,
:recap_notification_frequency,
:invitation_token,
:avatar,
:invitation_token
]
devise_parameter_sanitizer.permit(:sign_up, keys: additional_permitted_parameters)

View File

@@ -11,7 +11,6 @@ class CommentsController < ApplicationController
:is_post_update,
:created_at,
:updated_at,
'users.id as user_id', # required for avatar_url
'users.full_name as user_full_name',
'users.email as user_email',
'users.role as user_role',
@@ -19,17 +18,6 @@ class CommentsController < ApplicationController
.where(post_id: params[:post_id])
.left_outer_joins(:user)
.order(created_at: :desc)
.includes(user: { avatar_attachment: :blob }) # Preload avatars
comments = comments.map do |comment|
user_avatar_url = comment.user.avatar.attached? ? comment.user.avatar.blob.url : nil
attachment_urls = comment.attachments.order(:created_at).map { |attachment| attachment.blob.url }
comment.attributes.merge(
attachment_urls: attachment_urls,
user_avatar: user_avatar_url
)
end
render json: comments
end
@@ -38,22 +26,11 @@ class CommentsController < ApplicationController
@comment = Comment.new
@comment.assign_attributes(comment_create_params)
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:comment][:attachments].present?
@comment.attachments.attach(params[:comment][:attachments])
end
if @comment.save
SendNotificationForCommentWorkflow.new(comment: @comment).run
render json: @comment.attributes.merge(
{
attachment_urls: @comment.attachments.order(:created_at).map { |attachment| attachment.blob.url },
user_full_name: current_user.full_name,
user_email: current_user.email,
user_avatar: current_user.avatar.attached? ? current_user.avatar.blob.url : nil,
user_role: current_user.role_before_type_cast
}
{ user_full_name: current_user.full_name, user_email: current_user.email, user_role: current_user.role_before_type_cast }
), status: :created
else
render json: {
@@ -66,29 +43,9 @@ class CommentsController < ApplicationController
@comment = Comment.find(params[:id])
authorize @comment
@comment.assign_attributes(comment_update_params)
# handle attachment deletion
if params[:comment][:attachments_to_delete].present? && @comment.attachments.attached?
@comment.attachments.order(:created_at).each_with_index do |attachment, index|
attachment.purge if params[:comment][:attachments_to_delete].include?(index.to_s)
end
end
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:comment][:attachments].present?
@comment.attachments.attach(params[:comment][:attachments])
end
if @comment.save
if @comment.update(comment_update_params)
render json: @comment.attributes.merge(
{
attachment_urls: @comment.attachments.order(:created_at).map { |attachment| attachment.blob.url },
user_full_name: @comment.user.full_name,
user_email: @comment.user.email,
user_avatar: @comment.user.avatar.attached? ? @comment.user.avatar.blob.url : nil,
user_role: @comment.user.role_before_type_cast
}
{ user_full_name: @comment.user.full_name, user_email: @comment.user.email, user_role: @comment.user.role_before_type_cast }
)
else
render json: {

View File

@@ -7,17 +7,10 @@ class LikesController < ApplicationController
.select(
:id,
:full_name,
:email,
'users.id as user_id', # required for avatar_url
:email
)
.left_outer_joins(:user)
.where(post_id: params[:post_id])
.includes(user: { avatar_attachment: :blob }) # Preload avatars
likes = likes.map do |like|
user_avatar_url = like.user.avatar.attached? ? like.user.avatar.blob.url : nil
like.attributes.merge(user_avatar: user_avatar_url)
end
render json: likes
end
@@ -30,7 +23,6 @@ class LikesController < ApplicationController
id: like.id,
full_name: current_user.full_name,
email: current_user.email,
user_avatar: current_user.avatar.attached? ? current_user.avatar.blob.url : nil,
}, status: :created
else
render json: {

View File

@@ -1,14 +0,0 @@
class LocalFilesController < ApplicationController
def show
blob = ActiveStorage::Blob.find_by(key: params[:key])
if blob.present? && blob.service.is_a?(ActiveStorage::Service::DiskService)
send_file blob.service.path_for(blob.key),
type: blob.content_type, # Set correct MIME type
disposition: :inline, # Show in browser
filename: blob.filename.to_s # Ensure correct filename
else
head :not_found
end
end
end

View File

@@ -170,10 +170,6 @@ class OAuthsController < ApplicationController
@o_auth = OAuth.find(params[:id])
authorize @o_auth
if params[:o_auth][:should_delete_logo] == "true"
@o_auth.logo.purge if @o_auth.logo.attached?
end
if @o_auth.update(o_auth_params)
render json: to_json_custom(@o_auth)
else
@@ -202,7 +198,7 @@ class OAuthsController < ApplicationController
def to_json_custom(o_auth)
o_auth.as_json(
methods: [:logo_url, :callback_url, :default_o_auth_is_enabled],
methods: [:callback_url, :default_o_auth_is_enabled],
except: [:client_secret]
)
end
@@ -210,10 +206,6 @@ class OAuthsController < ApplicationController
def o_auth_params
params
.require(:o_auth)
.permit(
policy(@o_auth)
.permitted_attributes
.concat([{ additional_params: [:should_delete_logo] }])
)
.permit(policy(@o_auth).permitted_attributes)
end
end

View File

@@ -4,19 +4,12 @@ class PostStatusChangesController < ApplicationController
.select(
:post_status_id,
:created_at,
'users.id as user_id', # required for avatar_url
'users.full_name as user_full_name',
'users.email as user_email',
)
.where(post_id: params[:post_id])
.left_outer_joins(:user)
.order(created_at: :asc)
.includes(user: { avatar_attachment: :blob }) # Preload avatars
post_status_changes = post_status_changes.map do |post_status_change|
user_avatar_url = post_status_change.user.avatar.attached? ? post_status_change.user.avatar.blob.url : nil
post_status_change.attributes.merge(user_avatar: user_avatar_url)
end
render json: post_status_changes
end

View File

@@ -32,11 +32,6 @@ class PostsController < ApplicationController
# apply post status filter if present
posts = posts.where(post_status_id: params[:post_status_ids].map { |id| id == "0" ? nil : id }) if params[:post_status_ids].present?
# check if posts have attachments
posts = posts.map do |post|
post.attributes.merge(has_attachments: post.attachments.attached?)
end
render json: posts
end
@@ -62,11 +57,6 @@ class PostsController < ApplicationController
@post = Post.new(approval_status: approval_status)
@post.assign_attributes(post_create_params(is_anonymous: is_anonymous))
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:post][:attachments].present? && !is_anonymous
@post.attachments.attach(params[:post][:attachments])
end
if @post.save
Follow.create(post_id: @post.id, user_id: current_user.id) unless is_anonymous
@@ -97,7 +87,7 @@ class PostsController < ApplicationController
)
.eager_load(:user) # left outer join
.find(params[:id])
@post_statuses = PostStatus.select(:id, :name, :color).order(order: :asc)
@board = @post.board
@@ -106,7 +96,7 @@ class PostsController < ApplicationController
respond_to do |format|
format.html
format.json { render json: @post.as_json.merge(attachment_urls: @post.attachments.order(:created_at).map { |attachment| attachment.blob.url }) }
format.json { render json: @post }
end
end
@@ -116,18 +106,6 @@ class PostsController < ApplicationController
@post.assign_attributes(post_update_params)
# handle attachment deletion
if params[:post][:attachments_to_delete].present? && @post.attachments.attached?
@post.attachments.order(:created_at).each_with_index do |attachment, index|
attachment.purge if params[:post][:attachments_to_delete].include?(index.to_s)
end
end
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:post][:attachments].present?
@post.attachments.attach(params[:post][:attachments])
end
if @post.save
if @post.post_status_id_previously_changed?
ExecutePostStatusChangeLogicWorkflow.new(
@@ -137,7 +115,7 @@ class PostsController < ApplicationController
).run
end
render json: @post.as_json.merge(attachment_urls: @post.attachments.order(:created_at).map { |attachment| attachment.blob.url })
render json: @post
else
render json: {
error: @post.errors.full_messages
@@ -172,7 +150,6 @@ class PostsController < ApplicationController
:user_id,
:board_id,
:created_at,
'users.id as user_id', # required for avatar_url
'users.email as user_email',
'users.full_name as user_full_name'
)
@@ -180,12 +157,6 @@ class PostsController < ApplicationController
.where(approval_status: ["pending", "rejected"])
.order_by(created_at: :desc)
.limit(100)
.includes(user: { avatar_attachment: :blob }) # Preload avatars
posts = posts.map do |post|
user_avatar_url = post.user.avatar.attached? ? post.user.avatar.blob.url : nil
post.attributes.merge(user_avatar: user_avatar_url)
end
render json: posts
end
@@ -215,10 +186,6 @@ class PostsController < ApplicationController
def post_update_params
params
.require(:post)
.permit(
policy(@post)
.permitted_attributes_for_update
.concat([{ additional_params: [:attachments_to_delete] }])
)
.permit(policy(@post).permitted_attributes_for_update)
end
end

View File

@@ -77,13 +77,6 @@ class RegistrationsController < Devise::RegistrationsController
respond_with_navigational(resource){ redirect_to after_sign_out_path_for(resource_name) }
end
def delete_avatar
user = User.find(current_user.id)
user.avatar.purge
render json: { success: true }, status: :ok
end
def send_set_password_instructions
user = User.find_by_email(params[:email])

View File

@@ -11,14 +11,13 @@ class TenantsController < ApplicationController
end
def show
tenant = Current.tenant_or_raise!
tenant.attributes.merge(site_logo_url: tenant.site_logo.attached? ? tenant.site_logo.blob.url : nil)
render json: tenant
render json: Current.tenant_or_raise!
end
def create
# NOTE: new tenants registrations disabled
raise "Tenant registration disabled"
@tenant = Tenant.new
@tenant.assign_attributes(tenant_create_params)
authorize @tenant
@@ -96,27 +95,7 @@ class TenantsController < ApplicationController
# to avoid unique constraint violation
params[:tenant][:custom_domain] = nil if params[:tenant][:custom_domain].blank?
# Handle site logo attachment
if params[:tenant][:should_delete_site_logo] == "true"
@tenant.site_logo.purge if @tenant.site_logo.attached?
elsif params[:tenant][:site_logo].present?
@tenant.site_logo.purge if @tenant.site_logo.attached?
@tenant.site_logo.attach(params[:tenant][:site_logo])
should_delete_old_site_logo = true
end
# Handle site favicon attachment
if params[:tenant][:should_delete_site_favicon] == "true"
@tenant.site_favicon.purge if @tenant.site_favicon.attached?
elsif params[:tenant][:site_favicon].present?
@tenant.site_favicon.purge if @tenant.site_favicon.attached?
@tenant.site_favicon.attach(params[:tenant][:site_favicon])
end
@tenant.assign_attributes(tenant_update_params)
@tenant.old_site_logo = nil if should_delete_old_site_logo
if @tenant.save
if @tenant.update(tenant_update_params)
render json: @tenant
else
render json: {
@@ -152,8 +131,7 @@ class TenantsController < ApplicationController
policy(@tenant)
.permitted_attributes_for_update
.concat([{
tenant_setting_attributes: policy(@tenant.tenant_setting).permitted_attributes_for_update,
additional_params: [:should_delete_site_logo, :should_delete_site_favicon]
tenant_setting_attributes: policy(@tenant.tenant_setting).permitted_attributes_for_update
}]) # in order to permit nested attributes for tenant_setting
)
end

View File

@@ -8,10 +8,6 @@ class UsersController < ApplicationController
.all
.order(role: :desc, created_at: :desc)
@users = @users.map do |user|
user.attributes.merge(avatar_url: user.avatar.attached? ? user.avatar.blob.url : nil)
end
render json: @users
end

View File

@@ -3,8 +3,8 @@ import { ThunkAction } from 'redux-thunk';
import { State } from '../../reducers/rootReducer';
import ICommentJSON from '../../interfaces/json/IComment';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import HttpStatus from '../../constants/http_status';
import buildFormData from '../../helpers/buildFormData';
export const COMMENT_SUBMIT_START = 'COMMENT_SUBMIT_START';
interface CommentSubmitStartAction {
@@ -53,24 +53,21 @@ export const submitComment = (
body: string,
parentId: number,
isPostUpdate: boolean,
attachments: File[],
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(commentSubmitStart(parentId));
try {
let formDataObj = {
'comment[body]': body,
'comment[parent_id]': parentId,
'comment[is_post_update]': isPostUpdate,
'comment[attachments][]': attachments,
};
const requestBody = buildFormData(formDataObj);
const res = await fetch(`/posts/${postId}/comments`, {
method: 'POST',
headers: { 'X-CSRF-Token': authenticityToken },
body: requestBody,
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
comment: {
body,
parent_id: parentId,
is_post_update: isPostUpdate,
},
}),
});
const json = await res.json();
@@ -79,11 +76,7 @@ export const submitComment = (
} else {
dispatch(commentSubmitFailure(parentId, json.error));
}
return Promise.resolve(res);
} catch (e) {
dispatch(commentSubmitFailure(parentId, e));
return Promise.resolve(null);
}
}

View File

@@ -5,7 +5,6 @@ import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import ICommentJSON from "../../interfaces/json/IComment";
import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const COMMENT_UPDATE_START = 'COMMENT_UPDATE_START';
interface CommentUpdateStartAction {
@@ -50,25 +49,20 @@ export const updateComment = (
commentId: number,
body: string,
isPostUpdate: boolean,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(commentUpdateStart());
try {
let formDataObj = {
'comment[body]': body,
'comment[is_post_update]': isPostUpdate,
'comment[attachments_to_delete][]': attachmentsToDelete,
'comment[attachments][]': attachments,
};
const requestBody = buildFormData(formDataObj);
const res = await fetch(`/posts/${postId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'X-CSRF-Token': authenticityToken },
body: requestBody,
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
comment: {
body,
is_post_update: isPostUpdate,
},
}),
});
const json = await res.json();

View File

@@ -2,9 +2,9 @@ import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { IOAuth, IOAuthJSON, oAuthJS2JSON } from "../../interfaces/IOAuth";
import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const OAUTH_SUBMIT_START = 'OAUTH_SUBMIT_START';
interface OAuthSubmitStartAction {
@@ -46,26 +46,20 @@ const oAuthSubmitFailure = (error: string): OAuthSubmitFailureAction => ({
export const submitOAuth = (
oAuth: IOAuth,
oAuthLogo: File = null,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(oAuthSubmitStart());
let formDataObj = {};
Object.entries(oAuthJS2JSON(oAuth)).forEach(([key, value]) => {
formDataObj[`o_auth[${key}]`] = value;
});
formDataObj['o_auth[logo]'] = oAuthLogo;
formDataObj['o_auth[is_enabled]'] = false;
const body = buildFormData(formDataObj);
try {
const res = await fetch(`/o_auths`, {
method: 'POST',
headers: { 'X-CSRF-Token': authenticityToken },
body,
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
o_auth: {
...oAuthJS2JSON(oAuth),
is_enabled: false,
},
}),
});
const json = await res.json();

View File

@@ -6,7 +6,6 @@ import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { IOAuthJSON } from "../../interfaces/IOAuth";
import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const OAUTH_UPDATE_START = 'OAUTH_UPDATE_START';
interface OAuthUpdateStartAction {
@@ -50,7 +49,6 @@ interface UpdateOAuthParams {
id: number;
form?: ISiteSettingsOAuthForm;
isEnabled?: boolean;
shouldDeleteLogo?: boolean;
authenticityToken: string;
}
@@ -58,7 +56,6 @@ export const updateOAuth = ({
id,
form = null,
isEnabled = null,
shouldDeleteLogo = false,
authenticityToken,
}: UpdateOAuthParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(oAuthUpdateStart());
@@ -66,7 +63,7 @@ export const updateOAuth = ({
const o_auth = Object.assign({},
form !== null ? {
name: form.name,
logo: form.logo ? form.logo : null,
logo: form.logo,
client_id: form.clientId,
client_secret: form.clientSecret,
authorize_url: form.authorizeUrl,
@@ -79,20 +76,11 @@ export const updateOAuth = ({
isEnabled !== null ? {is_enabled: isEnabled} : null,
);
let formDataObj = {};
Object.entries(o_auth).forEach(([key, value]) => {
formDataObj[`o_auth[${key}]`] = value;
});
formDataObj['o_auth[should_delete_logo]'] = shouldDeleteLogo.toString();
const body = buildFormData(formDataObj);
try {
const res = await fetch(`/o_auths/${id}`, {
method: 'PATCH',
headers: { 'X-CSRF-Token': authenticityToken },
body,
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({o_auth}),
});
const json = await res.json();

View File

@@ -5,7 +5,6 @@ import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import IPostJSON from "../../interfaces/json/IPost";
import { State } from "../../reducers/rootReducer";
import { PostApprovalStatus } from "../../interfaces/IPost";
import buildFormData from "../../helpers/buildFormData";
export const POST_UPDATE_START = 'POST_UPDATE_START';
interface PostUpdateStartAction {
@@ -51,28 +50,22 @@ export const updatePost = (
description: string,
boardId: number,
postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(postUpdateStart());
try {
let formDataObj = {
'post[title]': title,
'post[description]': description,
'post[board_id]': boardId,
'post[post_status_id]': postStatusId,
'post[attachments_to_delete][]': attachmentsToDelete,
'post[attachments][]': attachments,
};
const body = buildFormData(formDataObj);
const res = await fetch(`/posts/${id}`, {
method: 'PATCH',
headers: { 'X-CSRF-Token': authenticityToken },
body,
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
post: {
title,
description,
board_id: boardId,
post_status_id: postStatusId,
}
}),
});
const json = await res.json();

View File

@@ -2,10 +2,10 @@ import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import ITenantSetting from "../../interfaces/ITenantSetting";
import ITenantJSON from "../../interfaces/json/ITenant";
import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const TENANT_UPDATE_START = 'TENANT_UPDATE_START';
interface TenantUpdateStartAction {
@@ -47,11 +47,7 @@ const tenantUpdateFailure = (error: string): TenantUpdateFailureAction => ({
interface UpdateTenantParams {
siteName?: string;
siteLogo?: File;
shouldDeleteSiteLogo?: boolean;
oldSiteLogo?: string;
siteFavicon?: File;
shouldDeleteSiteFavicon?: boolean;
siteLogo?: string;
tenantSetting?: ITenantSetting;
locale?: string;
customDomain?: string;
@@ -61,10 +57,6 @@ interface UpdateTenantParams {
export const updateTenant = ({
siteName = null,
siteLogo = null,
shouldDeleteSiteLogo = null,
oldSiteLogo = null,
siteFavicon = null,
shouldDeleteSiteFavicon = null,
tenantSetting = null,
locale = null,
customDomain = null,
@@ -72,32 +64,24 @@ export const updateTenant = ({
}: UpdateTenantParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(tenantUpdateStart());
try {
let formDataObj = {
'tenant[site_name]': siteName,
'tenant[site_logo]': siteLogo,
'tenant[should_delete_site_logo]': shouldDeleteSiteLogo.toString(),
'tenant[old_site_logo]': oldSiteLogo,
'tenant[site_favicon]': siteFavicon,
'tenant[should_delete_site_favicon]': shouldDeleteSiteFavicon.toString(),
'tenant[locale]': locale,
'tenant[custom_domain]': customDomain,
}
const tenant = Object.assign({},
siteName !== null ? { site_name: siteName } : null,
siteLogo !== null ? { site_logo: siteLogo } : null,
locale !== null ? { locale } : null,
customDomain !== null ? { custom_domain: customDomain } : null,
);
if (tenantSetting) {
Object.entries(tenantSetting).forEach(([key, value]) => {
formDataObj[`tenant[tenant_setting_attributes][${key}]`] = value;
});
}
const body = buildFormData(formDataObj);
try {
const body = JSON.stringify({
tenant: {
...tenant,
tenant_setting_attributes: tenantSetting,
},
});
const res = await fetch(`/tenants/0`, {
method: 'PATCH',
headers: {
'X-CSRF-Token': authenticityToken,
// do not set Content-Type header when using FormData
},
headers: buildRequestHeaders(authenticityToken),
body,
});
const json = await res.json();

View File

@@ -125,7 +125,7 @@ const Billing = ({
<p>Subscription {isExpired ? 'expired' : 'expires'} on {subscriptionEndsAtFormatted}</p>
}
{
{/* {
(tenantBilling.status === TENANT_BILLING_STATUS_TRIAL) && chosenPrice === null &&
<PricingTable
prices={prices}
@@ -169,16 +169,20 @@ const Billing = ({
You will be redirected to Stripe, our billing partner.
</SmallMutedText>
</div>
}
} */}
<div className="billingUsefulLinks">
<p>
We do not accept new subscriptions right now.
</p>
{/* <div className="billingUsefulLinks">
<ActionLink onClick={() => window.open('https://astuto.io/terms-of-service', '_blank')} icon={<LearnMoreIcon />}>
Terms of Service
</ActionLink>
<ActionLink onClick={() => window.open('https://astuto.io/privacy-policy', '_blank')} icon={<LearnMoreIcon />}>
Privacy Policy
</ActionLink>
</div>
</div> */}
</Box>
);
}

View File

@@ -115,7 +115,6 @@ class BoardP extends React.Component<Props> {
<Sidebar>
<NewPost
board={board}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn}
currentUserFullName={currentUserFullName}
isAnonymousFeedbackAllowed={tenantSetting.allow_anonymous_feedback}

View File

@@ -11,16 +11,12 @@ import {
import Button from '../common/Button';
import IBoard from '../../interfaces/IBoard';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import HttpStatus from '../../constants/http_status';
import { POST_APPROVAL_STATUS_APPROVED } from '../../interfaces/IPost';
import ActionLink from '../common/ActionLink';
import { CancelIcon } from '../common/Icons';
import buildFormData from '../../helpers/buildFormData';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props {
board: IBoard;
tenantSetting: ITenantSetting;
isLoggedIn: boolean;
currentUserFullName: string;
isAnonymousFeedbackAllowed: boolean;
@@ -38,7 +34,6 @@ interface State {
title: string;
description: string;
attachments: File[];
isSubmissionAnonymous: boolean;
// Honeypot anti-spam measure
@@ -60,7 +55,6 @@ class NewPost extends React.Component<Props, State> {
title: '',
description: '',
attachments: [],
isSubmissionAnonymous: false,
dnf1: '',
@@ -70,7 +64,6 @@ class NewPost extends React.Component<Props, State> {
this.toggleForm = this.toggleForm.bind(this);
this.onTitleChange = this.onTitleChange.bind(this);
this.onDescriptionChange = this.onDescriptionChange.bind(this);
this.onAttachmentsChange = this.onAttachmentsChange.bind(this);
this.submitForm = this.submitForm.bind(this);
this.onDnf1Change = this.onDnf1Change.bind(this)
@@ -99,12 +92,6 @@ class NewPost extends React.Component<Props, State> {
});
}
onAttachmentsChange(attachments: File[]) {
this.setState({
attachments,
});
}
onDnf1Change(dnf1: string) {
this.setState({
dnf1,
@@ -128,7 +115,7 @@ class NewPost extends React.Component<Props, State> {
const boardId = this.props.board.id;
const { authenticityToken, componentRenderedAt } = this.props;
const { title, description, attachments, isSubmissionAnonymous, dnf1, dnf2 } = this.state;
const { title, description, isSubmissionAnonymous, dnf1, dnf2 } = this.state;
if (title === '') {
this.setState({
@@ -139,23 +126,22 @@ class NewPost extends React.Component<Props, State> {
}
try {
let formDataObj = {
'post[title]': title,
'post[description]': description,
'post[attachments][]': attachments,
'post[board_id]': boardId,
'post[is_anonymous]': isSubmissionAnonymous.toString(),
'post[dnf1]': dnf1,
'post[dnf2]': dnf2,
'post[form_rendered_at]': componentRenderedAt,
};
const body = buildFormData(formDataObj);
const res = await fetch('/posts', {
method: 'POST',
headers: { 'X-CSRF-Token': authenticityToken },
body: body,
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
post: {
title,
description,
board_id: boardId,
is_anonymous: isSubmissionAnonymous,
dnf1,
dnf2,
form_rendered_at: componentRenderedAt,
},
}),
});
const json = await res.json();
this.setState({isLoading: false});
@@ -191,7 +177,6 @@ class NewPost extends React.Component<Props, State> {
render() {
const {
board,
tenantSetting,
isLoggedIn,
currentUserFullName,
isAnonymousFeedbackAllowed
@@ -205,7 +190,6 @@ class NewPost extends React.Component<Props, State> {
title,
description,
attachments,
isSubmissionAnonymous,
dnf1,
@@ -224,30 +208,31 @@ class NewPost extends React.Component<Props, State> {
{board.description}
</ReactMarkdown>
{
showForm ?
<ActionLink
onClick={this.toggleForm}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<Button
onClick={() => {
if (isLoggedIn) {
this.toggleForm();
this.setState({ isSubmissionAnonymous: false });
} else {
window.location.href = '/users/sign_in';
}
}}
className="submitBtn"
outline={showForm}
>
{I18n.t('board.new_post.submit_button')}
</Button>
}
<Button
onClick={() => {
if (showForm) {
this.toggleForm();
return;
}
if (isLoggedIn) {
this.toggleForm();
this.setState({ isSubmissionAnonymous: false });
} else {
window.location.href = '/users/sign_in';
}
}}
className="submitBtn"
outline={showForm}
>
{
showForm ?
I18n.t('board.new_post.cancel_button')
:
I18n.t('board.new_post.submit_button')
}
</Button>
{
(isAnonymousFeedbackAllowed && !showForm) &&
@@ -267,14 +252,12 @@ class NewPost extends React.Component<Props, State> {
}
{
showForm &&
showForm ?
<NewPostForm
title={title}
description={description}
attachments={attachments}
handleTitleChange={this.onTitleChange}
handleDescriptionChange={this.onDescriptionChange}
handleAttachmentsChange={this.onAttachmentsChange}
handleSubmit={this.submitForm}
@@ -283,10 +266,11 @@ class NewPost extends React.Component<Props, State> {
handleDnf1Change={this.onDnf1Change}
handleDnf2Change={this.onDnf2Change}
tenantSetting={tenantSetting}
currentUserFullName={currentUserFullName}
isSubmissionAnonymous={isSubmissionAnonymous}
/>
:
null
}
{ isLoading ? <Spinner /> : null }

View File

@@ -4,16 +4,12 @@ import I18n from 'i18n-js';
import Button from '../common/Button';
import { SmallMutedText } from '../common/CustomTexts';
import { MarkdownIcon } from '../common/Icons';
import Dropzone from '../common/Dropzone';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props {
title: string;
description: string;
attachments: File[];
handleTitleChange(title: string): void;
handleDescriptionChange(description: string): void;
handleAttachmentsChange(attachments: File[]): void;
handleSubmit(e: object): void;
@@ -22,7 +18,6 @@ interface Props {
handleDnf1Change(dnf1: string): void;
handleDnf2Change(dnf2: string): void;
tenantSetting: ITenantSetting;
currentUserFullName: string;
isSubmissionAnonymous: boolean;
}
@@ -30,10 +25,8 @@ interface Props {
const NewPostForm = ({
title,
description,
attachments,
handleTitleChange,
handleDescriptionChange,
handleAttachmentsChange,
handleSubmit,
@@ -42,14 +35,11 @@ const NewPostForm = ({
handleDnf1Change,
handleDnf2Change,
tenantSetting,
currentUserFullName,
isSubmissionAnonymous,
}: Props) => (
<div className="newPostForm">
<form encType="multipart/form-data">
{ /* Title */ }
<form>
<div className="form-group">
<input
type="text"
@@ -94,7 +84,6 @@ const NewPostForm = ({
className="form-control"
/>
{ /* Description */ }
<div className="form-group">
<textarea
value={description}
@@ -110,19 +99,6 @@ const NewPostForm = ({
</div>
</div>
{ /* Attachments */ }
{
tenantSetting.allow_attachment_upload && !isSubmissionAnonymous &&
<div className="form-group">
<Dropzone
files={attachments}
setFiles={handleAttachmentsChange}
maxSizeKB={2048}
maxFiles={5}
/>
</div>
}
<Button onClick={e => handleSubmit(e)} className="submitBtn d-block mx-auto">
{I18n.t('board.new_post.submit_button')}
</Button>

View File

@@ -63,7 +63,6 @@ const PostList = ({
showLikeButtons={showLikeButtons}
liked={post.liked}
commentsCount={post.commentsCount}
hasAttachments={post.hasAttachments}
isLoggedIn={isLoggedIn}
authenticityToken={authenticityToken}

View File

@@ -6,7 +6,6 @@ import CommentsNumber from '../common/CommentsNumber';
import PostStatusLabel from '../common/PostStatusLabel';
import IPostStatus from '../../interfaces/IPostStatus';
import { ImageIcon } from '../common/Icons';
interface Props {
id: number;
@@ -19,7 +18,6 @@ interface Props {
showLikeButtons: boolean;
liked: number;
commentsCount: number;
hasAttachments: boolean;
isLoggedIn: boolean;
authenticityToken: string;
@@ -36,7 +34,6 @@ const PostListItem = ({
showLikeButtons,
liked,
commentsCount,
hasAttachments,
isLoggedIn,
authenticityToken,
@@ -61,10 +58,7 @@ const PostListItem = ({
<div className="postDetails">
<CommentsNumber number={commentsCount} />
{ postStatus && <PostStatusLabel {...postStatus} /> }
{ hasAttachments && <span style={{marginLeft: '4px'}}><ImageIcon /></span> }
{ postStatus ? <PostStatusLabel {...postStatus} /> : null }
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import ReactMarkdown from 'react-markdown';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js';
import NewComment from './NewComment';
@@ -9,18 +10,13 @@ import { ReplyFormState } from '../../reducers/replyFormReducer';
import CommentEditForm from './CommentEditForm';
import CommentFooter from './CommentFooter';
import { StaffIcon } from '../common/Icons';
import Avatar from '../common/Avatar';
import CommentAttachments from './CommentAttachments';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props {
id: number;
body: string;
isPostUpdate: boolean;
attachmentUrls?: string[];
userFullName: string;
userEmail: string;
userAvatar?: string;
userRole: number;
createdAt: string;
updatedAt: string;
@@ -29,15 +25,13 @@ interface Props {
handleToggleCommentReply(): void;
handleCommentReplyBodyChange(e: React.FormEvent): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean, attachments: File[], onSuccess: Function): void;
handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[], onSuccess: Function): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void;
handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function): void;
handleDeleteComment(id: number): void;
tenantSetting: ITenantSetting;
isLoggedIn: boolean;
isPowerUser: boolean;
currentUserEmail: string;
currentUserAvatar?: string;
}
interface State {
@@ -60,13 +54,11 @@ class Comment extends React.Component<Props, State> {
this.setState({editMode: !this.state.editMode});
}
_handleUpdateComment(body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[]) {
_handleUpdateComment(body: string, isPostUpdate: boolean) {
this.props.handleUpdateComment(
this.props.id,
body,
isPostUpdate,
attachmentsToDelete,
attachments,
this.toggleEditMode,
);
}
@@ -76,10 +68,8 @@ class Comment extends React.Component<Props, State> {
id,
body,
isPostUpdate,
attachmentUrls,
userFullName,
userEmail,
userAvatar,
userRole,
createdAt,
updatedAt,
@@ -91,26 +81,26 @@ class Comment extends React.Component<Props, State> {
handleSubmitComment,
handleDeleteComment,
tenantSetting,
isLoggedIn,
isPowerUser,
currentUserEmail,
currentUserAvatar,
} = this.props;
return (
<div className="comment">
<div className="commentHeader">
<Avatar avatarUrl={userAvatar} email={userEmail} size={28} />
<Gravatar email={userEmail} size={28} className="gravatar" />
<span className="commentAuthor">{userFullName}</span>
{ userRole > 0 && <StaffIcon /> }
{
isPostUpdate &&
isPostUpdate ?
<span className="postUpdateBadge">
{I18n.t('post.comments.post_update_badge')}
</span>
:
null
}
</div>
@@ -120,10 +110,8 @@ class Comment extends React.Component<Props, State> {
id={id}
initialBody={body}
initialIsPostUpdate={isPostUpdate}
attachmentUrls={attachmentUrls}
isPowerUser={isPowerUser}
tenantSetting={tenantSetting}
handleUpdateComment={this._handleUpdateComment}
toggleEditMode={this.toggleEditMode}
@@ -138,11 +126,6 @@ class Comment extends React.Component<Props, State> {
{body}
</ReactMarkdown>
{
attachmentUrls && attachmentUrls.length > 0 &&
<CommentAttachments attachmentUrls={attachmentUrls} />
}
<CommentFooter
id={id}
createdAt={createdAt}
@@ -170,11 +153,9 @@ class Comment extends React.Component<Props, State> {
handlePostUpdateFlag={() => null}
handleSubmit={handleSubmitComment}
allowAttachmentUpload={tenantSetting.allow_attachment_upload}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
userEmail={currentUserEmail}
userAvatar={currentUserAvatar}
/>
:
null

View File

@@ -1,20 +0,0 @@
import React from 'react';
interface Props {
attachmentUrls?: string[];
}
const CommentAttachments = ({ attachmentUrls = [] }: Props) => (
attachmentUrls.length > 0 &&
<div className="commentAttachments">
{
attachmentUrls.map((url, index) => (
<a key={index} href={url} target="_blank">
<img src={url} className="commentAttachment" />
</a>
))
}
</div>
);
export default CommentAttachments;

View File

@@ -3,28 +3,22 @@ import I18n from 'i18n-js';
import Button from '../common/Button';
import Switch from '../common/Switch';
import ActionLink from '../common/ActionLink';
import { CancelIcon, DeleteIcon, MarkdownIcon } from '../common/Icons';
import Dropzone from '../common/Dropzone';
import ITenantSetting from '../../interfaces/ITenantSetting';
import { CancelIcon, MarkdownIcon } from '../common/Icons';
interface Props {
id: number;
initialBody: string;
initialIsPostUpdate: boolean;
attachmentUrls?: string[];
isPowerUser: boolean;
tenantSetting: ITenantSetting;
handleUpdateComment(body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[]): void;
handleUpdateComment(body: string, isPostUpdate: boolean): void;
toggleEditMode(): void;
}
interface State {
body: string;
isPostUpdate: boolean;
attachmentsToDelete: number[];
attachments: File[];
}
class CommentEditForm extends React.Component<Props, State> {
@@ -34,14 +28,10 @@ class CommentEditForm extends React.Component<Props, State> {
this.state = {
body: '',
isPostUpdate: false,
attachmentsToDelete: [],
attachments: [],
};
this.handleCommentBodyChange = this.handleCommentBodyChange.bind(this);
this.handleCommentIsPostUpdateChange = this.handleCommentIsPostUpdateChange.bind(this);
this.handleAttachmentsToDeleteChange = this.handleAttachmentsToDeleteChange.bind(this);
this.handleAttachmentsChange = this.handleAttachmentsChange.bind(this);
}
componentDidMount() {
@@ -59,17 +49,9 @@ class CommentEditForm extends React.Component<Props, State> {
this.setState({ isPostUpdate: newIsPostUpdate });
}
handleAttachmentsToDeleteChange(newAttachmentsToDelete: number[]) {
this.setState({ attachmentsToDelete: newAttachmentsToDelete });
}
handleAttachmentsChange(newAttachments: File[]) {
this.setState({ attachments: newAttachments });
}
render() {
const { id, attachmentUrls, isPowerUser, tenantSetting, handleUpdateComment, toggleEditMode } = this.props;
const { body, isPostUpdate, attachmentsToDelete, attachments } = this.state;
const { id, isPowerUser, handleUpdateComment, toggleEditMode } = this.props;
const { body, isPostUpdate } = this.state;
return (
<div className="editCommentForm">
@@ -87,81 +69,27 @@ class CommentEditForm extends React.Component<Props, State> {
</div>
</div>
<div className="editCommentFormAttachments">
{ /* Attachments */ }
<div className="thumbnailsContainer" style={{ display: attachmentUrls && attachmentUrls.length > 0 ? 'flex' : 'none' }}>
{
attachmentUrls && attachmentUrls.map((attachmentUrl, i) => (
<div className="thumbnailContainer" key={i}>
<div className={`thumbnail${attachmentsToDelete.includes(i) ? ' thumbnailToDelete' : ''}`}>
<div className="thumbnailInner">
<img
src={attachmentUrl}
className="thumbnailImage"
/>
</div>
</div>
{
attachmentsToDelete.includes(i) ?
<ActionLink
onClick={() => this.handleAttachmentsToDeleteChange(attachmentsToDelete.filter(index => index !== i))}
icon={<CancelIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => this.handleAttachmentsToDeleteChange([...attachmentsToDelete, i])}
icon={<DeleteIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
}
</div>
))
}
<div>
<div>
{
isPowerUser &&
<Switch
htmlId={`isPostUpdateFlagComment${id}`}
onClick={e => this.handleCommentIsPostUpdateChange(!isPostUpdate)}
checked={isPostUpdate || false}
label={I18n.t('post.new_comment.is_post_update')}
/>
}
</div>
{ /* Attachments dropzone */ }
{
tenantSetting.allow_attachment_upload &&
<div className="form-group">
<Dropzone
files={attachments}
setFiles={this.handleAttachmentsChange}
maxSizeKB={2048}
maxFiles={5}
customStyle={{ minHeight: '60px', marginTop: '16px' }}
/>
</div>
}
<div className="editCommentFormFooter">
{ /* Is post update */ }
<div className="editCommentFormPostUpdate">
{
isPowerUser &&
<Switch
htmlId={`isPostUpdateFlagComment${id}`}
onClick={e => this.handleCommentIsPostUpdateChange(!isPostUpdate)}
checked={isPostUpdate || false}
label={I18n.t('post.new_comment.is_post_update')}
/>
}
</div>
<div className="editCommentFormActions">
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
{I18n.t('common.buttons.cancel')}
</ActionLink>
&nbsp;
<Button onClick={() => handleUpdateComment(body, isPostUpdate, attachmentsToDelete, attachments)}>
{I18n.t('common.buttons.update')}
</Button>
</div>
<div className="editCommentFormActions">
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
{I18n.t('common.buttons.cancel')}
</ActionLink>
&nbsp;
<Button onClick={() => handleUpdateComment(body, isPostUpdate)}>
{I18n.t('common.buttons.update')}
</Button>
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import I18n from 'i18n-js';
import Separator from '../common/Separator';
import { MutedText } from '../common/CustomTexts';
import friendlyDate, { fromRailsStringToJavascriptDate } from '../../helpers/datetime';
import friendlyDate from '../../helpers/datetime';
import { ReplyFormState } from '../../reducers/replyFormReducer';
import ActionLink from '../common/ActionLink';
import { CancelIcon, DeleteIcon, EditIcon, ReplyIcon } from '../common/Icons';
@@ -71,7 +71,7 @@ const CommentFooter = ({
</span>
{
fromRailsStringToJavascriptDate(updatedAt).getTime() - fromRailsStringToJavascriptDate(createdAt).getTime() > 10000 &&
createdAt !== updatedAt &&
<>
<Separator />
<MutedText>{ I18n.t('common.edited').toLowerCase() }</MutedText>

View File

@@ -4,7 +4,6 @@ import Comment from './Comment';
import IComment from '../../interfaces/IComment';
import { ReplyFormState } from '../../reducers/replyFormReducer';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props {
comments: Array<IComment>;
@@ -15,15 +14,13 @@ interface Props {
toggleCommentReply(commentId: number): void;
setCommentReplyBody(commentId: number, body: string): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean, attachments: File[], onSuccess: Function): void;
handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[], onSuccess: Function): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void;
handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function): void;
handleDeleteComment(id: number): void;
tenantSetting: ITenantSetting;
isLoggedIn: boolean;
isPowerUser: boolean;
userEmail: string;
userAvatar?: string;
}
const CommentList = ({
@@ -38,11 +35,9 @@ const CommentList = ({
handleUpdateComment,
handleDeleteComment,
tenantSetting,
isLoggedIn,
isPowerUser,
userEmail,
userAvatar,
}: Props) => (
<>
{comments.map((comment, i) => {
@@ -64,11 +59,9 @@ const CommentList = ({
{...comment}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
currentUserEmail={userEmail}
currentUserAvatar={userAvatar}
/>
<CommentList
@@ -84,11 +77,9 @@ const CommentList = ({
handleUpdateComment={handleUpdateComment}
handleDeleteComment={handleDeleteComment}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
userEmail={userEmail}
userAvatar={userAvatar}
/>
</div>
);

View File

@@ -9,15 +9,12 @@ import { DangerText, MutedText } from '../common/CustomTexts';
import IComment from '../../interfaces/IComment';
import { ReplyFormState } from '../../reducers/replyFormReducer';
import Separator from '../common/Separator';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props {
postId: number;
tenantSetting: ITenantSetting;
isLoggedIn: boolean;
isPowerUser: boolean;
userEmail: string;
userAvatar?: string;
authenticityToken: string;
comments: Array<IComment>;
@@ -35,8 +32,6 @@ interface Props {
body: string,
parentId: number,
isPostUpdate: boolean,
attachments: File[],
onSuccess: Function,
authenticityToken: string,
): void;
updateComment(
@@ -44,8 +39,6 @@ interface Props {
commentId: number,
body: string,
isPostUpdate: boolean,
attachmentsToDelete: number[],
attachments: File[],
onSuccess: Function,
authenticityToken: string,
): void;
@@ -61,26 +54,22 @@ class CommentsP extends React.Component<Props> {
this.props.requestComments(this.props.postId);
}
_handleSubmitComment = (body: string, parentId: number, isPostUpdate: boolean, attachments: File[], onSuccess: Function) => {
_handleSubmitComment = (body: string, parentId: number, isPostUpdate: boolean) => {
this.props.submitComment(
this.props.postId,
body,
parentId,
isPostUpdate,
attachments,
onSuccess,
this.props.authenticityToken,
);
}
_handleUpdateComment = (commentId: number, body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[], onSuccess: Function) => {
_handleUpdateComment = (commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function) => {
this.props.updateComment(
this.props.postId,
commentId,
body,
isPostUpdate,
attachmentsToDelete,
attachments,
onSuccess,
this.props.authenticityToken,
);
@@ -96,11 +85,9 @@ class CommentsP extends React.Component<Props> {
render() {
const {
tenantSetting,
isLoggedIn,
isPowerUser,
userEmail,
userAvatar,
comments,
replyForms,
@@ -130,11 +117,9 @@ class CommentsP extends React.Component<Props> {
handlePostUpdateFlag={toggleCommentIsPostUpdateFlag}
handleSubmit={this._handleSubmitComment}
allowAttachmentUpload={tenantSetting.allow_attachment_upload}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
userEmail={userEmail}
userAvatar={userAvatar}
/>
<div className="commentsTitle">
@@ -157,11 +142,9 @@ class CommentsP extends React.Component<Props> {
handleDeleteComment={this._handleDeleteComment}
parentId={null}
level={1}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
userEmail={userEmail}
userAvatar={userAvatar}
/>
</div>
);

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js';
import NewCommentUpdateSection from './NewCommentUpdateSection';
@@ -6,10 +7,7 @@ import NewCommentUpdateSection from './NewCommentUpdateSection';
import Button from '../common/Button';
import Spinner from '../common/Spinner';
import { DangerText } from '../common/CustomTexts';
import { AttachIcon, CancelIcon, MarkdownIcon } from '../common/Icons';
import Avatar from '../common/Avatar';
import ActionLink from '../common/ActionLink';
import Dropzone from '../common/Dropzone';
import { MarkdownIcon } from '../common/Icons';
interface Props {
body: string;
@@ -22,16 +20,12 @@ interface Props {
handleSubmit(
body: string,
parentId: number,
isPostUpdate: boolean,
attachments: File[],
onSuccess: Function,
isPostUpdate: boolean
): void;
allowAttachmentUpload: boolean;
isLoggedIn: boolean;
isPowerUser: boolean;
userEmail: string;
userAvatar?: string;
}
const NewComment = ({
@@ -44,106 +38,56 @@ const NewComment = ({
handlePostUpdateFlag,
handleSubmit,
allowAttachmentUpload,
isLoggedIn,
isPowerUser,
userEmail,
userAvatar,
}: Props) => {
const [isAttachingFiles, setIsAttachingFiles] = React.useState(false);
const [attachments, setAttachments] = React.useState<File[]>([]);
return (
<>
<div className="newCommentForm">
{
isLoggedIn ?
<>
<div className="newCommentBodyForm">
<Avatar avatarUrl={userAvatar} email={userEmail} size={48} customClass="currentUserAvatar" />
<div style={{width: '100%', marginRight: '8px'}}>
<textarea
value={body}
onChange={handleChange}
autoFocus={parentId != null}
placeholder={I18n.t('post.new_comment.body_placeholder')}
className="commentForm"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
}: Props) => (
<>
<div className="newCommentForm">
{
isLoggedIn ?
<>
<div className="commentBodyForm">
<Gravatar email={userEmail} size={48} className="currentUserAvatar" />
<div style={{width: '100%', marginRight: '8px'}}>
<textarea
value={body}
onChange={handleChange}
autoFocus={parentId != null}
placeholder={I18n.t('post.new_comment.body_placeholder')}
className="commentForm"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
<Button
onClick={() => {
handleSubmit(
body,
parentId,
postUpdateFlagValue,
attachments,
() => { setIsAttachingFiles(false); setAttachments([]); }
);
}}
className="submitCommentButton">
{ isSubmitting ? <Spinner color="light" /> : I18n.t('post.new_comment.submit_button') }
</Button>
</div>
<div className="newCommentFooter">
{ /* Attach files */ }
<div className={`attachFilesSection${allowAttachmentUpload ? '' : ' attachFilesSectionHidden'}`}>
{
isAttachingFiles ?
<ActionLink
icon={<CancelIcon />}
onClick={() => { setIsAttachingFiles(false); setAttachments([]); }}
customClass='cancelAttachFilesNewComment'
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
icon={<AttachIcon />}
onClick={() => setIsAttachingFiles(true)}
customClass='showAttachFilesNewComment'
>
{I18n.t('common.buttons.attach')}
</ActionLink>
}
{
isAttachingFiles &&
<Dropzone
files={attachments}
setFiles={setAttachments}
maxSizeKB={2048}
maxFiles={5}
customStyle={{ minHeight: '60px', marginTop: '16px' }}
/>
}
</div>
<Button
onClick={() => handleSubmit(body, parentId, postUpdateFlagValue)}
className="submitCommentButton">
{ isSubmitting ? <Spinner color="light" /> : I18n.t('post.new_comment.submit_button') }
</Button>
</div>
{
isPowerUser && parentId == null ?
<NewCommentUpdateSection
postUpdateFlagValue={postUpdateFlagValue}
handlePostUpdateFlag={handlePostUpdateFlag}
/>
:
null
}
</>
:
<a href="/users/sign_in" className="loginInfo">
{I18n.t('post.new_comment.not_logged_in')}
</a>
}
</div>
{ /* Post update flag */ }
{
isPowerUser && parentId == null &&
<NewCommentUpdateSection
postUpdateFlagValue={postUpdateFlagValue}
handlePostUpdateFlag={handlePostUpdateFlag}
allowAttachmentUpload={allowAttachmentUpload}
/>
}
</div>
</>
:
<a href="/users/sign_in" className="loginInfo">
{I18n.t('post.new_comment.not_logged_in')}
</a>
}
</div>
{ error ? <DangerText>{error}</DangerText> : null }
</>
);
};
{ error ? <DangerText>{error}</DangerText> : null }
</>
);
export default NewComment;

View File

@@ -7,15 +7,13 @@ import Switch from '../common/Switch';
interface Props {
postUpdateFlagValue: boolean;
handlePostUpdateFlag(): void;
allowAttachmentUpload?: boolean;
}
const NewCommentUpdateSection = ({
postUpdateFlagValue,
handlePostUpdateFlag,
allowAttachmentUpload = true,
}: Props) => (
<div className={`commentIsUpdateForm${allowAttachmentUpload ? ' commentIsUpdateFormWithAttachment' : ' commentIsUpdateFormWithoutAttachment'}`}>
<div className="commentIsUpdateForm">
<Switch
htmlId="isPostUpdateFlag"
onClick={handlePostUpdateFlag}

View File

@@ -1,11 +1,11 @@
import React from 'react';
import I18n from 'i18n-js';
import Gravatar from 'react-gravatar';
import IPost, { PostApprovalStatus } from '../../../interfaces/IPost';
import { AnonymousIcon, ApproveIcon, RejectIcon } from '../../common/Icons';
import ReactMarkdown from 'react-markdown';
import ActionLink from '../../common/ActionLink';
import Avatar from '../../common/Avatar';
interface Props {
post: IPost;
@@ -25,7 +25,7 @@ const FeedbackListItem = ({ post, onUpdatePostApprovalStatus, hideRejectButton }
<div className="feedbackListItemIcon">
{
post.userId ?
<Avatar avatarUrl={post.userAvatar} email={post.userEmail} size={42} customClass="userAvatar" />
<Gravatar email={post.userEmail} size={42} title={post.userEmail} className="gravatar userGravatar" />
:
<AnonymousIcon size={42} />
}

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js';
import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_MODERATOR, USER_ROLE_OWNER, USER_ROLE_USER, USER_STATUS_ACTIVE, USER_STATUS_BLOCKED, USER_STATUS_DELETED } from "../../../interfaces/IUser";
@@ -7,7 +8,6 @@ import UserForm from "./UserForm";
import { MutedText } from "../../common/CustomTexts";
import { BlockIcon, CancelIcon, EditIcon, UnblockIcon } from "../../common/Icons";
import ActionLink from "../../common/ActionLink";
import Avatar from "../../common/Avatar";
interface Props {
user: IUser;
@@ -94,7 +94,7 @@ class UserEditable extends React.Component<Props, State> {
editMode === false ?
<>
<div className="userInfo">
<Avatar avatarUrl={user.avatarUrl} email={user.email} size={42} customClass="userAvatar" />
<Gravatar email={user.email} size={42} className="gravatar userGravatar" />
<div className="userFullNameRoleStatus">
<span className="userFullName">{ user.fullName }</span>

View File

@@ -1,10 +1,10 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js';
import Button from '../../common/Button';
import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_MODERATOR, USER_ROLE_USER } from '../../../interfaces/IUser';
import { getLabel } from '../../../helpers/formUtils';
import Avatar from '../../common/Avatar';
interface Props {
user: IUser;
@@ -44,7 +44,7 @@ class UserForm extends React.Component<Props, State> {
return (
<div className="userForm">
<Avatar avatarUrl={user.avatarUrl} email={user.email} size={42} customClass="userAvatar" />
<Gravatar email={user.email} size={42} className="gravatar userGravatar" />
<div className="userFullNameRoleForm">
<span className="userFullName">{ user.fullName }</span>

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Gravatar from 'react-gravatar';
import ILike from '../../interfaces/ILike';
import Spinner from '../common/Spinner';
@@ -8,7 +9,6 @@ import {
DangerText,
CenteredMutedText
} from '../common/CustomTexts';
import Avatar from '../common/Avatar';
interface Props {
likes: Array<ILike>;
@@ -26,8 +26,7 @@ const LikeList = ({ likes, areLoading, error}: Props) => (
{
likes.map((like, i) => (
<div className="likeListItem" key={i}>
<Avatar avatarUrl={like.userAvatar} email={like.email} size={32} />
<Gravatar email={like.email} size={32} className="gravatar" />
<div className="likeListItemUserInfo">
<span className="likeListItemName" title={like.fullName}>{like.fullName}</span>
<span className="likeListItemEmail" title={like.email}>{like.email}</span>

View File

@@ -1,20 +0,0 @@
import React from 'react';
interface Props {
attachmentUrls?: string[];
}
const PostAttachments = ({ attachmentUrls = [] }: Props) => (
attachmentUrls.length > 0 &&
<div className="postAttachments">
{
attachmentUrls.map((url, index) => (
<a key={index} href={url} target="_blank">
<img src={url} className="postAttachment" />
</a>
))
}
</div>
);
export default PostAttachments;

View File

@@ -9,17 +9,13 @@ import IBoard from '../../interfaces/IBoard';
import Button from '../common/Button';
import Spinner from '../common/Spinner';
import ActionLink from '../common/ActionLink';
import { CancelIcon, DeleteIcon } from '../common/Icons';
import ITenantSetting from '../../interfaces/ITenantSetting';
import Dropzone from '../common/Dropzone';
import { DangerText } from '../common/CustomTexts';
import { CancelIcon } from '../common/Icons';
interface Props {
title: string;
description?: string;
boardId: number;
postStatusId?: number;
attachmentUrls?: string[];
isUpdating: boolean;
error: string;
@@ -29,7 +25,6 @@ interface Props {
handleChangeBoard(boardId: number): void;
handleChangePostStatus(postStatusId: number): void;
tenantSetting: ITenantSetting;
isPowerUser: boolean;
boards: Array<IBoard>;
postStatuses: Array<IPostStatus>;
@@ -40,8 +35,6 @@ interface Props {
description: string,
boardId: number,
postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
): void;
}
@@ -50,7 +43,6 @@ const PostEditForm = ({
description,
boardId,
postStatusId,
attachmentUrls,
isUpdating,
error,
@@ -60,122 +52,59 @@ const PostEditForm = ({
handleChangeBoard,
handleChangePostStatus,
tenantSetting,
isPowerUser,
boards,
postStatuses,
toggleEditMode,
handleUpdatePost,
}: Props) => {
const [attachmentsToDelete, setAttachmentsToDelete] = React.useState<number[]>([]);
const [attachments, setAttachments] = React.useState<File[]>([]);
React.useEffect(() => {
setAttachmentsToDelete([]);
}, [attachmentUrls]);
return (
<div className="postEditForm">
<div className="postHeader">
<input
type="text"
value={title}
onChange={e => handleChangeTitle(e.target.value)}
autoFocus
className="postTitle form-control"
/>
</div>
{
isPowerUser ?
<div className="postSettings">
<PostBoardSelect
boards={boards}
selectedBoardId={boardId}
handleChange={newBoardId => handleChangeBoard(newBoardId)}
/>
<PostStatusSelect
postStatuses={postStatuses}
selectedPostStatusId={postStatusId}
handleChange={newPostStatusId => handleChangePostStatus(newPostStatusId)}
/>
</div>
:
null
}
<textarea
value={description}
onChange={e => handleChangeDescription(e.target.value)}
rows={5}
className="postDescription form-control"
}: Props) => (
<div className="postEditForm">
<div className="postHeader">
<input
type="text"
value={title}
onChange={e => handleChangeTitle(e.target.value)}
autoFocus
className="postTitle form-control"
/>
{ /* Attachments */ }
<div className="thumbnailsContainer" style={{ display: attachmentUrls && attachmentUrls.length > 0 ? 'flex' : 'none' }}>
{
attachmentUrls && attachmentUrls.map((attachmentUrl, i) => (
<div className="thumbnailContainer" key={i}>
<div className={`thumbnail${attachmentsToDelete.includes(i) ? ' thumbnailToDelete' : ''}`}>
<div className="thumbnailInner">
<img
src={attachmentUrl}
className="thumbnailImage"
/>
</div>
</div>
{
attachmentsToDelete.includes(i) ?
<ActionLink
onClick={() => setAttachmentsToDelete(attachmentsToDelete.filter(index => index !== i))}
icon={<CancelIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setAttachmentsToDelete([...attachmentsToDelete, i])}
icon={<DeleteIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
}
</div>
))
}
</div>
{ /* Attachments dropzone */ }
{
tenantSetting.allow_attachment_upload &&
<div className="form-group">
<Dropzone
files={attachments}
setFiles={setAttachments}
maxSizeKB={2048}
maxFiles={5}
customStyle={{ minHeight: '60px', marginTop: '16px' }}
/>
</div>
}
<div className="postEditFormButtons">
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
{I18n.t('common.buttons.cancel')}
</ActionLink>
&nbsp;
<Button onClick={() => handleUpdatePost(title, description, boardId, postStatusId, attachmentsToDelete, attachments)}>
{ isUpdating ? <Spinner /> : I18n.t('common.buttons.update') }
</Button>
</div>
{ error && <DangerText>{error}</DangerText> }
</div>
);
};
{
isPowerUser ?
<div className="postSettings">
<PostBoardSelect
boards={boards}
selectedBoardId={boardId}
handleChange={newBoardId => handleChangeBoard(newBoardId)}
/>
<PostStatusSelect
postStatuses={postStatuses}
selectedPostStatusId={postStatusId}
handleChange={newPostStatusId => handleChangePostStatus(newPostStatusId)}
/>
</div>
:
null
}
<textarea
value={description}
onChange={e => handleChangeDescription(e.target.value)}
rows={5}
className="postDescription form-control"
/>
<div className="postEditFormButtons">
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
{I18n.t('common.buttons.cancel')}
</ActionLink>
&nbsp;
<Button onClick={() => handleUpdatePost(title, description, boardId, postStatusId)}>
{ isUpdating ? <Spinner /> : I18n.t('common.buttons.update') }
</Button>
</div>
</div>
);
export default PostEditForm;

View File

@@ -1,11 +1,12 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js';
import { MutedText } from '../common/CustomTexts';
import friendlyDate from '../../helpers/datetime';
import Separator from '../common/Separator';
import ActionLink from '../common/ActionLink';
import { DeleteIcon, EditIcon } from '../common/Icons';
import Avatar from '../common/Avatar';
interface Props {
createdAt: string;
@@ -14,7 +15,6 @@ interface Props {
isPowerUser: boolean;
authorEmail: string;
authorFullName: string;
authorAvatar?: string;
currentUserEmail: string;
}
@@ -25,7 +25,6 @@ const PostFooter = ({
isPowerUser,
authorEmail,
authorFullName,
authorAvatar,
currentUserEmail,
}: Props) => (
<div className="postFooter">
@@ -34,7 +33,7 @@ const PostFooter = ({
authorEmail ?
<>
<span>{I18n.t('post.published_by').toLowerCase()} &nbsp;</span>
<Avatar avatarUrl={authorAvatar} email={authorEmail} size={24} customClass="postAuthorAvatar" /> &nbsp;
<Gravatar email={authorEmail} size={24} className="postAuthorAvatar" /> &nbsp;
<span>{authorFullName}</span>
</>
:

View File

@@ -9,7 +9,6 @@ import ITenantSetting from '../../interfaces/ITenantSetting';
import PostUpdateList from './PostUpdateList';
import PostEditForm from './PostEditForm';
import PostAttachments from './PostAttachments';
import PostFooter from './PostFooter';
import LikeList from './LikeList';
import ActionBox from './ActionBox';
@@ -48,7 +47,6 @@ interface Props {
isPowerUser: boolean;
currentUserFullName: string;
currentUserEmail: string;
currentUserAvatar?: string;
tenantSetting: ITenantSetting;
authenticityToken: string;
@@ -59,8 +57,6 @@ interface Props {
description: string,
boardId: number,
postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string,
): Promise<any>;
@@ -116,14 +112,7 @@ class PostP extends React.Component<Props, State> {
this.props.requestPostStatusChanges(postId);
}
_handleUpdatePost(
title: string,
description: string,
boardId: number,
postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
) {
_handleUpdatePost(title: string, description: string, boardId: number, postStatusId: number) {
const {
postId,
post,
@@ -143,8 +132,6 @@ class PostP extends React.Component<Props, State> {
description,
boardId,
postStatusId,
attachmentsToDelete,
attachments,
authenticityToken,
).then(res => {
if (res?.status !== HttpStatus.OK) return;
@@ -184,7 +171,6 @@ class PostP extends React.Component<Props, State> {
isLoggedIn,
isPowerUser,
currentUserEmail,
currentUserAvatar,
tenantSetting,
authenticityToken,
@@ -246,14 +232,12 @@ class PostP extends React.Component<Props, State> {
editMode ?
<PostEditForm
{...editForm}
attachmentUrls={postToShow.attachmentUrls}
handleChangeTitle={handleChangeEditFormTitle}
handleChangeDescription={handleChangeEditFormDescription}
handleChangeBoard={handleChangeEditFormBoard}
handleChangePostStatus={handleChangeEditFormPostStatus}
tenantSetting={tenantSetting}
isPowerUser={isPowerUser}
boards={boards}
postStatuses={postStatuses}
@@ -308,10 +292,6 @@ class PostP extends React.Component<Props, State> {
{postToShow.description}
</ReactMarkdown>
<PostAttachments
attachmentUrls={postToShow?.attachmentUrls}
/>
<PostFooter
createdAt={postToShow.createdAt}
handleDeletePost={this._handleDeletePost}
@@ -320,7 +300,6 @@ class PostP extends React.Component<Props, State> {
isPowerUser={isPowerUser}
authorEmail={postToShow.userEmail}
authorFullName={postToShow.userFullName}
authorAvatar={originPost.authorAvatar}
currentUserEmail={currentUserEmail}
/>
</>
@@ -328,11 +307,9 @@ class PostP extends React.Component<Props, State> {
<Comments
postId={this.props.postId}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
userEmail={currentUserEmail}
userAvatar={currentUserAvatar}
authenticityToken={authenticityToken}
/>
</div>

View File

@@ -1,8 +1,9 @@
import * as React from 'react';
import ReactMarkdown from 'react-markdown';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js';
import { DangerText, CenteredMutedText } from '../common/CustomTexts';
import { DangerText, CenteredMutedText, MutedText } from '../common/CustomTexts';
import Spinner from '../common/Spinner';
import IComment from '../../interfaces/IComment';
@@ -12,7 +13,6 @@ import IPostStatus from '../../interfaces/IPostStatus';
import friendlyDate from '../../helpers/datetime';
import PostStatusLabel from '../common/PostStatusLabel';
import SidebarBox from '../common/SidebarBox';
import Avatar from '../common/Avatar';
interface Props {
postUpdates: Array<IComment | IPostStatusChange>;
@@ -42,8 +42,7 @@ const PostUpdateList = ({
postUpdates.map((postUpdate, i) => (
<div className="postUpdateListItem" key={i}>
<div className="postUpdateListItemHeader">
<Avatar avatarUrl={postUpdate.userAvatar} email={postUpdate.userEmail} size={28} />
<Gravatar email={postUpdate.userEmail} size={28} className="gravatar" />
<span>{postUpdate.userFullName}</span>
</div>

View File

@@ -21,7 +21,6 @@ interface Props {
isPowerUser: boolean;
currentUserFullName: string;
currentUserEmail: string;
currentUserAvatar?: string;
tenantSetting: ITenantSetting;
authenticityToken: string;
}
@@ -45,7 +44,6 @@ class PostRoot extends React.Component<Props> {
isPowerUser,
currentUserFullName,
currentUserEmail,
currentUserAvatar,
tenantSetting,
authenticityToken
} = this.props;
@@ -62,7 +60,6 @@ class PostRoot extends React.Component<Props> {
isPowerUser={isPowerUser}
currentUserFullName={currentUserFullName}
currentUserEmail={currentUserEmail}
currentUserAvatar={currentUserAvatar}
tenantSetting={tenantSetting}
authenticityToken={authenticityToken}
/>

View File

@@ -78,7 +78,7 @@ const AppearanceSiteSettingsP = ({
<p style={{textAlign: 'left'}}>
<ActionLink
onClick={() => window.open('https://docs.astuto.io/appearance-customization/', '_blank')}
onClick={() => window.open('https://github.com/astuto/astuto-docs/blob/main/docs/appearance.md', '_blank')}
icon={<LearnMoreIcon />}
>
{I18n.t('site_settings.appearance.learn_more')}

View File

@@ -8,8 +8,8 @@ import { DangerText } from '../../common/CustomTexts';
import { IOAuth } from '../../../interfaces/IOAuth';
interface Props {
handleSubmitOAuth(oAuth: IOAuth, oAuthLogo: File): void;
handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean): void;
handleSubmitOAuth(oAuth: IOAuth): void;
handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm): void;
isSubmitting: boolean;
submitError: string;
selectedOAuth: IOAuth;

View File

@@ -16,8 +16,8 @@ interface Props {
oAuths: OAuthsState;
requestOAuths(): void;
onSubmitOAuth(oAuth: IOAuth, oAuthLogo: File, authenticityToken: string): Promise<any>;
onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean, authenticityToken: string): Promise<any>;
onSubmitOAuth(oAuth: IOAuth, authenticityToken: string): Promise<any>;
onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, authenticityToken: string): Promise<any>;
onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string): void;
onToggleEnabledDefaultOAuth(id: number, isEnabled: boolean, authenticityToken: string): void;
onDeleteOAuth(id: number, authenticityToken: string): void;
@@ -50,14 +50,14 @@ const AuthenticationSiteSettingsP = ({
useEffect(requestOAuths, []);
const handleSubmitOAuth = (oAuth: IOAuth, oAuthLogo: File) => {
onSubmitOAuth(oAuth, oAuthLogo, authenticityToken).then(res => {
const handleSubmitOAuth = (oAuth: IOAuth) => {
onSubmitOAuth(oAuth, authenticityToken).then(res => {
if (res?.status === HttpStatus.Created) setPage('index');
});
};
const handleUpdateOAuth = (id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean) => {
onUpdateOAuth(id, form, shouldDeleteLogo, authenticityToken).then(res => {
const handleUpdateOAuth = (id: number, form: ISiteSettingsOAuthForm) => {
onUpdateOAuth(id, form, authenticityToken).then(res => {
if (res?.status === HttpStatus.OK) setPage('index');
});
};

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { SubmitHandler, useForm } from 'react-hook-form';
import { DangerText } from '../../common/CustomTexts';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
@@ -11,22 +11,20 @@ import { AuthenticationPages } from './AuthenticationSiteSettingsP';
import { useState } from 'react';
import Separator from '../../common/Separator';
import ActionLink from '../../common/ActionLink';
import { BackIcon, CancelIcon, DeleteIcon, EditIcon } from '../../common/Icons';
import Dropzone from '../../common/Dropzone';
import { BackIcon } from '../../common/Icons';
interface Props {
selectedOAuth: IOAuth;
page: AuthenticationPages;
setPage: React.Dispatch<React.SetStateAction<AuthenticationPages>>;
handleSubmitOAuth(oAuth: IOAuth, oAuthLogo: File): void;
handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean): void;
handleSubmitOAuth(oAuth: IOAuth): void;
handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm): void;
}
export interface ISiteSettingsOAuthForm {
name: string;
logo?: File;
shouldDeleteLogo: boolean;
logo: string;
clientId: string;
clientSecret: string;
authorizeUrl: string;
@@ -50,14 +48,11 @@ const OAuthForm = ({
const {
register,
handleSubmit,
formState: { errors, isDirty },
control,
watch,
formState: { errors, isDirty }
} = useForm<ISiteSettingsOAuthForm>({
defaultValues: page === 'new' ? {
name: '',
logo: null,
shouldDeleteLogo: false,
logo: '',
clientId: '',
clientSecret: '',
authorizeUrl: '',
@@ -68,8 +63,7 @@ const OAuthForm = ({
jsonUserNamePath: '',
} : {
name: selectedOAuth.name,
logo: null,
shouldDeleteLogo: false,
logo: selectedOAuth.logo,
clientId: selectedOAuth.clientId,
clientSecret: selectedOAuth.clientSecret,
authorizeUrl: selectedOAuth.authorizeUrl,
@@ -85,26 +79,16 @@ const OAuthForm = ({
const oAuth = { ...data, isEnabled: false };
if (page === 'new') {
handleSubmitOAuth(
oAuth,
data.logo ? data.logo : null
);
handleSubmitOAuth(oAuth);
} else if (page === 'edit') {
if (editClientSecret === false) {
delete oAuth.clientSecret;
}
handleUpdateOAuth(
selectedOAuth.id,
oAuth as ISiteSettingsOAuthForm,
data.shouldDeleteLogo
);
handleUpdateOAuth(selectedOAuth.id, oAuth as ISiteSettingsOAuthForm);
}
};
const shouldDeleteLogo = watch('shouldDeleteLogo');
const [showOAuthLogoDropzone, setShowOAuthLogoDropzone] = React.useState([null, undefined, ''].includes(selectedOAuth?.logoUrl));
return (
<>
<ActionLink
@@ -135,81 +119,13 @@ const OAuthForm = ({
<div className="formGroup col-6">
<label htmlFor="logo">{ getLabel('o_auth', 'logo') }</label>
{
selectedOAuth && selectedOAuth.logoUrl &&
<div className={`oAuthLogoPreview${shouldDeleteLogo ? ' oAuthLogoPreviewShouldDelete' : ''}`}>
<img src={selectedOAuth.logoUrl} alt={`${selectedOAuth.name} OAuth logo`} className="oAuthLogoPreviewImg" />
</div>
}
<div className="oAuthLogoActions">
{
(selectedOAuth && selectedOAuth.logoUrl && !shouldDeleteLogo) &&
(showOAuthLogoDropzone ?
<ActionLink
onClick={() => setShowOAuthLogoDropzone(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setShowOAuthLogoDropzone(true)}
icon={<EditIcon />}
>
{I18n.t('common.buttons.edit')}
</ActionLink>)
}
{
(selectedOAuth && selectedOAuth.logoUrl && !showOAuthLogoDropzone) &&
(shouldDeleteLogo ?
<Controller
name="shouldDeleteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
)}
/>
:
<Controller
name="shouldDeleteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(true)}
icon={<DeleteIcon />}
>
{I18n.t('common.buttons.delete')}
</ActionLink>
)}
/>
)
}
</div>
{
showOAuthLogoDropzone &&
<Controller
name="logo"
control={control}
render={({ field }) => (
<Dropzone
files={field.value ? [field.value] : []}
setFiles={files => files.length > 0 ? field.onChange(files[0]) : field.onChange(null)}
maxSizeKB={64}
maxFiles={1}
/>
)}
/>
}
</div>
<input
{...register('logo')}
placeholder='https://example.com/logo.png'
id="logo"
className="formControl"
/>
</div>
</div>
<h5>{ I18n.t('site_settings.authentication.form.subtitle_oauth_config') }</h5>

View File

@@ -29,8 +29,8 @@ const OAuthProviderItem = ({
<li className="oAuthListItem">
<div className="oAuthInfo">
{
oAuth.logoUrl && oAuth.logoUrl.length > 0 ?
<img src={oAuth.logoUrl} className="oAuthLogo" width={42} height={42} />
oAuth.logo && oAuth.logo.length > 0 ?
<img src={oAuth.logo} className="oAuthLogo" width={42} height={42} />
:
<div className="oauthLogo" style={{width: 42, height: 42}}></div>
}

View File

@@ -28,14 +28,14 @@ const OAuthProvidersList = ({
<>
<div className="oauthProvidersTitle">
<h4>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h4>
<Button onClick={() => { setSelectedOAuth(null); setPage('new'); }}>
<Button onClick={() => setPage('new')}>
{ I18n.t('common.buttons.new') }
</Button>
</div>
<p style={{textAlign: 'left'}}>
<ActionLink
onClick={() => window.open('https://docs.astuto.io/oauth/oauth-configuration-basics', '_blank')}
onClick={() => window.open('https://github.com/astuto/astuto-docs/blob/main/docs/oauth/oauth-configuration-basics.md', '_blank')}
icon={<LearnMoreIcon />}
>
{I18n.t('site_settings.authentication.learn_more')}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useForm, SubmitHandler, Controller } from 'react-hook-form';
import { useForm, SubmitHandler } from 'react-hook-form';
import I18n from 'i18n-js';
import Box from '../../common/Box';
@@ -21,16 +21,11 @@ import { DangerText, SmallMutedText } from '../../common/CustomTexts';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
import IBoardJSON from '../../../interfaces/json/IBoard';
import ActionLink from '../../common/ActionLink';
import { CancelIcon, DeleteIcon, EditIcon, LearnMoreIcon } from '../../common/Icons';
import Dropzone from '../../common/Dropzone';
import { LearnMoreIcon } from '../../common/Icons';
export interface ISiteSettingsGeneralForm {
siteName: string;
siteLogo?: File;
shouldDeleteSiteLogo: boolean;
oldSiteLogo: string;
siteFavicon?: File;
shouldDeleteSiteFavicon: boolean;
siteLogo: string;
brandDisplaySetting: string;
locale: string;
useBrowserLocale: boolean;
@@ -39,7 +34,6 @@ export interface ISiteSettingsGeneralForm {
isPrivate: boolean;
allowAnonymousFeedback: boolean;
feedbackApprovalPolicy: string;
allowAttachmentUpload: boolean;
logoLinksTo: string;
logoCustomUrl?: string;
showRoadmapInHeader: boolean;
@@ -52,8 +46,6 @@ export interface ISiteSettingsGeneralForm {
interface Props {
originForm: ISiteSettingsGeneralForm;
siteLogoUrl?: string;
siteFaviconUrl?: string;
boards: IBoardJSON[];
isMultiTenant: boolean;
authenticityToken: string;
@@ -63,11 +55,7 @@ interface Props {
updateTenant(
siteName: string,
siteLogo: File,
shouldDeleteSiteLogo: boolean,
oldSiteLogo: string,
siteFavicon: File,
shouldDeleteSiteFavicon: boolean,
siteLogo: string,
brandDisplaySetting: string,
locale: string,
useBrowserLocale: boolean,
@@ -76,7 +64,6 @@ interface Props {
isPrivate: boolean,
allowAnonymousFeedback: boolean,
feedbackApprovalPolicy: string,
allowAttachmentUpload: boolean,
logoLinksTo: string,
logoCustomUrl: string,
showRoadmapInHeader: boolean,
@@ -91,8 +78,6 @@ interface Props {
const GeneralSiteSettingsP = ({
originForm,
siteLogoUrl,
siteFaviconUrl,
boards,
isMultiTenant,
authenticityToken,
@@ -106,15 +91,10 @@ const GeneralSiteSettingsP = ({
handleSubmit,
formState: { isDirty, isSubmitSuccessful, errors },
watch,
control,
} = useForm<ISiteSettingsGeneralForm>({
defaultValues: {
siteName: originForm.siteName,
siteLogo: null,
shouldDeleteSiteLogo: false,
oldSiteLogo: originForm.oldSiteLogo,
siteFavicon: null,
shouldDeleteSiteFavicon: false,
siteLogo: originForm.siteLogo,
brandDisplaySetting: originForm.brandDisplaySetting,
locale: originForm.locale,
useBrowserLocale: originForm.useBrowserLocale,
@@ -123,7 +103,6 @@ const GeneralSiteSettingsP = ({
isPrivate: originForm.isPrivate,
allowAnonymousFeedback: originForm.allowAnonymousFeedback,
feedbackApprovalPolicy: originForm.feedbackApprovalPolicy,
allowAttachmentUpload: originForm.allowAttachmentUpload,
logoLinksTo: originForm.logoLinksTo,
logoCustomUrl: originForm.logoCustomUrl,
showRoadmapInHeader: originForm.showRoadmapInHeader,
@@ -138,11 +117,7 @@ const GeneralSiteSettingsP = ({
const onSubmit: SubmitHandler<ISiteSettingsGeneralForm> = data => {
updateTenant(
data.siteName,
data.siteLogo ? data.siteLogo : null,
data.shouldDeleteSiteLogo,
data.oldSiteLogo,
data.siteFavicon ? data.siteFavicon : null,
data.shouldDeleteSiteFavicon,
data.siteLogo,
data.brandDisplaySetting,
data.locale,
data.useBrowserLocale,
@@ -151,7 +126,6 @@ const GeneralSiteSettingsP = ({
data.isPrivate,
data.allowAnonymousFeedback,
data.feedbackApprovalPolicy,
data.allowAttachmentUpload,
data.logoLinksTo,
data.logoCustomUrl,
data.showRoadmapInHeader,
@@ -170,6 +144,8 @@ const GeneralSiteSettingsP = ({
});
};
const customDomain = watch('customDomain');
React.useEffect(() => {
if (window.location.hash) {
const anchor = window.location.hash.substring(1);
@@ -187,13 +163,6 @@ const GeneralSiteSettingsP = ({
}
}, []);
const customDomain = watch('customDomain');
const shouldDeleteSiteLogo = watch('shouldDeleteSiteLogo');
const shouldDeleteSiteFavicon = watch('shouldDeleteSiteFavicon');
const [showSiteLogoDropzone, setShowSiteLogoDropzone] = React.useState([null, undefined, ''].includes(siteLogoUrl));
const [showSiteFaviconDropzone, setShowSiteFaviconDropzone] = React.useState([null, undefined, ''].includes(siteFaviconUrl));
return (
<>
<Box customClass="generalSiteSettingsContainer">
@@ -201,7 +170,7 @@ const GeneralSiteSettingsP = ({
<form onSubmit={handleSubmit(onSubmit)}>
<div className="formRow">
<div className="formGroup col-6">
<div className="formGroup col-4">
<label htmlFor="siteName">{ getLabel('tenant', 'site_name') }</label>
<input
{...register('siteName', { required: true })}
@@ -211,7 +180,17 @@ const GeneralSiteSettingsP = ({
<DangerText>{errors.siteName && getValidationMessage(errors.siteName.type, 'tenant', 'site_name')}</DangerText>
</div>
<div className="formGroup col-6">
<div className="formGroup col-4">
<label htmlFor="siteLogo">{ getLabel('tenant', 'site_logo') }</label>
<input
{...register('siteLogo')}
placeholder='https://example.com/logo.png'
id="siteLogo"
className="formControl"
/>
</div>
<div className="formGroup col-4">
<label htmlFor="brandSetting">{ getLabel('tenant_setting', 'brand_display') }</label>
<select
{...register('brandDisplaySetting')}
@@ -232,177 +211,6 @@ const GeneralSiteSettingsP = ({
</option>
</select>
</div>
{/* Hidden oldSiteLogo field for backwards compatibility */}
<div className="formGroup d-none">
<label htmlFor="oldSiteLogo">{ getLabel('tenant', 'site_logo') }</label>
<input
{...register('oldSiteLogo')}
placeholder='https://example.com/logo.png'
id="oldSiteLogo"
className="formControl"
/>
</div>
</div>
<div className="formRow">
<div className="formGroup col-6">
<label htmlFor="siteLogo">{ getLabel('tenant', 'site_logo') }</label>
{
siteLogoUrl &&
<div className={`siteLogoPreview${shouldDeleteSiteLogo ? ' siteLogoPreviewShouldDelete' : ''}`}>
<img src={siteLogoUrl} alt={`${originForm.siteName} logo`} className="siteLogoPreviewImg" />
</div>
}
<div className="siteLogoActions">
{
(siteLogoUrl && !shouldDeleteSiteLogo) &&
(showSiteLogoDropzone ?
<ActionLink
onClick={() => setShowSiteLogoDropzone(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setShowSiteLogoDropzone(true)}
icon={<EditIcon />}
>
{I18n.t('common.buttons.edit')}
</ActionLink>)
}
{
(siteLogoUrl && !showSiteLogoDropzone) &&
(shouldDeleteSiteLogo ?
<Controller
name="shouldDeleteSiteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
)}
/>
:
<Controller
name="shouldDeleteSiteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(true)}
icon={<DeleteIcon />}
>
{I18n.t('common.buttons.delete')}
</ActionLink>
)}
/>
)
}
</div>
{
showSiteLogoDropzone &&
<Controller
name="siteLogo"
control={control}
render={({ field }) => (
<Dropzone
files={field.value ? [field.value] : []}
setFiles={files => files.length > 0 ? field.onChange(files[0]) : field.onChange(null)}
maxSizeKB={256}
maxFiles={1}
/>
)}
/>
}
</div>
<div className="formGroup col-6">
<label htmlFor="siteFavicon">{ getLabel('tenant', 'site_favicon') }</label>
{
siteFaviconUrl &&
<div className={`siteFaviconPreview${shouldDeleteSiteFavicon ? ' siteFaviconPreviewShouldDelete' : ''}`}>
<img src={siteFaviconUrl} alt={`${originForm.siteName} favicon`} className="siteFaviconPreviewImg" />
</div>
}
<div className="siteFaviconActions">
{
(siteFaviconUrl && !shouldDeleteSiteFavicon) &&
(showSiteFaviconDropzone ?
<ActionLink
onClick={() => setShowSiteFaviconDropzone(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setShowSiteFaviconDropzone(true)}
icon={<EditIcon />}
>
{I18n.t('common.buttons.edit')}
</ActionLink>)
}
{
(siteFaviconUrl && !showSiteFaviconDropzone) &&
(shouldDeleteSiteFavicon ?
<Controller
name="shouldDeleteSiteFavicon"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
)}
/>
:
<Controller
name="shouldDeleteSiteFavicon"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(true)}
icon={<DeleteIcon />}
>
{I18n.t('common.buttons.delete')}
</ActionLink>
)}
/>
)
}
</div>
{
showSiteFaviconDropzone &&
<Controller
name="siteFavicon"
control={control}
render={({ field }) => (
<Dropzone
files={field.value ? [field.value] : []}
setFiles={files => files.length > 0 ? field.onChange(files[0]) : field.onChange(null)}
maxSizeKB={64}
maxFiles={1}
accept={['image/x-icon', 'image/icon', 'image/png', 'image/jpeg', 'image/jpg']}
/>
)}
/>
}
</div>
</div>
<div className="formGroup">
@@ -470,7 +278,7 @@ const GeneralSiteSettingsP = ({
}
<div style={{marginTop: 8}}>
<ActionLink
onClick={() => window.open('https://docs.astuto.io/custom-domain', '_blank')}
onClick={() => window.open('https://github.com/astuto/astuto-docs/blob/main/docs/custom-domain.md', '_blank')}
icon={<LearnMoreIcon />}
>
{I18n.t('site_settings.general.custom_domain_learn_more')}
@@ -527,16 +335,6 @@ const GeneralSiteSettingsP = ({
{ I18n.t('site_settings.general.feedback_approval_policy_help') }
</SmallMutedText>
</div>
<div className="formGroup">
<div className="checkboxSwitch">
<input {...register('allowAttachmentUpload')} type="checkbox" id="allow_attachment_upload" />
<label htmlFor="allow_attachment_upload">{ getLabel('tenant_setting', 'allow_attachment_upload') }</label>
<SmallMutedText>
{ I18n.t('site_settings.general.allow_attachment_upload_help') }
</SmallMutedText>
</div>
</div>
</div>
<div id="header" className="settingsGroup">

View File

@@ -10,8 +10,6 @@ import { ISiteSettingsGeneralForm } from './GeneralSiteSettingsP';
interface Props {
originForm: ISiteSettingsGeneralForm;
siteLogoUrl?: string;
siteFaviconUrl?: string;
boards: IBoardJSON[];
isMultiTenant: boolean;
authenticityToken: string;
@@ -31,8 +29,6 @@ class GeneralSiteSettingsRoot extends React.Component<Props> {
<Provider store={this.store}>
<GeneralSiteSettings
originForm={this.props.originForm}
siteLogoUrl={this.props.siteLogoUrl}
siteFaviconUrl={this.props.siteFaviconUrl}
boards={this.props.boards}
isMultiTenant={this.props.isMultiTenant}
authenticityToken={this.props.authenticityToken}

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
import { useForm } from 'react-hook-form';
import I18n from 'i18n-js';
@@ -12,7 +13,6 @@ import IInvitation from '../../../interfaces/IInvitation';
import friendlyDate, { fromRailsStringToJavascriptDate, nMonthsAgo } from '../../../helpers/datetime';
import ActionLink from '../../common/ActionLink';
import { TestIcon } from '../../common/Icons';
import Avatar from '../../common/Avatar';
interface Props {
siteName: string;
@@ -218,8 +218,7 @@ const Invitations = ({ siteName, invitations, currentUserEmail, authenticityToke
invitationsToDisplay.map((invitation, i) => (
<li key={i} className="invitationListItem">
<div className="invitationUserInfo">
<Avatar email={invitation.email} size={42} customClass="userAvatar" />
<Gravatar email={invitation.email} size={42} className="gravatar userGravatar" />
<span className="invitationEmail">{ invitation.email }</span>
</div>

View File

@@ -48,7 +48,7 @@ const WebhooksIndexPage = ({
<p style={{textAlign: 'left'}}>
<ActionLink
onClick={() => window.open('https://docs.astuto.io/webhooks/webhooks-introduction/', '_blank')}
onClick={() => window.open('https://github.com/astuto/astuto-docs/blob/main/docs/webhooks/webhooks-introduction.md', '_blank')}
icon={<LearnMoreIcon />}
>
{I18n.t('site_settings.webhooks.learn_more')}

View File

@@ -7,6 +7,7 @@ import ConfirmEmailSignUpPage from './ConfirmEmailSignUpPage';
import ConfirmOAuthSignUpPage from './ConfirmOAuthSignUpPage';
import { IOAuth } from '../../interfaces/IOAuth';
import HttpStatus from '../../constants/http_status';
import Box from '../common/Box';
interface Props {
oAuthLoginCompleted: boolean;
@@ -116,57 +117,70 @@ const TenantSignUpP = ({
});
}
// return (
// <>
// <img src={astutoLogoImage} width={64} height={64} className="astutoLogo" />
// <div className="tenantSignUpContainer">
// {
// (currentStep === 1 || currentStep === 2) &&
// <UserSignUpForm
// currentStep={currentStep}
// setCurrentStep={setCurrentStep}
// authMethod={authMethod}
// setAuthMethod={setAuthMethod}
// oAuths={oAuths}
// userData={userData}
// setUserData={setUserData}
// setGoneBack={setGoneBack}
// />
// }
// {
// (goneBack || currentStep === 2) &&
// <TenantSignUpForm
// isSubmitting={isSubmitting}
// error={error}
// handleSignUpSubmit={handleSignUpSubmit}
// trialPeriodDays={trialPeriodDays}
// currentStep={currentStep}
// setCurrentStep={setCurrentStep}
// />
// }
// {
// currentStep === 3 && authMethod === 'oauth' &&
// <ConfirmOAuthSignUpPage
// baseUrl={baseUrl}
// subdomain={tenantData.subdomain}
// feedbackSpaceCreatedImage={feedbackSpaceCreatedImage}
// />
// }
// {
// currentStep === 3 && authMethod === 'email' &&
// <ConfirmEmailSignUpPage
// subdomain={tenantData.subdomain}
// userEmail={userData.email}
// pendingTenantImage={pendingTenantImage}
// />
// }
// </div>
// </>
// );
return (
<>
<img src={astutoLogoImage} width={64} height={64} className="astutoLogo" />
<div className="tenantSignUpContainer">
{
(currentStep === 1 || currentStep === 2) &&
<UserSignUpForm
currentStep={currentStep}
setCurrentStep={setCurrentStep}
authMethod={authMethod}
setAuthMethod={setAuthMethod}
oAuths={oAuths}
userData={userData}
setUserData={setUserData}
setGoneBack={setGoneBack}
/>
}
{
(goneBack || currentStep === 2) &&
<TenantSignUpForm
isSubmitting={isSubmitting}
error={error}
handleSignUpSubmit={handleSignUpSubmit}
trialPeriodDays={trialPeriodDays}
currentStep={currentStep}
setCurrentStep={setCurrentStep}
/>
}
{
currentStep === 3 && authMethod === 'oauth' &&
<ConfirmOAuthSignUpPage
baseUrl={baseUrl}
subdomain={tenantData.subdomain}
feedbackSpaceCreatedImage={feedbackSpaceCreatedImage}
/>
}
{
currentStep === 3 && authMethod === 'email' &&
<ConfirmEmailSignUpPage
subdomain={tenantData.subdomain}
userEmail={userData.email}
pendingTenantImage={pendingTenantImage}
/>
}
</div>
<img src={astutoLogoImage} width={64} height={64} className="astutoLogo" />
<div className="tenantSignUpContainer">
<Box>
<p>It is not possible to sign up to Astuto.</p>
<p>You can <a href="https://github.com/astuto/astuto">self-host your own instance</a> instead.</p>
</Box>
</div>
</>
);
)
}
export default TenantSignUpP;

View File

@@ -78,6 +78,7 @@ const UserSignUpForm = ({
<OAuthProviderLink
oAuthId={oAuth.id}
oAuthName={oAuth.name}
oAuthLogo={oAuth.logo}
oAuthReason='tenantsignup'
isSignUp
key={i}

View File

@@ -1,55 +0,0 @@
import * as React from 'react';
import I18n from 'i18n-js';
import ActionLink from '../common/ActionLink';
import { DeleteIcon } from '../common/Icons';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import Spinner from '../common/Spinner';
interface Props {
deleteAvatarEndpoint: string;
userProfileUrl: string;
authenticityToken: string;
}
const DeleteAvatarButton = ({ deleteAvatarEndpoint, userProfileUrl, authenticityToken }: Props) => {
const [isDeleting, setIsDeleting] = React.useState(false);
const [error, setError] = React.useState('');
return (
<>
<ActionLink
icon={<DeleteIcon />}
onClick={async () => {
setIsDeleting(true);
try {
const response = await fetch(deleteAvatarEndpoint, {
method: 'DELETE',
headers: buildRequestHeaders(authenticityToken),
});
if (response.ok) {
window.location.href = userProfileUrl;
} else {
throw new Error();
}
} catch {
setError(I18n.t('common.errors.unknown'));
}
setIsDeleting(false);
if (error) {
alert(error);
}
}
}>
{ I18n.t('common.buttons.delete') }
</ActionLink>
{ isDeleting && <Spinner /> }
</>
);
}
export default DeleteAvatarButton;

View File

@@ -81,14 +81,6 @@ const GenerateApiKeyDialog = ({
</>
}
<br /><br />
<ActionLink
icon={<LearnMoreIcon />}
onClick={() => window.open('https://docs.astuto.io/api', '_blank')}
>
{I18n.t('common.forms.api_key.api_key_learn_more')}
</ActionLink>
{ error && <DangerText>{error}</DangerText> }
</>
);

View File

@@ -1,21 +0,0 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
interface Props {
avatarUrl?: string;
email: string;
customClass?: string;
size?: number;
}
const Avatar = ({avatarUrl, email, customClass, size = 28}: Props) => {
const customClassName = `avatar${customClass ? ' '+customClass : ''}`;
if (avatarUrl) {
return <img src={avatarUrl} alt={`${email} avatar`} width={size} height={size} className={customClassName} />;
} else {
return <Gravatar email={email} size={size} className={customClassName} />;
}
}
export default Avatar;

View File

@@ -1,96 +0,0 @@
import React, {useEffect} from 'react';
import {useDropzone} from 'react-dropzone';
import I18n from 'i18n-js';
import { SmallMutedText } from './CustomTexts';
import ActionLink from './ActionLink';
import { DeleteIcon } from './Icons';
interface Props {
files: any[];
setFiles: React.Dispatch<React.SetStateAction<any[]>>;
maxSizeKB?: number;
maxFiles?: number;
accept?: string[];
customStyle?: { [key: string]: string };
}
const Dropzone = ({
files,
setFiles,
maxSizeKB = 256,
maxFiles = 1,
accept = ['image/png', 'image/jpeg', 'image/jpg', 'image/x-icon', 'image/icon', 'image/svg+xml', 'image/svg', 'image/webp'],
customStyle = {}
}: Props) => {
const acceptDict = accept.reduce((acc, type) => {
acc[type] = [];
return acc;
}, {});
const {
getRootProps,
getInputProps,
isDragAccept,
isDragReject
} = useDropzone({
accept: acceptDict,
maxSize: maxSizeKB * 1024,
maxFiles: maxFiles,
disabled: files.length >= maxFiles,
onDrop: (acceptedFiles, fileRejections) => {
if (fileRejections.length > 0) {
alert(I18n.t('common.errors.invalid_file', { errors: fileRejections.map(rejection => rejection.errors[0].message).join(', ') }));
}
// If number of files already uploaded is lower than maxFiles, add the new files
const filesAfterDrop = (files.length + acceptedFiles.length <= maxFiles) ? files.concat(acceptedFiles) : acceptedFiles;
setFiles(filesAfterDrop.map(file => Object.assign(file, {
preview: URL.createObjectURL(file)
})));
},
});
const thumbnails = files.map(file => (
<div className="thumbnailContainer" key={file.name}>
<div className="thumbnail">
<div className="thumbnailInner">
<img
src={file.preview}
className="thumbnailImage"
// Revoke data uri after image is loaded
onLoad={() => { URL.revokeObjectURL(file.preview) }}
/>
</div>
</div>
<ActionLink
onClick={() => setFiles(files.filter(f => f !== file))}
icon={<DeleteIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
</div>
));
useEffect(() => {
// Make sure to revoke the data uris to avoid memory leaks, will run on unmount
return () => files.forEach(file => URL.revokeObjectURL(file.preview));
}, [files]);
return (
<section>
<div {...getRootProps({className: 'dropzone' + (isDragAccept ? ' dropzone-accept' : isDragReject ? ' dropzone-reject' : '') + (files.length >= maxFiles ? ' dropzone-disabled' : '')})} style={customStyle}>
<input {...getInputProps()} />
<SmallMutedText>{I18n.t('common.drag_and_drop', { maxCount: maxFiles, maxSize: maxSizeKB })}</SmallMutedText>
</div>
<aside className="thumbnailsContainer">
{thumbnails}
</aside>
</section>
);
}
export default Dropzone;

View File

@@ -17,9 +17,8 @@ import {
MdCheck,
MdClear,
MdAdd,
MdAttachFile,
} from 'react-icons/md';
import { FaUserNinja, FaMarkdown, FaRegImage } from "react-icons/fa";
import { FaUserNinja, FaMarkdown } from "react-icons/fa";
import { FaDroplet } from "react-icons/fa6";
export const EditIcon = () => <FiEdit />;
@@ -107,8 +106,4 @@ export const MarkdownIcon = ({size = 24, style = {}}) => (
</a>
<Tooltip id="markdown-tooltip" />
</>
);
export const AttachIcon = ({size = 24}) => <MdAttachFile size={size} />;
export const ImageIcon = ({size = 24}) => <FaRegImage size={size} />;
);

View File

@@ -3,7 +3,7 @@ import I18n from 'i18n-js';
const PoweredByLink = () => (
<div className="poweredBy">
<a href={`http://astuto.io/?utm_campaign=poweredby&utm_source=${window.location.hostname}`} target="_blank">
<a href="https://github.com/astuto/astuto/" target="_blank">
{ I18n.t('common.powered_by') } Astuto
</a>
</div>

View File

@@ -24,12 +24,12 @@ const mapDispatchToProps = (dispatch: any) => ({
dispatch(requestOAuths());
},
onSubmitOAuth(oAuth: IOAuth, oAuthLogo: File = null, authenticityToken: string): Promise<any> {
return dispatch(submitOAuth(oAuth, oAuthLogo, authenticityToken));
onSubmitOAuth(oAuth: IOAuth, authenticityToken: string): Promise<any> {
return dispatch(submitOAuth(oAuth, authenticityToken));
},
onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean, authenticityToken: string): Promise<any> {
return dispatch(updateOAuth({id, form, isEnabled: undefined, shouldDeleteLogo, authenticityToken}));
onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, authenticityToken: string): Promise<any> {
return dispatch(updateOAuth({id, form, authenticityToken}));
},
onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string) {

View File

@@ -44,13 +44,9 @@ const mapDispatchToProps = (dispatch) => ({
body: string,
parentId: number,
isPostUpdate: boolean,
attachments: File[],
onSuccess: Function,
authenticityToken: string,
) {
dispatch(submitComment(postId, body, parentId, isPostUpdate, attachments, authenticityToken)).then(res => {
if (res && res.status === HttpStatus.Created) onSuccess();
});
dispatch(submitComment(postId, body, parentId, isPostUpdate, authenticityToken));
},
updateComment(
@@ -58,12 +54,10 @@ const mapDispatchToProps = (dispatch) => ({
commentId: number,
body: string,
isPostUpdate: boolean,
attachmentsToDelete: number[],
attachments: File[],
onSuccess: Function,
authenticityToken: string,
) {
dispatch(updateComment(postId, commentId, body, isPostUpdate, attachmentsToDelete, attachments, authenticityToken)).then(res => {
dispatch(updateComment(postId, commentId, body, isPostUpdate, authenticityToken)).then(res => {
if (res && res.status === HttpStatus.OK) onSuccess();
});
},

View File

@@ -18,11 +18,7 @@ const mapStateToProps = (state: State) => ({
const mapDispatchToProps = (dispatch: any) => ({
updateTenant(
siteName: string,
siteLogo: File,
shouldDeleteSiteLogo: boolean,
oldSiteLogo: string,
siteFavicon: File,
shouldDeleteSiteFavicon: boolean,
siteLogo: string,
brandDisplaySetting: TenantSettingBrandDisplay,
locale: string,
useBrowserLocale: boolean,
@@ -31,7 +27,6 @@ const mapDispatchToProps = (dispatch: any) => ({
isPrivate: boolean,
allowAnonymousFeedback: boolean,
feedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy,
allowAttachmentUpload: boolean,
logoLinksTo: TenantSettingLogoLinksTo,
logoCustomUrl: string,
showRoadmapInHeader: boolean,
@@ -45,10 +40,6 @@ const mapDispatchToProps = (dispatch: any) => ({
return dispatch(updateTenant({
siteName,
siteLogo,
shouldDeleteSiteLogo,
oldSiteLogo,
siteFavicon,
shouldDeleteSiteFavicon,
tenantSetting: {
brand_display: brandDisplaySetting,
use_browser_locale: useBrowserLocale,
@@ -56,7 +47,6 @@ const mapDispatchToProps = (dispatch: any) => ({
is_private: isPrivate,
allow_anonymous_feedback: allowAnonymousFeedback,
feedback_approval_policy: feedbackApprovalPolicy,
allow_attachment_upload: allowAttachmentUpload,
logo_links_to: logoLinksTo,
logo_custom_url: logoCustomUrl,
show_roadmap_in_header: showRoadmapInHeader,

View File

@@ -44,11 +44,9 @@ const mapDispatchToProps = (dispatch) => ({
description: string,
boardId: number,
postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string,
) {
return dispatch(updatePost(postId, title, description, boardId, postStatusId, attachmentsToDelete, attachments, authenticityToken));
return dispatch(updatePost(postId, title, description, boardId, postStatusId, authenticityToken));
},
toggleEditMode() {

View File

@@ -1,20 +0,0 @@
const buildFormData = (data: { [key: string]: any }) => {
const formData = new FormData();
for (const [key, value] of Object.entries(data)) {
if (value !== null && value !== undefined) {
if (Array.isArray(value)) {
// If the value is an array, append each array item to the form data separately
value.forEach((item) => {
formData.append(key, item);
});
} else {
formData.append(key, value);
}
}
}
return formData;
}
export default buildFormData;

View File

@@ -1,6 +1,6 @@
const buildRequestHeaders = (authenticityToken: string, contentType: string = 'application/json') => ({
const buildRequestHeaders = (authenticityToken: string) => ({
Accept: 'application/json',
'Content-Type': contentType,
'Content-Type': 'application/json',
'X-CSRF-Token': authenticityToken,
});

View File

@@ -3,10 +3,8 @@ interface IComment {
body: string;
parentId: number;
isPostUpdate: boolean;
attachmentUrls?: string[];
userFullName: string;
userEmail: string;
userAvatar?: string;
userRole: number;
createdAt: string;
updatedAt: string;

View File

@@ -4,7 +4,6 @@ interface ILike {
id: number;
fullName: string;
email: string;
userAvatar?: string;
}
export default ILike;
@@ -13,5 +12,4 @@ export const likeJSON2JS = (likeJSON: ILikeJSON): ILike => ({
id: likeJSON.id,
fullName: likeJSON.full_name,
email: likeJSON.email,
userAvatar: likeJSON.user_avatar,
});

View File

@@ -1,7 +1,7 @@
export interface IOAuth {
id?: number;
name: string;
logoUrl?: string;
logo?: string;
isEnabled: boolean;
clientId: string;
clientSecret?: string;
@@ -20,7 +20,7 @@ export interface IOAuth {
export interface IOAuthJSON {
id: string;
name: string;
logo_url?: string;
logo?: string;
is_enabled: boolean;
client_id: string;
client_secret?: string;
@@ -39,7 +39,7 @@ export interface IOAuthJSON {
export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON): IOAuth => ({
id: parseInt(oAuthJSON.id),
name: oAuthJSON.name,
logoUrl: oAuthJSON.logo_url,
logo: oAuthJSON.logo,
isEnabled: oAuthJSON.is_enabled,
clientId: oAuthJSON.client_id,
clientSecret: oAuthJSON.client_secret,
@@ -58,7 +58,7 @@ export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON): IOAuth => ({
export const oAuthJS2JSON = (oAuth: IOAuth) => ({
id: oAuth.id?.toString(),
name: oAuth.name,
logo_url: oAuth.logoUrl,
logo: oAuth.logo,
is_enabled: oAuth.isEnabled,
client_id: oAuth.clientId,
client_secret: oAuth.clientSecret,

View File

@@ -15,8 +15,6 @@ interface IPost {
title: string;
slug?: string;
description?: string;
attachmentUrls?: string[];
hasAttachments?: boolean;
approvalStatus: PostApprovalStatus;
boardId: number;
postStatusId?: number;
@@ -27,7 +25,6 @@ interface IPost {
userId: number;
userEmail: string;
userFullName: string;
userAvatar?: string;
createdAt: string;
}
@@ -38,8 +35,6 @@ export const postJSON2JS = (postJSON: IPostJSON): IPost => ({
title: postJSON.title,
slug: postJSON.slug,
description: postJSON.description,
attachmentUrls: postJSON.attachment_urls,
hasAttachments: postJSON.has_attachments,
approvalStatus: postJSON.approval_status,
boardId: postJSON.board_id,
postStatusId: postJSON.post_status_id,
@@ -50,6 +45,5 @@ export const postJSON2JS = (postJSON: IPostJSON): IPost => ({
userId: postJSON.user_id,
userEmail: postJSON.user_email,
userFullName: postJSON.user_full_name,
userAvatar: postJSON.user_avatar,
createdAt: postJSON.created_at,
});

View File

@@ -2,7 +2,6 @@ interface IPostStatusChange {
postStatusId: number;
userFullName: string;
userEmail: string;
userAvatar?: string;
createdAt: string;
}

View File

@@ -1,7 +1,7 @@
interface ITenant {
id: number;
siteName: string;
oldSiteLogo: string;
siteLogo: string;
locale: string;
customDomain?: string;
}

View File

@@ -58,7 +58,6 @@ interface ITenantSetting {
allowed_email_domains?: string;
allow_anonymous_feedback?: boolean;
feedback_approval_policy?: TenantSettingFeedbackApprovalPolicy;
allow_attachment_upload?: boolean;
show_vote_count?: boolean;
show_vote_button_in_board?: boolean;
show_roadmap_in_header?: boolean;

View File

@@ -24,7 +24,6 @@ interface IUser {
id: number;
email: string;
fullName: string;
avatarUrl?: string;
role: UserRoles;
status: UserStatuses;
}

View File

@@ -3,10 +3,8 @@ interface ICommentJSON {
body: string;
parent_id: number;
is_post_update: boolean;
attachment_urls?: string[];
user_full_name: string;
user_email: string;
user_avatar?: string;
user_role: number;
created_at: string;
updated_at: string;

View File

@@ -4,7 +4,6 @@ interface ILikeJSON {
post_id: number;
full_name: string;
email: string;
user_avatar?: string;
}
export default ILikeJSON;

View File

@@ -5,8 +5,6 @@ interface IPostJSON {
title: string;
slug?: string;
description?: string;
attachment_urls?: string[];
has_attachments?: boolean;
approval_status: PostApprovalStatus;
board_id: number;
post_status_id?: number;
@@ -17,7 +15,6 @@ interface IPostJSON {
user_id: number;
user_email: string;
user_full_name: string;
user_avatar?: string;
created_at: string;
}

View File

@@ -2,7 +2,6 @@ interface IPostStatusChangeJSON {
post_status_id: number;
user_full_name: string;
user_email: string;
user_avatar?: string;
created_at: string;
}

View File

@@ -1,7 +1,7 @@
interface ITenantJSON {
id: number;
site_name: string;
old_site_logo: string;
site_logo: string;
brand_display_setting: string;
locale: string;
custom_domain?: string;

View File

@@ -2,7 +2,6 @@ interface IUserJSON {
id: number;
email: string;
full_name: string;
avatar_url?: string;
role: string;
status: string;
}

View File

@@ -15,10 +15,8 @@ const initialState: IComment = {
body: '',
parentId: null,
isPostUpdate: false,
attachmentUrls: [],
userFullName: '<Unknown user>',
userEmail: 'example@example.com',
userAvatar: undefined,
userRole: 0,
createdAt: undefined,
updatedAt: undefined,
@@ -36,10 +34,8 @@ const commentReducer = (
body: action.comment.body,
parentId: action.comment.parent_id,
isPostUpdate: action.comment.is_post_update,
attachmentUrls: action.comment.attachment_urls,
userFullName: action.comment.user_full_name,
userEmail: action.comment.user_email,
userAvatar: action.comment.user_avatar,
userRole: action.comment.user_role,
createdAt: action.comment.created_at,
updatedAt: action.comment.updated_at,

View File

@@ -42,7 +42,6 @@ const likesReducer = (
id: like.id,
fullName: like.full_name,
email: like.email,
userAvatar: like.user_avatar,
})),
areLoading: false,
error: '',
@@ -64,7 +63,6 @@ const likesReducer = (
id: action.like.id,
fullName: action.like.full_name,
email: action.like.email,
userAvatar: action.like.user_avatar,
},
...state.items,
],

View File

@@ -15,8 +15,6 @@ const initialState: IPost = {
title: '',
slug: null,
description: null,
attachmentUrls: [],
hasAttachments: false,
approvalStatus: POST_APPROVAL_STATUS_APPROVED,
boardId: 0,
postStatusId: null,
@@ -27,7 +25,6 @@ const initialState: IPost = {
userId: 0,
userEmail: '',
userFullName: '',
userAvatar: '',
createdAt: '',
};
@@ -44,8 +41,6 @@ const postReducer = (
title: action.post.title,
slug: action.post.slug,
description: action.post.description,
attachmentUrls: action.post.attachment_urls,
hasAttachments: action.post.has_attachments,
approvalStatus: action.post.approval_status,
boardId: action.post.board_id,
postStatusId: action.post.post_status_id,
@@ -56,7 +51,6 @@ const postReducer = (
userId: action.post.user_id,
userEmail: action.post.user_email,
userFullName: action.post.user_full_name,
userAvatar: action.post.user_avatar,
createdAt: action.post.created_at,
};
@@ -65,7 +59,6 @@ const postReducer = (
...state,
title: action.post.title,
description: action.post.description,
attachmentUrls: action.post.attachment_urls,
boardId: action.post.board_id,
postStatusId: action.post.post_status_id,
approvalStatus: action.post.approval_status,

View File

@@ -44,7 +44,6 @@ const postStatusChangesReducer = (
postStatusId: postStatusChange.post_status_id,
userFullName: postStatusChange.user_full_name,
userEmail: postStatusChange.user_email,
userAvatar: postStatusChange.user_avatar,
createdAt: postStatusChange.created_at,
})),
areLoading: false,

View File

@@ -44,7 +44,6 @@ const usersReducer = (
id: userJson.id,
email: userJson.email,
fullName: userJson.full_name,
avatarUrl: userJson.avatar_url,
role: userJson.role,
status: userJson.status,
})),

View File

@@ -4,17 +4,11 @@ class Comment < ApplicationRecord
belongs_to :user
belongs_to :post
belongs_to :parent, class_name: 'Comment', optional: true
has_many :children, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy
has_many_attached :attachments
after_create :run_webhooks
validates :body, presence: true
validates :attachments,
content_type: Rails.application.accepted_image_types,
size: { less_than: 2048.kilobytes },
limit: { max: 5 }
private

View File

@@ -6,7 +6,6 @@ class OAuth < ApplicationRecord
extend FriendlyId
has_many :tenant_default_o_auths, dependent: :destroy
has_one_attached :logo, service: ENV.fetch('ACTIVE_STORAGE_PUBLIC_SERVICE', :local).to_sym
attr_accessor :state
@@ -19,9 +18,6 @@ class OAuth < ApplicationRecord
validates :profile_url, presence: true
validates :scope, presence: true
validates :json_user_email_path, presence: true
validates :logo,
content_type: Rails.application.accepted_image_types,
size: { less_than: 64.kilobytes }
friendly_id :generate_random_slug, use: :scoped, scope: :tenant_id
@@ -29,10 +25,6 @@ class OAuth < ApplicationRecord
tenant_id == nil
end
def logo_url
self.logo.attached? ? self.logo.blob.url : nil
end
def callback_url
# Default OAuths are available to all tenants
# but must have a single callback url:

View File

@@ -14,8 +14,6 @@ class Post < ApplicationRecord
has_many :comments, dependent: :destroy
has_many :post_status_changes, dependent: :destroy
has_many_attached :attachments
after_create :run_new_post_webhooks
after_destroy :run_delete_post_webhooks
@@ -26,10 +24,6 @@ class Post < ApplicationRecord
]
validates :title, presence: true, length: { in: 4..128 }
validates :attachments,
content_type: Rails.application.accepted_image_types,
size: { less_than: 2048.kilobytes },
limit: { max: 5 }
paginates_per Rails.application.posts_per_page

View File

@@ -12,9 +12,6 @@ class Tenant < ApplicationRecord
# used to query all globally enabled default oauths that are also enabled by the specific tenant
has_many :default_o_auths, -> { where tenant_id: nil, is_enabled: true }, through: :tenant_default_o_auths, source: :o_auth
has_one_attached :site_logo, service: ENV.fetch('ACTIVE_STORAGE_PUBLIC_SERVICE', :local).to_sym
has_one_attached :site_favicon, service: ENV.fetch('ACTIVE_STORAGE_PUBLIC_SERVICE', :local).to_sym
enum status: [:active, :pending, :blocked]
after_initialize :set_default_status, if: :new_record?
@@ -24,12 +21,6 @@ class Tenant < ApplicationRecord
validates :subdomain, presence: true, uniqueness: true
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :custom_domain, format: { with: /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}\z/ }, uniqueness: true, allow_blank: true, allow_nil: true
validates :site_logo,
content_type: Rails.application.accepted_image_types,
size: { less_than: 256.kilobytes }
validates :site_favicon,
content_type: Rails.application.accepted_image_types,
size: { less_than: 64.kilobytes }
accepts_nested_attributes_for :tenant_setting, update_only: true

View File

@@ -11,7 +11,6 @@ class User < ApplicationRecord
has_many :likes, dependent: :destroy
has_many :comments, dependent: :destroy
has_one :api_key, dependent: :destroy
has_one_attached :avatar, service: ENV.fetch('ACTIVE_STORAGE_PUBLIC_SERVICE', :local).to_sym
enum role: [:user, :moderator, :admin, :owner]
enum status: [:active, :blocked, :deleted]
@@ -29,9 +28,6 @@ class User < ApplicationRecord
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, allow_blank: true, length: { in: 6..128 }
validates :password, presence: true, on: :create
validates :avatar,
content_type: Rails.application.accepted_image_types,
size: { less_than: 128.kilobytes }
def set_default_role
self.role ||= :user

View File

@@ -1,17 +1,17 @@
class CommentPolicy < ApplicationPolicy
def permitted_attributes_for_create
if user.moderator?
[:body, :parent_id, :is_post_update, :attachments]
[:body, :parent_id, :is_post_update]
else
[:body, :parent_id, :attachments]
[:body, :parent_id]
end
end
def permitted_attributes_for_update
if user.moderator?
[:body, :is_post_update, :attachments]
[:body, :is_post_update]
else
[:body, :attachments]
[:body]
end
end

Some files were not shown because too many files have changed in this diff Show More