diff --git a/messages/en.json b/messages/en.json
index b9f47e608d..5c36f9e2c2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1708,6 +1708,7 @@
"search-by-version": "Search by version",
"search-channels": "Search channel",
"search-groups": "Search groups",
+ "search-scope-items": "Search scopes",
"search-members": "Search members",
"search-organizations": "Search organizations",
"search-role-bindings": "Search by user, group or role...",
diff --git a/src/components/DataTable.vue b/src/components/DataTable.vue
index 234ef45c92..f957f12e31 100644
--- a/src/components/DataTable.vue
+++ b/src/components/DataTable.vue
@@ -30,6 +30,7 @@ interface Props {
isLoading?: boolean
filterText?: string
filters?: { [key: string]: boolean }
+ filterLabels?: { [key: string]: string }
searchPlaceholder?: string
showAdd?: boolean
addButtonTestId?: string
@@ -94,6 +95,10 @@ const filterActivated = computed(() => {
}, 0)
})
+function getFilterLabel(filter: string) {
+ return props.filterLabels?.[filter] ?? t(filter)
+}
+
function sortClick(key: number) {
if (!props.columns[key].sortable)
return
@@ -125,10 +130,10 @@ function updateUrlParams() {
params.set('search', searchVal.value)
else params.delete('search')
if (props.filters) {
+ params.delete('filter')
Object.entries(props.filters).forEach(([key, value]) => {
if (value)
params.append('filter', key)
- else params.delete('filter', key)
})
}
if (props.currentPage)
@@ -172,7 +177,6 @@ function loadFromUrlParams() {
newFilters[key] = filterParams.includes(key)
})
if (JSON.stringify(newFilters) !== JSON.stringify(props.filters)) {
- console.log('update filters', newFilters, props.filters)
emit('update:filters', newFilters)
}
}
@@ -497,10 +501,10 @@ const paginationClass = computed(() => props.mobileFixedPagination
{{ t(filterText) }}
-
+
diff --git a/src/pages/ApiKeys.vue b/src/pages/ApiKeys.vue
index fe39eaf405..04daeafddd 100644
--- a/src/pages/ApiKeys.vue
+++ b/src/pages/ApiKeys.vue
@@ -5,7 +5,7 @@ import { FormKit } from '@formkit/vue'
import { VueDatePicker } from '@vuepic/vue-datepicker'
import { useDark } from '@vueuse/core'
import dayjs from 'dayjs'
-import { computed, ref, watch } from 'vue'
+import { computed, h, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import IconArrowPath from '~icons/heroicons/arrow-path'
@@ -13,10 +13,10 @@ import IconCalendar from '~icons/heroicons/calendar'
import IconClipboard from '~icons/heroicons/clipboard-document'
import IconPencil from '~icons/heroicons/pencil'
import IconTrash from '~icons/heroicons/trash'
+import IconXMark from '~icons/heroicons/x-mark'
import {
confirmApiKeyDeletion,
confirmApiKeyRegeneration,
- formatApiKeyScope,
isApiKeyExpired,
showApiKeySecretModal,
sortApiKeyRows,
@@ -48,6 +48,21 @@ interface RoleBindingRow {
role_name: string
}
+interface ScopeBadgeItem {
+ id: string
+ label: string
+ filterId?: string | null
+ type?: 'org' | 'app'
+}
+
+interface ScopePickerState {
+ title: string
+ items: ScopeBadgeItem[]
+ x: number
+ y: number
+ width: number
+}
+
const { t } = useI18n()
const isDark = useDark()
const dialogStore = useDialogV2Store()
@@ -55,9 +70,16 @@ const displayStore = useDisplayStore()
const main = useMainStore()
const currentPage = ref(1)
const isLoading = ref(false)
+const scopeFilters = ref>({})
+const hasUserChangedScopeFilters = ref(false)
+const hasInitialScopeFilterInUrl = new URLSearchParams(window.location.search).has('filter')
+const defaultScopeFilterKey = ref(null)
+const scopePicker = ref(null)
+const scopePickerQuery = ref('')
const supabase = useSupabase()
const keys = ref([])
const organizationStore = useOrganizationStore()
+const currentOrganizationId = computed(() => organizationStore.currentOrganization?.gid ?? null)
const columns: Ref = ref([])
// RBAC data
@@ -93,6 +115,7 @@ const minExpirationDate = computed(() => {
// Cache for organization and app names
const orgCache = ref(new Map())
const appCache = ref(new Map())
+const organizationNameById = computed(() => new Map(organizationStore.organizations.map(org => [org.gid, org.name])))
// Function to truncate strings (show first 5 and last 5 characters)
function hideString(str: string | null) {
@@ -163,8 +186,208 @@ function getDisplayAppIds(key: Database['public']['Tables']['apikeys']['Row']):
return getRbacAppBindingIds(key)
}
+function getDisplayOrgItems(key: Database['public']['Tables']['apikeys']['Row']): ScopeBadgeItem[] {
+ const orgIds = getDisplayOrgIds(key)
+ if (coversAllOrganizations(orgIds)) {
+ return [{ id: 'all', label: t('key-all'), filterId: null, type: 'org' }]
+ }
+ return orgIds.map(orgId => ({
+ id: orgId,
+ label: orgCache.value.get(orgId) || t('unknown'),
+ filterId: orgId,
+ type: 'org',
+ }))
+}
+
+function getDisplayOrgNames(key: Database['public']['Tables']['apikeys']['Row']): string[] {
+ return getDisplayOrgItems(key).map(item => item.label)
+}
+
+function getDisplayAppItems(key: Database['public']['Tables']['apikeys']['Row']): ScopeBadgeItem[] {
+ return getDisplayAppIds(key).map(appId => ({
+ id: appId,
+ label: appCache.value.get(appId) || t('unknown'),
+ filterId: appId,
+ type: 'app',
+ }))
+}
+
+function getDisplayAppNames(key: Database['public']['Tables']['apikeys']['Row']): string[] {
+ return getDisplayAppItems(key).map(item => item.label)
+}
+
function formatDisplayApps(key: Database['public']['Tables']['apikeys']['Row']) {
- return getDisplayAppIds(key).map(appId => appCache.value.get(appId) || 'Unknown').join(', ')
+ return getDisplayAppNames(key).join(', ')
+}
+
+function formatDisplayOrganizations(key: Database['public']['Tables']['apikeys']['Row']) {
+ return getDisplayOrgNames(key).join(', ')
+}
+
+function capitalizeLabel(label: string) {
+ return label.charAt(0).toUpperCase() + label.slice(1)
+}
+
+function makeScopeFilterKey(type: 'org' | 'app', id: string) {
+ return `${type}:${id}`
+}
+
+function parseScopeFilterKey(key: string) {
+ const [type, id] = key.split(':')
+ if ((type !== 'org' && type !== 'app') || !id)
+ return null
+ return { type, id } as const
+}
+
+function clearScopeFilters(markUserChange = true) {
+ if (markUserChange)
+ hasUserChangedScopeFilters.value = true
+
+ scopeFilters.value = Object.fromEntries(
+ Object.keys(scopeFilters.value).map(key => [key, false]),
+ )
+ currentPage.value = 1
+}
+
+function setSingleScopeFilter(type: 'org' | 'app', id: string | null, markUserChange = true) {
+ if (markUserChange)
+ hasUserChangedScopeFilters.value = true
+
+ if (id === null) {
+ clearScopeFilters(markUserChange)
+ return
+ }
+
+ const selectedKey = makeScopeFilterKey(type, id)
+ const filterKeys = Array.from(new Set([...Object.keys(scopeFilters.value), selectedKey]))
+ scopeFilters.value = Object.fromEntries(
+ filterKeys.map(key => [key, key === selectedKey]),
+ )
+ currentPage.value = 1
+}
+
+function updateScopeFilters(filters: Record) {
+ hasUserChangedScopeFilters.value = true
+ scopeFilters.value = filters
+ currentPage.value = 1
+}
+
+function isFilterableScopeItem(item: ScopeBadgeItem): item is ScopeBadgeItem & { type: 'org' | 'app', filterId: string | null } {
+ return !!item.type && Object.hasOwn(item, 'filterId')
+}
+
+function isScopeItemActive(item: ScopeBadgeItem) {
+ if (!isFilterableScopeItem(item))
+ return false
+ if (item.filterId === null)
+ return !Object.values(scopeFilters.value).some(Boolean)
+ return !!scopeFilters.value[makeScopeFilterKey(item.type, item.filterId)]
+}
+
+function closeScopePicker() {
+ scopePicker.value = null
+ scopePickerQuery.value = ''
+}
+
+function selectScopeItem(item: ScopeBadgeItem) {
+ if (!isFilterableScopeItem(item))
+ return
+
+ setSingleScopeFilter(item.type, item.filterId)
+ closeScopePicker()
+}
+
+function openScopePicker(event: MouseEvent, label: string, items: ScopeBadgeItem[]) {
+ const trigger = event.currentTarget as HTMLElement
+ const rect = trigger.getBoundingClientRect()
+ const width = Math.min(340, window.innerWidth - 32)
+ const estimatedHeight = Math.min(384, 76 + items.length * 44)
+ const x = Math.min(Math.max(16, rect.left), window.innerWidth - width - 16)
+ const belowY = rect.bottom + 8
+ const y = belowY + estimatedHeight <= window.innerHeight
+ ? belowY
+ : Math.max(16, rect.top - estimatedHeight - 8)
+
+ scopePicker.value = {
+ title: `${capitalizeLabel(label)} (${items.length})`,
+ items,
+ x,
+ y,
+ width,
+ }
+ scopePickerQuery.value = ''
+}
+
+const filteredScopePickerItems = computed(() => {
+ if (!scopePicker.value)
+ return []
+
+ const query = scopePickerQuery.value.trim().toLowerCase()
+ if (!query)
+ return scopePicker.value.items
+
+ return scopePicker.value.items.filter(item => item.label.toLowerCase().includes(query))
+})
+
+function renderScopeBadges(items: ScopeBadgeItem[], visibleCount: number, overflowLabel: string) {
+ const cleanItems = items.filter(item => item.label)
+ if (cleanItems.length === 0) {
+ return h('span', {
+ class: 'text-slate-400 dark:text-slate-500',
+ }, '-')
+ }
+
+ const visibleItems = cleanItems.slice(0, visibleCount)
+ const hiddenItems = cleanItems.slice(visibleCount)
+ const fullLabel = cleanItems.map(item => item.label).join(', ')
+
+ return h('div', {
+ 'class': 'flex min-w-0 max-w-full items-center gap-1.5 overflow-hidden',
+ 'title': fullLabel,
+ 'aria-label': fullLabel,
+ }, [
+ ...visibleItems.map((item) => {
+ const isFilterable = isFilterableScopeItem(item)
+ const isActive = isScopeItemActive(item)
+ const chipClass = [
+ 'min-w-0 max-w-[9rem] truncate rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:ring-offset-1 dark:focus:ring-offset-slate-800',
+ isActive
+ ? 'border-cyan-300 bg-cyan-50 text-cyan-700 dark:border-cyan-500/40 dark:bg-cyan-500/15 dark:text-cyan-200'
+ : 'border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-600 dark:bg-slate-700/60 dark:text-slate-200',
+ isFilterable ? 'cursor-pointer hover:border-cyan-300 hover:text-cyan-700 dark:hover:border-cyan-500/40 dark:hover:text-cyan-200' : '',
+ ].join(' ')
+
+ if (!isFilterable) {
+ return h('span', {
+ class: chipClass,
+ title: item.label,
+ }, item.label)
+ }
+
+ return h('button', {
+ type: 'button',
+ class: chipClass,
+ title: item.label,
+ onClick: (event: MouseEvent) => {
+ event.stopPropagation()
+ selectScopeItem(item)
+ },
+ }, item.label)
+ }),
+ hiddenItems.length > 0
+ ? h('button', {
+ 'type': 'button',
+ 'class': 'shrink-0 rounded-md border border-cyan-200 bg-cyan-50 px-2 py-0.5 text-xs font-semibold text-cyan-700 dark:border-cyan-500/30 dark:bg-cyan-500/10 dark:text-cyan-200',
+ 'title': hiddenItems.map(item => item.label).join(', '),
+ 'aria-label': `${hiddenItems.length} ${overflowLabel}`,
+ 'aria-haspopup': 'dialog',
+ 'onClick': (event: MouseEvent) => {
+ event.stopPropagation()
+ openScopePicker(event, overflowLabel, cleanItems)
+ },
+ }, `+${hiddenItems.length}`)
+ : null,
+ ])
}
function cacheAppNames(apps: { id: string | null, app_id: string, name: string | null }[]) {
@@ -178,7 +401,7 @@ function cacheAppNames(apps: { id: string | null, app_id: string, name: string |
}
function getOrgNameById(orgId: string) {
- return orgCache.value.get(orgId) || orgId
+ return orgCache.value.get(orgId) || organizationNameById.value.get(orgId) || orgId
}
const selectedOrgNamesForCreation = computed(() => {
@@ -194,6 +417,9 @@ const uniqueOrgIds = computed(() => {
return new Set()
const orgIds = new Set()
+ if (currentOrganizationId.value)
+ orgIds.add(currentOrganizationId.value)
+
allBindings.value.forEach((b) => {
if (b.org_id)
orgIds.add(b.org_id)
@@ -216,10 +442,74 @@ const uniqueAppIds = computed(() => {
return appIds
})
-// Helper computed property to get organization name by ID
-const getOrgName = computed(() => {
- return (orgId: string) => orgCache.value.get(orgId) || 'Unknown'
-})
+const orgFilterOptions = computed(() => Array.from(uniqueOrgIds.value)
+ .map(orgId => ({
+ id: orgId,
+ name: orgCache.value.get(orgId) || getOrgNameById(orgId),
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name)))
+
+const appFilterOptions = computed(() => Array.from(uniqueAppIds.value)
+ .map(appId => ({
+ id: appId,
+ name: appCache.value.get(appId) || t('unknown'),
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name)))
+
+const scopeFilterLabels = computed(() => ({
+ ...Object.fromEntries(orgFilterOptions.value.map(org => [
+ makeScopeFilterKey('org', org.id),
+ `${capitalizeLabel(t('organizations'))}: ${org.name}`,
+ ])),
+ ...Object.fromEntries(appFilterOptions.value.map(app => [
+ makeScopeFilterKey('app', app.id),
+ `${capitalizeLabel(t('apps'))}: ${app.name}`,
+ ])),
+}))
+
+function syncScopeFilters() {
+ const labels = scopeFilterLabels.value
+ const nextFilters = Object.fromEntries(
+ Object.keys(labels).map(key => [key, scopeFilters.value[key] ?? false]),
+ )
+
+ if (JSON.stringify(nextFilters) !== JSON.stringify(scopeFilters.value))
+ scopeFilters.value = nextFilters
+}
+
+function applyCurrentOrganizationDefaultFilter() {
+ if (hasInitialScopeFilterInUrl || hasUserChangedScopeFilters.value)
+ return
+
+ const orgId = currentOrganizationId.value
+ if (!orgId)
+ return
+
+ const filterKey = makeScopeFilterKey('org', orgId)
+ if (!(filterKey in scopeFilterLabels.value))
+ return
+
+ const activeFilterKeys = Object.entries(scopeFilters.value)
+ .filter(([, enabled]) => enabled)
+ .map(([key]) => key)
+ if (
+ activeFilterKeys.length > 0
+ && (activeFilterKeys.length > 1 || activeFilterKeys[0] !== defaultScopeFilterKey.value)
+ ) {
+ return
+ }
+
+ setSingleScopeFilter('org', orgId, false)
+ defaultScopeFilterKey.value = filterKey
+}
+
+function selectedScopeFilterIds(type: 'org' | 'app') {
+ return Object.entries(scopeFilters.value)
+ .filter(([, enabled]) => enabled)
+ .map(([key]) => parseScopeFilterKey(key))
+ .filter((parsed): parsed is { type: 'org' | 'app', id: string } => parsed?.type === type)
+ .map(parsed => parsed.id)
+}
// Function to fetch organization and app names in parallel
async function fetchOrgAndAppNames() {
@@ -277,12 +567,31 @@ const searchQuery = ref('')
const filteredAndSortedKeys = computed(() => {
let result = keys.value ?? []
+ const orgFilterIds = selectedScopeFilterIds('org')
+ if (orgFilterIds.length > 0) {
+ result = result.filter((key) => {
+ const orgIds = getDisplayOrgIds(key)
+ return orgFilterIds.some(orgId => orgIds.includes(orgId))
+ })
+ }
+
+ const appFilterIds = selectedScopeFilterIds('app')
+ if (appFilterIds.length > 0) {
+ result = result.filter((key) => {
+ const appIds = getDisplayAppIds(key)
+ return appFilterIds.some(appId => appIds.includes(appId))
+ })
+ }
+
// Filter first
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(key =>
key.name?.toLowerCase().includes(query)
- || key.key?.toLowerCase().includes(query),
+ || key.key?.toLowerCase().includes(query)
+ || getRoleDisplayName(getHighestRole(key) || '').toLowerCase().includes(query)
+ || formatDisplayOrganizations(key).toLowerCase().includes(query)
+ || formatDisplayApps(key).toLowerCase().includes(query),
)
}
@@ -404,18 +713,17 @@ columns.value = [
{
key: 'org_scope',
label: t('organizations'),
- displayFunction: (row: Database['public']['Tables']['apikeys']['Row']) => {
- const orgIds = getDisplayOrgIds(row)
- if (coversAllOrganizations(orgIds))
- return '*'
- return formatApiKeyScope(orgIds, orgId => getOrgName.value(orgId))
+ class: 'w-[16rem] max-w-[16rem]',
+ renderFunction: (row: Database['public']['Tables']['apikeys']['Row']) => {
+ return renderScopeBadges(getDisplayOrgItems(row), 2, t('organizations'))
},
},
{
key: 'app_scope',
label: t('apps'),
- displayFunction: (row: Database['public']['Tables']['apikeys']['Row']) => {
- return formatDisplayApps(row)
+ class: 'w-[22rem] max-w-[22rem]',
+ renderFunction: (row: Database['public']['Tables']['apikeys']['Row']) => {
+ return renderScopeBadges(getDisplayAppItems(row), 3, t('apps'))
},
},
{
@@ -954,6 +1262,11 @@ async function copyKey(apikey: Database['public']['Tables']['apikeys']['Row']) {
}
// Watch for org selection changes to prune app bindings
+watch([scopeFilterLabels, currentOrganizationId], () => {
+ syncScopeFilters()
+ applyCurrentOrganizationDefaultFilter()
+}, { immediate: true })
+
watch(selectedOrgsForCreation, () => {
pruneAppBindings()
ensureSelectedOrgRoleAllowed()
@@ -988,11 +1301,15 @@ getKeys()
:auto-reload="false"
:columns="columns"
:element-list="filteredAndSortedKeys"
+ :filter-labels="scopeFilterLabels"
+ :filters="scopeFilters"
+ filter-text="scope"
:is-loading="isLoading"
:total="filteredAndSortedKeys.length"
:search-placeholder="t('search-api-keys')"
:search="searchQuery"
@add="addNewApiKey"
+ @update:filters="updateScopeFilters"
@update:search="searchQuery = $event"
@reload="getKeys()"
@reset="refreshData()"
@@ -1032,6 +1349,61 @@ getKeys()
+
+
+
+
+
+ {{ scopePicker.title }}
+
+
+
+
+
+
+
+ -
+
+
+
+
+ {{ t('no_elements_found') }}
+
+
+
+
diff --git a/src/services/apikeys.ts b/src/services/apikeys.ts
index a4b49ea8d5..6a87664ef5 100644
--- a/src/services/apikeys.ts
+++ b/src/services/apikeys.ts
@@ -179,17 +179,6 @@ export function isApiKeyExpired(expiresAt: string | null): boolean {
return new Date(expiresAt) < new Date()
}
-export function formatApiKeyScope(
- items: string[] | null | undefined,
- formatItem: (item: string) => string,
- emptyValue = '',
-): string {
- if (!items || items.length === 0)
- return emptyValue
-
- return items.map(formatItem).join(', ')
-}
-
export function sortApiKeyRows(
rows: T[],
columns: TableColumn[],