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

View file

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