feat: add token list

This commit is contained in:
Shivam Mishra
2026-05-25 16:24:58 +05:30
parent b0aa51e811
commit c816e6b330
5 changed files with 264 additions and 21 deletions
@@ -20,6 +20,9 @@ const FEATURE_HELP_URLS = {
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
access_token_api:
'https://developers.chatwoot.com/api-reference/introduction',
chatwoot_cli: 'https://developers.chatwoot.com/cli',
};
export function getHelpUrlForFeature(featureName) {
@@ -92,8 +92,25 @@
"COPY": "Copy",
"RESET": "Reset",
"GENERATE": "Generate",
"CREATE": "Create token",
"CONFIRM_RESET": "Are you sure?",
"CONFIRM_HINT": "Click again to confirm",
"TABLE": {
"TOKEN": "Token",
"PERMISSION": "Permission"
},
"EMPTY": {
"TITLE": "No tokens yet",
"DESCRIPTION": "Create your first access token to start building integrations against the API."
},
"SCOPES": {
"FULL_LABEL": "Full access",
"READ_ONLY_LABEL": "Read only"
},
"LINKS": {
"API_REFERENCE": "API Reference",
"CLI": "Chatwoot CLI"
},
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again",
"READ_ONLY_TITLE": "Read-only Access Token",
@@ -45,6 +45,7 @@ const { t } = useI18n();
>
<slot name="description">{{ description }}</slot>
</p>
<slot name="links" />
</div>
<div class="col-span-1">
<slot name="headerActions" />
@@ -8,6 +8,10 @@ import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { parseBoolean } from '@chatwoot/utils';
import { OnClickOutside } from '@vueuse/components';
import { getHelpUrlForFeature } from 'dashboard/helper/featureHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
@@ -18,7 +22,7 @@ import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import SectionLayout from '../account/components/SectionLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import TokenList from './TokenList.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import Policy from 'dashboard/components/policy.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
@@ -40,9 +44,12 @@ export default {
ChangePassword,
NotificationPreferences,
AudioNotifications,
AccessToken,
TokenList,
MfaSettingsCard,
BaseSettingsHeader,
OnClickOutside,
NextButton,
DropdownMenu,
},
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
@@ -92,6 +99,7 @@ export default {
],
notificationPermissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
audioNotificationPermissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
isCreateTokenMenuOpen: false,
};
},
computed: {
@@ -103,6 +111,41 @@ export default {
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
accessTokens() {
return [
{ scope: 'full', value: this.currentUser.access_token },
{ scope: 'read_only', value: this.currentUser.read_only_access_token },
].filter(token => token.value);
},
apiReferenceUrl() {
return getHelpUrlForFeature('access_token_api');
},
cliUrl() {
return getHelpUrlForFeature('chatwoot_cli');
},
createTokenMenuItems() {
// Each user holds at most one token per scope, so only offer scopes
// that don't have a token yet.
const presentScopes = this.accessTokens.map(token => token.scope);
return [
{
label: this.$t(
'PROFILE_SETTINGS.FORM.ACCESS_TOKEN.SCOPES.FULL_LABEL'
),
icon: 'i-lucide-shield-check',
value: 'full',
},
{
label: this.$t(
'PROFILE_SETTINGS.FORM.ACCESS_TOKEN.SCOPES.READ_ONLY_LABEL'
),
icon: 'i-lucide-eye',
value: 'read_only',
},
]
.filter(item => !presentScopes.includes(item.value))
.map(item => ({ ...item, action: 'create' }));
},
},
mounted() {
if (this.currentUserId) {
@@ -192,6 +235,17 @@ export default {
await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
onCreateToken({ value }) {
this.isCreateTokenMenuOpen = false;
this.resetToken(value);
},
async resetToken(scope) {
if (scope === 'read_only') {
await this.resetReadOnlyAccessToken();
} else {
await this.resetAccessToken();
}
},
async resetAccessToken() {
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
@@ -353,25 +407,52 @@ export default {
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
"
>
<AccessToken
:value="currentUser.access_token"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
</SectionLayout>
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.READ_ONLY_TITLE')"
:description="
replaceInstallationName(
$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.READ_ONLY_NOTE')
)
"
>
<AccessToken
:value="currentUser.read_only_access_token"
@on-copy="onCopyToken"
@on-reset="resetReadOnlyAccessToken"
<template #links>
<div class="flex items-center gap-2 mt-2 text-xs">
<a
:href="apiReferenceUrl"
target="_blank"
rel="noopener noreferrer"
class="text-body-main text-n-blue-11 hover:underline"
>
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.LINKS.API_REFERENCE') }}
</a>
<span class="w-px h-3 bg-n-slate-6" aria-hidden="true" />
<a
:href="cliUrl"
target="_blank"
rel="noopener noreferrer"
class="text-body-main text-n-blue-11 hover:underline"
>
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.LINKS.CLI') }}
</a>
</div>
</template>
<template v-if="createTokenMenuItems.length" #headerActions>
<OnClickOutside @trigger="isCreateTokenMenuOpen = false">
<div class="relative flex justify-end">
<NextButton
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CREATE')"
icon="i-lucide-plus"
size="sm"
blue
solid
class="rounded-lg"
@click="isCreateTokenMenuOpen = !isCreateTokenMenuOpen"
/>
<DropdownMenu
v-if="isCreateTokenMenuOpen"
:menu-items="createTokenMenuItems"
class="ltr:right-0 rtl:left-0 top-10"
@action="onCreateToken"
/>
</div>
</OnClickOutside>
</template>
<TokenList
:tokens="accessTokens"
@copy="onCopyToken"
@reset="resetToken"
/>
</SectionLayout>
</div>
@@ -0,0 +1,141 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
defineProps({
tokens: {
// [{ scope: 'full' | 'read_only', value: String }]
type: Array,
default: () => [],
},
});
const emit = defineEmits(['copy', 'reset']);
const { t } = useI18n();
const tk = key => t(`PROFILE_SETTINGS.FORM.ACCESS_TOKEN.${key}`);
const SCOPE_META = {
full: {
label: () => tk('SCOPES.FULL_LABEL'),
icon: 'i-lucide-shield-check',
badgeClass: 'bg-n-teal-3 text-n-teal-11',
},
read_only: {
label: () => tk('SCOPES.READ_ONLY_LABEL'),
icon: 'i-lucide-eye',
badgeClass: 'bg-n-alpha-2 text-n-slate-11 outline outline-1 outline-n-weak',
},
};
// track reveal state per scope
const revealedScopes = ref([]);
const isRevealed = scope => revealedScopes.value.includes(scope);
const toggleReveal = scope => {
revealedScopes.value = isRevealed(scope)
? revealedScopes.value.filter(s => s !== scope)
: [...revealedScopes.value, scope];
};
const maskToken = value =>
`${value.slice(0, 4)}${'•'.repeat(20)}${value.slice(-4)}`;
</script>
<template>
<div class="overflow-hidden rounded-xl outline outline-1 outline-n-weak">
<div
v-if="!tokens.length"
class="flex flex-col items-center justify-center gap-1 px-4 py-12 text-center"
>
<span
class="grid mb-2 rounded-lg size-10 place-items-center bg-n-alpha-1 text-n-slate-11"
>
<span class="i-lucide-key-round size-5" />
</span>
<p class="text-sm font-medium text-n-slate-12">
{{ tk('EMPTY.TITLE') }}
</p>
<p class="max-w-xs text-sm text-n-slate-11">
{{ tk('EMPTY.DESCRIPTION') }}
</p>
</div>
<table v-else class="min-w-full text-sm border-collapse">
<thead>
<tr class="bg-n-alpha-1 text-n-slate-11">
<th
class="px-4 py-2.5 font-medium text-start text-xs uppercase tracking-wide"
>
{{ tk('TABLE.TOKEN') }}
</th>
<th
class="px-4 py-2.5 font-medium text-start text-xs uppercase tracking-wide"
>
{{ tk('TABLE.PERMISSION') }}
</th>
<th class="px-4 py-2.5 w-40" />
</tr>
</thead>
<tbody class="divide-y divide-n-weak">
<tr
v-for="token in tokens"
:key="token.scope"
class="border-t border-n-weak"
>
<td class="px-4 py-3">
<div class="flex items-center gap-1.5 min-w-0">
<code
class="px-2 py-1 font-mono text-xs rounded-md bg-n-alpha-1 text-n-slate-12 outline outline-1 outline-n-weak max-w-xs truncate"
>
{{
isRevealed(token.scope) ? token.value : maskToken(token.value)
}}
</code>
<NextButton
:icon="
isRevealed(token.scope) ? 'i-lucide-eye-off' : 'i-lucide-eye'
"
size="xs"
slate
ghost
class="shrink-0"
@click="toggleReveal(token.scope)"
/>
<NextButton
icon="i-lucide-copy"
size="xs"
slate
ghost
class="shrink-0"
@click="emit('copy', token.value)"
/>
</div>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full"
:class="SCOPE_META[token.scope].badgeClass"
>
<span :class="SCOPE_META[token.scope].icon" class="size-3" />
{{ SCOPE_META[token.scope].label() }}
</span>
</td>
<td class="px-4 py-3 text-end">
<ConfirmButton
:label="tk('RESET')"
:confirm-label="tk('CONFIRM_RESET')"
color="slate"
confirm-color="ruby"
variant="outline"
size="xs"
icon="i-lucide-key-round"
@click="emit('reset', token.scope)"
/>
</td>
</tr>
</tbody>
</table>
</div>
</template>