diff --git a/client/src/pages/User.js b/client/src/pages/User.js
index 045d2e1..77b7d03 100644
--- a/client/src/pages/User.js
+++ b/client/src/pages/User.js
@@ -1,27 +1,27 @@
// @flow
-import { EmbeddedList, FileInputButton, LazyImage } from '@performant-software/semantic-components';
+import { EmbeddedList } from '@performant-software/semantic-components';
import type { EditContainerProps } from '@performant-software/shared-components/types';
-import React, { type ComponentType, useEffect } from 'react';
+import React, { type ComponentType, useContext, useEffect } from 'react';
import { withTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
-import { Button, Form, Message } from 'semantic-ui-react';
+import { Form } from 'semantic-ui-react';
import _ from 'underscore';
-import AuthenticationService from '../services/Authentication';
import i18n from '../i18n/i18n';
-import OrganizationModal from '../components/OrganizationModal';
import SimpleEditPage from '../components/SimpleEditPage';
import type { Translateable } from '../types/Translateable';
import UsersService from '../services/Users';
import withEditPage from '../hooks/EditPage';
+import { AuthenticationContext } from '../contexts/AuthenticationContext';
const UserForm = withTranslation()((props: EditContainerProps & Translateable) => {
+ const { user } = useContext(AuthenticationContext);
+
/**
* Pre-populate the organization if the user is only a member of one organization.
*/
useEffect(() => {
if (!props.item.id) {
- const user = AuthenticationService.getCurrentUser();
if (user.user_organizations && user.user_organizations.length === 1) {
const { organization } = _.first(user.user_organizations);
props.onSetState({ user_organizations: [{ organization_id: organization.id, organization }] });
@@ -38,59 +38,20 @@ const UserForm = withTranslation()((props: EditContainerProps & Translateable) =
name={props.t('Common.tabs.details')}
>
-
- { !props.item.avatar_url && (
- {
- const file = _.first(files);
- const url = URL.createObjectURL(file);
- props.onSetState({
- avatar: file,
- avatar_url: url,
- avatar_preview_url: url
- });
- }}
- />
- )}
- { props.item.avatar_url && (
-
-
-
- { AuthenticationService.isAdmin() && (
+ { user.admin && (
)}
-
-
-
o.organization.location,
sortable: true
}]}
- modal={{
- component: OrganizationModal,
- props: {
- required: ['organization_id']
- }
- }}
items={props.item.user_organizations}
- onDelete={props.onDeleteChildAssociation.bind(this, 'user_organizations')}
- onSave={props.onSaveChildAssociation.bind(this, 'user_organizations')}
/>
diff --git a/client/src/pages/Users.js b/client/src/pages/Users.js
index 826fa56..b42a7ad 100644
--- a/client/src/pages/Users.js
+++ b/client/src/pages/Users.js
@@ -14,13 +14,7 @@ const Users: ComponentType
= withTranslation()(() => {
actions={[{
name: 'edit',
onClick: (item) => navigate(`/users/${item.id}`)
- }, {
- name: 'delete'
}]}
- addButton={{
- location: 'top',
- onClick: () => navigate('/users/new')
- }}
collectionName='users'
defaultSort='name'
onLoad={(params) => UsersService.fetchAll(params)}
@@ -29,7 +23,7 @@ const Users: ComponentType = withTranslation()(() => {
renderImage={(user) => (
)}
renderMeta={(user) => user.email}
diff --git a/client/src/services/Authentication.js b/client/src/services/Authentication.js
deleted file mode 100644
index 8a7d131..0000000
--- a/client/src/services/Authentication.js
+++ /dev/null
@@ -1,109 +0,0 @@
-// @flow
-
-import { BaseService, BaseTransform } from '@performant-software/shared-components';
-import AuthenticationTransform from '../transforms/Authentication';
-import type { User } from '../types/User';
-
-const STORAGE_KEY = 'current_user';
-
-class Authentication extends BaseService {
- /**
- * Returns the authentication base URL.
- *
- * @returns {string}
- */
- getBaseUrl(): string {
- return '/auth/login';
- }
-
- /**
- * Returns the current user.
- *
- * @returns {*}
- */
- getCurrentUser(): User {
- const user = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
- return user.user;
- }
-
- /**
- * Returns the user authentication token.
- *
- * @returns {*}
- */
- getToken(): string {
- const user = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
- return user && user.token;
- }
-
- /**
- * Returns the authentication transform object.
- *
- * @returns {Authentication}
- */
- getTransform(): typeof BaseTransform {
- return AuthenticationTransform;
- }
-
- /**
- * Returns true if the logged in user is an administrator.
- *
- * @returns {boolean|*}
- */
- isAdmin(): boolean {
- if (!localStorage.getItem(STORAGE_KEY)) {
- return false;
- }
-
- const user = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
- return user && user.user && user.user.admin;
- }
-
- /**
- * Returns true if the current user is authenticated.
- *
- * @returns {*|boolean}
- */
- isAuthenticated(): boolean {
- if (!localStorage.getItem(STORAGE_KEY)) {
- return false;
- }
-
- const user = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
- const { token, exp } = user;
-
- const expirationDate = new Date(Date.parse(exp));
- const today = new Date();
-
- return token && token.length && expirationDate.getTime() > today.getTime();
- }
-
- /**
- * Attempts to authenticate the current user and stores the response in local storage.
- *
- * @param params
- *
- * @returns {*}
- */
- login(params: any): Promise {
- return this
- .create(params)
- .then((response) => {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(response.data));
- return response;
- });
- }
-
- /**
- * Removes the properties of the current user from local storage.
- *
- * @returns {Promise<*>}
- */
- logout(): Promise {
- localStorage.removeItem(STORAGE_KEY);
- return Promise.resolve();
- }
-}
-
-const AuthenticationService: Authentication = new Authentication();
-export default AuthenticationService;
diff --git a/client/src/services/Users.js b/client/src/services/Users.js
index 606f891..268fd7e 100644
--- a/client/src/services/Users.js
+++ b/client/src/services/Users.js
@@ -2,6 +2,7 @@
import { BaseService } from '@performant-software/shared-components';
import User from '../transforms/User';
+import type { User as UserType } from '../types/User';
/**
* Class responsible for handling all users API requests.
@@ -24,6 +25,10 @@ class Users extends BaseService {
getTransform(): any {
return User;
}
+
+ getMe(): UserType {
+ return this.getAxios().get(`${this.getBaseUrl()}/me`)
+ }
}
const UsersService: Users = new Users();
diff --git a/client/src/transforms/Authentication.js b/client/src/transforms/Authentication.js
deleted file mode 100644
index fd022f0..0000000
--- a/client/src/transforms/Authentication.js
+++ /dev/null
@@ -1,35 +0,0 @@
-// @flow
-
-import { BaseTransform } from '@performant-software/shared-components';
-import _ from 'underscore';
-
-/**
- * Class responsible for transforming authentication objects.
- */
-class Authentication extends BaseTransform {
- /**
- * Returns the authentication payload keys used for PUT/POST requests.
- *
- * @returns {string[]}
- */
- getPayloadKeys(): Array {
- return [
- 'email',
- 'password'
- ];
- }
-
- /**
- * Returns an object containing only the params specified in the payload keys.
- *
- * @param params
- *
- * @returns {*}
- */
- toPayload(params: any): any {
- return _.pick(params, this.getPayloadKeys());
- }
-}
-
-const AuthenticationTransform: Authentication = new Authentication();
-export default AuthenticationTransform;
diff --git a/client/src/transforms/Organization.js b/client/src/transforms/Organization.js
index d43f523..ee4deab 100644
--- a/client/src/transforms/Organization.js
+++ b/client/src/transforms/Organization.js
@@ -1,7 +1,6 @@
// @flow
import { BaseTransform } from '@performant-software/shared-components';
-import UserOrganizations from './UserOrganizations';
import type { Organization as OrganizationType } from '../types/Organization';
/**
@@ -24,7 +23,6 @@ class Organization extends BaseTransform {
*/
getPayloadKeys(): Array {
return [
- 'name',
'location'
];
}
@@ -43,24 +41,6 @@ class Organization extends BaseTransform {
text: organization.name
};
}
-
- /**
- * Returns the passed organization for PUT/POST requests.
- *
- * @param organization
- *
- * @returns {{[p: string]: {[p: string]: *}}}
- */
- toPayload(organization: OrganizationType): any {
- const payload = super.toPayload(organization)[this.getParameterName()];
-
- return {
- [this.getParameterName()]: {
- ...payload,
- ...UserOrganizations.toPayload(organization)
- }
- };
- }
}
const OrganizationTransform: Organization = new Organization();
diff --git a/client/src/transforms/User.js b/client/src/transforms/User.js
index 1a95296..8fd8072 100644
--- a/client/src/transforms/User.js
+++ b/client/src/transforms/User.js
@@ -1,8 +1,7 @@
// @flow
-import { Attachments, FormDataTransform } from '@performant-software/shared-components';
+import { FormDataTransform } from '@performant-software/shared-components';
import type { User as UserType } from '../types/User';
-import UserOrganizations from './UserOrganizations';
/**
* Class responsible for transforming user objects.
@@ -24,9 +23,6 @@ class User extends FormDataTransform {
*/
getPayloadKeys(): Array {
return [
- 'admin',
- 'name',
- 'email',
'api_key'
];
}
@@ -45,27 +41,6 @@ class User extends FormDataTransform {
text: user.name
};
}
-
- /**
- * Returns the passed user record as JSON for PUT/POST requests.
- *
- * @param user
- *
- * @returns {{[p: string]: *}}
- */
- toPayload(user: UserType): any {
- const formData = super.toPayload(user);
- Attachments.toPayload(formData, this.getParameterName(), user, 'avatar');
- UserOrganizations.toFormData(formData, this.getParameterName(), user);
-
- // Only include the password/confirmation in the payload if we're changing it
- if (user.password && user.password_confirmation) {
- formData.append(`${this.getParameterName()}[password]`, user.password);
- formData.append(`${this.getParameterName()}[password_confirmation]`, user.password_confirmation);
- }
-
- return formData;
- }
}
const UserTransform: User = new User();
diff --git a/client/src/types/User.js b/client/src/types/User.js
index 750ae65..284676f 100644
--- a/client/src/types/User.js
+++ b/client/src/types/User.js
@@ -6,11 +6,6 @@ export type User = {
id: number,
name: string,
email: string,
- password: string,
- password_confirmation: string,
- avatar_url: string,
- avatar_download_url: string,
- avatar_preview_url: string,
- avatar_thumbnail_url: string,
+ admin: boolean,
user_organizations: Array
};
diff --git a/client/yarn.lock b/client/yarn.lock
index 4701d8a..76de2de 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -1014,6 +1014,24 @@
flow-remove-types "^2.158.0"
rollup-pluginutils "^2.8.2"
+"@clerk/react@^6.12.2":
+ version "6.12.2"
+ resolved "https://registry.yarnpkg.com/@clerk/react/-/react-6.12.2.tgz#e0f93dbbc7631a5454b851da3821368e3729af5d"
+ integrity sha512-Y+OGZlYfRW+3b+fjdbvSYyYB/IraGitrlLJKYaHxeZYNIwvoUNH0zlreSxUSmxYZ0E2yETSEvkCXszvuZUMa3A==
+ dependencies:
+ "@clerk/shared" "^4.25.2"
+ tslib "2.8.1"
+
+"@clerk/shared@^4.25.2":
+ version "4.25.2"
+ resolved "https://registry.yarnpkg.com/@clerk/shared/-/shared-4.25.2.tgz#b63e8616f02bd9e42b789881544e5a407d97a909"
+ integrity sha512-v7zgDh0eVLl3KzRHbKYTsPQ+4Xcx8A2oiU5iZ9WhME024WudwkjjPJRK12RycY0VA1Sj8i4Pi5zEpecqB/gp1Q==
+ dependencies:
+ "@tanstack/query-core" "^5.100.6"
+ dequal "2.0.3"
+ glob-to-regexp "0.4.1"
+ js-cookie "3.0.7"
+
"@emnapi/core@^1.4.3":
version "1.4.5"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.5.tgz#bfbb0cbbbb9f96ec4e2c4fd917b7bbe5495ceccb"
@@ -1654,6 +1672,11 @@
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
+"@tanstack/query-core@^5.100.6":
+ version "5.101.2"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.101.2.tgz#c901fb76b68169ff5e1e44017404e7e2e90ce1af"
+ integrity sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==
+
"@tybys/wasm-util@^0.10.0":
version "0.10.0"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.0.tgz#2fd3cd754b94b378734ce17058d0507c45c88369"
@@ -2546,7 +2569,7 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
-dequal@^2.0.3:
+dequal@2.0.3, dequal@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
@@ -3274,6 +3297,11 @@ glob-parent@~5.1.2:
dependencies:
is-glob "^4.0.1"
+glob-to-regexp@0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
+ integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
+
glob@^7.2.0:
version "7.2.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
@@ -3830,6 +3858,11 @@ jquery@x.*:
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470"
integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==
+js-cookie@3.0.7:
+ version "3.0.7"
+ resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.7.tgz#0a53abfc459c8e89c85d7a38eb6cb68714965b8c"
+ integrity sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==
+
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -5144,7 +5177,7 @@ tr46@~0.0.3:
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
-tslib@^2.4.0:
+tslib@2.8.1, tslib@^2.4.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
diff --git a/config/initializers/jwt_auth.rb b/config/initializers/jwt_auth.rb
deleted file mode 100644
index e21ee8f..0000000
--- a/config/initializers/jwt_auth.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-JwtAuth.configure do |config|
- config.model_class = 'User'
- config.login_attribute = 'email'
- config.user_serializer = 'UsersSerializer'
-end
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index b9a33c5..2a504b4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -4,14 +4,10 @@
# Enable Rack session middleware only for Sidekiq Web UI
Sidekiq::Web.use Rack::Session::Cookie, secret: Rails.application.credentials.secret_key_base, same_site: :lax, max_age: 86400
-# Add Basic Authentication for Security
-Sidekiq::Web.use Rack::Auth::Basic do |email, password|
- user = User.find_by(email:,)
- user.present? && user.admin? && user.authenticate(password)
-end
+# Restrict access to admins authenticated via Clerk
+Sidekiq::Web.use SidekiqWebAuthentication
Rails.application.routes.draw do
- mount JwtAuth::Engine, at: '/auth'
mount Sidekiq::Web => '/sidekiq'
mount UserDefinedFields::Engine, at: '/user_defined_fields'
@@ -26,10 +22,9 @@
post :convert, on: :member
post :upload, on: :collection
end
- resources :users
-
- # Authentication
- post '/auth/login', to: 'authentication#login'
+ resources :users do
+ get :me, on: :collection
+ end
end
namespace :public do
diff --git a/db/migrate/20260713155706_add_sso_ids.rb b/db/migrate/20260713155706_add_sso_ids.rb
new file mode 100644
index 0000000..23b3c8f
--- /dev/null
+++ b/db/migrate/20260713155706_add_sso_ids.rb
@@ -0,0 +1,6 @@
+class AddSsoIds < ActiveRecord::Migration[8.0]
+ def change
+ add_column :users, :sso_id, :string
+ add_column :organizations, :sso_id, :string
+ end
+end
diff --git a/db/migrate/20260713181018_remove_passwords_from_users.rb b/db/migrate/20260713181018_remove_passwords_from_users.rb
new file mode 100644
index 0000000..2fde424
--- /dev/null
+++ b/db/migrate/20260713181018_remove_passwords_from_users.rb
@@ -0,0 +1,5 @@
+class RemovePasswordsFromUsers < ActiveRecord::Migration[8.0]
+ def change
+ remove_column :users, :password_digest, :string
+ end
+end
diff --git a/db/migrate/20260714185244_add_avatar_url_to_users.rb b/db/migrate/20260714185244_add_avatar_url_to_users.rb
new file mode 100644
index 0000000..24d614f
--- /dev/null
+++ b/db/migrate/20260714185244_add_avatar_url_to_users.rb
@@ -0,0 +1,5 @@
+class AddAvatarUrlToUsers < ActiveRecord::Migration[8.0]
+ def change
+ add_column :users, :avatar_url, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7f44206..8e7549d 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,9 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_07_23_194140) do
+ActiveRecord::Schema[8.0].define(version: 2026_07_14_185244) do
+ create_schema "heroku_ext"
+
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements"
@@ -78,6 +80,7 @@
t.string "location"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.string "sso_id"
end
create_table "projects", force: :cascade do |t|
@@ -134,11 +137,12 @@
create_table "users", force: :cascade do |t|
t.string "name"
t.string "email"
- t.string "password_digest"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "admin", default: false
t.string "api_key"
+ t.string "sso_id"
+ t.string "avatar_url"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
diff --git a/db/seeds.rb b/db/seeds.rb
index e8070cc..40ee6f4 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -5,4 +5,4 @@
#
# movies = Movie.create([{ name: "Star Wars" }, { name: "Lord of the Rings" }])
# Character.create(name: "Luke", movie: movies.first)
-User.create!(name: 'Administrator', email: 'admin@example.com', password: 'password', password_confirmation: 'password', admin: true)
+User.create!(name: 'Administrator', email: 'admin@example.com', sso_id: nil, admin: true)
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index e2f5253..11c0b4d 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -3,9 +3,7 @@
one:
name: MyString
email: MyString
- password_digest: MyString
two:
name: MyString
email: MyString
- password_digest: MyString