ZKRINET Docs
Building blocks

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

CollectionContentWho sees it
zk_teamnamn, slug (created from the name: "Åkes Bär & Bröd" → akes-bar-brod), skapad_avThe team's members
zk_team_medlemmarteam, anvandare, roll (agare / admin / medlem)The team's members
zk_team_inbjudningarteam, epost, roll, utgar, anvand — the code is stored only as a hashThe team's owners and admins
RoleMay
agare (owner)Everything: rename and delete the team, make others owners, change and remove anyone
adminInvite, 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:

RuleValue
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_*):

Two traps, both measured:

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

Limitations

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.