diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 3dc7cb7f..6ac5a920 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -35,6 +35,7 @@ @import "./components/ranks.css"; @import "./components/ranking.css"; @import "./components/teachers.css"; +@import "./components/organizations.css"; @import "./components/badges.css"; @import "./components/items.css"; @import "./components/shop.css"; diff --git a/app/assets/stylesheets/components/organizations.css b/app/assets/stylesheets/components/organizations.css new file mode 100644 index 00000000..6868ba62 --- /dev/null +++ b/app/assets/stylesheets/components/organizations.css @@ -0,0 +1,170 @@ +@layer components { + + .gh-organizations-panel { + margin-top: 22px; + padding: 6px 8px; + } + + .gh-organizations-panel-title { + padding: 12px 12px 6px; + } + + .gh-organizations-panel > .gh-explainer-note { + margin: 0; + padding: 6px 12px 14px; + } + + .gh-organization-row, + .gh-organization-member-row { + display: grid; + gap: 12px; + align-items: center; + padding: 12px; + border-top: 1px solid var(--gh-line); + } + + .gh-organization-row { + grid-template-columns: minmax(0, 2fr) 120px 120px; + } + + .gh-organization-member-row { + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr) 140px 96px; + } + + .gh-organizations-panel-title + .gh-organization-member-row { + border-top: 0; + } + + .gh-organization-row--link { + color: inherit; + text-decoration: none; + } + + .gh-organization-row--link:hover { + background: color-mix(in srgb, var(--gh-ink) 5%, transparent); + } + + .gh-organization-name { + font: 650 15px var(--gh-f-ui); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .gh-organization-member-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + } + + /* Admin start screens */ + + .gh-stat-row--numeric .gh-stat-tile:last-child dd { + font-size: 26px; + line-height: 1; + } + + .gh-start-org-list { + margin-top: 10px; + } + + .gh-start-org-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(140px, 220px); + gap: 16px; + align-items: center; + padding: 12px 4px; + border-top: 1px solid var(--gh-line); + color: inherit; + text-decoration: none; + } + + a.gh-start-org-row:hover b { + text-decoration: underline; + } + + .gh-capacity-bar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + margin: 10px 0; + } + + .gh-capacity-bar .gh-small { + font-variant-numeric: tabular-nums; + } + + .gh-progress-bar--full > i { + background: var(--gh-heart); + } + + .gh-start-more { + margin-top: 14px; + } + + .gh-start-attention-list { + list-style: none; + margin: 10px 0 0; + padding: 0; + } + + .gh-start-attention-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 12px; + align-items: center; + padding: 10px 0; + border-top: 1px solid var(--gh-line); + } + + .gh-start-attention-list li > :first-child { + font-weight: 650; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .gh-start-attention-list small { + grid-column: 1; + color: var(--gh-ink-3); + } + + .gh-start-attention-list .gh-btn { + grid-column: 2; + grid-row: 1 / 3; + } + + @media (width <= theme(--breakpoint-mobile)) { + .gh-start-org-row { + grid-template-columns: minmax(0, 1fr); + gap: 4px; + } + + .gh-organization-row, + .gh-organization-member-row { + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px 12px; + } + + .gh-organization-row--header, + .gh-organization-member-row--header { + display: none; + } + + .gh-organization-row > :not(:first-child) { + grid-column: 1; + } + + .gh-organization-member-row > :not(:last-child) { + grid-column: 1; + } + + .gh-organization-member-row > :last-child { + grid-column: 2; + grid-row: 1 / 4; + align-self: center; + } + } +} diff --git a/app/assets/stylesheets/components/table.css b/app/assets/stylesheets/components/table.css index 96b5c3a2..db3330e8 100644 --- a/app/assets/stylesheets/components/table.css +++ b/app/assets/stylesheets/components/table.css @@ -22,7 +22,9 @@ .gh-invite-row--header, .gh-ledger-row--header, .gh-ranking-row--header, - .gh-student-row--header { + .gh-student-row--header, + .gh-organization-row--header, + .gh-organization-member-row--header { border-top: 0; font: 680 12.5px var(--gh-f-ui); color: var(--gh-ink-3); diff --git a/app/components/shared/sidebar_component.rb b/app/components/shared/sidebar_component.rb index 8e476c1d..ebfa5a7c 100644 --- a/app/components/shared/sidebar_component.rb +++ b/app/components/shared/sidebar_component.rb @@ -20,9 +20,14 @@ def nav_items end # `all_story_groups` already unions owned, taught and joined groups and - # uniques them, which is exactly the mockup's "Twoje grupy" list. + # uniques them, which is exactly the mockup's "Twoje grupy" list. Global + # admins manage organizations only, so they get no group list. def groups - @groups ||= user.all_story_groups.sort_by { |group| group.name.to_s.downcase } + @groups ||= if user.global_admin? + [] + else + user.all_story_groups.sort_by { |group| group.name.to_s.downcase } + end end # "Prowadzisz" for a group you own, "Wspierasz" for one you co-teach, diff --git a/app/controllers/account_setups_controller.rb b/app/controllers/account_setups_controller.rb new file mode 100644 index 00000000..3f5d9677 --- /dev/null +++ b/app/controllers/account_setups_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class AccountSetupsController < ApplicationController + skip_before_action :require_account_setup! + before_action :redirect_configured_user + before_action -> { @chrome = false } + + def edit; end + + def update + @current_user.assign_attributes(account_setup_params) + @current_user.first_login = false + + if @current_user.save(context: :account_setup) + redirect_to home_path, notice: 'Pomyślnie skonfigurowano konto.' + else + @current_user.first_login = true + render :edit, status: :unprocessable_content + end + end + + private + + def redirect_configured_user + redirect_to home_path unless @current_user.needs_account_setup? + end + + def account_setup_params + params.expect( + user: %i[ + full_name + ], + ) + end +end diff --git a/app/controllers/auth/usos_controller.rb b/app/controllers/auth/usos_controller.rb index 474d8ac9..a41c2169 100644 --- a/app/controllers/auth/usos_controller.rb +++ b/app/controllers/auth/usos_controller.rb @@ -19,6 +19,10 @@ def create redirect_to root_path, alert: 'Nieprawidłowy dostawca logowania.' rescue Providers::InvalidAuthError redirect_to root_path, alert: 'Nieprawidłowa próba autoryzacji.' + rescue SessionUserBuilder::OrganizationNotFoundError + redirect_to root_path, alert: 'Twoja uczelnia nie jest zarejestrowana jako organizacja.' + rescue SessionUserBuilder::OrganizationFullError + redirect_to root_path, alert: 'Organizacja Twojej uczelni osiągnęła limit członków.' end private diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 8be0391e..d41faabe 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -6,6 +6,7 @@ module ::Authentication included do before_action :set_current_user before_action :authenticate! + before_action :require_account_setup! end protected @@ -30,4 +31,8 @@ def store_return_path def full_page_navigation? request.get? && !request.xhr? && !turbo_frame_request? end + + def require_account_setup! + redirect_to edit_account_setup_path if @current_user&.needs_account_setup? + end end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index fa070fa3..c969812d 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -1,12 +1,18 @@ # frozen_string_literal: true class HomeController < ApplicationController - # The landing screen after login. Two different screens behind one route, + helper OrganizationsHelper + + # The landing screen after login. One screen per role behind one route, # mirroring StoryGroupsController#show: the view dispatches on the role, the # controller loads the matching dashboard. def index @dashboard = - if current_user.teacher? + if current_user.global_admin? + GlobalAdminStartDashboard.new.load + elsif current_user.organization_admin? + OrganizationAdminStartDashboard.new(user: current_user).load + elsif current_user.teacher? TeacherStartDashboard.new(user: current_user, filter: params[:group]).load else StudentStartDashboard.new(user: current_user).load diff --git a/app/controllers/organizations/users_controller.rb b/app/controllers/organizations/users_controller.rb new file mode 100644 index 00000000..6c4da1c5 --- /dev/null +++ b/app/controllers/organizations/users_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class Organizations::UsersController < ApplicationController + helper OrganizationsHelper + + before_action :set_presentation + before_action :set_organization + before_action :set_user + + def edit; end + + def update + @user.assign_attributes(user_params) + + if @user.save(context: :user_invitation) + redirect_after_save notice: "Zapisano zmiany: #{@user.full_name}." + else + render :edit, status: :unprocessable_content + end + end + + def confirm_destroy; end + + def destroy + name = @user.full_name + @user.destroy! + + redirect_after_save notice: "Usunięto użytkownika: #{name}." + end + + private + + def set_presentation + @in_modal = turbo_frame_request_id == 'modal' + end + + def redirect_after_save(notice:) + path = organization_path(@organization) + return redirect_outside_turbo_frame(path, notice: notice) if @in_modal + + redirect_to path, notice: notice, status: :see_other + end + + def set_organization + @organization = Organization.find(params.expect(:organization_id)) + authorize @organization, :manage_users? + end + + def set_user + @user = @organization.users.where(role: User::INVITABLE_ROLES).find(params.expect(:id)) + end + + def user_params + params.expect( + user: %i[ + email + full_name + university_number + usos_id + role + ], + ) + end +end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb new file mode 100644 index 00000000..cef0cee0 --- /dev/null +++ b/app/controllers/organizations_controller.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +class OrganizationsController < ApplicationController + ADMIN_INVITATION_EXPIRES_IN = 7.days + USER_INVITATION_EXPIRES_IN = 7.days + + before_action :set_presentation + before_action :set_organization, + only: %i[show edit update confirm_destroy destroy new_admin add_admin new_user add_user] + + def index + @organizations = policy_scope(Organization).order(:name) + + members = User.where(organization: @organizations).group(:organization_id) + @member_counts = members.count + @admin_counts = members.organization_admin.count + end + + def show + authorize @organization + + @admins = @organization.users.organization_admin.order(:created_at) + @member_count = @organization.users.count + + return unless @current_user.organization_admin? + + @users = @organization.users.where(role: User::INVITABLE_ROLES).order(:created_at) + end + + def new + @organization = Organization.new + authorize @organization + end + + def create + @organization = Organization.new(organization_params) + + authorize @organization + + if @organization.save + redirect_after_save organization_path(@organization), notice: 'Pomyślnie utworzono organizację.' + else + render :new, status: :unprocessable_content + end + end + + def edit + authorize @organization + end + + def update + authorize @organization + + if @organization.update(organization_params) + redirect_after_save organization_path(@organization), notice: 'Pomyślnie zaktualizowano organizację.' + else + render :edit, status: :unprocessable_content + end + end + + def confirm_destroy + authorize @organization, :destroy? + end + + def destroy + authorize @organization + + unless params[:confirm].to_s.strip == @organization.name.to_s + @confirm_failed = true + return render :confirm_destroy, status: :unprocessable_content + end + + @organization.destroy! + redirect_after_save organizations_path, notice: "Usunięto organizację #{@organization.name}." + end + + def new_admin + authorize @organization + + @org_admin = User.new + end + + def add_admin + authorize @organization + + @org_admin = User.new(admin_params) + @org_admin.organization = @organization + @org_admin.university_name = @organization.name + @org_admin.role = :organization_admin + @org_admin.first_login = true + + token = User.transaction do + @org_admin.save(context: :admin_invitation) && + @org_admin.create_login_token!(expires_in: ADMIN_INVITATION_EXPIRES_IN) + end + + if token + send_admin_invitation(token) + redirect_after_save organization_path(@organization), + notice: 'Pomyślnie dodano administratora organizacji. Wysłano zaproszenie na email.' + else + render :new_admin, status: :unprocessable_content + end + end + + def new_user + authorize @organization + + @new_user = User.new + end + + def add_user + authorize @organization + + @new_user = User.new(user_params) + @new_user.organization = @organization + @new_user.university_name = @organization.name + @new_user.first_login = true + + token = User.transaction do + @new_user.save(context: :user_invitation) && + @new_user.create_login_token!(expires_in: USER_INVITATION_EXPIRES_IN) + end + + if token + send_user_invitation(token) + redirect_after_save organization_path(@organization), + notice: 'Pomyślnie dodano użytkownika do organizacji. Wysłano zaproszenie na email.' + else + render :new_user, status: :unprocessable_content + end + end + + private + + def set_presentation + @in_modal = turbo_frame_request_id == 'modal' + end + + def redirect_after_save(path, notice:) + return redirect_outside_turbo_frame(path, notice: notice) if @in_modal + + redirect_to path, notice: notice, status: :see_other + end + + def set_organization + @organization = Organization.find(params.expect(:id)) + end + + def organization_params + params.expect( + organization: %i[ + name + max_members + ], + ) + end + + def send_admin_invitation(token) + OrganizationAdminMailer.with( + token_link: auth_passwordless_verify_url(token: token.raw_token), + email: @org_admin.email, + organization_name: @organization.name, + ).invitation_email.deliver_later + end + + def send_user_invitation(token) + NewUserMailer.with( + token_link: auth_passwordless_verify_url(token: token.raw_token), + email: @new_user.email, + organization_name: @organization.name, + ).invitation_email.deliver_later + end + + def admin_params + params.expect( + user: %i[ + email + ], + ) + end + + def user_params + params.expect( + user: %i[ + email + full_name + university_number + usos_id + role + ], + ) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 0bc2e206..da7f8f40 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -4,6 +4,7 @@ class SessionsController < ApplicationController include LoggedUserRedirector skip_before_action :authenticate!, only: :new + skip_before_action :require_account_setup!, only: :destroy before_action :redirect_logged_user, only: :new before_action -> { @chrome = false }, only: :new diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index 03620959..e6889af8 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -1,4 +1,11 @@ # frozen_string_literal: true module HomeHelper + def home_greeting(user) + first_name = user.full_name.to_s.split.first + base_greeting = 'Dzień dobry' + return base_greeting if first_name.blank? + + "#{base_greeting}, #{first_name}" + end end diff --git a/app/helpers/organizations_helper.rb b/app/helpers/organizations_helper.rb new file mode 100644 index 00000000..de150c35 --- /dev/null +++ b/app/helpers/organizations_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module OrganizationsHelper + USER_ROLE_LABELS = { + 'student' => 'Student', + 'teacher' => 'Nauczyciel', + }.freeze + + def user_role_label(role) + str_role = role.to_s + USER_ROLE_LABELS.fetch(str_role, str_role.humanize) + end + + def organization_capacity_lead(organization, members) + free = [organization.max_members - members, 0].max + free_text = gh_plural(free, 'wolne', 'wolne', 'wolnych') + + "#{members} z #{organization.max_members} miejsc zajętych · #{free} #{free_text}" + end + + def user_role_options + User::INVITABLE_ROLES.map { |role| [user_role_label(role), role] } + end +end diff --git a/app/javascript/controllers/list_search_controller.ts b/app/javascript/controllers/list_search_controller.ts index 7d57bdb3..93d0ff04 100644 --- a/app/javascript/controllers/list_search_controller.ts +++ b/app/javascript/controllers/list_search_controller.ts @@ -2,10 +2,12 @@ import { application } from "@controllers/application" import { Controller } from "@hotwired/stimulus" class ListSearchController extends Controller { - static targets = ["input", "row", "count", "empty", "emptyQuery"] + static targets = ["input", "filter", "row", "count", "empty", "emptyQuery"] static values = { total: Number, of: String } declare readonly inputTarget: HTMLInputElement + declare readonly filterTarget: HTMLSelectElement + declare readonly hasFilterTarget: boolean declare readonly rowTargets: HTMLElement[] declare readonly countTarget: HTMLElement declare readonly emptyTarget: HTMLElement @@ -16,9 +18,11 @@ class ListSearchController extends Controller { filter() { const typed = this.inputTarget.value.trim() const query = fold(typed) + const selected = this.hasFilterTarget ? this.filterTarget.value : "" const shown = this.rowTargets.filter((row) => { - const hit = fold(row.dataset.listSearchName ?? "").includes(query) + const hit = fold(row.dataset.listSearchName ?? "").includes(query) && + (selected === "" || row.dataset.listSearchFilter === selected) row.hidden = !hit diff --git a/app/mailers/new_user_mailer.rb b/app/mailers/new_user_mailer.rb new file mode 100644 index 00000000..06e4c639 --- /dev/null +++ b/app/mailers/new_user_mailer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class NewUserMailer < ApplicationMailer + def invitation_email + @token_link = params[:token_link] + @email = params[:email] + @organization_name = params[:organization_name] + + mail to: @email, subject: 'Zaproszenie do systemu GamificationHub' + end +end diff --git a/app/mailers/organization_admin_mailer.rb b/app/mailers/organization_admin_mailer.rb new file mode 100644 index 00000000..6e981c0f --- /dev/null +++ b/app/mailers/organization_admin_mailer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class OrganizationAdminMailer < ApplicationMailer + def invitation_email + @token_link = params[:token_link] + @email = params[:email] + @organization_name = params[:organization_name] + + mail to: @email, subject: 'Zaproszenie do systemu GamificationHub' + end +end diff --git a/app/models/login_token.rb b/app/models/login_token.rb index b1b8abb6..c935ee39 100644 --- a/app/models/login_token.rb +++ b/app/models/login_token.rb @@ -33,6 +33,6 @@ def generate_token! @raw_token = raw_token self.token_digest = self.class.digest(raw_token) - self.expires_at = EXPIRES_IN.from_now + self.expires_at ||= EXPIRES_IN.from_now end end diff --git a/app/models/organization.rb b/app/models/organization.rb new file mode 100644 index 00000000..7a36a8bf --- /dev/null +++ b/app/models/organization.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Organization < ApplicationRecord + has_many :users, dependent: :destroy + + validates :name, length: { maximum: 100 }, uniqueness: true + validates :max_members, presence: true, numericality: { only_integer: true, greater_than: 0 } + validate :max_members_not_below_members_count + + def full? + max_members.present? && users.count >= max_members + end + + private + + def max_members_not_below_members_count + return if new_record? || max_members.blank? + + members_count = users.count + return if max_members >= members_count + + errors.add(:max_members, "can't be lower than the current number of members (#{members_count})") + end +end diff --git a/app/models/story_group.rb b/app/models/story_group.rb index 867d1c12..6a7cc2fe 100644 --- a/app/models/story_group.rb +++ b/app/models/story_group.rb @@ -32,6 +32,8 @@ class StoryGroup < ApplicationRecord validates :description, length: { maximum: 1024 } validates :default_lives, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + delegate :organization, to: :owner + def ranking_summary return 'Wyłączony' unless ranking_enabled? diff --git a/app/models/user.rb b/app/models/user.rb index fbfe3f90..38f76f9a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -8,6 +8,8 @@ class User < ApplicationRecord global_admin: 4, } + INVITABLE_ROLES = %w[student teacher].freeze + has_many :student_memberships, class_name: 'StoryGroupStudent', foreign_key: 'user_id', dependent: :destroy has_many :teacher_memberships, class_name: 'StoryGroupTeacher', foreign_key: 'user_id', dependent: :destroy has_many :owner_story_groups, class_name: 'StoryGroup', foreign_key: 'owner_id' @@ -16,6 +18,8 @@ class User < ApplicationRecord has_many :notifications, dependent: :destroy has_one :login_token, dependent: :destroy + belongs_to :organization, optional: true + normalizes :email, with: ->(email) { email.strip.downcase } validates :email, length: { maximum: 255 }, uniqueness: true, allow_nil: true @@ -24,6 +28,10 @@ class User < ApplicationRecord validates :full_name, length: { maximum: 80 } validates :university_name, length: { maximum: 100 } validates_presence_of :email, :full_name, on: :account_setup + validates_presence_of :email, on: :admin_invitation + validates_presence_of :email, :full_name, on: :user_invitation + validates :role, inclusion: { in: INVITABLE_ROLES }, on: :user_invitation + validate :organization_has_free_slot, if: :will_save_change_to_organization_id? encrypts :email, deterministic: true encrypts :university_number, deterministic: true @@ -33,12 +41,22 @@ def all_story_groups (owner_story_groups + student_story_groups + teacher_story_groups).uniq end - def create_login_token! + def needs_account_setup? + organization_admin? && first_login? + end + + def create_login_token!(expires_in: LoginToken::EXPIRES_IN) consume_login_token! - LoginToken.create!(user: self) + LoginToken.create!(user: self, expires_at: expires_in.from_now) end def consume_login_token! login_token&.destroy! end + + private + + def organization_has_free_slot + errors.add(:organization, 'has reached its member limit') if organization&.full? + end end diff --git a/app/policies/organization_policy.rb b/app/policies/organization_policy.rb new file mode 100644 index 00000000..0592406d --- /dev/null +++ b/app/policies/organization_policy.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class OrganizationPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + def resolve + if user.global_admin? + scope.all + elsif user.organization_admin? + scope.where(id: user.organization.id) + else + scope.none + end + end + end + + def show? + user.global_admin? || own_organization_admin? + end + + def new? + user.global_admin? + end + + def create? + user.global_admin? + end + + def edit? + user.global_admin? + end + + def update? + user.global_admin? + end + + def destroy? + user.global_admin? + end + + def new_admin? + user.global_admin? + end + + def add_admin? + user.global_admin? + end + + def new_user? + manage_users? + end + + def add_user? + manage_users? + end + + def manage_users? + user.global_admin? || own_organization_admin? + end + + private + + def own_organization_admin? + user.organization_admin? && user.organization_id == record.id + end +end diff --git a/app/policies/story_group_policy.rb b/app/policies/story_group_policy.rb index 1d3279b7..e6e1f4c6 100644 --- a/app/policies/story_group_policy.rb +++ b/app/policies/story_group_policy.rb @@ -3,8 +3,10 @@ class StoryGroupPolicy < ApplicationPolicy class Scope < ApplicationPolicy::Scope def resolve - if user.organization_admin? || user.global_admin? - scope.all.includes(:owner) + if user.global_admin? + scope.none + elsif user.organization_admin? + scope.where(owner_id: user.organization.users.select(:id)) elsif user.teacher? owner = scope.where(owner_id: user.id) # and also those to which they belong as teacher and student teaching = scope.where(id: user.teacher_story_groups.select(:id)) @@ -69,7 +71,7 @@ def student? private def admin? - user.global_admin? || user.organization_admin? + user.organization_admin? end def member? diff --git a/app/policies/story_group_student_policy.rb b/app/policies/story_group_student_policy.rb index 50162ab8..e0440add 100644 --- a/app/policies/story_group_student_policy.rb +++ b/app/policies/story_group_student_policy.rb @@ -25,7 +25,7 @@ def resolve private def admin? - user.organization_admin? || user.global_admin? + user.organization_admin? end end @@ -84,7 +84,7 @@ def own_record? private def admin? - user.organization_admin? || user.global_admin? + user.organization_admin? end def story_group_teacher? diff --git a/app/services/global_admin_start_dashboard.rb b/app/services/global_admin_start_dashboard.rb new file mode 100644 index 00000000..eaa5110c --- /dev/null +++ b/app/services/global_admin_start_dashboard.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class GlobalAdminStartDashboard + NEARLY_FULL = 0.9 + + Org = Data.define(:organization, :member_count, :admins, :pending_admins) do + def free = [organization.max_members - member_count, 0].max + def fill_percent = (member_count * 100.0 / organization.max_members).clamp(0, 100).round + def full? = member_count >= organization.max_members + def nearly_full? = member_count >= organization.max_members * NEARLY_FULL + def no_admin? = admins.zero? + end + + Tile = Data.define(:label, :value, :note) + + attr_reader :organizations + + def load + @organizations = build_organizations + self + end + + def stat_tiles + [ + Tile.new(label: 'Organizacje', value: organizations.size, note: nil), + Tile.new(label: 'Konta w organizacjach', value: organizations.sum(&:member_count), note: nil), + Tile.new(label: 'Wolne miejsca', value: organizations.sum(&:free), note: nil), + Tile.new(label: 'Niezaakceptowane zaproszenia', value: organizations.sum(&:pending_admins), note: nil), + ] + end + + def without_admin = organizations.select(&:no_admin?) + def nearly_full = organizations.select(&:nearly_full?) + def attention? = without_admin.any? || nearly_full.any? + + private + + def build_organizations + organizations = Organization.order(:name).to_a + users = User.where(organization: organizations).group(:organization_id) + members = users.count + admins = users.organization_admin.count + pending = users.organization_admin.where(first_login: true).count + + organizations.map do |organization| + Org.new( + organization: organization, + member_count: members.fetch(organization.id, 0), + admins: admins.fetch(organization.id, 0), + pending_admins: pending.fetch(organization.id, 0), + ) + end + end +end diff --git a/app/services/organization_admin_start_dashboard.rb b/app/services/organization_admin_start_dashboard.rb new file mode 100644 index 00000000..70067908 --- /dev/null +++ b/app/services/organization_admin_start_dashboard.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class OrganizationAdminStartDashboard + RECENT_LIMIT = 6 + + Tile = GlobalAdminStartDashboard::Tile + + attr_reader :organization, :members, :recent_users, :pending_users + + def initialize(user:) + @organization = user.organization + end + + def load + return self unless organization + + @members = organization.users.count + @counts = organization.users.group(:role).count + @recent_users = invitable.order(created_at: :desc).limit(RECENT_LIMIT).to_a + @pending_users = invitable.where(first_login: true).order(:created_at).to_a + self + end + + def free = [organization.max_members - members, 0].max + def fill_percent = (members * 100.0 / organization.max_members).clamp(0, 100).round + def full? = members >= organization.max_members + + def stat_tiles + [ + Tile.new(label: 'Studenci', value: @counts.fetch('student', 0), note: nil), + Tile.new(label: 'Nauczyciele', value: @counts.fetch('teacher', 0), note: nil), + Tile.new(label: 'Wolne miejsca', value: free, note: "z #{organization.max_members}"), + Tile.new(label: 'Oczekujące zaproszenia', value: pending_users.size, note: nil), + ] + end + + private + + def invitable + organization.users.where(role: User::INVITABLE_ROLES) + end +end diff --git a/app/services/session_user_builder.rb b/app/services/session_user_builder.rb index ea607985..b8677400 100644 --- a/app/services/session_user_builder.rb +++ b/app/services/session_user_builder.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class SessionUserBuilder + class OrganizationNotFoundError < StandardError; end + class OrganizationFullError < StandardError; end + attr_reader :provider def initialize(provider) @@ -8,13 +11,14 @@ def initialize(provider) end def build - user_params = @provider.user_params + # Also populates the provider's attributes (email, usos_id, university_name) used below. + @user_params = @provider.user_params user = find_or_initialize_user - if user.new_record? || user.first_login? - user.assign_attributes(user_params) - user.first_login = false - user.save!(context: :account_setup) + if user.new_record? + create_in_organization(user) + elsif user.first_login? + complete_setup(user) end user @@ -22,6 +26,26 @@ def build private + # New accounts join the organization named after the provider's university. + # The lock keeps concurrent sign-ups from overfilling it. + def create_in_organization(user) + organization = Organization.find_by(name: @provider.university_name) + raise OrganizationNotFoundError unless organization + + organization.with_lock do + raise OrganizationFullError if organization.full? + + user.organization = organization + complete_setup(user) + end + end + + def complete_setup(user) + user.assign_attributes(@user_params) + user.first_login = false + user.save!(context: :account_setup) + end + def find_or_initialize_user if @provider.usos_id user = User.find_by( diff --git a/app/values/navigation.rb b/app/values/navigation.rb index 0b8e8fc6..3ba69341 100644 --- a/app/values/navigation.rb +++ b/app/values/navigation.rb @@ -27,8 +27,9 @@ def initialize(user) def primary_items [ Item.new(label: 'Start', path: home_path, icon: 'fa-house'), - Item.new(label: 'Grupy', path: story_groups_path, icon: 'fa-book'), - ] + (Item.new(label: 'Grupy', path: story_groups_path, icon: 'fa-book') unless @user.global_admin?), + organization_item, + ].compact end def tab_items @@ -104,6 +105,18 @@ def ranking_item(chrome) section('Ranking', story_group_ranking_path(chrome.story_group), 'fa-ranking-star') end + # Global admins manage every organization; an organization admin goes + # straight to their own. + def organization_item + if @user.global_admin? + Item.new(label: 'Organizacje', path: organizations_path, icon: 'fa-briefcase', match: organizations_path) + elsif @user.organization_admin? && @user.organization + path = organization_path(@user.organization) + Item.new(label: @user.organization.name, path: path, icon: 'fa-briefcase', + short: 'Organizacja', match: path,) + end + end + def overview(group) Item.new(label: 'Przegląd', path: story_group_path(group), icon: 'fa-table-columns') end diff --git a/app/views/account_setups/edit.html.haml b/app/views/account_setups/edit.html.haml new file mode 100644 index 00000000..b2913c78 --- /dev/null +++ b/app/views/account_setups/edit.html.haml @@ -0,0 +1,20 @@ +- content_for :title, 'Konfiguracja konta' + +%h1.gh-h1 Uzupełnij swoje dane +%p.gh-lead To jednorazowy krok. Imię i nazwisko zobaczą prowadzący w Twoich grupach. + += form_with model: @current_user, url: account_setup_path, method: :patch do |f| + .gh-form-field + = f.label :email, 'Adres e-mail' + .gh-text-input + = f.email_field :email, disabled: true + + = render 'organizations/field', form: f, record: @current_user, attribute: :full_name, + label: 'Imię i nazwisko', + options: { maxlength: 80, autofocus: true, required: true, + autocomplete: 'name', placeholder: 'Jan Nowak' } + + .gh-button-stack + %button.gh-btn{ type: 'submit' } Zapisz i przejdź dalej + += button_to 'Wyloguj', logout_path, method: :delete, class: 'gh-link-button' diff --git a/app/views/home/_capacity_bar.html.haml b/app/views/home/_capacity_bar.html.haml new file mode 100644 index 00000000..290ffcd0 --- /dev/null +++ b/app/views/home/_capacity_bar.html.haml @@ -0,0 +1,9 @@ +.gh-capacity-bar + .gh-progress-bar.gh-octagon-clip{ class: ('gh-progress-bar--full' if full), + role: 'progressbar', + 'aria-valuemin': 0, + 'aria-valuemax': max, + 'aria-valuenow': members, + 'aria-label': 'Zajęte miejsca', } + %i.gh-octagon-clip{ style: "--gh-p: #{percent}%" } + %span.gh-small= "#{members} / #{max}" diff --git a/app/views/home/_global_admin_start.html.haml b/app/views/home/_global_admin_start.html.haml new file mode 100644 index 00000000..ef58e67f --- /dev/null +++ b/app/views/home/_global_admin_start.html.haml @@ -0,0 +1,79 @@ +- organizations = @dashboard.organizations + +%section.gh-panel.gh-page-header + .gh-page-header-text + %h1.gh-h1= home_greeting(@current_user) + %p.gh-lead + - if organizations.empty? + Nie ma jeszcze żadnej organizacji. Utwórz pierwszą, żeby nowe konta miały gdzie trafić. + - elsif @dashboard.attention? + = "#{organizations.size} #{gh_plural(organizations.size, 'organizacja', 'organizacje', 'organizacji')} —" + część z nich potrzebuje Twojej uwagi. + - else + = "#{organizations.size} #{gh_plural(organizations.size, 'organizacja', 'organizacje', 'organizacji')}." + Wszystkie mają administratora i wolne miejsca. + + = link_to new_organization_path, class: 'gh-btn', data: { turbo_frame: 'modal' } do + %i.fa-solid.fa-plus{ 'aria-hidden': 'true' } + Utwórz organizację + +- if organizations.any? + = render 'stat_tiles', tiles: @dashboard.stat_tiles, label: 'Podsumowanie organizacji' + + .gh-dashboard-layout + %section.gh-panel.gh-recent-purchases-panel{ 'aria-labelledby': 'gh-orgs-title' } + %h2#gh-orgs-title.gh-h2 Organizacje + .gh-start-org-list + - organizations.each do |org| + = link_to organization_path(org.organization), class: 'gh-start-org-row' do + %span.gh-name-cell + %b= org.organization.name + %small + = "#{org.admins} #{gh_plural(org.admins, 'administrator', 'administratorów', 'administratorów')}" + - if org.pending_admins.positive? + = "· #{org.pending_admins} #{gh_plural(org.pending_admins, 'zaproszenie czeka', 'zaproszenia czekają', 'zaproszeń czeka')}" + = render 'capacity_bar', members: org.member_count, max: org.organization.max_members, + percent: org.fill_percent, full: org.full? + = link_to 'Wszystkie organizacje', organizations_path, class: 'gh-btn gh-btn--secondary gh-start-more' + + .gh-dashboard-side-column + - if @dashboard.attention? + %section.gh-card.gh-todo-card + .gh-card-frame + .gh-card-inset + %p.gh-page-kicker Wymaga uwagi + %ul.gh-start-attention-list + - @dashboard.without_admin.each do |org| + %li + = link_to org.organization.name, organization_path(org.organization) + %small Brak administratora + = link_to new_admin_organization_path(org.organization), class: 'gh-btn gh-btn--secondary gh-btn--small', + data: { turbo_frame: 'modal' } do + %i.fa-solid.fa-user-shield{ 'aria-hidden': 'true' } + Dodaj + - @dashboard.nearly_full.each do |org| + %li + = link_to org.organization.name, organization_path(org.organization) + %small= org.full? ? 'Brak wolnych miejsc' : "Zostało #{org.free} #{gh_plural(org.free, 'miejsce', 'miejsca', 'miejsc')}" + = link_to edit_organization_path(org.organization), class: 'gh-btn gh-btn--secondary gh-btn--small', + data: { turbo_frame: 'modal' } do + %i.fa-solid.fa-pen{ 'aria-hidden': 'true' } + Limit + - else + %section.gh-panel.gh-my-groups-panel + %h2.gh-h2 Wszystko w porządku + %p.gh-small Każda organizacja ma administratora i wolne miejsca. + + %section.gh-panel.gh-my-groups-panel + %h2.gh-h2 Jak to działa + %p.gh-small + Konto zakładane przez logowanie z uczelni trafia do organizacji o tej + samej nazwie co uczelnia. Jeśli takiej nie ma, logowanie się nie uda. +- else + %section.gh-panel.gh-empty-state + %h2.gh-h2 Zacznij od pierwszej organizacji + %p.gh-lead + Nazwij ją tak jak uczelnię u dostawcy logowania, ustaw limit kont i dodaj + administratora — on zaprosi resztę osób. + .gh-button-row + = link_to 'Utwórz organizację', new_organization_path, class: 'gh-btn', data: { turbo_frame: 'modal' } diff --git a/app/views/home/_organization_admin_start.html.haml b/app/views/home/_organization_admin_start.html.haml new file mode 100644 index 00000000..e7fe1c02 --- /dev/null +++ b/app/views/home/_organization_admin_start.html.haml @@ -0,0 +1,69 @@ +- organization = @dashboard.organization + +- if organization.nil? + %section.gh-panel.gh-empty-state + %h1.gh-h1= home_greeting(@current_user) + %p.gh-lead + Twoje konto administratora nie jest przypisane do żadnej organizacji. + Skontaktuj się z administratorem systemu. +- else + %section.gh-panel.gh-page-header + .gh-page-header-text + %p.gh-page-kicker= organization.name + %h1.gh-h1= home_greeting(@current_user) + %p.gh-lead + - if @dashboard.full? + Organizacja nie ma wolnych miejsc — nowe osoby nie dołączą, dopóki + ktoś nie zostanie usunięty albo limit nie wzrośnie. + - elsif @dashboard.pending_users.any? + - pending = @dashboard.pending_users.size + = "#{pending} #{gh_plural(pending, 'osoba jeszcze nie przyjęła', 'osoby jeszcze nie przyjęły', 'osób jeszcze nie przyjęło')} zaproszenia." + - else + = organization_capacity_lead(organization, @dashboard.members) + + .gh-button-row + = link_to organization_path(organization), class: 'gh-btn gh-btn--secondary' do + %i.fa-solid.fa-users{ 'aria-hidden': 'true' } + Użytkownicy + = link_to new_user_organization_path(organization), class: 'gh-btn', data: { turbo_frame: 'modal' } do + %i.fa-solid.fa-user-plus{ 'aria-hidden': 'true' } + Dodaj użytkownika + + = render 'stat_tiles', tiles: @dashboard.stat_tiles, label: 'Podsumowanie organizacji' + + .gh-dashboard-layout + %section.gh-panel.gh-recent-purchases-panel{ 'aria-labelledby': 'gh-recent-users-title' } + %h2#gh-recent-users-title.gh-h2 Ostatnio dodani + - if @dashboard.recent_users.any? + .gh-start-org-list + - @dashboard.recent_users.each do |user| + .gh-start-org-row + = render 'organizations/person', user: user + %span.gh-teacher-role + = user_role_label(user.role) + %small= user.first_login? ? 'Zaproszenie wysłane' : "Dodano #{user.created_at.strftime('%d.%m.%Y')}" + = link_to 'Wszyscy użytkownicy', organization_path(organization), class: 'gh-btn gh-btn--secondary gh-start-more' + - else + %p.gh-small + Nikogo tu jeszcze nie ma. Dodaj studentów i nauczycieli albo poczekaj, + aż zalogują się przez uczelnię. + + .gh-dashboard-side-column + %section.gh-panel.gh-my-groups-panel + %h2.gh-h2 Miejsca + = render 'capacity_bar', members: @dashboard.members, max: organization.max_members, + percent: @dashboard.fill_percent, full: @dashboard.full? + %p.gh-small + Limit ustala administrator systemu. Każde konto, także administratora, + zajmuje jedno miejsce. + + - if @dashboard.pending_users.any? + %section.gh-card.gh-todo-card + .gh-card-frame + .gh-card-inset + %p.gh-page-kicker Czeka na przyjęcie zaproszenia + %ul.gh-start-attention-list + - @dashboard.pending_users.first(OrganizationAdminStartDashboard::RECENT_LIMIT).each do |user| + %li + %span= user.full_name.presence || user.email + %small= user.email diff --git a/app/views/home/_stat_tiles.html.haml b/app/views/home/_stat_tiles.html.haml new file mode 100644 index 00000000..6dd359e7 --- /dev/null +++ b/app/views/home/_stat_tiles.html.haml @@ -0,0 +1,9 @@ +%section.gh-panel.gh-stat-row.gh-stat-row--numeric{ 'aria-label': label } + %dl + - tiles.each do |tile| + .gh-stat-tile + %dt= tile.label + %dd + = tile.value + - if tile.note + %small= tile.note diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 47992c22..944ee7d5 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,6 +1,10 @@ - content_for :title, 'Start' -- if @current_user.teacher? +- if @current_user.global_admin? + = render 'global_admin_start' +- elsif @current_user.organization_admin? + = render 'organization_admin_start' +- elsif @current_user.teacher? = render 'teacher_start' - else = render 'student_start' diff --git a/app/views/new_user_mailer/invitation_email.html.haml b/app/views/new_user_mailer/invitation_email.html.haml new file mode 100644 index 00000000..de5ab067 --- /dev/null +++ b/app/views/new_user_mailer/invitation_email.html.haml @@ -0,0 +1,3 @@ += render 'shared/invitation_email', + lead: "Zostałeś dodany jako członek organizacji #{@organization_name} w Gamification Hub.", + token_link: @token_link diff --git a/app/views/new_user_mailer/invitation_email.text.haml b/app/views/new_user_mailer/invitation_email.text.haml new file mode 100644 index 00000000..de5ab067 --- /dev/null +++ b/app/views/new_user_mailer/invitation_email.text.haml @@ -0,0 +1,3 @@ += render 'shared/invitation_email', + lead: "Zostałeś dodany jako członek organizacji #{@organization_name} w Gamification Hub.", + token_link: @token_link diff --git a/app/views/organization_admin_mailer/invitation_email.html.haml b/app/views/organization_admin_mailer/invitation_email.html.haml new file mode 100644 index 00000000..3d4a27f4 --- /dev/null +++ b/app/views/organization_admin_mailer/invitation_email.html.haml @@ -0,0 +1,3 @@ += render 'shared/invitation_email', + lead: "Zostałeś dodany jako administrator organizacji #{@organization_name} w Gamification Hub.", + token_link: @token_link diff --git a/app/views/organization_admin_mailer/invitation_email.text.haml b/app/views/organization_admin_mailer/invitation_email.text.haml new file mode 100644 index 00000000..3d4a27f4 --- /dev/null +++ b/app/views/organization_admin_mailer/invitation_email.text.haml @@ -0,0 +1,3 @@ += render 'shared/invitation_email', + lead: "Zostałeś dodany jako administrator organizacji #{@organization_name} w Gamification Hub.", + token_link: @token_link diff --git a/app/views/organizations/_admin_form.html.haml b/app/views/organizations/_admin_form.html.haml new file mode 100644 index 00000000..c6a407fa --- /dev/null +++ b/app/views/organizations/_admin_form.html.haml @@ -0,0 +1,17 @@ += form_with model: user, url: add_admin_organization_path(@organization), + data: { turbo_frame: (@in_modal ? 'modal' : nil) } do |form| + %h2.gh-h2 Dodaj administratora + %p + Wyślemy zaproszenie na podany adres. Po pierwszym logowaniu administrator + uzupełni swoje dane. + + = render 'organizations/field', form: form, record: user, attribute: :email, label: 'Adres e-mail', + input: :email_field, + options: { placeholder: 'jan.nowak@example.com', autofocus: true } + - if (error = user.errors[:organization].first) + %p.gh-field-error{ role: 'alert' } + %i.fa-solid.fa-triangle-exclamation{ 'aria-hidden': 'true' } + %span= error + + = render 'organizations/dialog_actions', cancel_path: organization_path(@organization), + submit_label: 'Wyślij zaproszenie' diff --git a/app/views/organizations/_admins.html.haml b/app/views/organizations/_admins.html.haml new file mode 100644 index 00000000..5739585c --- /dev/null +++ b/app/views/organizations/_admins.html.haml @@ -0,0 +1,13 @@ +%section.gh-panel.gh-organizations-panel + %h2.gh-h3.gh-organizations-panel-title Administratorzy + + - if admins.any? + - admins.each do |admin| + .gh-organization-member-row + = render 'organizations/person', user: admin + %span.gh-teacher-role + Administrator + - if admin.first_login? + %small Zaproszenie wysłane + - else + %p.gh-explainer-note Organizacja nie ma jeszcze administratora. diff --git a/app/views/organizations/_confirm_destroy.html.haml b/app/views/organizations/_confirm_destroy.html.haml new file mode 100644 index 00000000..b3745e47 --- /dev/null +++ b/app/views/organizations/_confirm_destroy.html.haml @@ -0,0 +1,35 @@ +- members = organization.users.count + +%h2.gh-h2 Usunąć organizację na zawsze? +%p + = "#{members} #{gh_plural(members, 'konto zostanie usunięte', 'konta zostaną usunięte', 'kont zostanie usuniętych')}" + razem z organizacją, w tym konta administratorów. + += form_with url: organization_path(organization), method: :delete, + data: { controller: 'confirm-name', + confirm_name_expected_value: organization.name, + turbo_frame: (@in_modal ? 'modal' : nil) } do + + .gh-form-field + = label_tag :confirm do + Wpisz nazwę organizacji, żeby potwierdzić: + %b= organization.name + .gh-text-input{ class: ('gh-text-input--invalid' if @confirm_failed) } + = text_field_tag :confirm, nil, autocomplete: 'off', autofocus: true, + 'aria-invalid': (@confirm_failed ? 'true' : nil), + 'aria-describedby': (@confirm_failed ? 'gh-del-organization-error' : nil), + data: { confirm_name_target: 'input', action: 'confirm-name#check' } + - if @confirm_failed + %p#gh-del-organization-error.gh-field-error{ role: 'alert' } + %i.fa-solid.fa-triangle-exclamation{ 'aria-hidden': 'true' } + %span Wpisana nazwa nie zgadza się z nazwą organizacji. Organizacja nie została usunięta. + + .gh-dialog-button-row + - if @in_modal + %button.gh-btn.gh-btn--secondary{ type: 'button', data: { action: 'dialog#close' } } Anuluj + - else + = link_to 'Anuluj', organization_path(organization), class: 'gh-btn gh-btn--secondary' + + %button.gh-btn{ type: 'submit', disabled: true, + data: { confirm_name_target: 'submit' } } + Usuń organizację diff --git a/app/views/organizations/_dialog_actions.html.haml b/app/views/organizations/_dialog_actions.html.haml new file mode 100644 index 00000000..f12a7058 --- /dev/null +++ b/app/views/organizations/_dialog_actions.html.haml @@ -0,0 +1,8 @@ +-# Cancel closes the dialog when the form opened in one, otherwise it goes +-# back to `cancel_path`. +.gh-dialog-button-row + - if @in_modal + %button.gh-btn.gh-btn--secondary{ type: 'button', data: { action: 'dialog#close' } } Anuluj + - else + = link_to 'Anuluj', cancel_path, class: 'gh-btn gh-btn--secondary' + %button.gh-btn{ type: 'submit' }= submit_label diff --git a/app/views/organizations/_field.html.haml b/app/views/organizations/_field.html.haml new file mode 100644 index 00000000..600ae8a0 --- /dev/null +++ b/app/views/organizations/_field.html.haml @@ -0,0 +1,19 @@ +-# Labelled input with its first error underneath. `input` is the form +-# builder method (text_field, email_field, number_field). +- input ||= :text_field +- options ||= {} +- error = record.errors[attribute].first +- dom_id = "gh-#{record.model_name.param_key}-#{attribute}" + +.gh-form-field + = form.label attribute, label + .gh-text-input{ class: ('gh-text-input--invalid' if error) } + = form.public_send input, attribute, + autocomplete: 'off', + 'aria-invalid': (error ? 'true' : nil), + 'aria-describedby': (error ? "#{dom_id}-error" : nil), + **options + - if error + %p.gh-field-error{ id: "#{dom_id}-error", role: 'alert' } + %i.fa-solid.fa-triangle-exclamation{ 'aria-hidden': 'true' } + %span= error diff --git a/app/views/organizations/_form.html.haml b/app/views/organizations/_form.html.haml new file mode 100644 index 00000000..711a9e25 --- /dev/null +++ b/app/views/organizations/_form.html.haml @@ -0,0 +1,17 @@ +- editing = organization.persisted? + += form_with model: organization, data: { turbo_frame: (@in_modal ? 'modal' : nil) } do |form| + %h2.gh-h2= editing ? 'Edytuj organizację' : 'Nowa organizacja' + %p + Nazwa musi być taka sama jak nazwa uczelni u dostawcy logowania — + po niej nowe konta trafiają do organizacji. + + = render 'organizations/field', form: form, record: organization, attribute: :name, label: 'Nazwa', + options: { maxlength: 100, autofocus: true } + = render 'organizations/field', form: form, record: organization, attribute: :max_members, + label: 'Limit członków', input: :number_field, + options: { min: 1, step: 1 } + + = render 'organizations/dialog_actions', + cancel_path: (editing ? organization_path(organization) : organizations_path), + submit_label: (editing ? 'Zapisz' : 'Utwórz organizację') diff --git a/app/views/organizations/_member_row.html.haml b/app/views/organizations/_member_row.html.haml new file mode 100644 index 00000000..0dc652c8 --- /dev/null +++ b/app/views/organizations/_member_row.html.haml @@ -0,0 +1,29 @@ +- name = user.full_name.presence || user.email + +.gh-organization-member-row{ id: dom_id(user), + data: { list_search_target: 'row', + list_search_name: [user.full_name, user.email].compact.join(' ').downcase, + list_search_filter: user.role, } } + = render 'organizations/person', user: user + + %span.gh-teacher-role + %span.gh-mobile-field-label Rola + = user_role_label(user.role) + - if user.first_login? + %small Zaproszenie wysłane + + %span + %span.gh-mobile-field-label Numer indeksu + %span.gh-small= user.university_number.presence || '—' + + %span.gh-organization-member-actions + = link_to edit_organization_user_path(@organization, user), + class: 'gh-icon-button', title: 'Edytuj', + 'aria-label': "Edytuj: #{name}", + data: { turbo_frame: 'modal' } do + %i.fa-solid.fa-pen{ 'aria-hidden': 'true' } + = link_to confirm_destroy_organization_user_path(@organization, user), + class: 'gh-icon-button', title: 'Usuń', + 'aria-label': "Usuń: #{name}", + data: { turbo_frame: 'modal' } do + %i.fa-solid.fa-trash{ 'aria-hidden': 'true' } diff --git a/app/views/organizations/_members.html.haml b/app/views/organizations/_members.html.haml new file mode 100644 index 00000000..754afff5 --- /dev/null +++ b/app/views/organizations/_members.html.haml @@ -0,0 +1,39 @@ +- if users.any? + %div{ data: { controller: 'list-search', + list_search_total_value: users.size, + list_search_of_value: 'z', } } + .gh-student-list-toolbar + %label.gh-search-input + %i.fa-solid.fa-magnifying-glass{ 'aria-hidden': 'true' } + %input{ type: 'search', + placeholder: 'Szukaj po nazwisku lub e-mailu', + 'aria-label': 'Szukaj użytkownika', + data: { list_search_target: 'input', action: 'input->list-search#filter' }, } + %span.gh-select-input + = select_tag :role, options_for_select(user_role_options), include_blank: 'Wszystkie role', + 'aria-label': 'Filtruj po roli', + data: { list_search_target: 'filter', action: 'change->list-search#filter' } + %i.fa-solid.fa-chevron-down{ 'aria-hidden': 'true' } + %span.gh-card-meta{ data: { list_search_target: 'count' } } + = "#{users.size} z #{users.size}" + + %section.gh-panel.gh-organizations-panel + .gh-organization-member-row.gh-organization-member-row--header{ 'aria-hidden': 'true' } + %span Użytkownik + %span Rola + %span Numer indeksu + %span.gh-ledger-cell--right Akcje + + = render partial: 'organizations/member_row', collection: users, as: :user + + %section.gh-panel.gh-empty-state{ hidden: true, data: { list_search_target: 'empty' } } + %h2.gh-h2 + Brak użytkowników pasujących do + %span{ data: { list_search_target: 'emptyQuery' } } + %p.gh-lead Wyczyść wyszukiwanie, zmień rolę albo sprawdź pisownię. +- else + %section.gh-panel.gh-empty-state + %h2.gh-h2 Nie ma jeszcze użytkowników + %p.gh-lead + Dodaj studentów i nauczycieli — dostaną zaproszenie e-mailem. Konta + zakładane przez logowanie z uczelni pojawią się tu same. diff --git a/app/views/organizations/_person.html.haml b/app/views/organizations/_person.html.haml new file mode 100644 index 00000000..b046ce7d --- /dev/null +++ b/app/views/organizations/_person.html.haml @@ -0,0 +1,8 @@ +- name = user.full_name.presence || user.email + +%span.gh-student-name-cell + %span.gh-avatar.gh-avatar--sm.gh-octagon-clip{ 'data-gh-h': gh_avatar_hue(name), 'aria-hidden': 'true' } + = gh_initials(name) + %span.gh-name-cell + %b= name + %small= user.email diff --git a/app/views/organizations/_user_form.html.haml b/app/views/organizations/_user_form.html.haml new file mode 100644 index 00000000..264ba150 --- /dev/null +++ b/app/views/organizations/_user_form.html.haml @@ -0,0 +1,36 @@ +- editing = user.persisted? +- url = editing ? organization_user_path(@organization, user) : add_user_organization_path(@organization) + += form_with model: user, url: url, data: { turbo_frame: (@in_modal ? 'modal' : nil) } do |form| + %h2.gh-h2= editing ? 'Edytuj użytkownika' : 'Dodaj użytkownika' + - unless editing + %p Wyślemy zaproszenie na podany adres. Konto zajmie jedno miejsce w organizacji. + + = render 'organizations/field', form: form, record: user, attribute: :email, label: 'Adres e-mail', + input: :email_field, + options: { placeholder: 'jan.nowak@example.com', autofocus: !editing } + = render 'organizations/field', form: form, record: user, attribute: :full_name, label: 'Imię i nazwisko', + options: { maxlength: 80 } + + .gh-form-field + = form.label :role, 'Rola' + %span.gh-select-input + = form.select :role, user_role_options + %i.fa-solid.fa-chevron-down{ 'aria-hidden': 'true' } + - if (error = user.errors[:role].first) + %p.gh-field-error{ role: 'alert' } + %i.fa-solid.fa-triangle-exclamation{ 'aria-hidden': 'true' } + %span= error + + .gh-form-row-2col + = render 'organizations/field', form: form, record: user, attribute: :university_number, + label: 'Numer indeksu (opcjonalnie)', options: { maxlength: 20 } + = render 'organizations/field', form: form, record: user, attribute: :usos_id, label: 'ID USOS (opcjonalnie)' + + - if (error = user.errors[:organization].first) + %p.gh-field-error{ role: 'alert' } + %i.fa-solid.fa-triangle-exclamation{ 'aria-hidden': 'true' } + %span= error + + = render 'organizations/dialog_actions', cancel_path: organization_path(@organization), + submit_label: (editing ? 'Zapisz' : 'Wyślij zaproszenie') diff --git a/app/views/organizations/confirm_destroy.html.haml b/app/views/organizations/confirm_destroy.html.haml new file mode 100644 index 00000000..733da5df --- /dev/null +++ b/app/views/organizations/confirm_destroy.html.haml @@ -0,0 +1,8 @@ +- content_for :title, "Usuń organizację #{@organization.name}" + +- if @in_modal + = turbo_frame_tag 'modal' do + = render 'confirm_destroy', organization: @organization +- else + %section.gh-panel.gh-dialog-page-panel + = render 'confirm_destroy', organization: @organization diff --git a/app/views/organizations/edit.html.haml b/app/views/organizations/edit.html.haml new file mode 100644 index 00000000..fe7ac1e5 --- /dev/null +++ b/app/views/organizations/edit.html.haml @@ -0,0 +1,8 @@ +- content_for :title, "Edytuj organizację — #{@organization.name}" + +- if @in_modal + = turbo_frame_tag 'modal' do + = render 'form', organization: @organization +- else + %section.gh-panel.gh-dialog-page-panel + = render 'form', organization: @organization diff --git a/app/views/organizations/index.html.haml b/app/views/organizations/index.html.haml new file mode 100644 index 00000000..7d240963 --- /dev/null +++ b/app/views/organizations/index.html.haml @@ -0,0 +1,35 @@ +- content_for :title, 'Organizacje' + +%section.gh-panel.gh-page-header + .gh-page-header-text + %h1.gh-h1 Organizacje + %p.gh-lead Uczelnie korzystające z aplikacji. Nowe konta trafiają do organizacji o nazwie swojej uczelni. + + - if policy(:organization).create? + = link_to new_organization_path, class: 'gh-btn', data: { turbo_frame: 'modal' } do + %i.fa-solid.fa-plus{ 'aria-hidden': 'true' } + Utwórz organizację + +- if @organizations.any? + %section.gh-panel.gh-organizations-panel + .gh-organization-row.gh-organization-row--header{ 'aria-hidden': 'true' } + %span Organizacja + %span Członkowie + %span Administratorzy + + - @organizations.each do |organization| + - members = @member_counts.fetch(organization.id, 0) + = link_to organization_path(organization), class: 'gh-organization-row gh-organization-row--link' do + %b.gh-organization-name= organization.name + %span + %span.gh-mobile-field-label Członkowie + %span.gh-numeric-cell= "#{members} / #{organization.max_members}" + %span + %span.gh-mobile-field-label Administratorzy + %span.gh-numeric-cell= @admin_counts.fetch(organization.id, 0) +- else + %section.gh-panel.gh-empty-state + %h2.gh-h2 Nie ma jeszcze żadnej organizacji + %p.gh-lead + Utwórz organizację o nazwie uczelni, a potem dodaj jej administratora — + on zaprosi resztę osób. diff --git a/app/views/organizations/new.html.haml b/app/views/organizations/new.html.haml new file mode 100644 index 00000000..220b39ed --- /dev/null +++ b/app/views/organizations/new.html.haml @@ -0,0 +1,8 @@ +- content_for :title, 'Nowa organizacja' + +- if @in_modal + = turbo_frame_tag 'modal' do + = render 'form', organization: @organization +- else + %section.gh-panel.gh-dialog-page-panel + = render 'form', organization: @organization diff --git a/app/views/organizations/new_admin.html.haml b/app/views/organizations/new_admin.html.haml new file mode 100644 index 00000000..9115f358 --- /dev/null +++ b/app/views/organizations/new_admin.html.haml @@ -0,0 +1,8 @@ +- content_for :title, 'Dodaj administratora organizacji' + +- if @in_modal + = turbo_frame_tag 'modal' do + = render 'admin_form', user: @org_admin +- else + %section.gh-panel.gh-dialog-page-panel + = render 'admin_form', user: @org_admin diff --git a/app/views/organizations/new_user.html.haml b/app/views/organizations/new_user.html.haml new file mode 100644 index 00000000..4f3c2c6e --- /dev/null +++ b/app/views/organizations/new_user.html.haml @@ -0,0 +1,8 @@ +- content_for :title, 'Dodaj użytkownika organizacji' + +- if @in_modal + = turbo_frame_tag 'modal' do + = render 'user_form', user: @new_user +- else + %section.gh-panel.gh-dialog-page-panel + = render 'user_form', user: @new_user diff --git a/app/views/organizations/show.html.haml b/app/views/organizations/show.html.haml new file mode 100644 index 00000000..fabd1852 --- /dev/null +++ b/app/views/organizations/show.html.haml @@ -0,0 +1,43 @@ +- content_for :title, @organization.name + +- manage_users = policy(@organization).manage_users? && @users + +%section.gh-panel.gh-page-header + .gh-page-header-text + - if @current_user.global_admin? + %p.gh-breadcrumbs + = link_to 'Organizacje', organizations_path + %i.fa-solid.fa-chevron-right{ 'aria-hidden': 'true' } + %span= @organization.name + %h1.gh-h1= @organization.name + %p.gh-lead= organization_capacity_lead(@organization, @member_count) + + .gh-button-row + - if policy(@organization).edit? + = link_to edit_organization_path(@organization), class: 'gh-btn gh-btn--secondary', + data: { turbo_frame: 'modal' } do + %i.fa-solid.fa-pen{ 'aria-hidden': 'true' } + Edytuj + - if policy(@organization).new_admin? + = link_to new_admin_organization_path(@organization), class: 'gh-btn', data: { turbo_frame: 'modal' } do + %i.fa-solid.fa-user-shield{ 'aria-hidden': 'true' } + Dodaj administratora + - if manage_users + = link_to new_user_organization_path(@organization), class: 'gh-btn', data: { turbo_frame: 'modal' } do + %i.fa-solid.fa-user-plus{ 'aria-hidden': 'true' } + Dodaj użytkownika + += render 'admins', admins: @admins + +- if manage_users + = render 'members', users: @users + +- if policy(@organization).destroy? + %section.gh-panel.gh-danger-zone + %h2.gh-h3 Usuń organizację + %p.gh-small Razem z organizacją znikną wszystkie jej konta. Tej operacji nie można cofnąć. + .gh-button-row + = link_to confirm_destroy_organization_path(@organization), + class: 'gh-btn gh-btn--secondary', data: { turbo_frame: 'modal' } do + %i.fa-solid.fa-trash{ 'aria-hidden': 'true' } + Usuń organizację diff --git a/app/views/organizations/users/_confirm_destroy.html.haml b/app/views/organizations/users/_confirm_destroy.html.haml new file mode 100644 index 00000000..1710b11d --- /dev/null +++ b/app/views/organizations/users/_confirm_destroy.html.haml @@ -0,0 +1,14 @@ +%h2.gh-h2= "Usunąć konto #{user.full_name}?" +%p + Konto zostanie usunięte na stałe, razem z członkostwem w grupach, walutą, + odznakami i przedmiotami. Zwolni się jedno miejsce w organizacji. + +.gh-dialog-button-row + - if @in_modal + %button.gh-btn.gh-btn--secondary{ type: 'button', data: { action: 'dialog#close' }, autofocus: true } Anuluj + - else + = link_to 'Anuluj', organization_path(@organization), class: 'gh-btn gh-btn--secondary' + + = button_to 'Usuń konto', organization_user_path(@organization, user), + method: :delete, class: 'gh-btn', + form: { data: { turbo_frame: (@in_modal ? 'modal' : nil) } } diff --git a/app/views/organizations/users/confirm_destroy.html.haml b/app/views/organizations/users/confirm_destroy.html.haml new file mode 100644 index 00000000..d83d4842 --- /dev/null +++ b/app/views/organizations/users/confirm_destroy.html.haml @@ -0,0 +1,8 @@ +- content_for :title, "Usunąć konto #{@user.full_name}?" + +- if @in_modal + = turbo_frame_tag 'modal' do + = render 'confirm_destroy', user: @user +- else + %section.gh-panel.gh-dialog-page-panel + = render 'confirm_destroy', user: @user diff --git a/app/views/organizations/users/edit.html.haml b/app/views/organizations/users/edit.html.haml new file mode 100644 index 00000000..d3b3ab4e --- /dev/null +++ b/app/views/organizations/users/edit.html.haml @@ -0,0 +1,8 @@ +- content_for :title, "Edytuj użytkownika — #{@user.full_name}" + +- if @in_modal + = turbo_frame_tag 'modal' do + = render 'organizations/user_form', user: @user +- else + %section.gh-panel.gh-dialog-page-panel + = render 'organizations/user_form', user: @user diff --git a/app/views/shared/_invitation_email.html.haml b/app/views/shared/_invitation_email.html.haml new file mode 100644 index 00000000..bd5824e2 --- /dev/null +++ b/app/views/shared/_invitation_email.html.haml @@ -0,0 +1,26 @@ +-# Same frame as passwordless_mailer/token_email, for organization invitations. +%table{ cellpadding: 0, cellspacing: 0, border: 0, width: '100%', style: 'background:#F3EEF6;padding:32px 16px;' } + %tr + %td{ align: 'center' } + %table{ cellpadding: 0, cellspacing: 0, border: 0, width: '480', style: 'max-width:480px;width:100%;background:#FFFDF9;' } + %tr + %td{ style: 'padding:32px;font-family:Helvetica,Arial,sans-serif;' } + %p{ style: 'margin:0 0 28px;font:800 18px/1 Helvetica,Arial,sans-serif;color:#221A2B;' } Gamification Hub + + %h1{ style: 'margin:0 0 16px;font-size:22px;color:#221A2B;' } Zaproszenie + %p{ style: 'margin:0 0 12px;font-size:15px;line-height:1.5;color:#584C64;' }= lead + %p{ style: 'margin:0 0 24px;font-size:15px;line-height:1.5;color:#584C64;' } + Kliknij przycisk poniżej, aby dokończyć zakładanie konta i uzupełnić swoje dane. + + %table{ cellpadding: 0, cellspacing: 0, border: 0 } + %tr + %td{ style: 'background:#FF7A1A;border-bottom:3px solid #C45A10;', align: 'center' } + %a{ href: token_link, style: 'display:inline-block;padding:14px 28px;font:700 15px Helvetica,Arial,sans-serif;color:#221A2B;text-decoration:none;' } Dokończ zakładanie konta + + %p{ style: 'margin:24px 0 0;font-size:13px;line-height:1.5;color:#85798F;' } + Link jest ważny przez 7 dni. Jeśli przycisk nie działa, skopiuj ten adres do przeglądarki: + %p{ style: 'margin:8px 0 0;font-size:13px;word-break:break-all;' } + %a{ href: token_link, style: 'color:#584C64;' }= token_link + + %p{ style: 'margin:28px 0 0;padding-top:16px;border-top:1px solid #E4DCEA;font-size:12.5px;color:#85798F;' } + Jeśli nie spodziewałeś się tego zaproszenia, możesz zignorować tę wiadomość. diff --git a/app/views/shared/_invitation_email.text.haml b/app/views/shared/_invitation_email.text.haml new file mode 100644 index 00000000..e9924020 --- /dev/null +++ b/app/views/shared/_invitation_email.text.haml @@ -0,0 +1,11 @@ +Gamification Hub + += lead + +Aby dokończyć zakładanie konta i uzupełnić swoje dane, otwórz poniższy link. + += token_link + +Link jest ważny przez 7 dni. + +Jeśli nie spodziewałeś się tego zaproszenia, możesz zignorować tę wiadomość. diff --git a/config/routes.rb b/config/routes.rb index a5272fee..e1cd3acc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -77,6 +77,22 @@ post :mark_as_read, on: :collection end + resources :organizations do + member do + get :confirm_destroy + get :new_admin + post :add_admin + get :new_user + post :add_user + end + + resources :users, only: %i[edit update destroy], module: :organizations do + get :confirm_destroy, on: :member + end + end + + resource :account_setup, only: %i[edit update] + namespace 'auth' do get '/:provider/callback', to: 'usos#create', as: :callback resource :passwordless, only: %i[new create], controller: 'passwordless' diff --git a/db/migrate/20260915175517_create_organizations.rb b/db/migrate/20260915175517_create_organizations.rb new file mode 100644 index 00000000..a58dccf5 --- /dev/null +++ b/db/migrate/20260915175517_create_organizations.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateOrganizations < ActiveRecord::Migration[8.1] + def change + create_table :organizations do |t| + t.string :name + t.integer :max_members + + t.timestamps + end + end +end diff --git a/db/migrate/20260922155430_add_university_number_to_organizations.rb b/db/migrate/20260922155430_add_university_number_to_organizations.rb new file mode 100644 index 00000000..9edb9317 --- /dev/null +++ b/db/migrate/20260922155430_add_university_number_to_organizations.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddUniversityNumberToOrganizations < ActiveRecord::Migration[8.1] + def change + add_column :organizations, :university_number, :string + add_index :organizations, :university_number, unique: true + end +end diff --git a/db/migrate/20260923171241_remove_university_number_from_organizations.rb b/db/migrate/20260923171241_remove_university_number_from_organizations.rb new file mode 100644 index 00000000..d08e8098 --- /dev/null +++ b/db/migrate/20260923171241_remove_university_number_from_organizations.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RemoveUniversityNumberFromOrganizations < ActiveRecord::Migration[8.1] + def change + remove_column :organizations, :university_number, :string + end +end diff --git a/db/migrate/20260925121911_add_organization_id_to_users.rb b/db/migrate/20260925121911_add_organization_id_to_users.rb new file mode 100644 index 00000000..2604b9ee --- /dev/null +++ b/db/migrate/20260925121911_add_organization_id_to_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdToUsers < ActiveRecord::Migration[8.1] + def change + add_reference :users, :organization, foreign_key: true + end +end diff --git a/db/migrate/20261010120000_add_unique_index_to_organizations_name.rb b/db/migrate/20261010120000_add_unique_index_to_organizations_name.rb new file mode 100644 index 00000000..2f1a2b5a --- /dev/null +++ b/db/migrate/20261010120000_add_unique_index_to_organizations_name.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddUniqueIndexToOrganizationsName < ActiveRecord::Migration[8.1] + def change + add_index :organizations, :name, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index df598b4c..ee6f2999 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_17_090000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_10_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -179,6 +179,14 @@ t.index ["user_id"], name: "index_notifications_on_user_id" end + create_table "organizations", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "max_members" + t.string "name" + t.datetime "updated_at", null: false + t.index ["name"], name: "index_organizations_on_name", unique: true + end + create_table "ranks", force: :cascade do |t| t.datetime "created_at", null: false t.integer "discount" @@ -272,12 +280,14 @@ t.string "email" t.boolean "first_login", default: true t.string "full_name" + t.bigint "organization_id" t.integer "role", default: 1 t.string "university_name" t.string "university_number" t.datetime "updated_at", null: false t.string "usos_id" t.index ["email"], name: "index_users_on_email", unique: true + t.index ["organization_id"], name: "index_users_on_organization_id" t.index ["usos_id", "university_name"], name: "index_users_on_usos_id_and_university_name", unique: true end @@ -312,4 +322,5 @@ add_foreign_key "students_badges", "story_group_students" add_foreign_key "students_items", "items" add_foreign_key "students_items", "story_group_students" + add_foreign_key "users", "organizations" end diff --git a/db/seeds.rb b/db/seeds.rb index 1cfe449a..7a3dfae2 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -6,6 +6,7 @@ Dir[Rails.root.join('db/seeds/*.rb')].each { |file| require file } Seeds::Users.call +Seeds::Organizations.call Seeds::StoryGroups.call Seeds::Ranks.call Seeds::Badges.call diff --git a/db/seeds/organizations.rb b/db/seeds/organizations.rb new file mode 100644 index 00000000..b4304923 --- /dev/null +++ b/db/seeds/organizations.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Seeds + class Organizations < Base + ORGANIZATIONS = [ + { name: 'Example university', max_members: 50 }, + { name: 'Politechnika Przykładowa', max_members: 20 }, + { name: 'Uniwersytet im. Adama Mickiewicza', max_members: 1000 }, + ].freeze + + ADMINS = { + 'Example university' => [ + { email: 'pawel.wisniewski@example.com' }, + { email: 'zofia.nowicka@example.com', full_name: nil, first_login: true }, + ], + }.freeze + + def call + log_start 'organizations' + + ORGANIZATIONS.each do |organization_data| + organization = Organization.find_or_create_by!(name: organization_data[:name]) do |org| + org.max_members = organization_data[:max_members] + end + + build_admins(organization) + assign_members(organization) + end + + log_finish 'organizations' + end + + private + + def assign_members(organization) + User.where(university_name: organization.name, organization_id: nil) + .where.not(role: :global_admin) + .find_each { |user| user.update!(organization: organization) } + end + + def build_admins(organization) + ADMINS.fetch(organization.name, []).each do |admin_data| + admin = User.find_by(email: admin_data[:email]) || + FactoryBot.build(:user, :organization_admin, **admin_data, usos_id: nil, university_number: nil) + + admin.update!(organization: organization, university_name: organization.name) + end + end + end +end diff --git a/test/controllers/account_setups_controller_test.rb b/test/controllers/account_setups_controller_test.rb new file mode 100644 index 00000000..395b7be2 --- /dev/null +++ b/test/controllers/account_setups_controller_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'test_helper' + +class AccountSetupsControllerTest < ActionDispatch::IntegrationTest + setup do + @admin = FactoryBot.create(:user, :organization_admin, first_login: true, full_name: nil) + end + + test 'org admin with first login is redirected to account setup' do + sign_in @admin + follow_redirect! + + assert_redirected_to edit_account_setup_path + end + + test 'non admin user with first login is not redirected to account setup' do + sign_in FactoryBot.create(:user, :student, first_login: true) + get home_path + + assert_response :success + end + + test '#edit - renders form' do + sign_in @admin + get edit_account_setup_path + + assert_response :success + end + + test '#edit - user not needing setup is redirected home' do + sign_in FactoryBot.create(:user, :student, first_login: true) + get edit_account_setup_path + + assert_redirected_to home_path + end + + test '#update - saves data and finishes setup' do + sign_in @admin + patch account_setup_path, params: { user: { full_name: 'Anna Kowalska' } } + + assert_redirected_to home_path + @admin.reload + assert_equal 'Anna Kowalska', @admin.full_name + assert_not @admin.first_login? + end + + test '#update - requires full name' do + sign_in @admin + patch account_setup_path, params: { user: { full_name: '' } } + + assert_response :unprocessable_content + assert @admin.reload.first_login? + end +end diff --git a/test/controllers/home_controller_test.rb b/test/controllers/home_controller_test.rb index 7915ac90..1deb8eeb 100644 --- a/test/controllers/home_controller_test.rb +++ b/test/controllers/home_controller_test.rb @@ -3,4 +3,56 @@ require 'test_helper' class HomeControllerTest < ActionDispatch::IntegrationTest + test 'global admin gets the organizations overview' do + FactoryBot.create(:organization, name: 'Uniwersytet Testowy') + sign_in FactoryBot.create(:user, :global_admin) + + get home_url + + assert_response :success + assert_select '.gh-start-org-row', text: /Uniwersytet Testowy/ + assert_select '.gh-todo-card', text: /Brak administratora/ + end + + test 'global admin without organizations gets the empty state' do + sign_in FactoryBot.create(:user, :global_admin) + + get home_url + + assert_response :success + assert_select '.gh-empty-state', text: /pierwszej organizacji/ + end + + test 'organization admin gets their organization overview' do + organization = FactoryBot.create(:organization, max_members: 10) + FactoryBot.create(:user, :student, organization: organization, full_name: 'Jan Nowak') + sign_in FactoryBot.create(:user, :organization_admin, organization: organization) + + get home_url + + assert_response :success + assert_select '.gh-page-kicker', text: organization.name + assert_select '.gh-start-org-row', text: /Jan Nowak/ + end + + test 'organization admin without organization sees a notice' do + sign_in FactoryBot.create(:user, :organization_admin) + + get home_url + + assert_response :success + assert_select '.gh-empty-state', text: /nie jest przypisane/ + end + + test 'global admin sidebar has no story groups' do + admin = FactoryBot.create(:user, :global_admin) + FactoryBot.create(:story_group, owner: admin, name: 'Grupa admina') + sign_in admin + + get home_url + + assert_select '.gh-sidebar-link', text: 'Organizacje' + assert_select '.gh-sidebar-link', text: 'Grupy', count: 0 + assert_select '.gh-sidebar-group-list', count: 0 + end end diff --git a/test/controllers/organizations/users_controller_test.rb b/test/controllers/organizations/users_controller_test.rb new file mode 100644 index 00000000..f26d1502 --- /dev/null +++ b/test/controllers/organizations/users_controller_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Organizations::UsersControllerTest < ActionDispatch::IntegrationTest + setup do + @organization = FactoryBot.create(:organization, max_members: 10) + @admin = FactoryBot.create(:user, :organization_admin, organization: @organization) + @student = FactoryBot.create(:user, :student, organization: @organization, full_name: 'Jan Nowak') + sign_in @admin + end + + test 'org admin sees users table on organization page' do + get organization_url(@organization) + + assert_response :success + assert_select "#user_#{@student.id}" + assert_select "#user_#{@admin.id}", count: 0 + end + + test '#edit - renders form' do + get edit_organization_user_url(@organization, @student) + assert_response :success + end + + test '#update - updates user' do + patch organization_user_url(@organization, @student), + params: { user: { full_name: 'Jan Kowalski', role: 'teacher' } }, + headers: { 'Turbo-Frame' => 'modal' } + + assert_turbo_redirected_to organization_url(@organization) + @student.reload + assert_equal 'Jan Kowalski', @student.full_name + assert @student.teacher? + end + + test '#update - cannot promote user to admin' do + patch organization_user_url(@organization, @student), params: { user: { role: 'global_admin' } } + + assert_response :unprocessable_content + assert @student.reload.student? + end + + test '#confirm_destroy - renders confirmation' do + get confirm_destroy_organization_user_url(@organization, @student) + assert_response :success + end + + test '#destroy - removes user' do + assert_difference 'User.count', -1 do + delete organization_user_url(@organization, @student) + end + + assert_redirected_to organization_url(@organization) + end + + test 'cannot edit or destroy an admin' do + other_admin = FactoryBot.create(:user, :organization_admin, organization: @organization) + + assert_no_difference 'User.count' do + delete organization_user_url(@organization, other_admin) + end + assert_redirected_to root_path + + patch organization_user_url(@organization, other_admin), params: { user: { role: 'student' } } + assert other_admin.reload.organization_admin? + end + + test 'admin of another organization cannot manage users' do + other_organization = FactoryBot.create(:organization, max_members: 10) + sign_out + sign_in FactoryBot.create(:user, :organization_admin, organization: other_organization) + + assert_no_difference 'User.count' do + delete organization_user_url(@organization, @student) + end + assert_redirected_to root_path + end + + test 'cannot reach a user from another organization through this one' do + outsider = FactoryBot.create(:user, :student, organization: FactoryBot.create(:organization)) + + assert_no_difference 'User.count' do + delete organization_user_url(@organization, outsider) + end + assert_redirected_to root_path + end +end diff --git a/test/controllers/organizations_controller_test.rb b/test/controllers/organizations_controller_test.rb new file mode 100644 index 00000000..a6cbe448 --- /dev/null +++ b/test/controllers/organizations_controller_test.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'test_helper' + +class OrganizationsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = FactoryBot.create(:user, role: :global_admin) + sign_in @user + @organization = FactoryBot.create(:organization) + end + + test 'should get index' do + get organizations_url + assert_response :success + end + + test 'should get show' do + get organization_url(@organization) + assert_response :success + end + + test 'should get new' do + get new_organization_url + assert_response :success + end + + test 'should get edit' do + get edit_organization_url(@organization) + assert_response :success + end + + test '#add_admin - creates org admin and sends invitation email' do + assert_difference 'User.count', 1 do + assert_enqueued_emails 1 do + post add_admin_organization_url(@organization), + params: { user: { email: 'admin@example.com', full_name: 'Anna Kowalska' } }, + headers: { 'Turbo-Frame' => 'modal' } + end + end + + assert_turbo_redirected_to organization_url(@organization) + + admin = User.find_by(email: 'admin@example.com') + assert admin.organization_admin? + assert admin.first_login? + assert_equal @organization, admin.organization + assert admin.login_token.expires_at > 6.days.from_now + end + + test '#add_admin - does not create admin without email' do + assert_no_difference 'User.count' do + assert_no_enqueued_emails do + post add_admin_organization_url(@organization), params: { user: { email: '', full_name: 'Anna' } } + end + end + + assert_response :unprocessable_content + end + + test '#add_admin - does not create admin when organization is full' do + FactoryBot.create(:user, organization: @organization) + + assert_no_difference 'User.count' do + assert_no_enqueued_emails do + post add_admin_organization_url(@organization), params: { user: { email: 'admin@example.com' } } + end + end + + assert_response :unprocessable_content + end + + test '#add_admin - non global admin cannot add admin' do + sign_out + sign_in FactoryBot.create(:user, :organization_admin, organization: @organization) + + assert_no_difference 'User.count' do + post add_admin_organization_url(@organization), params: { user: { email: 'admin@example.com' } } + end + + assert_redirected_to root_path + end + + test '#add_user - creates user with selected role' do + assert_difference 'User.count', 1 do + post add_user_organization_url(@organization), + params: { user: { email: 'teacher@example.com', full_name: 'Jan Nowak', role: 'teacher' } } + end + + assert User.find_by(email: 'teacher@example.com').teacher? + end + + test '#add_user - does not create user with admin role' do + %w[organization_admin global_admin].each do |role| + assert_no_difference 'User.count' do + assert_no_enqueued_emails do + post add_user_organization_url(@organization), + params: { user: { email: 'evil@example.com', full_name: 'Jan Nowak', role: role } } + end + end + + assert_response :unprocessable_content + end + end + + test '#show - global admin sees admins and capacity' do + FactoryBot.create(:user, :organization_admin, organization: @organization, full_name: 'Anna Kowalska') + + get organization_url(@organization) + + assert_response :success + assert_select '.gh-organization-member-row', text: /Anna Kowalska/ + assert_select '.gh-lead', text: /1 z #{@organization.max_members} miejsc/ + end + + test '#destroy - requires the organization name to be typed' do + assert_no_difference 'Organization.count' do + delete organization_url(@organization), params: { confirm: 'wrong name' } + end + + assert_response :unprocessable_content + end + + test '#destroy - deletes organization when name matches' do + assert_difference 'Organization.count', -1 do + delete organization_url(@organization), params: { confirm: @organization.name } + end + + assert_redirected_to organizations_url + end +end diff --git a/test/controllers/teachers_controller_test.rb b/test/controllers/teachers_controller_test.rb index b0b0f92f..b9ef5f6f 100644 --- a/test/controllers/teachers_controller_test.rb +++ b/test/controllers/teachers_controller_test.rb @@ -67,17 +67,6 @@ class TeachersControllerTest < ActionDispatch::IntegrationTest assert_select '.gh-teacher-result-row button[value=?]', elsewhere.id.to_s, count: 0 end - test 'a global admin sees every university in the pool' do - elsewhere = FactoryBot.create(:user, role: :teacher, university_name: 'Somewhere else') - admin = FactoryBot.create(:user, role: :global_admin) - sign_out - sign_in admin - - get new_story_group_teacher_url(@story_group) - - assert_select '.gh-teacher-result-row button[value=?]', elsewhere.id.to_s, count: 1 - end - test 'a supporting teacher may read the list but not change it' do sign_out sign_in @teacher1 diff --git a/test/factories/organizations.rb b/test/factories/organizations.rb new file mode 100644 index 00000000..1189a519 --- /dev/null +++ b/test/factories/organizations.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :organization do + sequence(:name) { |n| "Organization #{n}" } + max_members { 1 } + end +end diff --git a/test/mailers/organization_admin_mailer_test.rb b/test/mailers/organization_admin_mailer_test.rb new file mode 100644 index 00000000..885aa03b --- /dev/null +++ b/test/mailers/organization_admin_mailer_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'test_helper' + +class OrganizationAdminMailerTest < ActionMailer::TestCase + test 'invitation_email' do + mail = OrganizationAdminMailer.with( + token_link: 'http://example.com/test', + email: 'jan.nowak@example.com', + organization_name: 'Uniwersytet', + ).invitation_email + assert_equal 'Zaproszenie do systemu GamificationHub', mail.subject + assert_equal ['jan.nowak@example.com'], mail.to + assert_equal ['from@example.com'], mail.from + assert_match 'administrator organizacji Uniwersytet', mail.html_part.body.decoded + assert_match 'http://example.com/test', mail.html_part.body.decoded + end +end diff --git a/test/mailers/previews/organization_admin_mailer_preview.rb b/test/mailers/previews/organization_admin_mailer_preview.rb new file mode 100644 index 00000000..d2a4e336 --- /dev/null +++ b/test/mailers/previews/organization_admin_mailer_preview.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Preview all emails at http://localhost:3000/rails/mailers/organization_admin_mailer +class OrganizationAdminMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/organization_admin_mailer/invitation_email + def invitation_email + OrganizationAdminMailer.with( + token_link: 'http://example.com/test', + email: 'jan.nowak@example.com', + organization_name: 'Uniwersytet', + ).invitation_email + end +end diff --git a/test/models/organization_test.rb b/test/models/organization_test.rb new file mode 100644 index 00000000..309d7386 --- /dev/null +++ b/test/models/organization_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'test_helper' + +class OrganizationTest < ActiveSupport::TestCase + test 'requires max_members' do + organization = FactoryBot.build(:organization, max_members: nil) + + assert_not organization.valid? + assert organization.errors.added?(:max_members, :blank) + end + + test 'requires positive max_members' do + organization = FactoryBot.build(:organization, max_members: 0) + + assert_not organization.valid? + end + + test 'full? when members count reaches max_members' do + organization = FactoryBot.create(:organization, max_members: 1) + assert_not organization.full? + + FactoryBot.create(:user, organization: organization) + assert organization.full? + end + + test 'cannot lower max_members below current members count' do + organization = FactoryBot.create(:organization, max_members: 2) + FactoryBot.create_list(:user, 2, organization: organization) + + organization.max_members = 1 + assert_not organization.valid? + assert_includes organization.errors[:max_members], "can't be lower than the current number of members (2)" + end + + test 'requires unique name' do + FactoryBot.create(:organization, name: 'Example university') + organization = FactoryBot.build(:organization, name: 'Example university') + + assert_not organization.valid? + assert organization.errors[:name].any? + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 1d199a89..a39e905d 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -9,4 +9,29 @@ class UserTest < ActiveSupport::TestCase assert_equal 'siema@gmail.com', user.email end + + test 'cannot join a full organization' do + organization = FactoryBot.create(:organization, max_members: 1) + FactoryBot.create(:user, organization: organization) + + user = FactoryBot.build(:user, organization: organization) + assert_not user.valid? + assert_includes user.errors[:organization], 'has reached its member limit' + end + + test 'existing member can be updated in a full organization' do + organization = FactoryBot.create(:organization, max_members: 1) + user = FactoryBot.create(:user, organization: organization) + + assert user.update(full_name: 'Nowe Imię') + end + + test 'invited user can only be a student or teacher' do + assert FactoryBot.build(:user, role: :student).valid?(:user_invitation) + assert FactoryBot.build(:user, role: :teacher).valid?(:user_invitation) + + user = FactoryBot.build(:user, role: :global_admin) + assert_not user.valid?(:user_invitation) + assert user.errors[:role].any? + end end diff --git a/test/services/global_admin_start_dashboard_test.rb b/test/services/global_admin_start_dashboard_test.rb new file mode 100644 index 00000000..ff458c43 --- /dev/null +++ b/test/services/global_admin_start_dashboard_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'test_helper' + +class GlobalAdminStartDashboardTest < ActiveSupport::TestCase + setup do + @full = FactoryBot.create(:organization, name: 'Pełna', max_members: 2) + FactoryBot.create(:user, :organization_admin, organization: @full) + FactoryBot.create(:user, :student, organization: @full) + + @orphan = FactoryBot.create(:organization, name: 'Bez admina', max_members: 10) + FactoryBot.create(:user, :student, organization: @orphan) + + @fine = FactoryBot.create(:organization, name: 'W porządku', max_members: 10) + FactoryBot.create(:user, :organization_admin, organization: @fine, first_login: true) + end + + test 'summarizes every organization' do + dashboard = GlobalAdminStartDashboard.new.load + + names = dashboard.organizations.map { |org| org.organization.name } + assert_equal ['Bez admina', 'Pełna', 'W porządku'], names + assert_equal [3, 4, 18, 1], dashboard.stat_tiles.map(&:value) + end + + test 'flags organizations without admin and nearly full ones' do + dashboard = GlobalAdminStartDashboard.new.load + + assert_equal [@orphan], dashboard.without_admin.map(&:organization) + assert_equal [@full], dashboard.nearly_full.map(&:organization) + assert dashboard.attention? + end + + test 'fill level' do + org = GlobalAdminStartDashboard.new.load.organizations.find { |o| o.organization == @full } + + assert_equal 100, org.fill_percent + assert org.full? + assert_equal 0, org.free + end +end diff --git a/test/services/organization_admin_start_dashboard_test.rb b/test/services/organization_admin_start_dashboard_test.rb new file mode 100644 index 00000000..c8ba6e79 --- /dev/null +++ b/test/services/organization_admin_start_dashboard_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'test_helper' + +class OrganizationAdminStartDashboardTest < ActiveSupport::TestCase + setup do + @organization = FactoryBot.create(:organization, max_members: 10) + @admin = FactoryBot.create(:user, :organization_admin, organization: @organization) + @student = FactoryBot.create(:user, :student, organization: @organization) + @invited = FactoryBot.create(:user, :teacher, organization: @organization, first_login: true) + end + + test 'counts members by role and free seats' do + dashboard = OrganizationAdminStartDashboard.new(user: @admin).load + + assert_equal [1, 1, 7, 1], dashboard.stat_tiles.map(&:value) + assert_equal 30, dashboard.fill_percent + assert_not dashboard.full? + end + + test 'lists recent and pending users without admins' do + dashboard = OrganizationAdminStartDashboard.new(user: @admin).load + + assert_equal [@invited, @student], dashboard.recent_users + assert_equal [@invited], dashboard.pending_users + end + + test 'handles an admin without organization' do + admin = FactoryBot.create(:user, :organization_admin) + + assert_nil OrganizationAdminStartDashboard.new(user: admin).load.organization + end +end diff --git a/test/services/session_user_builder_test.rb b/test/services/session_user_builder_test.rb index e6b80b1c..674819cb 100644 --- a/test/services/session_user_builder_test.rb +++ b/test/services/session_user_builder_test.rb @@ -30,10 +30,34 @@ def build_params end test 'create new user if no user in database' do + organization = FactoryBot.create(:organization, name: 'Example university') + assert_difference 'User.count', 1 do user = SessionUserBuilder.new(@provider).build assert_equal 'jan.nowak@gmail.com', user.email + assert_equal organization, user.organization + end + end + + test 'do not create new user if no organization matches university name' do + FactoryBot.create(:organization, name: 'Other university') + + assert_no_difference 'User.count' do + assert_raises SessionUserBuilder::OrganizationNotFoundError do + SessionUserBuilder.new(@provider).build + end + end + end + + test 'do not create new user if organization is full' do + organization = FactoryBot.create(:organization, name: 'Example university', max_members: 1) + FactoryBot.create(:user, organization: organization) + + assert_no_difference 'User.count' do + assert_raises SessionUserBuilder::OrganizationFullError do + SessionUserBuilder.new(@provider).build + end end end diff --git a/test/values/navigation_test.rb b/test/values/navigation_test.rb new file mode 100644 index 00000000..9ff4c385 --- /dev/null +++ b/test/values/navigation_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'test_helper' + +class NavigationTest < ActiveSupport::TestCase + test 'global admin gets the organizations list' do + user = FactoryBot.create(:user, :global_admin) + + assert_equal %w[Start Organizacje], Navigation.new(user).primary_items.map(&:label) + assert_equal '/organizations', Navigation.new(user).primary_items.last.path + end + + test 'organization admin gets their own organization' do + organization = FactoryBot.create(:organization, max_members: 5) + user = FactoryBot.create(:user, :organization_admin, organization: organization) + + item = Navigation.new(user).primary_items.last + assert_equal organization.name, item.label + assert_equal "/organizations/#{organization.id}", item.path + end + + test 'other users get no organization item' do + user = FactoryBot.create(:user, :student) + + assert_equal %w[Start Grupy], Navigation.new(user).primary_items.map(&:label) + end +end