Team Member Management
Manage your company's team members through a secure invitation system with role assignment, token-based acceptance, and resend capabilities.
Invitation Lifecycle
Team member invitations follow a defined status lifecycle. Each invitation generates a unique token sent via email; resending generates a fresh token and resets the status to PENDING.
Once an invitation reaches ACCEPTED status, the resend mutation returns a BadRequestException. The user is already a company member at that point.
A previously accepted member can be invited again after being removed. The duplicate-invitation pre-check on inviteUserToCompany is scoped to status: 'PENDING' only, so historical ACCEPTED / CANCELLED / REJECTED rows never block a fresh invite. Removing a member also cascades the linked invitation to CANCELLED inside a $transaction so the roster stays clean. See SUP-00063 below.
Roles & Permissions
Who Can Invite and Resend
The same roles that can send new invitations can also resend them. Authorization is enforced by @HasRole in the company resolver and service-level checks:
| Role | Tier | Can Invite | Can Resend | Can Remove | Can Transfer Ownership |
|---|---|---|---|---|---|
| Super Admin | Admin | Yes | Yes | Yes | Yes (override) |
| Company Admin | Admin | Yes | Yes | Yes | Only if billingOwnerId |
| Internal Sales | Member | Yes | Yes | No | No |
| Recruiter/HR | Member | No | No | No | No |
| External Sales | Viewer | No | No | No | No |
| Economy | Viewer | No | No | No | No |
| Auditor/Viewer | Viewer | No | No | No | No |
| support | Special | No | No | No | No |
The removeUserCompanyRole resolver refuses to delete the UCR that anchors Company.billingOwnerId, regardless of who calls it (only a super admin can override). This protects subscription/plan resolution — see Company Ownership Transfer below for the supported handoff path.
See Roles & Permissions for the full role-permission matrix and authorization details.
Resending does not consume an additional maxUsersPerCompany plan limit slot. The invitation already exists — resend simply refreshes the token and re-sends the email.
Resend Feature
What Happens on Resend
- New token generated — The old acceptance link becomes invalid immediately
- Status reset to PENDING — Regardless of whether it was PENDING, REJECTED, or CANCELLED
- Email re-sent — The invitee receives a fresh invitation email with the new link
- Any admin can resend — Not restricted to the original inviter; any authorized role in the company can resend
Use Cases
- Reminder — Invitation still PENDING after several days, recipient may have missed the email
- Re-attempt after rejection — Recipient previously declined but has changed their mind
- Re-attempt after cancellation — Admin cancelled the invitation but now wants to re-invite without deleting and recreating
GraphQL API
Send New Invitation
Creates a new invitation record, generates a token, and sends the invitation email.
mutation InviteToCompany($input: InviteToCompanyInput!) {
inviteToCompany(input: $input) {
success
message
}
}
Variables:
{
"input": {
"email": "newmember@example.com",
"companyId": "7659fee2-6b88-4fdf-b2f5-7495c764c8fb",
"roleId": "role-uuid-here"
}
}
Response:
{
"data": {
"inviteToCompany": {
"success": true,
"message": "Invitation sent"
}
}
}
This mutation uses JwtAuthGuard, RoleGuard, and PlanLimitGuard (maxUsersPerCompany). The caller must be authenticated, have an authorized role, and the company must not have exceeded its plan limit.
Before creating the row, the service runs invitation.findFirst({ where: { email, companyId, status: 'PENDING' } }). Any match throws BadRequestException('User has already been invited to this company.'). Historical ACCEPTED / CANCELLED / REJECTED rows are ignored — so re-inviting a previously-removed member works without needing to delete old invitation records. (Regression fix for SUP-00063.)
Resend Invitation
Generates a new token, resets the status to PENDING, and re-sends the invitation email. Works on PENDING, REJECTED, and CANCELLED invitations.
mutation ResendTeamMemberInvitation($input: ResendTeamMemberInvitationInput!) {
resendTeamMemberInvitation(input: $input) {
success
message
}
}
Variables:
{
"input": {
"invitationId": "invitation-uuid-here"
}
}
Response (success):
{
"data": {
"resendTeamMemberInvitation": {
"success": true,
"message": "Invitation resent successfully"
}
}
}
Response (error — already accepted):
{
"errors": [
{
"message": "Cannot resend an invitation that has already been accepted.",
"extensions": {
"code": "BAD_REQUEST",
"statusCode": 400
}
}
]
}
Response (error — unauthorized role):
{
"errors": [
{
"message": "You do not have permission to resend invitations. Required role: Company Admin, Super Admin, or Internal Sales. Your role: Recruiter/HR",
"extensions": {
"code": "BAD_REQUEST",
"statusCode": 400
}
}
]
}
This mutation uses JwtAuthGuard only (no RoleGuard or PlanLimitGuard). The companyId is not in the input — it is resolved from the invitation record. Role authorization is validated inside the service by checking the caller's UserCompanyRole for the invitation's company.
Remove Invitation
Permanently deletes a pending invitation record (used when an admin wants to rescind an invite that hasn't been accepted yet).
For a member who already accepted the invitation, use removeUserCompanyRole(id) — that mutation hard-deletes the UserCompanyRole and, in the same $transaction, flips the linked Invitation.status to CANCELLED. This keeps historical audit trail while freeing the (email, companyId) pair for a fresh invite. See Re-invite after removal.
mutation RemoveInvitation($inviteId: String!) {
removeInvitation(inviteId: $inviteId) {
success
message
}
}
Variables:
{
"input": {
"inviteId": "invitation-uuid-here"
}
}
Response:
{
"data": {
"removeInvitation": {
"success": true,
"message": "Invitation removed successfully"
}
}
}
Query Invitations
Invitations are available as a field resolver on the Company type. The resolver filters to status: 'PENDING' — ACCEPTED / CANCELLED / REJECTED rows are excluded so the field can be rendered directly as the team's pending-invitations list without extra client-side filtering:
query CompanyInvitations($id: ID!) {
company(id: $id) {
id
companyName
invitations {
id
email
status
createdAt
updatedAt
role {
id
name
}
invitedBy {
firstName
lastName
email
}
}
}
}
Response:
{
"data": {
"company": {
"id": "7659fee2-6b88-4fdf-b2f5-7495c764c8fb",
"companyName": "AIQLick",
"invitations": [
{
"id": "inv-abc-123",
"email": "newmember@example.com",
"status": "PENDING",
"createdAt": "2026-03-10T10:00:00.000Z",
"updatedAt": "2026-03-10T12:30:00.000Z",
"role": {
"id": "role-uuid",
"name": "Recruiter/HR"
},
"invitedBy": {
"firstName": "Reza",
"lastName": "Zeraat",
"email": "rezdev97@gmail.com"
}
}
]
}
}
}
Security
Token Rotation
Every resend generates a new cryptographically random token (crypto.randomBytes(32)). The previous token is overwritten in the database — old acceptance links become immediately invalid. This prevents:
- Replay attacks using leaked/expired links
- Multiple people accepting the same invitation
- Stale tokens remaining valid after an admin resends
Acceptance Link Format
Invitation emails contain a link to the frontend message flow:
https://www.aiqlick.com/message/accept?type=team-member&token={token}&invitedEmail={email}
| Parameter | Description |
|---|---|
token | 64-character hex string (32 random bytes) |
type | Always team-member for team invitations |
invitedEmail | URL-encoded email of the invitee |
Old invitation links using /auth/accept?token=... are automatically redirected to the new /message/accept flow. Already-delivered emails continue to work.
Acceptance Flow
The email link is the entry point; the UI does the rest. New users complete account creation in one step (name + password) and land directly in the team workspace. The mutation is public (no JWT required) and synchronous — all database writes complete inside a single prisma.$transaction before the response returns.
One-step team-member acceptance (new user)
Already-logged-in fast path
When the invitee is authenticated (or checkUserExists returns true), the original acceptInvitation mutation is still used. It accepts the invite and refreshes user state, then routes the user to /welcome/team-member. No password entry is shown.
GraphQL — one-step new-user accept
mutation AcceptTeamInviteWithPassword($input: AcceptInvitationWithPasswordInput!) {
acceptInvitationWithPassword(input: $input) {
message
token
}
}
Variables:
{
"input": {
"token": "64-char-hex-from-email",
"fullName": "Liridona Hoxha",
"password": "Strong1Pass",
"confirmPassword": "Strong1Pass"
}
}
The returned token is a signed JWT (sub, email) intended for first-login. The frontend calls login(token) and skips the manual sign-in step. Existing users receive the standard acceptInvitation(input: { token }) mutation instead.
Key behaviors:
- Transaction safety — All database writes (user creation, role assignment, status update, invitation flip) are wrapped in
prisma.$transaction. Any failure rolls everything back. selectedCompanyIdset on the new user — The new account is pre-pointed at the invited company, soAuthGuarddoesn't redirect to/onboarding/company(which is the owner-creation wizard, not the team-member experience).- Password rules enforced at the service — Length ≥ 8 plus at least one uppercase, lowercase, and digit, even if GraphQL DTO validation is bypassed. Passwords are stored as
bcrypthashes. - No second password-setup email — The previous flow created the user with a random password and sent a
sendPasswordSetupEmailafter the transaction committed. The new flow takes the password up front, so no second email is sent and there is no manual/auth/setpassword?context=invitestep. - Existing-account guard — If
findFirst({ email, mode: 'insensitive' })returns a row, the mutation returns400 An account already exists for this email. Please sign in to accept the invitation.and the frontend stays on the sign-in path.
Resend Authorization Flow
Database Model
Team invitations are stored in the Invitation table:
model Invitation {
id String @id @default(uuid())
email String
companyId String @db.Uuid
invitedById String @db.Uuid
roleId String @db.Uuid
token String @unique
status String @default("PENDING")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Company Company @relation(fields: [companyId], references: [id])
User User @relation(fields: [invitedById], references: [id])
Role Role @relation(fields: [roleId], references: [id])
}
The invitation ID is available from the company.invitations field resolver. The frontend prefixes invitation IDs with invite: for UI purposes — strip this prefix before passing to the resendTeamMemberInvitation mutation.
Re-invite after removal (SUP-00063)
Before the fix, inviting any email that had ever been associated with the company failed with User has already been invited to this company. The root cause was a stateless duplicate-check — the pre-check matched on (email, companyId) alone, so stale ACCEPTED rows from members who had since been removed blocked every future invite.
Current behavior:
- Pre-check is PENDING-scoped —
invitation.findFirst({ where: { email, companyId, status: 'PENDING' } }). Only in-flight pending invitations block a new invite. - Member removal cascades —
UserCompanyRoleService.remove()wraps the delete in a$transaction; if the UCR has aninvitationId, the linkedInvitation.statusis flipped toCANCELLEDbefore the row is deleted. The originalACCEPTEDstate is preserved in the audit log but no longer blocks future invites. - Field resolver stays clean —
Company.invitationsreturns onlystatus: 'PENDING'rows, so the frontend team roster never shows ghost entries from past members. - Frontend defense-in-depth —
useMemberDatafilters merged invite rows tostatus === 'PENDING'in case any other caller of the field ever relaxes the backend filter.
Retest: invite a team member → have them accept → remove them → invite the same email again. The second invite should succeed without the duplicate-invitation error.
Cross-company UCR scoping (SUP-00163)
Before the fix, a user with multi-company memberships could be silently removed from the wrong company. Two issues compounded:
- The frontend team-members action menu resolved the UCR id as
target.userCompanyRoles[0].idwith a fallback to whichever UCR happened to come back first. For a user who was a member of multiple companies, that could point at a UCR for a different company than the one the admin was viewing. removeUserCompanyRolehad no caller-side permission gate at the resolver —JwtAuthGuardonly. Any authenticated user could remove any UCR they had a valid id for.
Current behavior:
- Server-side per-company filter —
User.userCompanyRoles(companyId: ID)accepts an optionalcompanyIdarg. When present, the resolver returns only the UCR row(s) wherecompanyIdmatches. With no arg, behaviour is unchanged (returns all UCRs for the user). - Caller permission gate —
removeUserCompanyRole(id)now requires the caller to be Company Admin of the target UCR's company OR a Super Admin. Non-admin callers receive403 You do not have permission to remove members of this company. Self-deletion guard is preserved (a user cannot remove their own UCR). - Frontend strict match —
app/(shared)/userprofile/members/memberPage.tsxandapp/company/team/members/page.tsxstrict-match bycompanyId === currentbefore sending the mutation. The[0]foreign-UCR fallback is gone; if no matching UCR is found the mutation is not sent. - Cascade to invitations preserved — the
$transactionfrom SUP-00063 still flips the linkedInvitation.statustoCANCELLEDwhen applicable; only the access boundary changed.
Retest: sign in as a user with memberships in two companies. In a company where the user is not admin, attempt to remove someone — expect a clean 403 with no row vanishing in either company. In a company where the user is admin, remove a member — expect that company's UCR to be deleted while the same target's UCR in the other company is untouched.
In-app notification on team invite (SUP-00170)
Before the fix, inviting an already-registered AIQLick user via the Teams flow only sent an email — no in-app notification appeared on the invitee's bell. The collaboration-invite path (used by external collaborators) had been emitting invitation.received since launch and CollaborationNotificationListener.handleInvitationReceived was already creating an INVITATION_RECEIVED notification from it; the team-member invite path was simply missing the emit.
Current behavior:
inviteUserToCompanyemitsinvitation.receivedfor registered invitees — after sending the email, the service looks up the invitee by email; if aUserrow exists, it emitsinvitation.receivedwithinvitedUserId,companyId,roleName,invitedByUserId,invitationLink, andtoken. The existingCollaborationNotificationListenerthen creates theINVITATION_RECEIVEDnotification viaNotificationService.createFromTemplate.resendTeamMemberInvitationmirrors the same emit — resending also emitsinvitation.receivedso a registered invitee gets an in-app notification on every resend, not just the initial invite.- Unregistered invitees skip the event — if the user lookup returns null (the invitee hasn't signed up yet), no event is emitted. The email is the only delivery channel for them; there's no
userIdto attach an in-app notification to. - Errors on emit are swallowed — both call sites wrap the
eventEmitter.emitin try/catch and log without rethrowing. The email already went out, so a notification-system hiccup must never break the invite flow. - Email lookup is case-insensitive (SUP-00170 follow-up) — the invitee's email is normalised at the top of
inviteUserToCompany(toLowerCase().trim()) and the user-existence check uses PrismafindFirstwithemail: { equals: ..., mode: 'insensitive' }. The same case-insensitivefindFirstis used inresendTeamMemberInvitationagainst the storedInvitation.email. This guarantees the in-app notification still fires when the invitation casing differs from how the user originally signed up (e.g. admin typesliridona@example.combut the user is stored asLiridona@example.com). Pre-fix,findUniquewas case-sensitive and silently missed those users — Copilot caught this on PR #171's review and PR #173 closed the gap. The duplicate-PENDING-invitation check is also case-insensitive, andInvitation.emailis now stored in the normalised form so downstream consumers see a canonical value.
Retest: invite a registered AIQLick user via Teams → sign in as that user → check the bell. Expect a fresh INVITATION_RECEIVED notification (status UNREAD) titled "Invitation to Join <company>" with a body like "<Inviter> from <company> has invited you to join as <Role>." On the resend path, the same notification is created again with a new token in the metadata.
Company Ownership Transfer
Company.billingOwnerId is the user whose Subscription resolves all plan limits and credit allocations for the company. Removing or orphaning this user would break every billing lookup — see Plan Limits → Owner-anchored resolution. Two guarantees keep the field consistent:
removeUserCompanyRolerefuses to delete the billing-owner UCR. Non-super-admin callers get HTTP 403 withYou cannot remove the company owner. Transfer ownership first.Super admins can override (e.g. when winding down a company), but the typical path is the transfer mutation below.transferCompanyOwnershipis the supported self-serve handoff. Only the current owner — or a super admin — can call it. The mutation movesbillingOwnerIdto a new user atomically; if the new owner doesn't yet have aUserCompanyRolefor the company, the service creates one with roleCompany Adminso the new owner can manage members immediately.
GraphQL API
mutation TransferCompanyOwnership($input: TransferCompanyOwnershipInput!) {
transferCompanyOwnership(input: $input) {
success
message
}
}
Variables:
{
"input": {
"companyId": "7659fee2-6b88-4fdf-b2f5-7495c764c8fb",
"newOwnerId": "11111111-2222-3333-4444-555555555555"
}
}
Response (success):
{
"data": {
"transferCompanyOwnership": {
"success": true,
"message": "Ownership transferred"
}
}
}
Response (caller is not owner):
{
"errors": [
{
"message": "Only the current company owner or a super admin can transfer ownership.",
"extensions": { "code": "FORBIDDEN", "statusCode": 403 }
}
]
}
The transfer flips Company.billingOwnerId only — the underlying Subscription.userId is not migrated. Because plan resolution traverses Company.billingOwnerId → User → Subscription, the new owner's own active subscription becomes the company's plan immediately after the transfer. If the new owner has no active subscription, the company falls back to the unsubscribed state until they purchase one. Migrate the Stripe subscription separately (admin-side) if the intent is to keep the same plan under the new owner.
Frontend (Team page)
app/company/team/members/page.tsx queries GET_COMPANY_OWNER_ID with fetchPolicy: cache-and-network so the owner badge revalidates on every mount of /company/team/members. The cache-and-network policy was chosen specifically so that, after transferCompanyOwnership succeeds, refetchMembers() plus the next route revisit refresh the owner badge — cache-first would have left a stale crown on the previous owner.
The "Transfer Ownership" affordance only appears when:
viewer.user.id === company.billingOwnerId, ORviewer.user.isSuperAdmin === true.
The delete affordance on the owner row is hidden for everyone (the bulk-delete short-circuit drops the owner row before issuing removeUserCompanyRole); for super admins it's intentionally still shown — the frontend defers to the server, which permits super-admin removal of the owner only after the gate above. The Apollo cache invalidation hook calls client.resetStore() (not clearStore()) on company switch so any active queries — including the owner badge — re-fire under the new context.
Retest
- Sign in as the current company owner. Click "Transfer Ownership", pick another member, confirm. Without a hard refresh, the team table should re-render with the crown on the new owner.
- Sign in as a Company Admin who is NOT the owner. The "Transfer Ownership" button should not be visible. Attempting the mutation directly returns 403.
- Try to remove the owner row from the bulk-delete checklist as a non-super-admin. The mutation should not fire (frontend short-circuit). Submitting it directly returns 403 with
You cannot remove the company owner. Transfer ownership first.
Related Documentation
- Company Collaboration — Company-to-company connections and partner invitations
- Plan Limits —
maxUsersPerCompanyand other plan restrictions - Mail System — How invitation emails are queued and delivered