072e1b10f1
- 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>
364 lines
10 KiB
Vue
364 lines
10 KiB
Vue
<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>
|