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
|
model_class = User
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
'api_tokens': "API Tokens",
|
||||||
|
}
|
||||||
|
|
||||||
grid_columns = [
|
grid_columns = [
|
||||||
'username',
|
'username',
|
||||||
'person',
|
'person',
|
||||||
|
@ -66,6 +70,7 @@ class UserView(MasterView):
|
||||||
'active',
|
'active',
|
||||||
'prevent_edit',
|
'prevent_edit',
|
||||||
'roles',
|
'roles',
|
||||||
|
'api_tokens',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_query(self, session=None):
|
def get_query(self, session=None):
|
||||||
|
@ -151,6 +156,12 @@ class UserView(MasterView):
|
||||||
if not self.creating:
|
if not self.creating:
|
||||||
f.set_default('roles', [role.uuid.hex for role in user.roles])
|
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):
|
def unique_username(self, node, value):
|
||||||
""" """
|
""" """
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
@ -251,6 +262,93 @@ class UserView(MasterView):
|
||||||
role = session.get(model.Role, uuid)
|
role = session.get(model.Role, uuid)
|
||||||
user.roles.remove(role)
|
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
|
@classmethod
|
||||||
def defaults(cls, config):
|
def defaults(cls, config):
|
||||||
""" """
|
""" """
|
||||||
|
@ -260,8 +358,38 @@ class UserView(MasterView):
|
||||||
app = wutta_config.get_app()
|
app = wutta_config.get_app()
|
||||||
cls.model_class = app.model.User
|
cls.model_class = app.model.User
|
||||||
|
|
||||||
|
cls._user_defaults(config)
|
||||||
cls._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):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -6,6 +6,7 @@ from sqlalchemy import orm
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
|
|
||||||
|
from wuttaweb.grids import Grid
|
||||||
from wuttaweb.views import users as mod
|
from wuttaweb.views import users as mod
|
||||||
from wuttaweb.testing import WebTestCase
|
from wuttaweb.testing import WebTestCase
|
||||||
|
|
||||||
|
@ -122,6 +123,18 @@ class TestUserView(WebTestCase):
|
||||||
view.configure_form(form)
|
view.configure_form(form)
|
||||||
self.assertNotIn('password', 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):
|
def test_unique_username(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
|
@ -317,3 +330,108 @@ class TestUserView(WebTestCase):
|
||||||
user = view.objectify(form)
|
user = view.objectify(form)
|
||||||
self.assertIs(user, barney)
|
self.assertIs(user, barney)
|
||||||
self.assertEqual(len(user.roles), 2)
|
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