-- Adding a second person to a company. -- -- Until now a tenant had exactly the users `provision user` had created on the -- server's own command line. That is not a gap in a UI, it is a gap in the -- product: a shop with an owner and four staff either shares one password -- between five people or raises a support ticket to add each of them, and a -- mobile app for shop floor staff cannot exist at all when there is only one -- account to sign in with. -- -- Registration is by INVITATION, never open signup. That is the same line -- `handlers_admin.go` already draws for creating a company: an endpoint a -- stranger can call to create an account is a much larger thing to secure than -- one reachable only through a manager who already has one, and a self-created -- account in a tenant is a row nobody asked for holding a place in a table -- every query joins against. -- -- The single-use guarantee is the same one enrolment codes use, and for the -- same reason: it lives in the UPDATE (`used_at IS NULL` and the write are one -- statement), never in a check followed by a write, so two people racing on one -- invitation cannot both win. BEGIN; CREATE TABLE IF NOT EXISTS invitations ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, -- The address the invitation was issued FOR. It becomes the account's -- address on redemption and is not caller-supplied at that point: letting -- the redeemer choose would turn one invitation into an account for -- anybody who was forwarded the email. email text NOT NULL, full_name text NOT NULL DEFAULT '', -- 'admin' is deliberately NOT allowed. A platform administrator is defined -- by having no client at all, so an invitation - which always carries one - -- could never mint a real one; what it could do is put the string 'admin' -- on a tenant-scoped row, and `adminOnly` guards against exactly that -- combination existing. Refusing it here means it cannot be created in the -- first place. role text NOT NULL DEFAULT 'staff', -- Only the hash. An invitation is a credential for as long as it is -- unused, so a database dump must not contain a working one - the same -- rule sessions and enrolment codes already follow. code_hash bytea NOT NULL UNIQUE, invited_by uuid REFERENCES app_users(id) ON DELETE SET NULL, expires_at timestamptz NOT NULL, used_at timestamptz, used_by uuid REFERENCES app_users(id) ON DELETE SET NULL, revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT invitations_role_known CHECK (role IN ('owner', 'manager', 'staff')) ); -- Pending invitations only. The list a manager looks at is "who has been asked -- and has not joined yet"; spent and revoked rows are history. CREATE INDEX IF NOT EXISTS invitations_pending_idx ON invitations (client_id, created_at DESC) WHERE used_at IS NULL AND revoked_at IS NULL; COMMIT;