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>
|
||||
Reference in New Issue
Block a user