# Authentication

Member and staff sign in, passwords, person creation and account activation.

Source: https://docs.champagne.dev-ukab.fr/fr/graphql/auth

Members sign in per club, staff sign in globally with access to several clubs.
Send the returned token as `Authorization: Bearer <token>` on later requests.
Automation authenticates with a club [API token](/fr/graphql/api-tokens) instead of signing in.

## Member sign in [#member-sign-in]

```graphql
mutation MemberLogin($clubId: ID!, $email: String!, $password: String!) {
  loginMember(clubId: $clubId, email: $email, password: $password) {
    token
    personId
    clubId
  }
}
```

The same email can exist in different clubs as separate logins. The login email is the person's
profile email, compared after trimming and lowercasing, so editing a member's email changes the
address they sign in with. `memberViewer`
returns the caller's `personId`, `clubId`, `guestExpiresAt` (null for regular members) and
`external` (true for temporary accounts created by [external reservations](/fr/graphql/external-reservations)),
or `null` without a member token. Guests whose access has ended cannot sign in; see
[guests](/fr/graphql/guests).

## Choose a password after onboarding [#choose-a-password-after-onboarding]

People created by an accepted application have no password yet. After the
application is accepted and every after-approval step is complete, choose one:

```graphql
mutation ChoosePassword($applicationId: ID!, $password: String!) {
  setMemberPassword(applicationId: $applicationId, password: $password) {
    token
    personId
    clubId
  }
}
```

Only the applicant can call it: send the application's scoped `X-Application-Token`. Staff and
member sessions receive `UNAUTHENTICATED`, even with `applications_manage` on the club.
Passwords need 12-200 characters with at least one lowercase letter, one uppercase letter, one digit and one symbol.
Seeded demo people use `password123`. Setting a password twice fails; duplicate
emails in one club fail with a correction message.

## Create people from the back office [#create-people-from-the-back-office]

`createPerson` requires a staff token with `members_manage` in the target club. Staff never
choose the member's password: the person is created without a login and receives an
activation email.

```graphql
mutation AddPerson($input: CreatePersonInput!) {
  createPerson(input: $input) {
    id
    firstName
    lastName
    email
  }
}
```

```json
{
  "input": {
    "clubId": "51000000-0000-4000-8000-000000000051",
    "firstName": "New",
    "lastName": "Member",
    "email": "new@example.com"
  }
}
```

`CreatePersonInput` takes `clubId`, `firstName`, `lastName` and `email`; there is no
`password` field. Names must contain 1-100 characters after trimming and the email must
be valid. Emails are unique per club after trimming and lowercasing; a duplicate fails
with "This email is already used in this club.". Creation records `person.created`.

After the person is saved, Champagne emails a single-use activation link built from
`CONSUMER_ORIGIN`: `/activate-account?token=…&club=<clubId>`. The link is valid for
7 days and the email is written in English and French. If `CONSUMER_ORIGIN` is not a bare
`http(s)` origin, `createPerson` fails before creating anyone with "Activation emails are
not configured. Please contact your administrator.". If delivery fails, the person is
still created, the unsent token is discarded and the failure is logged; the person page
then shows no pending link and staff can send one with `resendPersonActivation`.

## Activate a staff-created account [#activate-a-staff-created-account]

```graphql
mutation Activate($token: String!, $password: String!) {
  activateMemberAccount(token: $token, password: $password)
}
```

`activateMemberAccount` is public: the token is the credential. It applies the normal
password policy, creates the member login with the person's current profile email,
consumes every activation and reset link of that person, records `person.activated`
(attributed to the system) and returns `true`. No session is returned; like a password
reset, the member then signs in with `loginMember`. The consumer page at
`/activate-account` links back to sign in for the club in the link.

Failures are returned as `BAD_USER_INPUT` with these messages:

| Situation                                                                                       | Message                                                                               |
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Unknown, already used or superseded token, or a token sent to an email the person no longer has | This activation link is invalid or has already been used. Ask the club for a new one. |
| Token older than 7 days                                                                         | This activation link has expired. Ask the club for a new one.                         |
| The person already has a password                                                               | This account is already activated. Sign in instead.                                   |

Reset tokens cannot activate an account and activation tokens cannot reset a password.

`Person.activated` (in `people` and `peoplePage` items) and `PersonDetail.activated` are true
once the person has a password. The back office shows an active person who is not activated
as **Pending activation** instead of **Active**; suspended, booking-blocked, banned and disabled statuses take
precedence. `status` itself and the `status` filter are unchanged, so pending people still
match `status: active`.

## Resend an activation email [#resend-an-activation-email]

```graphql
mutation Resend($personId: ID!, $clubId: ID!) {
  resendPersonActivation(personId: $personId, clubId: $clubId)
}
```

Requires `members_manage` in the person's club. It issues a new 7-day link, emails it to the
person's current profile email, then invalidates every earlier activation link and records
`person.activation_resent`. When delivery fails, the new link is discarded, earlier links stay
valid and the mutation fails with "The activation email could not be sent. Please try again.".
It is refused with "This account is already activated. Sign in instead." when the person
already has a password, with "This person has not finished their application steps yet." when
the person comes from an accepted application whose after-approval steps are incomplete, and
with "This person was not found." when the person does not
belong to `clubId`.

## Staff sign in [#staff-sign-in]

Staff have one global login and can access several clubs. Staff emails are stored trimmed and
lowercased, so sign in is case-insensitive:

```graphql
mutation StaffLogin($email: String!, $password: String!) {
  loginStaff(email: $email, password: $password) {
    token
    userId
    clubIds
  }
}
```

`staffViewer` returns the caller's `userId`, `clubIds`, per-club `clubRoles`,
assigned `reservationCategoryIds`, linked `resources { id clubId categoryId }`, and effective
`clubRoles.permissions`, or `null` without a back-office token. Linked resources are the
reservation resources the caller is linked to, limited to clubs they can access. The demo owner is `admin@example.com` with `password123` and access to
every seeded club. Invalid credentials produce `BAD_USER_INPUT`; creating a
person without staff access produces `UNAUTHENTICATED`.

## Sign out [#sign-out]

```graphql
mutation SignOut {
  signOut
}
```

`signOut` takes no arguments and ends the session named by the request's
`Authorization: Bearer <token>` header, for members and staff alike. It is public and always
returns `true`: a missing, malformed, expired, or already revoked token is not an error, so
clients can call it unconditionally before discarding their stored token. Other sessions of the
same account stay active.

## Authorization and sessions [#authorization-and-sessions]

Tokens are opaque random credentials and only their SHA-256 hashes are stored. Member
sessions expire after 30 days without use and staff sessions after 7 days without use;
each use extends the session, at most once a day. Sign-out, password-reset revocation,
administrative revocation, and account deletion end a session immediately. Each request checks the session and
current club access. Missing and revoked tokens cannot access protected operations.
Time-limited application-access tokens continue to expire independently. Guest member
sessions stop resolving as soon as the guest's `guestExpiresAt` has passed.

Members can read only their own `person` and their own checkout state, plus their
club's `reservationCategories`. The member-only `createReservation` mutation derives
identity from the session and requires an active account and current club membership.
Admins manage the full back office within their clubs. Staff receive explicit per-club
permissions; reservation operations additionally require an assigned reservation category. Members may list only their own upcoming reservations and start payment
only for their own booked reservation; see [reservations](/fr/graphql/reservations).
Internal
`PersonDetail.note` is always null for members. Club administrators can read and
manage people, applications, sponsorships and billing only within their assigned
clubs. `peoplePage` defaults to the administrator's first club when no club is
specified; `people` includes only authorized clubs. Unknown operations are protected
by default. Club directory and application configuration remain public so a new
applicant can choose a club and apply. Staff directory results exclude inaccessible clubs.

`staffViewer` additionally returns `name`, `email`, `isSpaceOwner`, `isPlatformAdmin`, `readOnly`, `hidesPersonalData` and `closedClubs`, and each `clubRoles` entry lists the club's `disabledFeatures` (see [Platform administration](/fr/graphql/platform)). Owners see
admin management, while owners and admins see staff management. Review transitions store the
administrator ID and name from the authenticated session; clients cannot choose them.
History displays the current name of the linked staff account. If the account is no longer
available, its recorded name is retained. Email addresses are not used as display names.

## Individual staff permissions [#individual-staff-permissions]

The source requirements are the action/actor inventories in
`s3://champagne-storage-beb238f6/champagne-cadrage-bloc-1.md` §2.4 and
`s3://champagne-storage-beb238f6/champagne-cadrage-bloc-2.md` §2.1–2.5.
The latter explicitly lists role/permission management by Direction and Technique,
but does not prescribe a fixed role matrix. Champagne therefore uses individual
permission selections, independently for every club, instead of inferring access
from job titles. The existing administrator role manages staff permissions; only
the space owner manages administrators. A staff permission cannot promote its
holder or delegate access.

`staffSpace.permissionCatalog { id label group implies }` returns the assignable permissions
in display order. `group` (`members`, `operations`, `finance` or `configuration`) groups them
in the access editor, and `implies` lists the read permissions granted automatically with each
one, exactly as authorization applies them; the editor shows those as "Included automatically".
`staffSpace.members.clubPermissions` and `staffSpace.invitations.clubPermissions`
return stored grants. `staffViewer.clubRoles.permissions` returns effective grants,
including read dependencies and all permissions for club administrators.

| Permission            | Authorized actions                                                                                                                         |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `members_read`        | Member profiles, internal notes, membership/status history, signed documents, attendance summaries and credit wallets                      |
| `members_manage`      | Create/edit profiles and photos, send member notifications, inspect/revoke member sessions, resend activation emails, invite guests        |
| `member_notes`        | Edit internal profile notes                                                                                                                |
| `member_status`       | Change member status, including suspension and banning                                                                                     |
| `tags_manage`         | Create, rename and delete member tags and set them on profiles                                                                             |
| `memberships_manage`  | Assign/revoke memberships and initiate membership, add-on or credit checkout                                                               |
| `applications_manage` | Read/review applications, transitions, email history and sponsors                                                                          |
| `billing_manage`      | Read/refresh invoices and issue credit notes                                                                                               |
| `planning_read`       | Read reservations, availability, attendance, invitees and floor plans in assigned categories                                               |
| `reservations_manage` | Create/edit reservations, propose dates, refund deposits, cancel sessions and manage reservation invitees in assigned categories           |
| `attendance_manage`   | Validate tickets, mark attendance and record early check-outs in assigned categories                                                       |
| `availability_manage` | Edit their own default week, extra hours and time off                                                                                      |
| `chats_manage`        | Read/send reservation messages and manage the personal inbox in assigned categories                                                        |
| `customer_chats`      | Start, read and answer customer chats on any subject and manage the personal customer chat inbox                                           |
| `housekeeping_manage` | Read/update housekeeping, checklist checks and consumption in assigned categories                                                          |
| `catalog_manage`      | Configure club plans, categories, resources, sessions, blocks and floor plans                                                              |
| `settings_manage`     | Configure club settings, onboarding, credits, merchants, the newsletter sending domain and webhook integrations, including signing secrets |
| `newsletters_manage`  | Write, test and send member newsletters                                                                                                    |

The profile-write permissions (`members_manage`, `member_notes`, `member_status`,
`tags_manage`, `memberships_manage`) and `customer_chats` include `members_read`. Operational planning permissions and
`catalog_manage` include `planning_read`. These dependencies never grant another
write permission. Selecting a member when booking requires `members_read` in addition
to `reservations_manage`. Creating temporary guest access also requires
`members_manage`. Catalog access exposes the club's category configuration and floor
plans; access to individual reservations still requires an assigned category.
`availability_manage` does not include `planning_read` and only edits the caller's own
availability.

```graphql
mutation StaffPermissions(
  $spaceId: ID!
  $userId: ID!
  $clubIds: [ID!]!
  $categories: [ID!]!
  $grants: [StaffClubPermissionsInput!]!
) {
  updateStaffAccess(
    spaceId: $spaceId
    userId: $userId
    clubIds: $clubIds
    reservationCategoryIds: $categories
    clubPermissions: $grants
  )
}
```

Example `$grants`:

```json
[
  {
    "clubId": "51000000-0000-4000-8000-000000000051",
    "permissions": ["members_read", "member_notes"]
  },
  {
    "clubId": "51000000-0000-4000-8000-000000000052",
    "permissions": ["planning_read", "attendance_manage"]
  }
]
```

`inviteStaff` accepts the same `clubPermissions` argument. The selected permissions
are persisted with the invitation and applied on acceptance. Acceptance rechecks the
inviter's current club-management scope and the reservation-category assignments.
Changes to an existing member's access invalidate pending invitations for that member.

Both mutations replace access within the acting administrator's manageable clubs;
other clubs are preserved. Grants for a club absent from `clubIds`, duplicate clubs,
duplicate permissions, and unknown permission names are rejected. Omitted grants
and explicit empty lists grant no operational permissions. Staff without reservation
categories can still use their other granted modules. Removing all club access is
allowed. The owner cannot be edited or removed, and an administrator cannot remove
a staff account with access outside that administrator's clubs.

Sessions load stored permissions on every API request: revocation applies to the next
request without signing out. Back-office navigation and actions use the effective
viewer permissions and periodically refresh them. The server independently protects
operations, including direct GraphQL calls and forged category IDs. New operations
are denied to staff until explicitly included in the permission policy. Personal
account settings remain available with no operational permissions.

## Account settings and invitations [#account-settings-and-invitations]

```graphql
mutation UpdateAccount($name: String!) {
  updateStaffAccount(name: $name)
}
mutation ChangePassword($current: String!, $next: String!) {
  changeStaffPassword(currentPassword: $current, password: $next)
}
mutation Invite($spaceId: ID!, $clubIds: [ID!]!, $typeIds: [ID!]!, $email: String!) {
  inviteStaff(
    spaceId: $spaceId
    clubIds: $clubIds
    reservationCategoryIds: $typeIds
    role: staff
    email: $email
  )
}
mutation CancelInvite($spaceId: ID!, $email: String!) {
  cancelStaffInvitation(spaceId: $spaceId, email: $email)
}
mutation AcceptInvite($token: String!, $name: String!, $password: String!) {
  acceptStaffInvitation(token: $token, name: $name, password: $password) {
    token
    userId
    clubIds
  }
}
```

Updating the name requires an authenticated staff session and a name of 1–100
characters, but no password. `changeStaffPassword` separately requires the current
password and a valid new password, then invalidates all staff sessions.

`inviteStaff` sends an invitation email and returns `true` only after successful
delivery. No invitation token or copyable link is returned to the caller. The email
names the inviter (their name, or their email when it is empty) and the invited clubs,
and contains a single-use activation link, built from the configured `WEB_ORIGIN`, valid
for 48 hours. Sending another invitation to the same email in the same space replaces
the previous one. An email whose account already has access to one of the chosen clubs
is refused with `This person already has access to one of these clubs. Edit their access
instead.`: change that person's access instead. Delivery failure invalidates the new token
and returns a retryable error. Acceptance rechecks the inviter's current management permissions, club scope,
role, and reservation-category assignments.

`cancelStaffInvitation` deletes the pending invitation of that email in the space, so its
link stops working, and returns `true`. It requires the rights needed to send that
invitation (the owner for an admin invitation, otherwise an administrator of every invited
club) and records `staff.invitation_canceled`; an email without a pending invitation is
refused with `This invitation is no longer pending.`

`acceptStaffInvitation` atomically creates/links the account, consumes the invitation,
and returns a 12-hour staff session (`token`, `userId`, `clubIds`). The frontend stores
this session and opens the first authorized club directly. An existing administrator
must enter their current password; their name and password are not overwritten.

## Password recovery [#password-recovery]

```graphql
mutation Forgot($kind: String!, $email: String!, $clubId: ID) {
  requestPasswordReset(kind: $kind, email: $email, clubId: $clubId)
}
mutation Reset($token: String!, $password: String!) {
  resetPassword(token: $token, password: $password)
}
```

Use `kind: "staff"` for the backoffice or `kind: "member"` plus the club ID for members.
The request returns true without revealing whether an account exists. Known accounts
receive a single-use 30-minute link via the configured mail provider. Repeated requests
within a minute are suppressed. Links use `WEB_ORIGIN` for staff and `CONSUMER_ORIGIN`
for members, never a caller-supplied redirect. A successful reset revokes existing
sessions and other reset links. Password policy applies to every new password.

A member request for a person in that club who has no password yet (created by staff and
never activated) sends a new 7-day activation link instead of a reset link, with the same
neutral `true` response. An accepted applicant whose after-approval steps (payment, signature…)
are incomplete gets no link: they choose their password with `setMemberPassword` once the steps
are done. Earlier activation links are invalidated once it is delivered.
Requests within a minute of the previous activation link, including the one sent at
creation, are suppressed.

## Member activity [#member-activity]

```graphql
query Activity($personId: ID!, $clubId: ID!) {
  memberSessions(personId: $personId, clubId: $clubId) {
    lastLoginAt
    active
    activated
    activationExpiresAt
  }
}
mutation Revoke($personId: ID!, $clubId: ID!) {
  revokeMemberSessions(personId: $personId, clubId: $clubId)
}
```

Both operations require `members_manage` in the person's club. `lastLoginAt` is an
ISO timestamp or null if the person has not signed in. `active` means at least one
session exists, not that the browser is currently online. `activated` is true once the
person has a password. `activationExpiresAt` is the expiry of the latest activation link
still stored for a person who is not activated (it may be in the past), or null when the
person is activated or no link is pending. Revocation invalidates all
member sessions immediately on the server; open clients recheck their viewer periodically.

## Applicant access and development accounts [#applicant-access-and-development-accounts]

`startApplication` returns an `accessToken` scoped to the created application. Send
it as `X-Application-Token` to read or advance that application before member login.
It expires after seven days. Mailbox-verified `recoverApplications` results each include
an `accessToken`. An OTP-verified continuation from `advanceApplication` also returns
a token for its destination application. The consumer stores these per application in
session storage; a fresh browser session uses mailbox recovery. An application UUID
alone never grants access. Club administrators and the linked member can also access
the application using their normal session.

`demoMembers(clubId: ID!): [DemoMember!]!` returns `id`, `name`, and `email` for seeded
accounts only when `DEMO_SEED=true`, including production builds of demo deployments.
Otherwise it returns an empty list. Results are restricted to seeded demo person IDs
with login credentials in the requested club; real members are never listed.
Real applicants are not assigned the demo password by seeding.
In demo mode, the consumer login prefills the first returned member's email and
the existing seeded demo password, both on initial selection and when changing
clubs. Switching clubs clears the previous credentials while the new club loads;
clubs without demo accounts stay blank. Manual edits survive late responses and
background refreshes. Selecting a demo account explicitly restores its demo
credentials. Signing in still uses the normal authenticated login mutation; no
session is created automatically and non-demo accounts are never prefilled.

## Account restrictions [#account-restrictions]

Two independent switches restrict a back-office account, whatever its role and
permissions. They are set with `inviteStaff(readOnly:, hidesPersonalData:)` and
`updateStaffAccess(readOnly:, hidesPersonalData:)` (both default to `false`), returned by
`staffSpace.members` and `staffSpace.invitations`, and reported to the account itself by
`staffViewer { readOnly hidesPersonalData }`. They are stored per space and apply to the
whole account: an account restricted in one of its spaces is restricted everywhere. The
space owner is never restricted. Only the owner can change an admin's restrictions; admins
change those of staff.

**`readOnly`** — every query the role and permissions allow keeps working, and every
mutation is refused with `UNAUTHENTICATED` and the message `This account is read only.`
The exceptions are the account's own `updateStaffAccount`, `changeStaffPassword`,
`updateStaffProfile` (own profile only) and `reportBug`, signing in and out, and platform
operations for a platform admin. Staff document uploads over REST answer `403`.

**`hidesPersonalData`** — the account reads the same records, pseudonymized:

* A person's name becomes `Member` followed by a six-character code derived from the
  person's ID, so the same person carries the same pseudonym in lists, the planning,
  invoices and the activity history. On `Person`, `PersonDetail`, `Application` and
  `ApplicationSummary`, `firstName` is `Member` and `lastName` is the code. Names without
  a linked person (walk-in or external customers, invitees) read `Guest`.
* Email, phone, address, birth date, gender, photo, company identifiers, custom attribute
  values, application answers, internal notes, status and rejection reasons, customer
  sheets, invoice payer details and invoice PDF links are returned empty or `null`.
* Chat subjects and message bodies read `Hidden`.
* Activity entries about a person, application, reservation, invoice or membership carry
  the pseudonym and no `note` or `changes`; configuration and team history is unchanged.
* `peoplePage`, `people`, `applications` and `invoices` refuse the `search`, `minAge`,
  `maxAge`, `gender` and `attributes` arguments, and the following operations are refused,
  all with `This account cannot access personal data.`: creating or editing a profile,
  photo, note or attribute values, sending a member notification or newsletter test,
  downloading a signed document, inviting a guest or reservation invitee, creating a staff
  reservation, writing chat messages or customer sheets, and managing the team (which
  could otherwise grant an unrestricted account).

Combining both gives a technical account that can inspect production without changing it
or learning who the members are. Staff names and emails are not member data and stay
visible. Restrictions are re-read on every request.

## Admin and staff management [#admin-and-staff-management]

A space groups clubs under an owner. Clubs belong to one space and back-office accounts
can belong to multiple spaces. Owners manage admins and staff; admins can manage staff
within the clubs they administer. Staff cannot delegate access. New invitees enter their
name when accepting an invitation.

```graphql
query Spaces {
  staffSpaces {
    id
    name
  }
}
query Staff($spaceId: ID!) {
  staffSpace(spaceId: $spaceId) {
    id
    name
    ownerId
    clubs {
      id
      name
    }
    reservationCategories {
      id
      clubId
      name
    }
    members {
      id
      name
      email
      isOwner
      clubIds
      role
      reservationCategoryIds
    }
  }
}
mutation StaffAccess($spaceId: ID!, $userId: ID!, $clubIds: [ID!]!, $typeIds: [ID!]!) {
  updateStaffAccess(
    spaceId: $spaceId
    userId: $userId
    clubIds: $clubIds
    reservationCategoryIds: $typeIds
  )
}
mutation RemoveStaff($spaceId: ID!, $userId: ID!) {
  removeStaffAccess(spaceId: $spaceId, userId: $userId)
}
```

`staffSpaces` lists spaces the caller can manage, sorted by name. `staffSpace` returns
the admins, staff, invitations, clubs, and reservation categories in the manageable scope.
The owner sees the whole space. A club admin who does not own the space sees only the members
and invitations with access to at least one club they administer, plus staff and invitations
with no club in the space (the owner is not listed unless they hold such access); people who only
work in the space's other clubs are left out.
These lists have no filtering or pagination. Empty spaces return empty lists.
Foreign spaces and unauthorized callers are rejected. Club IDs must be distinct, belong
to the selected space, and number at most 200. Invitations require at least one club.
Access edits accept an empty list to revoke every club in that space and preserve
access to other spaces. The owner cannot have their own club access edited here.
Edits invalidate pending invitations for that person in the same space. Existing
sessions see current access on their next server request.

Staff assignments must reference reservation categories from the selected clubs. Removing a
staff member removes their access and planning assignments immediately. Removing a staff member,
or withdrawing their access to a club, also unlinks them from that club's resources
(`ReservationResource.staffUserIds`), so those resources stay editable and their slots no longer
follow the former coach's availability. Existing bookings are kept. Only the owner
can invite, edit, or remove admins; the owner account itself cannot be removed.

### Pending invitations [#pending-invitations]

`staffSpace(spaceId: ID!)` also returns `invitations { email clubIds role reservationCategoryIds readOnly hidesPersonalData expired }`, ordered by email. These entries appear in the matching Admins or Staff table with an invitation-pending or invitation-expired status and a button that cancels the invitation with `cancelStaffInvitation`. No token or invitation URL is returned.

### Demo staff accounts [#demo-staff-accounts]

With demo seeding enabled, all these accounts initially use `password123`:

| Email                                                       | Name               | Club access               | Role        |
| ----------------------------------------------------------- | ------------------ | ------------------------- | ----------- |
| [admin@example.com](mailto:admin@example.com)               | Demo Administrator | Ukab Clubs, Vosges, Astel | Space owner |
| [ukab.staff@example.com](mailto:ukab.staff@example.com)     | Sport coach        | Ukab Clubs                | Staff       |
| [vosges.staff@example.com](mailto:vosges.staff@example.com) | Alex Bernard       | Vosges                    | Admin       |
| [multi.staff@example.com](mailto:multi.staff@example.com)   | Louise Moreau      | Vosges, Astel             | Admin       |
| [tech.staff@example.com](mailto:tech.staff@example.com)     | Technical support  | Ukab Clubs, Vosges, Astel | Admin       |
| [chloe.dubois@example.com](mailto:chloe.dubois@example.com) | Chloé Dubois       | Ukab Clubs                | Staff       |
| [ines.laurent@example.com](mailto:ines.laurent@example.com) | Inès Laurent       | Ukab Clubs                | Staff       |
| [manon.petit@example.com](mailto:manon.petit@example.com)   | Manon Petit        | Ukab Clubs                | Staff       |
| [lea.garnier@example.com](mailto:lea.garnier@example.com)   | Léa Garnier        | Ukab Clubs                | Staff       |

`tech.staff@example.com` is read only with personal data hidden (see [Account restrictions](#account-restrictions)).

The four Ukab Clubs stylists manage only the "Soin en salon de beauté" category, whose resources carry their names.

Additional staff accounts are created only when their email does not already exist. Reseeding preserves their edited names, passwords and club permissions, including removed access. Only owners and club administrators can manage staff or invitations. Newly seeded coaches receive planning, reservation, attendance, chat and housekeeping permissions; reseeding preserves edited permissions.

Staff edits to a member's profile email also update their existing login email atomically.
Password recovery uses the updated address; links issued to a different address are no
longer valid. Existing sessions and passwords remain unchanged. See [Email consistency](/fr/graphql/people#email-consistency).

Camille is assigned to the seeded Ukab reservation categories and therefore lands in Planning.

## Staff activity history [#staff-activity-history]

Every staff change is written to one append-only activity log, in the same database
transaction as the change itself. The acting user comes from the authenticated session,
never from mutation input. Reads are not recorded. Changes made through Stripe webhooks
and background workers are attributed to the system; member self-service changes are
attributed to the member.

```graphql
query StaffActivity($spaceId: ID!, $userId: ID!, $page: Int!) {
  staffActivity(spaceId: $spaceId, userId: $userId, page: $page) {
    page
    hasMore
    items {
      id
      occurredAt
      action
      actorName
      clubId
      clubName
      subjectType
      subjectId
      subjectLabel
      personId
      personName
      note
      changes {
        field
        before
        after
      }
    }
  }
}
```

`staffActivity(spaceId: ID!, userId: ID!, page: Int! = 1)` returns the actions performed by
one member of the space, newest first, 20 per page, ordered by `occurredAt` and `id`. `page`
must be between 1 and 10,000. New entries can shift offset pages; refresh page 1 to see recent
actions.

Access follows team management: the space owner can read every member's history, including
team changes (invitations, access updates, removals and staff documents), which carry no
`clubId`. Club admins can read the history of staff members only, limited to actions in the
clubs they administer. Other callers receive `UNAUTHENTICATED`; a user outside the space or an
admin viewed by a non-owner is rejected with the team-management error.

`action` is a dotted verb such as `person.status_changed`, `reservation.attendance_marked`,
`plan.updated` or `webhook.secret_revealed`. `subjectType` and `subjectId` identify the changed
record, and `subjectLabel` is its name when the action happened (a person, plan, resource,
webhook URL or email). `personId`/`personName` identify the affected member when there is one;
`personName` and `actorName` reflect current names. `changes` lists modified fields as display
strings (`before`/`after` are null when empty); secrets, tokens, passwords, file contents and
message bodies are never stored. `note` holds the reason or note entered with the change.
`staff.invited` and `staff.access_updated` describe access with the same fields: `role` (invitations
only), `clubs`, `permissions` (per club, e.g. `Vosges: members_read, tags_manage`) and
`reservationCategories`. An invitation lists only the fields it grants, so the history shows who
gave which right whether it came from an invitation or a later edit.

Every staff mutation must declare the activity it records, or be explicitly exempt, in
`staffMutationActivity`; a unit test fails when a new staff mutation is missing. Exempt
mutations only create checkout links, refresh provider data, mark chats as read, or change the
caller's own account.

## Staff professional dossiers [#staff-professional-dossiers]

The Team page opens an existing account's **Professional profile** and **Access** tabs.
A dossier belongs to `(spaceId, userId)`, independently of dossiers in other spaces.
It is available after invitation acceptance. No member-facing profile is published.

The space owner can manage every dossier in that space, including their own. An
administrator can manage a staff dossier only when the person has staff access to
at least one club they administer, or when the person is still a member of the space
without access to any club (so it can be reopened and given access again). A person with any administrator role in the
space (or its owner) can only have their dossier managed by the owner. Ordinary
staff, including the dossier subject, and members have no access. These rules are
checked against current database access for every read, write and file request.

```graphql
query {
  staffDocuments(spaceId: "SPACE_UUID", userId: "STAFF_UUID") {
    id
    kind
    title
    number
    issuer
    issuedOn
    expiresOn
    filename
    contentType
    size
  }
}
```

`staffDocuments` returns all dossier documents ordered by kind, title and ID,
without pagination or filters. `kind` is `professional_card`, `diploma` or `other`;
multiple documents of each kind are supported. `title` is required (1–200 trimmed
characters). `number` and `issuer` are nullable strings of at most 200 characters.
`issuedOn` and `expiresOn` are nullable valid `YYYY-MM-DD` dates; expiration cannot
precede issue. `size` is the byte count. Storage keys are never exposed.

```graphql
mutation {
  updateStaffDocument(
    spaceId: "SPACE_UUID"
    userId: "STAFF_UUID"
    id: "DOCUMENT_UUID"
    input: {
      kind: professional_card
      title: "Professional card"
      number: "DEMO-123"
      issuer: "Issuing organization"
      issuedOn: "2026-01-01"
      expiresOn: "2031-01-01"
    }
  ) {
    id
    title
    expiresOn
  }
}
```

This mutation replaces the metadata; omitted optional fields are cleared. It
preserves the file. `removeStaffDocument(spaceId, userId, id)` removes metadata
and queues private-file deletion durably; repeated deletion returns `true`.
Cleanup runs immediately and retries every minute if storage is unavailable.

Files use authenticated HTTP rather than GraphQL payloads:

* `PUT /api/staff-documents/:spaceId/:userId/:id`: multipart `file` and `metadata`
  (JSON with all metadata fields, using null for absent optional fields). Use a
  client-generated document UUID for creation and reuse it when retrying. The
  same URL replaces an existing document's file and metadata atomically after
  storage succeeds. A failed upload leaves the previous document intact.
* `GET /api/staff-documents/:spaceId/:userId/:id`: attachment download with
  `Cache-Control: private, no-store` and `X-Content-Type-Options: nosniff`.

Both endpoints require the staff Bearer token. PDF, JPEG and PNG files must have
matching detected signatures and MIME types, be nonempty, and be at most 10 MiB.
Requests are bounded to that limit plus 64 KiB multipart overhead. Invalid inputs
return 400, oversized bodies 413, unauthenticated requests 401, unauthorized
scopes 403, missing documents 404, and unconfigured storage 503. Unexpected
failures are masked. GraphQL uses `BAD_USER_INPUT` for invalid metadata and
`FORBIDDEN` for dossier authorization failures after the staff-management gate.

The UI shows **Not expired**, **Expired**, or **Expiry not provided** using the
Europe/Paris calendar date; expiration is inclusive of its stated date. This is
only a date indicator, not certification. No reminders, planning restrictions,
or document version history are provided.

Storage uses the existing private `TICKET_S3_*` configuration with the separate
`staff-documents/` key prefix. With no S3 configuration in development, files live
under `/app/.local/staff-documents` (override: `STAFF_DOCUMENT_STORAGE_DIRECTORY`).
Partial S3 settings and production never fall back to local storage. Interrupted
uploads are queued for cleanup after one hour. Removing team access makes the
dossier inaccessible to ordinary administrators; the dossier remains associated
with its space and account and is available again if that account rejoins.

### Expired documents in the team directory [#expired-documents-in-the-team-directory]

`staffSpace(spaceId: ID!).members.expiredDocumentCount: Int` counts professional
documents whose expiration date is strictly before today's calendar date in
`Europe/Paris`. Documents expiring today remain current until the end of the day;
missing expiration dates are not counted. The count is scoped to this team space.
It returns `0` for an accessible dossier with no expired documents and `null`
when the viewer cannot consult that person's dossier. In particular, other
administrators' and the owner's counts are hidden from club administrators, as
are staff dossiers without a shared managed club. Ordinary staff and members
cannot query `staffSpace`.

```graphql
query TeamDocumentStatus($spaceId: ID!) {
  staffSpace(spaceId: $spaceId) {
    members {
      id
      name
      expiredDocumentCount
    }
  }
}
```

The team screen filters the returned members locally by a positive count; this
adds no server filter, sort or pagination argument. Invitations have no dossier
and are excluded from the expired-documents filter. Counts refresh after document
changes. The document list keeps its categories and displays expired documents
first within each category, then sorts by expiry date with undated files last.
The private preview uses the authenticated file download endpoint and releases
its temporary browser URL when closed. It grants no public or member access.

### Approaching document deadlines [#approaching-document-deadlines]

Staff members also expose nullable `expiringDocumentCount`, using the same
dossier permissions and team-space scope as `expiredDocumentCount`.
It counts expiry dates from today through today plus 30 calendar days, inclusive,
in Europe/Paris. Expired and undated documents are excluded. Unauthorized counts
are null. The team directory offers an “Expires within 30 days” client-side filter;
there are no automatic reminders or planning restrictions.

### Dossier consultation details [#dossier-consultation-details]

`StaffMember.nextDocumentExpiry` returns the earliest expiry date on or after
today (Europe/Paris), including dates beyond 30 days. It is null when there is no
upcoming expiry or the viewer cannot access the dossier; the same space and role
checks as the document counts apply.

`StaffDocument.addedOn` is the first upload's date in Europe/Paris. It is assigned
by the server and preserved by metadata edits and file replacements. Earlier
documents without a recorded upload date return null.

The dossier's search filters titles and issuing organizations locally, ignoring
case and accents, while retaining category grouping and expiry ordering. Search
does not change GraphQL arguments or pagination.

## Login attempt limits [#login-attempt-limits]

Member and staff password logins consume a shared PostgreSQL quota before looking
up credentials or verifying the password. Each normalized account has 10 attempts
per 15-minute window. Member accounts are scoped to their club; staff accounts
have a separate scope. Successful logins also count and do not reset the window.

Each network peer has an additional quota of 100 attempts per minute across all
accounts and both login operations. The peer address comes from the server's
connection, never from `X-Real-IP` or GraphQL arguments. When that connection comes from
a loopback address (`127.0.0.1`, `::1` or `::ffff:127.0.0.1`), which in production is
the Caddy proxy running in the same container, the first valid IP address of
`X-Forwarded-For` is used instead, so each client keeps its own quota. The header is
ignored from any other peer and when it holds no valid address. Behind another proxy
(such as the development Caddy container), its users share the peer quota. Requests
without transport address information share an `unknown` bucket.

Windows begin on the first accepted attempt and expire using the database clock.
Concurrent requests and multiple API instances share atomic counters. Rejected
attempts do not extend the expiry. Expired rows are reclaimed after one day during
later login requests; account and peer identifiers are stored as SHA-256 digests.
Quota exhaustion returns the same `Invalid email or password.` response as invalid
credentials. It never triggers password verification or creates a session.
Passwords over 200 characters are rejected before verification as well.

## Member-visible coach profiles [#member-visible-coach-profiles]

`staffViewer` and `staffSpace.members` include nullable `bio` and `avatar` fields.
Staff can edit their own profile; a space owner or an administrator can edit a
staff member they manage in that space. Profile fields belong to the global staff
account and are shared across its clubs.

```graphql
mutation UpdateCoachProfile($bio: String, $avatar: String, $spaceId: ID, $userId: ID) {
  updateStaffProfile(bio: $bio, avatar: $avatar, spaceId: $spaceId, userId: $userId)
}
```

Omit `userId` to edit yourself. Editing another user requires their `userId` and
`spaceId`; the server checks team membership, administrative scope and shared club
access. Only the space owner can edit another administrator. The mutation returns
`true` on success. It replaces both profile fields: omitted, null or empty fields
clear the existing value. Biographies are trimmed and limited to 4,000 characters.
Photos use PNG or JPEG data URLs, with a verified file signature and a decoded
size of at most 512,000 bytes, matching member profile photos.

Members see only the coach's `id`, `name`, `bio` and `avatar` through messages in
their own reservation conversations; see [reservations](/fr/graphql/reservations).

`StaffViewer.qualifications` and `StaffMember.qualifications` contain the diploma and professional-card metadata shown in the coach profile preview, scoped to the associated clubs. Original staff documents remain available only through the administrative dossier.
