3
0
Fork 0

feat: add tools to manage user API tokens

This commit is contained in:
Lance Edgar 2025-08-09 09:56:00 -05:00
parent fcfa47af4a
commit 72565cc49c
3 changed files with 372 additions and 0 deletions

View 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:&nbsp; You must record this token elsewhere
for later reference.&nbsp; 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>

View file

@ -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()

View file

@ -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"})