Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Staff applications #29

Merged
merged 4 commits into from
Nov 12, 2024
Merged
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
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ gem 'inline_svg'
gem 'local_time'
gem 'erb-formatter'


# Testing
gem 'capybara'

Expand Down
1 change: 1 addition & 0 deletions app/assets/config/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
//= link_tree ../../javascript .js
//= link_tree ../../../vendor/javascript .js
//= link_tree ../builds
//= link auth.css
6 changes: 3 additions & 3 deletions app/controllers/admin/staff_applications_controller.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
# frozen_string_literal: true
require 'myetm/staff_applications'

module Admin
# Updates a staff application with a new URI.
class StaffApplicationsController < ApplicationController
include AdminController

# Shows all staff applications that have been setup
def index

@staff_applications = MyEtm::StaffApplications.all
end


def update
result = CreateStaffApplication.call(
current_user,
MyEtm::StaffApplications.find(params[:id]),
MyEtm::StaffApplications.find(params[:format]),
uri: params[:uri].presence
)

Expand Down
9 changes: 0 additions & 9 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,5 @@ def render_not_found(thing = nil)
)

true

# Returns the Faraday client which should be used to communicate with ETEngine. This contains the
# user authentication token if the user is logged in.
# def engine_client
# if current_user
# identity_session.access_token.http_client
# else
# Identity.http_client
# end
end
end
46 changes: 46 additions & 0 deletions app/controllers/identity/token_exchange_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# app/controllers/token_exchange_controller.rb
module Identity
class TokenExchangeController < ApplicationController
before_action :validate_bearer_token

def create
user = User.find_by(id: @user_id_from_bearer_token)
if user
jwt_token = MyEtm::Auth.user_jwt(user, scopes: extract_scopes_from_request, client_uri: client_uri)
render json: { jwt: jwt_token }, status: :ok
else
render json: { error: 'Invalid user' }, status: :unauthorized
end
end

private

# Decode and validate the Bearer token
def validate_bearer_token
bearer_token = request.headers['Authorization']&.split(' ')&.last
return render json: { error: 'Bearer token missing' }, status: :unauthorized unless bearer_token

begin
decoded_token = decode_bearer_token(bearer_token)
@user_id_from_bearer_token = decoded_token[:sub] # Assuming `sub` holds the user ID
rescue JWT::DecodeError => e
render json: { error: 'Invalid bearer token' }, status: :unauthorized
end
end

# Decode the Bearer token issued by the IdP
def decode_bearer_token(bearer_token)
key = MyEtm::Auth.signing_key
decoded_token, _header = JWT.decode(bearer_token, key, true, { algorithm: 'RS256' })
decoded_token.symbolize_keys
end

def extract_scopes_from_request
params[:scopes] || []
end

def client_uri
request.headers['Client-Uri']
end
end
end
12 changes: 12 additions & 0 deletions app/controllers/users/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module Users
class RegistrationsController < Devise::RegistrationsController
before_action :configure_sign_up_params, only: [:create]
before_action :configure_account_update_params, only: [:update]
skip_before_action :require_no_authentication, only: [:new]
before_action :check_already_authenticated, only: [:new]

def confirm_destroy
@counts = stats_for_destroy
Expand Down Expand Up @@ -52,6 +54,16 @@ def configure_account_update_params
devise_parameter_sanitizer.permit(:account_update, keys: [:name])
end

# Check if the user is already signed in and redirect back to client or to root.
def check_already_authenticated
if user_signed_in?
token = MyEtm::Auth.user_jwt(current_user, client_uri: params[:redirect_to])
redirect_url = URI(params[:redirect_to] || root_path)
redirect_url.query = URI.encode_www_form(token: token)
redirect_to redirect_url.to_s
end
end

# Fetches information about what entities will be deleted with the account.
def stats_for_destroy
{
Expand Down
26 changes: 20 additions & 6 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,24 @@ def identity_back_to_etm_url
session[:back_to_etm_url] || Settings.etmodel_uri || 'https://energytransitionmodel.com'
end

# Like simple_format, except without inserting breaks on newlines.
def format_paragraphs(text)
# rubocop:disable Rails/OutputSafety
text.split("\n\n").map { |content| content_tag(:p, sanitize(content)) }.join.html_safe
# rubocop:enable Rails/OutputSafety
end
# Like simple_format, except without inserting breaks on newlines.
def format_paragraphs(text)
# rubocop:disable Rails/OutputSafety
text.split("\n\n").map { |content| content_tag(:p, sanitize(content)) }.join.html_safe
# rubocop:enable Rails/OutputSafety
end

def format_staff_config(config, app)

format(config, app.attributes.symbolize_keys.merge(
myetm_url: root_url.chomp('/root'),
etengine_url: Settings.etengine.uri || 'http://YOUR_ETENGINE_URL',
etmodel_url: Settings.etmodel.uri || 'http://YOUR_ETMODEL_URL',
collections_url: Settings.collections.uri || 'http://YOUR_COLLECTIONS_URL'
))
end

def format_staff_run_command(command, app)
format(command, port: app.uri ? URI.parse(app.uri).port : nil)
end
end
10 changes: 10 additions & 0 deletions app/javascript/controllers/toggle_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
static targets = ["content"]

show(event) {
this.contentTarget.classList.remove("hidden")
event.currentTarget.classList.add("hidden")
}
}
6 changes: 6 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
class User < ApplicationRecord
ROLES = {
1 => :scenario_viewer,
2 => :scenario_collaborator,
3 => :scenario_owner
}.freeze

# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable, :registerable
devise :database_authenticatable, :registerable,
Expand Down
4 changes: 4 additions & 0 deletions app/views/admin/staff_applications/_intro.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
%div.intro-container.bg-blue-50.border-l-4.border-blue-400.p-4.rounded-lg.mb-6.max-w-2xl

%p.text-sm.text-gray-700.mb-2
To set up your local environment, you will need to create and configure local applications.
70 changes: 70 additions & 0 deletions app/views/admin/staff_applications/_staff_application.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
- user_app = current_user.staff_applications.find_by(name: staff_application.key)

%div.flex.flex-col.mb-2
%div.staff-application.bg-white.shadow-md.rounded-lg.p-6.mb-6.border.border-gray-200.w-full.max-w-2xl{ id: "staff_application_#{staff_application.key}" }
- if user_app
- oauth_app = user_app.application

%h4.text-xl.font-semibold.mb-4
= link_to(staff_application.name, oauth_app.uri)

%p.text-sm.text-gray-600
%strong Hosted at:
= oauth_app.uri

%p.text-sm.text-gray-600.mt-4
%strong Run Command:
%div.flex.items-center.mt-1{ data: { controller: 'clipboard' } }
%pre.bg-gray-50.text-xs.rounded.p-2.overflow-auto.flex-1{ data: { clipboard_target: 'source' } }
= format_staff_run_command(staff_application.run_command, oauth_app)
%button.bg-blue-600.text-white.py-1.px-2.rounded.text-xs.ml-2{ data: { action: 'clipboard#copy', clipboard_target: 'button' }, aria: { label: 'Copy Run Command' } }
Copy
%span.text-green-500.text-xs.ml-2.hidden{ data: { clipboard_target: 'notice' } }
Copied!

- if staff_application.config_prologue || staff_application.config_content || staff_application.config_epilogue
%div.config-content.mt-6{ data: { controller: 'toggle' } }
.blocker.bg-gray-100.p-4.rounded.cursor-pointer.flex.items-center{ data: { action: 'click->toggle#show' } }
= inline_svg 'hero/20/pointer-click.svg', class: 'w-5 h-5 text-blue-500 mr-2'
%span.text-blue-500.text-sm Click to view config

%div.hidden.mt-4{ data: { toggle_target: 'content' } }
- if staff_application.config_prologue
%pre.text-xs.text-gray-600.mt-2
= format_staff_config(staff_application.config_prologue, oauth_app)

%div{ data: { controller: 'clipboard' } }
%pre.bg-gray-100.text-xs.p-2.rounded.mt-2{ data: { clipboard_target: 'source' } }
= format_staff_config(staff_application.config_content, oauth_app)

%div.flex.items-center.mt-2
%button.bg-blue-600.text-white.py-1.px-2.rounded.text-xs{ data: { action: 'clipboard#copy', clipboard_target: 'button' }, aria: { label: 'Copy Config' } }
Copy Config
%span.text-green-500.text-xs.ml-2.hidden{ data: { clipboard_target: 'notice' } }
Copied!

.update-form.mt-6
= form_tag admin_applications_path(staff_application.key), method: :put do
%div.flex.items-center.gap-4
= label_tag "staff_application_#{staff_application.key}_uri", 'Hosted at:', class: 'text-sm font-medium'
= text_field_tag :uri, oauth_app.uri, class: 'border border-gray-300 rounded px-2 py-1 w-half flex-1', id: "staff_application_#{staff_application.key}_uri"
%button.bg-gray-100.p-2.px-5.mr-5.ml-auto.rounded-md.text-sm.font-medium{ type: 'submit' }
Change

%p.text-xs.text-gray-600.mt-2
If you run the application at a different address, you must set the correct address here for authentication to work correctly. If you change the address, you must also update the config file. Remember to restart #{staff_application.name.chomp(' (Local)')} after changing the config!

- else
%h4.text-xl.font-semibold.mb-4
= staff_application.name

%p.text-sm.text-gray-600
This application is not configured. Set the location where you run the app. You can change this later.

.update-form.mt-6
= form_tag admin_applications_path(staff_application.key), method: :put do
%div.flex.items-center.gap-2
= label_tag "staff_application_#{staff_application.key}_uri", 'Hosted at:', class: 'text-sm font-medium'
= text_field_tag :uri, staff_application.uri, class: 'border border-gray-300 rounded px-2 py-1 w-full flex-1', id: "staff_application_#{staff_application.key}_uri"
%button.bg-blue-600.text-white.py-1.px-4.rounded.text-sm.font-medium{ type: 'submit' }
Create Application
4 changes: 0 additions & 4 deletions app/views/admin/staff_applications/index.erb

This file was deleted.

5 changes: 5 additions & 0 deletions app/views/admin/staff_applications/index.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- content_for :menu_title, 'Your Applications'
= render partial: '/admin/block_right_menu'

= render partial: 'intro'
= render partial: 'staff_application', collection: @staff_applications
1 change: 0 additions & 1 deletion config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,3 @@ en:
latest: |
This scenario was created in the live version of the ETM
which includes all the latest monthly updates. Learn more..

2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
get 'newsletter', to: 'newsletter#edit', as: :edit_newsletter
post 'newsletter', to: 'newsletter#update'

post 'token_exchange', to: 'token_exchange#create'

resources :tokens, only: [:index, :new, :create, :destroy], as: :tokens
resources :authorized_applications, only: [:index], as: :authorized_applications
end
Expand Down
23 changes: 8 additions & 15 deletions config/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,16 @@ etsource_lazy_load_dataset: true
# http://beta.et-model.com/pages/refresh_gqueries
client_refresh_url:

# TODO: FIx this config
# Optional scheme and hostname for ETEngine which will be used to send requests to ETModel.
etmodel_uri: localhost:3001

clients:
etmodel:
id:
secret:
uri: localhost:3001
engine:
id:
secret:
uri: localhost:3000

# TODO: Fix this to fetch from ENV
etmodel:
uri: http://localhost:3001
etengine:
uri: http://localhost:3000
collections:
uri: http://localhost:3005
myetm:
iss: my-etm
uri: localhost:3002
uri: http://localhost:3002


# set to true if the server won't have online access. this disables Sentry,
Expand Down
4 changes: 2 additions & 2 deletions lib/myetm/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ def signing_key
end

# Creates a new JWT for the given user, authorizing requests to the provided client.
def user_jwt(user, scopes: [])
def user_jwt(user, scopes: [], client_uri: nil)

payload = {
iss: Doorkeeper::OpenidConnect.configuration.issuer.call(user, nil),
aud: client_uri,
exp: 1.minute.from_now.to_i,
iat: Time.now.to_i,
scopes: scopes,
scopes: scopes, # TODO: Grab scopes from the user based on their roles
sub: user.id,
user: user.as_json(only: %i[id name])
}
Expand Down
Loading
Loading