User invitation
Someone from outside becomes a user by invitation, and only by invitation. An admin
opens /admin/convites — or, with tenancy, whoever holds admin_app opens
/app/{organization}/convites — and picks e-mail, role and organization; the kit sends a
link carrying a single-use token.
Whoever invites doesn’t need to know whether the address already has an account. The kit decides at acceptance time, and both paths use the same invitation and the same link:
| The address | What happens on acceptance |
|---|---|
| has no account | the person sets their own password and is born with the right role, in the right context, and with the e-mail already verified — the token proves ownership of the address |
| already has an account | it is an access offer: nobody is signed up again. The person logs in with the password they already have, confirms, and is linked to the organization with the invitation’s role — their access in other organizations stays untouched |
On the offer path the token is not enough: acceptance requires the authenticated account to be the invited e-mail, checked in the model and not in the screen’s query. An intercepted link is not access without the password of the invited address.
And saying no is possible. The user menu gains Received invitations (Convites recebidos), with the count of pending offers and the accept and decline actions; a decline is recorded, the invitation stops being valid (including through the link), and whoever administers sees “Declined” in the listing instead of re-inviting someone who already said no. The e-mail link remains the canonical path: it also works for someone who doesn’t belong to any organization yet and therefore can’t reach that screen.
The acceptance screen is Filament’s native registration page (/app/register), with one
guard: without a valid token in the query string it refuses and redirects to login.
There is no open sign-up.
| What | How |
|---|---|
| Token | Str::random(64), stored hashed (sha256) — a leaked database dump is not access |
| Lifetime | KIT_CONVITE_VALIDADE_DIAS (7 days by default) |
| In bulk | Invite in bulk in the listing header: paste the addresses, one role and one organization for the whole batch. Up to KIT_CONVITE_LIMITE_LOTE (100 by default) — one bad address does not stop the others, and the summary tells you how many went out and why the rest did not |
| Usage | single use: for a new account, aceito_em is stamped in the same transaction that creates the user; for an offer, by a conditional update — which is what keeps two clicks from counting twice |
| Reminder | KIT_CONVITE_LEMBRETES_DIAS (D+3 and D+5 by default, counted from the send): the kit sends one reminder per invitation per due day, carrying a second, parallel link — the original link keeps working, and nothing is revoked even if the reminder lands in spam. The cap is the number of days in the list, and an empty list turns the feature off. Every day must be smaller than the lifetime, otherwise the invitation expires before the reminder is due and no reminder ever goes out |
| Resend | issues a new token and kills the previous links — the one from the send and the one from the last reminder |
| Revoke | deletes the invitation; the link stops working immediately, and the deletion lands in /infra/audits |
| Edit | does not exist — the invitation was already sent; fix it by revoking and creating another |
⚠️ Invitations depend on two environment facts.
MAIL_MAILERat itslogdefault only writes the e-mail tostorage/logs— nothing leaves the machine. And the notification is queueable withQUEUE_CONNECTION=database: without a running worker the invitation never goes out.composer devstarts one; on a deploy, usephp artisan queue:work. A stalled queue shows up in the/inframonitor. Multiply that by N for bulk invitations: a batch of a hundred puts a hundred rows injobsand delivers zero, while the screen says “a hundred sent” — because they were, to the queue. WithQUEUE_CONNECTION=syncit is the opposite: each e-mail is an SMTP handshake inside the request, and a hundred of them hitmax_execution_time. That is what the batch limit protects.
⚠️ Reminders need both of the above AND the scheduler. They are sent by
kit:convites-lembrar, scheduled inroutes/console.phpfor 08:00 — withoutphp artisan schedule:work(or the docker composeschedulerservice) it is never called. And the invitation’s counter goes up even with the worker stopped: the write happens before the notification is queued, on purpose, so that a permanently broken address cannot make the cron retry the same invitation every day forever. The consequence is honest: a stopped worker spends reminders without delivering e-mail. On an installation with old pending invitations, rehearse withMAIL_MAILER=log— which is the kit’s default.
The invitation’s role decides the context of the assignment: a role of the /app panel is
granted inside the invitation’s organization; a role of /admin or /infra is granted in
the global context — being an admin of one organization is not a credential to administer
the installation.
Sequence: from send to acceptance
sequenceDiagram
%% DG-07
accTitle: Invitation, from send to acceptance
accDescr: Whoever invites sends the link through the queue, the scheduler reminds whoever has not answered yet, and acceptance follows one of two branches - a new account or an offer to an existing one - linking the invitation's organization when it has one (tenant_id, only exists with KIT_TENANCY).
participant quem_convida as Inviter (admin, or admin_app with KIT_TENANCY)
participant convite as Convite
participant fila as Queue
participant agendador as Scheduler
participant convidado_novo as New invitee
participant convidado_existente as Invitee with an account
quem_convida->>convite: enviar()
convite->>fila: ConviteDeAcesso (link to /app/register)
fila->>convidado_novo: e-mail with the link
loop daily at 08:00
agendador->>convite: lembrar() (kit:convites-lembrar)
end
alt no account with the invited e-mail (new account)
convidado_novo->>convite: aceitar(): sets their own password
convite->>convidado_novo: born verified, with the role, linked to the invitation's organization, if it has a tenant_id (KIT_TENANCY)
else existing account (offer)
convidado_existente->>convite: aceitarComoUsuarioExistente()
convite->>convidado_existente: gets the role in the invitation's organization, if it has a tenant_id (KIT_TENANCY), previous access untouched
else decline (requires KIT_TENANCY)
convidado_existente->>convite: recusar()
end
The link always goes to the /app panel’s registration route, whatever the invitation’s role is
(app/Notifications/ConviteDeAcesso.php:url:100); the notification is ShouldQueue -
without a worker, nothing goes out (app/Notifications/ConviteDeAcesso.php:ShouldQueue:27). The
reminder only exists through the scheduled command
(routes/console.php:40,
app/Console/Commands/KitConvitesLembrar.php), the only caller of lembrar() in app/.
Convite::enviar(), ::lembrar(), ::aceitar(), ::aceitarComoUsuarioExistente() and
::recusar() (app/Models/Convite.php:enviar:143, :lembrar:207, :aceitar:606,
:aceitarComoUsuarioExistente:674, :recusar:739).
Invitation states
stateDiagram-v2 %% DG-09 accTitle: Invitation states accDescr: From Pending, an invitation goes to Accepted, Declined (only with KIT_TENANCY) or Expired; only Expired goes back to Pending by resending; revoking deletes the invitation from any state. state "Pending" as Pendente state "Accepted" as Aceito state "Declined" as Recusado state "Expired" as Expirado Pendente --> Aceito : accept Pendente --> Recusado : decline Pendente --> Expirado : deadline passes Expirado --> Pendente : resend Pendente --> [*] : revoke Aceito --> [*] : revoke Recusado --> [*] : revoke Expirado --> [*] : revoke note right of Recusado: declining requires KIT_TENANCY (received invitations box)
Convite::situacao() has the precedence Accepted > Declined > Expired > Pending
(app/Models/Convite.php:situacao:587, :'Aceito':590, :'Recusado':591); the Resend action is
only visible on Pending or Expired
(app/Filament/Admin/Resources/Convites/Tables/ConvitesTable.php:Action::make('reenviar'):73,
:situacao() === 'Pendente':89); revoking is the native DeleteAction, visible in every state
(app/Filament/Admin/Resources/Convites/Tables/ConvitesTable.php:DeleteAction::make():97). The
decline event only exists through the received invitations box, which only exists with tenancy
(app/Filament/App/Pages/ConvitesRecebidos.php:Action::make('recusar'):132,
:config('kit.tenancy.enabled'):77).