Skip to content

Commit

Permalink
added creating/removing API access tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
kortirso committed Jul 3, 2024
1 parent dc962a3 commit 082c9b3
Show file tree
Hide file tree
Showing 13 changed files with 218 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Added
- co-owners for accounts
- expiration time for access tokens
- creating/removing API access tokens

### Modified
- skip reseting invites email after accepting invite
Expand Down
30 changes: 30 additions & 0 deletions app/controllers/api/frontend/api_access_tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

module Api
module Frontend
class ApiAccessTokensController < Api::Frontend::BaseController
include Deps[create_form: 'forms.api_access_tokens.create']

before_action :find_api_access_token, only: %i[destroy]

def create
case create_form.call(user: current_user)
in { errors: errors } then render json: { errors: errors }, status: :ok
in { result: result }
render json: { result: ApiAccessTokenSerializer.new(result).serializable_hash }, status: :ok
end
end

def destroy
@api_access_token.destroy
render json: { result: :ok }, status: :ok
end

private

def find_api_access_token
@api_access_token = current_user.api_access_tokens.find_by!(uuid: params[:id])
end
end
end
end
5 changes: 5 additions & 0 deletions app/controllers/profiles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class ProfilesController < ApplicationController
to_bool: 'to_bool'
]

before_action :find_api_access_tokens, only: %i[show]
before_action :find_invites, only: %i[show]
before_action :find_used_trial_subscription, only: %i[show]
before_action :find_end_time, only: %i[show]
Expand All @@ -34,6 +35,10 @@ def destroy

private

def find_api_access_tokens
@api_access_tokens = current_user.api_access_tokens.hashable_pluck(:uuid, :value)
end

def find_invites
@accepted_invites = current_user.invites.accepted.hashable_pluck(:uuid, :email, :access)
@invites = current_user.invites.waiting.hashable_pluck(:uuid, :email, :access)
Expand Down
9 changes: 9 additions & 0 deletions app/forms/api_access_tokens/create_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module ApiAccessTokens
class CreateForm
def call(user:)
{ result: user.api_access_tokens.create!(value: SecureRandom.hex) }
end
end
end
70 changes: 70 additions & 0 deletions app/javascript/components/Profile/ProfileConfiguration.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ export const ProfileConfiguration = ({
vacations,
acceptedInvites,
invites,
apiAccessTokens,
}) => {
const [pageState, setPageState] = useState({
vacationFormIsOpen: false,
inviteFormIsOpen: false,
vacations: vacations.data.map((item) => item.attributes),
acceptedInvites: acceptedInvites,
invites: invites,
apiAccessTokens: apiAccessTokens,
startTime: '',
inviteEmail: '',
inviteAccess: 'read',
Expand Down Expand Up @@ -177,10 +179,78 @@ export const ProfileConfiguration = ({
)
};

const onCreateApiAccessToken = async () => {
const result = await apiRequest({
url: '/api/frontend/api_access_tokens.json',
options: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
},
},
});
if (result.errors) setPageState({ ...pageState, errors: result.errors })
else setPageState({
...pageState,
apiAccessTokens: pageState.apiAccessTokens.concat(result.result.data.attributes),
errors: []
})
};

const onApiAccessTokenRemove = async (id) => {
const result = await apiRequest({
url: `/api/frontend/api_access_tokens/${id}.json`,
options: {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
}
},
});
if (result.errors) setPageState({ ...pageState, errors: result.errors })
else setPageState({
...pageState,
apiAccessTokens: pageState.apiAccessTokens.filter((item) => item.uuid !== id),
errors: []
})
};

const renderApiAccessTokensList = () => {
if (pageState.apiAccessTokens.length === 0) return <p>You didn't specify any API access tokens yet.</p>;

return (
<div className="zebra-list">
{pageState.apiAccessTokens.map((apiAccessToken) => (
<div className="zebra-list-element" key={apiAccessToken.uuid}>
<p>{apiAccessToken.value}</p>
<p
className="btn-danger btn-xs"
onClick={() => onApiAccessTokenRemove(apiAccessToken.uuid)}
>X</p>
</div>
))}
</div>
)
};

return (
<>
<Dropdown convertChildren={false} title="Privacy">
<div className="py-6 px-8">
<div className="grid lg:grid-cols-2 gap-8 mb-8">
<div>
{renderApiAccessTokensList()}
<p
className="btn-primary btn-small mt-4"
onClick={() => onCreateApiAccessToken()}
>Create API access token</p>
</div>
<div>
<p>In this block you can create access tokens for PullKeeper's API.</p>
</div>
</div>
<div className="grid lg:grid-cols-2 gap-8 mb-8">
<div>
{renderAcceptedInvitesList()}
Expand Down
2 changes: 2 additions & 0 deletions app/models/api_access_token.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

class ApiAccessToken < ApplicationRecord
include Uuidable

encrypts :value, deterministic: true

belongs_to :user
Expand Down
5 changes: 5 additions & 0 deletions app/serializers/api_access_token_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class ApiAccessTokenSerializer < ApplicationSerializer
attributes :uuid, :value
end
3 changes: 2 additions & 1 deletion app/views/controllers/profiles/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
deleteHtml: render(partial: 'delete').html_safe,
vacations: @vacations,
acceptedInvites: @accepted_invites,
invites: @invites
invites: @invites,
apiAccessTokens: @api_access_tokens
%>
<script src="https://api.cryptocloud.plus/static/pay_btn/js/app.js"></script>
<% end %>
1 change: 1 addition & 0 deletions config/initializers/container.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def register(key)
register('forms.subscribers.create') { Subscribers::CreateForm.new }
register('forms.users.update') { Users::UpdateForm.new }
register('forms.companies.configurations.update') { Companies::Configurations::UpdateForm.new }
register('forms.api_access_tokens.create') { ApiAccessTokens::CreateForm.new }

# notifiers reports
register('notifiers.payloads.company') { Payloads::Company.new }
Expand Down
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@
resources :users, only: %i[destroy]
end
resources :invites, only: %i[destroy]
resources :api_access_tokens, only: %i[create destroy]
end

namespace :v1 do
resources :companies, only: %i[index]
end
Expand Down
16 changes: 16 additions & 0 deletions db/migrate/20240703185841_add_uuid_to_api_access_tokens.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class AddUuidToApiAccessTokens < ActiveRecord::Migration[7.1]
def up
safety_assured do
add_column :api_access_tokens, :uuid, :uuid
add_index :api_access_tokens, :uuid

ApiAccessToken.find_each { |api_access_token| api_access_token.update(uuid: SecureRandom.uuid) }

change_column_null :api_access_tokens, :uuid, false
end
end

def down
remove_column :api_access_tokens, :uuid
end
end
11 changes: 10 additions & 1 deletion db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,8 @@ CREATE TABLE public.api_access_tokens (
user_id bigint NOT NULL,
value text NOT NULL,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL
updated_at timestamp(6) without time zone NOT NULL,
uuid uuid NOT NULL
);


Expand Down Expand Up @@ -2001,6 +2002,13 @@ CREATE UNIQUE INDEX index_access_tokens_on_uuid ON public.access_tokens USING bt
CREATE INDEX index_api_access_tokens_on_user_id ON public.api_access_tokens USING btree (user_id);


--
-- Name: index_api_access_tokens_on_uuid; Type: INDEX; Schema: public; Owner: -
--

CREATE INDEX index_api_access_tokens_on_uuid ON public.api_access_tokens USING btree (uuid);


--
-- Name: index_api_access_tokens_on_value; Type: INDEX; Schema: public; Owner: -
--
Expand Down Expand Up @@ -2438,6 +2446,7 @@ ALTER TABLE ONLY public.kudos_achievements
SET search_path TO "$user", public;

INSERT INTO "schema_migrations" (version) VALUES
('20240703185841'),
('20240702074229'),
('20240701132738'),
('20240624073726'),
Expand Down
65 changes: 65 additions & 0 deletions spec/controllers/api/frontend/api_access_tokens_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

describe Api::Frontend::ApiAccessTokensController do
let!(:user) { create :user }
let(:access_token) { Auth::GenerateTokenService.new.call(user: user)[:result] }

describe 'POST#create' do
it_behaves_like 'required frontend auth'

context 'for logged users' do
let(:request) { post :create, params: { auth_token: access_token } }

it 'creates api access token', :aggregate_failures do
expect { request }.to change(user.api_access_tokens, :count).by(1)
expect(response).to have_http_status :ok
expect(response.parsed_body['errors']).to be_nil
end
end

def do_request
post :create, params: {}
end
end

describe 'DELETE#destroy' do
it_behaves_like 'required frontend auth'

context 'for logged users' do
let!(:api_access_token) { create :api_access_token }

context 'for unexisting api access token' do
let(:request) { delete :destroy, params: { id: 'unexisting', auth_token: access_token } }

it 'does not destroy api access token', :aggregate_failures do
expect { request }.not_to change(ApiAccessToken, :count)
expect(response).to have_http_status :not_found
end
end

context 'for not own api access token' do
let(:request) { delete :destroy, params: { id: api_access_token.uuid, auth_token: access_token } }

it 'does not destroy api access token', :aggregate_failures do
expect { request }.not_to change(ApiAccessToken, :count)
expect(response).to have_http_status :not_found
end
end

context 'for own api access token' do
let(:request) { delete :destroy, params: { id: api_access_token.uuid, auth_token: access_token } }

before { api_access_token.update!(user: user) }

it 'destroys api access token', :aggregate_failures do
expect { request }.to change(ApiAccessToken, :count).by(-1)
expect(response).to have_http_status :ok
end
end
end

def do_request
delete :destroy, params: { id: 'unexisting' }
end
end
end

0 comments on commit 082c9b3

Please sign in to comment.