Tentative changes

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

View file

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

View file

@ -1,28 +1,54 @@
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)
class DomainAdmin(admin.ModelAdmin):
list_display = ('name', 'uuid')
search_fields = ('name',)
list_display = ('name', 'organization', 'uuid')
list_filter = ('organization',)
search_fields = ('name', 'organization__name')
readonly_fields = ('uuid',)
fieldsets = (
(None, {'fields': ('name', 'uuid')}),
('Description', {'fields': ('description',)}),
('Organization', {'fields': ('organization',)}),
)
@admin.register(Organisation)
class OrganisationAdmin(admin.ModelAdmin):
list_display = ('name', 'uuid', 'created_at', 'updated_at')
search_fields = ('name',)
readonly_fields = ('uuid', 'created_at', 'updated_at')
fieldsets = (
(None, {'fields': ('name', 'uuid')}),
('Relations', {'fields': ('managers', 'employees', 'domains')}),
('Dates', {'fields': ('created_at', 'updated_at')}),
)
@admin.register(DomainMembership)
class DomainMembershipAdmin(admin.ModelAdmin):
list_display = ('user', 'domain', 'created_at')
list_filter = ('created_at',)
search_fields = ('user__email_address', 'domain__name')
readonly_fields = ('created_at', 'updated_at')
@admin.register(Dataset)
@ -36,3 +62,4 @@ class DatasetAdmin(admin.ModelAdmin):
('File', {'fields': ('datafile',)}),
('Dates', {'fields': ('created_at', 'updated_at')}),
)

View file

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

View file

@ -6,31 +6,108 @@ from django.db.models import (
UUIDField,
Model,
TextField,
ManyToManyField,
DateTimeField,
BooleanField,
TextChoices,
)
from django.utils.translation import gettext_lazy as _
from uuid import uuid4
from datetime import timedelta
from django.utils import timezone
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):
name = CharField(max_length=255, unique=True)
uuid = UUIDField(default=uuid4, editable=False, unique=True)
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:
return self.name
class Organisation(TimeStampMixin, Model):
name = CharField(max_length = 255, unique = True)
uuid = UUIDField(default = uuid4, editable = False, unique = True)
managers = ForeignKey(User, on_delete = CASCADE, related_name = "managed_organisations")
employees = ForeignKey(User, on_delete = CASCADE, related_name = "organisations")
domains = ForeignKey(Domain, on_delete = CASCADE, related_name = "organisations")
class DomainMembership(TimeStampMixin, Model):
user = ForeignKey(User, on_delete=CASCADE, related_name="domain_memberships")
domain = ForeignKey(Domain, on_delete=CASCADE, related_name="memberships")
class Meta:
verbose_name = _("Domain Membership")
verbose_name_plural = _("Domain Memberships")
unique_together = [["user", "domain"]]
def __str__(self) -> str:
return self.name
return f"{self.user.full_name} - {self.domain.name}"
class Dataset(TimeStampMixin, Model):

View file

@ -1,19 +1,79 @@
from rest_framework import serializers
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):
organization = OrganizationSerializer(read_only=True)
organization_id = serializers.IntegerField(write_only=True, required=False, allow_null=True)
member_count = serializers.SerializerMethodField()
class Meta:
model = Domain
fields = ['id', 'name', 'description', 'uuid']
fields = ['id', 'uuid', 'name', 'description', 'organization', 'organization_id', 'member_count']
read_only_fields = ['uuid']
class OrganisationSerializer(ModelSerializer):
class Meta:
model = Organisation
fields = ['id', 'name', 'managers', 'employees', 'domains', 'uuid', 'created_at', 'updated_at']
def get_member_count(self, obj):
return obj.memberships.count()
class DatasetSerializer(ModelSerializer):

View file

@ -1,28 +1,246 @@
from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from apps.domains.models import Domain, Organisation, Dataset
from apps.domains.serializers import DomainSerializer, OrganisationSerializer, DatasetSerializer
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from rest_framework.decorators import action
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):
queryset = Domain.objects.all()
serializer_class = DomainSerializer
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'uuid'
class OrganisationViewSet(ModelViewSet):
queryset = Organisation.objects.all()
serializer_class = OrganisationSerializer
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'uuid'
def get_queryset(self):
user = self.request.user
if user.is_authenticated:
return Domain.objects.filter(
organization__memberships__user=user
).distinct()
return Domain.objects.none()
class DatasetViewSet(ModelViewSet):
queryset = Dataset.objects.all()
serializer_class = DatasetSerializer
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'uuid'

View file

@ -1,12 +1,13 @@
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.agents.viewsets import AgentViewSet, AgentExecutionViewSet
router = DefaultRouter()
router.register(r'organization', OrganizationViewSet, basename='organization')
router.register(r'invite', InviteViewSet, basename='invite')
router.register(r'domain', DomainViewSet, basename='domain')
router.register(r'organisation', OrganisationViewSet, basename = 'organisation')
router.register(r'dataset', DatasetViewSet, basename='dataset')
router.register(r'user', UserViewSet, basename='user')
router.register(r'agent', AgentViewSet, basename='agent')

View file

@ -14,6 +14,7 @@ import {
DashboardOutlined,
LoginOutlined,
UserAddOutlined,
BuildOutlined,
} from '@ant-design/icons-vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/authStore';
@ -79,6 +80,12 @@ const navItems = [
icon: DashboardOutlined,
path: '/progress',
},
{
key: '/organizations',
label: 'Organizations',
icon: BuildOutlined,
path: '/organizations',
},
];
const visibleNavItems = computed(() =>
@ -88,15 +95,20 @@ const visibleNavItems = computed(() =>
);
const selectedKeys = computed(() => {
const match = visibleNavItems.value.find((item) =>
route.path.startsWith(item.key)
);
const match = visibleNavItems.value.find((item) => {
if (item.key === '/') return route.path === '/';
return route.path.startsWith(item.key);
});
return match ? [match.key] : [];
});
const onSelect: MenuProps['onSelect'] = ({ 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 () => {
@ -112,7 +124,9 @@ onMounted(() => {
<template>
<Layout class="shell">
<Layout.Header class="shell-header">
<div class="brand" @click="router.push('/')">Dynavera</div>
<div class="brand" @click="route.path !== '/' && router.push('/')">
Dynavera
</div>
<Menu
mode="horizontal"
theme="dark"

View file

@ -75,6 +75,24 @@ const router = createRouter({
component: () => import('../views/Resources.vue'),
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 },
},
],
});

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

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

View file

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

View file

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