Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0c8b615
fix: update button height and loading spinner minimum height
riderx May 11, 2026
fd18e6a
feat(apikeys): migrate legacy keys to v2
riderx May 17, 2026
1f192f2
fix(tests): update apikey v2 coverage
riderx May 17, 2026
56ff01f
fix(db): remove stale apikey lint variable
riderx May 17, 2026
cb8d4a2
fix(tests): bind v2 keys for dynamic orgs
riderx May 17, 2026
094c064
fix(tests): bind app validation key to org
riderx May 17, 2026
9d6036d
fix(tests): create hashed apikey fixtures in postgres
riderx May 17, 2026
c368fe3
feat(rbac): make legacy rights compatibility rbac-backed
riderx May 17, 2026
00f8351
fix(rbac): align pg tests with rbac-only rights
riderx May 17, 2026
982e5a2
fix(tests): finish rbac-only pg expectations
riderx May 17, 2026
290f27a
fix(rbac): keep pending invites out of active access
riderx May 17, 2026
65d8bed
fix(rbac): accept legacy invites after pending cleanup
riderx May 17, 2026
e1e8a89
fix(rbac): preserve legacy sql invite acceptance
riderx May 17, 2026
c4f6d79
fix(tests): seed legacy invite acceptance row
riderx May 17, 2026
e5911b3
fix(tests): force legacy invite fixture row
riderx May 17, 2026
46aef8e
fix(tests): use seeded org for legacy invite flow
riderx May 17, 2026
4067e59
fix(tests): align backend tests with rbac-only rights
riderx May 17, 2026
2006a98
fix(tests): expect scoped key org gate
riderx May 17, 2026
8bf3f31
fix(tests): expect hidden org for scoped key
riderx May 17, 2026
d6f78c8
fix(rbac): address apikey v2 review feedback
riderx May 17, 2026
b7aff9e
fix(tests): bind effective api key user permissions
riderx May 17, 2026
e8b6467
fix(tests): assert api key effective user mismatch
riderx May 17, 2026
dbcee6b
fix(tests): respect api key expiration binding policy
riderx May 17, 2026
93e6681
fix(api): remove dead transaction state writes
riderx May 17, 2026
5db42d7
Merge remote-tracking branch 'origin/main' into codex/migrate-apikeys-v2
riderx May 17, 2026
513a4c5
fix(db): move apikey v2 migration after main
riderx May 17, 2026
ca79eea
fix(db): preserve cli warnings on apikey v2
riderx May 17, 2026
89e6636
fix(db): preserve apikey v2 migration scopes
riderx May 18, 2026
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
4 changes: 3 additions & 1 deletion playwright/e2e/apikeys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { expect, test } from '../support/commands'
async function createReadApiKey(page: Page, keyName: string) {
await page.click('[data-test="create-key"]')
await page.locator('#dialog-v2-content input[type="text"]').fill(keyName)
await page.locator('#dialog-v2-content input[name="key-type"][value="read"]').check()
await page.locator('#dialog-v2-content label').filter({ hasText: 'Read' }).click()
await page.locator('#dialog-v2-content label').filter({ hasText: 'Limit the API key to selected organizations?' }).click()
await page.locator('#dialog-v2-content label').filter({ hasText: 'Demo org' }).click()
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator('[data-test="toast"]')).toContainText('Added new API key successfully')
}
Expand Down
1 change: 0 additions & 1 deletion src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ declare module 'vue' {
AdminOnlyModal: typeof import('./components/AdminOnlyModal.vue')['default']
AdminStatsCard: typeof import('./components/admin/AdminStatsCard.vue')['default']
AdminTrendChart: typeof import('./components/admin/AdminTrendChart.vue')['default']
ApiKeyRbacManager: typeof import('./components/organization/ApiKeyRbacManager.vue')['default']
AppAccess: typeof import('./components/dashboard/AppAccess.vue')['default']
AppNotFoundModal: typeof import('./components/AppNotFoundModal.vue')['default']
AppOnboardingFlow: typeof import('./components/dashboard/AppOnboardingFlow.vue')['default']
Expand Down
49 changes: 8 additions & 41 deletions src/components/dashboard/AppAccess.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { TableColumn } from '~/components/comp_def'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import IconInformation from '~icons/heroicons/information-circle'
import IconLock from '~icons/heroicons/lock-closed'
import IconPlus from '~icons/heroicons/plus'
import IconShield from '~icons/heroicons/shield-check'
Expand Down Expand Up @@ -60,7 +59,6 @@ const roleBindings = ref<RoleBinding[]>([])
const availableAppRoles = ref<Role[]>([])
const search = ref('')
const currentPage = ref(1)
const useNewRbac = ref(false)
const canAssignRoles = ref(false)
const ownerOrg = ref<string>('')

Expand Down Expand Up @@ -145,27 +143,6 @@ async function fetchAppDetails() {
}
}

async function checkRbacEnabled() {
if (!ownerOrg.value)
return

try {
const { data, error } = await supabase
.from('orgs')
.select('use_new_rbac')
.eq('id', ownerOrg.value)
.single()

if (error)
throw error

useNewRbac.value = (data as any)?.use_new_rbac || false
}
catch (error: any) {
console.error('Error checking RBAC status:', error)
}
}

async function fetchAppRoleBindings() {
if (!props.appId || !ownerOrg.value)
return
Expand Down Expand Up @@ -454,7 +431,6 @@ async function removeRoleBinding(bindingId: string) {

async function loadAppAccess() {
await fetchAppDetails()
await checkRbacEnabled()
if (props.appId) {
try {
canAssignRoles.value = await checkPermissions('app.update_user_roles', { appId: props.appId })
Expand All @@ -467,14 +443,12 @@ async function loadAppAccess() {
else {
canAssignRoles.value = false
}
if (useNewRbac.value) {
await Promise.all([
fetchAppRoleBindings(),
fetchAvailableAppRoles(),
fetchAvailableMembers(),
fetchAvailableGroups(),
])
}
await Promise.all([
fetchAppRoleBindings(),
fetchAvailableAppRoles(),
fetchAvailableMembers(),
fetchAvailableGroups(),
])
}

watch(() => props.appId, async () => {
Expand All @@ -488,12 +462,6 @@ onMounted(async () => {

<template>
<div class="w-full px-3 py-2">
<!-- RBAC not enabled message -->
<div v-if="!useNewRbac" class="mb-4 alert alert-info">
<IconInformation class="size-5" />
<span>{{ t('rbac-not-enabled-for-org') }}</span>
</div>

<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div>
Expand All @@ -506,7 +474,7 @@ onMounted(async () => {
</p>
</div>
<button
v-if="useNewRbac && canAssignRoles"
v-if="canAssignRoles"
class="d-btn d-btn-primary"
@click="openAssignRoleModal"
>
Expand All @@ -516,7 +484,7 @@ onMounted(async () => {
</div>

<!-- Search -->
<div v-if="useNewRbac" class="mb-4">
<div class="mb-4">
<SearchInput
v-model="search"
:placeholder="t('search-role-bindings')"
Expand All @@ -526,7 +494,6 @@ onMounted(async () => {

<!-- Role bindings table -->
<DataTable
v-if="useNewRbac"
:columns="columns"
:element-list="filteredBindings"
:total="filteredBindings.length"
Expand Down
33 changes: 11 additions & 22 deletions src/components/dashboard/AppOnboardingFlow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import IconSmartphone from '~icons/lucide/smartphone'
import IconSparkles from '~icons/lucide/sparkles'
import IconStore from '~icons/lucide/store'
import IconTerminal from '~icons/lucide/terminal'
import { createDefaultApiKey } from '~/services/apikeys'
import { createDefaultApiKey, findUsablePlainApiKey } from '~/services/apikeys'
import { createSignedImageUrl, getImmediateImageUrl } from '~/services/storage'
import { getLocalConfig, isLocal, useSupabase } from '~/services/supabase'
import { useDialogV2Store } from '~/stores/dialogv2'
Expand Down Expand Up @@ -257,18 +257,9 @@ async function ensureApiKey() {
if (!userId)
return

const isLiveKey = (expiresAt: string | null) => !expiresAt || new Date(expiresAt).getTime() > Date.now()

const { data, error } = await supabase
.from('apikeys')
.select('key, expires_at')
.eq('user_id', userId)
.eq('mode', 'all')
.order('created_at', { ascending: false })

const validKey = !error ? data?.find(key => !!key.key && isLiveKey(key.expires_at)) : null
if (validKey?.key) {
apiKey.value = validKey.key
const existingKey = await findUsablePlainApiKey(supabase, userId, currentOrg.value?.gid, resumeAppId.value)
if (existingKey) {
apiKey.value = existingKey
return
}

Expand All @@ -277,18 +268,16 @@ async function ensureApiKey() {
if (!claimsUserId)
return

const { error: createError } = await createDefaultApiKey(supabase, 'api-key')
const { data, error: createError } = await createDefaultApiKey(supabase, 'api-key', {
orgId: currentOrg.value?.gid,
appId: resumeAppId.value,
})
if (createError)
throw createError

const { data: refreshedData } = await supabase
.from('apikeys')
.select('key, expires_at')
.eq('user_id', claimsUserId)
.eq('mode', 'all')
.order('created_at', { ascending: false })

apiKey.value = refreshedData?.find(key => !!key.key && isLiveKey(key.expires_at))?.key ?? null
apiKey.value = typeof data?.key === 'string'
? data.key
: await findUsablePlainApiKey(supabase, claimsUserId, currentOrg.value?.gid, resumeAppId.value)
}

async function loadResumeApp() {
Expand Down
35 changes: 11 additions & 24 deletions src/components/dashboard/InviteTeammateModal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script setup lang="ts">
import type { Database } from '~/types/supabase.types'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
Expand Down Expand Up @@ -39,9 +38,8 @@ const inviteCaptchaElement = ref<InstanceType<typeof VueTurnstile> | null>(null)
const captchaKey = ref(import.meta.env.VITE_CAPTCHA_KEY)
const shouldUseCaptcha = computed(() => Boolean(captchaKey.value))
const isInviting = ref(false)
const useRbacInvites = computed(() => organizationStore.currentOrganization?.use_new_rbac === true)
const existingUserInviteRole = computed(() => (useRbacInvites.value ? 'org_admin' : 'invite_admin'))
const newUserInviteRole = computed(() => (useRbacInvites.value ? 'org_admin' : 'admin'))
const existingUserInviteRole = 'org_admin'
const newUserInviteRole = 'org_admin'
const emailDialogTitle = computed(() => props.inviteKind === 'technical'
? t('onboarding-invite-option-modal-title')
: t('invite-teammate-modal-title'))
Expand Down Expand Up @@ -212,24 +210,13 @@ async function handleEmailSubmit() {
let data: string | null = null
let error: unknown = null

if (useRbacInvites.value) {
const result = await supabase.rpc('invite_user_to_org_rbac', {
email,
org_id: orgId,
role_name: existingUserInviteRole.value,
})
data = result.data
error = result.error
}
else {
const result = await supabase.rpc('invite_user_to_org', {
email,
org_id: orgId,
invite_type: existingUserInviteRole.value as Database['public']['Enums']['user_min_right'],
})
data = result.data
error = result.error
}
const result = await supabase.rpc('invite_user_to_org_rbac', {
email,
org_id: orgId,
role_name: existingUserInviteRole,
})
data = result.data
error = result.error

if (error) {
console.error('Error inviting user:', error)
Expand All @@ -243,7 +230,7 @@ async function handleEmailSubmit() {
}

if (data === 'OK') {
if (shouldNotifyExistingUserInvite(existingUserInviteRole.value, useRbacInvites.value)) {
if (shouldNotifyExistingUserInvite(existingUserInviteRole, true)) {
const notified = await notifyExistingUserInvite(supabase, email, orgId)
if (!notified) {
console.warn('Failed to send invite email notification, but invite was created')
Expand Down Expand Up @@ -330,7 +317,7 @@ async function handleFullDetailsSubmit() {
body: {
email,
org_id: orgId,
invite_type: newUserInviteRole.value,
invite_type: newUserInviteRole,
captcha_token: shouldUseCaptcha.value ? inviteCaptchaToken.value : undefined,
first_name: firstName,
last_name: lastName,
Expand Down
31 changes: 14 additions & 17 deletions src/components/dashboard/StepsApp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import IconCheck from '~icons/lucide/check'
import IconChevronDown from '~icons/lucide/chevron-down'
import IconLoader from '~icons/lucide/loader-2'
import InviteTeammateModal from '~/components/dashboard/InviteTeammateModal.vue'
import { createDefaultApiKey } from '~/services/apikeys'
import { createDefaultApiKey, findUsablePlainApiKey } from '~/services/apikeys'
import { pushEvent } from '~/services/posthog'
import { getLocalConfig, isLocal, useSupabase } from '~/services/supabase'
import { sendEvent } from '~/services/tracking'
Expand Down Expand Up @@ -230,33 +230,30 @@ async function addNewApiKey() {

if (!userId) {
console.log('Not logged in, cannot regenerate API key')
return
return null
}
const { error } = await createDefaultApiKey(supabase, t('api-key'))
const { data, error } = await createDefaultApiKey(supabase, t('api-key'), {
orgId: organizationStore.currentOrganization?.gid,
appId: appId.value,
})

if (error)
throw error

return typeof data?.key === 'string' ? data.key : null
}

async function getKey(retry = true): Promise<void> {
isLoading.value = true
if (!main?.user?.id)
return
const { data, error } = await supabase
.from('apikeys')
.select()
.eq('user_id', main?.user?.id)
.eq('mode', 'all')

if (typeof data !== 'undefined' && data !== null && !error) {
if (data.length === 0) {
await addNewApiKey()
return getKey(false)
}
apiKey.value = data[0]?.key ?? null

const existingKey = await findUsablePlainApiKey(supabase, main.user.id, organizationStore.currentOrganization?.gid, appId.value)
if (existingKey) {
apiKey.value = existingKey
}
else if (retry && main?.user?.id) {
return getKey(false)
else if (retry) {
apiKey.value = await addNewApiKey()
}

isLoading.value = false
Expand Down
31 changes: 13 additions & 18 deletions src/components/dashboard/StepsBuild.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import IconSettings from '~icons/lucide/settings-2'
import IconTerminal from '~icons/lucide/terminal-square'
import IconAndroid from '~icons/mdi/android'
import IconApple from '~icons/mdi/apple'
import { createDefaultApiKey } from '~/services/apikeys'
import { createDefaultApiKey, findUsablePlainApiKey } from '~/services/apikeys'
import { pushEvent } from '~/services/posthog'
import { getLocalConfig, isLocal, useSupabase } from '~/services/supabase'
import { sendEvent } from '~/services/tracking'
Expand Down Expand Up @@ -222,35 +222,30 @@ async function addNewApiKey() {

if (!userId) {
console.log('Not logged in, cannot regenerate API key')
return
return null
}
const { error } = await createDefaultApiKey(supabase, t('api-key'))
const { data, error } = await createDefaultApiKey(supabase, t('api-key'), {
orgId: organizationStore.currentOrganization?.gid,
appId: props.appId,
})

if (error)
throw error

return typeof data?.key === 'string' ? data.key : null
}

async function getKey(retry = true): Promise<void> {
isLoading.value = true
if (!main?.user?.id)
return
const { data, error } = await supabase
.from('apikeys')
.select()
.eq('user_id', main?.user?.id)
.eq('mode', 'all')
.order('created_at', { ascending: true })
.limit(1)

if (typeof data !== 'undefined' && data !== null && !error) {
if (data.length === 0) {
await addNewApiKey()
return getKey(false)
}
apiKey.value = data[0].key ?? '[APIKEY]'
const existingKey = await findUsablePlainApiKey(supabase, main.user.id, organizationStore.currentOrganization?.gid, props.appId)
if (existingKey) {
apiKey.value = existingKey
}
else if (retry && main?.user?.id) {
return getKey(false)
else if (retry) {
apiKey.value = await addNewApiKey() ?? '[APIKEY]'
}

isLoading.value = false
Expand Down
Loading
Loading