Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/controllers/search_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ def search

return if @query.blank?

@users = User.accessible_by(current_ability).where(
'firstname ILIKE :search OR lastname ILIKE :search OR email ILIKE :search OR room ILIKE :search',
@users = User.accessible_by(current_ability).left_joins(:room).where(
'firstname ILIKE :search OR lastname ILIKE :search OR email ILIKE :search OR rooms.number ILIKE :search',
search: "%#{User.sanitize_sql_like @query}%"
)
@machines = Machine.accessible_by(current_ability).where(
Expand Down
1 change: 0 additions & 1 deletion app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ def create_developer
u.firstname = auth_hash[:first_name]
u.lastname = auth_hash[:last_name]
u.username = auth_hash[:username]
u.room = auth_hash[:room]
end
user.groups = auth_hash[:groups].split(',')
log_in user
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,6 @@ def destroy
private

def user_params
params.require(:user).permit(:firstname, :lastname, :email, :room, :username)
params.require(:user).permit(:firstname, :lastname, :email, :username, :room_number)
end
end
14 changes: 14 additions & 0 deletions app/jobs/sync_room_to_sso_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

class SyncRoomToSsoJob < ApplicationJob
queue_as :default

retry_on Net::OpenTimeout, Net::ReadTimeout, wait: 30.seconds, attempts: 5

def perform(user_id)
user = User.find_by(id: user_id)
return if user.nil?

SsoMetadataService.new.sync_room(user)
end
end
26 changes: 26 additions & 0 deletions app/models/room.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

class Room < ApplicationRecord
belongs_to :user, optional: true, inverse_of: :room

validates :number, presence: true, uniqueness: true, length: { maximum: 6 },
format: { with: /\A[A-Z0-9]+\z/, message: 'must be uppercase alphanumeric' }
# A room group represents the natural grouping of rooms. It can be the room number itself or a shared identifier
validates :group, presence: true, length: { maximum: 6 },
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a comment/example to understand what a "group" is?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before I write it in the code, what do you think about:

  • A room group represents the natural grouping of rooms. It can be the room number itself or a shared identifier

format: { with: /\A[A-Z0-9]+\z/, message: 'must be uppercase alphanumeric' }
validates :building, presence: true, inclusion: { in: ('A'..'F').to_a }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "Foyer" in the F building? Accueil or Halle projet rooms in A?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered by the seed

validates :floor, presence: true, inclusion: { in: 0..3 }
validates :user_id, uniqueness: true, allow_nil: true

after_commit :enqueue_room_sync_to_sso, on: [:create, :update], if: :saved_change_to_user_id?

# Returns rooms available for assignment: unoccupied rooms + the room already assigned to the given user
scope :available_for, ->(user) { where(user_id: [nil, user.id]) }

private

def enqueue_room_sync_to_sso
SyncRoomToSsoJob.perform_later(user_id) if user_id.present?
SyncRoomToSsoJob.perform_later(user_id_before_last_save) if user_id_before_last_save.present?
end
end
29 changes: 24 additions & 5 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

class User < ApplicationRecord
has_one :room, dependent: :nullify, inverse_of: :user
has_many :machines, dependent: :destroy
has_many :free_accesses, dependent: :destroy
has_many :free_accesses_by_date, lambda {
Expand All @@ -15,7 +16,6 @@ class User < ApplicationRecord
}, through: :sales_as_client, dependent: :destroy, class_name: 'Subscription', source: :subscription

normalizes :email, with: ->(email) { email.strip.downcase }
normalizes :room, with: ->(room) { room&.downcase&.upcase_first }

# Since the Radius MD4 hash is broken anyway (see: https://kanidm.github.io/kanidm/master/integrations/radius.html#cleartext-credential-storage)
# we choose to store the wifi_password encrypted using Rails built-in encryption.
Expand All @@ -27,14 +27,14 @@ class User < ApplicationRecord
validates :lastname, presence: true, allow_blank: false
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/
validates :email, presence: true, format: { with: VALID_EMAIL_REGEX }, uniqueness: true
# TODO: Make room regex case-sensitive once we fix support for 'DF1' with uppercase
VALID_ROOM_REGEX = /\A([A-F][0-3][0-9]{2}[a-b]?|DF[1-4])\z/i
validates :room, format: { with: VALID_ROOM_REGEX }, uniqueness: true, allow_nil: true
validates :wifi_password, presence: true, allow_blank: false
validates :username, presence: true, uniqueness: true, allow_blank: false
validate :room_number_must_exist

before_validation :ensure_has_wifi_password

before_save :assign_room_from_number

# @return [Array<String>]
attr_accessor :groups

Expand All @@ -48,7 +48,7 @@ def display_name
end

def display_address
address = room.present? ? "Appartement #{room}\n" : ''
address = room.present? ? "Appartement #{room.number}\n" : ''
"#{address}Résidence Léonard de Vinci\nAvenue Paul Langevin\n59650 Villeneuve-d'Ascq"
end

Expand Down Expand Up @@ -97,6 +97,14 @@ def update_from_sso(firstname:, lastname:, email:, username:)
update(firstname: firstname, lastname: lastname, email: email, username: username)
end

def room_number
@room_number || room&.number
end

def room_number=(value)
@room_number = value&.upcase&.presence
end

def admin?
return false if groups.nil?

Expand All @@ -105,6 +113,17 @@ def admin?

private

def room_number_must_exist
return if @room_number.blank?
return if Room.exists?(number: @room_number)

errors.add(:room_number, 'does not exist')
end

def assign_room_from_number
self.room = @room_number.blank? ? nil : Room.find_by(number: @room_number)
end

def subscription_expired?
subscription_expiration.nil? || (subscription_expiration < Time.current)
end
Expand Down
101 changes: 101 additions & 0 deletions app/services/sso_metadata_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# frozen_string_literal: true

class SsoMetadataService
SSO_BASE_URL = 'https://sso.rezoleo.fr'
HTTP_OPEN_TIMEOUT_SECONDS = 5
HTTP_READ_TIMEOUT_SECONDS = 10

# @param user [User]
def sync_room(user)
return if user.oidc_id.blank?

room_number = user.room&.number

unless production?
Rails.logger.info("[SSO] Dry-run: would sync room '#{room_number}' for user #{user.oidc_id}")
return
end

push_room_metadata(user, room_number)
end

private

def production?
Rails.env.production?
end

def push_room_metadata(user, room_number)
if room_number.present?
post_room_metadata(user, room_number)
else
delete_room_metadata(user)
end
end

# Metadata values in Zitadel must be base64-encoded.
# See https://zitadel.com/docs/reference/api/user/zitadel.user.v2.UserService.SetUserMetadata
def post_room_metadata(user, room_number)
uri = URI("#{SSO_BASE_URL}/v2/users/#{user.oidc_id}/metadata")
body = { metadata: [{ key: 'room', value: Base64.strict_encode64(room_number) }] }

req = build_request(Net::HTTP::Post, uri, body:)
res = execute_request(user:, req:)

return if res.is_a?(Net::HTTPSuccess)

log_failure(user, uri, req, res)
end

def delete_room_metadata(user)
uri = URI("#{SSO_BASE_URL}/v2/users/#{user.oidc_id}/metadata")
uri.query = URI.encode_www_form([['keys', 'room']])

req = build_request(Net::HTTP::Delete, uri)
res = execute_request(user:, req:)

return if res.is_a?(Net::HTTPSuccess) || res.is_a?(Net::HTTPNotFound) # NotFound => No existing metadata to delete

log_failure(user, uri, req, res)
end

def build_request(http_method, uri, body: nil)
req = http_method.new(uri)
req['Authorization'] = "Bearer #{access_token}"

return req if body.nil?

req.content_type = 'application/json'
req.body = body.to_json
req
end

def execute_request(user:, req:)
Net::HTTP.start(
req.uri.hostname,
req.uri.port,
use_ssl: true,
open_timeout: HTTP_OPEN_TIMEOUT_SECONDS,
read_timeout: HTTP_READ_TIMEOUT_SECONDS
) do |http|
http.request(req)
end
rescue Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error("[SSO] Timeout for user #{user.oidc_id}: #{e.message}")
raise
rescue StandardError => e
Rails.logger.error("[SSO] Error syncing room for user #{user.oidc_id}: #{e.message}")
raise
end

def log_failure(user, uri, req, res)
Rails.logger.error(
"[SSO] Failed to sync room for user #{user.oidc_id} " \
"(#{req.method} #{uri}): #{res.code} #{res.body}"
)
end

def access_token
Rails.application.credentials.sso_lea5_pat!
end
end
3 changes: 2 additions & 1 deletion app/views/api/users/_user.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# locals: (json:, user:)

json.extract! user, :id, :firstname, :lastname, :username, :email, :room, :created_at, :updated_at
json.extract! user, :id, :firstname, :lastname, :username, :email, :created_at, :updated_at
json.room user.room&.number
json.url api_user_url(user)
json.internet_expiration user.internet_expiration
2 changes: 1 addition & 1 deletion app/views/search/_user.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<li>
<%= link_to user, class: "user" do %>
<span><%= user.firstname %> <%= user.lastname %></span>
<span><%= user.room %></span>
<span><%= user.room&.number %></span>
<% if user.internet_expiration.nil? || user.internet_expiration < Time.current %>
<span style="color:var(--red-900)">No Internet</span>
<% else %>
Expand Down
9 changes: 7 additions & 2 deletions app/views/users/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@
</div>
<div>
<%= f.label :room %>
<%= f.text_field :room, placeholder: 'A123b or DF1', minlength: 3, maxlength: 5,
pattern: '([A-Fa-f][0-3][0-9]{2}[a-b]?|[Dd][Ff][1-4])' %>
<%= f.text_field :room_number, list: 'rooms-datalist', autocomplete: 'off',
placeholder: 'ex : A105A', value: f.object.room&.number %>
<datalist id="rooms-datalist">
<% Room.available_for(f.object).order(:number).each do |room| %>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This query should be hoisted to the controller and passed as a local variable.

<option value="<%= room.number %>"></option>
<% end %>
</datalist>
</div>
<div>
<%= f.submit yield(:button_text) %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/users/_user.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<li>
<%= link_to user, class: "user" do %>
<span><%= user.firstname %> <%= user.lastname %></span>
<span><%= user.room %></span>
<span><%= user.room&.number %></span>
<% if user.internet_expiration.nil? || user.internet_expiration < Time.current %>
<span style="color:var(--red-900)">No Internet</span>
<% else %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<% else %>
<span>No Internet</span>
<% end %>
<span><%= @user.room %></span>
<span><%= @user.room&.number %></span>
<span><%= pluralize(@machines.size, "machine") %></span>
<% if current_user == @user %>
<div class="card-content-user-details" data-controller="user" data-user-password-value="<%= @user.wifi_password %>">
Expand Down
31 changes: 31 additions & 0 deletions db/migrate/20260307123053_create_rooms.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

class CreateRooms < ActiveRecord::Migration[7.2]
def change
create_table :rooms do |t|
t.string :number, limit: 6, null: false
t.string :group, limit: 6, null: false
Comment on lines +6 to +7
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 6 characters limit is arbitrary? (as arbitrary as the regex before)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, to fit the longest number I thought of : ALUMNI 😆

t.string :building, limit: 1, null: false
t.integer :floor, null: false
t.references :user, foreign_key: true, index: { unique: true, where: 'user_id IS NOT NULL' }

t.timestamps
end
add_index :rooms, :number, unique: true
add_index :rooms, :group
add_index :rooms, [:building, :floor]

# Migrate existing user.room data to rooms.user_id
reversible do |dir|
dir.up do
execute <<~SQL.squish
UPDATE rooms SET user_id = users.id FROM users WHERE users.room = rooms.number
SQL
end
end

# Remove the old room column from users
remove_index :users, :room, name: 'index_users_on_room'
remove_column :users, :room, :string
end
end
19 changes: 15 additions & 4 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading