Merge pull request #101 from basecamp/access-config
Implement bucket access form
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user