Tentative changes
This commit is contained in:
parent
b9252068c4
commit
efc794381f
17 changed files with 2083 additions and 652 deletions
|
|
@ -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'},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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')}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||||
|
|
|
||||||
360
src/app/App.vue
360
src/app/App.vue
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
179
src/views/InviteAccept.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
485
src/views/OrganizationManage.vue
Normal file
485
src/views/OrganizationManage.vue
Normal 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>
|
||||||
226
src/views/OrganizationView.vue
Normal file
226
src/views/OrganizationView.vue
Normal 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>
|
||||||
Loading…
Reference in a new issue