Skip to content

Commit 33d1ecc

Browse files
pollock83Tim020claude
authored
Add support for API Tokens for REST API interactions after JWT change (#738)
* Adding support for API Tokens for REST API interactions after the move to JWT auth Since the move to JWT, the tokens regenerate so you cannot simply nab the cookie content anymore to use via an external application interacting with the REST API. Adding functionality to add an API Token to a user's account to authenticate in place of JWTs. * Fix formatting and database migration missing index * test: fix database isolation in test teardown Added database engine disposal to DigiScriptTestCase.tearDown() to ensure proper test isolation. Previously, in-memory SQLite database connections persisted across test methods, causing data leakage that resulted in 2 test failures when tests were run together: - test_create_admin failed due to username conflicts from previous tests - test_api_token_get failed because user already had API token from previous test execution The fix ensures each test method gets a fresh database instance by calling models.db.engine.dispose() in tearDown, forcing the creation of a new engine for the next test. Test results improved from 11 passed/2 failed to 31 passed/0 failed across all test suites. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Tim Bradgate <timbradgate@hotmail.co.uk> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f7ffb7 commit 33d1ecc

File tree

11 files changed

+637
-9
lines changed

11 files changed

+637
-9
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
**/.DS_Store
22
.claude
3+
CLAUDE.md
34

45
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
56
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839

client/src/store/modules/user/user.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,49 @@ export default {
207207

208208
await context.commit('SET_TOKEN_REFRESH_INTERVAL', refreshInterval);
209209
},
210+
async GENERATE_API_TOKEN(context) {
211+
const response = await fetch(makeURL('/api/v1/auth/api-token/generate'), {
212+
method: 'POST',
213+
headers: { 'Content-Type': 'application/json' },
214+
body: JSON.stringify({}),
215+
});
216+
if (response.ok) {
217+
const data = await response.json();
218+
Vue.$toast.success('API token generated successfully!');
219+
return data;
220+
}
221+
const responseBody = await response.json();
222+
log.error('Unable to generate API token');
223+
Vue.$toast.error(`Unable to generate API token: ${responseBody.message || 'Unknown error'}`);
224+
return null;
225+
},
226+
async REVOKE_API_TOKEN(context) {
227+
const response = await fetch(makeURL('/api/v1/auth/api-token/revoke'), {
228+
method: 'POST',
229+
headers: { 'Content-Type': 'application/json' },
230+
body: JSON.stringify({}),
231+
});
232+
if (response.ok) {
233+
Vue.$toast.success('API token revoked successfully!');
234+
return true;
235+
}
236+
const responseBody = await response.json();
237+
log.error('Unable to revoke API token');
238+
Vue.$toast.error(`Unable to revoke API token: ${responseBody.message || 'Unknown error'}`);
239+
return false;
240+
},
241+
async GET_API_TOKEN(context) {
242+
const response = await fetch(makeURL('/api/v1/auth/api-token'), {
243+
method: 'GET',
244+
});
245+
if (response.ok) {
246+
const data = await response.json();
247+
return data;
248+
}
249+
log.error('Unable to get API token');
250+
Vue.$toast.error('Unable to get API token!');
251+
return null;
252+
},
210253
},
211254
getters: {
212255
CURRENT_USER(state) {

client/src/views/user/Settings.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
>
2323
<stage-direction-styles />
2424
</b-tab>
25+
<b-tab title="API Token">
26+
<api-token />
27+
</b-tab>
2528
</b-tabs>
2629
</b-container>
2730
</template>
@@ -30,9 +33,12 @@
3033
import StageDirectionStyles from '@/vue_components/user/settings/StageDirectionStyles.vue';
3134
import AboutUser from '@/vue_components/user/settings/AboutUser.vue';
3235
import UserSettingsConfig from '@/vue_components/user/settings/Settings.vue';
36+
import ApiToken from '@/vue_components/user/settings/ApiToken.vue';
3337
3438
export default {
3539
name: 'UserSettings',
36-
components: { UserSettingsConfig, AboutUser, StageDirectionStyles },
40+
components: {
41+
UserSettingsConfig, AboutUser, StageDirectionStyles, ApiToken,
42+
},
3743
};
3844
</script>
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
<template>
2+
<b-container fluid>
3+
<b-row>
4+
<b-col>
5+
<h3>API Token Management</h3>
6+
<p class="text-muted">
7+
Generate a static API token for authenticating external applications and scripts
8+
to the DigiScript REST API. This token does not expire and can be used with the
9+
<code>X-API-Key</code> header.
10+
</p>
11+
</b-col>
12+
</b-row>
13+
14+
<b-row class="mt-3">
15+
<b-col>
16+
<b-card>
17+
<template v-if="!hasToken">
18+
<b-card-text>
19+
<b-alert
20+
variant="info"
21+
show
22+
>
23+
You do not have an API token. Generate one to access the DigiScript API
24+
from external applications.
25+
</b-alert>
26+
</b-card-text>
27+
<b-button
28+
variant="primary"
29+
:disabled="loading"
30+
@click="generateToken"
31+
>
32+
<b-spinner
33+
v-if="loading"
34+
small
35+
/>
36+
Generate API Token
37+
</b-button>
38+
</template>
39+
40+
<template v-else>
41+
<b-card-text>
42+
<b-alert
43+
variant="success"
44+
show
45+
>
46+
<strong>API Token Active</strong>
47+
<p class="mb-0 mt-2">
48+
Your API token is active. Keep this token secure and do not share it publicly.
49+
</p>
50+
</b-alert>
51+
52+
<div v-if="newlyGeneratedToken">
53+
<label for="api-token-display"><strong>Your New API Token:</strong></label>
54+
<b-input-group id="api-token-display">
55+
<b-form-input
56+
:value="newlyGeneratedToken"
57+
readonly
58+
type="text"
59+
/>
60+
<b-input-group-append>
61+
<b-button
62+
variant="outline-secondary"
63+
@click="copyToken"
64+
>
65+
<b-icon-clipboard />
66+
Copy
67+
</b-button>
68+
</b-input-group-append>
69+
</b-input-group>
70+
<b-form-text class="text-warning">
71+
<strong>IMPORTANT:</strong> This token will only be shown once.
72+
Save it securely now - you will not be able to retrieve it again!
73+
</b-form-text>
74+
</div>
75+
</b-card-text>
76+
77+
<b-card-text class="mt-3">
78+
<h5>Usage Example:</h5>
79+
<pre class="bg-light p-3 rounded"><code>curl -H "X-API-Key: YOUR_TOKEN_HERE" {{ apiBaseUrl }}/api/v1/auth</code></pre>
80+
</b-card-text>
81+
82+
<b-button
83+
variant="warning"
84+
:disabled="loading"
85+
@click="showRegenerateConfirm = true"
86+
>
87+
<b-spinner
88+
v-if="loading"
89+
small
90+
/>
91+
Regenerate Token
92+
</b-button>
93+
94+
<b-button
95+
variant="danger"
96+
class="ml-2"
97+
:disabled="loading"
98+
@click="showRevokeConfirm = true"
99+
>
100+
<b-spinner
101+
v-if="loading"
102+
small
103+
/>
104+
Revoke Token
105+
</b-button>
106+
</template>
107+
</b-card>
108+
</b-col>
109+
</b-row>
110+
111+
<!-- Regenerate Confirmation Modal -->
112+
<b-modal
113+
v-model="showRegenerateConfirm"
114+
title="Regenerate API Token"
115+
ok-variant="warning"
116+
ok-title="Regenerate Token"
117+
cancel-title="Cancel"
118+
@ok="regenerateToken"
119+
>
120+
<p>
121+
Are you sure you want to regenerate your API token?
122+
Your old token will be immediately invalidated and any applications using it
123+
will no longer be able to access the API.
124+
</p>
125+
<p class="mb-0">
126+
<strong>You will need to update all applications with the new token.</strong>
127+
</p>
128+
</b-modal>
129+
130+
<!-- Revoke Confirmation Modal -->
131+
<b-modal
132+
v-model="showRevokeConfirm"
133+
title="Revoke API Token"
134+
ok-variant="danger"
135+
ok-title="Revoke Token"
136+
cancel-title="Cancel"
137+
@ok="revokeToken"
138+
>
139+
<p>
140+
Are you sure you want to revoke your API token? Any applications using this token
141+
will no longer be able to access the API.
142+
</p>
143+
<p class="mb-0">
144+
<strong>This action cannot be undone.</strong>
145+
</p>
146+
</b-modal>
147+
</b-container>
148+
</template>
149+
150+
<script>
151+
import { mapActions } from 'vuex';
152+
import { baseURL } from '@/js/utils';
153+
import { BIconClipboard } from 'bootstrap-vue';
154+
155+
export default {
156+
name: 'ApiToken',
157+
components: {
158+
BIconClipboard,
159+
},
160+
data() {
161+
return {
162+
hasToken: false,
163+
newlyGeneratedToken: null,
164+
loading: false,
165+
showRegenerateConfirm: false,
166+
showRevokeConfirm: false,
167+
};
168+
},
169+
computed: {
170+
apiBaseUrl() {
171+
return baseURL();
172+
},
173+
},
174+
async mounted() {
175+
await this.checkTokenStatus();
176+
},
177+
methods: {
178+
...mapActions(['GENERATE_API_TOKEN', 'REVOKE_API_TOKEN', 'GET_API_TOKEN']),
179+
async checkTokenStatus() {
180+
this.loading = true;
181+
try {
182+
const data = await this.GET_API_TOKEN();
183+
if (data) {
184+
this.hasToken = data.has_token;
185+
}
186+
} finally {
187+
this.loading = false;
188+
}
189+
},
190+
async generateToken() {
191+
this.loading = true;
192+
try {
193+
const data = await this.GENERATE_API_TOKEN();
194+
if (data) {
195+
this.hasToken = true;
196+
this.newlyGeneratedToken = data.api_token;
197+
}
198+
} finally {
199+
this.loading = false;
200+
}
201+
},
202+
async regenerateToken() {
203+
this.loading = true;
204+
this.showRegenerateConfirm = false;
205+
try {
206+
const data = await this.GENERATE_API_TOKEN();
207+
if (data) {
208+
this.hasToken = true;
209+
this.newlyGeneratedToken = data.api_token;
210+
}
211+
} finally {
212+
this.loading = false;
213+
}
214+
},
215+
async revokeToken() {
216+
this.loading = true;
217+
this.showRevokeConfirm = false;
218+
try {
219+
const success = await this.REVOKE_API_TOKEN();
220+
if (success) {
221+
this.hasToken = false;
222+
this.newlyGeneratedToken = null;
223+
}
224+
} finally {
225+
this.loading = false;
226+
}
227+
},
228+
async copyToken() {
229+
try {
230+
await navigator.clipboard.writeText(this.newlyGeneratedToken);
231+
this.$toast.success('Token copied to clipboard!');
232+
} catch (err) {
233+
this.$toast.error('Failed to copy token to clipboard');
234+
}
235+
},
236+
},
237+
};
238+
</script>
239+
240+
<style scoped>
241+
pre {
242+
white-space: pre-wrap;
243+
word-wrap: break-word;
244+
}
245+
246+
code {
247+
color: #e83e8c;
248+
}
249+
</style>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Add user API token
2+
3+
Revision ID: e1a2b3c4d5e6
4+
Revises: 8c78b9c89ee6
5+
Create Date: 2025-11-27 20:45:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "e1a2b3c4d5e6"
16+
down_revision: Union[str, None] = "8c78b9c89ee6"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
# ### commands auto generated by Alembic - please adjust! ###
23+
with op.batch_alter_table("user", schema=None) as batch_op:
24+
batch_op.add_column(sa.Column("api_token", sa.String(), nullable=True))
25+
batch_op.create_index("ix_user_api_token", ["api_token"], unique=False)
26+
27+
# ### end Alembic commands ###
28+
29+
30+
def downgrade() -> None:
31+
# ### commands auto generated by Alembic - please adjust! ###
32+
with op.batch_alter_table("user", schema=None) as batch_op:
33+
batch_op.drop_index("ix_user_api_token")
34+
batch_op.drop_column("api_token")
35+
36+
# ### end Alembic commands ###

0 commit comments

Comments
 (0)