feat: add token list
This commit is contained in:
@@ -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",
|
||||
|
||||
+1
@@ -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>
|
||||
Reference in New Issue
Block a user