diff --git a/.env.example b/.env.example index 2e66fc3..6d510c6 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,12 @@ CANTALOUPE_API_USERNAME=admin # API password for the Cantaloupe instance CANTALOUPE_API_PASSWORD=mySecret +# Clerk config +VITE_CLERK_SIGN_IN_URL=https://staging.performant.studio/sso +VITE_CLERK_PUBLISHABLE_KEY=my_key +CLERK_SECRET_KEY=my_secret_key +VITE_CLERK_SIGN_IN_FORCE_REDIRECT_URL=http://localhost:6780/dashboard + # DB Host name. In docker-compose setup this should be db. # Otherwise it should be the host of the DB (i.e. localhost, NOT http://localhost) DATABASE_HOST=db @@ -100,6 +106,9 @@ SECRET_KEY_BASE=ac2f3e6030bbd6f234267dc400996a1ec109e50b6317b8de34cc76cd9136dd1e # Recommended: 5 SIDEKIQ_CONCURRENCY=5 +# Vite environment +VITE_ENVIRONMENT=development + # Port to use for the Vite dev server. VITE_PORT=6780 diff --git a/Gemfile b/Gemfile index 2a35666..e7e9d03 100644 --- a/Gemfile +++ b/Gemfile @@ -27,12 +27,6 @@ gem 'resource_api', git: 'https://github.com/performant-software/resource-api.gi # Use Json Web Token (JWT) for token based authentication gem 'jwt', '~> 3.1.2' -# JWT authentication -gem 'jwt_auth', git: 'https://github.com/performant-software/jwt-auth.git', tag: 'v0.1.3' - -# Use ActiveModel has_secure_password -gem 'bcrypt', '~> 3.1.20' - # Active storage service gem 'aws-sdk-s3', '~> 1.193' @@ -51,6 +45,9 @@ gem 'data_migrate', '~> 11.3' # Background jobs gem 'sidekiq', '~> 8.0.5', group: :production +# Clerk +gem 'clerk-sdk-ruby', '>= 7.0' + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem 'debug', platforms: %i[ mri mingw x64_mingw ] diff --git a/Gemfile.lock b/Gemfile.lock index 2825dc0..159b19b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,12 +1,3 @@ -GIT - remote: https://github.com/performant-software/jwt-auth.git - revision: 993b47f395d7349c499a0ffda6ef286871ef89dc - tag: v0.1.3 - specs: - jwt_auth (0.1.0) - jwt (~> 3.1.2) - rails (>= 7.1, < 9) - GIT remote: https://github.com/performant-software/resource-api.git revision: ab5afbdfedfd4e89309d748b46167652562a22bf @@ -119,13 +110,19 @@ GEM aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) - bcrypt (3.1.20) benchmark (0.4.1) bigdecimal (3.2.2) bootsnap (1.18.4) msgpack (~> 1.2) builder (3.3.0) - concurrent-ruby (1.3.4) + clerk-sdk-ruby (7.0.0) + base64 (>= 0.2.0, < 1.0) + concurrent-ruby (~> 1.3.5) + faraday (>= 2.14.1) + faraday-multipart (~> 1.2.0) + faraday-retry (~> 2.4.0) + jwt (~> 3.0) + concurrent-ruby (1.3.7) connection_pool (2.5.3) crass (1.0.6) csv (3.3.5) @@ -142,6 +139,16 @@ GEM railties (>= 6.1) drb (2.2.3) erubi (1.13.1) + faraday (2.14.3) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) globalid (1.2.1) activesupport (>= 6.1) httparty (0.23.1) @@ -176,6 +183,9 @@ GEM msgpack (1.7.5) multi_xml (0.7.2) bigdecimal (~> 3.1) + multipart-post (2.4.1) + net-http (0.9.1) + uri (>= 0.11.1) net-imap (0.5.9) date net-protocol @@ -278,14 +288,13 @@ PLATFORMS DEPENDENCIES aws-sdk-s3 (~> 1.193) - bcrypt (~> 3.1.20) bootsnap + clerk-sdk-ruby (>= 7.0) data_migrate (~> 11.3) debug dotenv-rails httparty (~> 0.23.1) jwt (~> 3.1.2) - jwt_auth! mini_magick (~> 5.3.0) pg (~> 1.5.9) puma (~> 6.6.0) diff --git a/README.md b/README.md index b97226e..246c4af 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,34 @@ -# rails-react-template +# FairImage -This README would normally document whatever steps are necessary to get the -application up and running. +## Setup -## Requirements -- Ruby -- NodeJS - - Yarn -- Heroku CLI (optional) +1. Run `bundle install` to install the Rails dependencies. +2. `cd` into `client` and run `yarn` to install the client dependencies. -Things you may want to cover: +To start FairImage, run `bundle exec rake start`. -* Ruby version +## Local Development with Clerk -* System dependencies +To run FairImage locally, you'll need to set up Clerk. Follow these steps: -* Configuration +1. Create a Clerk account at [https://clerk.com](https://clerk.com). +2. Create a new application in Clerk. +3. Make sure organizations are enabled +4. Obtain your Clerk API keys under Instance -> Configure -> API Keys. +5. Plug them into your `.env` file as `VITE_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY`. +6. Obtain your Clerk sign in URL under Configure -> Developers -> Paths -> Component paths -> `` +7. Plug it into your `.env` file as `VITE_CLERK_SIGN_IN_URL`. -* Database creation +You won't be able to sign in until the `sso_id` value is set on the FairImage user record you want to use. -* Database initialization +Assuming you've got your FairImage database set up with users and orgs (either by `heroku pg:pull`ing from staging or creating some records yourself in the console), take these steps: -* How to run the test suite +For each user you want to work with (you'll probably want one admin and one non-admin): +1. Create a corresponding account in the Users tab in Clerk. +2. Copy the User ID value from Clerk and plug it into the FairImage user's `sso_id` field. +3. If you want the account to be an admin, include `is_global_admin: true` in the private metadata. -* Services (job queues, cache servers, search engines, etc.) - -* Deployment instructions - -* ... +For each organization: +1. Create a corresponding organization in the Organizations tab in Clerk. +2. Copy the Organization ID value from Clerk and plug it into the FairImage organization's `sso_id` field. +3. Add whichever users you want under the Members tab on the organization's Clerk page. (Note: roles are irrelevant to FairImage, but you may want to set them to reasonable values if you're planning to use this Clerk org for other apps.) diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 4f618c1..f9c666a 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -1,11 +1,61 @@ +require 'clerk' + class Api::BaseController < Api::ResourceController - # Includes - include JwtAuth::Authenticateable + protected - # Actions before_action :authenticate_request - protected + attr_reader :current_user + + def authenticate_request + token = request.cookies["__session"] + return deny_access unless token.present? + + clerk_session = clerk_client.verify_token(token) + clerk_id = clerk_session["sub"] + + @current_user = User.find_by(sso_id: clerk_id) + + # If the user exists in Clerk but not in FairImage, + # create a local account for them automatically. + if @current_user.nil? + clerk_user = get_clerk_data(clerk_id) + @current_user = create_user_from_clerk(clerk_user) + end + + return deny_access unless @current_user + + @current_user + end + + def clerk_client + @clerk_client ||= Clerk::SDK.new(secret_key: ENV.fetch("CLERK_SECRET_KEY")) + end + + def create_user_from_clerk(clerk_user) + user = User.new( + sso_id: clerk_user.id, + email: clerk_user.email_addresses.first.email_address, + name: [clerk_user.first_name, clerk_user.last_name].join(" "), + role: 'member', + ) + + user.save! + + user + end + + def get_clerk_data(clerk_id) + clerk_client.users.get(user_id: clerk_id).user + end + + def get_clerk_organization_memberships(clerk_id) + clerk_client.users.get_organization_memberships(user_id: clerk_id, limit: 500).organization_memberships.data + end + + def get_clerk_role(clerk_user) + clerk_user.public_metadata["role"] + end def check_authorization(organization_id) deny_access unless current_user.has_access?(organization_id) diff --git a/app/controllers/api/users_controller.rb b/app/controllers/api/users_controller.rb index aa69804..1d7b5e9 100644 --- a/app/controllers/api/users_controller.rb +++ b/app/controllers/api/users_controller.rb @@ -3,13 +3,27 @@ class Api::UsersController < Api::BaseController search_attributes :name, :email # Preloads - preloads User.attachment_preloads preloads user_organizations: :organization, only: :show # Actions before_action :validate_new_user, unless: -> { current_user.admin? }, only: :create before_action :validate_user, unless: -> { current_user.admin? }, only: [:update, :destroy] + def me + return unless current_user + + puts "Attempting to authenticate user #{current_user.id}" + + clerk_user = get_clerk_data(current_user.sso_id) + + update_user_from_sso(current_user, clerk_user) + sync_organizations_from_sso(current_user) + + serializer = UsersSerializer.new + + render json: serializer.render_show(current_user), status: :ok + end + protected def base_query @@ -26,6 +40,30 @@ def base_query private + def update_user_from_sso(local_user, sso_user) + local_user.assign_attributes( + admin: sso_user.private_metadata['is_global_admin'] == true, + avatar_url: sso_user.profile_image_url + ) + + local_user.save! + end + + def sync_organizations_from_sso(user) + memberships = get_clerk_organization_memberships(user.sso_id) + clerk_organization_ids = memberships.map { |membership| membership.organization.id } + + local_organization_ids = Organization.where(sso_id: clerk_organization_ids).pluck(:id) + current_organization_ids = user.user_organizations.pluck(:organization_id) + + (local_organization_ids - current_organization_ids).each do |organization_id| + user.user_organizations.create!(organization_id: organization_id) + end + + removed_organization_ids = current_organization_ids - local_organization_ids + user.user_organizations.where(organization_id: removed_organization_ids).destroy_all if removed_organization_ids.any? + end + def validate_new_user organization_ids = params[:user][:user_organizations].map{ |uo| uo[:organization_id] } check_authorization organization_ids diff --git a/app/middleware/sidekiq_web_authentication.rb b/app/middleware/sidekiq_web_authentication.rb new file mode 100644 index 0000000..e0e14ab --- /dev/null +++ b/app/middleware/sidekiq_web_authentication.rb @@ -0,0 +1,35 @@ +require 'clerk' + +# Restricts the Sidekiq Web UI to admins authenticated via Clerk. +class SidekiqWebAuthentication + def initialize(app) + @app = app + end + + def call(env) + token = Rack::Request.new(env).cookies['__session'] + return deny_access unless token.present? + + clerk_id = begin + clerk_client.verify_token(token)['sub'] + rescue JWT::DecodeError, Clerk::Error + nil + end + return deny_access unless clerk_id + + user = User.find_by(sso_id: clerk_id) + return deny_access unless user&.admin? + + @app.call(env) + end + + private + + def clerk_client + @clerk_client ||= Clerk::SDK.new(secret_key: ENV.fetch('CLERK_SECRET_KEY')) + end + + def deny_access + [403, { 'Content-Type' => 'text/plain' }, ['Forbidden']] + end +end diff --git a/app/models/organization.rb b/app/models/organization.rb index 662f7ec..b23d3b8 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -2,9 +2,6 @@ class Organization < ApplicationRecord # Relationships has_many :user_organizations, dependent: :destroy - # Nested attributes - accepts_nested_attributes_for :user_organizations, allow_destroy: true - # Resourceable attributes - allow_params :name, :location, user_organizations_attributes: [:id, :user_id, :_destroy] + allow_params :location end diff --git a/app/models/user.rb b/app/models/user.rb index bc9420d..b6f364b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,25 +1,9 @@ class User < ApplicationRecord - # Includes - include Attachable - - # JWT - has_secure_password - # Relationships has_many :user_organizations, dependent: :destroy - # Nested attributes - accepts_nested_attributes_for :user_organizations, allow_destroy: true - # Resourceable parameters - allow_params :name, :email, :admin, :api_key, :avatar, :password, :password_confirmation, - user_organizations_attributes: [:id, :organization_id, :_destroy] - - # ActiveStorage - has_one_attached :avatar - - # Validations - validates :user_organizations, presence: true, unless: :admin? + allow_params :api_key def has_access?(organization_ids) if organization_ids.is_a?(Integer) diff --git a/app/serializers/users_serializer.rb b/app/serializers/users_serializer.rb index b0b8d28..a36fc88 100644 --- a/app/serializers/users_serializer.rb +++ b/app/serializers/users_serializer.rb @@ -1,4 +1,4 @@ class UsersSerializer < BaseSerializer - index_attributes :id, :name, :email, :admin, :avatar_thumbnail_url - show_attributes :id, :name, :email, :admin, :api_key, :avatar_url, :avatar_preview_url, user_organizations: [:id, :organization_id, organization: OrganizationsSerializer] + index_attributes :id, :name, :email, :admin, :avatar_url + show_attributes :id, :name, :email, :admin, :api_key, :avatar_url, user_organizations: [:id, :organization_id, organization: OrganizationsSerializer] end diff --git a/client/package.json b/client/package.json index dd23b7a..b46a467 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@bunchtogether/vite-plugin-flow": "^1.0.2", + "@clerk/react": "^6.12.2", "@performant-software/semantic-components": "^3.1.0", "@performant-software/shared-components": "^3.1.0", "@performant-software/user-defined-fields": "^3.1.0", diff --git a/client/src/App.js b/client/src/App.js index edbc95a..cf91d7d 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -21,103 +21,112 @@ import Resource from './pages/Resource'; import Resources from './pages/Resources'; import User from './pages/User'; import Users from './pages/Users'; +import { ClerkProvider } from '@clerk/react'; +import { AuthenticationContextProvider } from './contexts/AuthenticationContext'; const App: ComponentType = () => ( - - - - } - exact - path='/' - /> - - - - )} - path='/' - > - } - path='/dashboard' - /> - } - path='/organizations' - /> - } - path='/organizations/new' - /> - } - path='/organizations/:organizationId' - /> - + + + + } - index + element={} + exact + path='/' /> } - path='new' - /> - + + + )} + path='/' > } - index + element={} + path='/dashboard' /> } + path='/organizations' + /> + } + path='/organizations/new' + /> + } + path='/organizations/:organizationId' + /> + } + element={} index /> } + element={} path='new' /> } - path=':resourceId' - /> + path=':projectId' + > + } + index + /> + + } + index + /> + } + path='new' + /> + } + path=':resourceId' + /> + + + } + path='/users' + /> + } + path='/users/new' + /> + } + path='/users/:userId' + /> - - } - path='/users' - /> - } - path='/users/new' - /> - } - path='/users/:userId' - /> - - + )} + path='*' /> - )} - path='*' - /> - - - + + + + + ); export default App; diff --git a/client/src/components/AdminPage.js b/client/src/components/AdminPage.js index 7d99748..a56f3f3 100644 --- a/client/src/components/AdminPage.js +++ b/client/src/components/AdminPage.js @@ -1,11 +1,13 @@ // @flow -import React, { type ComponentType } from 'react'; +import React, { type ComponentType, useContext } from 'react'; import { Navigate } from 'react-router'; -import AuthenticationService from '../services/Authentication'; +import { AuthenticationContext } from '../contexts/AuthenticationContext'; const AdminPage: ComponentType = (props) => { - if (!AuthenticationService.isAdmin()) { + const { user } = useContext(AuthenticationContext); + + if (!user.admin) { return ; } diff --git a/client/src/components/AuthenticatedRoute.js b/client/src/components/AuthenticatedRoute.js index 30e95c9..46e30a0 100644 --- a/client/src/components/AuthenticatedRoute.js +++ b/client/src/components/AuthenticatedRoute.js @@ -1,15 +1,17 @@ // @flow -import React, { type ComponentType } from 'react'; +import React, { type ComponentType, useContext } from 'react'; import { Navigate } from 'react-router'; -import AuthenticationService from '../services/Authentication'; +import { AuthenticationContext } from '../contexts/AuthenticationContext'; type Props = { children: any }; const AuthenticatedRoute: ComponentType = ({ children }: Props) => { - if (!AuthenticationService.isAuthenticated()) { + const { user } = useContext(AuthenticationContext); + + if (!user) { return ; } diff --git a/client/src/components/ClerkLoginModal.js b/client/src/components/ClerkLoginModal.js new file mode 100644 index 0000000..b2cbb1d --- /dev/null +++ b/client/src/components/ClerkLoginModal.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { Button } from 'semantic-ui-react'; +import { useClerk } from '@clerk/react'; +import { useTranslation } from 'react-i18next'; +import styles from './ClerkLoginModal.module.css'; + +const ClerkLoginModal = () => { + const { t } = useTranslation(); + + const { buildSignInUrl } = useClerk(); + + return ( +
+
+

+ {t('ClerkLoginModal.header')} +

+ +
+
+ ); +} + +export default ClerkLoginModal; diff --git a/client/src/components/ClerkLoginModal.module.css b/client/src/components/ClerkLoginModal.module.css new file mode 100644 index 0000000..6abf62f --- /dev/null +++ b/client/src/components/ClerkLoginModal.module.css @@ -0,0 +1,26 @@ +.clerkModalContainer { + height: 100vh; + width: 100vw; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; +} + +.clerkModal { + background-color: white; + border-radius: 8px; + padding: 48px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + align-items: center; +} + +.clerkModalHeader { + margin-bottom: 28px; + font-size: 26px; + font-weight: bold; + color: #333; + text-align: center; +} diff --git a/client/src/components/OrganizationModal.js b/client/src/components/OrganizationModal.js deleted file mode 100644 index 910b05f..0000000 --- a/client/src/components/OrganizationModal.js +++ /dev/null @@ -1,41 +0,0 @@ -// @flow - -import { AssociatedDropdown } from '@performant-software/semantic-components'; -import React, { type ComponentType } from 'react'; -import { withTranslation } from 'react-i18next'; -import { Form, Modal } from 'semantic-ui-react'; -import Organization from '../transforms/Organization'; -import OrganizationsService from '../services/Organizations'; - -const OrganizationModal: ComponentType = withTranslation()((props) => ( - - - - - OrganizationsService.fetchAll({ search, sort_by: 'name' })} - onSelection={props.onAssociationInputChange.bind(this, 'organization_id', 'organization')} - renderOption={(organization) => Organization.toDropdown(organization)} - searchQuery={props.item.organization && props.item.organization.name} - value={props.item.organization_id} - /> - - - { props.children } - -)); - -export default OrganizationModal; diff --git a/client/src/components/Sidebar.js b/client/src/components/Sidebar.js index 4279046..47ed65a 100644 --- a/client/src/components/Sidebar.js +++ b/client/src/components/Sidebar.js @@ -1,14 +1,14 @@ // @flow import cx from 'classnames'; -import React, { useCallback, type ComponentType } from 'react'; +import React, { useCallback, type ComponentType, useContext } from 'react'; import { useNavigate, useParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import { Icon, Menu, Popup } from 'semantic-ui-react'; -import AuthenticationService from '../services/Authentication'; import MenuLink from './MenuLink'; import styles from './Sidebar.module.css'; import type { Translateable } from '../types/Translateable'; +import { AuthenticationContext } from '../contexts/AuthenticationContext'; type Props = Translateable & { context: { @@ -20,13 +20,14 @@ const Sidebar: ComponentType = (props: Props) => { const navigate = useNavigate(); const params = useParams(); const { t } = useTranslation(); + const { signOut, user } = useContext(AuthenticationContext); /** * Logs the user out and navigates to the index page. * * @type {function(): Promise|Promise|Promise<*>|*} */ - const onLogout = useCallback(() => AuthenticationService.logout().then(() => navigate('/login')), []); + const onLogout = useCallback(() => signOut().then(() => navigate('/login')), []); return (
= (props: Props) => { }} /> - { AuthenticationService.isAdmin() && ( + { user.admin && ( = (props: Props) => { )} /> )} - { AuthenticationService.isAdmin() && ( + { user.admin && ( , - allowAdd?: boolean, - allowEdit?: boolean, - buttons?: Array -}; - -const SimpleList: ComponentType = (props: Props) => { - const { - actions, - allowAdd = true, - allowEdit = true, - buttons, - ...rest - } = props; - - const { t } = useTranslation(); - - return ( - allowEdit, - name: 'edit', - render: (item) => ( -