feat: introduce personal access tokens for authorization (#4094)

Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
This commit is contained in:
James George
2024-06-23 23:15:31 -07:00
committed by GitHub
parent c2085b8b6f
commit 021ecf17ce
9 changed files with 859 additions and 228 deletions

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>