Internal Documentation internal
TalkIDE internal documentation

Founder-gated invite cascade — founder manuálně generuje tokenové pozvánky, uživatelé je redeemují při registraci, BE atomicky validuje token, vytváří uživatele (včetně user_budget row), dekrementuje kvótu issuera a přiřazuje nového uživatele do generace kaskády.

  • Systém vychází z ADR-020 Rozhodnutí 6 — invites NEJSOU udělované automaticky. Founder (Mirek) rozhoduje ručně přes admin tool, kdy a komu udělit invite tokeny.
  • Invite link má formát https://talkide.app/join?token=<UUID-v4>. Founder-direct tokeny mají issued_by_user_id = NULL.
  • Token expiruje za 30 dní od vydání (výchozí hodnota). Tokeny s status != PENDING nelze redeemovat.
  • Každý uživatel může mít max 5 nevybraných (PENDING) tokenů současně — anti-abuse pravidlo. Viz specification/invite-system.md sekce 5.1.
  • Při claimu BE ověří email invitee proti blocklist burnerových domén (10minutemail, mailinator atd.). Viz spec sekce 5.2.
  • IP rate limit: max 3 invite claims z jedné IP adresy za 24 hodin. Viz spec sekce 5.3.
  • Invite generation nového uživatele = issuer.invite_generation + 1. Founder-direct tokeny (issued_by_user_id = NULL) → generation 0.
  • KRITICKY: Signup přes invite MUSÍ vytvořit user_budget row — stejně jako standardní registrace. Viz Implementation Notes níže.
  • Všechna pole v API requestech/responsech jsou snake_case (konvence dle UC-08002).
  • Detailní DB schéma (tabulky invites, user_invite_quota, invite_grants, rozšíření users) viz specification/invite-system.md.

Flow 1 — Founder generuje invite tokeny

sequenceDiagram
    actor Founder

    Founder->>+FE: vyplní počet tokenů (a volitelně issued_to_email)

    FE->>FE: validate form
    alt form is invalid
        FE-->>Founder: show error messages
    end

    Founder->>+FE: potvrdí generování

    FE->>+BE: POST /api/v1/admin/invites <br> Authorization: Bearer {accessToken} <br> GenerateInvitesRequest

    BE->>BE: ověř roli ADMIN
    alt uživatel není ADMIN
        BE-->>FE: 403 Forbidden <br> ErrorResponse
    end

    BE->>BE: validate request
    alt request is invalid
        BE-->>FE: 400 Bad Request <br> ErrorResponse
    end

    loop count krát
        BE->>DB: INSERT invites <br> (token=UUID-v4, issued_by_user_id=NULL, status=PENDING, expires_at=now+30d)
    end

    BE->>-FE: 201 Created <br> GeneratedInvitesResponse

    FE->>-Founder: zobraz vygenerované tokeny / invite linky

POST /api/v1/admin/invites GenerateInvitesRequest:

{
  "count": 5,
  "issued_to_email": null,
  "expires_in_days": 30
}

201 Created GeneratedInvitesResponse:

{
  "tokens": [
    {
      "token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "invite_url": "https://talkide.app/join?token=a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "expires_at": "2026-06-07T10:00:00Z"
    }
  ]
}

400 Bad Request (validation) ErrorResponse:

{
  "code": "VALIDATION",
  "message": "Bad request"
}

403 Forbidden ErrorResponse:

{
  "code": "FORBIDDEN",
  "message": "Insufficient role"
}

sequenceDiagram
    actor User

    User->>+FE: otevře invite link <br> https://talkide.app/join?token={UUID}

    FE->>+BE: GET /api/v1/invites/{token}/preview <br> (public endpoint — bez autentizace)

    BE->>DB: SELECT invite WHERE token = ?

    alt token neexistuje
        BE-->>FE: 404 Not Found <br> ErrorResponse
        FE-->>User: stránka "Pozvánka neexistuje"
    end

    alt token CLAIMED nebo REVOKED
        BE-->>FE: 410 Gone <br> ErrorResponse
        FE-->>User: stránka "Pozvánka již byla použita"
    end

    alt token EXPIRED (status = EXPIRED nebo expires_at < now)
        BE-->>FE: 422 Unprocessable Entity <br> ErrorResponse
        FE-->>User: stránka "Pozvánka vypršela"
    end

    BE->>-FE: 200 OK <br> InvitePreviewResponse

    FE->>-User: zobraz signup formulář <br> (email prefill pokud issued_to_email != null)

GET /api/v1/invites/{token}/preview — public endpoint, bez Authorization headeru.

200 OK InvitePreviewResponse:

{
  "valid": true,
  "issued_to_email": "jan@example.com",
  "expires_at": "2026-06-07T10:00:00Z"
}

404 Not Found ErrorResponse:

{
  "code": "NOT_FOUND",
  "message": "Invite token not found"
}

410 Gone ErrorResponse:

{
  "code": "INVITE_ALREADY_CLAIMED",
  "message": "This invite has already been used"
}

422 Unprocessable Entity ErrorResponse:

{
  "code": "INVITE_EXPIRED",
  "message": "This invite has expired"
}

Flow 3 — Uživatel se zaregistruje přes invite

sequenceDiagram
    actor User

    User->>+FE: vyplní email + heslo v signup formuláři

    FE->>FE: validate form
    alt form is invalid
        FE-->>User: show error messages
    end

    User->>+FE: odešle signup formulář

    FE->>+BE: POST /api/v1/auth/signup-with-invite <br> SignupWithInviteRequest

    BE->>BE: validate request
    alt request is invalid
        BE-->>FE: 400 Bad Request <br> ErrorResponse
    end

    BE->>DB: SELECT invite WHERE token = ? FOR UPDATE <br> (SERIALIZABLE transakce)

    alt token neplatný / expirovaný / claimed
        BE-->>FE: 422 Unprocessable Entity <br> ErrorResponse
    end

    BE->>BE: ověř email není burnerová doména
    alt burner email
        BE-->>FE: 422 Unprocessable Entity <br> ErrorResponse
    end

    BE->>BE: zkontroluj IP rate limit (max 3 claims / 24h)
    alt IP rate limit překročen
        BE-->>FE: 429 Too Many Requests <br> ErrorResponse
    end

    BE->>DB: check email uniqueness
    alt email již existuje
        BE-->>FE: 409 Conflict <br> ErrorResponse
    end

    BE->>DB: INSERT users <br> (email, password_hash, invite_generation)
    BE->>DB: INSERT user_budget <br> (user_id — stejně jako RegisterUserUseCase)
    BE->>DB: INSERT user_invite_quota <br> (user_id, invites_total=0, invites_used=0)
    BE->>DB: UPDATE invites SET status=CLAIMED, claimed_by_user_id, claimed_at <br> WHERE token = ?
    BE->>DB: UPDATE user_invite_quota SET invites_used = invites_used + 1 <br> WHERE user_id = issuer_id (pokud issued_by != NULL)

    BE->>BE: vygeneruj JWT access token + refresh token

    BE->>-FE: 201 Created <br> AuthResponse

    FE->>FE: ulož tokeny (localStorage)
    FE->>-User: přesměruj na workspace / onboarding

POST /api/v1/auth/signup-with-invite SignupWithInviteRequest:

{
  "invite_token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "email": "jan@example.com",
  "password": "secretPassword123"
}

201 Created AuthResponse:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
  "user": {
    "id": 42,
    "email": "jan@example.com",
    "invite_generation": 0
  }
}

400 Bad Request (validation) ErrorResponse:

{
  "code": "VALIDATION",
  "message": "Bad request"
}

409 Conflict ErrorResponse:

{
  "code": "CONFLICT_USER",
  "message": "User with this email already exists"
}

422 Unprocessable Entity (token neplatný) ErrorResponse:

{
  "code": "INVITE_EXPIRED",
  "message": "Invite token is expired, already used, or revoked"
}

422 Unprocessable Entity (burner email) ErrorResponse:

{
  "code": "INVITE_BURNER_EMAIL",
  "message": "Registration with disposable email addresses is not allowed"
}

429 Too Many Requests (IP rate limit) ErrorResponse:

{
  "code": "INVITE_IP_RATE_LIMIT",
  "message": "Too many signup attempts from this IP address. Try again later."
}

Flow 4 — Uživatel vidí svůj invite quota

sequenceDiagram
    actor User

    User->>+FE: otevře sekci "Invite friends" v profilu / dashboardu

    FE->>+BE: GET /api/v1/me/invites <br> Authorization: Bearer {accessToken}

    BE->>DB: SELECT user_invite_quota WHERE user_id = ?
    BE->>DB: SELECT invites WHERE issued_by_user_id = ? ORDER BY issued_at DESC

    BE->>-FE: 200 OK <br> MyInvitesResponse

    FE->>-User: zobraz zbývající invite počet + seznam vydaných tokenů

GET /api/v1/me/invites — vyžaduje platný Bearer token.

200 OK MyInvitesResponse:

{
  "invites_total": 3,
  "invites_used": 1,
  "invites_remaining": 2,
  "issued_invites": [
    {
      "token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "invite_url": "https://talkide.app/join?token=a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "issued_to_email": null,
      "status": "CLAIMED",
      "issued_at": "2026-05-01T09:00:00Z",
      "expires_at": "2026-05-31T09:00:00Z",
      "claimed_at": "2026-05-05T14:30:00Z"
    },
    {
      "token": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
      "invite_url": "https://talkide.app/join?token=b2c3d4e5-f6a7-8901-bcde-f12345678901",
      "issued_to_email": "petra@example.com",
      "status": "PENDING",
      "issued_at": "2026-05-08T10:00:00Z",
      "expires_at": "2026-06-07T10:00:00Z",
      "claimed_at": null
    }
  ]
}

401 Unauthorized ErrorResponse:

{
  "code": "UNAUTHORIZED",
  "message": "Missing or invalid authentication token"
}

Flow 5 — Uživatel vytvoří invite token (regulérní user s kvótou)

sequenceDiagram
    actor User

    User->>+FE: klikne "Vytvořit pozvánku" (+ volitelně zadá email)

    FE->>FE: validate form
    alt form is invalid
        FE-->>User: show error messages
    end

    User->>+FE: potvrdí

    FE->>+BE: POST /api/v1/me/invites <br> Authorization: Bearer {accessToken} <br> CreateInviteRequest

    BE->>BE: validate request

    BE->>DB: SELECT user_invite_quota WHERE user_id = ?
    alt invites_remaining = 0
        BE-->>FE: 422 Unprocessable Entity <br> ErrorResponse (INVITE_QUOTA_EXHAUSTED)
    end

    BE->>DB: COUNT pending invites WHERE issued_by_user_id = ? AND status = PENDING
    alt pending count >= 5
        BE-->>FE: 422 Unprocessable Entity <br> ErrorResponse (INVITE_PENDING_LIMIT)
    end

    BE->>DB: INSERT invites <br> (token=UUID-v4, issued_by_user_id=current_user, status=PENDING, expires_at=now+30d)

    BE->>-FE: 201 Created <br> CreatedInviteResponse

    FE->>-User: zobraz invite link pro sdílení

POST /api/v1/me/invites CreateInviteRequest:

{
  "issued_to_email": "petra@example.com"
}

201 Created CreatedInviteResponse:

{
  "token": "c3d4e5f6-a7b8-9012-cdef-123456789012",
  "invite_url": "https://talkide.app/join?token=c3d4e5f6-a7b8-9012-cdef-123456789012",
  "issued_to_email": "petra@example.com",
  "expires_at": "2026-06-07T10:00:00Z"
}

422 Unprocessable Entity (quota vyčerpána) ErrorResponse:

{
  "code": "INVITE_QUOTA_EXHAUSTED",
  "message": "You have no invite tokens remaining"
}

422 Unprocessable Entity (pending limit) ErrorResponse:

{
  "code": "INVITE_PENDING_LIMIT",
  "message": "You have reached the maximum number of pending invites (5)"
}

Flow 6 — Admin udělí uživateli invite kvótu (Grant)

sequenceDiagram
    actor Admin

    Admin->>+FE: v admin pohledu sekce "Invites" <br> zadá userId, count, reason a klikne "Grant"

    FE->>FE: validate form
    alt form is invalid
        FE-->>Admin: show error messages
    end

    Admin->>+FE: potvrdí akci

    FE->>+BE: POST /api/v1/admin/users/{userId}/invite-grants <br> Authorization: Bearer {token} <br> InviteGrantRequest

    BE->>BE: ověř roli ADMIN
    alt uživatel není ADMIN
        BE-->>FE: 403 Forbidden <br> ErrorResponse
    end

    BE->>BE: validate request
    alt request is invalid
        BE-->>FE: 400 Bad Request <br> ErrorResponse
    end

    BE->>DB: SELECT users WHERE id = userId
    alt uživatel neexistuje
        BE-->>FE: 404 Not Found <br> ErrorResponse
    end

    BE->>DB: INSERT invite_grants <br> (user_id, granted_by_admin_id, count, reason, granted_at)
    BE->>DB: INSERT user_invite_quota (user_id, invites_total=count) <br> ON CONFLICT (user_id) DO UPDATE <br> SET invites_total = invites_total + count, <br>     last_grant_at = now()

    BE->>-FE: 201 Created <br> InviteGrantResponse

    FE->>-Admin: aktualizuj zobrazení kvóty uživatele, <br> toast "Grant uložen"

POST /api/v1/admin/users/{userId}/invite-grants InviteGrantRequest:

{
  "count": 5,
  "reason": "Early alpha tester — trusted referrer"
}

201 Created InviteGrantResponse:

{
  "data": {
    "grant_id": 12,
    "user_id": 42,
    "granted_by_admin_id": 1,
    "count": 5,
    "reason": "Early alpha tester — trusted referrer",
    "granted_at": "2026-05-17T11:00:00Z",
    "quota_after": {
      "invites_total": 8,
      "invites_used": 3,
      "invites_remaining": 5
    }
  }
}

400 Bad Request (validation) ErrorResponse:

{
  "code": "VALIDATION",
  "message": "Bad request"
}

403 Forbidden ErrorResponse:

{
  "code": "FORBIDDEN",
  "message": "Insufficient role"
}

404 Not Found ErrorResponse:

{
  "code": "NOT_FOUND",
  "message": "User not found"
}

Transakčnost grantu

INSERT invite_grants + UPDATE user_invite_quota běží v jediné transakci (@Transactional). user_invite_quota row může neexistovat pro uživatele, kteří ještě žádný grant nedostali — použít INSERT ... ON CONFLICT DO UPDATE (upsert).


DB Schema

Nové tabulky zaváděné tímto UC (Liquibase migrace, pre-production drop-first režim):

CREATE TABLE invites (
  id                 BIGSERIAL PRIMARY KEY,
  token              VARCHAR(64) UNIQUE NOT NULL,
  issued_by_user_id  BIGINT REFERENCES users(id),
  issued_to_email    VARCHAR(255),
  claimed_by_user_id BIGINT REFERENCES users(id),
  issued_at          TIMESTAMPTZ NOT NULL,
  expires_at         TIMESTAMPTZ NOT NULL,
  claimed_at         TIMESTAMPTZ,
  revoked_at         TIMESTAMPTZ,
  revoked_reason     TEXT,
  status             VARCHAR(20) NOT NULL
);

CREATE INDEX idx_invites_token ON invites (token);
CREATE INDEX idx_invites_status ON invites (status);
CREATE INDEX idx_invites_issued_by ON invites (issued_by_user_id);

CREATE TABLE user_invite_quota (
  user_id         BIGINT PRIMARY KEY REFERENCES users(id),
  invites_total   INT NOT NULL DEFAULT 0,
  invites_used    INT NOT NULL DEFAULT 0,
  last_grant_at   TIMESTAMPTZ
);

CREATE TABLE invite_grants (
  id                  BIGSERIAL PRIMARY KEY,
  user_id             BIGINT NOT NULL REFERENCES users(id),
  granted_by_admin_id BIGINT NOT NULL REFERENCES users(id),
  count               INT NOT NULL,
  reason              TEXT,
  granted_at          TIMESTAMPTZ NOT NULL
);

ALTER TABLE users
  ADD COLUMN invite_generation INT NOT NULL DEFAULT 0;

issued_by_user_id = NULL identifikuje founder-direct tokeny (Fáze 1 seeding). Nový uživatel z founder-direct tokenu dostane invite_generation = 0. Detailní popis kaskády viz specification/invite-system.md.


Implementation Notes

user_budget row při signup s invite

Commit e541155 fix(signup): vytvoř user_budget row při registraci zavedl povinné vytvoření user_budget row v RegisterUserUseCase. Invite signup MUSÍ zachovat tuto invariantu. Doporučené varianty:

(a) Preferovaná: SignupWithInviteUseCase deleguje vytvoření uživatele na existující RegisterUserUseCase (nebo sdílenou internal metodu UserCreationService.createUser()), která user_budget row vytváří. Invite-specifická logika (validace tokenu, update invites, update user_invite_quota) se děje před/po tomto volání ve stejné transakci.

(b) Alternativní: SignupWithInviteUseCase si vytváří uživatele vlastní cestou — pak explicitně musí obsahovat krok INSERT user_budget. Riziko: divergence s RegisterUserUseCase při budoucích změnách uživatelského modelu.

BE developer si zvolí variantu (a) nebo (b); varianta (a) je preferovaná kvůli DRY. Invarianta user_budget existuje pro každého uživatele musí být zachována — žádný user bez user_budget row.

Transakční atomicita při claimu

Celý krok 4 (validace tokenu + create user + create user_budget + update invite status + decrement issuer quota) probíhá v jediné SERIALIZABLE transakci. Token se načítá přes SELECT ... FOR UPDATE — zabraňuje race condition při paralelním claimu téhož tokenu.

Email odeslání po vystavení tokenu

Decision (2026-05-08, BE implementace): BE NEPOSÍLÁ email po POST /admin/invites ani POST /me/invites. issued_to_email se ukládá pouze pro audit a pro prefill na public preview stránce. Žádná SMTP/mail dependency v alpha.

Founder/uživatel sdílí invite link manuálně (Slack, Discord, e-mail, pin na lednici). Emailové notifikace se mohou přidat post-alpha jako opt-in featura — v té chvíli nutno zavést SMTP konfiguraci a templaty (mimo scope UC-08003).

Endpoint auth levels

EndpointAuth
GET /api/v1/invites/{token}/previewPublic (bez tokenu)
POST /api/v1/auth/signup-with-invitePublic (bez tokenu)
GET /api/v1/me/invitesBearer JWT (authenticated user)
POST /api/v1/me/invitesBearer JWT (authenticated user)
POST /api/v1/admin/invitesBearer JWT + role ADMIN

UX Guidelines

Flow 1 — Admin generuje invite tokeny

User flow

  1. Admin (Mirek) otevře /profile?section=invites → zobrazí se sekce “Invites” v levém sidebaru Profile screenu.
  2. Stránka načte seznam všech vydaných tokenů (GET /api/v1/admin/invites — budoucí endpoint nebo filtrovaný pohled přes existující data).
  3. Admin vidí tabulku tokenů + tlačítko “Create invite” vpravo nahoře v content oblasti.
  4. Admin klikne “Create invite” → otevře se inline formulář (ne modal) pod tabulkou nebo jako slide-in panel.
  5. Vyplní volitelný issued_to_email a count (1–50) + expires_in_days (default 30).
  6. Klikne “Generate” → tlačítko přejde do loading stavu (SpinLoader + text “Generating…”).
  7. Po 201 Created: formulář se skryje, tabulka se aktualizuje, zobrazí se toast “N invite(s) created” (var(--green-soft) border).
  8. Každý nový token má inline “Copy link” tlačítko (clipboard icon, Lucide Copy) — po kliknutí se ikonka přemění na Check na 2 s, toast “Link copied”.

Layout

/profile?section=invites
┌─────────────────────────────────────────────────────────────────┐
│ TTopBar: [← TalkIDE | Účet]              [Concierge] [theme]   │
├────────────────┬────────────────────────────────────────────────┤
│  NASTAVENÍ     │  INVITES                                       │
│  Profil        │  Admin invite management                       │
│  Účet          │                                                │
│  Zvuky         │  [Total: 42 issued]       [Create invite  +]  │
│  Fakturace     │  ┌─────────────────────────────────────────┐   │
│  Oznámení      │  │ Token    To email  Status  Expires  [ ]│   │
│  Tým           │  │ a1b2c3.. jan@..   CLAIMED  31.5.26  [⎘]│   │
│  Propojené     │  │ b2c3d4.. —        PENDING  7.6.26   [⎘]│   │
│  Invites  ←new │  │ …                                       │   │
│  Nebezpečná    │  └─────────────────────────────────────────┘   │
│                │                                                │
│                │  [Create invite form — inline, collapsible]    │
└────────────────┴────────────────────────────────────────────────┘

Sekce “Invites” je viditelná pouze pro uživatele s rolí ADMIN — podmíněné renderování dle authStore.user.role === 'ADMIN'. Pro běžné uživatele tato položka v sidebaru neexistuje (viz Flow 4 níže, kde mají vlastní sekci “Pozvánky”).


User flow

  1. Příjemce klikne na https://talkide.app/join?token=<UUID>.
  2. FE router zachytí /join → vykreslí JoinScreen (standalone, bez TTopBar, bez sidebaru).
  3. Při mount: FE validuje UUID formát z query param token; pokud chybí nebo neodpovídá [0-9a-f-]{36}, okamžitě zobrazí error state “Invalid invite link” bez volání BE.
  4. Pokud token formálně OK: FE zavolá GET /api/v1/invites/{token}/preview.
  5. Zobrazí skeleton loader (3 šedé bloky, výška odpovídá obsahu karty) po dobu čekání.
  6. Po odpovědi BE:
    • 200 OK: zobrazí “You’ve been invited” kartu s jménem issuera, CTA tlačítkem → přechod na signup formulář.
    • 404: error state “This invite link doesn’t exist.”
    • 410: error state “This invite has already been used.”
    • 422: error state “This invite has expired on {expires_at formatted}.”
  7. CTA “Create your account →” scrolluje dolů na signup formulář na téže stránce (single-page layout), nebo přesměruje na /sign-up?token=<UUID> (doporučeno pro SSR-friendly URL).

Layout — standalone landing page

https://talkide.app/join?token=...
┌─────────────────────────────────────────────────────────────────┐
│  [TalkIDE logo, centered, 20px]              [theme toggle]    │
│                                                                 │
│  ┌──────────────────────────────────────────────────────┐      │
│  │  (AuthBackdrop animated blobs — stejné jako /login)  │      │
│  │                                                       │      │
│  │  [TAvatar: issuer initials, 48px, --amber accent]    │      │
│  │  "Miroslav P. invited you to TalkIDE"                │      │
│  │  subtitle: "Join the waitlist. Free to start."       │      │
│  │                                                       │      │
│  │  ┌─────────────────────────────────────────────────┐ │      │
│  │  │  Email (pre-filled if issued_to_email != null)  │ │      │
│  │  │  Password                                        │ │      │
│  │  │  [hidden: invite_token]                          │ │      │
│  │  │  [Create your account →]  ButtonPrimary          │ │      │
│  │  └─────────────────────────────────────────────────┘ │      │
│  │                                                       │      │
│  │  Expires: 7. 6. 2026  ·  TalkIDE  ·  Terms  Privacy  │      │
│  └──────────────────────────────────────────────────────┘      │
└─────────────────────────────────────────────────────────────────┘

Stránka používá AuthShell komponentu s upraveným title/subtitle. Issuer jméno pochází z budoucího rozšíření InvitePreviewResponse (pole issued_by_name: string | null); pokud null, zobrazí se “Someone invited you to TalkIDE”.

Error states sdílejí stejnou kartu, jen content se mění — žádné přesměrování na 404 stránku:

┌──────────────────────────────────────┐
│  [AlertTriangle --rose, 32px]        │
│  "This invite has expired"           │
│  "This link was valid until          │
│   7 June 2026."                      │
│                                      │
│  [← Back to TalkIDE]  ButtonPrimary  │
└──────────────────────────────────────┘

Flow 3 — Signup s invite tokenem

User flow

  1. Příjemce je na /join?token=... (po úspěšném preview) nebo na /sign-up?token=....
  2. invite_token je skrytý input — předvyplněný z URL query param, uživatel ho nevidí ani neediuje.
  3. Email je předvyplněný pokud issued_to_email != null a pole je editovatelné (některé invity jsou personalizované, ale uživatel může zadat jiný email — BE nevaliduje shodu).
  4. Validace polí on blur: Email (EmailInput), Password (PasswordInput, min 8 znaků).
  5. Uživatel klikne “Create your account →” → ButtonPrimary loading state (“Creating account…”).
  6. Po 201 Created: uložení JWT, animate-slide-out animace na kartě, redirect na /studio (nebo /onboarding pokud existuje).
  7. Po chybě: animateShake na kartě (AuthShell prop), chybová zpráva pod formulářem (var(—rose)).

Chybové zprávy

BE kódText zobrazený uživateli (EN / CS)
INVITE_EXPIRED”This invite is no longer valid.” / “Tato pozvánka již není platná.”
INVITE_BURNER_EMAIL”Disposable email addresses aren’t allowed.” / “Jednorázové e-maily nejsou povoleny.”
INVITE_IP_RATE_LIMIT”Too many signups from your network. Try again later.” / “Příliš mnoho registrací z tvé sítě. Zkus to za chvíli.”
CONFLICT_USER”An account with this email already exists.” / “Účet s tímto e-mailem již existuje.”
VALIDATION”Please check your input.” / “Zkontroluj zadané údaje.”

Flow 4 — Uživatel vidí svůj invite quota

User flow

  1. Přihlášený uživatel otevře /profile?section=invites (nová sidebar položka “Pozvánky” — viditelná pro všechny autentizované uživatele, ne jen adminy).
  2. FE zavolá GET /api/v1/me/invites při mount.
  3. Skeleton loader (2 řádky stat karet + tabulka).
  4. Po 200 OK: zobrazí se quota badge + tabulka tokenů.

Layout — sekce v Profile screenu

/profile?section=invites
┌──────────────────────────────────────────────────────────────┐
│  POZVÁNKY                                                     │
│  Pozvi přátele do TalkIDE                                    │
│                                                              │
│  ┌──────────────────┐  ┌──────────────────┐                 │
│  │  CELKEM          │  │  ZBÝVÁ           │                 │
│  │  3               │  │  2               │                 │
│  │  pozvánek        │  │  k použití       │                 │
│  └──────────────────┘  └──────────────────┘                 │
│                                                              │
│  [Vytvořit pozvánku +]  (disabled + tooltip když quota = 0) │
│                                                              │
│  Vystavené pozvánky                                          │
│  ┌──────────────────────────────────────────────────────┐   │
│  │ Komu           Status     Vystaveno    Vyprší    [⎘] │   │
│  │ jan@example    CLAIMED    1. 5. 2026   —         [✓] │   │
│  │ petra@example  PENDING    8. 5. 2026   7. 6. 26  [⎘] │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│  [empty state / quota 0 state — viz níže]                   │
└──────────────────────────────────────────────────────────────┘

Sidebar položka “Pozvánky” (ikona UserPlus z lucide-vue-next) se vloží mezi “Tým” a “Propojené účty”. Pro uživatele s rolí ADMIN se tato sekce stane admin verzí (tabulka všech tokenů + Create invite form).


Flow 5 — Uživatel vytváří invite token

User flow

  1. Uživatel je na /profile?section=invites a vidí invites_remaining > 0.
  2. Klikne “Vytvořit pozvánku +” → pod tabulkou se expanduje inline formulář (transition fade-up).
  3. Volitelně vyplní issued_to_email (EmailInput, placeholder “jan@example.com — optional”).
  4. Klikne “Vytvořit” → ButtonPrimary loading state.
  5. Po 201 Created: formulář se skryje, nový token se přidá na začátek tabulky (prepend), quota badge se aktualizuje. Toast “Invite link created” (green).
  6. Clipboard tlačítko u nového tokenu je dočasně zvýrazněné (amber outline, 3 s) — signál “zkopíruj a odešli”.

Komponenty

ElementKomponentaPoznámky
Invite sekce v sidebaruProfileScreen.vue SECTIONS arrayPřidat { id: 'invites', label: t('profile.nav.invites'), icon: UserPlus }
Quota stat kartaStatBlock (ze studio/components/)Reuse, props: label, value, unit
Invite list tabulkaInviteListTable (nová)Sloupce: issued_to_email, status pill, issued_at, expires_at, clipboard btn
Status pillTPillVarianty: green (CLAIMED), amber (PENDING), rose (EXPIRED/REVOKED)
Clipboard tlačítkoTIconBtn + Lucide Copy/CheckTransition na Check po kliknutí, 2 s reset
Generate invite formulářCreateInviteForm (nová)EmailInput + ButtonPrimary, inline collapsible
Token input (skrytý)<input type="hidden">v signup formuláři, hodnota z URL query param
Public preview kartaJoinScreen (nová, standalone route)Reuse AuthShell s custom title/subtitle
Error state kartaJoinErrorState (nová, v JoinScreen)AlertTriangle + text + back button
Invite quota badge v TopBarVolitelné (post-MVP)Např. číslo za ikonkou UserPlus v TTopBar
Toast notifikaceuseToast composableExistující infrastruktura
Skeleton loaderinline bg-[var(--bg-3)] animate-pulse blokyKonzistentní s ostatními sekcemi

Validation Feedback

Flow 1 & 5 — formulář vytvoření invite

  • issued_to_email: validace on blur, EmailInput s pattern rule → červený border + inline chyba pod polem.
  • count (admin): NumberInput, min=1 max=50, validace on blur.
  • Form-level error banner (var(—rose-soft) bg, var(—rose-line) border) nad submit tlačítkem pro BE chyby:
BE kódZpráva (EN / CS)
INVITE_PENDING_LIMIT”You have {n} pending invites. Use or revoke them first.” / “Máš {n} čekajících pozvánek. Nejdříve je použij nebo zruš.”
INVITE_QUOTA_EXHAUSTED”You have no invites remaining.” / “Nemáš žádné pozvánky k vystavení.”
VALIDATION”Please check your input.” / “Zkontroluj zadané údaje.”

Flow 3 — signup s invite

  • Field-level validace viz existující SignupForm pattern (on blur, v-model:error).
  • BE chyby mapovány na form-level error pod formulářem (var(—rose), font-size 13px) — stejný pattern jako v SignupForm.vue (formData.errors.form).
  • animateShake na AuthShell kartě při form-level chybě.

Flow 2 — preview error states

  • Chyby nejsou inline validace formuláře — jsou to celé “error screens” uvnitř karty (nahrazují obsah karty):
    • 404 → “This invite link doesn’t exist.” + back link.
    • 410 → “This invite has already been used.” + back link.
    • 422 → “This invite expired on {date}.” + back link.

Accessibility

  • Sekce “Invites” v sidebaru má aria-current="page" na aktivní položce (stejný pattern jako ostatní sekce v ProfileScreen).
  • Quota badge: <span aria-label="2 invites remaining">2</span> — screen reader čte celý label.
  • “Vytvořit pozvánku” tlačítko: aria-disabled="true" + title="No invites remaining" pokud invites_remaining === 0 (místo disabled atributu, aby tooltip fungoval).
  • Clipboard tlačítko: aria-label="Copy invite link for petra@example.com" (dynamické dle issued_to_email nebo tokenu prefix).
  • Po kliknutí clipboard: aria-live="polite" region oznamuje “Link copied to clipboard”.
  • Public preview (JoinScreen): autofocus na Email field po načtení (nebo na první chybu pokud error state). CTA tlačítko “Create your account →” je primary focus trap target.
  • Tabulka tokenů: <table> s <th scope="col"> pro každý sloupec, role="status" na status pillu.
  • Error states: role="alert" na error kartě, aria-live="assertive".
  • Keyboard: Tab order v tabulce — clipboard tlačítko focusable, Enter/Space kopíruje.

Loading States

  • Quota sekce načítání: skeleton — 2 stat karty (bg-[var(--bg-3)] animate-pulse, výška 80px, šířka 160px) + tabulka (3 skeleton řádky).
  • Tlačítko “Vytvořit”: ButtonPrimary s loading=trueSpinLoader + “Vytvářím…”.
  • Public preview načítání: skeleton karta — oval pro avatar (48px), 2 text bloky, button placeholder.
  • Signup submit: tlačítko “Creating account…” + SpinLoader, formulář disabled.

Error States

StavCo user vidí
invites_remaining = 0, žádné vydané tokenyQuota badge “0 / {total}” + prázdná tabulka + text “You haven’t sent any invites yet.” + šedé disabled tlačítko “Vytvořit pozvánku” + podtext “Mirek grants invites manually — watch this space.” / “Mirek uděluje pozvánky ručně — sleduj novinky.”
invites_remaining > 0, žádné vydané tokenyQuota badge + prázdná tabulka + empty state “You haven’t sent any invites yet. Share TalkIDE with someone you trust.” / “Zatím jsi nikoho nepozval(a). Sdílej TalkIDE s někým, komu věříš.” + tlačítko “Send your first invite”
Všechny vydané tokeny CLAIMEDTabulka se zobrazí normálně. Doplňující text pod tabulkou: “All invites used — well done!” / “Všechny pozvánky využity — skvělá práce!” (var(—green), malé písmo)
Kvóta 0, bez udělené kvóty vůbec (invites_total = 0)Prázdná sekce s textem “Invite access isn’t enabled for your account yet.” / “Přístup k pozvánkám pro tvůj účet zatím není aktivován.” + podtext “Mirek grants invites manually — watch this space.” + odkaz na changelog/announcements
Network error při načítáníInline error banner s RefreshCw tlačítkem “Try again” — stejný pattern jako v ostatních sekcích Profilu

i18n — klíče

Všechny klíče patří do souborů src/screens/profile/i18n.ts (sekce v profilu) a src/screens/join/i18n.ts (public preview).

Profile sekce (profile.invites.*)

profile.nav.invites                   Pozvánky / Invites
profile.invites.kicker                POZVÁNKY / INVITES
profile.invites.title                 Pozvi přátele / Invite friends
profile.invites.subtitle              Sdílej TalkIDE s někým, komu věříš. / Share TalkIDE with someone you trust.
profile.invites.quota.total           Celkem / Total
profile.invites.quota.remaining       Zbývá / Remaining
profile.invites.quota.unit            pozvánek / invites
profile.invites.create.button         Vytvořit pozvánku / Create invite
profile.invites.create.loading        Vytvářím... / Creating...
profile.invites.create.emailLabel     Komu (volitelné) / For (optional)
profile.invites.create.emailPlaceholder  jan@example.com — volitelné / jan@example.com — optional
profile.invites.table.toEmail         Komu / To
profile.invites.table.status          Stav / Status
profile.invites.table.issuedAt        Vystaveno / Issued
profile.invites.table.expiresAt       Vyprší / Expires
profile.invites.table.copyAriaLabel   Zkopírovat odkaz pozvánky / Copy invite link
profile.invites.status.pending        Čeká / Pending
profile.invites.status.claimed        Použito / Claimed
profile.invites.status.expired        Vypršelo / Expired
profile.invites.status.revoked        Zrušeno / Revoked
profile.invites.empty.noInvites       Zatím jsi nikoho nepozval(a). / You haven't sent any invites yet.
profile.invites.empty.allClaimed      Všechny pozvánky využity — skvělá práce! / All invites used — well done!
profile.invites.empty.quotaZero       Přístup k pozvánkám zatím není aktivován. / Invite access isn't enabled yet.
profile.invites.empty.quotaZeroHint   Mirek uděluje pozvánky ručně — sleduj novinky. / Mirek grants invites manually — watch this space.
profile.invites.toast.created         Pozvánka vytvořena / Invite created
profile.invites.toast.copied          Odkaz zkopírován / Link copied
profile.invites.error.pendingLimit    Máš {n} čekajících pozvánek. Nejdříve je použij nebo zruš. / You have {n} pending invites. Use or revoke them first.
profile.invites.error.quotaExhausted  Nemáš žádné pozvánky k vystavení. / You have no invites remaining.

Public preview (join.*)

join.loading                          Načítám pozvánku... / Loading invite...
join.invited.title                    Pozval(a) tě na TalkIDE / invited you to TalkIDE
join.invited.titleFallback            Byl(a) jsi pozván(a) na TalkIDE / You've been invited to TalkIDE
join.invited.cta                      Vytvořit účet → / Create your account →
join.invited.expires                  Platná do {date} / Valid until {date}
join.error.notFound.title             Tato pozvánka neexistuje. / This invite link doesn't exist.
join.error.notFound.hint              Možná byl odkaz zkrácen nebo je neplatný. / The link may have been truncated or is invalid.
join.error.claimed.title              Tato pozvánka již byla použita. / This invite has already been used.
join.error.expired.title              Tato pozvánka vypršela. / This invite has expired.
join.error.expired.hint               Byla platná do {date}. / It was valid until {date}.
join.error.back                       ← Zpět na TalkIDE / ← Back to TalkIDE
join.signup.loadingTitle              Vytvářím účet... / Creating account...
join.signup.burnerEmail               Jednorázové e-maily nejsou povoleny. / Disposable email addresses aren't allowed.
join.signup.ipRateLimit               Příliš mnoho registrací z tvé sítě. Zkus to za chvíli. / Too many signups from your network. Try again later.
join.signup.inviteInvalid             Tato pozvánka již není platná. / This invite is no longer valid.
join.signup.emailExists               Účet s tímto e-mailem již existuje. / An account with this email already exists.

Avatar / branding na public preview

  • Horní část karty: logo TLogo (22px) centrovaně — stejně jako AuthShell.
  • Pod logem: TAvatar komponent (48px) zobrazující iniciály issuera (první písmeno jména), accent --amber (stejný jako existující TAvatar).
  • Text: “{issuer_name} invited you to TalkIDE”font-display, font-size 20px, font-weight 600.
  • Pokud issued_by_name = null (founder-direct token nebo BE nevrátí jméno): fallback “You’ve been invited to TalkIDE” bez avataru.
  • Subtitle: “Join the early access.” / “Připoj se k early access.” — var(—fg-3), font-size 13px.
  • Animated backdrop: AuthBackdrop komponent (stejné blobové animace jako na /login a /sign-up).
  • Footer karty: “Expires {date} · Terms · Privacy” — var(—fg-4), font-size 11px.

UX Guidelines — Flow 6 (Admin grant kvóty)

Umístění admin UI pro grant

Admin grant kvóty se umísťuje do existující admin sekce “Invites” v /profile?section=invites (viz UX Guidelines Flow 1 výše). Tato sekce je viditelná pouze pro role=ADMIN.

Rozšíření admin Invites sekce:

Pod tabulkou vydaných tokenů přidat subsekci “Udělení kvóty” s inline formulářem:

/profile?section=invites
┌──────────────────────────────────────────────────────────────────┐
│  INVITES — Admin                                                  │
│  [tabulka tokenů + Create invite form — stávající]               │
│                                                                   │
│  ─────────────────────────────────────────────────────           │
│  GRANT INVITE QUOTA                                              │
│  Udělí uživateli N pozvánek k vystavení.                         │
│                                                                   │
│  User ID: [____________________]                                 │
│  Count:   [__5__] (min 1, max 100)                               │
│  Reason:  [________________________________] (volitelné)         │
│                                                                   │
│  [Grant invites]                              [výsledek / error] │
└──────────────────────────────────────────────────────────────────┘

Zdůvodnění volby umístění: Alternativa — admin user list — neexistuje v projektu jako standalone stránka. InvitesSection.vue v admin pohledu je přirozené místo pro všechny operace kolem invite kvót. Grant formulář je interní nástroj (admin-only), nevyžaduje dedikovanou stránku.

Po úspěšném grantu: toast “Kvóta udělena: +{count} pozvánek pro user #{userId}.” (var(--green-soft) border). Formulář se vymaže.

Politika zůstává: Nový uživatel (signup s invite nebo invite ze waitlistu) dostane invites_total=0. Founder musí explicitně spustit Flow 6, aby mu kvótu přiřadil. Tato politika se nemění (ADR-020 Rozhodnutí 6).


Frontend

Validations

PoleConstraintsSizePatternNote
invite_token (URL param)not_blank36[0-9a-f-]{36} (UUID v4)Přečteno z URL query param token; FE přesměruje na error page pokud chybí nebo neodpovídá formátu
email (signup)not_blank, email5 - 255^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$Prefill z InvitePreviewResponse.issued_to_email pokud non-null; editovatelný
password (signup)not_blank8 - 100Min 8 znaků
count (admin generate)not_null, positive1 - 50Max 50 tokenů v jednom requestu
issued_to_email (create invite)email nebo prázdné0 / 5 - 255Volitelné; pokud vyplněno, musí být platný email
userId (admin grant — path param)positive integer^\d+$Číselné ID cílového uživatele
count (admin grant)not_null, positive1 - 100Počet pozvánek k udělení
reason (admin grant)optional0 - 500Volitelné zdůvodnění pro audit záznam

UX poznámky

  • Tlačítko “Vytvořit pozvánku” (Flow 5) zobrazit jako disabled pokud invites_remaining = 0.
  • Na stránce /join?token=... zobrazit loading state při volání /preview, pak buď error screen nebo signup formulář.
  • Po úspěšném signupu přesměrovat na onboarding nebo workspace (dle existence existujících projektů).

Backend

Validations

PoleConstraintsSizePatternNote
invite_tokennot_blank, UUID v4 format36[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}Validovat formát před DB dotazem
emailnot_blank, valid email5 - 255Lowercase normalizace před uložením
passwordnot_blank8 - 100BCrypt hash před uložením
count (admin generate)not_null, min=1, max=50
expires_in_daysnot_null, min=1, max=90Default 30 pokud není v requestu
Token status checkPENDING + not expiredAtomické ověření v SERIALIZABLE transakci při claimu
Burner emailEmail doména NOT IN blocklistBlocklist v application.yml, aktualizovatelný bez deploy
IP rate limitmax 3 claims / IP / 24hSpring Security filter nebo rate-limit middleware
Pending limitmax 5 PENDING invites na issueraCOUNT invites WHERE issued_by = ? AND status = PENDING
Issuer quotainvites_remaining > 0invites_remaining = invites_total - invites_used
invite_generationissuer.invite_generation + 1, nebo 0 pro founder-directNULL issued_by_user_id → generation 0
user_budgetVytvořit row pro každého nového uživateleViz Implementation Notes — invarianta nesmí být porušena
userId (admin grant)exists in users404 pokud neexistuje
count (admin grant)min=1, max=100400 VALIDATION pokud mimo rozsah
Grant transakceinvite_grants + user_invite_quota upsertAtomicky v jedné transakci; upsert pokud user_invite_quota row neexistuje

Test Cases

GIVENWHENTHEN
Founder (ADMIN role) je autentizovanýPOST /admin/invites s count=33 PENDING tokeny vytvořeny s issued_by_user_id=NULL, status=PENDING, expires_at = now + 30d; response obsahuje 3 invite URL
Uživatel bez role ADMIN je autentizovanýPOST /admin/invites s count=1403 Forbidden, code=FORBIDDEN
Platný PENDING token existujeGET /invites/{token}/preview200 OK, valid=true, issued_to_email vrácen (null pokud nebyl nastaven)
Token neexistuje v DBGET /invites/{token}/preview404 Not Found, code=NOT_FOUND
Token má status=CLAIMEDGET /invites/{token}/preview410 Gone, code=INVITE_ALREADY_CLAIMED
Token má expires_at v minulostiGET /invites/{token}/preview422 Unprocessable Entity, code=INVITE_EXPIRED
Platný PENDING token existuje, email je unikátníPOST /auth/signup-with-invite s validními daty201 Created; nový users záznam vytvořen; user_budget row vytvořen; user_invite_quota row vytvořen s invites_total=0; token status=CLAIMED; issuer invites_used inkrementován; JWT vrácen
Nový user má founder-direct token (issued_by_user_id=NULL)POST /auth/signup-with-inviteNový user dostane invite_generation=0; issuer quota se neinkrementuje (není issuer)
Nový user má token od Gen 0 usera (generation=0)POST /auth/signup-with-inviteNový user dostane invite_generation=1
Token je CLAIMED (již použitý)POST /auth/signup-with-invite422 Unprocessable Entity, code=INVITE_EXPIRED (generický error pro všechny neplatné stavy)
Email již existuje v users tabulcePOST /auth/signup-with-invite409 Conflict, code=CONFLICT_USER
Email je z burnerové domény (mailinator.com)POST /auth/signup-with-invite422 Unprocessable Entity, code=INVITE_BURNER_EMAIL
IP adresa odeslala 3 claimy za posledních 24hPOST /auth/signup-with-invite (4. pokus)429 Too Many Requests, code=INVITE_IP_RATE_LIMIT
Autentizovaný user s invites_remaining=2GET /me/invites200 OK; invites_total, invites_used, invites_remaining odpovídají DB stavu; issued_invites vrátí seznam tokenů seřazený issued_at DESC
Autentizovaný user s invites_remaining=1POST /me/invites201 Created; nový PENDING token vytvořen; issued_by_user_id = current_user_id
Autentizovaný user s invites_remaining=0POST /me/invites422 Unprocessable Entity, code=INVITE_QUOTA_EXHAUSTED
Autentizovaný user má 5 PENDING tokenůPOST /me/invites422 Unprocessable Entity, code=INVITE_PENDING_LIMIT i kdyby invites_remaining > 0
Dva paralelní requesty claimují tentýž token (race condition test)POST /auth/signup-with-invite × 2 současněPouze jeden claim uspěje (201); druhý dostane 422; token je CLAIMED právě jednou; user_budget row existuje právě jednou
Signup s platným tokenem proběhne úspěšněKontrola DB stavu po 201 Createduser_budget row existuje pro nového uživatele; row chybí → test failuje (invarianta z commitu e541155)
TBD — UC-08004: Globální capacity brake je aktivní (> 95 % monthly cap)POST /auth/signup-with-invite[TBD] Nové signupy mohou být zablokované; definuje UC-08004 Admin Capacity Console
Flow 6 — Admin grant kvóty: Admin (role ADMIN) je autentizovaný, cílový user existuje, count=5, reason="Alpha tester"POST /admin/users/{userId}/invite-grants201 Created; invite_grants záznam vytvořen s granted_by_admin_id=admin.id, count=5; user_invite_quota.invites_total navýšen o 5 (upsert); last_grant_at aktualizován; response obsahuje quota_after s aktuálními hodnotami
Flow 6: Admin, cílový user má již existující user_invite_quota s invites_total=3, invites_used=1POST /admin/users/{userId}/invite-grants s count=5201 Created; invites_total = 8, invites_used = 1, invites_remaining = 7
Flow 6: Admin, cílový user ID neexistujePOST /admin/users/{userId}/invite-grants404 Not Found, code=NOT_FOUND
Flow 6: Uživatel bez role ADMINPOST /admin/users/{userId}/invite-grants403 Forbidden, code=FORBIDDEN
Flow 6: count=0 nebo záporné čísloPOST /admin/users/{userId}/invite-grants400 Bad Request, code=VALIDATION
Flow 6: Grant audit trail — po úspěšném grantuKontrola DB stavuZáznam v invite_grants existuje s granted_by_admin_id, user_id, count, reason, granted_at; žádná data nejsou ztracena ani při opakovaném grantu témuž uživateli (accumulate záznamy v invite_grants)

References


Was this page helpful?

Thanks for the feedback.