Merge branch 'chore/update_dependencies' into bugfix/local_web_app_icons
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing

This commit is contained in:
Râu Cao 2024-02-22 15:13:18 +01:00
commit de1f234c15
Signed by: raucao
GPG Key ID: 37036C356E56CC51
21 changed files with 269 additions and 39 deletions

View File

@ -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

View File

@ -2,13 +2,21 @@
<div class="relative inline-block">
<div role="button" tabindex="0" data-dropdown-target="button"
class="inline-block select-none">
<% if @size == :large %>
<span class="appearance-none flex items-center inline-block">
<span class="p-2 bg-gray-50 hover:bg-gray-100 rounded-full">
<%= 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" } %>
</span>
</span>
<% elsif @size == :small %>
<span class="appearance-none flex items-center inline-block">
<span class="text-gray-500 hover:text-blue-600">
<%= render partial: "icons/#{@icon_name}",
locals: { custom_class: "inline h-4 w-4" } %>
</span>
</span>
<% end %>
</div>
<div data-dropdown-target="menu"
data-transition-enter="transition ease-out duration-200"

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
class DropdownComponent < ViewComponent::Base
def initialize(size: :large, icon_name: "kebap-menu")
@size = size.to_sym
@icon_name = icon_name
end
end

View File

@ -5,7 +5,9 @@
} : nil do %>
<div class="flex flex-col">
<label class="font-bold mb-1"><%= @title %></label>
<% if @description.present? %>
<p class="text-gray-500"><%= @descripton %></p>
<% end %>
</div>
<div class="relative ml-4 inline-flex flex-shrink-0">
<%= render FormElements::ToggleComponent.new(

View File

@ -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

View File

@ -18,9 +18,11 @@
<div class="m-1 bg-white rounded shadow">
<div class="p-8">
<%= content %>
<% if @show_close_button %>
<div class="flex justify-end items-center flex-wrap mt-6">
<button class="btn-md btn-blue" data-action="click->modal#close:prevent">Close</button>
</div>
<% end %>
</div>
</div>
</div>

View File

@ -1,2 +1,5 @@
class ModalComponent < ViewComponent::Base
def initialize(show_close_button: true)
@show_close_button = show_close_button
end
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,21 @@
<h3>Add new invitations to <%= @user.cn %>'s account</h3>
<%= form_with(url: invitations_admin_user_path, method: :post) do |form| %>
<ul role="list">
<%= render FormElements::FieldsetComponent.new(
positioning: :horizontal,
title: "Amount"
) do %>
<%= form.select :amount, options_for_select([
["3", "3"], ["5", "5"], ["10", "10"], ["20", "20"]
]) %>
<% end %>
<%= render FormElements::FieldsetToggleComponent.new(
field_name: "notify_user",
enabled: true,
title: "Notify user via email"
) %>
</ul>
<p class="pt-6 border-t border-gray-200 text-right">
<%= form.submit 'Add', class: "btn-md btn-blue w-full" %>
</p>
<% end %>

View File

@ -1,4 +1,4 @@
<%= render HeaderComponent.new(title: "Users: #{@ou}") %>
<%= render HeaderComponent.new(title: "Users") %>
<%= render MainSimpleComponent.new do %>
<section>
@ -16,19 +16,6 @@
<% end %>
</section>
<% if @orgs.length > 1 %>
<section>
<h3 class="hidden">Domains</h3>
<ul>
<% @orgs.each do |org| %>
<li class="inline-block">
<%= link_to org[:ou], admin_users_path(ou: org[:ou]), class: "ks-text-link" %>
</li>
<% end %>
</ul>
</section>
<% end %>
<section>
<table class="divided mb-8">
<thead>
@ -36,13 +23,12 @@
<th>UID</th>
<th>Status</th>
<th>Roles</th>
<!-- <th>Password</th> -->
</tr>
</thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= link_to(user.cn, admin_user_path(user.address), class: 'ks-text-link') %></td>
<td><%= link_to(user.cn, admin_user_path(user.cn), class: 'ks-text-link') %></td>
<td><%= user.confirmed_at.nil? ? badge("pending", :yellow) : "" %></td>
<td><%= user.is_admin? ? badge("admin", :red) : "" %></td>
</tr>

View File

@ -1,4 +1,4 @@
<%= render HeaderComponent.new(title: "User: #{@user.address}") %>
<%= render HeaderComponent.new(title: "User: #{@user.cn}") %>
<%= render MainSimpleComponent.new do %>
<div class="mb-12 sm:flex sm:flex-row sm:gap-x-8">
@ -42,8 +42,34 @@
</tr>
<tr>
<th>Invitations available</th>
<td>
<%= @user.invitations.count %>
<td data-controller="modal" data-action="keydown.esc->modal#close">
<div class="flex justify-between">
<span>
<%= @user.invitations.count %>
</span>
<span>
<button id="add-invitations" data-action="click->modal#open">
<%= render partial: "icons/plus-circle", locals: {
custom_class: "text-green-600 hover:text-green-500 -mt-2 -mb-1 h-6 w-6 inline-block"
} %>
</button>
<% 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 %>
</span>
</div>
<%= render ModalComponent.new(show_close_button: false) do %>
<%= render partial: "admin/users/create_invitations",
locals: { user: @user } %>
<% end %>
</td>
</tr>
<tr>

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus-circle <%= custom_class %>"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg>

Before

Width:  |  Height:  |  Size: 351 B

After

Width:  |  Height:  |  Size: 372 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x-circle"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x-circle <%= custom_class %>"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>

Before

Width:  |  Height:  |  Size: 346 B

After

Width:  |  Height:  |  Size: 367 B

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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