if you think this is a mistake.'),
+ ),
+ migrations.AddConstraint(
+ model_name='orgmembership',
+ constraint=models.UniqueConstraint(condition=models.Q(('deleted__isnull', True)), fields=('org', 'user'), name='unique_org_user'),
+ ),
+ ]
diff --git a/orgs/migrations/__init__.py b/orgs/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/orgs/models.py b/orgs/models.py
new file mode 100644
index 000000000..5a19dad78
--- /dev/null
+++ b/orgs/models.py
@@ -0,0 +1,344 @@
+import re
+from datetime import timedelta
+
+from django.db import models, transaction
+from django.core.exceptions import ValidationError
+from django.db.backends.base.schema import logger
+from django.db.models.query_utils import Q
+from django.utils import timezone
+from django.utils.text import slugify
+from safedelete.managers import SafeDeleteManager
+from safedelete.models import SafeDeleteModel, SOFT_DELETE_CASCADE
+
+from app_users.models import AppUser
+from daras_ai_v2 import settings
+from daras_ai_v2.fastapi_tricks import get_app_route_url
+from daras_ai_v2.crypto import get_random_doc_id
+from orgs.tasks import send_auto_accepted_email, send_invitation_email
+
+
+ORG_DOMAIN_NAME_RE = re.compile(r"^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]+$")
+
+
+def validate_org_domain_name(value):
+ from handles.models import COMMON_EMAIL_DOMAINS
+
+ if not ORG_DOMAIN_NAME_RE.fullmatch(value):
+ raise ValidationError("Invalid domain name")
+
+ if value in COMMON_EMAIL_DOMAINS:
+ raise ValidationError("This domain name is reserved")
+
+
+class OrgRole(models.IntegerChoices):
+ OWNER = 1
+ ADMIN = 2
+ MEMBER = 3
+
+
+class OrgManager(SafeDeleteManager):
+ def create_org(self, *, created_by: "AppUser", org_id: str | None = None, **kwargs):
+ org = self.model(
+ org_id=org_id or get_random_doc_id(), created_by=created_by, **kwargs
+ )
+ org.full_clean()
+ org.save()
+ org.add_member(
+ created_by,
+ role=OrgRole.OWNER,
+ )
+ return org
+
+
+class Org(SafeDeleteModel):
+ _safedelete_policy = SOFT_DELETE_CASCADE
+
+ org_id = models.CharField(max_length=100, null=True, blank=True, unique=True)
+
+ name = models.CharField(max_length=100)
+ created_by = models.ForeignKey(
+ "app_users.appuser",
+ on_delete=models.CASCADE,
+ )
+
+ logo = models.URLField(null=True, blank=True)
+ domain_name = models.CharField(
+ max_length=30,
+ blank=True,
+ null=True,
+ validators=[
+ validate_org_domain_name,
+ ],
+ )
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ objects = OrgManager()
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ fields=["domain_name"],
+ condition=Q(deleted__isnull=True),
+ name="unique_domain_name_when_not_deleted",
+ violation_error_message=f"This domain name is already in use by another team. Contact {settings.SUPPORT_EMAIL} if you think this is a mistake.",
+ )
+ ]
+
+ def __str__(self):
+ if self.deleted:
+ return f"[Deleted] {self.name}"
+ else:
+ return self.name
+
+ def get_slug(self):
+ return slugify(self.name)
+
+ def add_member(
+ self, user: AppUser, role: OrgRole, invitation: "OrgInvitation | None" = None
+ ):
+ OrgMembership(
+ org=self,
+ user=user,
+ role=role,
+ invitation=invitation,
+ ).save()
+
+ def invite_user(
+ self,
+ *,
+ invitee_email: str,
+ inviter: AppUser,
+ role: OrgRole,
+ auto_accept: bool = False,
+ ) -> "OrgInvitation":
+ """
+ auto_accept: If True, the user will be automatically added if they have an account
+ """
+ for member in self.memberships.all().select_related("user"):
+ if member.user.email == invitee_email:
+ raise ValidationError(f"{member.user} is already a member of this team")
+
+ for invitation in self.invitations.filter(status=OrgInvitation.Status.PENDING):
+ if invitation.invitee_email == invitee_email:
+ raise ValidationError(
+ f"{invitee_email} was already invited to this team"
+ )
+
+ invitation = OrgInvitation(
+ invite_id=get_random_doc_id(),
+ org=self,
+ invitee_email=invitee_email,
+ inviter=inviter,
+ role=role,
+ )
+ invitation.full_clean()
+ invitation.save()
+
+ if auto_accept:
+ try:
+ invitation.auto_accept()
+ except AppUser.DoesNotExist:
+ pass
+
+ if not invitation.auto_accepted:
+ invitation.send_email()
+
+ return invitation
+
+
+class OrgMembership(SafeDeleteModel):
+ org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="memberships")
+ user = models.ForeignKey(
+ "app_users.AppUser", on_delete=models.CASCADE, related_name="org_memberships"
+ )
+ invitation = models.OneToOneField(
+ "OrgInvitation",
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ default=None,
+ related_name="membership",
+ )
+
+ role = models.IntegerField(choices=OrgRole.choices, default=OrgRole.MEMBER)
+
+ created_at = models.DateTimeField(auto_now_add=True) # same as joining date
+ updated_at = models.DateTimeField(auto_now=True)
+
+ objects = SafeDeleteManager()
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ fields=["org", "user"],
+ condition=Q(deleted__isnull=True),
+ name="unique_org_user",
+ )
+ ]
+
+ def __str__(self):
+ return f"{self.get_role_display()} - {self.user} ({self.org})"
+
+ def can_edit_org_metadata(self):
+ return self.role in (OrgRole.OWNER, OrgRole.ADMIN)
+
+ def can_delete_org(self):
+ return self.role == OrgRole.OWNER
+
+ def has_higher_role_than(self, other: "OrgMembership"):
+ # creator > owner > admin > member
+ match other.role:
+ case OrgRole.OWNER:
+ return self.org.created_by == OrgRole.OWNER
+ case OrgRole.ADMIN:
+ return self.role == OrgRole.OWNER
+ case OrgRole.MEMBER:
+ return self.role in (OrgRole.OWNER, OrgRole.ADMIN)
+
+ def can_change_role(self, other: "OrgMembership"):
+ return self.has_higher_role_than(other)
+
+ def can_kick(self, other: "OrgMembership"):
+ return self.has_higher_role_than(other)
+
+ def can_transfer_ownership(self):
+ return self.role == OrgRole.OWNER
+
+ def can_invite(self):
+ return self.role in (OrgRole.OWNER, OrgRole.ADMIN)
+
+
+class OrgInvitation(SafeDeleteModel):
+ class Status(models.IntegerChoices):
+ PENDING = 1
+ ACCEPTED = 2
+ REJECTED = 3
+ CANCELED = 4
+ EXPIRED = 5
+
+ invite_id = models.CharField(max_length=100, unique=True)
+ invitee_email = models.EmailField()
+
+ org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="invitations")
+ inviter = models.ForeignKey(
+ "app_users.AppUser", on_delete=models.CASCADE, related_name="sent_invitations"
+ )
+
+ status = models.IntegerField(choices=Status.choices, default=Status.PENDING)
+ auto_accepted = models.BooleanField(default=False)
+ role = models.IntegerField(choices=OrgRole.choices, default=OrgRole.MEMBER)
+
+ last_email_sent_at = models.DateTimeField(null=True, blank=True, default=None)
+ status_changed_at = models.DateTimeField(null=True, blank=True, default=None)
+ status_changed_by = models.ForeignKey(
+ "app_users.AppUser",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="received_invitations",
+ )
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def __str__(self):
+ return f"{self.invitee_email} - {self.org} ({self.get_status_display()})"
+
+ def has_expired(self):
+ return self.status == self.Status.EXPIRED or (
+ timezone.now() - (self.last_email_sent_at or self.created_at)
+ > timedelta(days=settings.ORG_INVITATION_EXPIRY_DAYS)
+ )
+
+ def auto_accept(self):
+ """
+ Automatically accept the invitation if user has an account.
+
+ If user is already part of the team, then the invitation will be canceled.
+
+ Raises: ValidationError
+ """
+ assert self.status == self.Status.PENDING
+
+ invitee = AppUser.objects.get(email=self.invitee_email)
+ self.accept(invitee, auto_accepted=True)
+
+ if self.auto_accepted:
+ logger.info(f"User {invitee} auto-accepted invitation to org {self.org}")
+ send_auto_accepted_email.delay(self.pk)
+
+ def get_url(self):
+ from routers.account import invitation_route
+
+ return get_app_route_url(
+ invitation_route,
+ path_params={"invite_id": self.invite_id, "org_slug": self.org.get_slug()},
+ )
+
+ def send_email(self):
+ # pre-emptively set last_email_sent_at to avoid sending multiple emails concurrently
+ if not self.can_resend_email():
+ raise ValidationError("This user has already been invited recently.")
+
+ self.last_email_sent_at = timezone.now()
+ self.save(update_fields=["last_email_sent_at"])
+
+ send_invitation_email.delay(invitation_pk=self.pk)
+
+ def accept(self, user: AppUser, *, auto_accepted: bool = False):
+ """
+ Raises: ValidationError
+ """
+ # can't accept an invitation that is already accepted / rejected / canceled
+ if self.status != self.Status.PENDING:
+ raise ValidationError(
+ f"This invitation has been {self.get_status_display().lower()}."
+ )
+
+ if self.has_expired():
+ self.status = self.Status.EXPIRED
+ self.save()
+ raise ValidationError(
+ "This invitation has expired. Please ask your team admin to send a new one."
+ )
+
+ if self.org.memberships.filter(user_id=user.pk).exists():
+ raise ValidationError(f"User is already a member of this team.")
+
+ self.status = self.Status.ACCEPTED
+ self.auto_accepted = auto_accepted
+ self.status_changed_at = timezone.now()
+ self.status_changed_by = user
+
+ self.full_clean()
+
+ with transaction.atomic():
+ user.org_memberships.all().delete() # delete current memberships
+ self.org.add_member(
+ user,
+ role=self.role,
+ invitation=self,
+ )
+ self.save()
+
+ def reject(self, user: AppUser):
+ self.status = self.Status.REJECTED
+ self.status_changed_at = timezone.now()
+ self.status_changed_by = user
+ self.save()
+
+ def cancel(self, user: AppUser):
+ self.status = self.Status.CANCELED
+ self.status_changed_at = timezone.now()
+ self.status_changed_by = user
+ self.save()
+
+ def can_resend_email(self):
+ if not self.last_email_sent_at:
+ return True
+
+ return timezone.now() - self.last_email_sent_at > timedelta(
+ seconds=settings.ORG_INVITATION_EMAIL_COOLDOWN_INTERVAL
+ )
diff --git a/orgs/signals.py b/orgs/signals.py
new file mode 100644
index 000000000..bb23b7e06
--- /dev/null
+++ b/orgs/signals.py
@@ -0,0 +1,49 @@
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from loguru import logger
+from safedelete.signals import post_softdelete
+
+from app_users.models import AppUser
+from orgs.models import Org, OrgMembership, OrgRole
+from orgs.tasks import send_auto_accepted_email
+
+
+@receiver(post_save, sender=AppUser)
+def add_user_existing_org(instance: AppUser, **kwargs):
+ """
+ if the domain name matches
+ """
+ if not instance.email:
+ return
+
+ email_domain = instance.email.split("@")[1]
+ org = Org.objects.filter(domain_name=email_domain).first()
+ if not org:
+ return
+
+ if instance.received_invitations.exists():
+ # user has some existing invitations
+ return
+
+ org_owner = org.memberships.filter(role=OrgRole.OWNER).first()
+ if not org_owner:
+ logger.warning(
+ f"Org {org} has no owner. Skipping auto-accept for user {instance}"
+ )
+ return
+
+ invitation = org.invite_user(
+ invitee_email=instance.email,
+ inviter=org_owner.user,
+ role=OrgRole.MEMBER,
+ auto_accept=not instance.org_memberships.exists(), # auto-accept only if user has no existing memberships
+ )
+
+
+@receiver(post_softdelete, sender=OrgMembership)
+def delete_org_if_no_members_left(instance: OrgMembership, **kwargs):
+ if instance.org.memberships.exists():
+ return
+
+ logger.info(f"Deleting org {instance.org} because it has no members left")
+ instance.org.delete()
diff --git a/orgs/tasks.py b/orgs/tasks.py
new file mode 100644
index 000000000..09258c9ec
--- /dev/null
+++ b/orgs/tasks.py
@@ -0,0 +1,68 @@
+from django.utils import timezone
+from loguru import logger
+
+from celeryapp.celeryconfig import app
+from daras_ai_v2 import settings
+from daras_ai_v2.fastapi_tricks import get_app_route_url
+from daras_ai_v2.send_email import send_email_via_postmark
+from daras_ai_v2.settings import templates
+
+
+@app.task
+def send_invitation_email(invitation_pk: int):
+ from orgs.models import OrgInvitation
+
+ invitation = OrgInvitation.objects.get(pk=invitation_pk)
+
+ assert invitation.status == invitation.Status.PENDING
+
+ logger.info(
+ f"Sending inviation email to {invitation.invitee_email} for org {invitation.org}..."
+ )
+ send_email_via_postmark(
+ to_address=invitation.invitee_email,
+ from_address=settings.SUPPORT_EMAIL,
+ subject=f"[Gooey.AI] Invitation to join {invitation.org.name}",
+ html_body=templates.get_template("org_invitation_email.html").render(
+ settings=settings,
+ invitation=invitation,
+ ),
+ message_stream="outbound",
+ )
+
+ invitation.last_email_sent_at = timezone.now()
+ invitation.save()
+ logger.info("Invitation sent. Saved to DB")
+
+
+@app.task
+def send_auto_accepted_email(invitation_pk: int):
+ from orgs.models import OrgInvitation
+ from routers.account import orgs_route
+
+ invitation = OrgInvitation.objects.get(pk=invitation_pk)
+ assert invitation.auto_accepted and invitation.status == invitation.Status.ACCEPTED
+ assert invitation.status_changed_by
+
+ user = invitation.status_changed_by
+ if not user.email:
+ logger.warning(f"User {user} has no email. Skipping auto-accepted email.")
+ return
+
+ logger.info(
+ f"Sending auto-accepted email to {user.email} for org {invitation.org}..."
+ )
+ send_email_via_postmark(
+ to_address=user.email,
+ from_address=settings.SUPPORT_EMAIL,
+ subject=f"[Gooey.AI] You've been added to a new team!",
+ html_body=templates.get_template(
+ "org_invitation_auto_accepted_email.html"
+ ).render(
+ settings=settings,
+ user=user,
+ org=invitation.org,
+ orgs_url=get_app_route_url(orgs_route),
+ ),
+ message_stream="outbound",
+ )
diff --git a/orgs/tests.py b/orgs/tests.py
new file mode 100644
index 000000000..7ce503c2d
--- /dev/null
+++ b/orgs/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/orgs/views.py b/orgs/views.py
new file mode 100644
index 000000000..ed864cb94
--- /dev/null
+++ b/orgs/views.py
@@ -0,0 +1,504 @@
+from __future__ import annotations
+
+import html as html_lib
+
+import gooey_gui as gui
+from django.core.exceptions import ValidationError
+
+from app_users.models import AppUser
+from orgs.models import Org, OrgInvitation, OrgMembership, OrgRole
+from daras_ai_v2 import icons
+from daras_ai_v2.fastapi_tricks import get_route_path
+
+
+DEFAULT_ORG_LOGO = "https://storage.googleapis.com/dara-c1b52.appspot.com/daras_ai/media/74a37c52-8260-11ee-a297-02420a0001ee/gooey.ai%20-%20A%20pop%20art%20illustration%20of%20robots%20taki...y%20Liechtenstein%20mint%20colour%20is%20main%20city%20Seattle.png"
+
+
+def invitation_page(user: AppUser, invitation: OrgInvitation):
+ from routers.account import orgs_route
+
+ orgs_page_path = get_route_path(orgs_route)
+
+ with gui.div(className="text-center my-5"):
+ gui.write(
+ f"# Invitation to join {invitation.org.name}", className="d-block mb-5"
+ )
+
+ if invitation.org.memberships.filter(user=user).exists():
+ # redirect to org page
+ raise gui.RedirectException(orgs_page_path)
+
+ if invitation.status != OrgInvitation.Status.PENDING:
+ gui.write(f"This invitation has been {invitation.get_status_display()}.")
+ return
+
+ gui.write(
+ f"**{format_user_name(invitation.inviter)}** has invited you to join **{invitation.org.name}**."
+ )
+
+ if other_m := user.org_memberships.first():
+ gui.caption(
+ f"You are currently a member of [{other_m.org.name}]({orgs_page_path}). You will be removed from that team if you accept this invitation."
+ )
+ accept_label = "Leave and Accept"
+ else:
+ accept_label = "Accept"
+
+ with gui.div(
+ className="d-flex justify-content-center align-items-center mx-auto",
+ style={"max-width": "600px"},
+ ):
+ accept_button = gui.button(accept_label, type="primary", className="w-50")
+ reject_button = gui.button("Decline", type="secondary", className="w-50")
+
+ if accept_button:
+ invitation.accept(user=user)
+ raise gui.RedirectException(orgs_page_path)
+ if reject_button:
+ invitation.reject(user=user)
+
+
+def orgs_page(user: AppUser):
+ memberships = user.org_memberships.all()
+ if not memberships:
+ gui.write("*You're not part of an organization yet... Create one?*")
+
+ render_org_creation_view(user)
+ else:
+ # only support one org for now
+ render_org_by_membership(memberships.first())
+
+
+def render_org_by_membership(membership: OrgMembership):
+ """
+ membership object has all the information we need:
+ - org
+ - current user
+ - current user's role in the org (and other metadata)
+ """
+ org = membership.org
+ current_user = membership.user
+
+ with gui.div(
+ className="d-xs-block d-sm-flex flex-row-reverse justify-content-between"
+ ):
+ with gui.div(className="d-flex justify-content-center align-items-center"):
+ if membership.can_edit_org_metadata():
+ org_edit_modal = gui.Modal("Edit Org", key="edit-org-modal")
+ if org_edit_modal.is_open():
+ with org_edit_modal.container():
+ render_org_edit_view_by_membership(
+ membership, modal=org_edit_modal
+ )
+
+ if gui.button(f"{icons.edit} Edit", type="secondary"):
+ org_edit_modal.open()
+
+ with gui.div(className="d-flex align-items-center"):
+ gui.image(
+ org.logo or DEFAULT_ORG_LOGO,
+ className="my-0 me-4 rounded",
+ style={"width": "128px", "height": "128px", "object-fit": "contain"},
+ )
+ with gui.div(className="d-flex flex-column justify-content-center"):
+ gui.write(f"# {org.name}")
+ if org.domain_name:
+ gui.write(
+ f"Org Domain: `@{org.domain_name}`", className="text-muted"
+ )
+
+ with gui.div(className="mt-4"):
+ with gui.div(className="d-flex justify-content-between align-items-center"):
+ gui.write("## Members")
+
+ if membership.can_invite():
+ invite_modal = gui.Modal("Invite Member", key="invite-member-modal")
+ if gui.button(f"{icons.add_user} Invite"):
+ invite_modal.open()
+
+ if invite_modal.is_open():
+ with invite_modal.container():
+ render_invite_creation_view(
+ org=org, inviter=current_user, modal=invite_modal
+ )
+
+ render_members_list(org=org, current_member=membership)
+
+ with gui.div(className="mt-4"):
+ render_pending_invitations_list(org=org, current_member=membership)
+
+ with gui.div(className="mt-4"):
+ org_leave_modal = gui.Modal("Leave Org", key="leave-org-modal")
+ if org_leave_modal.is_open():
+ with org_leave_modal.container():
+ render_org_leave_view_by_membership(membership, modal=org_leave_modal)
+
+ with gui.div(className="text-end"):
+ leave_org = gui.button(
+ "Leave",
+ className="btn btn-theme bg-danger border-danger text-white",
+ )
+ if leave_org:
+ org_leave_modal.open()
+
+
+def render_org_creation_view(user: AppUser):
+ gui.write(f"# {icons.company} Create an Org", unsafe_allow_html=True)
+ org_fields = render_org_create_or_edit_form()
+
+ if gui.button("Create"):
+ try:
+ Org.objects.create_org(
+ created_by=user,
+ **org_fields,
+ )
+ except ValidationError as e:
+ gui.write(", ".join(e.messages), className="text-danger")
+ else:
+ gui.experimental_rerun()
+
+
+def render_org_edit_view_by_membership(membership: OrgMembership, *, modal: gui.Modal):
+ org = membership.org
+ render_org_create_or_edit_form(org=org)
+
+ if gui.button("Save", className="w-100", type="primary"):
+ try:
+ org.full_clean()
+ except ValidationError as e:
+ # newlines in markdown
+ gui.write(" \n".join(e.messages), className="text-danger")
+ else:
+ org.save()
+ modal.close()
+
+ if membership.can_delete_org() or membership.can_transfer_ownership():
+ gui.write("---")
+ render_danger_zone_by_membership(membership)
+
+
+def render_danger_zone_by_membership(membership: OrgMembership):
+ gui.write("### Danger Zone", className="d-block my-2")
+
+ if membership.can_delete_org():
+ org_deletion_modal = gui.Modal("Delete Organization", key="delete-org-modal")
+ if org_deletion_modal.is_open():
+ with org_deletion_modal.container():
+ render_org_deletion_view_by_membership(
+ membership, modal=org_deletion_modal
+ )
+
+ with gui.div(className="d-flex justify-content-between align-items-center"):
+ gui.write("Delete Organization")
+ if gui.button(
+ f"{icons.delete} Delete",
+ className="btn btn-theme py-2 bg-danger border-danger text-white",
+ ):
+ org_deletion_modal.open()
+
+
+def render_org_deletion_view_by_membership(
+ membership: OrgMembership, *, modal: gui.Modal
+):
+ gui.write(
+ f"Are you sure you want to delete **{membership.org.name}**? This action is irreversible."
+ )
+
+ with gui.div(className="d-flex"):
+ if gui.button(
+ "Cancel", type="secondary", className="border-danger text-danger w-50"
+ ):
+ modal.close()
+
+ if gui.button(
+ "Delete", className="btn btn-theme bg-danger border-danger text-light w-50"
+ ):
+ membership.org.delete()
+ modal.close()
+
+
+def render_org_leave_view_by_membership(
+ current_member: OrgMembership, *, modal: gui.Modal
+):
+ org = current_member.org
+
+ gui.write("Are you sure you want to leave this organization?")
+
+ new_owner = None
+ if current_member.role == OrgRole.OWNER and org.memberships.count() == 1:
+ gui.caption(
+ "You are the only member. You will lose access to this team if you leave."
+ )
+ elif (
+ current_member.role == OrgRole.OWNER
+ and org.memberships.filter(role=OrgRole.OWNER).count() == 1
+ ):
+ members_by_uid = {
+ m.user.uid: m
+ for m in org.memberships.all().select_related("user")
+ if m != current_member
+ }
+
+ gui.caption(
+ "You are the only owner of this organization. Please choose another member to promote to owner."
+ )
+ new_owner_uid = gui.selectbox(
+ "New Owner",
+ options=list(members_by_uid),
+ format_func=lambda uid: format_user_name(members_by_uid[uid].user),
+ )
+ new_owner = members_by_uid[new_owner_uid]
+
+ with gui.div(className="d-flex"):
+ if gui.button(
+ "Cancel", type="secondary", className="border-danger text-danger w-50"
+ ):
+ modal.close()
+
+ if gui.button(
+ "Leave", className="btn btn-theme bg-danger border-danger text-light w-50"
+ ):
+ if new_owner:
+ new_owner.role = OrgRole.OWNER
+ new_owner.save()
+ current_member.delete()
+ modal.close()
+
+
+def render_members_list(org: Org, current_member: OrgMembership):
+ with gui.tag("table", className="table table-responsive"):
+ with gui.tag("thead"), gui.tag("tr"):
+ with gui.tag("th", scope="col"):
+ gui.html("Name")
+ with gui.tag("th", scope="col"):
+ gui.html("Role")
+ with gui.tag("th", scope="col"):
+ gui.html(f"{icons.time} Since")
+ with gui.tag("th", scope="col"):
+ gui.html("")
+
+ with gui.tag("tbody"):
+ for m in org.memberships.all().order_by("created_at"):
+ with gui.tag("tr"):
+ with gui.tag("td"):
+ name = format_user_name(
+ m.user, current_user=current_member.user
+ )
+ if m.user.handle_id:
+ with gui.link(to=m.user.handle.get_app_url()):
+ gui.html(html_lib.escape(name))
+ else:
+ gui.html(html_lib.escape(name))
+ with gui.tag("td"):
+ gui.html(m.get_role_display())
+ with gui.tag("td"):
+ gui.html(m.created_at.strftime("%b %d, %Y"))
+ with gui.tag("td", className="text-end"):
+ render_membership_actions(m, current_member=current_member)
+
+
+def render_membership_actions(m: OrgMembership, current_member: OrgMembership):
+ if current_member.can_change_role(m):
+ if m.role == OrgRole.MEMBER:
+ modal, confirmed = button_with_confirmation_modal(
+ f"{icons.admin} Make Admin",
+ key=f"promote-member-{m.pk}",
+ unsafe_allow_html=True,
+ confirmation_text=f"Are you sure you want to promote **{format_user_name(m.user)}** to an admin?",
+ modal_title="Make Admin",
+ modal_key=f"promote-member-{m.pk}-modal",
+ )
+ if confirmed:
+ m.role = OrgRole.ADMIN
+ m.save()
+ modal.close()
+ elif m.role == OrgRole.ADMIN:
+ modal, confirmed = button_with_confirmation_modal(
+ f"{icons.remove_user} Revoke Admin",
+ key=f"demote-member-{m.pk}",
+ unsafe_allow_html=True,
+ confirmation_text=f"Are you sure you want to revoke admin privileges from **{format_user_name(m.user)}**?",
+ modal_title="Revoke Admin",
+ modal_key=f"demote-member-{m.pk}-modal",
+ )
+ if confirmed:
+ m.role = OrgRole.MEMBER
+ m.save()
+ modal.close()
+
+ if current_member.can_kick(m):
+ modal, confirmed = button_with_confirmation_modal(
+ f"{icons.remove_user} Remove",
+ key=f"remove-member-{m.pk}",
+ unsafe_allow_html=True,
+ confirmation_text=f"Are you sure you want to remove **{format_user_name(m.user)}** from **{m.org.name}**?",
+ modal_title="Remove Member",
+ modal_key=f"remove-member-{m.pk}-modal",
+ className="bg-danger border-danger text-light",
+ )
+ if confirmed:
+ m.delete()
+ modal.close()
+
+
+def button_with_confirmation_modal(
+ btn_label: str,
+ confirmation_text: str,
+ modal_title: str | None = None,
+ modal_key: str | None = None,
+ modal_className: str = "",
+ **btn_props,
+) -> tuple[gui.Modal, bool]:
+ """
+ Returns boolean for whether user confirmed the action or not.
+ """
+
+ modal = gui.Modal(modal_title or btn_label, key=modal_key)
+
+ btn_classes = "btn btn-theme btn-sm my-0 py-0 " + btn_props.pop("className", "")
+ if gui.button(btn_label, className=btn_classes, **btn_props):
+ modal.open()
+
+ if modal.is_open():
+ with modal.container(className=modal_className):
+ gui.write(confirmation_text)
+ with gui.div(className="d-flex"):
+ if gui.button(
+ "Cancel",
+ type="secondary",
+ className="border-danger text-danger w-50",
+ ):
+ modal.close()
+
+ confirmed = gui.button(
+ "Confirm",
+ className="btn btn-theme bg-danger border-danger text-light w-50",
+ )
+ return modal, confirmed
+
+ return modal, False
+
+
+def render_pending_invitations_list(org: Org, *, current_member: OrgMembership):
+ pending_invitations = org.invitations.filter(status=OrgInvitation.Status.PENDING)
+ if not pending_invitations:
+ return
+
+ gui.write("## Pending")
+ with gui.tag("table", className="table table-responsive"):
+ with gui.tag("thead"), gui.tag("tr"):
+ with gui.tag("th", scope="col"):
+ gui.html("Email")
+ with gui.tag("th", scope="col"):
+ gui.html("Invited By")
+ with gui.tag("th", scope="col"):
+ gui.html(f"{icons.time} Last invited on")
+ with gui.tag("th", scope="col"):
+ pass
+
+ with gui.tag("tbody"):
+ for invite in pending_invitations:
+ with gui.tag("tr", className="text-break"):
+ with gui.tag("td"):
+ gui.html(html_lib.escape(invite.invitee_email))
+ with gui.tag("td"):
+ gui.html(
+ html_lib.escape(
+ format_user_name(
+ invite.inviter, current_user=current_member.user
+ )
+ )
+ )
+ with gui.tag("td"):
+ last_invited_at = invite.last_email_sent_at or invite.created_at
+ gui.html(last_invited_at.strftime("%b %d, %Y"))
+ with gui.tag("td", className="text-end"):
+ render_invitation_actions(invite, current_member=current_member)
+
+
+def render_invitation_actions(invitation: OrgInvitation, current_member: OrgMembership):
+ if current_member.can_invite() and invitation.can_resend_email():
+ modal, confirmed = button_with_confirmation_modal(
+ f"{icons.email} Resend",
+ className="btn btn-theme btn-sm my-0 py-0",
+ key=f"resend-invitation-{invitation.pk}",
+ unsafe_allow_html=True,
+ confirmation_text=f"Resend invitation to **{invitation.invitee_email}**?",
+ modal_title="Resend Invitation",
+ modal_key=f"resend-invitation-{invitation.pk}-modal",
+ )
+ if confirmed:
+ try:
+ invitation.send_email()
+ except ValidationError as e:
+ pass
+ finally:
+ modal.close()
+
+ if current_member.can_invite():
+ modal, confirmed = button_with_confirmation_modal(
+ f"{icons.delete} Cancel",
+ key=f"cancel-invitation-{invitation.pk}",
+ unsafe_allow_html=True,
+ confirmation_text=f"Are you sure you want to cancel the invitation to **{invitation.invitee_email}**?",
+ modal_title="Cancel Invitation",
+ modal_key=f"cancel-invitation-{invitation.pk}-modal",
+ className="bg-danger border-danger text-light",
+ )
+ if confirmed:
+ invitation.cancel(user=current_member.user)
+ modal.close()
+
+
+def render_invite_creation_view(org: Org, inviter: AppUser, modal: gui.Modal):
+ email = gui.text_input("Email")
+ if org.domain_name:
+ gui.caption(
+ f"Users with `@{org.domain_name}` email will be added automatically."
+ )
+
+ if gui.button(f"{icons.add_user} Invite", type="primary", unsafe_allow_html=True):
+ try:
+ org.invite_user(
+ invitee_email=email,
+ inviter=inviter,
+ role=OrgRole.MEMBER,
+ auto_accept=org.domain_name.lower() == email.split("@")[1].lower(),
+ )
+ except ValidationError as e:
+ gui.write(", ".join(e.messages), className="text-danger")
+ else:
+ modal.close()
+
+
+def render_org_create_or_edit_form(org: Org | None = None) -> AttrDict | Org:
+ org_proxy = org or AttrDict()
+
+ org_proxy.name = gui.text_input("Team Name", value=org and org.name or "")
+ org_proxy.logo = gui.file_uploader(
+ "Logo", accept=["image/*"], value=org and org.logo or ""
+ )
+ org_proxy.domain_name = gui.text_input(
+ "Domain Name (Optional)",
+ placeholder="e.g. gooey.ai",
+ value=org and org.domain_name or "",
+ )
+ if org_proxy.domain_name:
+ gui.caption(
+ f"Invite any user with `@{org_proxy.domain_name}` email to this organization."
+ )
+
+ return org_proxy
+
+
+def format_user_name(user: AppUser, current_user: AppUser | None = None):
+ name = user.display_name or user.first_name()
+ if current_user and user == current_user:
+ name += " (You)"
+ return name
+
+
+class AttrDict(dict):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.__dict__ = self
diff --git a/poetry.lock b/poetry.lock
index 73fc4dd20..4483d43e0 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
[[package]]
name = "absl-py"
@@ -1181,6 +1181,21 @@ phonenumberslite = {version = ">=7.0.2", optional = true, markers = "extra == \"
phonenumbers = ["phonenumbers (>=7.0.2)"]
phonenumberslite = ["phonenumberslite (>=7.0.2)"]
+[[package]]
+name = "django-safedelete"
+version = "1.4.0"
+description = "Mask your objects instead of deleting them from your database."
+optional = false
+python-versions = "*"
+files = [
+ {file = "django_safedelete-1.4.0-py3-none-any.whl", hash = "sha256:f722845088c00398711fad8961f044cf18badfecaf541bcc616102f46339adda"},
+ {file = "django_safedelete-1.4.0.tar.gz", hash = "sha256:ce63f2dd101fec303837ef624592628e022691c3ade2a0893c9fc4c7796e8288"},
+]
+
+[package.dependencies]
+Django = "*"
+packaging = "*"
+
[[package]]
name = "docker"
version = "7.0.0"
@@ -2959,6 +2974,16 @@ files = [
{file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
{file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
{file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
@@ -4486,6 +4511,7 @@ files = [
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+ {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
@@ -4493,8 +4519,15 @@ files = [
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+ {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+ {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
@@ -4511,6 +4544,7 @@ files = [
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+ {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
@@ -4518,6 +4552,7 @@ files = [
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
@@ -6466,4 +6501,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<3.13"
-content-hash = "3955eb5901ce23cc6e25cf4d45c9a742d830ea2a63a60000e1cfc1d93c6299a6"
+content-hash = "3db7b5843c1e50294e913e5a137bc7984c5c28cbbbf82e5124aa636a270b6d2b"
diff --git a/pyproject.toml b/pyproject.toml
index d44685d2f..0974344ef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -86,6 +86,7 @@ anthropic = "^0.25.5"
azure-cognitiveservices-speech = "^1.37.0"
twilio = "^9.2.3"
sentry-sdk = {version = "1.45.0", extras = ["loguru"]}
+django-safedelete = "^1.4.0"
gooey-gui = "^0.1.0"
[tool.poetry.group.dev.dependencies]
diff --git a/routers/account.py b/routers/account.py
index 89fc1aff2..093052314 100644
--- a/routers/account.py
+++ b/routers/account.py
@@ -3,11 +3,12 @@
from enum import Enum
import gooey_gui as gui
-from fastapi import APIRouter
from fastapi.requests import Request
from furl import furl
+from gooey_gui.core import RedirectException
from loguru import logger
from requests.models import HTTPError
+from starlette.responses import Response
from bots.models import PublishedRun, PublishedRunVisibility, Workflow
from daras_ai_v2 import icons, paypal
@@ -17,6 +18,8 @@
from daras_ai_v2.manage_api_keys_widget import manage_api_keys
from daras_ai_v2.meta_content import raw_build_meta_tags
from daras_ai_v2.profiles import edit_user_profile_page
+from orgs.models import OrgInvitation
+from orgs.views import invitation_page, orgs_page
from payments.webhooks import PaypalWebhookHandler
from routers.root import page_wrapper, get_og_url_path
@@ -139,6 +142,49 @@ def api_keys_route(request: Request):
)
+@gui.route(app, "/orgs/")
+def orgs_route(request: Request):
+ with account_page_wrapper(request, AccountTabs.orgs):
+ orgs_tab(request)
+
+ url = get_og_url_path(request)
+ return dict(
+ meta=raw_build_meta_tags(
+ url=url,
+ canonical_url=url,
+ title="Teams • Gooey.AI",
+ description="Your teams.",
+ robots="noindex,nofollow",
+ )
+ )
+
+
+@gui.route(app, "/invitation/{org_slug}/{invite_id}/")
+def invitation_route(request: Request, org_slug: str, invite_id: str):
+ from routers.root import login
+
+ if not request.user or request.user.is_anonymous:
+ next_url = request.url.path
+ redirect_url = str(furl(get_route_path(login), query_params={"next": next_url}))
+ raise RedirectException(redirect_url)
+
+ try:
+ invitation = OrgInvitation.objects.get(invite_id=invite_id)
+ except OrgInvitation.DoesNotExist:
+ return Response(status_code=404)
+
+ with page_wrapper(request):
+ invitation_page(user=request.user, invitation=invitation)
+ return dict(
+ meta=raw_build_meta_tags(
+ url=str(request.url),
+ title=f"Join {invitation.org.name} • Gooey.AI",
+ description=f"Invitation to join {invitation.org.name}",
+ robots="noindex,nofollow",
+ )
+ )
+
+
class TabData(typing.NamedTuple):
title: str
route: typing.Callable
@@ -149,6 +195,7 @@ class AccountTabs(TabData, Enum):
profile = TabData(title=f"{icons.profile} Profile", route=profile_route)
saved = TabData(title=f"{icons.save} Saved", route=saved_route)
api_keys = TabData(title=f"{icons.api} API Keys", route=api_keys_route)
+ orgs = TabData(title=f"{icons.company} Teams", route=orgs_route)
@property
def url_path(self) -> str:
@@ -208,6 +255,31 @@ def api_keys_tab(request: Request):
manage_api_keys(request.user)
+def orgs_tab(request: Request):
+ """only accessible to admins"""
+ from daras_ai_v2.base import BasePage
+
+ if not BasePage.is_user_admin(request.user):
+ raise RedirectException(get_route_path(account_route))
+
+ orgs_page(request.user)
+
+
+def get_tabs(request: Request) -> list[AccountTabs]:
+ from daras_ai_v2.base import BasePage
+
+ tab_list = [
+ AccountTabs.billing,
+ AccountTabs.profile,
+ AccountTabs.saved,
+ AccountTabs.api_keys,
+ ]
+ if BasePage.is_user_admin(request.user):
+ tab_list.append(AccountTabs.orgs)
+
+ return tab_list
+
+
@contextmanager
def account_page_wrapper(request: Request, current_tab: TabData):
if not request.user or request.user.is_anonymous:
@@ -218,7 +290,7 @@ def account_page_wrapper(request: Request, current_tab: TabData):
with page_wrapper(request):
gui.div(className="mt-5")
with gui.nav_tabs():
- for tab in AccountTabs:
+ for tab in get_tabs(request):
with gui.nav_item(tab.url_path, active=tab == current_tab):
gui.html(tab.title)
diff --git a/templates/org_invitation_auto_accepted_email.html b/templates/org_invitation_auto_accepted_email.html
new file mode 100644
index 000000000..843fb7426
--- /dev/null
+++ b/templates/org_invitation_auto_accepted_email.html
@@ -0,0 +1,19 @@
+
+ Hi {{ user.first_name() }},
+
+
+
+ You have been added to the team {{ org.name }} on Gooey.AI.
+ Visit the teams page to see your team.
+
+
+
+ Your invite was automatically accepted because your email domain matches the organization's configured email domain.
+ If you think this shouldn't have happened, you can leave this organization from the
+ teams page.
+
+
+
+ Cheers,
+ Gooey.AI team
+
diff --git a/templates/org_invitation_email.html b/templates/org_invitation_email.html
new file mode 100644
index 000000000..c8e12dc87
--- /dev/null
+++ b/templates/org_invitation_email.html
@@ -0,0 +1,25 @@
+
+ Hi!
+
+
+
+ {{ invitation.inviter.display_name or invitation.inviter.first_name() }} has invited
+ you to join their team {{ invitation.org.name }} on Gooey.AI.
+
+
+
+ {% set invitation_url = invitation.get_url() %}
+ Visit this link to view the invitation:
+ {{ invitation_url }}.
+
+
+
+ The link will expire in {{ settings.ORG_INVITATION_EXPIRY_DAYS }} days.
+
+
+
+ Cheers,
+ The Gooey.AI team
+
+
+{{ "{{{ pm:unsubscribe }}}" }}