Authentication
Member and staff sign in, passwords, person creation and account activation.
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 instead of signing in.
Member sign in
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),
or null without a member token. Guests whose access has ended cannot sign in; see
guests.
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:
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
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.
mutation AddPerson($input: CreatePersonInput!) {
createPerson(input: $input) {
id
firstName
lastName
email
}
}{
"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
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
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 have one global login and can access several clubs. Staff emails are stored trimmed and lowercased, so sign in is case-insensitive:
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
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
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.
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). 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
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.
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:
[
{
"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
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
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
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
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
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
Memberfollowed 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. OnPerson,PersonDetail,ApplicationandApplicationSummary,firstNameisMemberandlastNameis the code. Names without a linked person (walk-in or external customers, invitees) readGuest. - 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
noteorchanges; configuration and team history is unchanged. peoplePage,people,applicationsandinvoicesrefuse thesearch,minAge,maxAge,genderandattributesarguments, and the following operations are refused, all withThis 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
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.
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
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
With demo seeding enabled, all these accounts initially use password123:
| Name | Club access | Role | |
|---|---|---|---|
| admin@example.com | Demo Administrator | Ukab Clubs, Vosges, Astel | Space owner |
| ukab.staff@example.com | Sport coach | Ukab Clubs | Staff |
| vosges.staff@example.com | Alex Bernard | Vosges | Admin |
| multi.staff@example.com | Louise Moreau | Vosges, Astel | Admin |
| tech.staff@example.com | Technical support | Ukab Clubs, Vosges, Astel | Admin |
| chloe.dubois@example.com | Chloé Dubois | Ukab Clubs | Staff |
| ines.laurent@example.com | Inès Laurent | Ukab Clubs | Staff |
| manon.petit@example.com | Manon Petit | Ukab Clubs | Staff |
| lea.garnier@example.com | Léa Garnier | Ukab Clubs | Staff |
tech.staff@example.com is read only with personal data hidden (see 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.
Camille is assigned to the seeded Ukab reservation categories and therefore lands in Planning.
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.
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
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.
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.
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: multipartfileandmetadata(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 withCache-Control: private, no-storeandX-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
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.
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
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
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
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
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.
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.
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.