How to allow groups of users to securely collaborate on shared data in Bubble
Setting up Collaboration on Shared data using Teams can be a challenge. Invitations can be tricky. Here is how I set it up.
Making a website is relatively easy.
Making a multi user SaaS app is harder, but doable.
Making a multi user SaaS app with TEAMS is harder still. It’s made me consider my life choices at times :) but it’s doable, and I’ll explain the parts of it below.
This post is brought to you by DocuPotion
DocuPotion is the PDF plugin Bubble devs love to use. It gives you total control over the design of your PDFs and makes creating beautiful PDFs easy.
Want to create simple invoices? DocuPotion can do that.
Tired of dealing with page breaks that don’t work? DocuPotion has detailed page break controls.
Already using a PDF plugin but can’t run it in the backend? DocuPotion allows you to generate PDFs in backend workflows.
Want to generate a detailed 40-page report with graphs and charts that delights your users? No problem - DocuPotion can generate large PDFs without issue
Let’s Talk CRUD
Crud is an acronym, meaning the ability to Create, Read, Update and Delete data. These 4 activities are commonly required in any SaaS app where you have some kind of work item you are operating on, such as a task in a task list. The trick is to be able to control ‘who can do what’ by means on an authorization system. This could mean for example that every logged in user can read items, but only admins can delete items. Restricting items to be accessed by their creator is fairly simple in Bubble by means of logic such as:
and combined with Privacy rules like:
But this kind of mapping of a single user to a single kind of object is not always what you need.. sometimes you need to expand your definition of ‘tenancy’..
Tenancy
So, what is this tenancy thingy?
It’s just a fancy way of saying we are storing multiple customers data in one place (the database), in such a way that we can securely make sure that each customer can only access a subset of the data. In most cases this means their own data, but technically it could be another customers data that they are also allowed to access.
Well, it’s much like a building. You don’t really want to build an entire building and have just one person storing their stuff there, as that would be expensive and inefficient. Ideally you’d build one building and have multiple apartments, each with their own number, and door key. You could even have one person who looks out for their neighbour, and has a key for that apartment in addition to their own. So, it’s just a matter of having a system in place to manage which stuff lives in which part of the building, and controlling how to find it [room numbers], and who can access it.
Tenancy identifiers
You can imagine that finding specific data can be a challenge, and so we need to make sure that every piece of data in a multi tenanted system has a unique identifier.
In the case of many bubble apps this is simple, as bubble automatically generates a Unique id for each person in the users table. You can reference users in searches and privacy rules via ‘Current User’.
Search constraint example:
So, controlling each users access to their own data is relatively straightforward. But how would we control access for a group of people who want to collaborate on some shared data, as a team.
We need to make sure that each Team has its own unique identifier, then use that id to control the CRUD access rules.
Team Identifiers
As part of the onboarding of new team administrators in my chore scheduler app RosterBuddy.app, a new team is created. Each team needs to be unique in my system, and I have made sure that I separated the team ‘name’ from the team ‘id’. In this way, there can be many teams with the same name, but this doesn’t cause problems with the tenancy rules. Also, a team admin could later rename their team, without issues as the teamid stays the same.
We need a way to create a unique id. Here is how to do it using the built in bubble feature of ‘Calculate Random String’
Note, I’ve made it quite long at 32 characters, to reduce the risk of a guid collision, which is the incredibly small, but possible, chance that two identifiers are the same.
Also I’ve chosen to not use special characters in this id, as I was unsure if I would later include this id in the url as parameter, and didn’t want the hassle of managing complexity there.
User Records
In my data design I want a user record to know what role it has [ in my case this can be team member, or team admin], and also which team identifier it is associated with. For new team admins, I store the new team id on the user record with this step:
Collaborating on data
So, now that we have our teams created and referenced by their TeamId, and have allocated this TeamId to each user account, we can use this as the basis for controlling access to resources..
But what resources? Typically this would be entries in the database, in my case, individual Task items, such as ‘Clean Bathroom’.
Potentially in my chores app, there could be hundreds of line items with a name of ‘Clean Bathroom’, but if each one has their TeamId recorded with it, then we can show only the relevant item to each team member.
We therefore need to add this logic to each of the C.R.U.D actions that could take place. For example, when a team admin Creates a task, we add the TeamId to the record:
In this way, EVERY task always has a TeamId associated, and sanity is maintained.
For Reading a task, this is based on a search, which populates a repeating group. I cannot stress enough, that EVERY time you search for tenanted data, YOU MUST INCLUDE THE TENANTID. If you don’t, then you risk exposing EVERY teams tasks to the poor person trying to use your app. This is worse than annoying for them, it is a security breach so big that they will lose all trust in your service as they no longer have any confidence that you will look after their data appropriately.
I’ve made it a strong habit of mine, that every time of compose some search logic, the very first part of the search is to include the Tenancy identifier, like this::
I recommend you make it a habit too.
Note: Not all data is tenanted. You may have a list of plan types in a database that populate your pricing page, publicly available for all users. Another example is public metrics, such as at https://rosterbuddy.app/metrics.
Updates: For updating data, most likely it is for an attribute that is not the actual TenantId, for example you are changing the title, or description, due date etc. The only update actions that would be editing the TenantID would be in a transfer of a work item between teams.. which is doable, but not something required in my app.
Deletes: Deleting is relatively simple, but you should check of course that only data from 'this’ team is surfaced in the UI, and that a prompt is shown confirming that the user does in fact want to do the deletion.
Invitations
Now, in order to collaborate on shared data, we need some method to control which particular users are allowed to access it, and which are not. We need a process to invite other people in to access our data, and this is managed using Invitations.
Here is how I have set this up. If the current user has a role of ‘Team Admin’ then they can see a Settings menu in my app. This takes them to the settings pages, where team and task administration takes place. Within this are I have a list of current users who are part of the team, and a button to Invite more:
Clicking the Plus icon, shows the invitation popup:
The invitation requires First and Last name, and an email address [ unless the Invite using Email toggle is off]
Clicking Send Invite triggers the workflow that does the invitation:
Note: I have some logic to prevent a Team Admin from inviting themselves [ which happened a lot during early signups, presumably because people wanted to test out the functionality without bothering their housemates.]
For a legitimate invitation, these actions happen:
Hide Popup
Show Alert [ i.e UI feedback that the invite happened]
Create a new Invitation Record in the db
Trigger the Invitation Email to be sent, via an API Workflow
Generate a pseudo email address[ for those customers who were added without an email address]
Create a user account[ for those customers who were added without an email address]
Update the public metrics [ about invitations sent]
Some of the logic above, in italics, is unique to my app as I allow invitations by email [ which need accepting before further actions happen], and also invitations without emails [ which is perhaps for parents inviting their kids in, who may not have email addresses].
Below I’ll describe what happens for each of the email based invitations:
Hide Popup: this one is just a standard action to hide the input form
Show Alert : I use AirAlert here, with this text
This reminds the inviter that the invitation process won’t be complete until the invitee has accepted it.
Create a new Invitation Record.
What I decided to do is create a specific data type in called Invitation, in which I have these fields
It is important to know that an invited user isn’t yet a real user account.. it is only the potential for one. Invitations are a ‘long running, cancellable process’… by which I mean, they may never complete for a variety of reasons.. the email might not be delivered ; it might be classed as spam; it might not be noticed; it might be ignored or deleted ; it might be clicked on and then ignored.
The data I add to the Invitations db at this stage is here:
Trigger the Invitation Email to be sent
The sending of the email is done by the triggering of a backend workflow. To learn more about how to set this up, see my other article here: How to enable Email Notifications for the users of your Bubble app.
I trigger it with this action, passing in the Invitation
and the backend workflow accepts this Invitation as the input and uses the information to populate the email template:
The result is that the invitee receives a email similar to this:
the ‘Accept’ button contains a link to the app, which has the InvitationID as a url parameter, which is the same as the ‘Unique id’ field for the record in the Invitations database. e.g
https://rosterbuddy.app/invitationid=1632864761387x131338946746364800&signup=true
When the invited user signs up, the presence of this invitation id allows detection that this was an invited user, rather than just a general sign up, and the value of the invitation id is used to join the user to the correct group as a team member.
Update the public metrics
Here I just increment the invitations counter. More details here How to record metrics about activity within your app
The Invited User
So.. how do we treat the creation of this newly invited customer to relate their signup to the shared data of this particular team?
Well.. on my signup workflow I have an action called ‘Establish if this was an Invited User’, which has this config:
We get the invitationid from the url, if present, and now we know if this was an invited user, and for which team. We then update the Teamid field on this user account to be the same as the one for the invitation, which is of course the same as the teamid of the Team Admin that invited them.
And thus we have mapped our Tenancy based on Teams :) . Every task added by a Team Admin to the groups tasks list, has the same Teamid. Every search for team data includes the Teamid.
job done :)
Other things to consider
Removals
To allow removal of a user from a team, we just delete the Teamid record from a user account. I’ve set this up in the team admin portal for Team Admins.
Invitation vs signups
Invitations and signups have a lot in common, but it is good to be clear on their differences. I think of a Signup as like someone who signs the lease on a new apartment, ie they are the first person to start managing the resource [which has a tenant id of the apartment number].
The next step would then be Invitations, which is like this person wanting to get other people to come and be housemates in this apartment with them.. and when they agree, each of them get their own apartment key [ being their local copy of the teamid on their user account].
Team Transfers
I haven’t implemented this yet, but a person moving to another team should be a simple matter of being invited to the new team, accepting the invite, then all that needs to update is the Teamid value on the invitee’s user account.
Summary
So, I hope that was helpful. As you can see, it’s mostly a matter of defining a logical tenant identifier which relates to the group of data you want to manage, then ensuring that each user and task has the correct tenant identifier on it. And of course, always be thinking of tenancy when constructing your searches.
Happy Building!
























