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
28 changes: 28 additions & 0 deletions app/graphql/mutations/users/update_organization_pins.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module Mutations
module Users
class UpdateOrganizationPins < BaseMutation
description 'Update pinned organizations for a user.'

field :user, Types::UserType, null: true, description: 'The updated user.'

argument :organization_ids,
[Types::GlobalIdType[::Organization]],
required: true,
description: 'Ordered list of organization IDs to pin for the user.'

def resolve(organization_ids:)
organizations = organization_ids.map { |id| SagittariusSchema.object_from_id(id) }
if organizations.any?(&:nil?)
return { user: nil, errors: [create_error(:organization_not_found, 'Invalid organization with provided id')] }
end

::Users::UpdateOrganizationPinsService.new(
current_authentication,
organizations.map(&:id)
).execute.to_mutation_response(success_key: :user)
end
end
end
end
1 change: 1 addition & 0 deletions app/graphql/types/mutation_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class MutationType < Types::BaseObject
mount_mutation Mutations::Users::PasswordResetRequest
mount_mutation Mutations::Users::PasswordReset
mount_mutation Mutations::Users::Register
mount_mutation Mutations::Users::UpdateOrganizationPins
mount_mutation Mutations::Users::Update
mount_mutation Mutations::Echo
end
Expand Down
16 changes: 16 additions & 0 deletions app/graphql/types/user_organization_pin_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

module Types
class UserOrganizationPinType < Types::BaseObject
description 'Represents a pinned organization of a user'

authorize :read_user_organization_pin

field :organization, Types::OrganizationType, null: true, description: 'The pinned organization'
field :priority, Integer, null: false, description: 'Ordering priority of the pin'
field :user, Types::UserType, null: false, description: 'The user owning this pin'

id_field UserOrganizationPin
timestamps
end
end
12 changes: 12 additions & 0 deletions app/graphql/types/user_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ class UserType < Types::BaseObject
description: 'Identities of this user',
method: :user_identities

field :organization_pins, [Types::UserOrganizationPinType],
null: false,
description: 'Pinned organizations of this user with explicit priority'

field :pinned_organizations, [Types::OrganizationType],
null: false,
description: 'Pinned organizations of this user ordered by pin priority'

Comment on lines +45 to +52
field :mfa_status, Types::MfaStatusType,
null: true,
description: 'Multi-factor authentication status of this user'
Expand Down Expand Up @@ -72,5 +80,9 @@ def mfa_status
backup_codes_count: object.backup_codes.size,
}
end

def organization_pins
object.user_organization_pins.order(priority: :asc)
end
end
end
1 change: 1 addition & 0 deletions app/models/audit_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class AuditEvent < ApplicationRecord
password_reset: 37,
user_deleted: 38,
user_created: 39,
user_organization_pins_updated: 40,
}.with_indifferent_access

# rubocop:disable Lint/StructNewOverride
Expand Down
3 changes: 3 additions & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
class Organization < ApplicationRecord
include NamespaceParent

has_many :user_organization_pins, inverse_of: :organization
has_many :pinned_by_users, through: :user_organization_pins, source: :user

validates :name, presence: true,
length: { minimum: 3, maximum: 50 },
allow_blank: false,
Expand Down
2 changes: 2 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class User < ApplicationRecord
has_many :namespaces, through: :namespace_memberships, inverse_of: :users

has_many :user_identities, inverse_of: :user
has_many :user_organization_pins, -> { order(priority: :asc) }, inverse_of: :user
has_many :pinned_organizations, through: :user_organization_pins, source: :organization

has_one_attached :avatar

Expand Down
11 changes: 11 additions & 0 deletions app/models/user_organization_pin.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

class UserOrganizationPin < ApplicationRecord
belongs_to :user, inverse_of: :user_organization_pins
belongs_to :organization, inverse_of: :user_organization_pins

validates :priority, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 },
uniqueness: { scope: :user_id }
validates :organization_id, uniqueness: { scope: :user_id }
end
9 changes: 9 additions & 0 deletions app/policies/user_organization_pin_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class UserOrganizationPinPolicy < BasePolicy
delegate { subject.user }

condition(:user_is_self) { subject.user_id == user&.id }

rule { user_is_self }.enable :read_user_organization_pin
end
1 change: 1 addition & 0 deletions app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class UserPolicy < BasePolicy
enable :read_user_identity
enable :manage_mfa
enable :update_user
enable :update_user_organization_pin
enable :update_attachment_avatar
enable :verify_email
enable :send_verification_email
Expand Down
1 change: 1 addition & 0 deletions app/services/error_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def self.error_codes
failed_to_save_valid_backup_code: { description: 'The new backup codes could not be saved' },
invalid_setting: { description: 'Invalid setting provided' },
invalid_user: { description: 'The user is invalid because of active model errors' },
invalid_user_organization_pin: { description: 'The user organization pin is invalid because of active model errors' },
invalid_password_repeat: { description: 'The provided password repeat does not match the password' },
cannot_modify_admin: { description: 'Only administrators can modify admin status of users' },
cannot_modify_own_admin: { description: 'Users cannot modify their own admin status' },
Expand Down
10 changes: 10 additions & 0 deletions app/services/namespaces/members/delete_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def execute
end

check_last_administrator(t)
remove_organization_pin

AuditService.audit(
:namespace_member_deleted,
Expand Down Expand Up @@ -54,6 +55,15 @@ def check_last_administrator(t)
)
end
end

def remove_organization_pin
return unless namespace_member.namespace.organization_type?

UserOrganizationPin.where(
user: namespace_member.user,
organization: namespace_member.namespace.parent
).delete_all
end
end
end
end
59 changes: 59 additions & 0 deletions app/services/users/update_organization_pins_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

module Users
class UpdateOrganizationPinsService
include Sagittarius::Database::Transactional

attr_reader :current_authentication, :user, :organization_ids

def initialize(current_authentication, organization_ids)
@current_authentication = current_authentication
@user = current_authentication.user
@organization_ids = organization_ids.uniq
end

def execute
unless user && Ability.allowed?(current_authentication, :update_user_organization_pin, user)
return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission)
end

organizations = OrganizationsFinder.new(id: organization_ids, namespace_member_user: user).execute
if organizations.count != organization_ids.count
return ServiceResponse.error(message: 'Organization not found', error_code: :organization_not_found)
end

transactional do |t|
old_pins = user.user_organization_pins.map do |pin|
{ organization_id: pin.organization_id, priority: pin.priority }
end

UserOrganizationPin.where(user: user).delete_all

organization_ids.each_with_index do |organization_id, priority|
pin = user.user_organization_pins.build(organization_id: organization_id, priority: priority)
next if pin.save

t.rollback_and_return! ServiceResponse.error(
message: 'Failed to update user organization pins',
error_code: :invalid_user_organization_pin,
details: pin.errors
)
end

new_pins = user.user_organization_pins.reload.map do |pin|
{ organization_id: pin.organization_id, priority: pin.priority }
end

AuditService.audit(
:user_organization_pins_updated,
author_id: current_authentication.user.id,
entity: user,
target: user,
details: { old_pins: old_pins, new_pins: new_pins }
)

ServiceResponse.success(message: 'Updated user organization pins', payload: user)
end
end
end
end
16 changes: 16 additions & 0 deletions db/migrate/20260504153000_create_user_organization_pins.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

class CreateUserOrganizationPins < Code0::ZeroTrack::Database::Migration[1.0]
def change
create_table :user_organization_pins do |t|
t.references :user, null: false, foreign_key: { on_delete: :cascade }, index: false
t.references :organization, null: false, foreign_key: { on_delete: :cascade }, index: false
t.integer :priority, null: false

t.index %i[user_id organization_id], unique: true
t.index %i[user_id priority], unique: true

t.timestamps_with_timezone
end
end
end
1 change: 1 addition & 0 deletions db/schema_migrations/20260504153000
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
7cf08006d8ab53c1cea495d14f91286debd633d327ea0dee7148ce805d61c32d
33 changes: 33 additions & 0 deletions db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,24 @@ CREATE SEQUENCE user_identities_id_seq

ALTER SEQUENCE user_identities_id_seq OWNED BY user_identities.id;

CREATE TABLE user_organization_pins (
id bigint NOT NULL,
user_id bigint NOT NULL,
organization_id bigint NOT NULL,
priority integer NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);

CREATE SEQUENCE user_organization_pins_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;

ALTER SEQUENCE user_organization_pins_id_seq OWNED BY user_organization_pins.id;

CREATE TABLE user_sessions (
id bigint NOT NULL,
user_id bigint NOT NULL,
Expand Down Expand Up @@ -956,6 +974,8 @@ ALTER TABLE ONLY translations ALTER COLUMN id SET DEFAULT nextval('translations_

ALTER TABLE ONLY user_identities ALTER COLUMN id SET DEFAULT nextval('user_identities_id_seq'::regclass);

ALTER TABLE ONLY user_organization_pins ALTER COLUMN id SET DEFAULT nextval('user_organization_pins_id_seq'::regclass);

ALTER TABLE ONLY user_sessions ALTER COLUMN id SET DEFAULT nextval('user_sessions_id_seq'::regclass);

ALTER TABLE ONLY users ALTER COLUMN id SET DEFAULT nextval('users_id_seq'::regclass);
Expand Down Expand Up @@ -1098,6 +1118,9 @@ ALTER TABLE ONLY translations
ALTER TABLE ONLY user_identities
ADD CONSTRAINT user_identities_pkey PRIMARY KEY (id);

ALTER TABLE ONLY user_organization_pins
ADD CONSTRAINT user_organization_pins_pkey PRIMARY KEY (id);

ALTER TABLE ONLY user_sessions
ADD CONSTRAINT user_sessions_pkey PRIMARY KEY (id);

Expand Down Expand Up @@ -1256,6 +1279,10 @@ CREATE INDEX index_user_identities_on_user_id ON user_identities USING btree (us

CREATE UNIQUE INDEX index_user_identities_on_user_id_and_provider_id ON user_identities USING btree (user_id, provider_id);

CREATE UNIQUE INDEX index_user_organization_pins_on_user_id_and_organization_id ON user_organization_pins USING btree (user_id, organization_id);

CREATE UNIQUE INDEX index_user_organization_pins_on_user_id_and_priority ON user_organization_pins USING btree (user_id, priority);

CREATE UNIQUE INDEX index_user_sessions_on_token ON user_sessions USING btree (token);

CREATE INDEX index_user_sessions_on_user_id ON user_sessions USING btree (user_id);
Expand All @@ -1266,6 +1293,9 @@ CREATE UNIQUE INDEX "index_users_on_LOWER_username" ON users USING btree (lower(

CREATE UNIQUE INDEX index_users_on_totp_secret ON users USING btree (totp_secret) WHERE (totp_secret IS NOT NULL);

ALTER TABLE ONLY user_organization_pins
ADD CONSTRAINT fk_rails_036679312e FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;

ALTER TABLE ONLY node_parameters
ADD CONSTRAINT fk_rails_0d79310cfa FOREIGN KEY (node_function_id) REFERENCES node_functions(id) ON DELETE CASCADE;

Expand Down Expand Up @@ -1341,6 +1371,9 @@ ALTER TABLE ONLY flow_types
ALTER TABLE ONLY namespace_role_project_assignments
ADD CONSTRAINT fk_rails_69066bda8f FOREIGN KEY (project_id) REFERENCES namespace_projects(id);

ALTER TABLE ONLY user_organization_pins
ADD CONSTRAINT fk_rails_6b125cdf79 FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;

ALTER TABLE ONLY namespace_member_roles
ADD CONSTRAINT fk_rails_6c0d5a04c4 FOREIGN KEY (member_id) REFERENCES namespace_members(id) ON DELETE CASCADE;

Expand Down
1 change: 1 addition & 0 deletions docs/graphql/enum/errorcodeenum.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Represents the available error responses
| `INVALID_TOTP_SECRET` | The TOTP secret is invalid or cannot be verified |
| `INVALID_USER` | The user is invalid because of active model errors |
| `INVALID_USER_IDENTITY` | The user identity is invalid because of active model errors |
| `INVALID_USER_ORGANIZATION_PIN` | The user organization pin is invalid because of active model errors |
| `INVALID_USER_SESSION` | The user session is invalid because of active model errors |
| `INVALID_VERIFICATION_CODE` | Invalid verification code provided |
| `IS_PRIMARY_RUNTIME` | This runtime is the primary runtime of a project |
Expand Down
20 changes: 20 additions & 0 deletions docs/graphql/mutation/usersupdateorganizationpins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
title: usersUpdateOrganizationPins
---

Update pinned organizations for a user.

## Arguments

| Name | Type | Description |
|------|------|-------------|
| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. |
| `organizationIds` | [`[OrganizationID!]!`](../scalar/organizationid.md) | Ordered list of organization IDs to pin for the user. |

## Fields

| Name | Type | Description |
|------|------|-------------|
| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. |
| `errors` | [`[Error!]!`](../object/error.md) | Errors encountered during execution of the mutation. |
| `user` | [`User`](../object/user.md) | The updated user. |
2 changes: 2 additions & 0 deletions docs/graphql/object/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Represents a user
| `mfaStatus` | [`MfaStatus`](../object/mfastatus.md) | Multi-factor authentication status of this user |
| `namespace` | [`Namespace`](../object/namespace.md) | Namespace of this user |
| `namespaceMemberships` | [`NamespaceMemberConnection!`](../object/namespacememberconnection.md) | Namespace Memberships of this user |
| `organizationPins` | [`[UserOrganizationPin!]!`](../object/userorganizationpin.md) | Pinned organizations of this user with explicit priority |
| `pinnedOrganizations` | [`[Organization!]!`](../object/organization.md) | Pinned organizations of this user ordered by pin priority |
| `readme` | [`String`](../scalar/string.md) | Readme of the user |
| `sessions` | [`UserSessionConnection!`](../object/usersessionconnection.md) | Sessions of this user |
| `updatedAt` | [`Time!`](../scalar/time.md) | Time when this User was last updated |
Expand Down
16 changes: 16 additions & 0 deletions docs/graphql/object/userorganizationpin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: UserOrganizationPin
---

Represents a pinned organization of a user

## Fields without arguments

| Name | Type | Description |
|------|------|-------------|
| `createdAt` | [`Time!`](../scalar/time.md) | Time when this UserOrganizationPin was created |
| `id` | [`UserOrganizationPinID!`](../scalar/userorganizationpinid.md) | Global ID of this UserOrganizationPin |
| `organization` | [`Organization`](../object/organization.md) | The pinned organization |
| `priority` | [`Int!`](../scalar/int.md) | Ordering priority of the pin |
| `updatedAt` | [`Time!`](../scalar/time.md) | Time when this UserOrganizationPin was last updated |
| `user` | [`User!`](../object/user.md) | The user owning this pin |
5 changes: 5 additions & 0 deletions docs/graphql/scalar/userorganizationpinid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: UserOrganizationPinID
---

A unique identifier for all UserOrganizationPin entities of the application
11 changes: 11 additions & 0 deletions spec/factories/user_organization_pins.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

FactoryBot.define do
sequence(:user_organization_pin_priority)

factory :user_organization_pin do
user
organization
priority { generate(:user_organization_pin_priority) }
end
end
Loading