# Team, roles and invites

> The roster, what each role may do, how an invite is minted and spent, and the three checks that stop a guard from being mistaken for a permission.

Source: https://editor.astrocraftthemes.com/docs/team/

`/admin/team/` is admin-only. The roster is the accounts store unioned with its outstanding invites — an invited person has **no account** until they redeem, so their row is an invite rather than a member with a pending flag.

## The three roles

| Role              | Can                                                                                                                                                                      |
| :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Admin**         | Publish and approve changes · manage the team and invites · change settings and field overrides · connect and disconnect the project · everything a client editor can do |
| **Client editor** | Write and edit entries · upload media and write alt text · submit changes for review · preview the site. **Cannot** publish, approve or manage the team                  |
| **No access**     | Nothing. Sign-in refuses at the door                                                                                                                                     |

The split is one line: **editors may save, only admins may publish** — because publishing pushes.

"No access" is a real, selectable state rather than the absence of one. Taking access away is a role change, not a deletion, so the person's account, their history and their authorship on past commits stay intact.

## A guard is not a permission

Worth understanding, because it is why some things are checked twice.

The session guard answers _"is somebody signed in?"_ It never answers _"may **this** person do **this**?"_ So every admin-only action re-checks the caller's role in its own handler, behind the guard. Invite, role change, settings save and branch change all do.

The guard also re-reads your account from the store on **every guarded request** rather than trusting the session payload. A demotion therefore lands on the demoted person's next request, not at their next sign-in, and one refusal covers no session, deleted account, demoted to `none`, and a stale session at once.

Self-demotion is refused in the handler. The screen does not offer it, but the rule lives where it cannot be skipped.

## Inviting somebody

**Invite** mints a token carrying the address and the role, stores it server-side, mails the link, and hands it back to the modal so you can copy it. The default role in the picker is **Client editor**, not Admin.

The invited person opens the link, sets a password, and lands with the role you chose.

Two details that come from real failures:

- **The token is spent on POST**, never on the GET that loads the page. A mail scanner following the link used to burn the invite before its recipient ever saw it.
- **The redemption trusts the token's address, never the posted one.** A read-only field is a property of one browser, not of the request.

Invites need mail configured. Without `RESEND_API_KEY` and `MAIL_FROM`, the action refuses with a sentence on the page and a line in the server log — everything else keeps working. You can still copy the link out of the modal and send it yourself.

## Self-serve sign-up

`/admin/signup/` is public and anyone who reaches it can create an account. That account gets **No access**, which sign-in refuses until an admin grants it.

This is not a limitation to work around. A self-serve sign-up that granted itself access would hand a fresh deployment to whoever loaded the page first.

Sign-up also needs mail, because confirming a mailed link is how the address is proved. Without it the screen says accounts cannot be created right now — permanently, not transiently. The CLI is the documented path for the first account either way; see [Your first account](/docs/first-account/).

## Promoting somebody

The role picker on a member's row. It changes the role and nothing else — deliberately not the same operation as creating an account, which would reset their password.

Promoting an address nobody has is refused: a promotion is not an invite.

**Last active** is a fact rather than an estimate, and it is genuinely absent until that person's first sign-in rather than being backfilled with their creation date.

## Mail

One mailbox for the whole product: `MAIL_FROM`, a verified sender on a domain you own in Resend. Replies to an invite go to whoever sent it.

Resend is reached with a plain `fetch` — no SDK, a ten-second timeout — and it **never throws**. Provider errors go to the server log only, because they can name the account, the sender domain and the mailbox.

## Troubleshooting

**An invite link says it is already used.** It was spent. Mint another; tokens are single-use by design.

**Somebody signed up and cannot sign in.** Correct — sign-up grants No access. Promote them from this screen.

**The invite modal shows a link but no mail arrived.** Mail is not configured, or Resend rejected the send. Check the server log; the failure is there rather than on screen, because the message can name a mailbox. Copying the link out of the modal works regardless.

**A demoted user still has access.** They should lose it on their next request. If they genuinely do not, that is a bug worth reporting — sessions are not revoked, they are re-authorised per request.

**You have no admin at all.** Run the accounts CLI for your own address with no third argument; it defaults to `admin` and replaces the role along with the password.