diff --git a/app/graphql/mutations/api_keys/create.rb b/app/graphql/mutations/api_keys/create.rb new file mode 100644 index 00000000000..3520e1a93a8 --- /dev/null +++ b/app/graphql/mutations/api_keys/create.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module ApiKeys + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = 'developers:keys:manage' + + graphql_name 'CreateApiKey' + description 'Creates a new API key' + + argument :name, String, required: false + + type Types::ApiKeys::Object + + def resolve(**args) + result = ::ApiKeys::CreateService.call(args.merge(organization_id: current_organization.id)) + + result.success? ? result.api_key : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/api_keys/rotate.rb b/app/graphql/mutations/api_keys/rotate.rb index f44b9a78fb0..40d9214c874 100644 --- a/app/graphql/mutations/api_keys/rotate.rb +++ b/app/graphql/mutations/api_keys/rotate.rb @@ -4,6 +4,7 @@ module Mutations module ApiKeys class Rotate < BaseMutation include AuthenticableApiUser + include RequiredOrganization REQUIRED_PERMISSION = 'developers:keys:manage' diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 1fc18350f42..4fb610dd61a 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -151,6 +151,7 @@ class MutationType < Types::BaseObject field :create_dunning_campaign, mutation: Mutations::DunningCampaigns::Create field :update_dunning_campaign, mutation: Mutations::DunningCampaigns::Update + field :create_api_key, mutation: Mutations::ApiKeys::Create field :rotate_api_key, mutation: Mutations::ApiKeys::Rotate end end diff --git a/app/mailers/api_key_mailer.rb b/app/mailers/api_key_mailer.rb index e3100dda838..02d036eb6ae 100644 --- a/app/mailers/api_key_mailer.rb +++ b/app/mailers/api_key_mailer.rb @@ -13,4 +13,17 @@ def rotated ) end end + + def created + organization = params[:api_key].organization + @organization_name = organization.name + + I18n.with_locale(:en) do + mail( + bcc: organization.admins.pluck(:email), + from: ENV['LAGO_FROM_EMAIL'], + subject: I18n.t('email.api_key.created.subject') + ) + end + end end diff --git a/app/services/api_keys/create_service.rb b/app/services/api_keys/create_service.rb new file mode 100644 index 00000000000..e92ec07ea10 --- /dev/null +++ b/app/services/api_keys/create_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ApiKeys + class CreateService < BaseService + def initialize(params) + @params = params + super + end + + def call + return result.forbidden_failure! unless License.premium? + + api_key = ApiKey.create!( + params.slice(:organization_id, :name) + ) + + ApiKeyMailer.with(api_key:).created.deliver_later + + result.api_key = api_key + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :params + end +end diff --git a/app/views/api_key_mailer/created.slim b/app/views/api_key_mailer/created.slim new file mode 100644 index 00000000000..fdddb46180c --- /dev/null +++ b/app/views/api_key_mailer/created.slim @@ -0,0 +1,35 @@ +div style='margin-bottom: 32px;width: 80px;height: 24px;' + svg xmlns="http://www.w3.org/2000/svg" fill="none" viewbox="0 0 80 24" + g clip-path="url(#a)" + g fill="#19212E" clip-path="url(#b)" + path d="M69.85 18.161a5.529 5.529 0 0 1-2.324-2.326c-.557-1.003-.819-2.158-.819-3.463 0-1.305.279-2.46.819-3.463.54-1.004 1.326-1.773 2.324-2.326 1.015-.535 2.178-.82 3.504-.82s2.488.268 3.503.82a5.625 5.625 0 0 1 2.325 2.326c.54 1.004.818 2.158.818 3.463 0 1.322-.278 2.476-.819 3.48a5.678 5.678 0 0 1-2.324 2.309c-1.015.535-2.177.82-3.503.82s-2.505-.268-3.504-.82Zm5.795-3.095c.557-.686.852-1.59.852-2.677 0-1.104-.279-1.991-.852-2.677-.572-.686-1.326-1.037-2.291-1.037-.95 0-1.703.351-2.276 1.037-.573.686-.851 1.573-.851 2.677s.278 2.008.851 2.677c.573.686 1.326 1.037 2.276 1.037.965 0 1.719-.351 2.291-1.037ZM65.512 5.931v12.515c0 1.69-.556 3.044-1.637 4.048-1.097 1.004-2.8 1.506-5.108 1.506-1.784 0-3.224-.401-4.321-1.204-1.097-.804-1.686-1.941-1.768-3.413h3.487c.163.619.49 1.087.965 1.422.475.334 1.114.502 1.9.502.982 0 1.751-.252 2.291-.753.54-.502.819-1.255.819-2.226v-1.355c-.917 1.188-2.227 1.774-3.896 1.774-1.114 0-2.112-.268-2.996-.803-.884-.536-1.572-1.289-2.08-2.276-.507-.97-.752-2.124-.752-3.43 0-1.288.245-2.425.753-3.413a5.367 5.367 0 0 1 2.095-2.275c.884-.535 1.9-.803 3.012-.803 1.654 0 2.963.653 3.93 1.958L62.5 5.93h3.012Zm-4.24 8.984c.557-.669.835-1.539.835-2.61 0-1.087-.278-1.974-.835-2.66-.556-.686-1.31-1.02-2.242-1.02-.934 0-1.687.334-2.243 1.02-.573.67-.852 1.556-.852 2.644 0 1.087.279 1.974.852 2.643.573.67 1.31 1.004 2.242 1.004.934-.017 1.687-.351 2.243-1.02ZM51.22 5.931v12.9h-3.077l-.294-1.807c-1 1.288-2.309 1.924-3.93 1.924-1.113 0-2.111-.268-2.995-.803-.884-.536-1.572-1.305-2.08-2.31-.507-1.003-.752-2.158-.752-3.496 0-1.305.245-2.46.753-3.463A5.5 5.5 0 0 1 40.94 6.55c.884-.535 1.9-.82 3.012-.82.852 0 1.605.168 2.26.502a4.659 4.659 0 0 1 1.62 1.372l.344-1.706h3.045v.033Zm-4.24 9.152c.557-.67.836-1.573.836-2.677 0-1.121-.279-2.024-.835-2.71-.557-.686-1.31-1.038-2.243-1.038-.933 0-1.686.352-2.243 1.038-.573.686-.85 1.589-.85 2.71 0 1.104.277 1.99.85 2.677.573.686 1.31 1.02 2.243 1.02.933 0 1.686-.35 2.243-1.02ZM27.5 18.83V1.263h3.683v14.338h6.827v3.23H27.5Z" + g clip-path="url(#c)" + g fill="#19212E" clip-path="url(#d)" + path d="M19.875 11.693a9.973 9.973 0 0 1-2.804 5.558c-1.517 1.534-3.41 2.508-5.5 2.833 0-.018-.017-.036-.017-.054v-.018c-.018-.036-.018-.054-.036-.108l-.054-.163a5.625 5.625 0 0 1-.196-.83v-.018c0-.036-.018-.072-.018-.108v-.018c0-.036-.018-.072-.018-.09v-.036c0-.036-.018-.072-.018-.127-.018-.09-.018-.18-.018-.288v-.127c0-.108-.017-.216-.017-.343 0-3.572 2.892-6.496 6.428-6.496H18.071c.09 0 .197.018.286.036.036 0 .09 0 .143.018.036 0 .071.018.09.018h.017c.036 0 .072 0 .107.018h.018c.286.055.554.127.84.217l.16.054c.036.018.054.018.09.036h.017c0 .018.018.036.036.036Z" + path d="M20 10.213h-.018c-.16-.054-.321-.09-.482-.144-.018 0-.036-.018-.071-.018a3.26 3.26 0 0 0-.447-.09h-.018c-.035 0-.089-.018-.125-.018h-.035c-.054 0-.108-.018-.143-.018-.054 0-.125-.018-.161-.018-.107-.018-.232-.018-.34-.036h-.589c-4.339 0-7.857 3.554-7.857 7.94 0 .126 0 .252.018.396v.09c0 .037 0 .073.018.109.018.108.018.235.036.343 0 .054.018.108.018.162 0 .054.017.108.017.145.018.072.018.126.036.18.054.343.143.686.25 1.029v.018a9.768 9.768 0 0 1-6.09-2.003V17.847c0-7.561 6.09-13.715 13.572-13.715H18.018c1.321 1.697 2 3.844 1.982 6.082Z" opacity=".6" + path d="M16.732 2.617c-.071 0-.16.018-.232.018-.125 0-.232.018-.357.036-.036 0-.09 0-.125.018-.036 0-.054 0-.09.018a7.867 7.867 0 0 0-.642.09c-.161.018-.304.054-.465.072-.089.018-.196.036-.285.054-.107.018-.215.054-.34.072-.035.019-.089.019-.125.037-.071.018-.16.036-.232.054-.035 0-.053.018-.089.018-.09.018-.179.036-.25.072-.036.018-.09.018-.125.036-.071.018-.125.036-.196.054-.054.018-.108.036-.143.054-.09.018-.161.054-.232.072-.018 0-.036.019-.054.019-.107.036-.214.072-.321.126-.125.036-.233.09-.34.126a.656.656 0 0 0-.196.09c-.072.018-.125.055-.197.073-.107.054-.232.09-.339.144-.196.09-.393.18-.59.289-.25.126-.481.252-.731.397-.072.054-.161.09-.232.144-.09.054-.179.108-.286.18-.09.055-.179.109-.25.163-.072.054-.16.108-.232.162l-.215.163c-.178.126-.339.252-.5.379-.071.054-.125.108-.196.162-.107.09-.232.199-.34.289-.089.072-.178.162-.267.234-.072.054-.125.127-.197.18-.214.217-.428.416-.642.65a1.79 1.79 0 0 0-.179.199c-.09.09-.16.18-.232.27-.107.109-.197.235-.286.343-.053.073-.107.127-.16.199-.126.162-.25.343-.376.505l-.16.217c-.054.072-.107.162-.161.234-.054.09-.107.163-.16.253-.054.09-.126.18-.18.289-.053.072-.089.162-.142.234-.143.235-.268.487-.393.722a9.036 9.036 0 0 0-.286.595c-.053.109-.107.217-.143.343-.017.054-.053.127-.071.199-.036.072-.054.144-.09.198-.053.109-.089.235-.124.343-.036.108-.072.217-.125.325 0 .018-.018.036-.018.054-.036.072-.054.163-.072.235-.017.054-.035.108-.053.144-.018.072-.036.127-.054.199-.018.036-.018.09-.035.126-.018.09-.054.18-.072.253 0 .018-.018.054-.018.09-.018.072-.035.162-.053.234 0 .037-.018.073-.036.127-.018.108-.054.216-.071.343a8.66 8.66 0 0 0-.054.288 4.253 4.253 0 0 0-.071.47c-.036.216-.054.432-.09.65 0 .035 0 .053-.018.09 0 .035 0 .072-.017.126-.018.126-.018.234-.036.36 0 .073-.018.163-.018.235C.946 15.068.035 12.722 0 10.232A10.1 10.1 0 0 1 2.75 3.14c.054-.072.125-.126.179-.18l.178-.181C4.982.992 7.43 0 10 0h.125a9.965 9.965 0 0 1 6.607 2.617Z" opacity=".3" + defs + clippath#a + path fill="#fff" d="M0 0h80v24H0z" + clippath#b + path fill="#fff" d="M27.5 1.263H80V24H27.5z" + clippath#c + path fill="#fff" d="M0 0h20v20.21H0z" + clippath#d + path fill="#fff" d="M0 0h20v20.21H0z" + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.created.greetings') + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.created.key_has_been_created', organization_name: @organization_name) + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.created.change_notice') + +div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.created.access_warning') + +div style='width: 100%;height: 1px;background-color: #D9DEE7;margin-bottom: 32px;' +div style="color: #66758F;font-style: normal;font-weight: 400;font-size: 14px;line-height: 20px;" + = I18n.t('email.api_key.created.email_info') diff --git a/app/views/api_key_mailer/rotated.slim b/app/views/api_key_mailer/rotated.slim index 85bbfca1dd4..8ee03dc5fe0 100644 --- a/app/views/api_key_mailer/rotated.slim +++ b/app/views/api_key_mailer/rotated.slim @@ -19,9 +19,12 @@ div style='margin-bottom: 32px;width: 80px;height: 24px;' path fill="#fff" d="M0 0h20v20.21H0z" div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' - = I18n.t('email.api_key.rotated.greetings', organization_name: @organization_name) + = I18n.t('email.api_key.rotated.greetings') -div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.rotated.key_has_been_rotated', organization_name: @organization_name) + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' = I18n.t('email.api_key.rotated.change_notice') div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' diff --git a/config/locales/en/email.yml b/config/locales/en/email.yml index 87eda3bf78e..c8d1decde7d 100644 --- a/config/locales/en/email.yml +++ b/config/locales/en/email.yml @@ -2,11 +2,19 @@ en: email: api_key: + created: + access_warning: However, if you did not authorize this change, please reset your password and delete the API key immediately to protect your account. + change_notice: If someone from your team initiated this, you can start using this API key to automate your billing process. + email_info: You're receiving this email because a new API key has been created in your Lago instance, and you have admin privileges. + greetings: Hello, + key_has_been_created: A new API key has been successfully created for your organization, %{organization_name}. + subject: A new Lago API key has been created rotated: access_warning: If you did not authorize this request, please reset your password and roll your API key immediately to secure your account. change_notice: If someone from your team initiated this change, no further action is required. To keep your billing running smoothly, update your app to reference the new token as soon as possible. email_info: You’re receiving this email because an API key has been rotated in your Lago instance, and you have admin privileges. - greetings: Your %{organization_name}'s API key has been rotated! + greetings: Hello, + key_has_been_rotated: Your %{organization_name}'s API key has been rotated! subject: Your Lago API key has been rolled credit_note: created: diff --git a/schema.graphql b/schema.graphql index 94e3aa01ea1..5932df70237 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1829,6 +1829,17 @@ input CreateAnrokIntegrationInput { name: String! } +""" +Autogenerated input type of CreateApiKey +""" +input CreateApiKeyInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + name: String +} + """ Autogenerated input type of CreateAppliedCoupon """ @@ -4717,6 +4728,16 @@ type Mutation { input: CreateAnrokIntegrationInput! ): AnrokIntegration + """ + Creates a new API key + """ + createApiKey( + """ + Parameters for CreateApiKey + """ + input: CreateApiKeyInput! + ): ApiKey + """ Assigns a Coupon to a Customer """ diff --git a/schema.json b/schema.json index ac0128f0650..5d46e18a5a5 100644 --- a/schema.json +++ b/schema.json @@ -6632,6 +6632,41 @@ ], "enumValues": null }, + { + "kind": "INPUT_OBJECT", + "name": "CreateApiKeyInput", + "description": "Autogenerated input type of CreateApiKey", + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "enumValues": null + }, { "kind": "INPUT_OBJECT", "name": "CreateAppliedCouponInput", @@ -24254,6 +24289,35 @@ } ] }, + { + "name": "createApiKey", + "description": "Creates a new API key", + "type": { + "kind": "OBJECT", + "name": "ApiKey", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + { + "name": "input", + "description": "Parameters for CreateApiKey", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateApiKeyInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "name": "createAppliedCoupon", "description": "Assigns a Coupon to a Customer", diff --git a/spec/graphql/mutations/api_keys/create_spec.rb b/spec/graphql/mutations/api_keys/create_spec.rb new file mode 100644 index 00000000000..1fd95ed868d --- /dev/null +++ b/spec/graphql/mutations/api_keys/create_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::ApiKeys::Create, type: :graphql do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {input: {name:}} + ) + end + + let(:query) do + <<-GQL + mutation($input: CreateApiKeyInput!) { + createApiKey(input: $input) { id name value } + } + GQL + end + + let(:required_permission) { 'developers:keys:manage' } + let!(:membership) { create(:membership) } + let(:name) { Faker::Lorem.word } + + it_behaves_like 'requires current user' + it_behaves_like 'requires current organization' + it_behaves_like 'requires permission', 'developers:keys:manage' + + context 'with premium organization' do + around { |test| lago_premium!(&test) } + + it 'creates a new API key' do + expect { result }.to change(ApiKey, :count).by(1) + end + + it 'returns created API key' do + api_key_response = result['data']['createApiKey'] + + expect(api_key_response['name']).to eq(name) + end + end + + context 'with free organization' do + it 'returns an error' do + expect_graphql_error(result:, message: 'feature_unavailable') + end + end +end diff --git a/spec/graphql/mutations/api_keys/rotate_spec.rb b/spec/graphql/mutations/api_keys/rotate_spec.rb index 9d7812e6ef0..3ab0bca43dd 100644 --- a/spec/graphql/mutations/api_keys/rotate_spec.rb +++ b/spec/graphql/mutations/api_keys/rotate_spec.rb @@ -25,6 +25,7 @@ let!(:membership) { create(:membership) } it_behaves_like 'requires current user' + it_behaves_like 'requires current organization' it_behaves_like 'requires permission', 'developers:keys:manage' context 'when api key with such ID exists in the current organization' do diff --git a/spec/mailers/api_key_mailer_spec.rb b/spec/mailers/api_key_mailer_spec.rb index 14783f53815..bccc4abee29 100644 --- a/spec/mailers/api_key_mailer_spec.rb +++ b/spec/mailers/api_key_mailer_spec.rb @@ -36,4 +36,38 @@ end end end + + describe '#created' do + let(:mail) { described_class.with(api_key:).created } + let(:api_key) { create(:api_key) } + let(:organization) { api_key.organization } + + before { create(:membership, organization:, role: :admin) } + + describe 'subject' do + subject { mail.subject } + + it { is_expected.to eq 'A new Lago API key has been created' } + end + + describe 'recipients' do + subject { mail.bcc } + + before { create(:membership, organization:, role: :manager) } + + specify do + expect(subject) + .to be_present + .and eq organization.admins.pluck(:email) + end + end + + describe 'body' do + subject { mail.body.to_s } + + it "includes organization's name" do + expect(subject).to include CGI.escapeHTML(organization.name) + end + end + end end diff --git a/spec/mailers/previews/api_key_mailer_preview.rb b/spec/mailers/previews/api_key_mailer_preview.rb index df538fd82cf..5cf2d17f3c2 100644 --- a/spec/mailers/previews/api_key_mailer_preview.rb +++ b/spec/mailers/previews/api_key_mailer_preview.rb @@ -5,4 +5,9 @@ def rotated api_key = FactoryBot.create(:api_key) ApiKeyMailer.with(api_key:).rotated end + + def created + api_key = FactoryBot.create(:api_key) + ApiKeyMailer.with(api_key:).created + end end diff --git a/spec/services/api_keys/create_service_spec.rb b/spec/services/api_keys/create_service_spec.rb new file mode 100644 index 00000000000..9ef0f4d5ba4 --- /dev/null +++ b/spec/services/api_keys/create_service_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ApiKeys::CreateService, type: :service do + describe '#call' do + subject(:service_result) { described_class.call(params) } + + let!(:params) do + { + organization_id: create(:organization).id, + name: Faker::Lorem.words.join(' ') + } + end + + context 'with premium organization' do + around { |test| lago_premium!(&test) } + + it 'creates a new API key' do + expect { service_result }.to change(ApiKey, :count).by(1) + end + + it 'sends an API key created email' do + expect { service_result } + .to have_enqueued_mail(ApiKeyMailer, :created) + .with(hash_including(params: {api_key: instance_of(ApiKey)})) + end + end + + context 'with free organization' do + it 'does not create an API key' do + expect { service_result }.not_to change(ApiKey, :count) + end + + it 'returns an error' do + aggregate_failures do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ForbiddenFailure) + end + end + end + end +end