Tentative changes

This commit is contained in:
Viswamedha Nalabotu 2025-12-18 23:27:24 +00:00
parent b9252068c4
commit efc794381f
17 changed files with 2083 additions and 652 deletions

View file

@ -0,0 +1,21 @@
# Generated by Django 5.2.8 on 2025-12-17 17:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('agents', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='agent',
options={'verbose_name': 'Agent', 'verbose_name_plural': 'Agents'},
),
migrations.AlterModelOptions(
name='agentexecution',
options={'verbose_name': 'Agent Execution', 'verbose_name_plural': 'Agent Executions'},
),
]

View file

@ -1,38 +1,65 @@
from django.contrib import admin from django.contrib import admin
from apps.domains.models import Domain, Organisation, Dataset from apps.domains.models import Domain, Organization, Dataset, OrganizationMembership, InviteToken, DomainMembership
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
list_display = ('name', 'owner', 'uuid', 'created_at', 'updated_at')
search_fields = ('name', 'owner__email_address')
readonly_fields = ('uuid', 'created_at', 'updated_at')
fieldsets = (
(None, {'fields': ('name', 'uuid', 'description')}),
('Ownership', {'fields': ('owner',)}),
('Dates', {'fields': ('created_at', 'updated_at')}),
)
@admin.register(OrganizationMembership)
class OrganizationMembershipAdmin(admin.ModelAdmin):
list_display = ('user', 'organization', 'role', 'created_at')
list_filter = ('role', 'created_at')
search_fields = ('user__email_address', 'organization__name')
readonly_fields = ('created_at', 'updated_at')
@admin.register(InviteToken)
class InviteTokenAdmin(admin.ModelAdmin):
list_display = ('organization', 'created_by', 'expires_at', 'is_active', 'used_by', 'used_at')
list_filter = ('is_active', 'created_at', 'expires_at')
search_fields = ('organization__name', 'created_by__email_address', 'token')
readonly_fields = ('token', 'created_at', 'updated_at')
@admin.register(Domain) @admin.register(Domain)
class DomainAdmin(admin.ModelAdmin): class DomainAdmin(admin.ModelAdmin):
list_display = ('name', 'uuid') list_display = ('name', 'organization', 'uuid')
search_fields = ('name',) list_filter = ('organization',)
readonly_fields = ('uuid',) search_fields = ('name', 'organization__name')
fieldsets = ( readonly_fields = ('uuid',)
(None, {'fields': ('name', 'uuid')}), fieldsets = (
('Description', {'fields': ('description',)}), (None, {'fields': ('name', 'uuid')}),
) ('Description', {'fields': ('description',)}),
('Organization', {'fields': ('organization',)}),
)
@admin.register(Organisation) @admin.register(DomainMembership)
class OrganisationAdmin(admin.ModelAdmin): class DomainMembershipAdmin(admin.ModelAdmin):
list_display = ('name', 'uuid', 'created_at', 'updated_at') list_display = ('user', 'domain', 'created_at')
search_fields = ('name',) list_filter = ('created_at',)
readonly_fields = ('uuid', 'created_at', 'updated_at') search_fields = ('user__email_address', 'domain__name')
fieldsets = ( readonly_fields = ('created_at', 'updated_at')
(None, {'fields': ('name', 'uuid')}),
('Relations', {'fields': ('managers', 'employees', 'domains')}),
('Dates', {'fields': ('created_at', 'updated_at')}),
)
@admin.register(Dataset) @admin.register(Dataset)
class DatasetAdmin(admin.ModelAdmin): class DatasetAdmin(admin.ModelAdmin):
list_display = ('name', 'domain', 'uuid', 'created_by', 'created_at') list_display = ('name', 'domain', 'uuid', 'created_by', 'created_at')
search_fields = ('name', 'domain__name') search_fields = ('name', 'domain__name')
readonly_fields = ('uuid', 'created_at', 'updated_at') readonly_fields = ('uuid', 'created_at', 'updated_at')
fieldsets = ( fieldsets = (
(None, {'fields': ('name', 'uuid')}), (None, {'fields': ('name', 'uuid')}),
('Details', {'fields': ('domain', 'description', 'created_by')}), ('Details', {'fields': ('domain', 'description', 'created_by')}),
('File', {'fields': ('datafile',)}), ('File', {'fields': ('datafile',)}),
('Dates', {'fields': ('created_at', 'updated_at')}), ('Dates', {'fields': ('created_at', 'updated_at')}),
) )

View file

@ -0,0 +1,105 @@
# Generated by Django 5.2.8 on 2025-12-17 17:27
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('domains', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='domain',
options={'verbose_name': 'Domain', 'verbose_name_plural': 'Domains'},
),
migrations.CreateModel(
name='DomainMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='domains.domain')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domain_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Domain Membership',
'verbose_name_plural': 'Domain Memberships',
'unique_together': {('user', 'domain')},
},
),
migrations.AddField(
model_name='domain',
name='members',
field=models.ManyToManyField(related_name='domains', through='domains.DomainMembership', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='Organization',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('name', models.CharField(max_length=255, unique=True)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('description', models.TextField(blank=True, default='')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_organizations', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Organization',
'verbose_name_plural': 'Organizations',
},
),
migrations.CreateModel(
name='InviteToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('expires_at', models.DateTimeField()),
('used_at', models.DateTimeField(blank=True, null=True)),
('is_active', models.BooleanField(default=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_invites', to=settings.AUTH_USER_MODEL)),
('used_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='used_invites', to=settings.AUTH_USER_MODEL)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invite_tokens', to='domains.organization')),
],
options={
'verbose_name': 'Invite Token',
'verbose_name_plural': 'Invite Tokens',
},
),
migrations.AddField(
model_name='domain',
name='organization',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='domains.organization'),
),
migrations.CreateModel(
name='OrganizationMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('role', models.CharField(choices=[('employer', 'Employer'), ('employee', 'Employee')], default='employee', max_length=50)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='domains.organization')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Organization Membership',
'verbose_name_plural': 'Organization Memberships',
'unique_together': {('user', 'organization')},
},
),
migrations.AddField(
model_name='organization',
name='members',
field=models.ManyToManyField(related_name='organizations', through='domains.OrganizationMembership', to=settings.AUTH_USER_MODEL),
),
migrations.DeleteModel(
name='Organisation',
),
]

View file

@ -6,31 +6,108 @@ from django.db.models import (
UUIDField, UUIDField,
Model, Model,
TextField, TextField,
ManyToManyField,
DateTimeField,
BooleanField,
TextChoices,
) )
from django.utils.translation import gettext_lazy as _
from uuid import uuid4 from uuid import uuid4
from datetime import timedelta
from django.utils import timezone
from apps.users.models import TimeStampMixin, User from apps.users.models import TimeStampMixin, User
class Organization(TimeStampMixin, Model):
name = CharField(max_length=255, unique=True)
uuid = UUIDField(default=uuid4, editable=False, unique=True)
description = TextField(blank=True, default="")
owner = ForeignKey(User, on_delete=CASCADE, related_name="owned_organizations")
members = ManyToManyField(User, through="OrganizationMembership", related_name="organizations")
class Meta:
verbose_name = _("Organization")
verbose_name_plural = _("Organizations")
def __str__(self) -> str:
return self.name
class OrganizationMembership(TimeStampMixin, Model):
class Role(TextChoices):
EMPLOYER = "employer", _("Employer")
EMPLOYEE = "employee", _("Employee")
user = ForeignKey(User, on_delete=CASCADE, related_name="organization_memberships")
organization = ForeignKey(Organization, on_delete=CASCADE, related_name="memberships")
role = CharField(max_length=50, choices=Role.choices, default=Role.EMPLOYEE)
class Meta:
verbose_name = _("Organization Membership")
verbose_name_plural = _("Organization Memberships")
unique_together = [["user", "organization"]]
def __str__(self) -> str:
return f"{self.user.full_name} - {self.organization.name} ({self.role})"
class InviteToken(TimeStampMixin, Model):
token = UUIDField(default=uuid4, unique=True, editable=False)
organization = ForeignKey(Organization, on_delete=CASCADE, related_name="invite_tokens")
created_by = ForeignKey(User, on_delete=CASCADE, related_name="created_invites")
expires_at = DateTimeField()
used_by = ForeignKey(User, on_delete=CASCADE, null=True, blank=True, related_name="used_invites")
used_at = DateTimeField(null=True, blank=True)
is_active = BooleanField(default=True)
class Meta:
verbose_name = _("Invite Token")
verbose_name_plural = _("Invite Tokens")
def save(self, *args, **kwargs):
if not self.expires_at:
self.expires_at = timezone.now() + timedelta(days=7)
super().save(*args, **kwargs)
def is_valid(self):
return self.is_active and not self.used_by and timezone.now() < self.expires_at
def __str__(self) -> str:
return f"Invite for {self.organization.name} (expires {self.expires_at})"
class Domain(Model): class Domain(Model):
name = CharField(max_length = 255, unique = True) name = CharField(max_length=255, unique=True)
uuid = UUIDField(default = uuid4, editable = False, unique = True) uuid = UUIDField(default=uuid4, editable=False, unique=True)
description = TextField(blank = True, default = "") description = TextField(blank=True, default="")
organization = ForeignKey(Organization, on_delete=CASCADE, related_name="domains", null=True, blank=True)
members = ManyToManyField(User, through="DomainMembership", related_name="domains")
class Meta:
verbose_name = _("Domain")
verbose_name_plural = _("Domains")
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
class Organisation(TimeStampMixin, Model):
name = CharField(max_length = 255, unique = True) class DomainMembership(TimeStampMixin, Model):
uuid = UUIDField(default = uuid4, editable = False, unique = True)
managers = ForeignKey(User, on_delete = CASCADE, related_name = "managed_organisations") user = ForeignKey(User, on_delete=CASCADE, related_name="domain_memberships")
employees = ForeignKey(User, on_delete = CASCADE, related_name = "organisations") domain = ForeignKey(Domain, on_delete=CASCADE, related_name="memberships")
domains = ForeignKey(Domain, on_delete = CASCADE, related_name = "organisations")
class Meta:
verbose_name = _("Domain Membership")
verbose_name_plural = _("Domain Memberships")
unique_together = [["user", "domain"]]
def __str__(self) -> str: def __str__(self) -> str:
return self.name return f"{self.user.full_name} - {self.domain.name}"
class Dataset(TimeStampMixin, Model): class Dataset(TimeStampMixin, Model):

View file

@ -1,19 +1,79 @@
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from apps.domains.models import Domain, Organisation, Dataset from apps.domains.models import Domain, Organization, Dataset, OrganizationMembership, InviteToken, DomainMembership
from apps.users.serializers import UserSerializer
class OrganizationSerializer(serializers.ModelSerializer):
owner = UserSerializer(read_only=True)
member_count = serializers.SerializerMethodField()
domain_count = serializers.SerializerMethodField()
class Meta:
model = Organization
fields = ['id', 'uuid', 'name', 'description', 'owner', 'created_at', 'updated_at', 'member_count', 'domain_count']
read_only_fields = ['uuid', 'owner', 'created_at', 'updated_at']
def get_member_count(self, obj):
return obj.memberships.count()
def get_domain_count(self, obj):
return obj.domains.count()
class OrganizationMembershipSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
user_id = serializers.IntegerField(write_only=True, required=False)
class Meta:
model = OrganizationMembership
fields = ['id', 'user', 'user_id', 'organization', 'role', 'created_at']
read_only_fields = ['organization', 'created_at']
class InviteTokenSerializer(serializers.ModelSerializer):
created_by = UserSerializer(read_only=True)
used_by = UserSerializer(read_only=True)
invite_url = serializers.SerializerMethodField()
is_valid = serializers.SerializerMethodField()
class Meta:
model = InviteToken
fields = ['id', 'token', 'organization', 'created_by', 'expires_at', 'used_by', 'used_at', 'is_active', 'invite_url', 'is_valid', 'created_at']
read_only_fields = ['token', 'organization', 'created_by', 'used_by', 'used_at', 'created_at']
def get_invite_url(self, obj):
request = self.context.get('request')
if request:
return request.build_absolute_uri(f'/invite/{obj.token}')
return f'/invite/{obj.token}'
def get_is_valid(self, obj):
return obj.is_valid()
class DomainMembershipSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
domain_name = serializers.CharField(source='domain.name', read_only=True)
class Meta:
model = DomainMembership
fields = ['id', 'user', 'domain', 'domain_name', 'created_at']
read_only_fields = ['created_at']
class DomainSerializer(ModelSerializer): class DomainSerializer(ModelSerializer):
organization = OrganizationSerializer(read_only=True)
organization_id = serializers.IntegerField(write_only=True, required=False, allow_null=True)
member_count = serializers.SerializerMethodField()
class Meta: class Meta:
model = Domain model = Domain
fields = ['id', 'name', 'description', 'uuid'] fields = ['id', 'uuid', 'name', 'description', 'organization', 'organization_id', 'member_count']
read_only_fields = ['uuid']
def get_member_count(self, obj):
class OrganisationSerializer(ModelSerializer): return obj.memberships.count()
class Meta:
model = Organisation
fields = ['id', 'name', 'managers', 'employees', 'domains', 'uuid', 'created_at', 'updated_at']
class DatasetSerializer(ModelSerializer): class DatasetSerializer(ModelSerializer):

View file

@ -1,28 +1,246 @@
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticatedOrReadOnly from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from apps.domains.models import Domain, Organisation, Dataset from rest_framework.decorators import action
from apps.domains.serializers import DomainSerializer, OrganisationSerializer, DatasetSerializer from rest_framework.response import Response
from rest_framework import status
from django.shortcuts import get_object_or_404
from django.utils import timezone
from apps.domains.models import Domain, Organization, Dataset, OrganizationMembership, InviteToken, DomainMembership
from apps.domains.serializers import (
DomainSerializer,
OrganizationSerializer,
DatasetSerializer,
OrganizationMembershipSerializer,
InviteTokenSerializer,
DomainMembershipSerializer,
)
class OrganizationViewSet(ModelViewSet):
queryset = Organization.objects.all()
serializer_class = OrganizationSerializer
permission_classes = [IsAuthenticated]
lookup_field = 'uuid'
def get_queryset(self):
user = self.request.user
return Organization.objects.filter(memberships__user=user).distinct()
def perform_create(self, serializer):
org = serializer.save(owner=self.request.user)
OrganizationMembership.objects.create(
organization=org,
user=self.request.user,
role=OrganizationMembership.Role.EMPLOYER
)
def update(self, request, *args, **kwargs):
org = self.get_object()
membership = OrganizationMembership.objects.filter(
organization=org,
user=request.user,
role=OrganizationMembership.Role.EMPLOYER
).first()
if not membership:
return Response(
{"error": "Only employers can update organization details"},
status=status.HTTP_403_FORBIDDEN
)
return super().update(request, *args, **kwargs)
@action(detail=True, methods=['get'])
def members(self, request, uuid=None):
org = self.get_object()
memberships = org.memberships.all()
serializer = OrganizationMembershipSerializer(memberships, many=True)
return Response(serializer.data)
@action(detail=True, methods=['patch'], url_path='members/(?P<user_id>[^/.]+)')
def update_member(self, request, uuid=None, user_id=None):
org = self.get_object()
membership = OrganizationMembership.objects.filter(
organization=org,
user=request.user,
role=OrganizationMembership.Role.EMPLOYER
).first()
if not membership:
return Response(
{"error": "Only employers can update member roles"},
status=status.HTTP_403_FORBIDDEN
)
target_membership = get_object_or_404(OrganizationMembership, organization=org, user_id=user_id)
serializer = OrganizationMembershipSerializer(target_membership, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['delete'], url_path='members/(?P<user_id>[^/.]+)')
def remove_member(self, request, uuid=None, user_id=None):
org = self.get_object()
membership = OrganizationMembership.objects.filter(
organization=org,
user=request.user,
role=OrganizationMembership.Role.EMPLOYER
).first()
if not membership:
return Response(
{"error": "Only employers can remove members"},
status=status.HTTP_403_FORBIDDEN
)
target_membership = get_object_or_404(OrganizationMembership, organization=org, user_id=user_id)
if target_membership.user == org.owner:
return Response(
{"error": "Cannot remove the organization owner"},
status=status.HTTP_400_BAD_REQUEST
)
target_membership.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['get', 'post'])
def invites(self, request, uuid=None):
org = self.get_object()
if request.method == 'GET':
tokens = org.invite_tokens.filter(is_active=True, used_by__isnull=True)
serializer = InviteTokenSerializer(tokens, many=True, context={'request': request})
return Response(serializer.data)
elif request.method == 'POST':
membership = OrganizationMembership.objects.filter(
organization=org,
user=request.user,
role=OrganizationMembership.Role.EMPLOYER
).first()
if not membership:
return Response(
{"error": "Only employers can create invites"},
status=status.HTTP_403_FORBIDDEN
)
token = InviteToken.objects.create(
organization=org,
created_by=request.user
)
serializer = InviteTokenSerializer(token, context={'request': request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['delete'], url_path='invites/(?P<token>[^/.]+)')
def revoke_invite(self, request, uuid=None, token=None):
org = self.get_object()
membership = OrganizationMembership.objects.filter(
organization=org,
user=request.user,
role=OrganizationMembership.Role.EMPLOYER
).first()
if not membership:
return Response(
{"error": "Only employers can revoke invites"},
status=status.HTTP_403_FORBIDDEN
)
invite = get_object_or_404(InviteToken, organization=org, token=token)
invite.is_active = False
invite.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['get'])
def domains(self, request, uuid=None):
org = self.get_object()
domains = org.domains.all()
serializer = DomainSerializer(domains, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'], url_path='domains/(?P<domain_id>[^/.]+)/members')
def domain_members(self, request, uuid=None, domain_id=None):
org = self.get_object()
domain = get_object_or_404(Domain, organization=org, id=domain_id)
memberships = domain.memberships.all()
serializer = DomainMembershipSerializer(memberships, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='domains/(?P<domain_id>[^/.]+)/members')
def add_domain_member(self, request, uuid=None, domain_id=None):
org = self.get_object()
domain = get_object_or_404(Domain, organization=org, id=domain_id)
user_id = request.data.get('user_id')
org_membership = OrganizationMembership.objects.filter(
organization=org,
user_id=user_id
).first()
if not org_membership:
return Response(
{"error": "User must be a member of the organization first"},
status=status.HTTP_400_BAD_REQUEST
)
domain_membership, created = DomainMembership.objects.get_or_create(
domain=domain,
user_id=user_id
)
serializer = DomainMembershipSerializer(domain_membership)
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
class InviteViewSet(ModelViewSet):
queryset = InviteToken.objects.all()
serializer_class = InviteTokenSerializer
permission_classes = [IsAuthenticated]
lookup_field = 'token'
http_method_names = ['get', 'post']
def get_queryset(self):
return InviteToken.objects.filter(is_active=True, used_by__isnull=True)
@action(detail=True, methods=['post'])
def accept(self, request, token=None):
invite = self.get_object()
if not invite.is_valid():
return Response(
{"error": "This invite is no longer valid"},
status=status.HTTP_400_BAD_REQUEST
)
membership, created = OrganizationMembership.objects.get_or_create(
organization=invite.organization,
user=request.user,
defaults={'role': OrganizationMembership.Role.EMPLOYEE}
)
if created:
invite.used_by = request.user
invite.used_at = timezone.now()
invite.is_active = False
invite.save()
serializer = OrganizationSerializer(invite.organization)
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
class DomainViewSet(ModelViewSet): class DomainViewSet(ModelViewSet):
queryset = Domain.objects.all() queryset = Domain.objects.all()
serializer_class = DomainSerializer serializer_class = DomainSerializer
permission_classes = [IsAuthenticatedOrReadOnly] permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'uuid' lookup_field = 'uuid'
def get_queryset(self):
class OrganisationViewSet(ModelViewSet): user = self.request.user
if user.is_authenticated:
queryset = Organisation.objects.all() return Domain.objects.filter(
serializer_class = OrganisationSerializer organization__memberships__user=user
permission_classes = [IsAuthenticatedOrReadOnly] ).distinct()
lookup_field = 'uuid' return Domain.objects.none()
class DatasetViewSet(ModelViewSet): class DatasetViewSet(ModelViewSet):
queryset = Dataset.objects.all() queryset = Dataset.objects.all()
serializer_class = DatasetSerializer serializer_class = DatasetSerializer
permission_classes = [IsAuthenticatedOrReadOnly] permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'uuid' lookup_field = 'uuid'

View file

@ -1,15 +1,16 @@
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.domains.viewsets import DomainViewSet, OrganisationViewSet, DatasetViewSet from apps.domains.viewsets import DomainViewSet, OrganizationViewSet, DatasetViewSet, InviteViewSet
from apps.users.viewsets import UserViewSet from apps.users.viewsets import UserViewSet
from apps.agents.viewsets import AgentViewSet, AgentExecutionViewSet from apps.agents.viewsets import AgentViewSet, AgentExecutionViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r'domain', DomainViewSet, basename = 'domain') router.register(r'organization', OrganizationViewSet, basename='organization')
router.register(r'organisation', OrganisationViewSet, basename = 'organisation') router.register(r'invite', InviteViewSet, basename='invite')
router.register(r'dataset', DatasetViewSet, basename = 'dataset') router.register(r'domain', DomainViewSet, basename='domain')
router.register(r'user', UserViewSet, basename = 'user') router.register(r'dataset', DatasetViewSet, basename='dataset')
router.register(r'agent', AgentViewSet, basename = 'agent') router.register(r'user', UserViewSet, basename='user')
router.register(r'agent-execution', AgentExecutionViewSet, basename = 'agent-execution') router.register(r'agent', AgentViewSet, basename='agent')
router.register(r'agent-execution', AgentExecutionViewSet, basename='agent-execution')
urlpatterns = router.urls urlpatterns = router.urls

View file

@ -3,17 +3,18 @@ import { computed, onMounted } from 'vue';
import { Layout, Menu, Button, Space, Typography } from 'ant-design-vue'; import { Layout, Menu, Button, Space, Typography } from 'ant-design-vue';
import type { MenuProps } from 'ant-design-vue'; import type { MenuProps } from 'ant-design-vue';
import { import {
HomeOutlined, HomeOutlined,
InfoCircleOutlined, InfoCircleOutlined,
RocketOutlined, RocketOutlined,
ReadOutlined, ReadOutlined,
TeamOutlined, TeamOutlined,
RobotOutlined, RobotOutlined,
BulbOutlined, BulbOutlined,
AppstoreOutlined, AppstoreOutlined,
DashboardOutlined, DashboardOutlined,
LoginOutlined, LoginOutlined,
UserAddOutlined, UserAddOutlined,
BuildOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
@ -23,193 +24,206 @@ const route = useRoute();
const authStore = useAuthStore(); const authStore = useAuthStore();
const navItems = [ const navItems = [
{ {
key: '/', key: '/',
label: 'Home', label: 'Home',
icon: HomeOutlined, icon: HomeOutlined,
path: '/', path: '/',
}, },
{ {
key: '/about', key: '/about',
label: 'About', label: 'About',
icon: InfoCircleOutlined, icon: InfoCircleOutlined,
path: '/about', path: '/about',
}, },
{ {
key: '/onboarding', key: '/onboarding',
label: 'Onboarding', label: 'Onboarding',
icon: RocketOutlined, icon: RocketOutlined,
path: '/onboarding', path: '/onboarding',
}, },
{ {
key: '/training', key: '/training',
label: 'Training', label: 'Training',
icon: ReadOutlined, icon: ReadOutlined,
path: '/training', path: '/training',
}, },
{ {
key: '/roles', key: '/roles',
label: 'Roles', label: 'Roles',
icon: TeamOutlined, icon: TeamOutlined,
path: '/roles', path: '/roles',
roles: ['manager', 'admin'], roles: ['manager', 'admin'],
}, },
{ {
key: '/agents', key: '/agents',
label: 'Agents', label: 'Agents',
icon: RobotOutlined, icon: RobotOutlined,
path: '/agents', path: '/agents',
roles: ['manager', 'admin'], roles: ['manager', 'admin'],
}, },
{ {
key: '/assessments', key: '/assessments',
label: 'Assessments', label: 'Assessments',
icon: BulbOutlined, icon: BulbOutlined,
path: '/assessments', path: '/assessments',
}, },
{ {
key: '/resources', key: '/resources',
label: 'Resources', label: 'Resources',
icon: AppstoreOutlined, icon: AppstoreOutlined,
path: '/resources', path: '/resources',
}, },
{ {
key: '/progress', key: '/progress',
label: 'Progress', label: 'Progress',
icon: DashboardOutlined, icon: DashboardOutlined,
path: '/progress', path: '/progress',
}, },
{
key: '/organizations',
label: 'Organizations',
icon: BuildOutlined,
path: '/organizations',
},
]; ];
const visibleNavItems = computed(() => const visibleNavItems = computed(() =>
navItems.filter((item) => navItems.filter((item) =>
item.roles ? authStore.hasRole(item.roles) : true item.roles ? authStore.hasRole(item.roles) : true
) )
); );
const selectedKeys = computed(() => { const selectedKeys = computed(() => {
const match = visibleNavItems.value.find((item) => const match = visibleNavItems.value.find((item) => {
route.path.startsWith(item.key) if (item.key === '/') return route.path === '/';
); return route.path.startsWith(item.key);
return match ? [match.key] : []; });
return match ? [match.key] : [];
}); });
const onSelect: MenuProps['onSelect'] = ({ key }) => { const onSelect: MenuProps['onSelect'] = ({ key }) => {
const item = visibleNavItems.value.find((n) => n.key === key); const item = visibleNavItems.value.find((n) => n.key === key);
if (item) router.push(item.path); if (item) {
if (route.path !== item.path) {
router.push(item.path);
}
}
}; };
const handleLogout = async () => { const handleLogout = async () => {
await authStore.logout(); await authStore.logout();
router.push('/'); router.push('/');
}; };
onMounted(() => { onMounted(() => {
authStore.fetchSession(); authStore.fetchSession();
}); });
</script> </script>
<template> <template>
<Layout class="shell"> <Layout class="shell">
<Layout.Header class="shell-header"> <Layout.Header class="shell-header">
<div class="brand" @click="router.push('/')">Dynavera</div> <div class="brand" @click="route.path !== '/' && router.push('/')">
<Menu Dynavera
mode="horizontal" </div>
theme="dark" <Menu
:selectedKeys="selectedKeys" mode="horizontal"
class="shell-menu" theme="dark"
@select="onSelect" :selectedKeys="selectedKeys"
> class="shell-menu"
<Menu.Item v-for="item in visibleNavItems" :key="item.key"> @select="onSelect"
<Space size="small"> >
<component :is="item.icon" /> <Menu.Item v-for="item in visibleNavItems" :key="item.key">
<span>{{ item.label }}</span> <Space size="small">
</Space> <component :is="item.icon" />
</Menu.Item> <span>{{ item.label }}</span>
</Menu> </Space>
<Space> </Menu.Item>
<template v-if="authStore.isAuthenticated"> </Menu>
<Typography.Text class="user-chip" strong> <Space>
{{ authStore.displayName || 'Account' }} <template v-if="authStore.isAuthenticated">
</Typography.Text> <Typography.Text class="user-chip" strong>
<Button {{ authStore.displayName || 'Account' }}
ghost </Typography.Text>
:loading="authStore.loading" <Button
@click="handleLogout" ghost
> :loading="authStore.loading"
Logout @click="handleLogout"
</Button> >
</template> Logout
<template v-else> </Button>
<Button ghost @click="router.push('/login')"> </template>
<LoginOutlined /> Login <template v-else>
</Button> <Button ghost @click="router.push('/login')">
<Button type="primary" @click="router.push('/register')"> <LoginOutlined /> Login
<UserAddOutlined /> Register </Button>
</Button> <Button type="primary" @click="router.push('/register')">
</template> <UserAddOutlined /> Register
</Space> </Button>
</Layout.Header> </template>
</Space>
</Layout.Header>
<Layout class="shell-body"> <Layout class="shell-body">
<Layout.Content class="shell-content"> <Layout.Content class="shell-content">
<router-view /> <router-view />
</Layout.Content> </Layout.Content>
<Layout.Footer class="shell-footer"> <Layout.Footer class="shell-footer">
<Typography.Text type="secondary"> <Typography.Text type="secondary">
<strong>Project Disclaimer:</strong> This is a <strong>Project Disclaimer:</strong> This is a
proof-of-concept demo project for educational purposes. All proof-of-concept demo project for educational purposes. All
testimonials, statistics, and company names are fictional testimonials, statistics, and company names are fictional
placeholders. placeholders.
</Typography.Text> </Typography.Text>
</Layout.Footer> </Layout.Footer>
</Layout> </Layout>
</Layout> </Layout>
</template> </template>
<style scoped> <style scoped>
.shell { .shell {
min-height: 100vh; min-height: 100vh;
background: #0b1220; background: #0b1220;
} }
.shell-header { .shell-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0 1.25rem; padding: 0 1.25rem;
background: #0f172a; background: #0f172a;
} }
.brand { .brand {
color: #e5e7eb; color: #e5e7eb;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
font-size: 1.05rem; font-size: 1.05rem;
} }
.shell-menu { .shell-menu {
flex: 1; flex: 1;
background: transparent; background: transparent;
border-bottom: none; border-bottom: none;
} }
.shell-body { .shell-body {
background: #0b1220; background: #0b1220;
min-height: calc(100vh - 64px); min-height: calc(100vh - 64px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.shell-content { .shell-content {
padding: 24px; padding: 24px;
flex: 1; flex: 1;
min-height: calc(100vh - 64px - 64px); min-height: calc(100vh - 64px - 64px);
} }
.shell-footer { .shell-footer {
text-align: center; text-align: center;
background: #0f172a; background: #0f172a;
} }
:deep(.ant-menu-dark) { :deep(.ant-menu-dark) {
background: transparent; background: transparent;
} }
:deep(.ant-menu-dark .ant-menu-item-selected) { :deep(.ant-menu-dark .ant-menu-item-selected) {
background: transparent !important; background: transparent !important;
} }
:deep(.ant-typography), :deep(.ant-typography),
:deep(.ant-typography p), :deep(.ant-typography p),
@ -221,41 +235,41 @@ onMounted(() => {
:deep(.ant-statistic-content), :deep(.ant-statistic-content),
:deep(.ant-card-meta-title), :deep(.ant-card-meta-title),
:deep(.ant-card-meta-description) { :deep(.ant-card-meta-description) {
color: #e5e7eb; color: #e5e7eb;
} }
:deep(.ant-typography-secondary) { :deep(.ant-typography-secondary) {
color: #cbd5e1 !important; color: #cbd5e1 !important;
} }
:deep(.ant-form-item-label > label) { :deep(.ant-form-item-label > label) {
color: #e5e7eb; color: #e5e7eb;
} }
:deep(.ant-input), :deep(.ant-input),
:deep(.ant-select-selector), :deep(.ant-select-selector),
:deep(.ant-select-selection-item), :deep(.ant-select-selection-item),
:deep(.ant-picker-input input) { :deep(.ant-picker-input input) {
background: #111827; background: #111827;
color: #e5e7eb; color: #e5e7eb;
border-color: #334155; border-color: #334155;
} }
:deep(.ant-input::placeholder), :deep(.ant-input::placeholder),
:deep(.ant-select-selection-placeholder), :deep(.ant-select-selection-placeholder),
:deep(.ant-picker-input input::placeholder) { :deep(.ant-picker-input input::placeholder) {
color: #9ca3af; color: #9ca3af;
} }
:deep(.ant-card) { :deep(.ant-card) {
background: #0f172a; background: #0f172a;
border-color: #1f2937; border-color: #1f2937;
} }
:deep(.ant-btn:not(.ant-btn-primary)) { :deep(.ant-btn:not(.ant-btn-primary)) {
color: #e5e7eb; color: #e5e7eb;
border-color: #334155; border-color: #334155;
background: #111827; background: #111827;
} }
:deep(.ant-btn-primary) { :deep(.ant-btn-primary) {
background: linear-gradient(90deg, #6366f1, #8b5cf6); background: linear-gradient(90deg, #6366f1, #8b5cf6);
border: none; border: none;
} }
.user-chip { .user-chip {
color: #e5e7eb; color: #e5e7eb;
} }
</style> </style>

View file

@ -75,6 +75,24 @@ const router = createRouter({
component: () => import('../views/Resources.vue'), component: () => import('../views/Resources.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/organizations/:id',
name: 'organization-view',
component: () => import('../views/OrganizationView.vue'),
meta: { requiresAuth: true },
},
{
path: '/organizations/:id/manage',
name: 'organization-manage',
component: () => import('../views/OrganizationManage.vue'),
meta: { requiresAuth: true },
},
{
path: '/invite/:token',
name: 'invite-accept',
component: () => import('../views/InviteAccept.vue'),
meta: { requiresAuth: true },
},
], ],
}); });

View file

@ -2,15 +2,15 @@
import { ref, onMounted, onUnmounted, computed } from 'vue'; import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { import {
Card, Card,
Typography, Typography,
Button, Button,
List, List,
Space, Space,
Spin, Spin,
Input, Input,
message, message,
Tag, Tag,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { useAgentStore } from '../stores/agentStore'; import { useAgentStore } from '../stores/agentStore';
import { apiClient, isAxiosError } from '../lib/api'; import { apiClient, isAxiosError } from '../lib/api';
@ -25,387 +25,387 @@ const agentId = route.params.id as string;
console.log('Agent ID:', agentId); console.log('Agent ID:', agentId);
if (!agentId) { if (!agentId) {
console.error('ERROR: No agent ID in route params'); console.error('ERROR: No agent ID in route params');
} }
const agent = ref<Record<string, unknown>>({ const agent = ref<Record<string, unknown>>({
id: agentId, id: agentId,
name: 'Loading...', name: 'Loading...',
description: '', description: '',
status: 'idle', status: 'idle',
}); });
console.log('Initial agent state:', agent.value); console.log('Initial agent state:', agent.value);
const queryInput = ref(''); const queryInput = ref('');
const isRunning = computed(() => { const isRunning = computed(() => {
console.log( console.log(
'isRunning computed - executionStatus:', 'isRunning computed - executionStatus:',
agentStore.executionStatus agentStore.executionStatus
); );
return agentStore.executionStatus === 'running'; return agentStore.executionStatus === 'running';
}); });
const isConnected = computed(() => { const isConnected = computed(() => {
console.log('isConnected computed - isConnected:', agentStore.isConnected); console.log('isConnected computed - isConnected:', agentStore.isConnected);
return agentStore.isConnected ?? false; return agentStore.isConnected ?? false;
}); });
const agentResponse = computed(() => { const agentResponse = computed(() => {
const completedEvent = agentStore.eventLog?.find( const completedEvent = agentStore.eventLog?.find(
(event) => event.type === 'completed' (event) => event.type === 'completed'
); );
if (completedEvent?.content && typeof completedEvent.content === 'object') { if (completedEvent?.content && typeof completedEvent.content === 'object') {
const output = completedEvent.content as Record<string, unknown>; const output = completedEvent.content as Record<string, unknown>;
return (output.response as string) || null; return (output.response as string) || null;
} }
return null; return null;
}); });
const statusColor = (status: string) => { const statusColor = (status: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
idle: 'default', idle: 'default',
running: 'processing', running: 'processing',
completed: 'success', completed: 'success',
failed: 'error', failed: 'error',
stopped: 'warning', stopped: 'warning',
}; };
return colors[status] || 'default'; return colors[status] || 'default';
}; };
const fetchAgent = async () => { const fetchAgent = async () => {
console.log('Fetching agent details for ID:', agentId); console.log('Fetching agent details for ID:', agentId);
try { try {
const response = await apiClient.get<Record<string, unknown>>( const response = await apiClient.get<Record<string, unknown>>(
`/api/agent/${agentId}/` `/api/agent/${agentId}/`
); );
agent.value = response.data; agent.value = response.data;
console.log('Agent fetched successfully:', agent.value); console.log('Agent fetched successfully:', agent.value);
} catch (error) { } catch (error) {
console.error('ERROR - Failed to fetch agent:', error); console.error('ERROR - Failed to fetch agent:', error);
if (isAxiosError(error)) { if (isAxiosError(error)) {
console.error('Axios error details:', { console.error('Axios error details:', {
status: error.response?.status, status: error.response?.status,
data: error.response?.data, data: error.response?.data,
message: error.message, message: error.message,
}); });
} }
message.error('Failed to load agent details'); message.error('Failed to load agent details');
} }
}; };
const startAgent = () => { const startAgent = () => {
console.log('Starting agent execution'); console.log('Starting agent execution');
if (!agentStore.isConnected) { if (!agentStore.isConnected) {
console.warn('WARNING: WebSocket not connected'); console.warn('WARNING: WebSocket not connected');
console.log('Connection state:', { console.log('Connection state:', {
isConnected: agentStore.isConnected, isConnected: agentStore.isConnected,
}); });
message.error('WebSocket not connected'); message.error('WebSocket not connected');
return; return;
} }
if (!queryInput.value.trim()) { if (!queryInput.value.trim()) {
message.error('Please enter a query'); message.error('Please enter a query');
return; return;
} }
try { try {
const data = { const data = {
query: queryInput.value.trim(), query: queryInput.value.trim(),
}; };
console.log('Sending data:', data); console.log('Sending data:', data);
console.log('Calling startAgent on store'); console.log('Calling startAgent on store');
agentStore.startAgent(data); agentStore.startAgent(data);
console.log('Agent execution initiated'); console.log('Agent execution initiated');
message.success('Agent execution started'); message.success('Agent execution started');
} catch (error) { } catch (error) {
console.error('ERROR - Failed to start agent:', error); console.error('ERROR - Failed to start agent:', error);
message.error('Failed to start agent'); message.error('Failed to start agent');
} }
}; };
const stopAgent = () => { const stopAgent = () => {
console.log('Stopping agent execution'); console.log('Stopping agent execution');
try { try {
console.log('Calling stopAgent on store'); console.log('Calling stopAgent on store');
agentStore.stopAgent(); agentStore.stopAgent();
console.log('Agent stop signal sent'); console.log('Agent stop signal sent');
message.success('Agent stop requested'); message.success('Agent stop requested');
} catch (error) { } catch (error) {
console.error('ERROR - Failed to stop agent:', error); console.error('ERROR - Failed to stop agent:', error);
} }
}; };
onMounted(() => { onMounted(() => {
console.log('Component mounted'); console.log('Component mounted');
console.log('Lifecycle: onMounted - starting initialization'); console.log('Lifecycle: onMounted - starting initialization');
fetchAgent(); fetchAgent();
console.log('Attempting WebSocket connection for agent:', agentId); console.log('Attempting WebSocket connection for agent:', agentId);
try { try {
agentStore.connect(agentId); agentStore.connect(agentId);
console.log('WebSocket connection initiated'); console.log('WebSocket connection initiated');
} catch (error) { } catch (error) {
console.error('ERROR - Failed to connect WebSocket:', error); console.error('ERROR - Failed to connect WebSocket:', error);
} }
}); });
onUnmounted(() => { onUnmounted(() => {
console.log('Component unmounted'); console.log('Component unmounted');
console.log('Lifecycle: onUnmounted - cleaning up'); console.log('Lifecycle: onUnmounted - cleaning up');
try { try {
console.log('Disconnecting WebSocket'); console.log('Disconnecting WebSocket');
agentStore.disconnect(); agentStore.disconnect();
console.log('WebSocket disconnected successfully'); console.log('WebSocket disconnected successfully');
} catch (error) { } catch (error) {
console.error('ERROR - Failed to disconnect WebSocket:', error); console.error('ERROR - Failed to disconnect WebSocket:', error);
} }
}); });
</script> </script>
<template> <template>
<div class="page"> <div class="page">
<Card class="panel" :bordered="false"> <Card class="panel" :bordered="false">
<div class="header"> <div class="header">
<Typography.Title :level="2">{{ agent.name }}</Typography.Title> <Typography.Title :level="2">{{ agent.name }}</Typography.Title>
<Tag <Tag
:color=" :color="
statusColor( statusColor(
String(agentStore.executionStatus || 'idle') String(agentStore.executionStatus || 'idle')
) )
" "
> >
{{ {{
(agentStore.executionStatus || 'idle') (agentStore.executionStatus || 'idle')
.toString() .toString()
.toUpperCase() .toUpperCase()
}} }}
</Tag> </Tag>
</div> </div>
<Typography.Paragraph type="secondary">{{ <Typography.Paragraph type="secondary">{{
agent.description || 'No description available' agent.description || 'No description available'
}}</Typography.Paragraph> }}</Typography.Paragraph>
<div class="connection-status"> <div class="connection-status">
<span>WebSocket Status:</span> <span>WebSocket Status:</span>
<Tag :color="agentStore.isConnected ? 'green' : 'red'"> <Tag :color="agentStore.isConnected ? 'green' : 'red'">
{{ agentStore.isConnected ? 'CONNECTED' : 'DISCONNECTED' }} {{ agentStore.isConnected ? 'CONNECTED' : 'DISCONNECTED' }}
</Tag> </Tag>
</div> </div>
<Typography.Title :level="4" class="section-title" <Typography.Title :level="4" class="section-title"
>Execution</Typography.Title >Execution</Typography.Title
> >
<div class="execution-controls"> <div class="execution-controls">
<Space direction="vertical" style="width: 100%"> <Space direction="vertical" style="width: 100%">
<div> <div>
<Typography.Text>Query:</Typography.Text> <Typography.Text>Query:</Typography.Text>
<Input.TextArea <Input.TextArea
v-model:value="queryInput" v-model:value="queryInput"
:disabled="isRunning" :disabled="isRunning"
placeholder="Enter your query here..." placeholder="Enter your query here..."
:rows="4" :rows="4"
/> />
</div> </div>
<Space> <Space>
<Button <Button
type="primary" type="primary"
:disabled="isRunning || !isConnected" :disabled="isRunning || !isConnected"
@click="startAgent" @click="startAgent"
> >
Run Agent Run Agent
</Button> </Button>
<Button <Button
danger danger
:disabled="!isRunning" :disabled="!isRunning"
@click="stopAgent" @click="stopAgent"
> >
Stop Agent Stop Agent
</Button> </Button>
</Space> </Space>
</Space> </Space>
</div> </div>
<Typography.Title :level="4" class="section-title" <Typography.Title :level="4" class="section-title"
>Execution Log</Typography.Title >Execution Log</Typography.Title
> >
<Spin :spinning="isRunning" tip="Agent running..."> <Spin :spinning="isRunning" tip="Agent running...">
<div class="log-container"> <div class="log-container">
<List <List
v-if="(agentStore.eventLog?.length ?? 0) > 0" v-if="(agentStore.eventLog?.length ?? 0) > 0"
:data-source="agentStore.eventLog || []" :data-source="agentStore.eventLog || []"
:bordered="false" :bordered="false"
> >
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<List.Item class="log-item"> <List.Item class="log-item">
<div class="log-entry"> <div class="log-entry">
<Tag class="log-type">{{ item.type }}</Tag> <Tag class="log-type">{{ item.type }}</Tag>
<span class="log-time">{{ <span class="log-time">{{
item.timestamp.toLocaleTimeString() item.timestamp.toLocaleTimeString()
}}</span> }}</span>
<div <div
v-if="item.message" v-if="item.message"
class="log-message" class="log-message"
> >
{{ item.message }} {{ item.message }}
</div> </div>
<div <div
v-if=" v-if="
item.content && item.content &&
typeof item.content === 'object' typeof item.content === 'object'
" "
class="log-content" class="log-content"
> >
<pre>{{ <pre>{{
JSON.stringify( JSON.stringify(
item.content, item.content,
null, null,
2 2
) )
}}</pre> }}</pre>
</div> </div>
<div <div
v-else-if="item.content" v-else-if="item.content"
class="log-content" class="log-content"
> >
{{ item.content }} {{ item.content }}
</div> </div>
</div> </div>
</List.Item> </List.Item>
</template> </template>
</List> </List>
<Typography.Paragraph v-else type="secondary"> <Typography.Paragraph v-else type="secondary">
No events yet. Start the agent to see execution logs. No events yet. Start the agent to see execution logs.
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
</Spin> </Spin>
<div v-if="agentResponse" class="response-section"> <div v-if="agentResponse" class="response-section">
<Typography.Title :level="4" class="section-title" <Typography.Title :level="4" class="section-title"
>Response</Typography.Title >Response</Typography.Title
> >
<Card class="response-card" :bordered="false"> <Card class="response-card" :bordered="false">
<div class="response-content"> <div class="response-content">
{{ agentResponse }} {{ agentResponse }}
</div> </div>
</Card> </Card>
</div> </div>
</Card> </Card>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page { .page {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
} }
.panel { .panel {
background: #0f172a; background: #0f172a;
border: 1px solid #1f2937; border: 1px solid #1f2937;
color: #e5e7eb; color: #e5e7eb;
} }
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.section-title { .section-title {
margin-top: 2rem !important; margin-top: 2rem !important;
margin-bottom: 1rem !important; margin-bottom: 1rem !important;
} }
.connection-status { .connection-status {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin: 1rem 0; margin: 1rem 0;
padding: 0.5rem; padding: 0.5rem;
background: #1f2937; background: #1f2937;
border-radius: 4px; border-radius: 4px;
} }
.execution-controls { .execution-controls {
background: #1f2937; background: #1f2937;
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: 4px;
margin: 1rem 0; margin: 1rem 0;
} }
.log-container { .log-container {
background: #1f2937; background: #1f2937;
border-radius: 4px; border-radius: 4px;
max-height: 500px; max-height: 500px;
overflow-y: auto; overflow-y: auto;
} }
.log-item { .log-item {
border-bottom: 1px solid #374151 !important; border-bottom: 1px solid #374151 !important;
padding: 0.75rem !important; padding: 0.75rem !important;
} }
.log-entry { .log-entry {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
width: 100%; width: 100%;
} }
.log-type { .log-type {
width: fit-content; width: fit-content;
} }
.log-time { .log-time {
font-size: 0.75rem; font-size: 0.75rem;
color: #9ca3af; color: #9ca3af;
} }
.log-message { .log-message {
color: #e5e7eb; color: #e5e7eb;
font-size: 0.9rem; font-size: 0.9rem;
} }
.log-content { .log-content {
background: #111827; background: #111827;
padding: 0.5rem; padding: 0.5rem;
border-radius: 3px; border-radius: 3px;
overflow-x: auto; overflow-x: auto;
} }
.log-content pre { .log-content pre {
margin: 0; margin: 0;
font-size: 0.8rem; font-size: 0.8rem;
color: #d1d5db; color: #d1d5db;
} }
.response-section { .response-section {
margin-top: 2rem; margin-top: 2rem;
} }
.response-card { .response-card {
background: #1f2937; background: #1f2937;
border: 1px solid #374151; border: 1px solid #374151;
} }
.response-content { .response-content {
color: #e5e7eb; color: #e5e7eb;
font-size: 1rem; font-size: 1rem;
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
padding: 0.5rem; padding: 0.5rem;
} }
</style> </style>

View file

@ -4,11 +4,11 @@ import { List, Typography, Button, Card, Spin, message } from 'ant-design-vue';
import { apiClient } from '../lib/api'; import { apiClient } from '../lib/api';
interface Agent { interface Agent {
uuid: string; uuid: string;
id: string; id: string;
name: string; name: string;
description: string; description: string;
status: string; status: string;
} }
const agents = ref<Agent[]>([]); const agents = ref<Agent[]>([]);
@ -16,83 +16,83 @@ const loading = ref(false);
const loadError = ref(false); const loadError = ref(false);
const fetchAgents = async () => { const fetchAgents = async () => {
loading.value = true; loading.value = true;
loadError.value = false; loadError.value = false;
try { try {
const response = await apiClient.get<Agent[]>('/api/agent/'); const response = await apiClient.get<Agent[]>('/api/agent/');
agents.value = Array.isArray(response.data) ? response.data : []; agents.value = Array.isArray(response.data) ? response.data : [];
} catch (error) { } catch (error) {
console.error('Failed to fetch agents:', error); console.error('Failed to fetch agents:', error);
message.error('Failed to load agents'); message.error('Failed to load agents');
agents.value = []; agents.value = [];
loadError.value = true; loadError.value = true;
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
onMounted(() => { onMounted(() => {
fetchAgents(); fetchAgents();
}); });
</script> </script>
<template> <template>
<div class="page"> <div class="page">
<Typography.Title :level="2">Agents</Typography.Title> <Typography.Title :level="2">Agents</Typography.Title>
<Typography.Paragraph type="secondary" <Typography.Paragraph type="secondary"
>Manage and inspect the available AI agents.</Typography.Paragraph >Manage and inspect the available AI agents.</Typography.Paragraph
> >
<Card class="panel" :bordered="false"> <Card class="panel" :bordered="false">
<Spin :spinning="loading" tip="Loading agents..."> <Spin :spinning="loading" tip="Loading agents...">
<div v-if="loadError" class="empty"> <div v-if="loadError" class="empty">
<Typography.Paragraph type="danger"> <Typography.Paragraph type="danger">
Failed to load agents. Failed to load agents.
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<div v-else-if="!loading && agents.length === 0" class="empty"> <div v-else-if="!loading && agents.length === 0" class="empty">
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
No agents found. No agents found.
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<List <List
v-else v-else
:data-source="agents" :data-source="agents"
item-layout="horizontal" item-layout="horizontal"
:bordered="false" :bordered="false"
> >
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<List.Item class="item"> <List.Item class="item">
<List.Item.Meta <List.Item.Meta
:title="item.name" :title="item.name"
:description="`${item.description} • Status: ${item.status}`" :description="`${item.description} • Status: ${item.status}`"
/> />
<RouterLink :to="`/agents/${item.uuid || item.id}`"> <RouterLink :to="`/agents/${item.uuid || item.id}`">
<Button type="primary" size="small" <Button type="primary" size="small"
>Open</Button >Open</Button
> >
</RouterLink> </RouterLink>
</List.Item> </List.Item>
</template> </template>
</List> </List>
</Spin> </Spin>
</Card> </Card>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page { .page {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
} }
.panel { .panel {
background: #0f172a; background: #0f172a;
border: 1px solid #1f2937; border: 1px solid #1f2937;
color: #e5e7eb; color: #e5e7eb;
} }
.item :deep(.ant-list-item-meta-title), .item :deep(.ant-list-item-meta-title),
.item :deep(.ant-list-item-meta-description) { .item :deep(.ant-list-item-meta-description) {
color: #e5e7eb; color: #e5e7eb;
} }
</style> </style>

179
src/views/InviteAccept.vue Normal file
View file

@ -0,0 +1,179 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
Card,
Typography,
Button,
Spin,
message,
Result,
} from 'ant-design-vue';
import { apiClient, isAxiosError } from '../lib/api';
const route = useRoute();
const router = useRouter();
interface InviteToken {
id: number;
token: string;
organization: {
id: number;
uuid: string;
name: string;
description: string;
};
expires_at: string;
is_valid: boolean;
}
const token = route.params.token as string;
const invite = ref<InviteToken | null>(null);
const loading = ref(false);
const accepting = ref(false);
const accepted = ref(false);
const error = ref<string | null>(null);
const fetchInvite = async () => {
loading.value = true;
error.value = null;
try {
const response = await apiClient.get<InviteToken>(
`/api/invite/${token}/`
);
invite.value = response.data;
if (!response.data.is_valid) {
error.value = 'This invite is no longer valid or has expired.';
}
} catch (err) {
console.error('Failed to fetch invite:', err);
if (isAxiosError(err) && err.response?.status === 404) {
error.value = 'Invalid invite link.';
} else {
error.value = 'Failed to load invite details.';
}
} finally {
loading.value = false;
}
};
const acceptInvite = async () => {
accepting.value = true;
try {
await apiClient.post(`/api/invite/${token}/accept/`);
message.success('Successfully joined organization');
accepted.value = true;
setTimeout(() => {
router.push(`/organizations/${invite.value?.organization.uuid}`);
}, 2000);
} catch (err) {
console.error('Failed to accept invite:', err);
if (isAxiosError(err)) {
message.error(
err.response?.data?.error || 'Failed to accept invite'
);
}
} finally {
accepting.value = false;
}
};
onMounted(() => {
fetchInvite();
});
</script>
<template>
<div class="page">
<Spin :spinning="loading" tip="Loading invite...">
<Card class="panel" :bordered="false">
<div v-if="error">
<Result status="error" :title="error">
<template #extra>
<Button type="primary" @click="router.push('/')">
Go Home
</Button>
</template>
</Result>
</div>
<div v-else-if="accepted">
<Result
status="success"
title="Successfully Joined Organization"
sub-title="Redirecting to organization page..."
/>
</div>
<div v-else-if="invite" class="invite-content">
<Typography.Title :level="2">
Organization Invite
</Typography.Title>
<div class="org-info">
<Typography.Title :level="4">
{{ invite.organization.name }}
</Typography.Title>
<Typography.Paragraph>
{{
invite.organization.description ||
'No description provided'
}}
</Typography.Paragraph>
<Typography.Paragraph type="secondary">
Expires:
{{ new Date(invite.expires_at).toLocaleString() }}
</Typography.Paragraph>
</div>
<div class="actions">
<Typography.Paragraph>
You've been invited to join this organization. Click
accept to become a member.
</Typography.Paragraph>
<Button
type="primary"
size="large"
:loading="accepting"
@click="acceptInvite"
>
Accept Invite
</Button>
</div>
</div>
</Card>
</Spin>
</div>
</template>
<style scoped>
.page {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.panel {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.invite-content {
text-align: center;
padding: 2rem;
}
.org-info {
background: #1f2937;
border-radius: 8px;
padding: 1.5rem;
margin: 2rem 0;
}
.actions {
margin-top: 2rem;
}
</style>

View file

@ -3,85 +3,85 @@ import { ref } from 'vue';
import { Card, Typography, Timeline, Button, Space } from 'ant-design-vue'; import { Card, Typography, Timeline, Button, Space } from 'ant-design-vue';
const steps = ref([ const steps = ref([
{ {
id: 1, id: 1,
title: 'Welcome & Orientation', title: 'Welcome & Orientation',
description: 'Intro to company, mission and tools.', description: 'Intro to company, mission and tools.',
eta: 15, eta: 15,
}, },
{ {
id: 2, id: 2,
title: 'Account Setup', title: 'Account Setup',
description: 'Set up accounts, access, and credentials.', description: 'Set up accounts, access, and credentials.',
eta: 20, eta: 20,
}, },
{ {
id: 3, id: 3,
title: 'Team Introductions', title: 'Team Introductions',
description: 'Meet key stakeholders and team rituals.', description: 'Meet key stakeholders and team rituals.',
eta: 30, eta: 30,
}, },
]); ]);
</script> </script>
<template> <template>
<div class="page"> <div class="page">
<Card class="panel" :bordered="false"> <Card class="panel" :bordered="false">
<Typography.Title :level="2">Onboarding Flow</Typography.Title> <Typography.Title :level="2">Onboarding Flow</Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
Step-by-step AI-guided onboarding for new team members. Step-by-step AI-guided onboarding for new team members.
</Typography.Paragraph> </Typography.Paragraph>
<Timeline mode="left" class="timeline"> <Timeline mode="left" class="timeline">
<Timeline.Item <Timeline.Item
v-for="step in steps" v-for="step in steps"
:key="step.id" :key="step.id"
color="purple" color="purple"
> >
<Space direction="vertical" size="small"> <Space direction="vertical" size="small">
<Typography.Title :level="4">{{ <Typography.Title :level="4">{{
step.title step.title
}}</Typography.Title> }}</Typography.Title>
<Typography.Text>{{ <Typography.Text>{{
step.description step.description
}}</Typography.Text> }}</Typography.Text>
<Typography.Text type="secondary" <Typography.Text type="secondary"
>Est. time: {{ step.eta }} mins</Typography.Text >Est. time: {{ step.eta }} mins</Typography.Text
> >
</Space> </Space>
</Timeline.Item> </Timeline.Item>
</Timeline> </Timeline>
<Space class="actions" wrap> <Space class="actions" wrap>
<RouterLink to="/training" <RouterLink to="/training"
><Button type="primary">Start Training</Button></RouterLink ><Button type="primary">Start Training</Button></RouterLink
> >
<RouterLink to="/agents" <RouterLink to="/agents"
><Button ghost>View Agents</Button></RouterLink ><Button ghost>View Agents</Button></RouterLink
> >
</Space> </Space>
</Card> </Card>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page { .page {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
} }
.panel { .panel {
background: #0f172a; background: #0f172a;
border: 1px solid #1f2937; border: 1px solid #1f2937;
color: #e5e7eb; color: #e5e7eb;
} }
.timeline :deep(.ant-timeline-item-head) { .timeline :deep(.ant-timeline-item-head) {
background: #8b5cf6; background: #8b5cf6;
} }
.timeline :deep(.ant-typography) { .timeline :deep(.ant-typography) {
color: #e5e7eb; color: #e5e7eb;
} }
.actions { .actions {
margin-top: 1rem; margin-top: 1rem;
} }
</style> </style>

View file

@ -0,0 +1,485 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import {
Card,
Typography,
Button,
List,
Space,
Spin,
Input,
message,
Tag,
Divider,
Modal,
Select,
Tabs,
} from 'ant-design-vue';
import { apiClient, isAxiosError } from '../lib/api';
const route = useRoute();
interface Organization {
id: number;
uuid: string;
name: string;
description: string;
owner: {
id: number;
full_name: string;
};
member_count: number;
domain_count: number;
}
interface Member {
id: number;
user: {
id: number;
full_name: string;
email_address: string;
};
role: string;
created_at: string;
}
interface InviteToken {
id: number;
token: string;
invite_url: string;
created_by: {
full_name: string;
};
expires_at: string;
is_valid: boolean;
}
interface Domain {
id: number;
uuid: string;
name: string;
description: string;
member_count: number;
}
const orgId = route.params.id as string;
const organization = ref<Organization | null>(null);
const members = ref<Member[]>([]);
const invites = ref<InviteToken[]>([]);
const domains = ref<Domain[]>([]);
const loading = ref(false);
const inviteModalVisible = ref(false);
const newInviteUrl = ref('');
const editingDescription = ref(false);
const newDescription = ref('');
const fetchOrganization = async () => {
loading.value = true;
try {
const response = await apiClient.get<Organization>(
`/api/organization/${orgId}/`
);
organization.value = response.data;
newDescription.value = response.data.description;
} catch (error) {
console.error('Failed to fetch organization:', error);
message.error('Failed to load organization details');
} finally {
loading.value = false;
}
};
const fetchMembers = async () => {
try {
const response = await apiClient.get<Member[]>(
`/api/organization/${orgId}/members/`
);
members.value = response.data;
} catch (error) {
console.error('Failed to fetch members:', error);
}
};
const fetchInvites = async () => {
try {
const response = await apiClient.get<InviteToken[]>(
`/api/organization/${orgId}/invites/`
);
invites.value = response.data;
} catch (error) {
console.error('Failed to fetch invites:', error);
}
};
const fetchDomains = async () => {
try {
const response = await apiClient.get<Domain[]>(
`/api/organization/${orgId}/domains/`
);
domains.value = response.data;
} catch (error) {
console.error('Failed to fetch domains:', error);
}
};
const createInvite = async () => {
try {
const response = await apiClient.post<InviteToken>(
`/api/organization/${orgId}/invites/`
);
newInviteUrl.value = response.data.invite_url;
inviteModalVisible.value = true;
fetchInvites();
} catch (error) {
console.error('Failed to create invite:', error);
message.error('Failed to create invite');
}
};
const copyInviteUrl = () => {
window.navigator.clipboard.writeText(newInviteUrl.value);
message.success('Invite URL copied to clipboard');
};
const copyUrl = (url: string) => {
window.navigator.clipboard.writeText(url);
message.success('Copied to clipboard');
};
const revokeInvite = async (token: string) => {
try {
await apiClient.delete(`/api/organization/${orgId}/invites/${token}/`);
message.success('Invite revoked');
fetchInvites();
} catch (error) {
console.error('Failed to revoke invite:', error);
message.error('Failed to revoke invite');
}
};
const updateMemberRole = async (userId: number, newRole: string) => {
try {
await apiClient.patch(`/api/organization/${orgId}/members/${userId}/`, {
role: newRole,
});
message.success('Member role updated');
fetchMembers();
} catch (error) {
console.error('Failed to update member role:', error);
if (isAxiosError(error)) {
message.error(
error.response?.data?.error || 'Failed to update member role'
);
}
}
};
const removeMember = async (userId: number) => {
try {
await apiClient.delete(`/api/organization/${orgId}/members/${userId}/`);
message.success('Member removed');
fetchMembers();
} catch (error) {
console.error('Failed to remove member:', error);
if (isAxiosError(error)) {
message.error(
error.response?.data?.error || 'Failed to remove member'
);
}
}
};
const saveDescription = async () => {
try {
await apiClient.patch(`/api/organization/${orgId}/`, {
description: newDescription.value,
});
message.success('Description updated');
editingDescription.value = false;
fetchOrganization();
} catch (error) {
console.error('Failed to update description:', error);
message.error('Failed to update description');
}
};
onMounted(() => {
fetchOrganization();
fetchMembers();
fetchInvites();
fetchDomains();
});
</script>
<template>
<div class="page">
<Spin :spinning="loading" tip="Loading organization...">
<Card v-if="organization" class="panel" :bordered="false">
<Typography.Title :level="2">
Manage {{ organization.name }}
</Typography.Title>
<Tabs>
<Tabs.TabPane key="details" tab="Details">
<div class="section">
<Typography.Title :level="4">
Description
</Typography.Title>
<div v-if="!editingDescription">
<Typography.Paragraph>
{{
organization.description ||
'No description provided'
}}
</Typography.Paragraph>
<Button
@click="editingDescription = true"
size="small"
>
Edit Description
</Button>
</div>
<div v-else>
<Input.TextArea
v-model:value="newDescription"
:rows="4"
placeholder="Enter organization description"
/>
<Space style="margin-top: 0.5rem">
<Button
type="primary"
@click="saveDescription"
>
Save
</Button>
<Button @click="editingDescription = false">
Cancel
</Button>
</Space>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="members" tab="Members">
<div class="section">
<div class="section-header">
<Typography.Title :level="4">
Members ({{ members.length }})
</Typography.Title>
</div>
<List :data-source="members" :bordered="false">
<template #renderItem="{ item }">
<List.Item class="member-item">
<List.Item.Meta
:title="item.user.full_name"
:description="
item.user.email_address
"
/>
<Space>
<Select
:value="item.role"
style="width: 120px"
@change="
(value) =>
updateMemberRole(
item.user.id,
value
)
"
>
<Select.Option value="employee">
Employee
</Select.Option>
<Select.Option value="employer">
Employer
</Select.Option>
</Select>
<Button
v-if="
item.user.id !==
organization.owner.id
"
danger
size="small"
@click="
removeMember(item.user.id)
"
>
Remove
</Button>
<Tag v-else color="blue">Owner</Tag>
</Space>
</List.Item>
</template>
</List>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="invites" tab="Invites">
<div class="section">
<div class="section-header">
<Typography.Title :level="4">
Invite Tokens
</Typography.Title>
<Button type="primary" @click="createInvite">
Create Invite
</Button>
</div>
<List
v-if="invites.length > 0"
:data-source="invites"
:bordered="false"
>
<template #renderItem="{ item }">
<List.Item class="invite-item">
<List.Item.Meta
:title="`Created by ${item.created_by.full_name}`"
:description="`Expires: ${new Date(
item.expires_at
).toLocaleDateString()}`"
/>
<Space>
<Tag
:color="
item.is_valid
? 'green'
: 'red'
"
>
{{
item.is_valid
? 'Valid'
: 'Expired'
}}
</Tag>
<Button
size="small"
@click="
copyUrl(item.invite_url)
"
>
Copy URL
</Button>
<Button
danger
size="small"
@click="
revokeInvite(item.token)
"
>
Revoke
</Button>
</Space>
</List.Item>
</template>
</List>
<Typography.Paragraph v-else type="secondary">
No active invites. Create one to invite new
members.
</Typography.Paragraph>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="domains" tab="Domains">
<div class="section">
<Typography.Title :level="4">
Domains ({{ domains.length }})
</Typography.Title>
<List
v-if="domains.length > 0"
:data-source="domains"
:bordered="false"
>
<template #renderItem="{ item }">
<List.Item class="domain-item">
<List.Item.Meta
:title="item.name"
:description="
item.description ||
'No description'
"
/>
<Tag
>{{
item.member_count
}}
members</Tag
>
</List.Item>
</template>
</List>
<Typography.Paragraph v-else type="secondary">
No domains in this organization yet.
</Typography.Paragraph>
</div>
</Tabs.TabPane>
</Tabs>
</Card>
</Spin>
<Modal
v-model:open="inviteModalVisible"
title="Invite Created"
@ok="inviteModalVisible = false"
>
<div>
<Typography.Paragraph>
Share this URL with people you want to invite:
</Typography.Paragraph>
<Input
:value="newInviteUrl"
readonly
@click="copyInviteUrl"
style="cursor: pointer"
/>
<Button
type="primary"
block
style="margin-top: 1rem"
@click="copyInviteUrl"
>
Copy to Clipboard
</Button>
</div>
</Modal>
</div>
</template>
<style scoped>
.page {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.panel {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.section {
margin: 2rem 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.member-item :deep(.ant-list-item-meta-title),
.member-item :deep(.ant-list-item-meta-description),
.invite-item :deep(.ant-list-item-meta-title),
.invite-item :deep(.ant-list-item-meta-description),
.domain-item :deep(.ant-list-item-meta-title),
.domain-item :deep(.ant-list-item-meta-description) {
color: #e5e7eb;
}
</style>

View file

@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
Card,
Typography,
Button,
List,
Space,
Spin,
message,
Tag,
Divider,
} from 'ant-design-vue';
import { apiClient, isAxiosError } from '../lib/api';
const route = useRoute();
const router = useRouter();
interface Organization {
id: number;
uuid: string;
name: string;
description: string;
owner: {
id: number;
full_name: string;
email_address: string;
};
member_count: number;
domain_count: number;
created_at: string;
}
interface Domain {
id: number;
uuid: string;
name: string;
description: string;
member_count: number;
}
interface DomainMembership {
id: number;
domain: {
id: number;
name: string;
};
}
const orgId = route.params.id as string;
const organization = ref<Organization | null>(null);
const domains = ref<Domain[]>([]);
const userDomains = ref<number[]>([]);
const loading = ref(false);
const isOwner = computed(() => {
return false;
});
const fetchOrganization = async () => {
loading.value = true;
try {
const response = await apiClient.get<Organization>(
`/api/organization/${orgId}/`
);
organization.value = response.data;
} catch (error) {
console.error('Failed to fetch organization:', error);
message.error('Failed to load organization details');
} finally {
loading.value = false;
}
};
const fetchDomains = async () => {
try {
const response = await apiClient.get<Domain[]>(
`/api/organization/${orgId}/domains/`
);
domains.value = response.data;
} catch (error) {
console.error('Failed to fetch domains:', error);
}
};
const selectDomain = async (domainId: number) => {
try {
await apiClient.post(
`/api/organization/${orgId}/domains/${domainId}/members/`,
{ user_id: 'current' }
);
message.success('Successfully joined domain');
userDomains.value.push(domainId);
} catch (error) {
console.error('Failed to join domain:', error);
if (isAxiosError(error)) {
message.error(
error.response?.data?.error || 'Failed to join domain'
);
}
}
};
onMounted(() => {
fetchOrganization();
fetchDomains();
});
</script>
<template>
<div class="page">
<Spin :spinning="loading" tip="Loading organization...">
<Card v-if="organization" class="panel" :bordered="false">
<div class="header">
<Typography.Title :level="2">{{
organization.name
}}</Typography.Title>
<Button
v-if="isOwner"
type="primary"
@click="
router.push(
`/organizations/${organization.uuid}/manage`
)
"
>
Manage Organization
</Button>
</div>
<Typography.Paragraph v-if="organization.description">
{{ organization.description }}
</Typography.Paragraph>
<Typography.Paragraph v-else type="secondary">
No description provided
</Typography.Paragraph>
<Space direction="vertical" :size="4" style="margin: 1rem 0">
<div>
<Typography.Text strong>Owner:</Typography.Text>
{{ organization.owner.full_name }} ({{
organization.owner.email_address
}})
</div>
<div>
<Typography.Text strong>Members:</Typography.Text>
{{ organization.member_count }}
</div>
<div>
<Typography.Text strong>Domains:</Typography.Text>
{{ organization.domain_count }}
</div>
</Space>
<Divider />
<Typography.Title :level="4" class="section-title">
Available Domains
</Typography.Title>
<div v-if="domains.length > 0">
<List :data-source="domains" :bordered="false">
<template #renderItem="{ item }">
<List.Item class="domain-item">
<List.Item.Meta
:title="item.name"
:description="
item.description ||
'No description available'
"
/>
<Space>
<Tag>{{ item.member_count }} members</Tag>
<Button
v-if="!userDomains.includes(item.id)"
type="primary"
size="small"
@click="selectDomain(item.id)"
>
Join Domain
</Button>
<Tag v-else color="success">Joined</Tag>
</Space>
</List.Item>
</template>
</List>
</div>
<Typography.Paragraph v-else type="secondary">
No domains available in this organization.
</Typography.Paragraph>
</Card>
</Spin>
</div>
</template>
<style scoped>
.page {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.panel {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-title {
margin-top: 1.5rem !important;
margin-bottom: 1rem !important;
}
.domain-item :deep(.ant-list-item-meta-title),
.domain-item :deep(.ant-list-item-meta-description) {
color: #e5e7eb;
}
</style>