feat(frontend): add bot settings page and services

This commit is contained in:
kolaente
2026-04-05 20:09:35 +02:00
committed by kolaente
parent c4e5f55b6d
commit d467a06e72
11 changed files with 885 additions and 381 deletions

View File

@@ -3,27 +3,32 @@
class="user"
:class="{'is-inline': isInline}"
>
<img
v-tooltip="displayName"
:height="avatarSize"
:src="avatarSrc"
:width="avatarSize"
:alt="'Avatar of ' + displayName"
class="avatar"
>
<span class="avatar-wrapper">
<img
v-tooltip="displayName"
:height="avatarSize"
:src="avatarSrc"
:width="avatarSize"
:alt="'Avatar of ' + displayName"
class="avatar"
>
<span
v-if="isBot"
v-tooltip="t('user.settings.bots.badge')"
class="bot-badge"
aria-label="Bot"
>B</span>
</span>
<span
v-if="showUsername"
class="username"
>{{ displayName }}</span>
<span
v-if="isBot"
class="bot-badge"
>{{ $t('user.bot.badge') }}</span>
</div>
</template>
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
@@ -39,6 +44,8 @@ const props = withDefaults(defineProps<{
isInline: false,
})
const {t} = useI18n({useScope: 'global'})
const displayName = computed(() => getDisplayName(props.user))
const isBot = computed(() => ((props.user as IUser & {botOwnerId?: number}).botOwnerId ?? 0) > 0)
const avatarSrc = ref('')
@@ -60,22 +67,38 @@ watch(() => [props.user, props.avatarSize], loadAvatar, { immediate: true })
}
}
.avatar {
border-radius: 100%;
vertical-align: middle;
.avatar-wrapper {
position: relative;
display: inline-flex;
margin-inline-end: .5rem;
}
.avatar {
border-radius: 100%;
vertical-align: middle;
}
.bot-badge {
display: inline-block;
align-self: center;
margin-inline-start: .5rem;
padding: 0 .4rem;
font-size: .75rem;
line-height: 1.2;
color: var(--grey-700);
background: var(--grey-200);
border-radius: 4px;
position: absolute;
inset-block-end: 0;
inset-inline-start: 0;
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 40%;
block-size: 40%;
min-inline-size: 14px;
min-block-size: 14px;
max-inline-size: 22px;
max-block-size: 22px;
font-size: .65rem;
font-weight: 700;
line-height: 1;
color: var(--white);
background: var(--primary);
border: 2px solid var(--white);
border-radius: 100%;
text-transform: uppercase;
pointer-events: auto;
}
</style>

View File

@@ -0,0 +1,402 @@
<script setup lang="ts">
import {computed, onMounted, ref} from 'vue'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
import XButton from '@/components/input/Button.vue'
import ApiTokenService from '@/services/apiToken'
import ApiTokenModel from '@/models/apiTokenModel'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {useI18n} from 'vue-i18n'
import FormField from '@/components/input/FormField.vue'
import type {IApiToken} from '@/modelTypes/IApiToken'
const props = withDefaults(defineProps<{
ownerId?: number,
loading?: boolean,
initialTitle?: string,
initialScopes?: string,
}>(), {
ownerId: 0,
loading: false,
initialTitle: '',
initialScopes: '',
})
const emit = defineEmits<{
created: [token: IApiToken]
cancel: []
}>()
const service = new ApiTokenService()
const {t} = useI18n()
const now = new Date()
const availableRoutes = ref(null)
const newToken = ref<IApiToken>(new ApiTokenModel())
const newTokenExpiry = ref<string | number>(30)
const newTokenExpiryCustom = ref(new Date())
const newTokenPermissions = ref({})
const newTokenPermissionsGroup = ref({})
const newTokenTitleValid = ref(true)
const newTokenPermissionValid = ref(true)
const apiTokenTitle = ref()
interface TokenPreset {
id: string
groups: Record<string, string[] | '*'>
}
const presets: TokenPreset[] = [
{
id: 'readOnly',
groups: {
'*': ['read_one', 'read_all'],
},
},
{
id: 'tasks',
groups: {
'tasks': '*',
'tasks_attachments': '*',
'tasks_assignees': '*',
'tasks_labels': '*',
'tasks_comments': '*',
'tasks_relations': '*',
'labels': ['read_one', 'read_all', 'create'],
'projects': ['read_one', 'read_all', 'views_buckets_tasks'],
'projects_views': ['read_one', 'read_all'],
'projects_views_tasks': ['read_one', 'read_all'],
},
},
{
id: 'projects',
groups: {
'projects': '*',
'projects_views': '*',
'projects_teams': '*',
'projects_users': '*',
'projects_shares': '*',
'projects_webhooks': '*',
'projects_buckets': '*',
'projects_views_tasks': '*',
'tasks': ['read_one', 'read_all'],
'teams': ['read_one', 'read_all'],
},
},
{
id: 'fullAccess',
groups: {
'*': '*',
},
},
]
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
locale: useFlatpickrLanguage().value,
minDate: now,
}))
onMounted(async () => {
const allRoutes = await service.getAvailableRoutes()
const routesAvailable = {}
const keys = Object.keys(allRoutes)
keys.sort((a, b) => (a === 'other' ? 1 : b === 'other' ? -1 : 0))
keys.forEach(key => {
routesAvailable[key] = allRoutes[key]
})
availableRoutes.value = routesAvailable
resetPermissions()
// Apply initial values from props (e.g. from query parameters)
if (props.initialTitle) {
newToken.value.title = props.initialTitle
newTokenTitleValid.value = true
}
if (props.initialScopes) {
const requestedScopes: Record<string, string[]> = {}
for (const scope of props.initialScopes.split(',')) {
const [group, permission] = scope.split(':')
if (group && permission) {
if (!requestedScopes[group]) {
requestedScopes[group] = []
}
requestedScopes[group].push(permission)
}
}
for (const [group, permissions] of Object.entries(requestedScopes)) {
if (newTokenPermissions.value[group]) {
for (const permission of permissions) {
if (newTokenPermissions.value[group][permission] !== undefined) {
newTokenPermissions.value[group][permission] = true
}
}
toggleGroupPermissionsFromChild(group, true)
}
}
}
})
function resetPermissions() {
newTokenPermissions.value = {}
newTokenPermissionsGroup.value = {}
newTokenPermissionValid.value = true
Object.entries(availableRoutes.value).forEach(entry => {
const [group, routes] = entry
newTokenPermissions.value[group] = {}
newTokenPermissionsGroup.value[group] = false
Object.keys(routes).forEach(r => {
newTokenPermissions.value[group][r] = false
})
})
}
function applyPreset(preset: TokenPreset) {
resetPermissions()
for (const [groupKey, permissions] of Object.entries(preset.groups)) {
if (groupKey === '*') {
for (const group of Object.keys(availableRoutes.value)) {
applyPermissionsToGroup(group, permissions)
}
} else if (availableRoutes.value[groupKey]) {
applyPermissionsToGroup(groupKey, permissions)
}
}
}
function applyPermissionsToGroup(group: string, permissions: string[] | '*') {
if (permissions === '*') {
selectPermissionGroup(group, true)
newTokenPermissionsGroup.value[group] = true
} else {
for (const perm of permissions) {
if (newTokenPermissions.value[group]?.[perm] !== undefined) {
newTokenPermissions.value[group][perm] = true
}
}
toggleGroupPermissionsFromChild(group, true)
}
}
function selectPermissionGroup(group: string, checked: boolean) {
Object.entries(availableRoutes.value[group]).forEach(entry => {
const [key] = entry
newTokenPermissions.value[group][key] = checked
})
if (checked) {
newTokenPermissionValid.value = true
}
}
function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
if (checked) {
newTokenPermissionValid.value = true
let allChecked = true
Object.entries(availableRoutes.value[group]).forEach(entry => {
const [key] = entry
if (!newTokenPermissions.value[group][key]) {
allChecked = false
}
})
if (allChecked) {
newTokenPermissionsGroup.value[group] = true
}
} else {
newTokenPermissionsGroup.value[group] = false
}
}
function formatPermissionTitle(title: string): string {
return title.replaceAll('_', ' ')
}
async function createToken() {
newTokenTitleValid.value = newToken.value.title.trim() !== ''
if (!newTokenTitleValid.value) {
apiTokenTitle.value.focus()
return
}
let hasPermissions = false
newToken.value.permissions = {}
Object.entries(newTokenPermissions.value).forEach(([key, ps]) => {
const all = Object.entries(ps)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, v]) => v)
.map(p => p[0])
if (all.length > 0) {
newToken.value.permissions[key] = all
hasPermissions = true
}
})
if (!hasPermissions) {
newTokenPermissionValid.value = false
return
}
const expiry = Number(newTokenExpiry.value)
if (!isNaN(expiry)) {
newToken.value.expiresAt = new Date((+new Date()) + expiry * MILLISECONDS_A_DAY)
} else {
newToken.value.expiresAt = new Date(newTokenExpiryCustom.value)
}
if (props.ownerId > 0) {
(newToken.value as IApiToken & {ownerId: number}).ownerId = props.ownerId
}
const token = await service.create(newToken.value)
emit('created', token)
newToken.value = new ApiTokenModel()
newTokenExpiry.value = 30
newTokenExpiryCustom.value = new Date()
resetPermissions()
}
</script>
<template>
<form @submit.prevent="createToken">
<!-- Title -->
<FormField
id="apiTokenTitle"
ref="apiTokenTitle"
v-model="newToken.title"
v-focus
:label="$t('user.settings.apiTokens.attributes.title')"
type="text"
:placeholder="$t('user.settings.apiTokens.attributes.titlePlaceholder')"
:error="newTokenTitleValid ? null : $t('user.settings.apiTokens.titleRequired')"
@keyup="() => newTokenTitleValid = newToken.title !== ''"
@focusout="() => newTokenTitleValid = newToken.title !== ''"
/>
<!-- Expiry -->
<div class="field">
<label
class="label"
for="apiTokenExpiry"
>
{{ $t('user.settings.apiTokens.attributes.expiresAt') }}
</label>
<div class="is-flex">
<div class="control select">
<select
id="apiTokenExpiry"
v-model="newTokenExpiry"
class="select"
>
<option value="30">
{{ $t('user.settings.apiTokens.30d') }}
</option>
<option value="60">
{{ $t('user.settings.apiTokens.60d') }}
</option>
<option value="90">
{{ $t('user.settings.apiTokens.90d') }}
</option>
<option value="custom">
{{ $t('misc.custom') }}
</option>
</select>
</div>
<flat-pickr
v-if="newTokenExpiry === 'custom'"
v-model="newTokenExpiryCustom"
class="mis-2"
:config="flatPickerConfig"
/>
</div>
</div>
<!-- Permissions -->
<div class="field">
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>
<p>{{ $t('user.settings.apiTokens.permissionExplanation') }}</p>
<!-- Presets -->
<div class="preset-buttons mbe-4">
<label class="label">{{ $t('user.settings.apiTokens.presets.title') }}</label>
<div
class="is-flex"
style="gap: .5rem; flex-wrap: wrap;"
>
<XButton
v-for="preset in presets"
:key="preset.id"
variant="secondary"
type="button"
@click="applyPreset(preset)"
>
{{ $t(`user.settings.apiTokens.presets.${preset.id}`) }}
</XButton>
</div>
</div>
<div
v-for="(routes, group) in availableRoutes"
:key="group"
class="mbe-2"
>
<template
v-if="Object.keys(routes).length >= 1"
>
<FancyCheckbox
v-model="newTokenPermissionsGroup[group]"
class="mie-2 is-capitalized has-text-weight-bold"
@update:modelValue="checked => selectPermissionGroup(group, checked)"
>
{{ formatPermissionTitle(group) }}
</FancyCheckbox>
<br>
</template>
<template
v-for="(paths, permission) in routes"
:key="group+'-'+permission"
>
<FancyCheckbox
v-model="newTokenPermissions[group][permission]"
class="mis-4 mie-2 is-capitalized"
@update:modelValue="checked => toggleGroupPermissionsFromChild(group, checked)"
>
{{ formatPermissionTitle(permission) }}
</FancyCheckbox>
<br>
</template>
</div>
</div>
<p
v-if="!newTokenPermissionValid"
class="help is-danger"
>
{{ $t('user.settings.apiTokens.permissionRequired') }}
</p>
<XButton
:loading="loading"
type="submit"
>
{{ $t('user.settings.apiTokens.createToken') }}
</XButton>
<XButton
variant="tertiary"
type="button"
@click="emit('cancel')"
>
{{ $t('misc.cancel') }}
</XButton>
</form>
</template>

View File

@@ -59,9 +59,6 @@
"text": "Please check your network connection and try again."
},
"user": {
"bot": {
"badge": "Bot"
},
"auth": {
"username": "Username",
"usernameEmail": "Username Or Email Address",
@@ -110,6 +107,19 @@
"registrationFailed": "An error occurred during registration. Please check your input and try again."
},
"settings": {
"bots": {
"title": "Bot Users",
"description": "Bot users are API-only users you own. They can be added to projects, assigned tasks, and authenticated with API tokens. They cannot log in interactively.",
"namePlaceholder": "My Assistant",
"create": "Create bot",
"enable": "Enable",
"badge": "Bot",
"delete": {
"header": "Delete this bot user",
"text1": "Are you sure you want to delete the bot user \"{username}\"?",
"text2": "This is irreversible. Any API tokens belonging to this bot will be revoked."
}
},
"title": "Settings",
"newPasswordTitle": "Update Your Password",
"newPassword": "New password",

View File

@@ -11,4 +11,5 @@ export interface IApiToken extends IAbstract {
permissions: IApiPermission
expiresAt: Date
created: Date
ownerId?: number
}

View File

@@ -8,6 +8,7 @@ export default class ApiTokenModel extends AbstractModel<IApiToken> {
permissions = null
expiresAt: Date = null
created: Date = null
ownerId = 0
constructor(data: Partial<IApiToken> = {}) {
super()

View File

@@ -163,6 +163,11 @@ const router = createRouter({
name: 'user.settings.webhooks',
component: () => import('@/views/user/settings/Webhooks.vue'),
},
{
path: '/user/settings/bots',
name: 'user.settings.bots',
component: () => import('@/views/user/settings/BotUsers.vue'),
},
{
path: '/user/settings/migrate',
name: 'migrate.start',

View File

@@ -0,0 +1,19 @@
import AbstractService from '@/services/abstractService'
import type {IUser} from '@/modelTypes/IUser'
import UserModel from '@/models/user'
export default class BotUserService extends AbstractService<IUser> {
constructor() {
super({
create: '/user/bots',
getAll: '/user/bots',
get: '/user/bots/{id}',
update: '/user/bots/{id}',
delete: '/user/bots/{id}',
})
}
modelFactory(data: Partial<IUser>) {
return new UserModel(data)
}
}

View File

@@ -26,6 +26,7 @@ const migratorsEnabled = computed(() => configStore.migratorsEnabled)
const isLocalUser = computed(() => authStore.info?.isLocalUser)
const userDeletionEnabled = computed(() => configStore.userDeletionEnabled)
const webhooksEnabled = computed(() => configStore.webhooksEnabled)
const botUsersEnabled = computed(() => configStore.botUsersEnabled)
const navigationItems = computed(() => {
const items = [
@@ -80,6 +81,11 @@ const navigationItems = computed(() => {
routeName: 'user.settings.webhooks',
condition: webhooksEnabled.value,
},
{
title: t('user.settings.bots.title'),
routeName: 'user.settings.bots',
condition: botUsersEnabled.value,
},
{
title: t('user.deletion.title'),
routeName: 'user.settings.deletion',

View File

@@ -1,35 +1,19 @@
<script setup lang="ts">
import ApiTokenService from '@/services/apiToken'
import {computed, onMounted, ref} from 'vue'
import {onMounted, ref} from 'vue'
import {useRoute} from 'vue-router'
import {parseScopesFromQuery} from '@/helpers/parseScopesFromQuery'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
import {formatDateSince, formatDisplayDate} from '@/helpers/time/formatDate'
import XButton from '@/components/input/Button.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import ApiTokenModel from '@/models/apiTokenModel'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {useI18n} from 'vue-i18n'
import Message from '@/components/misc/Message.vue'
import FormField from '@/components/input/FormField.vue'
import type {IApiToken} from '@/modelTypes/IApiToken'
import ApiTokenForm from '@/components/token/ApiTokenForm.vue'
const service = new ApiTokenService()
const tokens = ref<IApiToken[]>([])
const apiDocsUrl = window.API_URL + '/docs'
const showCreateForm = ref(false)
const availableRoutes = ref(null)
const newToken = ref<IApiToken>(new ApiTokenModel())
const newTokenExpiry = ref<string | number>(30)
const newTokenExpiryCustom = ref(new Date())
const newTokenPermissions = ref({})
const newTokenPermissionsGroup = ref({})
const newTokenTitleValid = ref(true)
const newTokenPermissionValid = ref(true)
const apiTokenTitle = ref()
const tokenCreatedSuccessMessage = ref('')
const showDeleteModal = ref<boolean>(false)
@@ -39,160 +23,26 @@ const {t} = useI18n()
const route = useRoute()
const now = new Date()
interface TokenPreset {
id: string
groups: Record<string, string[] | '*'>
}
const presets: TokenPreset[] = [
{
id: 'readOnly',
groups: {
'*': ['read_one', 'read_all'],
},
},
{
id: 'tasks',
groups: {
'tasks': '*',
'tasks_attachments': '*',
'tasks_assignees': '*',
'tasks_labels': '*',
'tasks_comments': '*',
'tasks_relations': '*',
'labels': ['read_one', 'read_all', 'create'],
'projects': ['read_one', 'read_all', 'views_buckets_tasks'],
'projects_views': ['read_one', 'read_all'],
'projects_views_tasks': ['read_one', 'read_all'],
},
},
{
id: 'projects',
groups: {
'projects': '*',
'projects_views': '*',
'projects_teams': '*',
'projects_users': '*',
'projects_shares': '*',
'projects_webhooks': '*',
'projects_buckets': '*',
'projects_views_tasks': '*',
'tasks': ['read_one', 'read_all'],
'teams': ['read_one', 'read_all'],
},
},
{
id: 'fullAccess',
groups: {
'*': '*',
},
},
]
function applyPreset(preset: TokenPreset) {
resetPermissions()
for (const [groupKey, permissions] of Object.entries(preset.groups)) {
if (groupKey === '*') {
// Apply to all groups
for (const group of Object.keys(availableRoutes.value)) {
applyPermissionsToGroup(group, permissions)
}
} else if (availableRoutes.value[groupKey]) {
applyPermissionsToGroup(groupKey, permissions)
}
}
}
function applyPermissionsToGroup(group: string, permissions: string[] | '*') {
if (permissions === '*') {
// Select all permissions in this group
selectPermissionGroup(group, true)
newTokenPermissionsGroup.value[group] = true
} else {
for (const perm of permissions) {
if (newTokenPermissions.value[group]?.[perm] !== undefined) {
newTokenPermissions.value[group][perm] = true
}
}
toggleGroupPermissionsFromChild(group, true)
}
}
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
locale: useFlatpickrLanguage().value,
minDate: now,
}))
const initialTitle = ref('')
const initialScopes = ref('')
onMounted(async () => {
tokens.value = await service.getAll()
const allRoutes = await service.getAvailableRoutes()
const routesAvailable = {}
const keys = Object.keys(allRoutes)
keys.sort((a, b) => (a === 'other' ? 1 : b === 'other' ? -1 : 0))
keys.forEach(key => {
routesAvailable[key] = allRoutes[key]
})
availableRoutes.value = routesAvailable
resetPermissions()
// Apply query parameters if present
applyQueryParams()
})
function resetPermissions() {
newTokenPermissions.value = {}
newTokenPermissionsGroup.value = {}
Object.entries(availableRoutes.value).forEach(entry => {
const [group, routes] = entry
newTokenPermissions.value[group] = {}
Object.keys(routes).forEach(r => {
newTokenPermissions.value[group][r] = false
})
})
}
function applyQueryParams() {
// Normalize query params - they can be string, string[], or null
const titleParam = Array.isArray(route.query.title) ? route.query.title[0] : route.query.title
const scopesParam = Array.isArray(route.query.scopes) ? route.query.scopes[0] : route.query.scopes
if (titleParam) {
initialTitle.value = titleParam
}
if (scopesParam) {
initialScopes.value = scopesParam
}
if (titleParam || scopesParam) {
showCreateForm.value = true
}
if (titleParam) {
newToken.value.title = titleParam
newTokenTitleValid.value = true
}
if (scopesParam) {
const requestedScopes = parseScopesFromQuery(scopesParam)
// Apply requested scopes to the permissions checkboxes
for (const [group, permissions] of Object.entries(requestedScopes)) {
if (newTokenPermissions.value[group]) {
for (const permission of permissions) {
if (newTokenPermissions.value[group][permission] !== undefined) {
newTokenPermissions.value[group][permission] = true
}
}
// Update group checkbox if all permissions in group are selected
toggleGroupPermissionsFromChild(group, true)
}
}
}
}
})
async function deleteToken() {
await service.delete(tokenToDelete.value)
@@ -205,77 +55,14 @@ async function deleteToken() {
tokens.value.splice(index, 1)
}
async function createToken() {
if (!newTokenTitleValid.value) {
apiTokenTitle.value.focus()
return
}
let hasPermissions = false
newToken.value.permissions = {}
Object.entries(newTokenPermissions.value).forEach(([key, ps]) => {
const all = Object.entries(ps)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, v]) => v)
.map(p => p[0])
if (all.length > 0) {
newToken.value.permissions[key] = all
hasPermissions = true
}
})
if(!hasPermissions) {
newTokenPermissionValid.value = false
return
}
const expiry = Number(newTokenExpiry.value)
if (!isNaN(expiry)) {
// if it's a number, we assume it's the number of days in the future
newToken.value.expiresAt = new Date((+new Date()) + expiry * MILLISECONDS_A_DAY)
} else {
newToken.value.expiresAt = new Date(newTokenExpiryCustom.value)
}
const token = await service.create(newToken.value)
tokenCreatedSuccessMessage.value = t('user.settings.apiTokens.tokenCreatedSuccess', {token: token.token})
newToken.value = new ApiTokenModel()
newTokenExpiry.value = 30
newTokenExpiryCustom.value = new Date()
resetPermissions()
tokens.value.push(token)
showCreateForm.value = false
}
function formatPermissionTitle(title: string): string {
return title.replaceAll('_', ' ')
}
function selectPermissionGroup(group: string, checked: boolean) {
Object.entries(availableRoutes.value[group]).forEach(entry => {
const [key] = entry
newTokenPermissions.value[group][key] = checked
})
}
function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
if (checked) {
// Check if all permissions of that group are checked and check the "select all" checkbox in that case
let allChecked = true
Object.entries(availableRoutes.value[group]).forEach(entry => {
const [key] = entry
if (!newTokenPermissions.value[group][key]) {
allChecked = false
}
})
if (allChecked) {
newTokenPermissionsGroup.value[group] = true
}
} else {
newTokenPermissionsGroup.value[group] = false
}
function onTokenCreated(token: IApiToken) {
tokenCreatedSuccessMessage.value = t('user.settings.apiTokens.tokenCreatedSuccess', {token: token.token})
tokens.value.push(token)
showCreateForm.value = false
}
</script>
@@ -354,132 +141,14 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
</table>
</div>
<form
<ApiTokenForm
v-if="showCreateForm"
@submit.prevent="createToken"
>
<!-- Title -->
<FormField
id="apiTokenTitle"
ref="apiTokenTitle"
v-model="newToken.title"
v-focus
:label="$t('user.settings.apiTokens.attributes.title')"
type="text"
:placeholder="$t('user.settings.apiTokens.attributes.titlePlaceholder')"
:error="newTokenTitleValid ? null : $t('user.settings.apiTokens.titleRequired')"
@keyup="() => newTokenTitleValid = newToken.title !== ''"
@focusout="() => newTokenTitleValid = newToken.title !== ''"
/>
<!-- Expiry -->
<div class="field">
<label
class="label"
for="apiTokenExpiry"
>
{{ $t('user.settings.apiTokens.attributes.expiresAt') }}
</label>
<div class="is-flex">
<div class="control select">
<select
id="apiTokenExpiry"
v-model="newTokenExpiry"
class="select"
>
<option value="30">
{{ $t('user.settings.apiTokens.30d') }}
</option>
<option value="60">
{{ $t('user.settings.apiTokens.60d') }}
</option>
<option value="90">
{{ $t('user.settings.apiTokens.90d') }}
</option>
<option value="custom">
{{ $t('misc.custom') }}
</option>
</select>
</div>
<flat-pickr
v-if="newTokenExpiry === 'custom'"
v-model="newTokenExpiryCustom"
class="mis-2"
:config="flatPickerConfig"
/>
</div>
</div>
<!-- Permissions -->
<div class="field">
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>
<p>{{ $t('user.settings.apiTokens.permissionExplanation') }}</p>
<!-- Presets -->
<div class="preset-buttons mbe-4">
<label class="label">{{ $t('user.settings.apiTokens.presets.title') }}</label>
<div
class="is-flex"
style="gap: .5rem; flex-wrap: wrap;"
>
<XButton
v-for="preset in presets"
:key="preset.id"
variant="secondary"
type="button"
@click="applyPreset(preset)"
>
{{ $t(`user.settings.apiTokens.presets.${preset.id}`) }}
</XButton>
</div>
</div>
<div
v-for="(routes, group) in availableRoutes"
:key="group"
class="mbe-2"
>
<template
v-if="Object.keys(routes).length >= 1"
>
<FancyCheckbox
v-model="newTokenPermissionsGroup[group]"
class="mie-2 is-capitalized has-text-weight-bold"
@update:modelValue="checked => selectPermissionGroup(group, checked)"
>
{{ formatPermissionTitle(group) }}
</FancyCheckbox>
<br>
</template>
<template
v-for="(paths, permission) in routes"
:key="group+'-'+permission"
>
<FancyCheckbox
v-model="newTokenPermissions[group][permission]"
class="mis-4 mie-2 is-capitalized"
@update:modelValue="checked => toggleGroupPermissionsFromChild(group, checked)"
>
{{ formatPermissionTitle(permission) }}
</FancyCheckbox>
<br>
</template>
</div>
</div>
<p
v-if="!newTokenPermissionValid"
class="help is-danger"
>
{{ $t('user.settings.apiTokens.permissionRequired') }}
</p>
<XButton
:loading="service.loading"
type="submit"
>
{{ $t('user.settings.apiTokens.createToken') }}
</XButton>
</form>
:loading="service.loading"
:initial-title="initialTitle"
:initial-scopes="initialScopes"
@created="onTokenCreated"
@cancel="showCreateForm = false"
/>
<XButton
v-else
@@ -509,3 +178,9 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
</Modal>
</Card>
</template>
<style lang="scss" scoped>
.preset-buttons {
margin-block-start: 1rem;
}
</style>

View File

@@ -0,0 +1,362 @@
<script setup lang="ts">
import {onMounted, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@/composables/useTitle'
import XButton from '@/components/input/Button.vue'
import FormField from '@/components/input/FormField.vue'
import Message from '@/components/misc/Message.vue'
import ApiTokenForm from '@/components/token/ApiTokenForm.vue'
import BotUserService from '@/services/botUser'
import ApiTokenService from '@/services/apiToken'
import UserModel from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
import type {IApiToken} from '@/modelTypes/IApiToken'
import {formatDisplayDate} from '@/helpers/time/formatDate'
const STATUS_ACTIVE = 0
const STATUS_DISABLED = 2
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('user.settings.bots.title'))
const botService = new BotUserService()
const tokenService = new ApiTokenService()
const bots = ref<IUser[]>([])
const newBotUsername = ref('')
const newBotName = ref('')
const createError = ref<string | null>(null)
const showCreateForm = ref(false)
const tokensByBot = ref<Record<number, IApiToken[]>>({})
const newTokensByBot = ref<Record<number, string>>({})
const showTokenForm = ref<Record<number, boolean>>({})
const editingName = ref<Record<number, boolean>>({})
const nameDraft = ref<Record<number, string>>({})
const showDeleteModal = ref<boolean>(false)
const botToDelete = ref<IUser>()
async function loadBots() {
bots.value = await botService.getAll() as IUser[]
for (const bot of bots.value) {
await loadTokens(bot.id)
}
}
async function loadTokens(botId: number) {
tokensByBot.value[botId] = await tokenService.getAll({}, {owner_id: botId}) as IApiToken[]
}
async function createBot() {
createError.value = null
const username = newBotUsername.value.startsWith('bot-') ? newBotUsername.value : `bot-${newBotUsername.value}`
const payload: Partial<IUser> = {username}
const trimmedName = newBotName.value.trim()
if (trimmedName !== '') {
payload.name = trimmedName
}
try {
const created = await botService.create(new UserModel(payload))
bots.value.push(created as IUser)
newBotUsername.value = ''
newBotName.value = ''
showCreateForm.value = false
} catch (e: unknown) {
const err = e as {response?: {data?: {message?: string}}}
createError.value = err?.response?.data?.message ?? String(e)
}
}
async function toggleBotStatus(bot: IUser) {
const updated = new UserModel({
...bot,
status: bot.status === STATUS_ACTIVE ? STATUS_DISABLED : STATUS_ACTIVE,
})
const result = await botService.update(updated) as IUser
const idx = bots.value.findIndex(b => b.id === bot.id)
if (idx >= 0) {
bots.value[idx] = result
}
}
function startEditName(bot: IUser) {
nameDraft.value[bot.id] = bot.name ?? ''
editingName.value[bot.id] = true
}
function cancelEditName(bot: IUser) {
editingName.value[bot.id] = false
delete nameDraft.value[bot.id]
}
async function saveBotName(bot: IUser) {
const updated = new UserModel({
...bot,
name: (nameDraft.value[bot.id] ?? '').trim(),
})
const result = await botService.update(updated) as IUser
const idx = bots.value.findIndex(b => b.id === bot.id)
if (idx >= 0) {
bots.value[idx] = result
}
editingName.value[bot.id] = false
delete nameDraft.value[bot.id]
}
async function deleteBot() {
const bot = botToDelete.value
if (!bot) {
return
}
await botService.delete(bot)
bots.value = bots.value.filter(b => b.id !== bot.id)
showDeleteModal.value = false
botToDelete.value = undefined
}
function onTokenCreated(bot: IUser, token: IApiToken) {
newTokensByBot.value[bot.id] = token.token
showTokenForm.value[bot.id] = false
loadTokens(bot.id)
}
async function deleteToken(bot: IUser, token: IApiToken) {
await tokenService.delete(token)
await loadTokens(bot.id)
}
onMounted(loadBots)
</script>
<template>
<div class="content">
<h2>{{ $t('user.settings.bots.title') }}</h2>
<p>{{ $t('user.settings.bots.description') }}</p>
<div
v-if="bots.length === 0 || showCreateForm"
class="create-form"
>
<FormField
:label="$t('user.auth.username')"
:error="createError"
>
<input
v-model="newBotUsername"
class="input"
placeholder="bot-myassistant"
>
</FormField>
<FormField :label="$t('admin.users.nameLabel')">
<input
v-model="newBotName"
class="input"
:placeholder="$t('user.settings.bots.namePlaceholder')"
>
</FormField>
<XButton @click="createBot">
{{ $t('user.settings.bots.create') }}
</XButton>
</div>
<XButton
v-else
icon="plus"
class="mbe-4"
@click="showCreateForm = true"
>
{{ $t('user.settings.bots.create') }}
</XButton>
<div
v-for="bot in bots"
:key="bot.id"
class="bot-card"
>
<div class="bot-header">
<strong>{{ bot.username }}</strong>
<template v-if="editingName[bot.id]">
<span class="bot-name-edit"></span>
<input
v-model="nameDraft[bot.id]"
v-focus
class="input bot-name-input"
:placeholder="$t('user.settings.bots.namePlaceholder')"
@keyup.enter="saveBotName(bot)"
@keyup.esc="cancelEditName(bot)"
>
<XButton
variant="secondary"
@click="saveBotName(bot)"
>
{{ $t('misc.save') }}
</XButton>
<XButton
variant="tertiary"
@click="cancelEditName(bot)"
>
{{ $t('misc.cancel') }}
</XButton>
</template>
<template v-else>
<span v-if="bot.name"> {{ bot.name }}</span>
<span
v-else
class="no-name"
>{{ $t('project.share.links.noName') }}</span>
<XButton
variant="tertiary"
icon="pencil-alt"
@click="startEditName(bot)"
>
{{ $t('menu.edit') }}
</XButton>
</template>
<span class="status">{{ bot.status === STATUS_ACTIVE ? $t('admin.users.statusActive') : $t('admin.users.statusDisabled') }}</span>
</div>
<div class="bot-actions">
<XButton
variant="secondary"
@click="toggleBotStatus(bot)"
>
{{ bot.status === STATUS_ACTIVE ? $t('misc.disable') : $t('user.settings.bots.enable') }}
</XButton>
<XButton
variant="tertiary"
class="is-danger"
@click="() => {botToDelete = bot; showDeleteModal = true}"
>
{{ $t('misc.delete') }}
</XButton>
</div>
<div class="tokens">
<h4>{{ $t('user.settings.apiTokens.title') }}</h4>
<Message
v-if="newTokensByBot[bot.id]"
variant="warning"
>
{{ $t('user.settings.apiTokens.tokenCreatedNotSeeAgain') }}
<code>{{ newTokensByBot[bot.id] }}</code>
</Message>
<div
v-if="(tokensByBot[bot.id] ?? []).length > 0"
class="has-horizontal-overflow"
>
<table class="table">
<thead>
<tr>
<th>{{ $t('user.settings.apiTokens.attributes.title') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.expiresAt') }}</th>
<th>{{ $t('misc.created') }}</th>
<th class="has-text-end">
{{ $t('misc.actions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="token in tokensByBot[bot.id] ?? []"
:key="token.id"
>
<td>{{ token.title }}</td>
<td>{{ formatDisplayDate(token.expiresAt) }}</td>
<td>{{ formatDisplayDate(token.created) }}</td>
<td class="has-text-end">
<XButton
variant="secondary"
@click="deleteToken(bot, token)"
>
{{ $t('misc.delete') }}
</XButton>
</td>
</tr>
</tbody>
</table>
</div>
<ApiTokenForm
v-if="showTokenForm[bot.id]"
:owner-id="bot.id"
@created="(token: IApiToken) => onTokenCreated(bot, token)"
@cancel="showTokenForm[bot.id] = false"
/>
<XButton
v-else
icon="plus"
class="mbe-4"
@click="showTokenForm[bot.id] = true"
>
{{ $t('user.settings.apiTokens.createToken') }}
</XButton>
</div>
</div>
<Modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="deleteBot()"
>
<template #header>
{{ $t('user.settings.bots.delete.header') }}
</template>
<template #text>
<p>
{{ $t('user.settings.bots.delete.text1', {username: botToDelete?.username}) }}<br>
{{ $t('user.settings.bots.delete.text2') }}
</p>
</template>
</Modal>
</div>
</template>
<style lang="scss" scoped>
.bot-card {
padding: 1rem;
margin-block-start: 1rem;
border: 1px solid var(--grey-200);
border-radius: 4px;
}
.bot-header {
display: flex;
gap: .5rem;
align-items: center;
margin-block-end: .5rem;
}
.bot-name-input {
max-inline-size: 16rem;
}
.no-name {
font-style: italic;
color: var(--grey-500);
}
.status {
margin-inline-start: auto;
font-size: .85rem;
color: var(--grey-600);
}
.bot-actions {
display: flex;
gap: .5rem;
margin-block-end: 1rem;
}
.tokens {
margin-block-start: 1rem;
padding-block-start: 1rem;
border-block-start: 1px solid var(--grey-200);
}
.create-form {
display: flex;
flex-direction: column;
gap: .5rem;
margin-block-end: 1rem;
}
</style>

View File

@@ -56,7 +56,7 @@ type APIToken struct {
// The user ID of the token owner. When creating a token for a bot user, set this
// to the bot's ID. If omitted, defaults to the authenticated user.
OwnerID int64 `xorm:"bigint not null" json:"owner_id,omitempty"`
OwnerID int64 `xorm:"bigint not null" json:"owner_id,omitempty" query:"owner_id"`
web.Permissions `xorm:"-" json:"-"`
web.CRUDable `xorm:"-" json:"-"`