diff --git a/core/admin.py b/core/admin.py index f958f7b..af0357f 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference +from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, Workspace, WorkspaceMembership class CollectionSectionInline(admin.TabularInline): @@ -42,3 +42,5 @@ admin.site.register(TeamMembership) admin.site.register(TeamInvite) admin.site.register(Kanban) admin.site.register(UserPreference) +admin.site.register(Workspace) +admin.site.register(WorkspaceMembership) diff --git a/core/context_processors.py b/core/context_processors.py index 3c883b5..565101b 100644 --- a/core/context_processors.py +++ b/core/context_processors.py @@ -1,7 +1,22 @@ from django.urls import reverse from django.utils.translation import gettext as _ -from .models import UserPreference +from .models import UserPreference, WorkspaceMembership, get_or_create_personal_workspace + + +def active_workspace_navigation(request): + if not request.user.is_authenticated: + return {'active_workspace': None, 'workspace_memberships': []} + workspace = get_or_create_personal_workspace(request.user) + workspace_id = request.session.get('workspace_id') + if workspace_id: + membership = WorkspaceMembership.objects.select_related('workspace').filter(user=request.user, workspace_id=workspace_id).first() + if membership: + workspace = membership.workspace + return { + 'active_workspace': workspace, + 'workspace_memberships': WorkspaceMembership.objects.filter(user=request.user).select_related('workspace').order_by('workspace__name'), + } def overview_navigation(request): diff --git a/core/forms.py b/core/forms.py index f1fb5ef..c572c04 100644 --- a/core/forms.py +++ b/core/forms.py @@ -3,7 +3,7 @@ from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.models import User from django.db.models import Q from django.utils.translation import gettext_lazy as _ -from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference +from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference, Workspace, get_or_create_personal_workspace class MultipleFileInput(forms.ClearableFileInput): @@ -73,6 +73,20 @@ class UserPreferenceForm(forms.ModelForm): } +class WorkspaceForm(forms.ModelForm): + class Meta: + model = Workspace + fields = ['name', 'slug'] + labels = {'name': _('Workspace name'), 'slug': _('Short name')} + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Company or workspace name')}), + 'slug': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'firma'}), + } + + def clean_slug(self): + return self.cleaned_data['slug'].strip().lower() + + class UserInviteForm(forms.ModelForm): email = forms.EmailField(required=True, label=_('Email'), widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'person@example.com', 'required': True})) @@ -96,6 +110,11 @@ class InviteRegistrationForm(UserCreationForm): class TeamForm(forms.ModelForm): + def __init__(self, *args, user=None, workspace=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.workspace = workspace or (get_or_create_personal_workspace(user) if user and user.is_authenticated else None) + class Meta: model = Team fields = ['name', 'slug'] @@ -106,7 +125,15 @@ class TeamForm(forms.ModelForm): } def clean_slug(self): - return self.cleaned_data['slug'].strip().lstrip('#').lower() + slug = self.cleaned_data['slug'].strip().lstrip('#').lower() + if self.workspace and Team.objects.filter(workspace=self.workspace, slug=slug).exclude(pk=self.instance.pk).exists(): + raise forms.ValidationError(_('This team tag is already used in this workspace.')) + return slug + + def save(self, commit=True): + if self.workspace: + self.instance.workspace = self.workspace + return super().save(commit=commit) class TeamInviteForm(forms.ModelForm): @@ -120,6 +147,11 @@ class TeamInviteForm(forms.ModelForm): class KanbanForm(forms.ModelForm): tag_name = forms.CharField(label=_('Tag'), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'})) + def __init__(self, *args, user=None, workspace=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.workspace = workspace or (get_or_create_personal_workspace(user) if user and user.is_authenticated else None) + class Meta: model = Kanban fields = ['name', 'tag_name'] @@ -127,7 +159,8 @@ class KanbanForm(forms.ModelForm): def save(self, commit=True): tag_name = self.cleaned_data['tag_name'].strip().lstrip('#').lower() - tag, _ = Tag.objects.get_or_create(name=tag_name) + tag, _ = Tag.objects.get_or_create(workspace=self.workspace, name=tag_name) + self.instance.workspace = self.workspace self.instance.tag = tag return super().save(commit=commit) @@ -135,19 +168,20 @@ class KanbanForm(forms.ModelForm): class CollectionForm(forms.ModelForm): team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, label=_('Team'), widget=forms.Select(attrs={'class': 'form-select'})) - def __init__(self, *args, user=None, **kwargs): + def __init__(self, *args, user=None, workspace=None, **kwargs): super().__init__(*args, **kwargs) self.fields['visibility'].choices = [ (Item.Visibility.PRIVATE, _('Private')), (Item.Visibility.PUBLIC, _('Public')), ] if user and user.is_authenticated: - team_ids = user.team_memberships.values_list('team_id', flat=True) - self.fields['team'].queryset = Team.objects.filter(pk__in=team_ids).order_by('name') + workspace = self.instance.workspace if self.instance and self.instance.pk else (workspace or get_or_create_personal_workspace(user)) + team_ids = user.team_memberships.filter(team__workspace=workspace).values_list('team_id', flat=True) + self.fields['team'].queryset = Team.objects.filter(workspace=workspace, pk__in=team_ids).order_by('name') self.fields['team'].initial = self.instance.team_id if self.instance and self.instance.pk else None - item_scope = Q(items__owner=user) | Q(items__team_id__in=team_ids) - team_slugs = Team.objects.values_list('slug', flat=True) - self.fields['filter_tags'].queryset = Tag.objects.filter(item_scope).exclude(name__in=team_slugs).distinct().order_by('name') + item_scope = Q(items__workspace=workspace) & (Q(items__owner=user) | Q(items__team_id__in=team_ids)) + team_slugs = Team.objects.filter(workspace=workspace).values_list('slug', flat=True) + self.fields['filter_tags'].queryset = Tag.objects.filter(workspace=workspace).filter(item_scope).exclude(name__in=team_slugs).distinct().order_by('name') else: self.fields['team'].queryset = Team.objects.none() self.fields['filter_tags'].queryset = Tag.objects.none() @@ -189,10 +223,11 @@ class CollectionForm(forms.ModelForm): class ApiKeyForm(forms.ModelForm): - def __init__(self, *args, user=None, **kwargs): + def __init__(self, *args, user=None, workspace=None, **kwargs): super().__init__(*args, **kwargs) if user and user.is_authenticated: - self.fields['tags'].queryset = Tag.objects.filter(items__owner=user).distinct().order_by('name') + workspace = workspace or get_or_create_personal_workspace(user) + self.fields['tags'].queryset = Tag.objects.filter(workspace=workspace, items__owner=user).distinct().order_by('name') else: self.fields['tags'].queryset = Tag.objects.none() diff --git a/core/migrations/0022_workspace_tenancy.py b/core/migrations/0022_workspace_tenancy.py new file mode 100644 index 0000000..fe5454a --- /dev/null +++ b/core/migrations/0022_workspace_tenancy.py @@ -0,0 +1,140 @@ +# Generated by Pi coding agent + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def forwards(apps, schema_editor): + User = apps.get_model(*settings.AUTH_USER_MODEL.split('.')) + Workspace = apps.get_model('core', 'Workspace') + WorkspaceMembership = apps.get_model('core', 'WorkspaceMembership') + Tag = apps.get_model('core', 'Tag') + Team = apps.get_model('core', 'Team') + Item = apps.get_model('core', 'Item') + Collection = apps.get_model('core', 'Collection') + Kanban = apps.get_model('core', 'Kanban') + ApiKey = apps.get_model('core', 'ApiKey') + + personal = {} + for user in User.objects.all(): + full_name = f'{getattr(user, "first_name", "")} {getattr(user, "last_name", "")}'.strip() + name = full_name or getattr(user, 'username', '') or f'User {user.pk}' + workspace, _ = Workspace.objects.get_or_create(slug=f'user-{user.pk}', defaults={'name': name, 'owner_id': user.pk}) + WorkspaceMembership.objects.get_or_create(workspace=workspace, user=user, defaults={'role': 'owner'}) + personal[user.pk] = workspace + + def workspace_for_user(user_id): + if user_id not in personal: + user = User.objects.get(pk=user_id) + workspace, _ = Workspace.objects.get_or_create(slug=f'user-{user.pk}', defaults={'name': getattr(user, 'username', '') or f'User {user.pk}', 'owner_id': user.pk}) + WorkspaceMembership.objects.get_or_create(workspace=workspace, user=user, defaults={'role': 'owner'}) + personal[user_id] = workspace + return personal[user_id] + + for team in Team.objects.select_related('owner'): + team.workspace = workspace_for_user(team.owner_id) + team.save(update_fields=['workspace']) + + for item in Item.objects.select_related('owner', 'team'): + item.workspace = item.team.workspace if item.team_id and item.team.workspace_id else workspace_for_user(item.owner_id) + item.save(update_fields=['workspace']) + + for collection in Collection.objects.select_related('owner', 'team'): + collection.workspace = collection.team.workspace if collection.team_id and collection.team.workspace_id else workspace_for_user(collection.owner_id) + collection.save(update_fields=['workspace']) + + for kanban in Kanban.objects.select_related('owner'): + kanban.workspace = workspace_for_user(kanban.owner_id) + kanban.save(update_fields=['workspace']) + + for api_key in ApiKey.objects.select_related('owner'): + api_key.workspace = workspace_for_user(api_key.owner_id) + api_key.save(update_fields=['workspace']) + + fallback_workspace = next(iter(personal.values()), None) + for tag in list(Tag.objects.all()): + workspace_ids = set(tag.items.exclude(workspace__isnull=True).values_list('workspace_id', flat=True)) + workspace_ids.update(tag.tag_collections.exclude(workspace__isnull=True).values_list('workspace_id', flat=True)) + workspace_ids.update(tag.kanbans.exclude(workspace__isnull=True).values_list('workspace_id', flat=True)) + workspace_ids.update(tag.api_keys.exclude(workspace__isnull=True).values_list('workspace_id', flat=True)) + workspace_ids.update(Team.objects.filter(slug=tag.name).exclude(workspace__isnull=True).values_list('workspace_id', flat=True)) + if not workspace_ids and fallback_workspace: + workspace_ids.add(fallback_workspace.pk) + if not workspace_ids: + tag.delete() + continue + + first = True + for workspace_id in sorted(workspace_ids): + if first: + scoped_tag = tag + scoped_tag.workspace_id = workspace_id + scoped_tag.save(update_fields=['workspace']) + first = False + else: + scoped_tag, _ = Tag.objects.get_or_create(workspace_id=workspace_id, name=tag.name) + + for item in list(tag.items.filter(workspace_id=workspace_id)): + item.tags.remove(tag) + item.tags.add(scoped_tag) + for collection in list(tag.tag_collections.filter(workspace_id=workspace_id)): + collection.filter_tags.remove(tag) + collection.filter_tags.add(scoped_tag) + for api_key in list(tag.api_keys.filter(workspace_id=workspace_id)): + api_key.tags.remove(tag) + api_key.tags.add(scoped_tag) + Kanban.objects.filter(workspace_id=workspace_id, tag=tag).update(tag=scoped_tag) + + for team in Team.objects.exclude(workspace__isnull=True): + Tag.objects.get_or_create(workspace=team.workspace, name=team.slug) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0021_collection_type_filters'), + ] + + operations = [ + migrations.CreateModel( + name='Workspace', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120)), + ('slug', models.SlugField(allow_unicode=True, max_length=80, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_workspaces', to=settings.AUTH_USER_MODEL)), + ], + options={'ordering': ['name']}, + ), + migrations.CreateModel( + name='WorkspaceMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Member')], default='member', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_memberships', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='core.workspace')), + ], + options={'unique_together': {('workspace', 'user')}}, + ), + migrations.AddField('tag', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='core.workspace')), + migrations.AlterField('tag', 'name', models.SlugField(allow_unicode=True, max_length=80)), + migrations.AddField('team', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='core.workspace')), + migrations.AlterField('team', 'slug', models.SlugField(allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos', max_length=80)), + migrations.AddField('item', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.workspace')), + migrations.AddField('collection', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='collections', to='core.workspace')), + migrations.AddField('kanban', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kanbans', to='core.workspace')), + migrations.AddField('apikey', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to='core.workspace')), + migrations.RunPython(forwards, migrations.RunPython.noop), + migrations.AlterField('tag', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='core.workspace')), + migrations.AlterField('team', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='core.workspace')), + migrations.AlterField('item', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.workspace')), + migrations.AlterField('collection', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collections', to='core.workspace')), + migrations.AlterField('kanban', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kanbans', to='core.workspace')), + migrations.AlterField('apikey', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to='core.workspace')), + migrations.AddConstraint('tag', models.UniqueConstraint(fields=('workspace', 'name'), name='unique_workspace_tag_name')), + migrations.AddConstraint('team', models.UniqueConstraint(fields=('workspace', 'slug'), name='unique_workspace_team_slug')), + ] diff --git a/core/models.py b/core/models.py index e583a95..2aa89a3 100644 --- a/core/models.py +++ b/core/models.py @@ -35,17 +35,63 @@ def without_markdown_code(text): def delete_unused_tags(tags=None): """Delete tags that are no longer used by items or tag-based features.""" candidates = Tag.objects.all() if tags is None else Tag.objects.filter(pk__in=[tag.pk for tag in tags]) - candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True).exclude( - name__in=Team.objects.values_list('slug', flat=True), - ).delete() + for tag in candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True): + if not Team.objects.filter(workspace=tag.workspace, slug=tag.name).exists(): + tag.delete() -class Tag(models.Model): - name = models.SlugField(max_length=80, unique=True, allow_unicode=True) +class Workspace(models.Model): + name = models.CharField(max_length=120) + slug = models.SlugField(max_length=80, unique=True, allow_unicode=True) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_workspaces') + created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['name'] + def __str__(self): + return self.name + + +class WorkspaceMembership(models.Model): + class Role(models.TextChoices): + OWNER = 'owner', 'Owner' + ADMIN = 'admin', 'Admin' + MEMBER = 'member', _('Member') + + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='memberships') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='workspace_memberships') + role = models.CharField(max_length=10, choices=Role.choices, default=Role.MEMBER) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = [('workspace', 'user')] + + def __str__(self): + return f'{self.user} in {self.workspace}' + + +def personal_workspace_slug(user): + return f'user-{user.pk}' + + +def get_or_create_personal_workspace(user): + workspace, _created = Workspace.objects.get_or_create( + slug=personal_workspace_slug(user), + defaults={'owner': user, 'name': user.get_full_name() or user.get_username() or f'User {user.pk}'}, + ) + WorkspaceMembership.objects.get_or_create(workspace=workspace, user=user, defaults={'role': WorkspaceMembership.Role.OWNER}) + return workspace + + +class Tag(models.Model): + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='tags') + name = models.SlugField(max_length=80, allow_unicode=True) + + class Meta: + ordering = ['name'] + constraints = [models.UniqueConstraint(fields=['workspace', 'name'], name='unique_workspace_tag_name')] + def __str__(self): return f'#{self.name}' @@ -70,13 +116,20 @@ class UserInvite(models.Model): class Team(models.Model): + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='teams') name = models.CharField(max_length=120) - slug = models.SlugField(max_length=80, unique=True, allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos') + slug = models.SlugField(max_length=80, allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_teams') created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['name'] + constraints = [models.UniqueConstraint(fields=['workspace', 'slug'], name='unique_workspace_team_slug')] + + def save(self, *args, **kwargs): + if not self.workspace_id and self.owner_id: + self.workspace = get_or_create_personal_workspace(self.owner) + super().save(*args, **kwargs) def __str__(self): return self.name @@ -132,6 +185,7 @@ class Item(models.Model): TEAM = 'team', _('Team') PUBLIC = 'public', _('Public') + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='items') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='items') team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='items') kind = models.CharField(max_length=10, choices=Kind.choices, default=Kind.NOTE) @@ -170,18 +224,26 @@ class Item(models.Model): def is_due_today(self): return self.kind == self.Kind.TODO and not self.is_done and self.due_at and timezone.localdate(self.due_at) == timezone.localdate() + def save(self, *args, **kwargs): + if not self.workspace_id and self.owner_id: + self.workspace = get_or_create_personal_workspace(self.owner) + if self.team_id and self.team.workspace_id != self.workspace_id: + self.workspace = self.team.workspace + super().save(*args, **kwargs) + def sync_metadata(self): old_tags = list(self.tags.all()) metadata_text = without_markdown_code(self.content) tag_names = {m.group(1).lower() for m in TAG_RE.finditer(metadata_text)} - tags = [Tag.objects.get_or_create(name=name)[0] for name in tag_names] + tags = [Tag.objects.get_or_create(workspace=self.workspace, name=name)[0] for name in tag_names] self.tags.set(tags) delete_unused_tags(old_tags) ids = [int(m.group(2)) for m in LINK_RE.finditer(f'{self.content}\n{self.comment}')] - self.linked_items.set(Item.objects.filter(id__in=ids).exclude(id=self.id)) + self.linked_items.set(Item.objects.filter(workspace=self.workspace, id__in=ids).exclude(id=self.id)) class Kanban(models.Model): + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='kanbans') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='kanbans') name = models.CharField(max_length=120) tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name='kanbans') @@ -191,6 +253,11 @@ class Kanban(models.Model): ordering = ['name'] unique_together = [('owner', 'tag')] + def save(self, *args, **kwargs): + if not self.workspace_id and self.owner_id: + self.workspace = get_or_create_personal_workspace(self.owner) + super().save(*args, **kwargs) + def __str__(self): return self.name @@ -208,6 +275,7 @@ class Collection(models.Model): def is_structured(self): return self.mode in self.STRUCTURED_MODES + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='collections') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='collections') team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='collections') title = models.CharField(max_length=200) @@ -225,6 +293,13 @@ class Collection(models.Model): class Meta: ordering = ['-updated_at'] + def save(self, *args, **kwargs): + if not self.workspace_id and self.owner_id: + self.workspace = get_or_create_personal_workspace(self.owner) + if self.team_id and self.team.workspace_id != self.workspace_id: + self.workspace = self.team.workspace + super().save(*args, **kwargs) + def __str__(self): return self.title @@ -285,6 +360,7 @@ class UserPreference(models.Model): class ApiKey(models.Model): + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='api_keys') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='api_keys') name = models.CharField(max_length=120, default='API Key') token = models.CharField(max_length=80, unique=True, editable=False) @@ -296,6 +372,8 @@ class ApiKey(models.Model): ordering = ['-created_at'] def save(self, *args, **kwargs): + if not self.workspace_id and self.owner_id: + self.workspace = get_or_create_personal_workspace(self.owner) if not self.token: self.token = secrets.token_urlsafe(32) super().save(*args, **kwargs) diff --git a/core/templates/core/base.html b/core/templates/core/base.html index 9a168b5..e2e72dd 100644 --- a/core/templates/core/base.html +++ b/core/templates/core/base.html @@ -30,7 +30,12 @@
{% if user.is_authenticated %} -{% trans "Separate private and company data into workspaces." %}
+{% trans "Invite new users during the test phase. Only people with an invitation link can register." %}
@@ -73,7 +89,7 @@{% trans "Create API keys for integrations and optionally restrict them to tags." %}
+{% blocktrans with workspace=active_workspace.name %}Create API keys for integrations in workspace {{ workspace }} and optionally restrict them to tags.{% endblocktrans %}