diff --git a/Dockerfile b/Dockerfile index 7da4b88..a6f8081 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,18 @@ # syntax=docker/dockerfile:1 -FROM ruby:3.3.0 +FROM debian:bullseye-slim as base SHELL ["/bin/bash", "-o", "pipefail", "-c"] -RUN apt-get update -qq && apt-get install -y --no-install-recommends curl \ - ldap-utils tini libvips +# TODO Remove when upstream Ruby works properly on Apple silicon +RUN apt update && apt install -y build-essential wget autoconf libpq-dev pkg-config +RUN wget https://github.com/postmodern/ruby-install/releases/download/v0.9.3/ruby-install-0.9.3.tar.gz \ + && tar -xzvf ruby-install-0.9.3.tar.gz \ + && cd ruby-install-0.9.3/ \ + && make install +RUN ruby-install -p https://github.com/ruby/ruby/pull/9371.diff ruby 3.3.0 +ENV PATH="/opt/rubies/ruby-3.3.0/bin:${PATH}" + +RUN apt-get install -y --no-install-recommends curl ldap-utils tini libvips RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - RUN apt-get update && apt-get install -y nodejs diff --git a/app/components/dropdown_component.html.erb b/app/components/dropdown_component.html.erb index 3ea3bce..eb04538 100644 --- a/app/components/dropdown_component.html.erb +++ b/app/components/dropdown_component.html.erb @@ -2,13 +2,21 @@
+ <% if @size == :large %> - <%= render partial: "icons/kebab-menu", locals: { - custom_class: "inline text-gray-500 h-6 w-6" - } %> + <%= render partial: "icons/#{@icon_name}", + locals: { custom_class: "inline text-gray-500 h-6 w-6" } %> + <% elsif @size == :small %> + + + <%= render partial: "icons/#{@icon_name}", + locals: { custom_class: "inline h-4 w-4" } %> + + + <% end %>
+ <% if @description.present? %>

<%= @descripton %>

+ <% end %>
<%= render FormElements::ToggleComponent.new( diff --git a/app/components/form_elements/fieldset_toggle_component.rb b/app/components/form_elements/fieldset_toggle_component.rb index 686f5f1..09fd36d 100644 --- a/app/components/form_elements/fieldset_toggle_component.rb +++ b/app/components/form_elements/fieldset_toggle_component.rb @@ -3,7 +3,7 @@ module FormElements class FieldsetToggleComponent < ViewComponent::Base def initialize(tag: "li", form: nil, attribute: nil, field_name: nil, - enabled: false, input_enabled: true, title:, description:) + enabled: false, input_enabled: true, title:, description: nil) @tag = tag @form = form @attribute = attribute diff --git a/app/components/modal_component.html.erb b/app/components/modal_component.html.erb index fa92a72..f8d4baf 100644 --- a/app/components/modal_component.html.erb +++ b/app/components/modal_component.html.erb @@ -18,9 +18,11 @@
<%= content %> + <% if @show_close_button %>
+ <% end %>
diff --git a/app/components/modal_component.rb b/app/components/modal_component.rb index 249ec37..bbc86f4 100644 --- a/app/components/modal_component.rb +++ b/app/components/modal_component.rb @@ -1,2 +1,5 @@ class ModalComponent < ViewComponent::Base + def initialize(show_close_button: true) + @show_close_button = show_close_button + end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index db5da30..6b6f510 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,11 +1,11 @@ class Admin::UsersController < Admin::BaseController - before_action :set_user, only: [:show] + before_action :set_user, except: [:index] before_action :set_current_section + # GET /admin/users def index ldap = LdapService.new - @ou = params[:ou] || Setting.primary_domain - @orgs = ldap.fetch_organizations + @ou = Setting.primary_domain @pagy, @users = pagy(User.where(ou: @ou).order(cn: :asc)) @stats = { @@ -14,6 +14,7 @@ class Admin::UsersController < Admin::BaseController } end + # GET /admin/users/:username def show if Setting.lndhub_admin_enabled? @lndhub_user = @user.lndhub_user @@ -24,11 +25,35 @@ class Admin::UsersController < Admin::BaseController @avatar = LdapManager::FetchAvatar.call(cn: @user.cn) end + # POST /admin/users/:username/invitations + def create_invitations + amount = params[:amount].to_i + notify_user = ActiveRecord::Type::Boolean.new.cast(params[:notify_user]) + + CreateInvitations.call(user: @user, amount: amount, notify: notify_user) + + redirect_to admin_user_path(@user.cn), flash: { + success: "Added #{amount} invitations to #{@user.cn}'s account" + } + end + + # DELETE /admin/users/:username/invitations + def delete_invitations + invitations = @user.invitations.unused + amount = invitations.count + + invitations.destroy_all + + redirect_to admin_user_path(@user.cn), flash: { + success: "Removed #{amount} invitations from #{@user.cn}'s account" + } + end + private def set_user - address = params[:address].split("@") - @user = User.where(cn: address.first, ou: address.last).first + @user = User.find_by(cn: params[:username], ou: Setting.primary_domain) + http_status :not_found unless @user end def set_current_section diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 81132b9..004f94f 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -17,4 +17,10 @@ class NotificationMailer < ApplicationMailer @subject = "New app connected to your storage" mail to: @user.email, subject: @subject end + + def new_invitations_available + @user = params[:user] + @subject = "New invitations added to your account" + mail to: @user.email, subject: @subject + end end diff --git a/app/services/create_invitations.rb b/app/services/create_invitations.rb new file mode 100644 index 0000000..3003b1a --- /dev/null +++ b/app/services/create_invitations.rb @@ -0,0 +1,17 @@ +class CreateInvitations < ApplicationService + def initialize(user:, amount:, notify: true) + @user = user + @amount = amount + @notify = notify + end + + def call + @amount.times do + Invitation.create(user: @user) + end + + if @notify + NotificationMailer.with(user: @user).new_invitations_available.deliver_later + end + end +end diff --git a/app/views/admin/users/_create_invitations.html.erb b/app/views/admin/users/_create_invitations.html.erb new file mode 100644 index 0000000..dfffa36 --- /dev/null +++ b/app/views/admin/users/_create_invitations.html.erb @@ -0,0 +1,21 @@ +

Add new invitations to <%= @user.cn %>'s account

+<%= form_with(url: invitations_admin_user_path, method: :post) do |form| %> + +

+ <%= form.submit 'Add', class: "btn-md btn-blue w-full" %> +

+<% end %> diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index e0eb90d..830057a 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,4 +1,4 @@ -<%= render HeaderComponent.new(title: "Users: #{@ou}") %> +<%= render HeaderComponent.new(title: "Users") %> <%= render MainSimpleComponent.new do %>
@@ -16,19 +16,6 @@ <% end %>
- <% if @orgs.length > 1 %> -
- -
    - <% @orgs.each do |org| %> -
  • - <%= link_to org[:ou], admin_users_path(ou: org[:ou]), class: "ks-text-link" %> -
  • - <% end %> -
-
- <% end %> -
@@ -36,13 +23,12 @@ - <% @users.each do |user| %> - + diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index 05f8f11..bb27846 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -1,4 +1,4 @@ -<%= render HeaderComponent.new(title: "User: #{@user.address}") %> +<%= render HeaderComponent.new(title: "User: #{@user.cn}") %> <%= render MainSimpleComponent.new do %>
@@ -42,8 +42,34 @@
- diff --git a/app/views/icons/_plus-circle.html.erb b/app/views/icons/_plus-circle.html.erb index 4291ff0..14ae0bf 100644 --- a/app/views/icons/_plus-circle.html.erb +++ b/app/views/icons/_plus-circle.html.erb @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/views/icons/_x-circle.html.erb b/app/views/icons/_x-circle.html.erb index 94aad5e..c1bea72 100644 --- a/app/views/icons/_x-circle.html.erb +++ b/app/views/icons/_x-circle.html.erb @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/views/notification_mailer/new_invitations_available.text.erb b/app/views/notification_mailer/new_invitations_available.text.erb new file mode 100644 index 0000000..9cf8adc --- /dev/null +++ b/app/views/notification_mailer/new_invitations_available.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.display_name.presence || @user.cn %>, + +New invitations have just been added to your Kosmos account, so you can invite more people to our cooperative services: + +<%= invitations_url %> + +Have a nice day! + +--- + +Tip: if you want to invite someone you're meeting in person, log into your account panel on a mobile device and let people scan the invitation QR code from theirs. diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index bb264bf..d59cdd9 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -325,3 +325,10 @@ Devise.setup do |config| # changed. Defaults to true, so a user is signed in automatically after changing a password. # config.sign_in_after_change_password = true end + +# https://github.com/heartcombo/devise/issues/5644 +class Devise::SecretKeyFinder + def find + @application.secret_key_base + end +end diff --git a/config/routes.rb b/config/routes.rb index 643b095..39e69f2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -73,9 +73,19 @@ Rails.application.routes.draw do namespace :admin do root to: 'dashboard#index' - resources 'users', param: 'address', only: ['index', 'show'], constraints: { address: /.*/ } + resources 'users', param: 'username', only: ['index', 'show'] do + member do + post 'invitations', to: 'users#create_invitations' + delete 'invitations', to: 'users#delete_invitations' + end + end + + # post 'users/:username/invitations', to: 'users#create_invitations' + get 'invitations', to: 'invitations#index' + resources :donations + get 'lightning', to: 'lightning#index' namespace :app_catalog do diff --git a/db/seeds.rb b/db/seeds.rb index dd13a45..fa957a6 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -3,10 +3,10 @@ require 'sidekiq/testing' ldap = LdapService.new Sidekiq::Testing.inline! do - CreateAccount.call( + CreateAccount.call(account: { username: "admin", domain: "kosmos.org", email: "admin@example.com", password: "admin is admin", confirmed: true - ) + }) ldap.add_attribute "cn=admin,ou=kosmos.org,cn=users,dc=kosmos,dc=org", :admin, "true" @@ -15,9 +15,9 @@ Sidekiq::Testing.inline! do email = Faker::Internet.unique.email next if username.length < 3 - CreateAccount.call( + CreateAccount.call(account: { username: username, domain: "kosmos.org", email: email, password: "user is user", confirmed: true - ) + }) end end diff --git a/spec/features/admin/users_spec.rb b/spec/features/admin/users_spec.rb new file mode 100644 index 0000000..c804823 --- /dev/null +++ b/spec/features/admin/users_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +RSpec.describe "Admin: User management", type: :feature do + let(:admin) { create :user } + let(:user) { create :user, id: 2, cn: "alfred", email: "alfred@example.com" } + + before do + user.save! + + allow(Devise::LDAP::Adapter).to receive(:get_ldap_param) + .with(admin.cn, :admin).and_return(["true"]) + allow(Devise::LDAP::Adapter).to receive(:get_ldap_param) + .with(user.cn, :admin).and_return(nil) + allow_any_instance_of(User).to receive(:ldap_entry) + .and_return({ uid: user.cn, mail: user.email, display_name: "Freddy" }) + allow_any_instance_of(LdapManager::FetchAvatar).to receive(:call) + .and_return(nil) + + login_as admin, :scope => :user + end + + describe "User details page" do + before do + visit admin_user_path("alfred") + end + + it "shows the user info" do + within "h1" do + expect(page).to have_content("User: alfred") + end + expect(page).to have_content("alfred@example.com") + end + end + + scenario 'Add invitations to account' do + visit admin_user_path("alfred") + find("#add-invitations").click + + select "5", :from => "amount" + uncheck "notify_user" + click_button "Add" + + expect(user.invitations.count).to eq(5) + end + + scenario 'Remove invitations from account' do + 3.times { Invitation.create(user: user) } + expect(user.invitations.count).to eq(3) + + visit admin_user_path("alfred") + find("#remove-invitations").click + + expect(user.invitations.count).to eq(0) + end +end diff --git a/spec/services/create_invitations_spec.rb b/spec/services/create_invitations_spec.rb new file mode 100644 index 0000000..4e5de23 --- /dev/null +++ b/spec/services/create_invitations_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe CreateInvitations, type: :model do + include ActiveJob::TestHelper + + let(:user) { create :user } + + describe "#call" do + before do + CreateInvitations.call(user: user, amount: 5) + end + + after(:each) { clear_enqueued_jobs } + + it "creates the right amount of invitations for the given user" do + expect(user.invitations.count).to eq(5) + end + + it "sends an email notification to the user" do + expect(enqueued_jobs.size).to eq(1) + expect(enqueued_jobs.first["job_class"]).to eq("ActionMailer::MailDeliveryJob") + args = enqueued_jobs.first['arguments'] + expect(args[0]).to eq("NotificationMailer") + expect(args[1]).to eq("new_invitations_available") + expect(args[3]["params"]["user"]["_aj_globalid"]).to eq("gid://akkounts/User/1") + end + end + + describe "#call with notification disabled" do + before do + CreateInvitations.call(user: user, amount: 3, notify: false) + end + + after(:each) { clear_enqueued_jobs } + + it "does not send an email notification to the user" do + expect(enqueued_jobs.size).to eq(0) + end + end +end
UID Status Roles
<%= link_to(user.cn, admin_user_path(user.address), class: 'ks-text-link') %><%= link_to(user.cn, admin_user_path(user.cn), class: 'ks-text-link') %> <%= user.confirmed_at.nil? ? badge("pending", :yellow) : "" %> <%= user.is_admin? ? badge("admin", :red) : "" %>
Invitations available - <%= @user.invitations.count %> + +
+ + <%= @user.invitations.count %> + + + + <% if @user.invitations.unused.count > 0 %> + <%= link_to invitations_admin_user_path(@user.cn), + id: "remove-invitations", data: { + turbo_method: :delete, + turbo_confirm: "Delete all of #{@user.cn}'s available invitations?" + } do %> + <%= render partial: "icons/x-circle", locals: { + custom_class: "text-red-600 hover:text-red-500 -mt-2 -mb-1 h-6 w-6 inline-block" + } %> + <% end %> + <% end %> + +
+ <%= render ModalComponent.new(show_close_button: false) do %> + <%= render partial: "admin/users/create_invitations", + locals: { user: @user } %> + <% end %>