Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce personal access tokens for authorization #4094

Open
wants to merge 5 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions packages/hoppscotch-common/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@
"remove_telemetry": "Are you sure you want to opt-out of Telemetry?",
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
"save_unsaved_tab": "Do you want to save changes made in this tab?",
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress.",
"delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?"
},
"context_menu": {
"add_parameters": "Add to parameters",
Expand Down Expand Up @@ -270,7 +271,8 @@
"subscription": "Subscriptions are empty",
"team_name": "Workspace name empty",
"teams": "You don't belong to any workspaces",
"tests": "There are no tests for this request"
"tests": "There are no tests for this request",
"access_tokens": "Access tokens are empty"
},
"environment": {
"add_to_global": "Add to Global",
Expand Down Expand Up @@ -342,7 +344,10 @@
"script_fail": "Could not execute pre-request script",
"something_went_wrong": "Something went wrong",
"test_script_fail": "Could not execute post-request script",
"reading_files": "Error while reading one or more files."
"reading_files": "Error while reading one or more files.",
"fetching_access_tokens_list": "Something went wrong while fetching the list of tokens",
"generate_access_token": "Something went wrong while generating the access token",
"delete_access_token": "Something went wrong while deleting the access token"
},
"export": {
"as_json": "Export as JSON",
Expand Down Expand Up @@ -1033,5 +1038,27 @@
"login_to_continue": "Login to continue",
"login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.",
"error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status"
},
"access_tokens": {
"tab_title": "Tokens",
"section_title": "Personal Access Tokens",
"section_description": "Personal access tokens currently helps you connect the CLI to your Hoppscotch account",
"last_used_on": "Last used on",
"expires_on": "Expires on",
"no_expiration": "No expiration",
"expired": "Expired",
"copy_token_warning": "Make sure to copy your personal access token now. You won't be able to see it again!",
"token_purpose": "What's this token for?",
"expiration_label": "Expiration",
"scope_label": "Scope",
"workspace_read_only_access": "Read-only access to workspace data.",
"personal_workspace_access_limitation": "Personal Access Tokens can't access your personal workspace.",
"generate_token": "Generate Token",
"invalid_label": "Please provide a label for the token",
"no_expiration_verbose": "This token will never expire!",
"token_expires_on": "This token will expire on",
"generate_new_token": "Generate new token",
"generate_modal_title": "New Personal Access Token",
"deletion_success": "The access token {label} has been deleted"
}
}
10 changes: 8 additions & 2 deletions packages/hoppscotch-common/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export {}

declare module 'vue' {
export interface GlobalComponents {
AccessTokens: typeof import('./components/accessTokens/index.vue')['default']
AccessTokensGenerate: typeof import('./components/accessTokens/Generate.vue')['default']
AccessTokensGenerateModal: typeof import('./components/accessTokens/GenerateModal.vue')['default']
AccessTokensList: typeof import('./components/accessTokens/List.vue')['default']
AccessTokensOverview: typeof import('./components/accessTokens/Overview.vue')['default']
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppBanner: typeof import('./components/app/Banner.vue')['default']
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
Expand Down Expand Up @@ -148,7 +153,7 @@ declare module 'vue' {
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBrush: (typeof import("~icons/lucide/brush"))["default"]
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
Expand All @@ -158,9 +163,10 @@ declare module 'vue' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: (typeof import("~icons/lucide/rss"))["default"]
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
Loading