mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-21 22:29:38 +02:00
Feature/profile image uploads (#687)
* Introduce ActiveStorage * Add active storage related service gems * Update storage.yml * Install image processing gem - sudo apt-get install libvips (required dependency) * Set default active storage service * Add profile image to user model * Amend form to allow profile images to be saved, introduce stimulus controller. * Purge image when form is blank * Update markup/stimulus controller * Add test for profile image uplaods * Add profile image validation * Use rails guide gem versions * Use correct ERB syntax and make all storage options configurable * Ensure form submits when user clears profile image * Add profile image thumbnail method * Extract profile image to a partial * Updates env.example and storage.yml * Fix bug with double form save * Add profile image to the sidenav * Update production config * Fix ERB formatting * normalize en.yml * Handle non-square images * Use pre-processing on thumbnail variant * Resovle gemfile.lock issues * Rubocop style changes --------- Signed-off-by: Christian <47796704+crobbo@users.noreply.github.com> Co-authored-by: Christian Robinson <christian@robbo.dev>
This commit is contained in:
parent
19ee773d9b
commit
dc024d63b0
17 changed files with 349 additions and 54 deletions
30
.env.example
30
.env.example
|
@ -59,3 +59,33 @@ UPGRADES_TARGET=release # `release` or `commit`
|
||||||
GITHUB_REPO_OWNER=maybe-finance
|
GITHUB_REPO_OWNER=maybe-finance
|
||||||
GITHUB_REPO_NAME=maybe
|
GITHUB_REPO_NAME=maybe
|
||||||
GITHUB_REPO_BRANCH=main
|
GITHUB_REPO_BRANCH=main
|
||||||
|
|
||||||
|
# ======================================================================================================
|
||||||
|
# Active Storage Configuration - responsible for storing file uploads
|
||||||
|
# ======================================================================================================
|
||||||
|
#
|
||||||
|
# * Defaults to disk storage but you can also use Amazon S3, Google Cloud Storage, or Microsoft Azure Storage.
|
||||||
|
# * Set the appropriate environment variables to use these services.
|
||||||
|
# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
|
||||||
|
#
|
||||||
|
# Amazon S3
|
||||||
|
# ==========
|
||||||
|
# ACTIVE_STORAGE_SERVICE=amazon
|
||||||
|
# S3_ACCESS_KEY_ID=
|
||||||
|
# S3_SECRET_ACCESS_KEY=
|
||||||
|
# S3_REGION= # defaults to `us-east-1` if not set
|
||||||
|
# S3_BUCKET=
|
||||||
|
|
||||||
|
# Google Cloud Storage
|
||||||
|
# =====================
|
||||||
|
# Save your JSON keyfile as `gcp-storage-keyfile.json` in the root of the project
|
||||||
|
# ACTIVE_STORAGE_SERVICE=google
|
||||||
|
# GCS_PROJECT=
|
||||||
|
# GCS_BUCKET=
|
||||||
|
|
||||||
|
# Microsoft Azure Storage
|
||||||
|
# ========================
|
||||||
|
# ACTIVE_STORAGE_SERVICE=azure
|
||||||
|
# AZURE_STORAGE_ACCOUNT_NAME=
|
||||||
|
# AZURE_STORAGE_ACCESS_KEY=
|
||||||
|
# AZURE_STORAGE_CONTAINER=
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -50,3 +50,6 @@
|
||||||
|
|
||||||
# Ignore .devcontainer files
|
# Ignore .devcontainer files
|
||||||
compose-dev.yaml
|
compose-dev.yaml
|
||||||
|
|
||||||
|
# Ignore GCP keyfile
|
||||||
|
gcp-storage-keyfile.json
|
||||||
|
|
7
Gemfile
7
Gemfile
|
@ -36,6 +36,12 @@ gem "sentry-rails"
|
||||||
gem "rails-settings-cached"
|
gem "rails-settings-cached"
|
||||||
gem "octokit"
|
gem "octokit"
|
||||||
|
|
||||||
|
# Active Storage
|
||||||
|
gem "aws-sdk-s3", require: false
|
||||||
|
gem "azure-storage-blob", "~> 2.0", require: false
|
||||||
|
gem "google-cloud-storage", "~> 1.11", require: false
|
||||||
|
gem "image_processing", ">= 1.2"
|
||||||
|
|
||||||
# Other
|
# Other
|
||||||
gem "bcrypt", "~> 3.1.7"
|
gem "bcrypt", "~> 3.1.7"
|
||||||
gem "inline_svg"
|
gem "inline_svg"
|
||||||
|
@ -51,6 +57,7 @@ group :development, :test do
|
||||||
gem "letter_opener"
|
gem "letter_opener"
|
||||||
gem "i18n-tasks"
|
gem "i18n-tasks"
|
||||||
gem "erb_lint"
|
gem "erb_lint"
|
||||||
|
gem "byebug"
|
||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
|
|
106
Gemfile.lock
106
Gemfile.lock
|
@ -107,6 +107,30 @@ GEM
|
||||||
addressable (2.8.6)
|
addressable (2.8.6)
|
||||||
public_suffix (>= 2.0.2, < 6.0)
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
ast (2.4.2)
|
ast (2.4.2)
|
||||||
|
aws-eventstream (1.3.0)
|
||||||
|
aws-partitions (1.922.0)
|
||||||
|
aws-sdk-core (3.193.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
|
aws-sigv4 (~> 1.8)
|
||||||
|
jmespath (~> 1, >= 1.6.1)
|
||||||
|
aws-sdk-kms (1.80.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.193.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
|
aws-sdk-s3 (1.148.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.193.0)
|
||||||
|
aws-sdk-kms (~> 1)
|
||||||
|
aws-sigv4 (~> 1.8)
|
||||||
|
aws-sigv4 (1.8.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
azure-storage-blob (2.0.3)
|
||||||
|
azure-storage-common (~> 2.0)
|
||||||
|
nokogiri (~> 1, >= 1.10.8)
|
||||||
|
azure-storage-common (2.0.4)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
|
||||||
|
net-http-persistent (~> 4.0)
|
||||||
|
nokogiri (~> 1, >= 1.10.8)
|
||||||
base64 (0.2.0)
|
base64 (0.2.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
better_html (2.0.2)
|
better_html (2.0.2)
|
||||||
|
@ -123,6 +147,7 @@ GEM
|
||||||
brakeman (6.1.2)
|
brakeman (6.1.2)
|
||||||
racc
|
racc
|
||||||
builder (3.2.4)
|
builder (3.2.4)
|
||||||
|
byebug (11.1.3)
|
||||||
capybara (3.40.0)
|
capybara (3.40.0)
|
||||||
addressable
|
addressable
|
||||||
matrix
|
matrix
|
||||||
|
@ -143,6 +168,9 @@ GEM
|
||||||
debug (1.9.2)
|
debug (1.9.2)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
|
declarative (0.0.20)
|
||||||
|
digest-crc (0.6.5)
|
||||||
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
dotenv (3.1.0)
|
dotenv (3.1.0)
|
||||||
dotenv-rails (3.1.0)
|
dotenv-rails (3.1.0)
|
||||||
dotenv (= 3.1.0)
|
dotenv (= 3.1.0)
|
||||||
|
@ -158,10 +186,11 @@ GEM
|
||||||
erubi (1.12.0)
|
erubi (1.12.0)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.2.11)
|
||||||
tzinfo
|
tzinfo
|
||||||
faraday (2.9.0)
|
faraday (1.2.0)
|
||||||
faraday-net_http (>= 2.0, < 3.2)
|
multipart-post (>= 1.2, < 3)
|
||||||
faraday-net_http (3.1.0)
|
ruby2_keywords
|
||||||
net-http
|
faraday_middleware (1.2.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
ffi (1.16.3)
|
ffi (1.16.3)
|
||||||
fugit (1.11.0)
|
fugit (1.11.0)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
|
@ -175,12 +204,47 @@ GEM
|
||||||
fugit (>= 1.1)
|
fugit (>= 1.1)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
thor (>= 0.14.1)
|
thor (>= 0.14.1)
|
||||||
|
google-apis-core (0.14.1)
|
||||||
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
|
googleauth (~> 1.9)
|
||||||
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
representable (~> 3.0)
|
||||||
|
retriable (>= 2.0, < 4.a)
|
||||||
|
rexml
|
||||||
|
google-apis-iamcredentials_v1 (0.20.0)
|
||||||
|
google-apis-core (>= 0.14.0, < 2.a)
|
||||||
|
google-apis-storage_v1 (0.37.0)
|
||||||
|
google-apis-core (>= 0.14.0, < 2.a)
|
||||||
|
google-cloud-core (1.7.0)
|
||||||
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
|
google-cloud-errors (~> 1.0)
|
||||||
|
google-cloud-env (2.1.1)
|
||||||
|
faraday (>= 1.0, < 3.a)
|
||||||
|
google-cloud-errors (1.4.0)
|
||||||
|
google-cloud-storage (1.51.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
digest-crc (~> 0.4)
|
||||||
|
google-apis-core (~> 0.13)
|
||||||
|
google-apis-iamcredentials_v1 (~> 0.18)
|
||||||
|
google-apis-storage_v1 (~> 0.37)
|
||||||
|
google-cloud-core (~> 1.6)
|
||||||
|
googleauth (~> 1.9)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
googleauth (1.11.0)
|
||||||
|
faraday (>= 1.0, < 3.a)
|
||||||
|
google-cloud-env (~> 2.1)
|
||||||
|
jwt (>= 1.4, < 3.0)
|
||||||
|
multi_json (~> 1.11)
|
||||||
|
os (>= 0.9, < 2.0)
|
||||||
|
signet (>= 0.16, < 2.a)
|
||||||
hashdiff (1.1.0)
|
hashdiff (1.1.0)
|
||||||
highline (3.0.1)
|
highline (3.0.1)
|
||||||
hotwire-livereload (1.3.2)
|
hotwire-livereload (1.3.2)
|
||||||
actioncable (>= 6.0.0)
|
actioncable (>= 6.0.0)
|
||||||
listen (>= 3.0.0)
|
listen (>= 3.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
|
httpclient (2.8.3)
|
||||||
i18n (1.14.4)
|
i18n (1.14.4)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
i18n-tasks (1.0.13)
|
i18n-tasks (1.0.13)
|
||||||
|
@ -194,6 +258,9 @@ GEM
|
||||||
rails-i18n
|
rails-i18n
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
terminal-table (>= 1.5.1)
|
terminal-table (>= 1.5.1)
|
||||||
|
image_processing (1.12.2)
|
||||||
|
mini_magick (>= 4.9.5, < 5)
|
||||||
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
importmap-rails (2.0.1)
|
importmap-rails (2.0.1)
|
||||||
actionpack (>= 6.0.0)
|
actionpack (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
|
@ -205,7 +272,10 @@ GEM
|
||||||
irb (1.12.0)
|
irb (1.12.0)
|
||||||
rdoc
|
rdoc
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
|
jmespath (1.6.2)
|
||||||
json (2.7.1)
|
json (2.7.1)
|
||||||
|
jwt (2.8.1)
|
||||||
|
base64
|
||||||
language_server-protocol (3.17.0.3)
|
language_server-protocol (3.17.0.3)
|
||||||
launchy (3.0.0)
|
launchy (3.0.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
|
@ -225,13 +295,16 @@ GEM
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.0.4)
|
marcel (1.0.4)
|
||||||
matrix (0.4.2)
|
matrix (0.4.2)
|
||||||
|
mini_magick (4.12.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.21.2)
|
minitest (5.21.2)
|
||||||
mocha (2.2.0)
|
mocha (2.2.0)
|
||||||
ruby2_keywords (>= 0.0.5)
|
ruby2_keywords (>= 0.0.5)
|
||||||
msgpack (1.7.2)
|
msgpack (1.7.2)
|
||||||
net-http (0.4.1)
|
multi_json (1.15.0)
|
||||||
uri
|
multipart-post (2.4.0)
|
||||||
|
net-http-persistent (4.0.2)
|
||||||
|
connection_pool (~> 2.2)
|
||||||
net-imap (0.4.10)
|
net-imap (0.4.10)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
|
@ -258,6 +331,7 @@ GEM
|
||||||
base64
|
base64
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
sawyer (~> 0.9)
|
sawyer (~> 0.9)
|
||||||
|
os (1.1.4)
|
||||||
pagy (8.3.0)
|
pagy (8.3.0)
|
||||||
parallel (1.24.0)
|
parallel (1.24.0)
|
||||||
parser (3.3.0.5)
|
parser (3.3.0.5)
|
||||||
|
@ -316,6 +390,11 @@ GEM
|
||||||
regexp_parser (2.9.0)
|
regexp_parser (2.9.0)
|
||||||
reline (0.5.3)
|
reline (0.5.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
|
representable (3.2.0)
|
||||||
|
declarative (< 0.1.0)
|
||||||
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
|
uber (< 0.2.0)
|
||||||
|
retriable (3.1.2)
|
||||||
rexml (3.2.6)
|
rexml (3.2.6)
|
||||||
rubocop (1.60.2)
|
rubocop (1.60.2)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
|
@ -354,6 +433,8 @@ GEM
|
||||||
ruby-lsp (>= 0.16.0, < 0.17.0)
|
ruby-lsp (>= 0.16.0, < 0.17.0)
|
||||||
sorbet-runtime (>= 0.5.9897)
|
sorbet-runtime (>= 0.5.9897)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
|
ruby-vips (2.2.1)
|
||||||
|
ffi (~> 1.12)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.3.2)
|
rubyzip (2.3.2)
|
||||||
sawyer (0.9.2)
|
sawyer (0.9.2)
|
||||||
|
@ -370,6 +451,11 @@ GEM
|
||||||
sentry-ruby (5.17.3)
|
sentry-ruby (5.17.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
signet (0.19.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
faraday (>= 0.17.5, < 3.a)
|
||||||
|
jwt (>= 1.5, < 3.0)
|
||||||
|
multi_json (~> 1.10)
|
||||||
smart_properties (1.17.0)
|
smart_properties (1.17.0)
|
||||||
sorbet-runtime (0.5.11332)
|
sorbet-runtime (0.5.11332)
|
||||||
stackprof (0.2.26)
|
stackprof (0.2.26)
|
||||||
|
@ -392,14 +478,15 @@ GEM
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
thor (1.3.1)
|
thor (1.3.1)
|
||||||
timeout (0.4.1)
|
timeout (0.4.1)
|
||||||
|
trailblazer-option (0.1.2)
|
||||||
turbo-rails (2.0.5)
|
turbo-rails (2.0.5)
|
||||||
actionpack (>= 6.0.0)
|
actionpack (>= 6.0.0)
|
||||||
activejob (>= 6.0.0)
|
activejob (>= 6.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
|
uber (0.1.0)
|
||||||
unicode-display_width (2.5.0)
|
unicode-display_width (2.5.0)
|
||||||
uri (0.13.0)
|
|
||||||
useragent (0.16.10)
|
useragent (0.16.10)
|
||||||
vcr (6.2.0)
|
vcr (6.2.0)
|
||||||
web-console (4.2.1)
|
web-console (4.2.1)
|
||||||
|
@ -429,17 +516,22 @@ PLATFORMS
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
aws-sdk-s3
|
||||||
|
azure-storage-blob (~> 2.0)
|
||||||
bcrypt (~> 3.1.7)
|
bcrypt (~> 3.1.7)
|
||||||
bootsnap
|
bootsnap
|
||||||
brakeman
|
brakeman
|
||||||
|
byebug
|
||||||
capybara
|
capybara
|
||||||
debug
|
debug
|
||||||
dotenv-rails
|
dotenv-rails
|
||||||
erb_lint
|
erb_lint
|
||||||
faraday
|
faraday
|
||||||
good_job
|
good_job
|
||||||
|
google-cloud-storage (~> 1.11)
|
||||||
hotwire-livereload
|
hotwire-livereload
|
||||||
i18n-tasks
|
i18n-tasks
|
||||||
|
image_processing (>= 1.2)
|
||||||
importmap-rails
|
importmap-rails
|
||||||
inline_svg
|
inline_svg
|
||||||
letter_opener
|
letter_opener
|
||||||
|
|
|
@ -5,6 +5,10 @@ class Settings::ProfilesController < ApplicationController
|
||||||
def update
|
def update
|
||||||
user_params_with_family = user_params
|
user_params_with_family = user_params
|
||||||
|
|
||||||
|
if params[:user][:delete_profile_image] == "true"
|
||||||
|
Current.user.profile_image.purge
|
||||||
|
end
|
||||||
|
|
||||||
if Current.family && user_params_with_family[:family_attributes]
|
if Current.family && user_params_with_family[:family_attributes]
|
||||||
family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id })
|
family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id })
|
||||||
user_params_with_family[:family_attributes] = family_attributes
|
user_params_with_family[:family_attributes] = family_attributes
|
||||||
|
@ -13,7 +17,7 @@ class Settings::ProfilesController < ApplicationController
|
||||||
if Current.user.update(user_params_with_family)
|
if Current.user.update(user_params_with_family)
|
||||||
redirect_to settings_profile_path, notice: t(".success")
|
redirect_to settings_profile_path, notice: t(".success")
|
||||||
else
|
else
|
||||||
render :edit, status: :unprocessable_entity
|
redirect_to settings_profile_path, alert: t(".file_size_error")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -29,7 +33,7 @@ class Settings::ProfilesController < ApplicationController
|
||||||
private
|
private
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:first_name, :last_name,
|
params.require(:user).permit(:first_name, :last_name, :profile_image,
|
||||||
family_attributes: [ :name, :id ])
|
family_attributes: [ :name, :id ])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["imagePreview", "fileField", "deleteField", "clearBtn", "template"]
|
||||||
|
|
||||||
|
preview(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-24 h-24 rounded-full object-cover" />`;
|
||||||
|
this.templateTarget.classList.add("hidden");
|
||||||
|
this.clearBtnTarget.classList.remove("hidden");
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.deleteFieldTarget.value = true;
|
||||||
|
this.fileFieldTarget.value = null;
|
||||||
|
this.templateTarget.classList.remove("hidden");
|
||||||
|
this.imagePreviewTarget.innerHTML = this.templateTarget.innerHTML;
|
||||||
|
this.clearBtnTarget.classList.add("hidden");
|
||||||
|
this.element.submit();
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,6 +11,12 @@ class User < ApplicationRecord
|
||||||
|
|
||||||
enum :role, { member: "member", admin: "admin" }, validate: true
|
enum :role, { member: "member", admin: "admin" }, validate: true
|
||||||
|
|
||||||
|
has_one_attached :profile_image do |attachable|
|
||||||
|
attachable.variant :thumbnail, resize_to_limit: [ 150, 150 ], preprocessed: true
|
||||||
|
end
|
||||||
|
|
||||||
|
validate :profile_image_size
|
||||||
|
|
||||||
generates_token_for :password_reset, expires_in: 15.minutes do
|
generates_token_for :password_reset, expires_in: 15.minutes do
|
||||||
password_salt&.last(10)
|
password_salt&.last(10)
|
||||||
end
|
end
|
||||||
|
@ -74,4 +80,10 @@ class User < ApplicationRecord
|
||||||
def deactivated_email
|
def deactivated_email
|
||||||
email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
|
email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def profile_image_size
|
||||||
|
if profile_image.attached? && profile_image.byte_size > 5.megabytes
|
||||||
|
errors.add(:profile_image, "is too large. Maximum size is 5 MB.")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,11 +4,24 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
<div id="user-menu" data-controller="menu">
|
<div id="user-menu" data-controller="menu">
|
||||||
<button data-menu-target="button">
|
<button data-menu-target="button">
|
||||||
<div class="text-white w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.initial %></div>
|
<% profile_image_attached = Current.user.profile_image.attached? %>
|
||||||
|
<% if profile_image_attached %>
|
||||||
|
<div class="text-white w-9 h-9">
|
||||||
|
<%= render "shared/user_profile_image", user: Current.user %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-white w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.initial %></div>
|
||||||
|
<% end %>
|
||||||
</button>
|
</button>
|
||||||
<div data-menu-target="content" class="hidden absolute w-[240px] z-10 top-10 left-[255px] top-[72px] bg-white rounded-sm shadow-xs border border-alpha-black-25">
|
<div data-menu-target="content" class="hidden absolute w-[240px] z-10 top-10 left-[255px] top-[72px] bg-white rounded-sm shadow-xs border border-alpha-black-25">
|
||||||
<div class="p-3 flex items-center gap-3">
|
<div class="p-3 flex items-center gap-3">
|
||||||
<div class="text-white shrink-0 w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.initial %></div>
|
<% if profile_image_attached %>
|
||||||
|
<div class="text-white shrink-0 w-9 h-9">
|
||||||
|
<%= render "shared/user_profile_image", user: Current.user %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-white shrink-0 w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.initial %></div>
|
||||||
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-900 font-medium text-sm"><%= Current.user.display_name %></span>
|
<span class="text-gray-900 font-medium text-sm"><%= Current.user.display_name %></span>
|
||||||
<% if Current.user.display_name != Current.user.email %>
|
<% if Current.user.display_name != Current.user.email %>
|
||||||
|
|
|
@ -5,26 +5,41 @@
|
||||||
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1>
|
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<%= settings_section title: t(".profile_title"), subtitle: t(".profile_subtitle") do %>
|
<%= settings_section title: t(".profile_title"), subtitle: t(".profile_subtitle") do %>
|
||||||
<div class="flex items-center gap-4">
|
<%= form_with model: Current.user, url: settings_profile_path, html: {data: { controller: "profile-image-preview" }} do |form| %>
|
||||||
<div class="flex justify-center items-center bg-gray-50 w-24 h-24 rounded-full border border-alpha-black-25">
|
<div class="flex items-center gap-4">
|
||||||
<%= lucide_icon "image-plus", class: "w-6 h-6 text-gray-500" %>
|
<div class="relative flex justify-center items-center bg-gray-50 w-24 h-24 rounded-full border border-alpha-black-25">
|
||||||
|
<div data-profile-image-preview-target="imagePreview">
|
||||||
|
<% profile_image_attached = Current.user.profile_image.attached? %>
|
||||||
|
<% if profile_image_attached %>
|
||||||
|
<div class="h-24 w-24">
|
||||||
|
<%= render "shared/user_profile_image", user: Current.user %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%= lucide_icon "image-plus", class: "w-6 h-6 text-gray-500" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<%= lucide_icon "image-plus", class: "hidden w-6 h-6 text-gray-500", data: { profile_image_preview_target: "template" } %>
|
||||||
|
<div data-profile-image-preview-target="clearBtn" data-action="click->profile-image-preview#clear" class="<%= profile_image_attached ? "" : "hidden" %> cursor-pointer absolute bottom-0 right-0 w-8 h-8 bg-gray-50 rounded-full flex justify-center items-center border border-white border-2">
|
||||||
|
<%= lucide_icon "x", class: "w-4 h-4 text-gray-500" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p><%= t(".profile_image_type") %></p>
|
||||||
|
<%= form.label :profile_image, t(".profile_image_choose"), class: "inline-block cursor-pointer px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium" %>
|
||||||
|
<%= form.file_field :profile_image, accept: "wimage/png, image/jpeg, image/gif", class: "hidden px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium", data: {profile_image_preview_target: "fileField", action: "change->profile-image-preview#preview"} %>
|
||||||
|
<%= form.hidden_field :delete_profile_image, value: false, data: {profile_image_preview_target: "deleteField"} %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div>
|
||||||
<p><%= t(".profile_image_type") %></p>
|
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||||
<button class="cursor-not-allowed px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium" disabled><%= t(".profile_image_choose") %></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<%= form_with model: Current.user, url: settings_profile_path, html: { class: "space-y-4" } do |form| %>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<%= form.text_field :first_name, placeholder: "First name", value: Current.user.first_name, label: true %>
|
<%= form.text_field :first_name, placeholder: "First name", value: Current.user.first_name, label: true %>
|
||||||
<%= form.text_field :last_name, placeholder: "Last name", value: Current.user.last_name, label: true %>
|
<%= form.text_field :last_name, placeholder: "Last name", value: Current.user.last_name, label: true %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end mt-4">
|
||||||
<%= form.submit t(".save"), class: "bg-gray-900 text-white rounded-lg px-3 py-2" %>
|
<%= form.submit t(".save"), class: "bg-gray-900 text-white rounded-lg px-3 py-2" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
</div>
|
||||||
</div>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %>
|
<%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
1
app/views/shared/_user_profile_image.html.erb
Normal file
1
app/views/shared/_user_profile_image.html.erb
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<%= image_tag user.profile_image.variant(:thumbnail), class: "rounded-full w-full h-full object-cover" %>
|
|
@ -31,7 +31,7 @@ Rails.application.configure do
|
||||||
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
|
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
|
||||||
|
|
||||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||||
config.active_storage.service = :local
|
config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym
|
||||||
|
|
||||||
# Mount Action Cable outside main process or domain.
|
# Mount Action Cable outside main process or domain.
|
||||||
# config.action_cable.mount_path = nil
|
# config.action_cable.mount_path = nil
|
||||||
|
|
|
@ -106,4 +106,5 @@ en:
|
||||||
profile_title: Profile
|
profile_title: Profile
|
||||||
save: Save
|
save: Save
|
||||||
update:
|
update:
|
||||||
|
file_size_error: File size must be less than 5MB
|
||||||
success: Profile updated successfully.
|
success: Profile updated successfully.
|
||||||
|
|
|
@ -1,34 +1,25 @@
|
||||||
test:
|
|
||||||
service: Disk
|
|
||||||
root: <%= Rails.root.join("tmp/storage") %>
|
|
||||||
|
|
||||||
local:
|
local:
|
||||||
service: Disk
|
service: Disk
|
||||||
root: <%= Rails.root.join("storage") %>
|
root: <%= Rails.root.join("storage") %>
|
||||||
|
|
||||||
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
test:
|
||||||
# amazon:
|
service: Disk
|
||||||
# service: S3
|
root: <%= Rails.root.join("tmp/storage") %>
|
||||||
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
|
|
||||||
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
|
|
||||||
# region: us-east-1
|
|
||||||
# bucket: your_own_bucket-<%= Rails.env %>
|
|
||||||
|
|
||||||
# Remember not to checkin your GCS keyfile to a repository
|
amazon:
|
||||||
# google:
|
service: S3
|
||||||
# service: GCS
|
access_key_id: <%= ENV["S3_ACCESS_KEY_ID"] %>
|
||||||
# project: your_project
|
secret_access_key: <%= ENV["S3_SECRET_ACCESS_KEY"] %>
|
||||||
# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
|
region: <%= ENV["S3_REGION"] || "us-east-1" %>
|
||||||
# bucket: your_own_bucket-<%= Rails.env %>
|
bucket: <%= ENV["S3_BUCKET"] %>
|
||||||
|
google:
|
||||||
|
service: GCS
|
||||||
|
project: <%= ENV["GCS_PROJECT"] %>
|
||||||
|
credentials: <%= Rails.root.join("gcp-storage-keyfile.json") %>
|
||||||
|
bucket: <%= ENV["GCS_BUCKET"] %>
|
||||||
|
|
||||||
# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
|
azure:
|
||||||
# microsoft:
|
service: AzureStorage
|
||||||
# service: AzureStorage
|
storage_account_name: <%= ENV["AZURE_STORAGE_ACCOUNT_NAME"] %>
|
||||||
# storage_account_name: your_account_name
|
storage_access_key: <%= ENV["AZURE_STORAGE_ACCESS_KEY"] %>
|
||||||
# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
|
container: <%= ENV["AZURE_STORAGE_CONTAINER"] %>
|
||||||
# container: your_container_name-<%= Rails.env %>
|
|
||||||
|
|
||||||
# mirror:
|
|
||||||
# service: Mirror
|
|
||||||
# primary: local
|
|
||||||
# mirrors: [ amazon, google, microsoft ]
|
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
# This migration comes from active_storage (originally 20170806125915)
|
||||||
|
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
# Use Active Record's configured type for primary and foreign keys
|
||||||
|
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
||||||
|
|
||||||
|
create_table :active_storage_blobs, id: primary_key_type do |t|
|
||||||
|
t.string :key, null: false
|
||||||
|
t.string :filename, null: false
|
||||||
|
t.string :content_type
|
||||||
|
t.text :metadata
|
||||||
|
t.string :service_name, null: false
|
||||||
|
t.bigint :byte_size, null: false
|
||||||
|
t.string :checksum
|
||||||
|
|
||||||
|
if connection.supports_datetime_with_precision?
|
||||||
|
t.datetime :created_at, precision: 6, null: false
|
||||||
|
else
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
t.index [ :key ], unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :active_storage_attachments, id: primary_key_type do |t|
|
||||||
|
t.string :name, null: false
|
||||||
|
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||||
|
t.references :blob, null: false, type: foreign_key_type
|
||||||
|
|
||||||
|
if connection.supports_datetime_with_precision?
|
||||||
|
t.datetime :created_at, precision: 6, null: false
|
||||||
|
else
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
|
||||||
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :active_storage_variant_records, id: primary_key_type do |t|
|
||||||
|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
||||||
|
t.string :variation_digest, null: false
|
||||||
|
|
||||||
|
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
|
||||||
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def primary_and_foreign_key_types
|
||||||
|
config = Rails.configuration.generators
|
||||||
|
setting = config.options[config.orm][:primary_key_type]
|
||||||
|
primary_key_type = setting || :primary_key
|
||||||
|
foreign_key_type = setting || :bigint
|
||||||
|
[ primary_key_type, foreign_key_type ]
|
||||||
|
end
|
||||||
|
end
|
30
db/schema.rb
generated
30
db/schema.rb
generated
|
@ -96,6 +96,34 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
|
||||||
t.index ["family_id"], name: "index_accounts_on_family_id"
|
t.index ["family_id"], name: "index_accounts_on_family_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "name", null: false
|
||||||
|
t.string "record_type", null: false
|
||||||
|
t.uuid "record_id", null: false
|
||||||
|
t.uuid "blob_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
|
||||||
|
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "key", null: false
|
||||||
|
t.string "filename", null: false
|
||||||
|
t.string "content_type"
|
||||||
|
t.text "metadata"
|
||||||
|
t.string "service_name", null: false
|
||||||
|
t.bigint "byte_size", null: false
|
||||||
|
t.string "checksum"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "active_storage_variant_records", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.uuid "blob_id", null: false
|
||||||
|
t.string "variation_digest", null: false
|
||||||
|
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "base_currency", null: false
|
t.string "base_currency", null: false
|
||||||
t.string "converted_currency", null: false
|
t.string "converted_currency", null: false
|
||||||
|
@ -275,6 +303,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do
|
||||||
|
|
||||||
add_foreign_key "account_balances", "accounts", on_delete: :cascade
|
add_foreign_key "account_balances", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "accounts", "families"
|
add_foreign_key "accounts", "families"
|
||||||
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "transaction_categories", "families"
|
add_foreign_key "transaction_categories", "families"
|
||||||
add_foreign_key "transaction_merchants", "families"
|
add_foreign_key "transaction_merchants", "families"
|
||||||
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
||||||
|
|
BIN
test/fixtures/files/profile_image.png
vendored
Normal file
BIN
test/fixtures/files/profile_image.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
|
@ -61,6 +61,18 @@ class SettingsTest < ApplicationSystemTestCase
|
||||||
# TODO: Implement test for back navigation and escape key functionality.
|
# TODO: Implement test for back navigation and escape key functionality.
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "can upload profile image" do
|
||||||
|
open_settings_from_sidebar
|
||||||
|
|
||||||
|
label = find("label", text: "Choose")
|
||||||
|
|
||||||
|
attach_file(label["for"], Rails.root.join("test/fixtures/files/profile_image.png"), make_visible: true)
|
||||||
|
|
||||||
|
click_button "Save"
|
||||||
|
|
||||||
|
assert_selector("img[src*='profile_image.png']")
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def open_settings_from_sidebar
|
def open_settings_from_sidebar
|
||||||
find("#user-menu").click
|
find("#user-menu").click
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue