feat: introduce personal access tokens for authorization (#4094)
Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<HoppSmartModal
|
||||
dialog
|
||||
:title="t('access_tokens.generate_modal_title')"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<template v-if="accessToken">
|
||||
<p class="p-4 mb-4 border rounded-md text-amber-500 border-amber-600">
|
||||
{{ t("access_tokens.copy_token_warning") }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between p-4 mt-4 rounded-md bg-primaryLight"
|
||||
>
|
||||
<div class="text-secondaryDark">{{ accessToken }}</div>
|
||||
<HoppButtonSecondary
|
||||
outline
|
||||
filled
|
||||
:icon="copyIcon"
|
||||
@click="copyAccessToken"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<div class="font-semibold text-secondaryDark">
|
||||
{{ t("action.label") }}
|
||||
</div>
|
||||
<HoppSmartInput
|
||||
v-model="accessTokenLabel"
|
||||
:placeholder="t('access_tokens.token_purpose')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="expiration" class="font-semibold text-secondaryDark">{{
|
||||
t("access_tokens.expiration_label")
|
||||
}}</label>
|
||||
|
||||
<div class="grid items-center grid-cols-2 gap-x-2">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions?.focus()"
|
||||
>
|
||||
<HoppSmartSelectWrapper>
|
||||
<input
|
||||
id="expiration"
|
||||
:value="expiration"
|
||||
readonly
|
||||
class="flex flex-1 px-4 py-2 bg-transparent border rounded cursor-pointer border-divider"
|
||||
/>
|
||||
</HoppSmartSelectWrapper>
|
||||
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
class="flex flex-col focus:outline-none"
|
||||
@keyup.escape="hide"
|
||||
>
|
||||
<HoppSmartItem
|
||||
v-for="expirationOption in Object.keys(expirationOptions)"
|
||||
:key="expirationOption"
|
||||
:label="expirationOption"
|
||||
:icon="
|
||||
expirationOption === expiration
|
||||
? IconCircleDot
|
||||
: IconCircle
|
||||
"
|
||||
:active="expirationOption === expiration"
|
||||
:aria-selected="expirationOption === expiration"
|
||||
@click="
|
||||
() => {
|
||||
expiration = expirationOption
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
|
||||
<span class="text-secondaryLight">{{ expirationDateText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="font-semibold text-secondaryDark">
|
||||
{{ t("access_tokens.scope_label") }}
|
||||
</div>
|
||||
|
||||
<p class="text-secondaryLight">
|
||||
{{ t("access_tokens.workspace_read_only_access") }}<br />
|
||||
{{ t("access_tokens.personal_workspace_access_limitation") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<HoppButtonSecondary
|
||||
v-if="accessToken"
|
||||
:label="t('action.close')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
|
||||
<div v-else class="flex items-center gap-x-2">
|
||||
<HoppButtonPrimary
|
||||
:loading="tokenGenerateActionLoading"
|
||||
filled
|
||||
outline
|
||||
:label="t('access_tokens.generate_token')"
|
||||
@click="generateAccessToken"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { VNodeRef, computed, ref } from "vue"
|
||||
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconCircleDot from "~icons/lucide/circle-dot"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
tokenGenerateActionLoading: boolean
|
||||
accessToken: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
(
|
||||
e: "generate-access-token",
|
||||
{ label, expiryInDays }: { label: string; expiryInDays: number | null }
|
||||
): void
|
||||
}>()
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<VNodeRef | null>(null)
|
||||
|
||||
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
const accessTokenLabel = ref<string>("")
|
||||
const expiration = ref<string>("30 days")
|
||||
|
||||
const expirationOptions: Record<string, number | null> = {
|
||||
"7 days": 7,
|
||||
"30 days": 30,
|
||||
"60 days": 60,
|
||||
"90 days": 90,
|
||||
"No expiration": null,
|
||||
}
|
||||
|
||||
const expirationDateText = computed(() => {
|
||||
const chosenExpiryInDays = expirationOptions[expiration.value]
|
||||
|
||||
if (chosenExpiryInDays === null) {
|
||||
return t("access_tokens.no_expiration_verbose")
|
||||
}
|
||||
|
||||
const currentDate = new Date()
|
||||
currentDate.setDate(currentDate.getDate() + chosenExpiryInDays)
|
||||
|
||||
const expirationDate = shortDateTime(currentDate, false)
|
||||
return `${t("access_tokens.token_expires_on")} ${expirationDate}`
|
||||
})
|
||||
|
||||
const copyAccessToken = () => {
|
||||
if (!props.accessToken) {
|
||||
toast.error("error.something_went_wrong")
|
||||
return
|
||||
}
|
||||
|
||||
copyToClipboard(props.accessToken)
|
||||
copyIcon.value = IconCheck
|
||||
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
const generateAccessToken = async () => {
|
||||
if (!accessTokenLabel.value) {
|
||||
toast.error(t("access_tokens.invalid_label"))
|
||||
return
|
||||
}
|
||||
|
||||
emit("generate-access-token", {
|
||||
label: accessTokenLabel.value,
|
||||
expiryInDays: expirationOptions[expiration.value],
|
||||
})
|
||||
}
|
||||
|
||||
const hideModal = () => emit("hide-modal")
|
||||
</script>
|
||||
128
packages/hoppscotch-common/src/components/accessTokens/List.vue
Normal file
128
packages/hoppscotch-common/src/components/accessTokens/List.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div v-if="isInitialPageLoad" class="flex flex-col items-center py-3">
|
||||
<HoppSmartSpinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="initialPageLoadHasError"
|
||||
class="flex flex-col items-center py-4"
|
||||
>
|
||||
<icon-lucide-help-circle class="mb-4 svg-icons" />
|
||||
{{ t("error.something_went_wrong") }}
|
||||
</div>
|
||||
|
||||
<HoppSmartPlaceholder
|
||||
v-else-if="accessTokens.length === 0"
|
||||
:src="`/images/states/${colorMode}/pack.svg`"
|
||||
:alt="`${t('empty.access_tokens')}`"
|
||||
:text="t('empty.access_tokens')"
|
||||
@drop.stop
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-4 p-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="{ id, label, lastUsedOn, expiresOn } in accessTokens"
|
||||
:key="id"
|
||||
class="flex flex-col items-center gap-4 p-4 border rounded border-divider"
|
||||
>
|
||||
<div class="w-full text-sm font-semibold truncate text-secondaryDark">
|
||||
{{ label }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between w-full gap-x-4">
|
||||
<div class="space-y-1 text-secondaryLight">
|
||||
<div class="space-x-1">
|
||||
<span class="font-semibold"
|
||||
>{{ t("access_tokens.last_used_on") }}:</span
|
||||
>
|
||||
<span>
|
||||
{{ shortDateTime(lastUsedOn, false) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-x-1">
|
||||
<span class="font-semibold"
|
||||
>{{ t("access_tokens.expires_on") }}:</span
|
||||
>
|
||||
<span>
|
||||
{{ getTokenExpiryText(expiresOn) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.delete')"
|
||||
filled
|
||||
outline
|
||||
@click="
|
||||
emit('delete-access-token', {
|
||||
tokenId: id,
|
||||
tokenLabel: label,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HoppSmartIntersection
|
||||
v-if="hasMoreTokens"
|
||||
@intersecting="emit('fetch-more-tokens')"
|
||||
>
|
||||
<div v-if="loading" class="flex flex-col items-center py-3">
|
||||
<HoppSmartSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasError" class="flex flex-col items-center py-4">
|
||||
<icon-lucide-help-circle class="mb-4 svg-icons" />
|
||||
{{ t("error.something_went_wrong") }}
|
||||
</div>
|
||||
</HoppSmartIntersection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@vueuse/core"
|
||||
import { computed } from "vue"
|
||||
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
import { AccessToken } from "./index.vue"
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
accessTokens: AccessToken[]
|
||||
hasMoreTokens: boolean
|
||||
loading: boolean
|
||||
hasError: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "fetch-more-tokens"): void
|
||||
(
|
||||
e: "delete-access-token",
|
||||
{ tokenId, tokenLabel }: { tokenId: string; tokenLabel: string }
|
||||
): void
|
||||
}>()
|
||||
|
||||
const isInitialPageLoad = computed(() => props.loading && !props.hasMoreTokens)
|
||||
const initialPageLoadHasError = computed(
|
||||
() => props.hasError && !props.hasMoreTokens
|
||||
)
|
||||
|
||||
const getTokenExpiryText = (tokenExpiresOn: string | null) => {
|
||||
if (!tokenExpiresOn) {
|
||||
return t("access_tokens.no_expiration")
|
||||
}
|
||||
|
||||
const isTokenExpired = new Date(tokenExpiresOn).toISOString() > tokenExpiresOn
|
||||
|
||||
return isTokenExpired
|
||||
? t("access_tokens.expired")
|
||||
: shortDateTime(tokenExpiresOn, false)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="space-y-1">
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t("access_tokens.section_title") }}
|
||||
</h4>
|
||||
|
||||
<p class="text-secondaryLight">
|
||||
{{ t("access_tokens.section_description") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HoppButtonSecondary
|
||||
filled
|
||||
outline
|
||||
:label="t('access_tokens.generate_new_token')"
|
||||
@click="emit('show-access-tokens-generate-modal')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "show-access-tokens-generate-modal"): void
|
||||
}>()
|
||||
</script>
|
||||
208
packages/hoppscotch-common/src/components/accessTokens/index.vue
Normal file
208
packages/hoppscotch-common/src/components/accessTokens/index.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<AccessTokensOverview
|
||||
@show-access-tokens-generate-modal="showAccessTokensGenerateModal = true"
|
||||
/>
|
||||
|
||||
<AccessTokensList
|
||||
:access-tokens="accessTokens"
|
||||
:has-error="tokensListFetchErrored"
|
||||
:has-more-tokens="hasMoreTokens"
|
||||
:loading="tokensListLoading"
|
||||
@delete-access-token="displayDeleteAccessTokenConfirmationModal"
|
||||
@fetch-more-tokens="fetchAccessTokens"
|
||||
/>
|
||||
|
||||
<AccessTokensGenerateModal
|
||||
v-if="showAccessTokensGenerateModal"
|
||||
:access-token="accessToken"
|
||||
:token-generate-action-loading="tokenGenerateActionLoading"
|
||||
@generate-access-token="generateAccessToken"
|
||||
@hide-modal="hideAccessTokenGenerateModal"
|
||||
/>
|
||||
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmDeleteAccessToken"
|
||||
:loading-state="tokenDeleteActionLoading"
|
||||
:title="
|
||||
t('confirm.delete_access_token', { tokenLabel: tokenToDelete?.label })
|
||||
"
|
||||
@hide-modal="confirmDeleteAccessToken = false"
|
||||
@resolve="deleteAccessToken"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import axios from "axios"
|
||||
import { Ref, onMounted, ref } from "vue"
|
||||
import { platform } from "~/platform"
|
||||
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
export type AccessToken = {
|
||||
id: string
|
||||
label: string
|
||||
createdOn: string
|
||||
lastUsedOn: string
|
||||
expiresOn: string | null
|
||||
}
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const confirmDeleteAccessToken = ref(false)
|
||||
const hasMoreTokens = ref(false)
|
||||
const showAccessTokensGenerateModal = ref(false)
|
||||
const tokenDeleteActionLoading = ref(false)
|
||||
const tokenGenerateActionLoading = ref(false)
|
||||
const tokensListFetchErrored = ref(false)
|
||||
const tokensListLoading = ref(false)
|
||||
|
||||
const accessToken: Ref<string | null> = ref(null)
|
||||
const tokenToDelete = ref<{ id: string; label: string } | null>(null)
|
||||
|
||||
const accessTokens: Ref<AccessToken[]> = ref([])
|
||||
|
||||
const limit = 12
|
||||
let offset = 0
|
||||
|
||||
const endpointPrefix = `${import.meta.env.VITE_BACKEND_API_URL}/access-tokens`
|
||||
|
||||
const getAxiosPlatformConfig = async () => {
|
||||
await platform.auth.waitProbableLoginToConfirm()
|
||||
return platform.auth.axiosPlatformConfig?.() ?? {}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAccessTokens()
|
||||
})
|
||||
|
||||
const fetchAccessTokens = async () => {
|
||||
tokensListLoading.value = true
|
||||
|
||||
const axiosConfig = await getAxiosPlatformConfig()
|
||||
const endpoint = `${endpointPrefix}/list?offset=${offset}&limit=${limit}`
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(endpoint, axiosConfig)
|
||||
|
||||
accessTokens.value.push(...data)
|
||||
|
||||
if (data.length > 0) {
|
||||
offset += data.length
|
||||
}
|
||||
|
||||
hasMoreTokens.value = data.length === limit
|
||||
|
||||
if (tokensListFetchErrored.value) {
|
||||
tokensListFetchErrored.value = false
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t("error.fetching_access_tokens_list"))
|
||||
tokensListFetchErrored.value = true
|
||||
} finally {
|
||||
tokensListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateAccessToken = async ({
|
||||
label,
|
||||
expiryInDays,
|
||||
}: {
|
||||
label: string
|
||||
expiryInDays: number | null
|
||||
}) => {
|
||||
tokenGenerateActionLoading.value = true
|
||||
|
||||
const axiosConfig = await getAxiosPlatformConfig()
|
||||
const endpoint = `${endpointPrefix}/create`
|
||||
|
||||
const body = {
|
||||
label,
|
||||
expiryInDays,
|
||||
}
|
||||
|
||||
try {
|
||||
const { data }: { data: { token: string; info: AccessToken } } =
|
||||
await axios.post(endpoint, body, axiosConfig)
|
||||
|
||||
accessTokens.value.unshift(data.info)
|
||||
accessToken.value = data.token
|
||||
|
||||
// Incrementing the offset value by 1 to account for the newly generated token
|
||||
offset += 1
|
||||
|
||||
// Toggle the error state in case it was set
|
||||
if (tokensListFetchErrored.value) {
|
||||
tokensListFetchErrored.value = false
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t("error.generate_access_token"))
|
||||
showAccessTokensGenerateModal.value = false
|
||||
} finally {
|
||||
tokenGenerateActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAccessToken = async () => {
|
||||
if (tokenToDelete.value === null) {
|
||||
toast.error(t("error.something_went_wrong"))
|
||||
return
|
||||
}
|
||||
|
||||
const { id: tokenIdToDelete, label: tokenLabelToDelete } = tokenToDelete.value
|
||||
|
||||
tokenDeleteActionLoading.value = true
|
||||
|
||||
const axiosConfig = await getAxiosPlatformConfig()
|
||||
const endpoint = `${endpointPrefix}/revoke?id=${tokenIdToDelete}`
|
||||
|
||||
try {
|
||||
await axios.delete(endpoint, axiosConfig)
|
||||
|
||||
accessTokens.value = accessTokens.value.filter(
|
||||
(token) => token.id !== tokenIdToDelete
|
||||
)
|
||||
|
||||
// Decreasing the offset value by 1 to account for the deleted token
|
||||
offset = offset > 0 ? offset - 1 : offset
|
||||
|
||||
toast.success(
|
||||
t("access_tokens.deletion_success", { label: tokenLabelToDelete })
|
||||
)
|
||||
|
||||
// Toggle the error state in case it was set
|
||||
if (tokensListFetchErrored.value) {
|
||||
tokensListFetchErrored.value = false
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t("error.delete_access_token"))
|
||||
} finally {
|
||||
tokenDeleteActionLoading.value = false
|
||||
confirmDeleteAccessToken.value = false
|
||||
|
||||
tokenToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const hideAccessTokenGenerateModal = () => {
|
||||
// Reset the reactive state variable holding access token value and hide the modal
|
||||
accessToken.value = null
|
||||
showAccessTokensGenerateModal.value = false
|
||||
}
|
||||
|
||||
const displayDeleteAccessTokenConfirmationModal = ({
|
||||
tokenId,
|
||||
tokenLabel,
|
||||
}: {
|
||||
tokenId: string
|
||||
tokenLabel: string
|
||||
}) => {
|
||||
confirmDeleteAccessToken.value = true
|
||||
|
||||
tokenToDelete.value = {
|
||||
id: tokenId,
|
||||
label: tokenLabel,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user