Team — organisations, members and roles
On Zkrinet: add the block under Building blocks in the panel — one click, your own code is untouched and a snapshot is taken first. The installation steps below apply if you run PocketBase yourself, for example after an export.
Teams (organisations) with members and three roles, invitations by email, and — the important part — ready-made API rules that you copy to your own collections so that each team only sees its own data. Tested against PocketBase 0.40.4. Requires the plugin mejl (the invitations are sent with it).
#Install
Copy pb_hooks/ and pb_migrations/ (and the plugin mejl) into the project and restart. Set Application URL under Settings — the link in the invitation email is built from it. SMTP is set up under Settings → Mail settings.
#What you get
| Collection | Content | Who sees it |
|---|---|---|
zk_team | namn, slug (created from the name: "Åkes Bär & Bröd" → akes-bar-brod), skapad_av | The team's members |
zk_team_medlemmar | team, anvandare, roll (agare / admin / medlem) | The team's members |
zk_team_inbjudningar | team, epost, roll, utgar, anvand — the code is stored only as a hash | The team's owners and admins |
| Role | May |
|---|---|
agare (owner) | Everything: rename and delete the team, make others owners, change and remove anyone |
admin | Invite, revoke invitations, remove members, switch member ⇄ admin. Cannot touch an owner and cannot make anyone an owner. |
medlem (member) | Read the team and the member list. Leave. |
Whoever creates a team automatically becomes the owner. A team always has at least one owner — the last owner cannot leave, be demoted or be removed. This also applies in the admin panel and in your own code (the protection is in a hook, not only in the routes).
#Usage (PocketBase JS SDK)
// Create a team — you become the owner
const team = await pb.collection("zk_team").create({ namn: "Bageriet" })
// My teams
const mina = await pb.collection("zk_team").getFullList({ sort: "namn" })
// Members with name and email (regular expand on users does not work — users is private per person)
const { items } = await pb.send(`/api/zk/team/${team.id}/medlemmar`, {})
// Invite (owner/admin). roll: "medlem" (default) or "admin". dagar: 1–30 (default 7).
await pb.send(`/api/zk/team/${team.id}/bjud-in`, { method: "POST", body: { epost: "nisse@exempel.se", roll: "medlem", dagar: 7 } })
// Change role / remove / leave
await pb.send(`/api/zk/team/${team.id}/roll`, { method: "POST", body: { anvandare: userId, roll: "admin" } })
await pb.send(`/api/zk/team/${team.id}/ta-bort`, { method: "POST", body: { anvandare: userId } })
await pb.send(`/api/zk/team/${team.id}/lamna`, { method: "POST" })
// Open invitations (owner/admin) and revoke one
const inb = await pb.collection("zk_team_inbjudningar").getFullList({ filter: `team = "${team.id}" && anvand = ""` })
await pb.send(`/api/zk/team/inbjudan/${inb[0].id}/aterkalla`, { method: "POST" })
// Rename / delete the team (owner only). Deletion takes memberships and invitations with it.
await pb.collection("zk_team").update(team.id, { namn: "Nya namnet" })
await pb.collection("zk_team").delete(team.id)
All routes require a logged-in user in users. Errors come as { message: "..." } in Swedish: 400 (invalid, last owner, already used/expired invitation), 403 (wrong role, wrong email), 404 (the team does not exist or you are not a member — we do not reveal which).
#Invitation
The recipient gets an email (the template team_inbjudan in zk_mejlmallar, editable) with a link to a page with the button Gå med (Join). The page uses the login that the PocketBase JS SDK has stored (pocketbase_auth in the browser) and then sends the user to /. If your frontend is on a different address: take kod from the link and call it yourself:
await pb.send("/api/zk/team/inbjudan/acceptera", { method: "POST", body: { kod } }) // → { team: {id, namn, slug}, roll }
The invitation is only accepted by a logged-in user with the same email address that was invited and a verified email (verified). It works once and until utgar. If the recipient has no account: let them create one (or use the plugin inloggningslankar) and open the link again.
#Protect YOUR collections per team
Give the collection a relation team (→ zk_team, one record). Paste the rules under API Rules:
| Rule | Value |
|---|---|
| List / View | @request.auth.id != '' && team.zk_team_medlemmar_via_team.anvandare ?= @request.auth.id |
| Create | @request.auth.id != '' && team.zk_team_medlemmar_via_team.anvandare ?= @request.auth.id |
| Update | @request.auth.id != '' && team.zk_team_medlemmar_via_team.anvandare ?= @request.auth.id && @request.body.team:changed = false |
| Delete (owner/admin only) | @request.auth.id != '' && team.zk_team_medlemmar_via_team.anvandare ?= @request.auth.id && team.zk_team_medlemmar_via_team.roll ?!= 'medlem' |
Owner only: replace the last part with team.zk_team_medlemmar_via_team.roll ?= 'agare'. zk_team_medlemmar_via_team means "the rows in zk_team_medlemmar whose field team points here" (PocketBase back-relation).
This is proven in the test bench against a real collection projekt (see test.py, test_regel_*):
- A member of team A sees, fetches, filters and expands only A's records — never B's, not even with B's id.
- Nobody can create a record in a team they are not in, or move a record to another team (
:changed = false). - Guests and outsiders see nothing. Someone who leaves the team stops seeing the data immediately.
- The conditions on membership and role hit the same member row. Tested with the trap: the person is a member in A but owner in B — they may not delete in A.
Two traps, both measured:
- Use
?=, never=. In PocketBase=means that all member rows must match — as soon as the team has two members, neither of them sees anything. - Do not build the role check with
@collection.zk_team_medlemmar...without an alias link to the right team and person; then the conditions can hit different rows. Stick toteam.zk_team_medlemmar_via_team...as above.
If your collection has the relation under a different name (e.g. organisation), just replace the first word: organisation.zk_team_medlemmar_via_team.anvandare ?= @request.auth.id.
#Security
- Memberships and invitations can not be written directly via the API (create/update/delete rules are closed) — only via the routes, which check the role.
- Whoever creates a team via the API always becomes
skapad_avthemselves, regardless of what is sent.skapad_avcannot be changed via the API. - A membership cannot be moved to another team or another person (not even by a superuser).
- The invitation code (40 characters) is stored only as a SHA-256 hash in a hidden field. It is never visible in the API, not even to the owner.
- The link in the email accepts nothing by itself — email scanners open links but do not press buttons. The page is sent with
Cache-Control: no-storeandReferrer-Policy: no-referrer. - Changes happen in transactions. Tested: two owners leaving at the same time → exactly one succeeds; four simultaneous accepts of the same invitation → exactly one succeeds.
#Limitations
- The last owner cannot delete their account as long as they own a team (the deletion of the membership is stopped). Make someone else the owner or delete the team first. Keep this in mind in your GDPR flow.
- Only the collection
usersis supported as members. - No rate limiting on invitations: an owner/admin can send any number of emails through your SMTP. Put a limit in front if needed.
- The invitation requires a verified email (
verified). Apps that never verify email must do so (e.g. PocketBaserequestVerification). Whether OTP login viainloggningslankarsetsverifiedis not tested in this plugin. - An invitation cannot grant the owner role — invite as admin/member and promote afterwards.
- The landing page only finds the login if the frontend is on the same address as PocketBase; otherwise call
accepterafrom your app. updateonzk_teamfrom the SDK must not includeskapad_av(even the same value is rejected) — send only the fields you change.- The rule pattern is verified in 0.40.4. Back-relations and the "same row" behaviour are PocketBase-internal semantics — run
./bank.py teamagain before you upgrade PocketBase. - No built-in "change owner" button: make the new person an owner, and then demote yourself.
#Testing
./bank.py team
17 tests: ownership on creation (including via the admin panel), guest rejected everywhere, direct writing of memberships rejected, only owners change the team, admin cannot touch owners or make owners, member can only read, last owner protected (routes, admin panel, account deletion) and simultaneous attempts, invitation all the way (email, scanner protection, wrong email, unverified, once, simultaneous), expiry and 1–30 days, code only as hash, revocation, outsiders get 404, and the rule pattern: isolation between teams, the same-row trap and why = does not work. The security tests are mutant-verified: 37 mutants (each protection deliberately broken); 36 caught, the 37th (accept without transaction) is harmless because the unique index (team, user) stops double membership anyway — if the index is also removed, it is caught.