feat: initial schemas-app implementation
- Add Nuxt 4 application with Vuetify UI framework - Implement GraphQL schema registry management interface - Add Apollo Client integration with Auth0 authentication - Create organization and API key management - Add schema and ref browsing capabilities - Implement organization switcher for multi-org users - Add delete functionality for organizations and API keys - Create Kubernetes deployment descriptors - Add Docker configuration with nginx Features: - Dashboard with organization overview - Schema browsing by ref with supergraph viewing - Ref management with schema details - Settings page for organizations and API keys - User list per organization with provider icons - Admin-only organization creation - Delete confirmations with warnings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h3 mb-4">Dashboard</h1>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="!auth0?.isAuthenticated?.value">
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal">
|
||||
Please log in to view your schema registry dashboard.
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else-if="organizations.value?.loading?.value">
|
||||
<v-col cols="12" class="text-center">
|
||||
<v-progress-circular indeterminate size="64" />
|
||||
<p class="mt-4">Loading your organizations...</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else-if="organizations.value?.error?.value">
|
||||
<v-col cols="12">
|
||||
<v-alert type="error" variant="tonal">
|
||||
Error loading data: {{ organizations.value?.error?.value?.message }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else>
|
||||
<v-col cols="12" md="4">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon icon="mdi-graphql" class="mr-2" />
|
||||
Total Schemas
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="text-h2">{{ stats.totalSchemas }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="4">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon icon="mdi-source-branch" class="mr-2" />
|
||||
Refs
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="text-h2">{{ stats.totalRefs }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="4">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon icon="mdi-update" class="mr-2" />
|
||||
Last Updated
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="text-subtitle-1">{{ stats.lastUpdate }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="auth0?.isAuthenticated?.value && !organizations.value?.loading?.value && !organizations.value?.error?.value" class="mt-4">
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>Organizations</span>
|
||||
<v-btn
|
||||
v-if="isAdmin && !getOrganizationsData()?.length"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="createOrgDialog = true"
|
||||
>
|
||||
Create Organization
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list v-if="getOrganizationsData()?.length">
|
||||
<v-list-item
|
||||
v-for="org in getOrganizationsData()"
|
||||
:key="org.id"
|
||||
:title="org.name"
|
||||
:subtitle="`${org.apiKeys?.length || 0} API Keys`"
|
||||
prepend-icon="mdi-domain"
|
||||
>
|
||||
<template #append>
|
||||
<v-chip size="small" class="mr-2">
|
||||
{{ org.users?.length || 0 }} users
|
||||
</v-chip>
|
||||
<v-btn
|
||||
v-if="isAdmin"
|
||||
icon="mdi-account-plus"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="openAddUserDialog(org)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-alert v-else type="info" variant="tonal">
|
||||
<div class="d-flex flex-column align-center">
|
||||
<p>You don't have any organizations yet.</p>
|
||||
<p v-if="isAdmin" class="text-caption">Create one to start managing your GraphQL schemas.</p>
|
||||
<p v-else class="text-caption">Contact your administrator to get access to an organization.</p>
|
||||
</div>
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-dialog v-model="createOrgDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>Create Organization</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="newOrgName"
|
||||
label="Organization Name"
|
||||
variant="outlined"
|
||||
:error-messages="orgNameError"
|
||||
@keyup.enter="createOrganization"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="createOrgDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="creatingOrg"
|
||||
:disabled="!newOrgName"
|
||||
@click="createOrganization"
|
||||
>
|
||||
Create
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="addUserDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>Add User to {{ selectedOrg?.name }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="newUserId"
|
||||
label="User ID (Auth0 Subject)"
|
||||
variant="outlined"
|
||||
:error-messages="userIdError"
|
||||
hint="Enter the Auth0 subject (e.g., auth0|123456)"
|
||||
persistent-hint
|
||||
@keyup.enter="addUser"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="addUserDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="addingUser"
|
||||
:disabled="!newUserId"
|
||||
@click="addUser"
|
||||
>
|
||||
Add User
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar v-model="snackbar" :timeout="3000" :color="snackbarColor">
|
||||
{{ snackbarText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuth0 } from '@auth0/auth0-vue'
|
||||
|
||||
import {
|
||||
useAddOrganizationMutation,
|
||||
useAddUserToOrganizationMutation,
|
||||
useAllOrganizationsQuery,
|
||||
useOrganizationsQuery,
|
||||
} from '~/graphql/generated'
|
||||
|
||||
const auth0 = useAuth0()
|
||||
|
||||
// Check if user has admin role
|
||||
const isAdmin = computed(() => {
|
||||
if (!auth0?.isAuthenticated?.value || !auth0?.user?.value) return false
|
||||
|
||||
const roles = auth0.user.value['https://unbound.se/roles']
|
||||
return Array.isArray(roles) && roles.includes('admin')
|
||||
})
|
||||
|
||||
// Start with queries paused, then unpause after Auth0 is ready
|
||||
const pauseQueries = ref(true)
|
||||
|
||||
// Wait for Auth0 to be fully ready before unpausing queries
|
||||
watch(() => auth0?.isAuthenticated?.value, (isAuthenticated) => {
|
||||
if (isAuthenticated) {
|
||||
// Small delay to ensure access token is available
|
||||
setTimeout(() => {
|
||||
pauseQueries.value = false
|
||||
}, 100)
|
||||
} else {
|
||||
pauseQueries.value = true
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Use generated Apollo composables
|
||||
const userOrgs = useOrganizationsQuery(() => ({
|
||||
skip: pauseQueries.value,
|
||||
}))
|
||||
const allOrgs = useAllOrganizationsQuery(() => ({
|
||||
skip: pauseQueries.value,
|
||||
}))
|
||||
|
||||
// Mutations
|
||||
const { mutate: addOrganizationMutation } = useAddOrganizationMutation({})
|
||||
const { mutate: addUserMutation } = useAddUserToOrganizationMutation({})
|
||||
|
||||
// Select which query to use based on admin status
|
||||
const organizations = computed(() => {
|
||||
return isAdmin.value ? allOrgs : userOrgs
|
||||
})
|
||||
|
||||
// Helper function to get organizations data based on admin status
|
||||
const getOrganizationsData = () => {
|
||||
if (isAdmin.value) {
|
||||
return allOrgs.result?.value?.allOrganizations
|
||||
}
|
||||
return userOrgs.result?.value?.organizations
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
const orgsData = getOrganizationsData()
|
||||
|
||||
if (!auth0?.isAuthenticated?.value || !orgsData) {
|
||||
return {
|
||||
totalSchemas: 0,
|
||||
totalRefs: 0,
|
||||
lastUpdate: 'Not available',
|
||||
}
|
||||
}
|
||||
|
||||
const allApiKeys = orgsData.flatMap(org => org.apiKeys || [])
|
||||
const allRefs = new Set(allApiKeys.flatMap(key => key.refs || []))
|
||||
|
||||
return {
|
||||
totalSchemas: allApiKeys.length,
|
||||
totalRefs: allRefs.size,
|
||||
lastUpdate: new Date().toLocaleString(),
|
||||
}
|
||||
})
|
||||
|
||||
// Organization creation
|
||||
const createOrgDialog = ref(false)
|
||||
const newOrgName = ref('')
|
||||
const orgNameError = ref('')
|
||||
const creatingOrg = ref(false)
|
||||
const snackbar = ref(false)
|
||||
const snackbarText = ref('')
|
||||
const snackbarColor = ref('success')
|
||||
|
||||
// Add user to organization
|
||||
const addUserDialog = ref(false)
|
||||
const selectedOrg = ref<any>(null)
|
||||
const newUserId = ref('')
|
||||
const userIdError = ref('')
|
||||
const addingUser = ref(false)
|
||||
|
||||
const openAddUserDialog = (org: any) => {
|
||||
selectedOrg.value = org
|
||||
newUserId.value = ''
|
||||
userIdError.value = ''
|
||||
addUserDialog.value = true
|
||||
}
|
||||
|
||||
const addUser = async () => {
|
||||
if (!newUserId.value) {
|
||||
userIdError.value = 'User ID is required'
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedOrg.value) {
|
||||
return
|
||||
}
|
||||
|
||||
addingUser.value = true
|
||||
userIdError.value = ''
|
||||
|
||||
try {
|
||||
const result = await addUserMutation({
|
||||
organizationId: selectedOrg.value.id,
|
||||
userId: newUserId.value,
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Failed to add user')
|
||||
}
|
||||
|
||||
snackbarText.value = `User added to "${selectedOrg.value.name}" successfully!`
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
addUserDialog.value = false
|
||||
newUserId.value = ''
|
||||
|
||||
// Refetch organizations
|
||||
await userOrgs.refetch()
|
||||
await allOrgs.refetch()
|
||||
} catch (error: any) {
|
||||
userIdError.value = error.message || 'Failed to add user'
|
||||
snackbarText.value = 'Failed to add user'
|
||||
snackbarColor.value = 'error'
|
||||
snackbar.value = true
|
||||
} finally {
|
||||
addingUser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createOrganization = async () => {
|
||||
if (!newOrgName.value) {
|
||||
orgNameError.value = 'Organization name is required'
|
||||
return
|
||||
}
|
||||
|
||||
creatingOrg.value = true
|
||||
orgNameError.value = ''
|
||||
|
||||
try {
|
||||
const result = await addOrganizationMutation({ name: newOrgName.value })
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Failed to create organization')
|
||||
}
|
||||
|
||||
snackbarText.value = `Organization "${newOrgName.value}" created successfully!`
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
createOrgDialog.value = false
|
||||
newOrgName.value = ''
|
||||
|
||||
// Refetch organizations
|
||||
await userOrgs.refetch()
|
||||
await allOrgs.refetch()
|
||||
} catch (error: any) {
|
||||
orgNameError.value = error.message || 'Failed to create organization'
|
||||
snackbarText.value = 'Failed to create organization'
|
||||
snackbarColor.value = 'error'
|
||||
snackbar.value = true
|
||||
} finally {
|
||||
creatingOrg.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h3 mb-4">Schema Refs</h1>
|
||||
<p class="text-subtitle-1 text-grey mb-4">
|
||||
View and manage different versions of your federated GraphQL schemas
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="!auth0.isAuthenticated.value">
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal">
|
||||
Please log in to view schema refs.
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else-if="loading?.value">
|
||||
<v-col cols="12" class="text-center">
|
||||
<v-progress-circular indeterminate size="64" />
|
||||
<p class="mt-4">Loading refs...</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else-if="error?.value">
|
||||
<v-col cols="12">
|
||||
<v-alert type="error" variant="tonal">
|
||||
Error loading refs: {{ error?.value?.message }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else-if="refs.length === 0">
|
||||
<v-col cols="12" class="text-center py-12">
|
||||
<v-icon icon="mdi-source-branch" size="64" class="mb-4 text-grey" />
|
||||
<p class="text-h6 text-grey">No refs found</p>
|
||||
<p class="text-grey">Create an API key with refs to get started</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else>
|
||||
<v-col
|
||||
v-for="ref in refs"
|
||||
:key="ref.name"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
>
|
||||
<v-card hover>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon icon="mdi-source-branch" class="mr-2" />
|
||||
{{ ref.name }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list density="compact">
|
||||
<v-list-item>
|
||||
<v-list-item-title>Subgraphs</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ ref.subgraphCount }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title>Last Updated</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ ref.lastUpdate }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title>Status</v-list-item-title>
|
||||
<template #append>
|
||||
<v-chip :color="ref.status === 'healthy' ? 'success' : 'warning'" size="small">
|
||||
{{ ref.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn variant="text" color="primary" @click="viewSupergraph(ref.name)">
|
||||
View Supergraph
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-download" variant="text" @click="downloadSupergraph(ref.name)" />
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-dialog v-model="dialog" max-width="900">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>Supergraph - {{ selectedRef }}</span>
|
||||
<v-btn icon="mdi-close" variant="text" @click="dialog = false" />
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<pre class="sdl-viewer"><code>{{ supergraph }}</code></pre>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
prepend-icon="mdi-content-copy"
|
||||
variant="text"
|
||||
@click="copySupergraph"
|
||||
>
|
||||
Copy
|
||||
</v-btn>
|
||||
<v-btn
|
||||
prepend-icon="mdi-download"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="downloadSupergraph(selectedRef)"
|
||||
>
|
||||
Download
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar v-model="snackbar" :timeout="2000">
|
||||
{{ snackbarText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuth0 } from '@auth0/auth0-vue'
|
||||
|
||||
import { useOrganizationSelector } from '~/composables/useOrganizationSelector'
|
||||
import {
|
||||
useLatestSchemaQuery,
|
||||
useSupergraphQuery,
|
||||
} from '~/graphql/generated'
|
||||
|
||||
const auth0 = useAuth0()
|
||||
const { selectedOrganization, loading, error } = useOrganizationSelector()
|
||||
|
||||
const dialog = ref(false)
|
||||
const selectedRef = ref<string | null>(null)
|
||||
const supergraph = ref('')
|
||||
const snackbar = ref(false)
|
||||
const snackbarText = ref('')
|
||||
|
||||
// Supergraph query - only runs when selectedRef is set
|
||||
const supergraphQuery = useSupergraphQuery(() => ({
|
||||
ref: selectedRef.value || '',
|
||||
isAfter: null,
|
||||
}), () => ({
|
||||
skip: !selectedRef.value,
|
||||
}))
|
||||
|
||||
// Watch for supergraph query results
|
||||
watch(() => supergraphQuery.result.value, (data) => {
|
||||
if (data?.supergraph) {
|
||||
if (data.supergraph.__typename === 'SubGraphs') {
|
||||
supergraph.value = data.supergraph.sdl || '# No supergraph available'
|
||||
} else if (data.supergraph.__typename === 'Unchanged') {
|
||||
supergraph.value = `# Supergraph unchanged (ID: ${data.supergraph.id})\n# Please retry after ${data.supergraph.minDelaySeconds} seconds`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for supergraph query errors
|
||||
watch(() => supergraphQuery.error.value, (err) => {
|
||||
if (err) {
|
||||
supergraph.value = `# Error loading supergraph\n# ${err.message || 'Unknown error'}`
|
||||
snackbarText.value = 'Failed to load supergraph'
|
||||
snackbar.value = true
|
||||
}
|
||||
})
|
||||
|
||||
// Get available refs from the selected organization
|
||||
const refNames = computed(() => {
|
||||
if (!selectedOrganization.value) return []
|
||||
|
||||
const allRefs = new Set<string>()
|
||||
selectedOrganization.value.apiKeys?.forEach(key => {
|
||||
key.refs?.forEach(ref => allRefs.add(ref))
|
||||
})
|
||||
|
||||
return Array.from(allRefs)
|
||||
})
|
||||
|
||||
// Create reactive queries for each ref
|
||||
const refQueries = computed(() => {
|
||||
if (!auth0.isAuthenticated.value) return {}
|
||||
|
||||
const queries: Record<string, any> = {}
|
||||
refNames.value.forEach(ref => {
|
||||
queries[ref] = useLatestSchemaQuery(() => ({
|
||||
ref,
|
||||
}), () => ({
|
||||
skip: !auth0.isAuthenticated.value,
|
||||
}))
|
||||
})
|
||||
return queries
|
||||
})
|
||||
|
||||
const refs = computed(() => {
|
||||
return refNames.value.map(refName => {
|
||||
const query = refQueries.value[refName]
|
||||
const latestSchema = query?.result?.value?.latestSchema
|
||||
|
||||
return {
|
||||
name: refName,
|
||||
subgraphCount: latestSchema?.subGraphs?.length || 0,
|
||||
lastUpdate: latestSchema?.subGraphs?.[0]?.changedAt
|
||||
? new Date(latestSchema.subGraphs[0].changedAt).toLocaleString()
|
||||
: 'N/A',
|
||||
status: query?.loading?.value ? 'loading' : (query?.error?.value ? 'error' : 'healthy'),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const viewSupergraph = (refName: string) => {
|
||||
selectedRef.value = refName
|
||||
supergraph.value = '# Loading...'
|
||||
dialog.value = true
|
||||
}
|
||||
|
||||
const downloadSupergraph = (refName: string) => {
|
||||
const blob = new Blob([supergraph.value || '# Loading...'], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `supergraph-${refName}.graphql`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
snackbarText.value = 'Supergraph downloaded'
|
||||
snackbar.value = true
|
||||
}
|
||||
|
||||
const copySupergraph = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(supergraph.value)
|
||||
snackbarText.value = 'Supergraph copied to clipboard'
|
||||
snackbar.value = true
|
||||
} catch (_err) {
|
||||
snackbarText.value = 'Failed to copy'
|
||||
snackbar.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sdl-viewer {
|
||||
background: #f5f5f5;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-family: Monaco, Menlo, 'Ubuntu Mono', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
max-height: 60vh;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
prepend-icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
@click="navigateTo('/schemas')"
|
||||
>
|
||||
Back to Schemas
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="schema">
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h3 mb-2">{{ schema.service }}</h1>
|
||||
<v-chip class="mr-2">{{ schema.ref }}</v-chip>
|
||||
<v-chip color="success">Active</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="schema">
|
||||
<v-col cols="12" md="6">
|
||||
<v-card>
|
||||
<v-card-title>Details</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-list-item-title>Service</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ schema.service }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title>Ref</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ schema.ref }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title>URL</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ schema.url }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="schema.wsUrl">
|
||||
<v-list-item-title>WebSocket URL</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ schema.wsUrl }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title>Last Updated</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ schema.updatedAt }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title>Updated By</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ schema.updatedBy }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card>
|
||||
<v-card-title>Actions</v-card-title>
|
||||
<v-card-text>
|
||||
<v-btn
|
||||
block
|
||||
prepend-icon="mdi-download"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
@click="downloadSchema"
|
||||
>
|
||||
Download SDL
|
||||
</v-btn>
|
||||
<v-btn
|
||||
block
|
||||
prepend-icon="mdi-content-copy"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
@click="copyToClipboard"
|
||||
>
|
||||
Copy SDL
|
||||
</v-btn>
|
||||
<v-btn
|
||||
block
|
||||
prepend-icon="mdi-graph"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
>
|
||||
View Federation Graph
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="schema">
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>Schema Definition (SDL)</span>
|
||||
<v-btn
|
||||
icon="mdi-content-copy"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="copyToClipboard"
|
||||
/>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<pre class="sdl-viewer"><code>{{ schema.sdl }}</code></pre>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="!schema && !loading">
|
||||
<v-col cols="12" class="text-center py-12">
|
||||
<v-icon icon="mdi-alert-circle" size="64" class="mb-4 text-warning" />
|
||||
<p class="text-h6">Schema not found</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-snackbar v-model="snackbar" :timeout="2000">
|
||||
{{ snackbarText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const _config = useRuntimeConfig()
|
||||
|
||||
const schema = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const snackbar = ref(false)
|
||||
const snackbarText = ref('')
|
||||
|
||||
const downloadSchema = () => {
|
||||
if (!schema.value) return
|
||||
|
||||
const blob = new Blob([schema.value.sdl], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${schema.value.service}-${schema.value.ref}.graphql`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
snackbarText.value = 'Schema downloaded'
|
||||
snackbar.value = true
|
||||
}
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (!schema.value) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(schema.value.sdl)
|
||||
snackbarText.value = 'SDL copied to clipboard'
|
||||
snackbar.value = true
|
||||
} catch (_err) {
|
||||
snackbarText.value = 'Failed to copy'
|
||||
snackbar.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Fetch actual data from the GraphQL API
|
||||
onMounted(() => {
|
||||
// Mock data for now
|
||||
setTimeout(() => {
|
||||
schema.value = {
|
||||
id: route.params.id,
|
||||
service: 'users-service',
|
||||
ref: 'production',
|
||||
url: 'http://users.example.com/graphql',
|
||||
wsUrl: 'ws://users.example.com/graphql',
|
||||
updatedAt: '2024-11-21 19:30:00',
|
||||
updatedBy: 'john.doe@example.com',
|
||||
sdl: `type User @key(fields: "id") {
|
||||
id: ID!
|
||||
username: String!
|
||||
email: String!
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
type Query {
|
||||
user(id: ID!): User
|
||||
users(limit: Int, offset: Int): [User!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createUser(input: CreateUserInput!): User!
|
||||
updateUser(id: ID!, input: UpdateUserInput!): User!
|
||||
deleteUser(id: ID!): Boolean!
|
||||
}
|
||||
|
||||
input CreateUserInput {
|
||||
username: String!
|
||||
email: String!
|
||||
}
|
||||
|
||||
input UpdateUserInput {
|
||||
username: String
|
||||
email: String
|
||||
}
|
||||
|
||||
scalar DateTime`,
|
||||
}
|
||||
loading.value = false
|
||||
}, 500)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sdl-viewer {
|
||||
background: #f5f5f5;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-family: Monaco, Menlo, 'Ubuntu Mono', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h3 mb-4">Published Schemas</h1>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="!auth0.isAuthenticated.value">
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal">
|
||||
Please log in to view published schemas.
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="selectedRef"
|
||||
:items="refs"
|
||||
label="Select Ref"
|
||||
prepend-icon="mdi-source-branch"
|
||||
variant="outlined"
|
||||
:disabled="!selectedOrganization"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="9">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
label="Search schemas"
|
||||
prepend-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="auth0.isAuthenticated.value && latestSchema.loading.value">
|
||||
<v-col cols="12" class="text-center">
|
||||
<v-progress-circular indeterminate size="64" />
|
||||
<p class="mt-4">Loading schemas for {{ selectedRef }}...</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else-if="auth0.isAuthenticated.value && latestSchema.error.value">
|
||||
<v-col cols="12">
|
||||
<v-alert type="error" variant="tonal">
|
||||
Error loading schemas: {{ latestSchema.error.value.message }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else-if="auth0.isAuthenticated.value">
|
||||
<v-col
|
||||
v-for="schema in filteredSchemas"
|
||||
:key="schema.id"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
>
|
||||
<v-card hover @click="navigateTo(`/schemas/${schema.id}`)">
|
||||
<v-card-title>
|
||||
<v-icon icon="mdi-graphql" class="mr-2" />
|
||||
{{ schema.service }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle>{{ schema.ref }}</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<div class="mb-2">
|
||||
<strong>URL:</strong> {{ schema.url }}
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
Updated: {{ schema.updatedAt }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn variant="text" color="primary">
|
||||
View Schema
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-chip size="small" color="success">Active</v-chip>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col v-if="filteredSchemas.length === 0" cols="12" class="text-center py-12">
|
||||
<v-icon icon="mdi-graphql" size="64" class="mb-4 text-grey" />
|
||||
<p class="text-h6 text-grey">No schemas found</p>
|
||||
<p class="text-grey">{{ search ? 'Try a different search term' : 'Publish your first schema to get started' }}</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuth0 } from '@auth0/auth0-vue'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useOrganizationSelector } from '~/composables/useOrganizationSelector'
|
||||
import {
|
||||
useLatestSchemaQuery,
|
||||
} from '~/graphql/generated'
|
||||
|
||||
const auth0 = useAuth0()
|
||||
const { selectedOrganization } = useOrganizationSelector()
|
||||
|
||||
const selectedRef = ref('production')
|
||||
const search = ref('')
|
||||
|
||||
// Get available refs from the selected organization
|
||||
const refs = computed(() => {
|
||||
if (!selectedOrganization.value) return ['production', 'staging', 'development']
|
||||
|
||||
const allRefs = new Set<string>()
|
||||
selectedOrganization.value.apiKeys?.forEach(key => {
|
||||
key.refs?.forEach(ref => allRefs.add(ref))
|
||||
})
|
||||
|
||||
return Array.from(allRefs).length > 0 ? Array.from(allRefs) : ['production', 'staging', 'development']
|
||||
})
|
||||
|
||||
// Fetch schema for selected ref
|
||||
const latestSchema = useLatestSchemaQuery(() => ({
|
||||
ref: selectedRef.value,
|
||||
}), () => ({
|
||||
skip: !auth0.isAuthenticated.value || !selectedRef.value,
|
||||
}))
|
||||
|
||||
const schemas = computed(() => {
|
||||
if (!latestSchema.result.value?.latestSchema?.subGraphs) return []
|
||||
|
||||
return latestSchema.result.value.latestSchema.subGraphs.map(subgraph => ({
|
||||
id: subgraph.id,
|
||||
service: subgraph.service,
|
||||
ref: selectedRef.value,
|
||||
url: subgraph.url || 'N/A',
|
||||
wsUrl: subgraph.wsUrl,
|
||||
updatedAt: new Date(subgraph.changedAt).toLocaleString(),
|
||||
changedBy: subgraph.changedBy,
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredSchemas = computed(() => {
|
||||
let filtered = schemas.value
|
||||
|
||||
if (search.value) {
|
||||
const searchLower = search.value.toLowerCase()
|
||||
filtered = filtered.filter(s =>
|
||||
s.service.toLowerCase().includes(searchLower) ||
|
||||
s.url.toLowerCase().includes(searchLower),
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
// Watch for ref changes and ensure the first ref is selected
|
||||
watch(refs, (newRefs) => {
|
||||
if (newRefs.length > 0 && !newRefs.includes(selectedRef.value)) {
|
||||
selectedRef.value = newRefs[0]
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
@@ -0,0 +1,716 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h3 mb-4">Settings</h1>
|
||||
<p class="text-subtitle-1 text-grey mb-4">
|
||||
Manage API keys and organization settings
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="!auth0.isAuthenticated.value">
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal">
|
||||
Please log in to manage settings.
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else-if="!selectedOrganization">
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal">
|
||||
Please select an organization to manage API keys.
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-else>
|
||||
<!-- Organizations Section -->
|
||||
<v-col cols="12">
|
||||
<v-card class="mb-4">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>Organizations</span>
|
||||
<v-btn
|
||||
v-if="isAdmin"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="openAddOrgDialog"
|
||||
>
|
||||
Create Organization
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list v-if="organizations.length > 0">
|
||||
<v-list-group
|
||||
v-for="org in organizations"
|
||||
:key="org.id"
|
||||
class="mb-2"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:active="org.id === selectedOrganization?.id"
|
||||
border
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-domain" class="mr-2" />
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">
|
||||
{{ org.name }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<v-chip size="small" class="mr-2" variant="tonal">
|
||||
{{ org.apiKeys?.length || 0 }} API keys
|
||||
</v-chip>
|
||||
<v-chip size="small" variant="tonal">
|
||||
{{ org.users?.length || 0 }} users
|
||||
</v-chip>
|
||||
</v-list-item-subtitle>
|
||||
<template v-if="isAdmin" #append>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
variant="text"
|
||||
color="error"
|
||||
size="small"
|
||||
@click.stop="openDeleteOrgDialog(org)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<!-- Expandable content showing users -->
|
||||
<v-list-item>
|
||||
<v-list-item-title class="text-subtitle-2 mb-2">
|
||||
<v-icon icon="mdi-account-multiple" size="small" class="mr-1" />
|
||||
Users
|
||||
</v-list-item-title>
|
||||
<v-list density="compact" class="ml-4">
|
||||
<v-list-item
|
||||
v-for="user in org.users"
|
||||
:key="user.id"
|
||||
class="text-caption"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :icon="getUserProviderIcon(user.id)" size="small" class="mr-2" />
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2">
|
||||
<v-chip v-if="getUserProvider(user.id)" size="x-small" variant="tonal" class="mr-2">
|
||||
{{ getUserProvider(user.id) }}
|
||||
</v-chip>
|
||||
{{ getUserId(user.id) }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption">
|
||||
<span class="text-grey">Full ID: {{ user.id }}</span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="!org.users || org.users.length === 0">
|
||||
<v-list-item-title class="text-caption text-grey">
|
||||
No users
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-list-item>
|
||||
</v-list-group>
|
||||
</v-list>
|
||||
<v-alert v-else type="info" variant="tonal">
|
||||
<div class="d-flex flex-column align-center">
|
||||
<p>You don't belong to any organizations yet.</p>
|
||||
<p v-if="isAdmin" class="text-caption">Create one to get started.</p>
|
||||
<p v-else class="text-caption">Contact your administrator to get access to an organization.</p>
|
||||
</div>
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- API Keys Section -->
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>API Keys - {{ selectedOrganization.name }}</span>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="openAddKeyDialog"
|
||||
>
|
||||
Add API Key
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list v-if="selectedOrganization.apiKeys && selectedOrganization.apiKeys.length > 0">
|
||||
<v-list-item
|
||||
v-for="apiKey in selectedOrganization.apiKeys"
|
||||
:key="apiKey.id"
|
||||
class="mb-2"
|
||||
border
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-key" class="mr-2" />
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">
|
||||
{{ apiKey.name }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<div class="mt-2">
|
||||
<v-chip size="small" class="mr-2" variant="tonal">
|
||||
{{ apiKey.refs?.length || 0 }} refs
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="apiKey.read"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
color="blue"
|
||||
variant="tonal"
|
||||
>
|
||||
Read
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="apiKey.publish"
|
||||
size="small"
|
||||
color="green"
|
||||
variant="tonal"
|
||||
>
|
||||
Publish
|
||||
</v-chip>
|
||||
</div>
|
||||
<div v-if="apiKey.refs && apiKey.refs.length > 0" class="mt-2">
|
||||
<strong>Refs:</strong> {{ apiKey.refs.join(', ') }}
|
||||
</div>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
variant="text"
|
||||
color="error"
|
||||
size="small"
|
||||
@click.stop="openDeleteKeyDialog(apiKey)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-alert v-else type="info" variant="tonal">
|
||||
No API keys yet. Create one to get started.
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Create Organization Dialog -->
|
||||
<v-dialog v-model="addOrgDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>Create Organization</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="newOrgName"
|
||||
label="Organization Name"
|
||||
variant="outlined"
|
||||
:error-messages="orgNameError"
|
||||
hint="A unique name for your organization"
|
||||
persistent-hint
|
||||
@keyup.enter="createOrganization"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="addOrgDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="creatingOrg"
|
||||
:disabled="!newOrgName"
|
||||
@click="createOrganization"
|
||||
>
|
||||
Create
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Add API Key Dialog -->
|
||||
<v-dialog v-model="addKeyDialog" max-width="600">
|
||||
<v-card>
|
||||
<v-card-title>Create API Key</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="newKeyName"
|
||||
label="Key Name"
|
||||
variant="outlined"
|
||||
:error-messages="keyNameError"
|
||||
class="mb-4"
|
||||
hint="A descriptive name for this API key"
|
||||
persistent-hint
|
||||
/>
|
||||
|
||||
<v-combobox
|
||||
v-model="newKeyRefs"
|
||||
label="Refs"
|
||||
variant="outlined"
|
||||
multiple
|
||||
chips
|
||||
:error-messages="refsError"
|
||||
class="mb-4"
|
||||
hint="Which refs this key can access (e.g., production, staging)"
|
||||
persistent-hint
|
||||
/>
|
||||
|
||||
<v-checkbox
|
||||
v-model="newKeyRead"
|
||||
label="Read Access"
|
||||
hint="Allow reading schemas and supergraphs"
|
||||
persistent-hint
|
||||
/>
|
||||
|
||||
<v-checkbox
|
||||
v-model="newKeyPublish"
|
||||
label="Publish Access"
|
||||
hint="Allow publishing/updating subgraph schemas"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="addKeyDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="creatingKey"
|
||||
:disabled="!canCreateKey"
|
||||
@click="createAPIKey"
|
||||
>
|
||||
Create Key
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Show API Key Dialog (only shown once after creation) -->
|
||||
<v-dialog v-model="showKeyDialog" max-width="600" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon icon="mdi-alert" color="warning" class="mr-2" />
|
||||
Save Your API Key
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert type="warning" variant="tonal" class="mb-4">
|
||||
This is the only time you'll see this key. Please copy it now and store it securely.
|
||||
</v-alert>
|
||||
|
||||
<div class="mb-4">
|
||||
<strong>Key Name:</strong> {{ createdKey?.name }}
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
:model-value="createdKey?.key"
|
||||
label="API Key"
|
||||
variant="outlined"
|
||||
readonly
|
||||
class="mb-2"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-btn
|
||||
icon="mdi-content-copy"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="copyKey"
|
||||
/>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<div class="text-caption text-grey">
|
||||
Use this key in the X-API-Key header when making requests to the schema registry.
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="closeKeyDialog"
|
||||
>
|
||||
I've Saved the Key
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Organization Confirmation Dialog -->
|
||||
<v-dialog v-model="deleteOrgDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title class="text-error">
|
||||
<v-icon icon="mdi-alert" color="error" class="mr-2" />
|
||||
Delete Organization
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert type="warning" variant="tonal" class="mb-4">
|
||||
This action cannot be undone. All API keys and data for this organization will be permanently removed.
|
||||
</v-alert>
|
||||
<p>Are you sure you want to delete <strong>{{ orgToDelete?.name }}</strong>?</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="deleteOrgDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
:loading="deletingOrg"
|
||||
@click="deleteOrganization"
|
||||
>
|
||||
Delete Organization
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete API Key Confirmation Dialog -->
|
||||
<v-dialog v-model="deleteKeyDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title class="text-error">
|
||||
<v-icon icon="mdi-alert" color="error" class="mr-2" />
|
||||
Delete API Key
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert type="warning" variant="tonal" class="mb-4">
|
||||
This action cannot be undone. Services using this key will lose access.
|
||||
</v-alert>
|
||||
<p>Are you sure you want to delete the API key <strong>{{ keyToDelete?.name }}</strong>?</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="deleteKeyDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
:loading="deletingKey"
|
||||
@click="deleteAPIKey"
|
||||
>
|
||||
Delete Key
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar v-model="snackbar" :timeout="3000" :color="snackbarColor">
|
||||
{{ snackbarText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuth0 } from '@auth0/auth0-vue'
|
||||
|
||||
import { useOrganizationSelector } from '~/composables/useOrganizationSelector'
|
||||
import {
|
||||
useAddApiKeyMutation,
|
||||
useAddOrganizationMutation,
|
||||
useRemoveApiKeyMutation,
|
||||
useRemoveOrganizationMutation,
|
||||
} from '~/graphql/generated'
|
||||
|
||||
const auth0 = useAuth0()
|
||||
const { selectedOrganization, organizations, refetch, selectOrganization } = useOrganizationSelector()
|
||||
|
||||
// Check if user has admin role
|
||||
const isAdmin = computed(() => {
|
||||
if (!auth0?.isAuthenticated?.value || !auth0?.user?.value) return false
|
||||
|
||||
const roles = auth0.user.value['https://unbound.se/roles']
|
||||
return Array.isArray(roles) && roles.includes('admin')
|
||||
})
|
||||
|
||||
// Create Organization Dialog
|
||||
const addOrgDialog = ref(false)
|
||||
const newOrgName = ref('')
|
||||
const orgNameError = ref('')
|
||||
const creatingOrg = ref(false)
|
||||
|
||||
// Add API Key Dialog
|
||||
const addKeyDialog = ref(false)
|
||||
const newKeyName = ref('')
|
||||
const newKeyRefs = ref<string[]>([])
|
||||
const newKeyRead = ref(true)
|
||||
const newKeyPublish = ref(false)
|
||||
const keyNameError = ref('')
|
||||
const refsError = ref('')
|
||||
const creatingKey = ref(false)
|
||||
|
||||
// Computed property for button state to ensure reactivity
|
||||
const canCreateKey = computed(() => {
|
||||
return newKeyName.value.trim() !== '' && newKeyRefs.value.length > 0
|
||||
})
|
||||
|
||||
// Show created key dialog
|
||||
const showKeyDialog = ref(false)
|
||||
const createdKey = ref<any>(null)
|
||||
|
||||
// Snackbar
|
||||
const snackbar = ref(false)
|
||||
const snackbarText = ref('')
|
||||
const snackbarColor = ref('success')
|
||||
|
||||
// Delete Organization Dialog
|
||||
const deleteOrgDialog = ref(false)
|
||||
const orgToDelete = ref<any>(null)
|
||||
const deletingOrg = ref(false)
|
||||
|
||||
// Delete API Key Dialog
|
||||
const deleteKeyDialog = ref(false)
|
||||
const keyToDelete = ref<any>(null)
|
||||
const deletingKey = ref(false)
|
||||
|
||||
// Mutations
|
||||
const { mutate: addAPIKeyMutation } = useAddApiKeyMutation({})
|
||||
const { mutate: addOrganizationMutation } = useAddOrganizationMutation({})
|
||||
const { mutate: removeAPIKeyMutation } = useRemoveApiKeyMutation({})
|
||||
const { mutate: removeOrganizationMutation } = useRemoveOrganizationMutation({})
|
||||
|
||||
const openAddOrgDialog = () => {
|
||||
newOrgName.value = ''
|
||||
orgNameError.value = ''
|
||||
addOrgDialog.value = true
|
||||
}
|
||||
|
||||
const createOrganization = async () => {
|
||||
if (!newOrgName.value) {
|
||||
orgNameError.value = 'Organization name is required'
|
||||
return
|
||||
}
|
||||
|
||||
creatingOrg.value = true
|
||||
orgNameError.value = ''
|
||||
|
||||
try {
|
||||
const result = await addOrganizationMutation({
|
||||
name: newOrgName.value,
|
||||
})
|
||||
|
||||
if (!result?.data?.addOrganization) {
|
||||
throw new Error('Failed to create organization')
|
||||
}
|
||||
|
||||
snackbarText.value = `Organization "${newOrgName.value}" created successfully!`
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
addOrgDialog.value = false
|
||||
newOrgName.value = ''
|
||||
|
||||
// Refresh organizations and select the new one
|
||||
await refetch()
|
||||
selectOrganization(result.data.addOrganization.id)
|
||||
} catch (error: any) {
|
||||
orgNameError.value = error.message || 'Failed to create organization'
|
||||
snackbarText.value = 'Failed to create organization'
|
||||
snackbarColor.value = 'error'
|
||||
snackbar.value = true
|
||||
} finally {
|
||||
creatingOrg.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openAddKeyDialog = () => {
|
||||
newKeyName.value = ''
|
||||
newKeyRefs.value = []
|
||||
newKeyRead.value = true
|
||||
newKeyPublish.value = false
|
||||
keyNameError.value = ''
|
||||
refsError.value = ''
|
||||
addKeyDialog.value = true
|
||||
}
|
||||
|
||||
const createAPIKey = async () => {
|
||||
if (!newKeyName.value) {
|
||||
keyNameError.value = 'Key name is required'
|
||||
return
|
||||
}
|
||||
|
||||
if (newKeyRefs.value.length === 0) {
|
||||
refsError.value = 'At least one ref is required'
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedOrganization.value) {
|
||||
snackbarText.value = 'No organization selected'
|
||||
snackbarColor.value = 'error'
|
||||
snackbar.value = true
|
||||
return
|
||||
}
|
||||
|
||||
creatingKey.value = true
|
||||
keyNameError.value = ''
|
||||
refsError.value = ''
|
||||
|
||||
try {
|
||||
const result = await addAPIKeyMutation({
|
||||
input: {
|
||||
name: newKeyName.value,
|
||||
organizationId: selectedOrganization.value.id,
|
||||
refs: newKeyRefs.value,
|
||||
read: newKeyRead.value,
|
||||
publish: newKeyPublish.value,
|
||||
},
|
||||
})
|
||||
|
||||
if (!result?.data?.addAPIKey) {
|
||||
throw new Error('Failed to create API key')
|
||||
}
|
||||
|
||||
createdKey.value = result.data.addAPIKey
|
||||
addKeyDialog.value = false
|
||||
showKeyDialog.value = true
|
||||
|
||||
// Refresh organizations to show new key
|
||||
await refetch()
|
||||
} catch (error: any) {
|
||||
keyNameError.value = error.message || 'Failed to create API key'
|
||||
snackbarText.value = 'Failed to create API key'
|
||||
snackbarColor.value = 'error'
|
||||
snackbar.value = true
|
||||
} finally {
|
||||
creatingKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyKey = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.value?.key || '')
|
||||
snackbarText.value = 'API key copied to clipboard'
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
} catch (_err) {
|
||||
snackbarText.value = 'Failed to copy'
|
||||
snackbarColor.value = 'error'
|
||||
snackbar.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const closeKeyDialog = () => {
|
||||
showKeyDialog.value = false
|
||||
createdKey.value = null
|
||||
}
|
||||
|
||||
// Helper functions to parse user IDs
|
||||
const getUserProvider = (userId: string) => {
|
||||
if (!userId) return ''
|
||||
const parts = userId.split('|')
|
||||
if (parts.length === 2) {
|
||||
// Map provider names to more readable formats
|
||||
const providerMap: Record<string, string> = {
|
||||
'google-oauth2': 'Google',
|
||||
'auth0': 'Email/Password',
|
||||
'github': 'GitHub',
|
||||
'twitter': 'Twitter',
|
||||
'facebook': 'Facebook',
|
||||
'windowslive': 'Microsoft',
|
||||
'linkedin': 'LinkedIn',
|
||||
}
|
||||
return providerMap[parts[0]] || parts[0]
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getUserId = (userId: string) => {
|
||||
if (!userId) return ''
|
||||
const parts = userId.split('|')
|
||||
if (parts.length === 2) {
|
||||
return parts[1]
|
||||
}
|
||||
return userId
|
||||
}
|
||||
|
||||
const getUserProviderIcon = (userId: string) => {
|
||||
if (!userId) return 'mdi-account'
|
||||
const parts = userId.split('|')
|
||||
if (parts.length === 2) {
|
||||
const iconMap: Record<string, string> = {
|
||||
'google-oauth2': 'mdi-google',
|
||||
'auth0': 'mdi-email',
|
||||
'github': 'mdi-github',
|
||||
'twitter': 'mdi-twitter',
|
||||
'facebook': 'mdi-facebook',
|
||||
'windowslive': 'mdi-microsoft',
|
||||
'linkedin': 'mdi-linkedin',
|
||||
}
|
||||
return iconMap[parts[0]] || 'mdi-account'
|
||||
}
|
||||
return 'mdi-account'
|
||||
}
|
||||
|
||||
const openDeleteOrgDialog = (org: any) => {
|
||||
orgToDelete.value = org
|
||||
deleteOrgDialog.value = true
|
||||
}
|
||||
|
||||
const deleteOrganization = async () => {
|
||||
if (!orgToDelete.value) return
|
||||
|
||||
deletingOrg.value = true
|
||||
|
||||
try {
|
||||
const result = await removeOrganizationMutation({
|
||||
organizationId: orgToDelete.value.id,
|
||||
})
|
||||
|
||||
if (!result?.data?.removeOrganization) {
|
||||
throw new Error('Failed to delete organization')
|
||||
}
|
||||
|
||||
snackbarText.value = `Organization "${orgToDelete.value.name}" deleted successfully`
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
deleteOrgDialog.value = false
|
||||
orgToDelete.value = null
|
||||
|
||||
// Refresh organizations
|
||||
await refetch()
|
||||
|
||||
// If deleted org was selected, select another one
|
||||
if (organizations.value.length > 0) {
|
||||
selectOrganization(organizations.value[0].id)
|
||||
}
|
||||
} catch (error: any) {
|
||||
snackbarText.value = error.message || 'Failed to delete organization'
|
||||
snackbarColor.value = 'error'
|
||||
snackbar.value = true
|
||||
} finally {
|
||||
deletingOrg.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openDeleteKeyDialog = (apiKey: any) => {
|
||||
keyToDelete.value = apiKey
|
||||
deleteKeyDialog.value = true
|
||||
}
|
||||
|
||||
const deleteAPIKey = async () => {
|
||||
if (!keyToDelete.value || !selectedOrganization.value) return
|
||||
|
||||
deletingKey.value = true
|
||||
|
||||
try {
|
||||
const result = await removeAPIKeyMutation({
|
||||
organizationId: selectedOrganization.value.id,
|
||||
keyName: keyToDelete.value.name,
|
||||
})
|
||||
|
||||
if (!result?.data?.removeAPIKey) {
|
||||
throw new Error('Failed to delete API key')
|
||||
}
|
||||
|
||||
snackbarText.value = `API key "${keyToDelete.value.name}" deleted successfully`
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
deleteKeyDialog.value = false
|
||||
keyToDelete.value = null
|
||||
|
||||
// Refresh organizations
|
||||
await refetch()
|
||||
} catch (error: any) {
|
||||
snackbarText.value = error.message || 'Failed to delete API key'
|
||||
snackbarColor.value = 'error'
|
||||
snackbar.value = true
|
||||
} finally {
|
||||
deletingKey.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user