Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
**/.DS_Store
.claude
CLAUDE.md

# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
Expand Down
43 changes: 43 additions & 0 deletions client/src/store/modules/user/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,49 @@ export default {

await context.commit('SET_TOKEN_REFRESH_INTERVAL', refreshInterval);
},
async GENERATE_API_TOKEN(context) {
const response = await fetch(makeURL('/api/v1/auth/api-token/generate'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (response.ok) {
const data = await response.json();
Vue.$toast.success('API token generated successfully!');
return data;
}
const responseBody = await response.json();
log.error('Unable to generate API token');
Vue.$toast.error(`Unable to generate API token: ${responseBody.message || 'Unknown error'}`);
return null;
},
async REVOKE_API_TOKEN(context) {
const response = await fetch(makeURL('/api/v1/auth/api-token/revoke'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (response.ok) {
Vue.$toast.success('API token revoked successfully!');
return true;
}
const responseBody = await response.json();
log.error('Unable to revoke API token');
Vue.$toast.error(`Unable to revoke API token: ${responseBody.message || 'Unknown error'}`);
return false;
},
async GET_API_TOKEN(context) {
const response = await fetch(makeURL('/api/v1/auth/api-token'), {
method: 'GET',
});
if (response.ok) {
const data = await response.json();
return data;
}
log.error('Unable to get API token');
Vue.$toast.error('Unable to get API token!');
return null;
},
},
getters: {
CURRENT_USER(state) {
Expand Down
8 changes: 7 additions & 1 deletion client/src/views/user/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
>
<stage-direction-styles />
</b-tab>
<b-tab title="API Token">
<api-token />
</b-tab>
</b-tabs>
</b-container>
</template>
Expand All @@ -30,9 +33,12 @@
import StageDirectionStyles from '@/vue_components/user/settings/StageDirectionStyles.vue';
import AboutUser from '@/vue_components/user/settings/AboutUser.vue';
import UserSettingsConfig from '@/vue_components/user/settings/Settings.vue';
import ApiToken from '@/vue_components/user/settings/ApiToken.vue';

export default {
name: 'UserSettings',
components: { UserSettingsConfig, AboutUser, StageDirectionStyles },
components: {
UserSettingsConfig, AboutUser, StageDirectionStyles, ApiToken,
},
};
</script>
249 changes: 249 additions & 0 deletions client/src/vue_components/user/settings/ApiToken.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
<template>
<b-container fluid>
<b-row>
<b-col>
<h3>API Token Management</h3>
<p class="text-muted">
Generate a static API token for authenticating external applications and scripts
to the DigiScript REST API. This token does not expire and can be used with the
<code>X-API-Key</code> header.
</p>
</b-col>
</b-row>

<b-row class="mt-3">
<b-col>
<b-card>
<template v-if="!hasToken">
<b-card-text>
<b-alert
variant="info"
show
>
You do not have an API token. Generate one to access the DigiScript API
from external applications.
</b-alert>
</b-card-text>
<b-button
variant="primary"
:disabled="loading"
@click="generateToken"
>
<b-spinner
v-if="loading"
small
/>
Generate API Token
</b-button>
</template>

<template v-else>
<b-card-text>
<b-alert
variant="success"
show
>
<strong>API Token Active</strong>
<p class="mb-0 mt-2">
Your API token is active. Keep this token secure and do not share it publicly.
</p>
</b-alert>

<div v-if="newlyGeneratedToken">
<label for="api-token-display"><strong>Your New API Token:</strong></label>
<b-input-group id="api-token-display">
<b-form-input
:value="newlyGeneratedToken"
readonly
type="text"
/>
<b-input-group-append>
<b-button
variant="outline-secondary"
@click="copyToken"
>
<b-icon-clipboard />
Copy
</b-button>
</b-input-group-append>
</b-input-group>
<b-form-text class="text-warning">
<strong>IMPORTANT:</strong> This token will only be shown once.
Save it securely now - you will not be able to retrieve it again!
</b-form-text>
</div>
</b-card-text>

<b-card-text class="mt-3">
<h5>Usage Example:</h5>
<pre class="bg-light p-3 rounded"><code>curl -H "X-API-Key: YOUR_TOKEN_HERE" {{ apiBaseUrl }}/api/v1/auth</code></pre>
</b-card-text>

<b-button
variant="warning"
:disabled="loading"
@click="showRegenerateConfirm = true"
>
<b-spinner
v-if="loading"
small
/>
Regenerate Token
</b-button>

<b-button
variant="danger"
class="ml-2"
:disabled="loading"
@click="showRevokeConfirm = true"
>
<b-spinner
v-if="loading"
small
/>
Revoke Token
</b-button>
</template>
</b-card>
</b-col>
</b-row>

<!-- Regenerate Confirmation Modal -->
<b-modal
v-model="showRegenerateConfirm"
title="Regenerate API Token"
ok-variant="warning"
ok-title="Regenerate Token"
cancel-title="Cancel"
@ok="regenerateToken"
>
<p>
Are you sure you want to regenerate your API token?
Your old token will be immediately invalidated and any applications using it
will no longer be able to access the API.
</p>
<p class="mb-0">
<strong>You will need to update all applications with the new token.</strong>
</p>
</b-modal>

<!-- Revoke Confirmation Modal -->
<b-modal
v-model="showRevokeConfirm"
title="Revoke API Token"
ok-variant="danger"
ok-title="Revoke Token"
cancel-title="Cancel"
@ok="revokeToken"
>
<p>
Are you sure you want to revoke your API token? Any applications using this token
will no longer be able to access the API.
</p>
<p class="mb-0">
<strong>This action cannot be undone.</strong>
</p>
</b-modal>
</b-container>
</template>

<script>
import { mapActions } from 'vuex';
import { baseURL } from '@/js/utils';
import { BIconClipboard } from 'bootstrap-vue';

export default {
name: 'ApiToken',
components: {
BIconClipboard,
},
data() {
return {
hasToken: false,
newlyGeneratedToken: null,
loading: false,
showRegenerateConfirm: false,
showRevokeConfirm: false,
};
},
computed: {
apiBaseUrl() {
return baseURL();
},
},
async mounted() {
await this.checkTokenStatus();
},
methods: {
...mapActions(['GENERATE_API_TOKEN', 'REVOKE_API_TOKEN', 'GET_API_TOKEN']),
async checkTokenStatus() {
this.loading = true;
try {
const data = await this.GET_API_TOKEN();
if (data) {
this.hasToken = data.has_token;
}
} finally {
this.loading = false;
}
},
async generateToken() {
this.loading = true;
try {
const data = await this.GENERATE_API_TOKEN();
if (data) {
this.hasToken = true;
this.newlyGeneratedToken = data.api_token;
}
} finally {
this.loading = false;
}
},
async regenerateToken() {
this.loading = true;
this.showRegenerateConfirm = false;
try {
const data = await this.GENERATE_API_TOKEN();
if (data) {
this.hasToken = true;
this.newlyGeneratedToken = data.api_token;
}
} finally {
this.loading = false;
}
},
async revokeToken() {
this.loading = true;
this.showRevokeConfirm = false;
try {
const success = await this.REVOKE_API_TOKEN();
if (success) {
this.hasToken = false;
this.newlyGeneratedToken = null;
}
} finally {
this.loading = false;
}
},
async copyToken() {
try {
await navigator.clipboard.writeText(this.newlyGeneratedToken);
this.$toast.success('Token copied to clipboard!');
} catch (err) {
this.$toast.error('Failed to copy token to clipboard');
}
},
},
};
</script>

<style scoped>
pre {
white-space: pre-wrap;
word-wrap: break-word;
}

code {
color: #e83e8c;
}
</style>
36 changes: 36 additions & 0 deletions server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Add user API token

Revision ID: e1a2b3c4d5e6
Revises: 8c78b9c89ee6
Create Date: 2025-11-27 20:45:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "e1a2b3c4d5e6"
down_revision: Union[str, None] = "8c78b9c89ee6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("user", schema=None) as batch_op:
batch_op.add_column(sa.Column("api_token", sa.String(), nullable=True))
batch_op.create_index("ix_user_api_token", ["api_token"], unique=False)

# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("user", schema=None) as batch_op:
batch_op.drop_index("ix_user_api_token")
batch_op.drop_column("api_token")

# ### end Alembic commands ###
Loading
Loading