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 != PENDINGnelze 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
generationnové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_budgetrow — 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"
}
Flow 2 — Uživatel otevře invite link (preview / validace)
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
| Endpoint | Auth |
|---|---|
GET /api/v1/invites/{token}/preview | Public (bez tokenu) |
POST /api/v1/auth/signup-with-invite | Public (bez tokenu) |
GET /api/v1/me/invites | Bearer JWT (authenticated user) |
POST /api/v1/me/invites | Bearer JWT (authenticated user) |
POST /api/v1/admin/invites | Bearer JWT + role ADMIN |
UX Guidelines
Flow 1 — Admin generuje invite tokeny
User flow
- Admin (Mirek) otevře
/profile?section=invites→ zobrazí se sekce “Invites” v levém sidebaru Profile screenu. - 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).
- Admin vidí tabulku tokenů + tlačítko “Create invite” vpravo nahoře v content oblasti.
- Admin klikne “Create invite” → otevře se inline formulář (ne modal) pod tabulkou nebo jako slide-in panel.
- Vyplní volitelný
issued_to_emailacount(1–50) +expires_in_days(default 30). - Klikne “Generate” → tlačítko přejde do loading stavu (SpinLoader + text “Generating…”).
- Po
201 Created: formulář se skryje, tabulka se aktualizuje, zobrazí se toast “N invite(s) created” (var(--green-soft)border). - Každý nový token má inline “Copy link” tlačítko (clipboard icon, Lucide
Copy) — po kliknutí se ikonka přemění naCheckna 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”).
Flow 2 — Příjemce otevře invite link (public preview)
User flow
- Příjemce klikne na
https://talkide.app/join?token=<UUID>. - FE router zachytí
/join→ vykreslíJoinScreen(standalone, bez TTopBar, bez sidebaru). - 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. - Pokud token formálně OK: FE zavolá
GET /api/v1/invites/{token}/preview. - Zobrazí skeleton loader (3 šedé bloky, výška odpovídá obsahu karty) po dobu čekání.
- 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}.”
- 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
- Příjemce je na
/join?token=...(po úspěšném preview) nebo na/sign-up?token=.... invite_tokenje skrytý input — předvyplněný z URL query param, uživatel ho nevidí ani neediuje.- Email je předvyplněný pokud
issued_to_email != nulla pole je editovatelné (některé invity jsou personalizované, ale uživatel může zadat jiný email — BE nevaliduje shodu). - Validace polí on blur: Email (
EmailInput), Password (PasswordInput, min 8 znaků). - Uživatel klikne “Create your account →” →
ButtonPrimaryloading state (“Creating account…”). - Po
201 Created: uložení JWT,animate-slide-outanimace na kartě, redirect na/studio(nebo/onboardingpokud existuje). - Po chybě:
animateShakena kartě (AuthShellprop), chybová zpráva pod formulářem (var(—rose)).
Chybové zprávy
| BE kód | Text 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
- 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). - FE zavolá
GET /api/v1/me/invitespři mount. - Skeleton loader (2 řádky stat karet + tabulka).
- 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
- Uživatel je na
/profile?section=invitesa vidíinvites_remaining > 0. - Klikne “Vytvořit pozvánku +” → pod tabulkou se expanduje inline formulář (transition
fade-up). - Volitelně vyplní
issued_to_email(EmailInput, placeholder “jan@example.com — optional”). - Klikne “Vytvořit” → ButtonPrimary loading state.
- 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). - 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
| Element | Komponenta | Poznámky |
|---|---|---|
| Invite sekce v sidebaru | ProfileScreen.vue SECTIONS array | Přidat { id: 'invites', label: t('profile.nav.invites'), icon: UserPlus } |
| Quota stat karta | StatBlock (ze studio/components/) | Reuse, props: label, value, unit |
| Invite list tabulka | InviteListTable (nová) | Sloupce: issued_to_email, status pill, issued_at, expires_at, clipboard btn |
| Status pill | TPill | Varianty: green (CLAIMED), amber (PENDING), rose (EXPIRED/REVOKED) |
| Clipboard tlačítko | TIconBtn + Lucide Copy/Check | Transition 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 karta | JoinScreen (nová, standalone route) | Reuse AuthShell s custom title/subtitle |
| Error state karta | JoinErrorState (nová, v JoinScreen) | AlertTriangle + text + back button |
| Invite quota badge v TopBar | Volitelné (post-MVP) | Např. číslo za ikonkou UserPlus v TTopBar |
| Toast notifikace | useToast composable | Existující infrastruktura |
| Skeleton loader | inline bg-[var(--bg-3)] animate-pulse bloky | Konzistentní s ostatními sekcemi |
Validation Feedback
Flow 1 & 5 — formulář vytvoření invite
issued_to_email: validace on blur, EmailInput spatternrule → č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ód | Zprá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í
SignupFormpattern (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). animateShakenaAuthShellkartě 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 vProfileScreen). - 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"pokudinvites_remaining === 0(místodisabledatributu, aby tooltip fungoval). - Clipboard tlačítko:
aria-label="Copy invite link for petra@example.com"(dynamické dleissued_to_emailnebo 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”:
ButtonPrimarysloading=true→SpinLoader+ “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
| Stav | Co user vidí |
|---|---|
invites_remaining = 0, žádné vydané tokeny | Quota 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é tokeny | Quota 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 CLAIMED | Tabulka 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ě jakoAuthShell. - Pod logem:
TAvatarkomponent (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:
AuthBackdropkomponent (stejné blobové animace jako na/logina/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
| Pole | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
invite_token (URL param) | not_blank | 36 | [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, email | 5 - 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_blank | 8 - 100 | Min 8 znaků | |
count (admin generate) | not_null, positive | 1 - 50 | Max 50 tokenů v jednom requestu | |
issued_to_email (create invite) | email nebo prázdné | 0 / 5 - 255 | Volitelné; 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, positive | 1 - 100 | Počet pozvánek k udělení | |
reason (admin grant) | optional | 0 - 500 | Volitelné 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
| Pole | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
invite_token | not_blank, UUID v4 format | 36 | [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 |
email | not_blank, valid email | 5 - 255 | Lowercase normalizace před uložením | |
password | not_blank | 8 - 100 | BCrypt hash před uložením | |
count (admin generate) | not_null, min=1, max=50 | |||
expires_in_days | not_null, min=1, max=90 | Default 30 pokud není v requestu | ||
| Token status check | PENDING + not expired | Atomické ověření v SERIALIZABLE transakci při claimu | ||
| Burner email | Email doména NOT IN blocklist | Blocklist v application.yml, aktualizovatelný bez deploy | ||
| IP rate limit | max 3 claims / IP / 24h | Spring Security filter nebo rate-limit middleware | ||
| Pending limit | max 5 PENDING invites na issuera | COUNT invites WHERE issued_by = ? AND status = PENDING | ||
| Issuer quota | invites_remaining > 0 | invites_remaining = invites_total - invites_used | ||
invite_generation | issuer.invite_generation + 1, nebo 0 pro founder-direct | NULL issued_by_user_id → generation 0 | ||
user_budget | Vytvořit row pro každého nového uživatele | Viz Implementation Notes — invarianta nesmí být porušena | ||
userId (admin grant) | exists in users | 404 pokud neexistuje | ||
count (admin grant) | min=1, max=100 | 400 VALIDATION pokud mimo rozsah | ||
| Grant transakce | invite_grants + user_invite_quota upsert | Atomicky v jedné transakci; upsert pokud user_invite_quota row neexistuje |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| Founder (ADMIN role) je autentizovaný | POST /admin/invites s count=3 | 3 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=1 | 403 Forbidden, code=FORBIDDEN |
| Platný PENDING token existuje | GET /invites/{token}/preview | 200 OK, valid=true, issued_to_email vrácen (null pokud nebyl nastaven) |
| Token neexistuje v DB | GET /invites/{token}/preview | 404 Not Found, code=NOT_FOUND |
Token má status=CLAIMED | GET /invites/{token}/preview | 410 Gone, code=INVITE_ALREADY_CLAIMED |
Token má expires_at v minulosti | GET /invites/{token}/preview | 422 Unprocessable Entity, code=INVITE_EXPIRED |
| Platný PENDING token existuje, email je unikátní | POST /auth/signup-with-invite s validními daty | 201 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-invite | Nový 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-invite | Nový user dostane invite_generation=1 |
| Token je CLAIMED (již použitý) | POST /auth/signup-with-invite | 422 Unprocessable Entity, code=INVITE_EXPIRED (generický error pro všechny neplatné stavy) |
Email již existuje v users tabulce | POST /auth/signup-with-invite | 409 Conflict, code=CONFLICT_USER |
| Email je z burnerové domény (mailinator.com) | POST /auth/signup-with-invite | 422 Unprocessable Entity, code=INVITE_BURNER_EMAIL |
| IP adresa odeslala 3 claimy za posledních 24h | POST /auth/signup-with-invite (4. pokus) | 429 Too Many Requests, code=INVITE_IP_RATE_LIMIT |
Autentizovaný user s invites_remaining=2 | GET /me/invites | 200 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=1 | POST /me/invites | 201 Created; nový PENDING token vytvořen; issued_by_user_id = current_user_id |
Autentizovaný user s invites_remaining=0 | POST /me/invites | 422 Unprocessable Entity, code=INVITE_QUOTA_EXHAUSTED |
| Autentizovaný user má 5 PENDING tokenů | POST /me/invites | 422 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 Created | user_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-grants | 201 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=1 | POST /admin/users/{userId}/invite-grants s count=5 | 201 Created; invites_total = 8, invites_used = 1, invites_remaining = 7 |
| Flow 6: Admin, cílový user ID neexistuje | POST /admin/users/{userId}/invite-grants | 404 Not Found, code=NOT_FOUND |
| Flow 6: Uživatel bez role ADMIN | POST /admin/users/{userId}/invite-grants | 403 Forbidden, code=FORBIDDEN |
Flow 6: count=0 nebo záporné číslo | POST /admin/users/{userId}/invite-grants | 400 Bad Request, code=VALIDATION |
| Flow 6: Grant audit trail — po úspěšném grantu | Kontrola DB stavu | Zá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
- specification/invite-system.md — Detailní spec kaskády, DB schéma, admin tool, capacity recommender, anti-abuse pravidla
- adr/ADR-020 — Architektonická rozhodnutí: founder-gated growth (Rozhodnutí 6)
- UC-08001 Anthropic Call Gateway — Gateway infrastruktura
- UC-08002 Mara Fatigue & Quota Enforcement — FUP quota systém, kontext fatigue/capacity
- commit
e541155—fix(signup): vytvoř user_budget row při registraci— invarianta musí být zachována při invite signup - UC-11004 Invite from Waitlist — admin pozve zájemce z waitlistu (rozšíření Flow 1, nový endpoint, e-mail přes Mailgun)
Thanks for the feedback.