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:
2025-11-22 16:42:35 +01:00
commit 072e1b10f1
41 changed files with 25557 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
<template>
<v-app>
<v-app-bar color="primary" prominent>
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer" />
<v-toolbar-title>Unbound Schemas</v-toolbar-title>
<v-spacer />
<template v-if="auth0?.isLoading?.value">
<v-progress-circular indeterminate size="24" width="2" />
</template>
<template v-else-if="auth0?.isAuthenticated?.value">
<OrganizationSwitcher class="mr-4" />
<v-menu>
<template #activator="{ props }">
<v-btn v-bind="props" variant="text">
<v-avatar size="32" class="mr-2">
<v-img v-if="auth0?.user?.value?.picture" :src="auth0.user.value.picture" />
<v-icon v-else>mdi-account-circle</v-icon>
</v-avatar>
{{ auth0?.user?.value?.name || auth0?.user?.value?.email }}
</v-btn>
</template>
<v-list>
<v-list-item>
<v-list-item-title>{{ auth0?.user?.value?.email }}</v-list-item-title>
<v-list-item-subtitle>{{ auth0?.user?.value?.name }}</v-list-item-subtitle>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-cog" title="Settings" to="/settings" />
<v-list-item prepend-icon="mdi-logout" title="Logout" @click="auth0?.logout({ logoutParams: { returnTo: window.location.origin } })" />
</v-list>
</v-menu>
</template>
<template v-else>
<v-btn variant="outlined" @click="auth0?.loginWithRedirect()">
Login
</v-btn>
</template>
</v-app-bar>
<v-navigation-drawer v-model="drawer" temporary>
<v-list>
<v-list-item
prepend-icon="mdi-view-dashboard"
title="Dashboard"
to="/"
/>
<v-list-item
prepend-icon="mdi-graphql"
title="Schemas"
to="/schemas"
/>
<v-list-item
prepend-icon="mdi-source-branch"
title="Refs"
to="/refs"
/>
<v-list-item
prepend-icon="mdi-cog"
title="Settings"
to="/settings"
/>
</v-list>
</v-navigation-drawer>
<v-main>
<v-container fluid>
<NuxtPage />
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
import { useAuth0 } from '@auth0/auth0-vue'
const drawer = ref(false)
const auth0 = useAuth0()
</script>
+42
View File
@@ -0,0 +1,42 @@
<template>
<v-menu v-if="organizations.length > 1">
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="outlined"
prepend-icon="mdi-domain"
class="text-none"
>
{{ selectedOrganization?.name || 'Select Organization' }}
<v-icon end>mdi-menu-down</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="org in organizations"
:key="org.id"
:active="org.id === selectedOrgId"
@click="selectOrganization(org.id)"
>
<v-list-item-title>
<v-icon v-if="org.id === selectedOrgId" start size="small">mdi-check</v-icon>
{{ org.name }}
</v-list-item-title>
<template #append>
<v-chip size="small" variant="tonal">
{{ org.apiKeys?.length || 0 }} keys
</v-chip>
</template>
</v-list-item>
</v-list>
</v-menu>
<v-chip v-else-if="organizations.length === 1" variant="outlined" prepend-icon="mdi-domain">
{{ organizations[0].name }}
</v-chip>
</template>
<script setup lang="ts">
import { useOrganizationSelector } from '~/composables/useOrganizationSelector'
const { organizations, selectedOrganization, selectedOrgId, selectOrganization } = useOrganizationSelector()
</script>
@@ -0,0 +1,60 @@
import { useAuth0 } from '@auth0/auth0-vue'
import { computed, ref, watch } from 'vue'
import { useOrganizationsQuery } from '~/graphql/generated'
const selectedOrgId = ref<string | null>(null)
export const useOrganizationSelector = () => {
const auth0 = useAuth0()
// Fetch user's organizations
const { result, loading, error, refetch } = useOrganizationsQuery(() => ({
skip: !auth0.isAuthenticated.value,
}))
// Get list of organizations
const organizations = computed(() => {
return result.value?.organizations || []
})
// Get currently selected organization
const selectedOrganization = computed(() => {
if (!selectedOrgId.value) return null
return organizations.value.find(org => org.id === selectedOrgId.value) || null
})
// Auto-select first organization if none selected
watch(organizations, (orgs) => {
if (orgs.length > 0 && !selectedOrgId.value) {
// Try to restore from localStorage
const saved = localStorage.getItem('selectedOrgId')
if (saved && orgs.find(o => o.id === saved)) {
selectedOrgId.value = saved
} else {
selectedOrgId.value = orgs[0].id
}
}
}, { immediate: true })
// Save selection to localStorage
watch(selectedOrgId, (newId) => {
if (newId) {
localStorage.setItem('selectedOrgId', newId)
}
})
const selectOrganization = (orgId: string) => {
selectedOrgId.value = orgId
}
return {
organizations,
selectedOrganization,
selectedOrgId: computed(() => selectedOrgId.value),
selectOrganization,
loading: loading || ref(false),
error: error || ref(null),
refetch,
}
}
+584
View File
@@ -0,0 +1,584 @@
import * as VueApolloComposable from '@vue/apollo-composable'
import gql from 'graphql-tag'
import type * as VueCompositionApi from 'vue'
export type Maybe<T> = T | null
export type InputMaybe<T> = Maybe<T>
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> }
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> }
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never }
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }
export type ReactiveFunction<TParam> = () => TParam
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string }
String: { input: string; output: string }
Boolean: { input: boolean; output: boolean }
Int: { input: number; output: number }
Float: { input: number; output: number }
Time: { input: any; output: any }
}
export type ApiKey = {
__typename?: 'APIKey'
id: Scalars['ID']['output']
key?: Maybe<Scalars['String']['output']>
name: Scalars['String']['output']
organization: Organization
publish: Scalars['Boolean']['output']
read: Scalars['Boolean']['output']
refs: Array<Scalars['String']['output']>
}
export type InputApiKey = {
name: Scalars['String']['input']
organizationId: Scalars['ID']['input']
publish: Scalars['Boolean']['input']
read: Scalars['Boolean']['input']
refs: Array<Scalars['String']['input']>
}
export type InputSubGraph = {
ref: Scalars['String']['input']
sdl: Scalars['String']['input']
service: Scalars['String']['input']
url?: InputMaybe<Scalars['String']['input']>
wsUrl?: InputMaybe<Scalars['String']['input']>
}
export type Mutation = {
__typename?: 'Mutation'
addAPIKey: ApiKey
addOrganization: Organization
addUserToOrganization: Organization
removeAPIKey: Organization
removeOrganization: Scalars['Boolean']['output']
updateSubGraph: SubGraph
}
export type MutationAddApiKeyArgs = {
input?: InputMaybe<InputApiKey>
}
export type MutationAddOrganizationArgs = {
name: Scalars['String']['input']
}
export type MutationAddUserToOrganizationArgs = {
organizationId: Scalars['ID']['input']
userId: Scalars['String']['input']
}
export type MutationRemoveApiKeyArgs = {
keyName: Scalars['String']['input']
organizationId: Scalars['ID']['input']
}
export type MutationRemoveOrganizationArgs = {
organizationId: Scalars['ID']['input']
}
export type MutationUpdateSubGraphArgs = {
input: InputSubGraph
}
export type Organization = {
__typename?: 'Organization'
apiKeys: Array<ApiKey>
id: Scalars['ID']['output']
name: Scalars['String']['output']
users: Array<User>
}
export type Query = {
__typename?: 'Query'
allOrganizations: Array<Organization>
latestSchema: SchemaUpdate
organizations: Array<Organization>
supergraph: Supergraph
}
export type QueryLatestSchemaArgs = {
ref: Scalars['String']['input']
}
export type QuerySupergraphArgs = {
isAfter?: InputMaybe<Scalars['String']['input']>
ref: Scalars['String']['input']
}
export type SchemaUpdate = {
__typename?: 'SchemaUpdate'
cosmoRouterConfig?: Maybe<Scalars['String']['output']>
id: Scalars['ID']['output']
ref: Scalars['String']['output']
subGraphs: Array<SubGraph>
}
export type SubGraph = {
__typename?: 'SubGraph'
changedAt: Scalars['Time']['output']
changedBy: Scalars['String']['output']
id: Scalars['ID']['output']
sdl: Scalars['String']['output']
service: Scalars['String']['output']
url?: Maybe<Scalars['String']['output']>
wsUrl?: Maybe<Scalars['String']['output']>
}
export type SubGraphs = {
__typename?: 'SubGraphs'
id: Scalars['ID']['output']
minDelaySeconds: Scalars['Int']['output']
sdl: Scalars['String']['output']
subGraphs: Array<SubGraph>
}
export type Subscription = {
__typename?: 'Subscription'
schemaUpdates: SchemaUpdate
}
export type SubscriptionSchemaUpdatesArgs = {
ref: Scalars['String']['input']
}
export type Supergraph = SubGraphs | Unchanged
export type Unchanged = {
__typename?: 'Unchanged'
id: Scalars['ID']['output']
minDelaySeconds: Scalars['Int']['output']
}
export type User = {
__typename?: 'User'
id: Scalars['String']['output']
}
export type OrganizationsQueryVariables = Exact<{ [key: string]: never }>
export type OrganizationsQuery = { __typename?: 'Query'; organizations: Array<{ __typename?: 'Organization'; id: string; name: string; users: Array<{ __typename?: 'User'; id: string }>; apiKeys: Array<{ __typename?: 'APIKey'; id: string; name: string; refs: Array<string>; read: boolean; publish: boolean }> }> }
export type AllOrganizationsQueryVariables = Exact<{ [key: string]: never }>
export type AllOrganizationsQuery = { __typename?: 'Query'; allOrganizations: Array<{ __typename?: 'Organization'; id: string; name: string; users: Array<{ __typename?: 'User'; id: string }>; apiKeys: Array<{ __typename?: 'APIKey'; id: string; name: string; refs: Array<string>; read: boolean; publish: boolean }> }> }
export type LatestSchemaQueryVariables = Exact<{
ref: Scalars['String']['input']
}>
export type LatestSchemaQuery = { __typename?: 'Query'; latestSchema: { __typename?: 'SchemaUpdate'; ref: string; id: string; cosmoRouterConfig?: string | null; subGraphs: Array<{ __typename?: 'SubGraph'; id: string; service: string; url?: string | null; wsUrl?: string | null; sdl: string; changedBy: string; changedAt: any }> } }
export type SupergraphQueryVariables = Exact<{
ref: Scalars['String']['input']
isAfter?: InputMaybe<Scalars['String']['input']>
}>
export type SupergraphQuery = { __typename?: 'Query', supergraph:
| { __typename?: 'SubGraphs'; id: string; sdl: string; subGraphs: Array<{ __typename?: 'SubGraph'; id: string; service: string; url?: string | null; wsUrl?: string | null; sdl: string; changedBy: string; changedAt: any }> }
| { __typename?: 'Unchanged'; id: string; minDelaySeconds: number }
}
export type AddOrganizationMutationVariables = Exact<{
name: Scalars['String']['input']
}>
export type AddOrganizationMutation = { __typename?: 'Mutation'; addOrganization: { __typename?: 'Organization'; id: string; name: string; users: Array<{ __typename?: 'User'; id: string }>; apiKeys: Array<{ __typename?: 'APIKey'; id: string; name: string; refs: Array<string>; read: boolean; publish: boolean }> } }
export type AddUserToOrganizationMutationVariables = Exact<{
organizationId: Scalars['ID']['input']
userId: Scalars['String']['input']
}>
export type AddUserToOrganizationMutation = { __typename?: 'Mutation'; addUserToOrganization: { __typename?: 'Organization'; id: string; name: string; users: Array<{ __typename?: 'User'; id: string }>; apiKeys: Array<{ __typename?: 'APIKey'; id: string; name: string; refs: Array<string>; read: boolean; publish: boolean }> } }
export type AddApiKeyMutationVariables = Exact<{
input: InputApiKey
}>
export type AddApiKeyMutation = { __typename?: 'Mutation'; addAPIKey: { __typename?: 'APIKey'; id: string; name: string; key?: string | null; refs: Array<string>; read: boolean; publish: boolean; organization: { __typename?: 'Organization'; id: string; name: string } } }
export type RemoveApiKeyMutationVariables = Exact<{
organizationId: Scalars['ID']['input']
keyName: Scalars['String']['input']
}>
export type RemoveApiKeyMutation = { __typename?: 'Mutation'; removeAPIKey: { __typename?: 'Organization'; id: string; name: string; users: Array<{ __typename?: 'User'; id: string }>; apiKeys: Array<{ __typename?: 'APIKey'; id: string; name: string; refs: Array<string>; read: boolean; publish: boolean }> } }
export type RemoveOrganizationMutationVariables = Exact<{
organizationId: Scalars['ID']['input']
}>
export type RemoveOrganizationMutation = { __typename?: 'Mutation'; removeOrganization: boolean }
export const OrganizationsDocument = gql`
query Organizations {
organizations {
id
name
users {
id
}
apiKeys {
id
name
refs
read
publish
}
}
}
`
/**
* __useOrganizationsQuery__
*
* To run a query within a Vue component, call `useOrganizationsQuery` and pass it any options that fit your needs.
* When your component renders, `useOrganizationsQuery` returns an object from Apollo Client that contains result, loading and error properties
* you can use to render your UI.
*
* @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
*
* @example
* const { result, loading, error } = useOrganizationsQuery();
*/
export function useOrganizationsQuery(options: VueApolloComposable.UseQueryOptions<OrganizationsQuery, OrganizationsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<OrganizationsQuery, OrganizationsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<OrganizationsQuery, OrganizationsQueryVariables>> = {}) {
return VueApolloComposable.useQuery<OrganizationsQuery, OrganizationsQueryVariables>(OrganizationsDocument, {}, options)
}
export function useOrganizationsLazyQuery(options: VueApolloComposable.UseQueryOptions<OrganizationsQuery, OrganizationsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<OrganizationsQuery, OrganizationsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<OrganizationsQuery, OrganizationsQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<OrganizationsQuery, OrganizationsQueryVariables>(OrganizationsDocument, {}, options)
}
export type OrganizationsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<OrganizationsQuery, OrganizationsQueryVariables>
export const AllOrganizationsDocument = gql`
query AllOrganizations {
allOrganizations {
id
name
users {
id
}
apiKeys {
id
name
refs
read
publish
}
}
}
`
/**
* __useAllOrganizationsQuery__
*
* To run a query within a Vue component, call `useAllOrganizationsQuery` and pass it any options that fit your needs.
* When your component renders, `useAllOrganizationsQuery` returns an object from Apollo Client that contains result, loading and error properties
* you can use to render your UI.
*
* @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
*
* @example
* const { result, loading, error } = useAllOrganizationsQuery();
*/
export function useAllOrganizationsQuery(options: VueApolloComposable.UseQueryOptions<AllOrganizationsQuery, AllOrganizationsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<AllOrganizationsQuery, AllOrganizationsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<AllOrganizationsQuery, AllOrganizationsQueryVariables>> = {}) {
return VueApolloComposable.useQuery<AllOrganizationsQuery, AllOrganizationsQueryVariables>(AllOrganizationsDocument, {}, options)
}
export function useAllOrganizationsLazyQuery(options: VueApolloComposable.UseQueryOptions<AllOrganizationsQuery, AllOrganizationsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<AllOrganizationsQuery, AllOrganizationsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<AllOrganizationsQuery, AllOrganizationsQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<AllOrganizationsQuery, AllOrganizationsQueryVariables>(AllOrganizationsDocument, {}, options)
}
export type AllOrganizationsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<AllOrganizationsQuery, AllOrganizationsQueryVariables>
export const LatestSchemaDocument = gql`
query LatestSchema($ref: String!) {
latestSchema(ref: $ref) {
ref
id
subGraphs {
id
service
url
wsUrl
sdl
changedBy
changedAt
}
cosmoRouterConfig
}
}
`
/**
* __useLatestSchemaQuery__
*
* To run a query within a Vue component, call `useLatestSchemaQuery` and pass it any options that fit your needs.
* When your component renders, `useLatestSchemaQuery` returns an object from Apollo Client that contains result, loading and error properties
* you can use to render your UI.
*
* @param variables that will be passed into the query
* @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
*
* @example
* const { result, loading, error } = useLatestSchemaQuery({
* ref: // value for 'ref'
* });
*/
export function useLatestSchemaQuery(variables: LatestSchemaQueryVariables | VueCompositionApi.Ref<LatestSchemaQueryVariables> | ReactiveFunction<LatestSchemaQueryVariables>, options: VueApolloComposable.UseQueryOptions<LatestSchemaQuery, LatestSchemaQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<LatestSchemaQuery, LatestSchemaQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<LatestSchemaQuery, LatestSchemaQueryVariables>> = {}) {
return VueApolloComposable.useQuery<LatestSchemaQuery, LatestSchemaQueryVariables>(LatestSchemaDocument, variables, options)
}
export function useLatestSchemaLazyQuery(variables?: LatestSchemaQueryVariables | VueCompositionApi.Ref<LatestSchemaQueryVariables> | ReactiveFunction<LatestSchemaQueryVariables>, options: VueApolloComposable.UseQueryOptions<LatestSchemaQuery, LatestSchemaQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<LatestSchemaQuery, LatestSchemaQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<LatestSchemaQuery, LatestSchemaQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<LatestSchemaQuery, LatestSchemaQueryVariables>(LatestSchemaDocument, variables, options)
}
export type LatestSchemaQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<LatestSchemaQuery, LatestSchemaQueryVariables>
export const SupergraphDocument = gql`
query Supergraph($ref: String!, $isAfter: String) {
supergraph(ref: $ref, isAfter: $isAfter) {
... on SubGraphs {
id
sdl
subGraphs {
id
service
url
wsUrl
sdl
changedBy
changedAt
}
}
... on Unchanged {
id
minDelaySeconds
}
}
}
`
/**
* __useSupergraphQuery__
*
* To run a query within a Vue component, call `useSupergraphQuery` and pass it any options that fit your needs.
* When your component renders, `useSupergraphQuery` returns an object from Apollo Client that contains result, loading and error properties
* you can use to render your UI.
*
* @param variables that will be passed into the query
* @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
*
* @example
* const { result, loading, error } = useSupergraphQuery({
* ref: // value for 'ref'
* isAfter: // value for 'isAfter'
* });
*/
export function useSupergraphQuery(variables: SupergraphQueryVariables | VueCompositionApi.Ref<SupergraphQueryVariables> | ReactiveFunction<SupergraphQueryVariables>, options: VueApolloComposable.UseQueryOptions<SupergraphQuery, SupergraphQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<SupergraphQuery, SupergraphQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<SupergraphQuery, SupergraphQueryVariables>> = {}) {
return VueApolloComposable.useQuery<SupergraphQuery, SupergraphQueryVariables>(SupergraphDocument, variables, options)
}
export function useSupergraphLazyQuery(variables?: SupergraphQueryVariables | VueCompositionApi.Ref<SupergraphQueryVariables> | ReactiveFunction<SupergraphQueryVariables>, options: VueApolloComposable.UseQueryOptions<SupergraphQuery, SupergraphQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<SupergraphQuery, SupergraphQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<SupergraphQuery, SupergraphQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<SupergraphQuery, SupergraphQueryVariables>(SupergraphDocument, variables, options)
}
export type SupergraphQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<SupergraphQuery, SupergraphQueryVariables>
export const AddOrganizationDocument = gql`
mutation AddOrganization($name: String!) {
addOrganization(name: $name) {
id
name
users {
id
}
apiKeys {
id
name
refs
read
publish
}
}
}
`
/**
* __useAddOrganizationMutation__
*
* To run a mutation, you first call `useAddOrganizationMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useAddOrganizationMutation` returns an object that includes:
* - A mutate function that you can call at any time to execute the mutation
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
*
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
*
* @example
* const { mutate, loading, error, onDone } = useAddOrganizationMutation({
* variables: {
* name: // value for 'name'
* },
* });
*/
export function useAddOrganizationMutation(options: VueApolloComposable.UseMutationOptions<AddOrganizationMutation, AddOrganizationMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<AddOrganizationMutation, AddOrganizationMutationVariables>> = {}) {
return VueApolloComposable.useMutation<AddOrganizationMutation, AddOrganizationMutationVariables>(AddOrganizationDocument, options)
}
export type AddOrganizationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<AddOrganizationMutation, AddOrganizationMutationVariables>
export const AddUserToOrganizationDocument = gql`
mutation AddUserToOrganization($organizationId: ID!, $userId: String!) {
addUserToOrganization(organizationId: $organizationId, userId: $userId) {
id
name
users {
id
}
apiKeys {
id
name
refs
read
publish
}
}
}
`
/**
* __useAddUserToOrganizationMutation__
*
* To run a mutation, you first call `useAddUserToOrganizationMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useAddUserToOrganizationMutation` returns an object that includes:
* - A mutate function that you can call at any time to execute the mutation
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
*
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
*
* @example
* const { mutate, loading, error, onDone } = useAddUserToOrganizationMutation({
* variables: {
* organizationId: // value for 'organizationId'
* userId: // value for 'userId'
* },
* });
*/
export function useAddUserToOrganizationMutation(options: VueApolloComposable.UseMutationOptions<AddUserToOrganizationMutation, AddUserToOrganizationMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<AddUserToOrganizationMutation, AddUserToOrganizationMutationVariables>> = {}) {
return VueApolloComposable.useMutation<AddUserToOrganizationMutation, AddUserToOrganizationMutationVariables>(AddUserToOrganizationDocument, options)
}
export type AddUserToOrganizationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<AddUserToOrganizationMutation, AddUserToOrganizationMutationVariables>
export const AddApiKeyDocument = gql`
mutation AddAPIKey($input: InputAPIKey!) {
addAPIKey(input: $input) {
id
name
key
organization {
id
name
}
refs
read
publish
}
}
`
/**
* __useAddApiKeyMutation__
*
* To run a mutation, you first call `useAddApiKeyMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useAddApiKeyMutation` returns an object that includes:
* - A mutate function that you can call at any time to execute the mutation
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
*
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
*
* @example
* const { mutate, loading, error, onDone } = useAddApiKeyMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useAddApiKeyMutation(options: VueApolloComposable.UseMutationOptions<AddApiKeyMutation, AddApiKeyMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<AddApiKeyMutation, AddApiKeyMutationVariables>> = {}) {
return VueApolloComposable.useMutation<AddApiKeyMutation, AddApiKeyMutationVariables>(AddApiKeyDocument, options)
}
export type AddApiKeyMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<AddApiKeyMutation, AddApiKeyMutationVariables>
export const RemoveApiKeyDocument = gql`
mutation RemoveAPIKey($organizationId: ID!, $keyName: String!) {
removeAPIKey(organizationId: $organizationId, keyName: $keyName) {
id
name
users {
id
}
apiKeys {
id
name
refs
read
publish
}
}
}
`
/**
* __useRemoveApiKeyMutation__
*
* To run a mutation, you first call `useRemoveApiKeyMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useRemoveApiKeyMutation` returns an object that includes:
* - A mutate function that you can call at any time to execute the mutation
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
*
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
*
* @example
* const { mutate, loading, error, onDone } = useRemoveApiKeyMutation({
* variables: {
* organizationId: // value for 'organizationId'
* keyName: // value for 'keyName'
* },
* });
*/
export function useRemoveApiKeyMutation(options: VueApolloComposable.UseMutationOptions<RemoveApiKeyMutation, RemoveApiKeyMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<RemoveApiKeyMutation, RemoveApiKeyMutationVariables>> = {}) {
return VueApolloComposable.useMutation<RemoveApiKeyMutation, RemoveApiKeyMutationVariables>(RemoveApiKeyDocument, options)
}
export type RemoveApiKeyMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<RemoveApiKeyMutation, RemoveApiKeyMutationVariables>
export const RemoveOrganizationDocument = gql`
mutation RemoveOrganization($organizationId: ID!) {
removeOrganization(organizationId: $organizationId)
}
`
/**
* __useRemoveOrganizationMutation__
*
* To run a mutation, you first call `useRemoveOrganizationMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useRemoveOrganizationMutation` returns an object that includes:
* - A mutate function that you can call at any time to execute the mutation
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
*
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
*
* @example
* const { mutate, loading, error, onDone } = useRemoveOrganizationMutation({
* variables: {
* organizationId: // value for 'organizationId'
* },
* });
*/
export function useRemoveOrganizationMutation(options: VueApolloComposable.UseMutationOptions<RemoveOrganizationMutation, RemoveOrganizationMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<RemoveOrganizationMutation, RemoveOrganizationMutationVariables>> = {}) {
return VueApolloComposable.useMutation<RemoveOrganizationMutation, RemoveOrganizationMutationVariables>(RemoveOrganizationDocument, options)
}
export type RemoveOrganizationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<RemoveOrganizationMutation, RemoveOrganizationMutationVariables>
+142
View File
@@ -0,0 +1,142 @@
query Organizations {
organizations {
id
name
users {
id
}
apiKeys {
id
name
refs
read
publish
}
}
}
query AllOrganizations {
allOrganizations {
id
name
users {
id
}
apiKeys {
id
name
refs
read
publish
}
}
}
query LatestSchema($ref: String!) {
latestSchema(ref: $ref) {
ref
id
subGraphs {
id
service
url
wsUrl
sdl
changedBy
changedAt
}
cosmoRouterConfig
}
}
query Supergraph($ref: String!, $isAfter: String) {
supergraph(ref: $ref, isAfter: $isAfter) {
... on SubGraphs {
id
sdl
subGraphs {
id
service
url
wsUrl
sdl
changedBy
changedAt
}
}
... on Unchanged {
id
minDelaySeconds
}
}
}
mutation AddOrganization($name: String!) {
addOrganization(name: $name) {
id
name
users {
id
}
apiKeys {
id
name
refs
read
publish
}
}
}
mutation AddUserToOrganization($organizationId: ID!, $userId: String!) {
addUserToOrganization(organizationId: $organizationId, userId: $userId) {
id
name
users {
id
}
apiKeys {
id
name
refs
read
publish
}
}
}
mutation AddAPIKey($input: InputAPIKey!) {
addAPIKey(input: $input) {
id
name
key
organization {
id
name
}
refs
read
publish
}
}
mutation RemoveAPIKey($organizationId: ID!, $keyName: String!) {
removeAPIKey(organizationId: $organizationId, keyName: $keyName) {
id
name
users {
id
}
apiKeys {
id
name
refs
read
publish
}
}
}
mutation RemoveOrganization($organizationId: ID!) {
removeOrganization(organizationId: $organizationId)
}
+363
View File
@@ -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>
+256
View File
@@ -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>
+219
View File
@@ -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>
+163
View File
@@ -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>
+716
View File
@@ -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>
+75
View File
@@ -0,0 +1,75 @@
import { ApolloClient, from, HttpLink, InMemoryCache } from '@apollo/client/core'
import { setContext } from '@apollo/client/link/context'
import { DefaultApolloClient } from '@vue/apollo-composable'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
// Determine API URL based on current host
const getApiUrl = () => {
if (typeof window === 'undefined') {
return config.public.apiBase
}
const hostname = window.location.hostname
// If running on localhost, use localhost:8080
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return 'http://localhost:8080'
}
// If running on schemas.unbound.se, use the same domain
if (hostname === 'schemas.unbound.se') {
return 'https://schemas.unbound.se'
}
// Fallback to config or construct from current location
return config.public.apiBase || `${window.location.protocol}//${window.location.host}`
}
// HTTP link for GraphQL endpoint
const httpLink = new HttpLink({
uri: `${getApiUrl()}/query`,
})
// Auth link to add authorization header
const authLink = setContext(async (_, { headers }) => {
try {
// Access Auth0 from the global app instance
const auth0Instance = (nuxtApp.vueApp.config.globalProperties as any).$auth0
if (auth0Instance?.isAuthenticated?.value) {
// Get the access token for API calls
const token = await auth0Instance.getAccessTokenSilently()
if (token) {
return {
headers: {
...headers,
authorization: `Bearer ${token}`,
},
}
}
}
} catch (error) {
console.error('[Apollo] Failed to get Auth0 token:', error)
}
return { headers }
})
// Create Apollo client
const apolloClient = new ApolloClient({
link: from([authLink, httpLink]),
cache: new InMemoryCache(),
})
// Provide Apollo client to the app
nuxtApp.vueApp.provide(DefaultApolloClient, apolloClient)
return {
provide: {
apolloClient,
},
}
})
+17
View File
@@ -0,0 +1,17 @@
import { createAuth0 } from '@auth0/auth0-vue'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const auth0 = createAuth0({
domain: config.public.auth0.domain,
clientId: config.public.auth0.clientId,
authorizationParams: {
redirect_uri: window.location.origin,
audience: config.public.auth0.audience,
},
cacheLocation: 'localstorage',
})
nuxtApp.vueApp.use(auth0)
})