mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-05-10 15:15:41 -05:00
feat(frontend): add bot settings page and services
This commit is contained in:
@@ -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>
|
||||
|
||||
402
frontend/src/components/token/ApiTokenForm.vue
Normal file
402
frontend/src/components/token/ApiTokenForm.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -11,4 +11,5 @@ export interface IApiToken extends IAbstract {
|
||||
permissions: IApiPermission
|
||||
expiresAt: Date
|
||||
created: Date
|
||||
ownerId?: number
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
19
frontend/src/services/botUser.ts
Normal file
19
frontend/src/services/botUser.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
362
frontend/src/views/user/settings/BotUsers.vue
Normal file
362
frontend/src/views/user/settings/BotUsers.vue
Normal 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>
|
||||
@@ -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:"-"`
|
||||
|
||||
Reference in New Issue
Block a user