feat: add tools to manage user API tokens
This commit is contained in:
parent
fcfa47af4a
commit
72565cc49c
3 changed files with 372 additions and 0 deletions
126
src/wuttaweb/templates/users/view.mako
Normal file
126
src/wuttaweb/templates/users/view.mako
Normal file
|
@ -0,0 +1,126 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/master/view.mako" />
|
||||
|
||||
<%def name="page_content()">
|
||||
${parent.page_content()}
|
||||
|
||||
% if master.has_perm('manage_api_tokens'):
|
||||
<b-modal :active.sync="newTokenShowDialog"
|
||||
has-modal-card>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">
|
||||
New API Token
|
||||
</p>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
|
||||
<div v-if="!newTokenSaved">
|
||||
<b-field label="Description"
|
||||
:type="{'is-danger': !newTokenDescription}">
|
||||
<b-input v-model.trim="newTokenDescription"
|
||||
expanded
|
||||
ref="newTokenDescription">
|
||||
</b-input>
|
||||
</b-field>
|
||||
</div>
|
||||
|
||||
<div v-if="newTokenSaved">
|
||||
<p class="block">
|
||||
Your new API token is shown below.
|
||||
</p>
|
||||
<p class="block">
|
||||
IMPORTANT: You must record this token elsewhere
|
||||
for later reference. You will NOT be able to
|
||||
recover the value if you lose it.
|
||||
</p>
|
||||
<b-field horizontal label="API Token">
|
||||
{{ newTokenRaw }}
|
||||
</b-field>
|
||||
<b-field horizontal label="Description">
|
||||
{{ newTokenDescription }}
|
||||
</b-field>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<b-button @click="newTokenShowDialog = false">
|
||||
{{ newTokenSaved ? "Close" : "Cancel" }}
|
||||
</b-button>
|
||||
<b-button v-if="!newTokenSaved"
|
||||
type="is-primary"
|
||||
icon-pack="fas"
|
||||
icon-left="save"
|
||||
@click="newTokenSave()"
|
||||
:disabled="!newTokenDescription || newTokenSaving">
|
||||
{{ newTokenSaving ? "Working, please wait..." : "Save" }}
|
||||
</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</b-modal>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="render_form_tag()">
|
||||
% if master.has_perm('manage_api_tokens'):
|
||||
${form.render_vue_tag(**{'@new-token': 'newTokenInit', '@delete-token': 'deleteTokenInit'})}
|
||||
% else:
|
||||
${form.render_vue_tag()}
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="modify_vue_vars()">
|
||||
${parent.modify_vue_vars()}
|
||||
<script>
|
||||
% if master.has_perm('manage_api_tokens'):
|
||||
|
||||
ThisPageData.newTokenShowDialog = false
|
||||
ThisPageData.newTokenDescription = null
|
||||
ThisPageData.newTokenRaw = null
|
||||
ThisPageData.newTokenSaved = false
|
||||
ThisPageData.newTokenSaving = false
|
||||
|
||||
ThisPage.methods.newTokenInit = function() {
|
||||
this.newTokenDescription = null
|
||||
this.newTokenRaw = null
|
||||
this.newTokenSaved = false
|
||||
this.newTokenShowDialog = true
|
||||
this.$nextTick(() => {
|
||||
this.$refs.newTokenDescription.focus()
|
||||
})
|
||||
}
|
||||
|
||||
ThisPage.methods.newTokenSave = function() {
|
||||
this.newTokenSaving = true
|
||||
|
||||
const url = '${master.get_action_url('add_api_token', instance)}'
|
||||
const params = {
|
||||
description: this.newTokenDescription,
|
||||
}
|
||||
|
||||
this.wuttaPOST(url, params, response => {
|
||||
this.newTokenSaving = false
|
||||
this.newTokenRaw = response.data.token_string
|
||||
${form.vue_component}Data.gridContext['users.view.api_tokens'].data.push(response.data)
|
||||
this.newTokenSaved = true
|
||||
}, response => {
|
||||
this.newTokenSaving = false
|
||||
})
|
||||
}
|
||||
|
||||
ThisPage.methods.deleteTokenInit = function(token) {
|
||||
if (!confirm("Really delete this API token?")) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = '${master.get_action_url('delete_api_token', instance)}'
|
||||
const params = {uuid: token.uuid}
|
||||
this.wuttaPOST(url, params, response => {
|
||||
const i = ${form.vue_component}Data.gridContext['users.view.api_tokens'].data.indexOf(token)
|
||||
${form.vue_component}Data.gridContext['users.view.api_tokens'].data.splice(i, 1)
|
||||
})
|
||||
}
|
||||
|
||||
% endif
|
||||
</script>
|
||||
</%def>
|
|
@ -48,6 +48,10 @@ class UserView(MasterView):
|
|||
"""
|
||||
model_class = User
|
||||
|
||||
labels = {
|
||||
'api_tokens': "API Tokens",
|
||||
}
|
||||
|
||||
grid_columns = [
|
||||
'username',
|
||||
'person',
|
||||
|
@ -66,6 +70,7 @@ class UserView(MasterView):
|
|||
'active',
|
||||
'prevent_edit',
|
||||
'roles',
|
||||
'api_tokens',
|
||||
]
|
||||
|
||||
def get_query(self, session=None):
|
||||
|
@ -151,6 +156,12 @@ class UserView(MasterView):
|
|||
if not self.creating:
|
||||
f.set_default('roles', [role.uuid.hex for role in user.roles])
|
||||
|
||||
# api_tokens
|
||||
if self.viewing and self.has_perm('manage_api_tokens'):
|
||||
f.set_grid('api_tokens', self.make_api_tokens_grid(user))
|
||||
else:
|
||||
f.remove('api_tokens')
|
||||
|
||||
def unique_username(self, node, value):
|
||||
""" """
|
||||
model = self.app.model
|
||||
|
@ -251,6 +262,93 @@ class UserView(MasterView):
|
|||
role = session.get(model.Role, uuid)
|
||||
user.roles.remove(role)
|
||||
|
||||
def make_api_tokens_grid(self, user):
|
||||
"""
|
||||
Make and return the grid for the API Tokens field.
|
||||
|
||||
This is only shown when current user has permission to manage
|
||||
API tokens for other users.
|
||||
|
||||
:rtype: :class:`~wuttaweb.grids.base.Grid`
|
||||
"""
|
||||
model = self.app.model
|
||||
route_prefix = self.get_route_prefix()
|
||||
|
||||
grid = self.make_grid(key=f'{route_prefix}.view.api_tokens',
|
||||
data=[self.normalize_api_token(t) for t in user.api_tokens],
|
||||
columns=[
|
||||
'description',
|
||||
'created',
|
||||
],
|
||||
sortable=True,
|
||||
sort_on_backend=False,
|
||||
sort_defaults=[('created', 'desc')])
|
||||
|
||||
if self.has_perm('manage_api_tokens'):
|
||||
|
||||
# create token
|
||||
button = self.make_button("New", primary=True, icon_left='plus', **{'@click': "$emit('new-token')"})
|
||||
grid.add_tool(button, key='create')
|
||||
|
||||
# delete token
|
||||
grid.add_action('delete', url='#', icon='trash', link_class='has-text-danger', click_handler="$emit('delete-token', props.row)")
|
||||
|
||||
return grid
|
||||
|
||||
def normalize_api_token(self, token):
|
||||
""" """
|
||||
return {
|
||||
'uuid': token.uuid.hex,
|
||||
'description': token.description,
|
||||
'created': self.app.render_datetime(token.created),
|
||||
}
|
||||
|
||||
def add_api_token(self):
|
||||
"""
|
||||
AJAX view for adding a new user API token.
|
||||
|
||||
This calls
|
||||
:meth:`wuttjamaican:wuttjamaican.auth.AuthHandler.add_api_token()`
|
||||
for the creation logic.
|
||||
"""
|
||||
session = self.Session()
|
||||
auth = self.app.get_auth_handler()
|
||||
user = self.get_instance()
|
||||
data = self.request.json_body
|
||||
|
||||
token = auth.add_api_token(user, data['description'])
|
||||
session.flush()
|
||||
session.refresh(token)
|
||||
|
||||
result = self.normalize_api_token(token)
|
||||
result['token_string'] = token.token_string
|
||||
result['_action_url_delete'] = '#'
|
||||
return result
|
||||
|
||||
def delete_api_token(self):
|
||||
"""
|
||||
AJAX view for deleting a user API token.
|
||||
|
||||
This calls
|
||||
:meth:`wuttjamaican:wuttjamaican.auth.AuthHandler.delete_api_token()`
|
||||
for the deletion logic.
|
||||
"""
|
||||
model = self.app.model
|
||||
session = self.Session()
|
||||
auth = self.app.get_auth_handler()
|
||||
user = self.get_instance()
|
||||
data = self.request.json_body
|
||||
|
||||
token = session.get(model.UserAPIToken, data['uuid'])
|
||||
if not token:
|
||||
return {'error': "API token not found"}
|
||||
|
||||
if token.user is not user:
|
||||
return {'error': "API token not found"}
|
||||
|
||||
auth.delete_api_token(token)
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
""" """
|
||||
|
@ -260,8 +358,38 @@ class UserView(MasterView):
|
|||
app = wutta_config.get_app()
|
||||
cls.model_class = app.model.User
|
||||
|
||||
cls._user_defaults(config)
|
||||
cls._defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _user_defaults(cls, config):
|
||||
"""
|
||||
Provide extra default configuration for the User master view.
|
||||
"""
|
||||
route_prefix = cls.get_route_prefix()
|
||||
permission_prefix = cls.get_permission_prefix()
|
||||
instance_url_prefix = cls.get_instance_url_prefix()
|
||||
model_title = cls.get_model_title()
|
||||
|
||||
# manage API tokens
|
||||
config.add_wutta_permission(permission_prefix,
|
||||
f'{permission_prefix}.manage_api_tokens',
|
||||
f"Manage API tokens for any {model_title}")
|
||||
config.add_route(f'{route_prefix}.add_api_token',
|
||||
f'{instance_url_prefix}/add-api-token',
|
||||
request_method='POST')
|
||||
config.add_view(cls, attr='add_api_token',
|
||||
route_name=f'{route_prefix}.add_api_token',
|
||||
permission=f'{permission_prefix}.manage_api_tokens',
|
||||
renderer='json')
|
||||
config.add_route(f'{route_prefix}.delete_api_token',
|
||||
f'{instance_url_prefix}/delete-api-token',
|
||||
request_method='POST')
|
||||
config.add_view(cls, attr='delete_api_token',
|
||||
route_name=f'{route_prefix}.delete_api_token',
|
||||
permission=f'{permission_prefix}.manage_api_tokens',
|
||||
renderer='json')
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
|
|
@ -6,6 +6,7 @@ from sqlalchemy import orm
|
|||
|
||||
import colander
|
||||
|
||||
from wuttaweb.grids import Grid
|
||||
from wuttaweb.views import users as mod
|
||||
from wuttaweb.testing import WebTestCase
|
||||
|
||||
|
@ -122,6 +123,18 @@ class TestUserView(WebTestCase):
|
|||
view.configure_form(form)
|
||||
self.assertNotIn('password', form)
|
||||
|
||||
# api tokens grid shown only if current user has perm
|
||||
with patch.object(view, 'viewing', new=True):
|
||||
form = view.make_form(model_instance=barney)
|
||||
self.assertIn('api_tokens', form)
|
||||
view.configure_form(form)
|
||||
self.assertNotIn('api_tokens', form)
|
||||
with patch.object(self.request, 'is_root', new=True):
|
||||
form = view.make_form(model_instance=barney)
|
||||
self.assertIn('api_tokens', form)
|
||||
view.configure_form(form)
|
||||
self.assertIn('api_tokens', form)
|
||||
|
||||
def test_unique_username(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
|
@ -317,3 +330,108 @@ class TestUserView(WebTestCase):
|
|||
user = view.objectify(form)
|
||||
self.assertIs(user, barney)
|
||||
self.assertEqual(len(user.roles), 2)
|
||||
|
||||
def test_normalize_api_token(self):
|
||||
model = self.app.model
|
||||
auth = self.app.get_auth_handler()
|
||||
view = self.make_view()
|
||||
|
||||
user = model.User(username='foo')
|
||||
self.session.add(user)
|
||||
token = auth.add_api_token(user, 'test token')
|
||||
self.session.commit()
|
||||
|
||||
normal = view.normalize_api_token(token)
|
||||
self.assertIn('uuid', normal)
|
||||
self.assertEqual(normal['uuid'], token.uuid.hex)
|
||||
self.assertIn('description', normal)
|
||||
self.assertEqual(normal['description'], 'test token')
|
||||
self.assertIn('created', normal)
|
||||
|
||||
def test_make_api_tokens_grid(self):
|
||||
model = self.app.model
|
||||
auth = self.app.get_auth_handler()
|
||||
view = self.make_view()
|
||||
|
||||
user = model.User(username='foo')
|
||||
self.session.add(user)
|
||||
token1 = auth.add_api_token(user, 'test1')
|
||||
token2 = auth.add_api_token(user, 'test2')
|
||||
self.session.commit()
|
||||
|
||||
# grid should have 2 records but no tools/actions
|
||||
grid = view.make_api_tokens_grid(user)
|
||||
self.assertIsInstance(grid, Grid)
|
||||
self.assertEqual(len(grid.data), 2)
|
||||
self.assertEqual(len(grid.tools), 0)
|
||||
self.assertEqual(len(grid.actions), 0)
|
||||
|
||||
# create + delete allowed
|
||||
with patch.object(self.request, 'is_root', new=True):
|
||||
grid = view.make_api_tokens_grid(user)
|
||||
self.assertEqual(len(grid.tools), 1)
|
||||
self.assertIn('create', grid.tools)
|
||||
self.assertEqual(len(grid.actions), 1)
|
||||
self.assertEqual(grid.actions[0].key, 'delete')
|
||||
|
||||
def test_add_api_token(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
|
||||
user = model.User(username='foo')
|
||||
self.session.add(user)
|
||||
self.session.commit()
|
||||
self.session.refresh(user)
|
||||
self.assertEqual(len(user.api_tokens), 0)
|
||||
|
||||
with patch.object(view, 'Session', return_value=self.session):
|
||||
with patch.object(self.request, 'matchdict', new={'uuid': user.uuid}):
|
||||
with patch.object(self.request, 'json_body', create=True,
|
||||
new={'description': 'testing'}):
|
||||
result = view.add_api_token()
|
||||
self.assertEqual(len(user.api_tokens), 1)
|
||||
token = user.api_tokens[0]
|
||||
self.assertEqual(result['token_string'], token.token_string)
|
||||
self.assertEqual(result['description'], 'testing')
|
||||
|
||||
def test_delete_api_token(self):
|
||||
model = self.app.model
|
||||
auth = self.app.get_auth_handler()
|
||||
view = self.make_view()
|
||||
|
||||
user = model.User(username='foo')
|
||||
self.session.add(user)
|
||||
token1 = auth.add_api_token(user, 'test1')
|
||||
token2 = auth.add_api_token(user, 'test2')
|
||||
self.session.commit()
|
||||
self.session.refresh(user)
|
||||
self.assertEqual(len(user.api_tokens), 2)
|
||||
|
||||
with patch.object(view, 'Session', return_value=self.session):
|
||||
with patch.object(self.request, 'matchdict', new={'uuid': user.uuid}):
|
||||
|
||||
# normal behavior
|
||||
with patch.object(self.request, 'json_body', create=True,
|
||||
new={'uuid': token1.uuid.hex}):
|
||||
result = view.delete_api_token()
|
||||
self.assertEqual(result, {})
|
||||
self.session.refresh(user)
|
||||
self.assertEqual(len(user.api_tokens), 1)
|
||||
token = user.api_tokens[0]
|
||||
self.assertIs(token, token2)
|
||||
|
||||
# token for wrong user
|
||||
user2 = model.User(username='bar')
|
||||
self.session.add(user2)
|
||||
token3 = auth.add_api_token(user2, 'test3')
|
||||
self.session.commit()
|
||||
with patch.object(self.request, 'json_body', create=True,
|
||||
new={'uuid': token3.uuid.hex}):
|
||||
result = view.delete_api_token()
|
||||
self.assertEqual(result, {'error': "API token not found"})
|
||||
|
||||
# token not found
|
||||
with patch.object(self.request, 'json_body', create=True,
|
||||
new={'uuid': self.app.make_true_uuid().hex}):
|
||||
result = view.delete_api_token()
|
||||
self.assertEqual(result, {'error': "API token not found"})
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue