Merge pull request #101 from basecamp/access-config

Implement bucket access form
This commit is contained in:
Jose Farias
2024-10-09 12:05:02 -04:00
committed by GitHub
16 changed files with 183 additions and 48 deletions
+1
View File
@@ -20,6 +20,7 @@ gem "bcrypt", "~> 3.1.7"
gem "rqrcode"
group :development, :test do
gem "debug"
gem "brakeman", require: false
gem "rubocop-rails-omakase", require: false
end
+5 -1
View File
@@ -126,6 +126,9 @@ GEM
connection_pool (2.4.1)
crass (1.0.6)
date (3.3.4)
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
drb (2.2.1)
erubi (1.13.0)
globalid (1.2.1)
@@ -319,6 +322,7 @@ DEPENDENCIES
bootsnap
brakeman
capybara
debug
importmap-rails
propshaft
puma (>= 5.0)
@@ -335,4 +339,4 @@ RUBY VERSION
ruby 3.3.4p94
BUNDLED WITH
2.5.11
2.5.21
+10
View File
@@ -8,6 +8,16 @@
}
}
.filter--active {
li {
display: none;
&.selected {
display: flex;
}
}
}
.filter__button {
--hover-size: 0;
@@ -1,17 +0,0 @@
class Buckets::AccessesController < ApplicationController
include BucketScoped
def edit
@users = @bucket.users
end
def update
@bucket.update_access [ Current.user, *find_users ]
redirect_to edit_bucket_access_url(@bucket)
end
private
def find_users
Current.account.users.active.where(id: params[:user_ids])
end
end
+20 -2
View File
@@ -15,10 +15,16 @@ class BucketsController < ApplicationController
end
def edit
selected_user_ids = @bucket.users.pluck :id
@selected_users, @unselected_users = User.active.alphabetically.partition { |user| selected_user_ids.include? user.id }
end
def update
@bucket.update!(bucket_params)
@bucket.transaction do
@bucket.update! bucket_params
@bucket.accesses.revise granted: grantees, revoked: revokees
end
redirect_to bucket_bubbles_url(@bucket)
end
@@ -29,10 +35,22 @@ class BucketsController < ApplicationController
private
def set_bucket
@bucket = Current.user.buckets.find(params[:id])
@bucket = Current.user.buckets.find params[:id]
end
def bucket_params
params.require(:bucket).permit(:name)
end
def grantees
Current.account.users.active.where id: grantee_ids
end
def revokees
@bucket.users.where.not id: grantee_ids
end
def grantee_ids
params.fetch :user_ids, []
end
end
+2 -1
View File
@@ -2,7 +2,8 @@ module TranslationsHelper
TRANSLATIONS = {
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome" },
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email" },
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha" }
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha" },
bucket_name: { "🇺🇸": "Give it a name", "🇪🇸": "Dale un nombre", "🇫🇷": "Donnez lui un nom", "🇮🇳": "इसे एक नाम दें", "🇩🇪": "Gib ihm einen Namen", "🇧🇷": "Dê-lhe um nome" }
}
def translations_for(translation_key)
@@ -0,0 +1,46 @@
import { Controller } from "@hotwired/stimulus"
import { debounce } from "helpers/timing_helpers"
export default class extends Controller {
static targets = [ "list" ]
static classes = [ "active", "selected" ]
initialize() {
this.filter = debounce(this.filter.bind(this), 300)
}
connect() {
this.element.focus()
}
filter(event) {
this.#reset()
if (event.target.value != "") {
this.#selectMatches(event.target.value)
this.#activate()
}
}
#reset() {
this.#deactivate()
this.listTarget.querySelectorAll(`.${this.selectedClass}`).forEach((element) => {
element.classList.remove(this.selectedClass)
})
}
#activate() {
this.listTarget.classList.add(this.activeClass)
}
#deactivate() {
this.listTarget.classList.remove(this.activeClass)
}
#selectMatches(value) {
this.listTarget.querySelectorAll(`[data-value*=${value.toLowerCase()}]`).forEach((element) => {
element.classList.add(this.selectedClass)
})
}
}
+19 -15
View File
@@ -2,22 +2,26 @@ module Bucket::Accessible
extend ActiveSupport::Concern
included do
has_many :accesses, dependent: :destroy
has_many :accesses, dependent: :delete_all do
def revise(granted: [], revoked: [])
transaction do
grant_to granted
revoke_from revoked
end
end
private
def grant_to(users)
Access.insert_all Array(users).collect { |user| { bucket_id: proxy_association.owner.id, user_id: user.id } }
end
def revoke_from(users)
destroy_by user: users
end
end
has_many :users, through: :accesses
after_create -> { grant_access(creator) }
end
def update_access(users)
transaction do
grant_access(users)
accesses.where.not(user: Array(users)).delete_all
end
end
def grant_access(users)
Array(users).each do |user|
accesses.create_or_find_by!(user: user)
end
after_create -> { accesses.grant_to(creator) }
end
end
+1
View File
@@ -19,6 +19,7 @@ class User < ApplicationRecord
normalizes :email_address, with: ->(value) { value.strip.downcase }
scope :active, -> { where(active: true) }
scope :alphabetically, -> { order("LOWER(name)") }
def initials
name.to_s.scan(/\b\p{L}/).join.upcase
+5
View File
@@ -34,6 +34,11 @@
<% end %>
</menu>
</dialog>
<%= link_to edit_bucket_url(bucket), class: "btn btn--plain", style: "font-size: 0.25em;" do %>
<%= image_tag "pencil.svg", aria: { hidden: true }, size: 24 %>
<span class="for-screen-reader">Edit</span>
<% end %>
</div>
</h1>
+24
View File
@@ -0,0 +1,24 @@
<li class="flex align-center gap margin-none" data-value="<%= user.name.downcase %>">
<figure class="avatar flex-item-no-shrink" style="--avatar-size: 4ch;">
<%= avatar_tag user, loading: :lazy %>
</figure>
<div class="min-width">
<div class="overflow-ellipsis fill-shade">
<strong><%= user.name %></strong>
</div>
</div>
<hr class="separator" aria-hidden="true">
<% if user == Current.user %>
<%= hidden_field_tag "user_ids[]", user.id, id: nil %>
<%= image_tag "check.svg", size: 20, class: "colorize--black flex-item-no-shrink", aria: { hidden: "true" } %>
<% else %>
<label class="switch flex-item-no-shrink">
<%= check_box_tag "user_ids[]", user.id, selected, class: "switch__input", id: nil %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Give <%= user.name %> access</span>
</label>
<% end %>
</li>
+30 -5
View File
@@ -2,7 +2,7 @@
<% content_for :header do %>
<nav>
<%= link_to buckets_path, class: "btn flex-item-justify-start" do %>
<%= link_to bucket_bubbles_path(@bucket), class: "btn flex-item-justify-start" do %>
<%= image_tag "arrow-left.svg", aria: { hidden: true }, size: 24 %>
<span class="for-screen-reader">Go back</span>
<% end %>
@@ -17,11 +17,36 @@
<% end %>
<div class="panel shadow center" style="--panel-size: 55ch; view-transition-name: new-bucket">
<%= form_with model: @bucket, class: "flex flex-column gap txt-large" do |form| %>
<%= form_with model: @bucket, class: "flex flex-column gap txt-large", controller: "form" do |form| %>
<%= image_tag "bubbles.svg", aria: { hidden: "true" }, size: 150, class: "colorize--black center" %>
<label class="flex align-center gap">
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, placeholder: "Give it a name…" %>
</label>
<div class="flex align-center gap">
<%= translation_button(:bucket_name) %>
<label class="flex-item-grow txt-large">
<%= form.text_field :name, name: "bucket[name]", class: "input full-width",
required: true, autofocus: true, placeholder: "Give it a name…",
data: { action: "keydown.enter->form#submit:prevent" } %>
</label>
</div>
<section class="margin-block pad-inline fill-shade border-radius">
<menu class="flex flex-column gap margin-none pad overflow-y constrain-height" data-controller="filter" data-filter-active-class="filter--active" data-filter-selected-class="selected">
<% if User.active.count > 20 %>
<input placeholder="Filter…" class="input input--transparent full-width" type="search" autocorrect="off" autocomplete="off" data-1p-ignore="true" data-action="input->filter#filter">
<% end %>
<div data-filter-target="list">
<%= render partial: "buckets/user_toggle", collection: @selected_users, as: :user, locals: { bucket: @bucket, selected: true } %>
<% if @selected_users.any? && @unselected_users.any? %>
<hr class="separator full-width" style="--border-style: solid">
<% end %>
<%= render partial: "buckets/user_toggle", collection: @unselected_users, as: :user, locals: { bucket: @bucket, selected: false } %>
</div>
</menu>
</section>
<button type="submit" id="log_in" class="btn btn--reversed center">
<%= image_tag "check.svg", aria: { hidden: true }, size: 24 %>
+1 -1
View File
@@ -63,7 +63,7 @@ Rails.application.configure do
# config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true
config.action_view.annotate_rendered_view_with_filenames = true
# Uncomment if you wish to allow Action Cable access from any origin.
# config.action_cable.disable_request_forgery_protection = true
+15 -3
View File
@@ -1,7 +1,19 @@
require "test_helper"
class BucketsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
setup do
sign_in :kevin
end
test "edit" do
get edit_bucket_url(buckets(:writebook))
assert_response :success
end
test "update" do
patch bucket_url(buckets(:writebook)), params: { bucket: { name: "Writebook bugs" }, user_ids: users(:david, :jz).pluck(:id) }
assert_redirected_to bucket_bubbles_url(buckets(:writebook))
assert_equal users(:david, :jz), buckets(:writebook).users
assert_equal "Writebook bugs", buckets(:writebook).reload.name
end
end
View File
+4 -3
View File
@@ -1,7 +1,8 @@
require "test_helper"
class BucketTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
test "revising access" do
buckets(:writebook).accesses.revise granted: users(:david, :jz), revoked: users(:kevin)
assert_equal users(:david, :jz), buckets(:writebook).users
end
end