Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,13 @@ A clone logs to the console and ships nothing anywhere. The server URL is the on
## Frontend–API communication

Typed clients in `EvilCase.Api.Client` are generated at build time from the API controller sources by a source generator, so client and server cannot drift. Controllers are the single source of truth, DTOs are shared through `EvilCase.Api.Contract`, and controller conventions are enforced by analyzers at error severity. The app is served by the API host, so calls are same-origin and no CORS is involved.

## Integrations over REST

An integration authenticates with a personal API token. Create one in Nastavení → API tokeny (`/settings/api-tokens`) with a name, a scope (Jen čtení or Čtení i zápis) and an expiry of 30, 90 or 365 days; the token is shown once. Send it on every request:

```
curl -H "Authorization: Bearer evc_…" "https://localhost:5000/api/cases?take=20"
```

The token acts as its user, tenant included. A read-only token gets 403 on every write, and an unknown, revoked or expired token gets 401. `/api/auth/**` and token management accept the browser session only. `/openapi/v1.json` describes every endpoint and the Bearer scheme, and is anonymous in every environment.
3 changes: 2 additions & 1 deletion docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ Otevři je v prohlížeči přímo ze souboru.
| [`hledani.html`](hledani.html) | Hledání v horní liště, otevřená nabídka | všechny obrazovky |
| [`kontakty.html`](kontakty.html) | Kontakty | `/contacts` |
| [`detail-kontaktu.html`](detail-kontaktu.html) | Detail kontaktu | `/contacts/{id}` |
| [`nastaveni.html`](nastaveni.html) | Nastavení, sekce štítků | `/settings` |
| [`nastaveni.html`](nastaveni.html) | Nastavení, sekce štítků | `/settings/labels` |
| [`nastaveni-api-tokeny.html`](nastaveni-api-tokeny.html) | Nastavení, sekce API tokenů | `/settings/api-tokens` |

Text v hranatých závorkách je zástupný: vzorová data pro něj hodnotu nemají. Implementace na
jeho místě ukazuje skutečnou hodnotu, nebo prázdný stav podle SDD dané agendy.
Expand Down
269 changes: 269 additions & 0 deletions docs/design/nastaveni-api-tokeny.html

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions docs/design/nastaveni.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
a { color: #1C3A9F; text-decoration: none; } a:hover { color: #1F3AA8; }
.row:hover { background: #F8F9FB; }
.navlink:hover { color: #FFFFFF; background: rgba(255,255,255,0.06); }
.setlink:hover { background: #F1F2F5; }
.dark-input::placeholder { color: #A0A6B1; }
.iconbtn:hover { background: #F1F2F5; }
.iconbtn.danger:hover { background: #FDECEA; }
Expand Down Expand Up @@ -55,6 +56,12 @@ <h1 style="margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.02em
<a href="nastaveni.html" aria-current="page" style="display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 12px; border-radius: 10px; font-size: 14px; font-weight: 600; background: #E9EDF9; color: #2A48B8;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9-9-9"/><circle cx="7.5" cy="7.5" r="1.5"/></svg>Štítky
</a>
<a href="nastaveni-api-tokeny.html" class="setlink" style="display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 12px; border-radius: 10px; font-size: 14px; font-weight: 500; color: #454B57;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="15" r="4"/><path d="M10.8 12.2L20 3"/><path d="M17 6l3 3"/><path d="M14 9l2 2"/></svg>API tokeny
</a>
<a href="#" class="setlink" style="display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 12px; border-radius: 10px; font-size: 14px; font-weight: 500; color: #454B57;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h16"/><path d="M12 14V4"/><path d="M12 14l4-4"/><path d="M12 14l-4-4"/></svg>Export
</a>
</nav>

<section style="background: #FFFFFF; border: 1px solid #E1E4EA; border-radius: 16px; display: flex; flex-direction: column; overflow: hidden;">
Expand Down Expand Up @@ -216,6 +223,8 @@ <h1 style="margin: 0; font-size: 22px; font-weight: 600; flex-grow: 1;">Nastaven
</div>
<nav aria-label="Sekce nastavení (mobil)" style="display: flex; gap: 6px;">
<a href="nastaveni.html" aria-current="page" style="display: flex; align-items: center; height: 44px; padding: 0 14px; border-radius: 999px; font-size: 14px; font-weight: 600; background: #E9EDF9; color: #2A48B8;">Štítky</a>
<a href="nastaveni-api-tokeny.html" style="display: flex; align-items: center; height: 44px; padding: 0 14px; border-radius: 999px; font-size: 14px; font-weight: 500; color: #454B57; white-space: nowrap;">API tokeny</a>
<a href="#" style="display: flex; align-items: center; height: 44px; padding: 0 14px; border-radius: 999px; font-size: 14px; font-weight: 500; color: #454B57; white-space: nowrap;">Export</a>
</nav>
<section style="background: #FFFFFF; border: 1px solid #E1E4EA; border-radius: 16px; overflow: hidden;">
<div style="display: flex; align-items: center; gap: 10px; padding: 14px 16px;">
Expand Down
16 changes: 10 additions & 6 deletions docs/sdd/sdd-005-api-konvence.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ Tvary API a klienta.
jako Problem Details, nikdy `index.html` frontendu.
- Business služba vrací kontraktní DTO; žádná druhá sada modelů.
- Endpoint bez výslovné výjimky vyžaduje přihlášení. Anonymní jsou přihlášení, obnova tokenu,
odhlášení, health checks, upload klientských logů, 404 pod `/api` a v Development `/openapi`
a `/scalar`.
odhlášení, health checks, upload klientských logů, 404 pod `/api`, v každém prostředí
`/openapi/v1.json` a v Development `/scalar`.
- Přihlášení nese cookie, nebo API token v hlavičce `Authorization: Bearer` (SDD-006). OpenAPI
dokument deklaruje schéma Bearer.
- Založení odpovídá 201 s hlavičkou `Location` na detail nového záznamu; u souboru a verze míří na
stažení obsahu, detail souboru není. Upload, který nic neuloží, protože se obsah shoduje
s aktuální verzí, odpovídá 200. Komentář odpovídá 204 i na založení. Editace a smazání
odpovídají 204.
stažení obsahu, detail souboru není. Založení API tokenu odpovídá 201 bez `Location`, protože
token nemá detail. Upload, který nic neuloží, protože se obsah shoduje s aktuální verzí,
odpovídá 200. Komentář odpovídá 204 i na založení. Editace a smazání odpovídají 204.
- V těle jde hodnota výčtu jako název; číslo ani neznámý název se nepřijme (400). V query
stringu ji váže framework, který číslo bere.
- Každý výpis bere stránku, směr řazení, svůj řadicí klíč a hledaný text; k tomu zúžení své
Expand All @@ -40,7 +42,8 @@ Tvary API a klienta.
Limitem prochází celé `/api/auth/**`, přihlášené endpointy včetně, a upload klientských logů;
partition je adresa volajícího: přihlášení 5 za minutu, obnova tokenu 60, zbytek `/api/auth/**` 10,
upload klientských logů 120.
Vše ostatní je bez limitu, health checks včetně. Odmítnutí je 429 s `Retry-After`.
Vše ostatní je bez limitu, health checks a požadavky s API tokenem včetně. Odmítnutí je 429
s `Retry-After`.

### Zdroje

Expand All @@ -53,6 +56,7 @@ Vše ostatní je bez limitu, health checks včetně. Odmítnutí je 429 s `Retry
| Komentáře | na vlastníku, `…/comments`, `…/comments/{id}` |
| Hledání | `/api/search?q=`, viz [SDD-021](sdd-021-vyhledavani.md) |
| Štítky | `/api/labels`, `/api/labels/{id}`; přidělení na vlastníku `…/labels` |
| API tokeny | `/api/api-tokens`, zrušení `/api/api-tokens/{id}`; jen přihlášení v prohlížeči (SDD-006) |

- Id v routách je `Guid`.
- Tenanta i uživatele dodává kontext požadavku; endpoint ani dotaz nikdy neberou id tenanta
Expand Down
46 changes: 37 additions & 9 deletions docs/sdd/sdd-006-tenance-a-ucty.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

- **Stav:** platí
- **Milníky:** M2
- **Související SDD:** [003](sdd-003-testovani.md), [004](sdd-004-validace-a-chyby.md),
[005](sdd-005-api-konvence.md), [007](sdd-007-domenovy-model.md)
- **Související SDD:** [002](sdd-002-logovani-a-observabilita.md), [003](sdd-003-testovani.md),
[004](sdd-004-validace-a-chyby.md), [005](sdd-005-api-konvence.md),
[007](sdd-007-domenovy-model.md), [019](sdd-019-stitky.md)

## Rozsah

Účty, tenanti, uživatelé, izolace dat mezi tenanty a autentizace. Doménové entity popisuje
SDD-007.
Účty, tenanti, uživatelé, izolace dat mezi tenanty, autentizace a API tokeny. Doménové entity
popisuje SDD-007.

## Popis

Expand All @@ -19,7 +20,8 @@ SDD-007.
- **User** — tenantová entita, patří právě jednomu tenantu: e-mail, hash hesla, role, lockout
a `TenantId`.

Mimo tenant stojí jen Account, Tenant a refresh token; každá jiná entita nese `TenantId`.
Mimo tenant stojí jen Account, Tenant, refresh token a API token; každá jiná entita nese
`TenantId`.
Vlastníka `UserId` nese každá kromě kontaktu; kontakt patří tenantu (SDD-011). Obojí plní
zápis, ne volající. Tenanta ani uživatele nelze smazat, dokud drží řádky.

Expand All @@ -36,8 +38,8 @@ správu účtů, žádná registrace.
Data nesmí utéct mezi tenanty; únik je kritická chyba.

- Čtení nikdy nevidí řádek cizího tenantu; tenanta i uživatele dodává kontext požadavku.
Výjimka je hledání uživatele, které tenanta ještě znát nemůže — přihlášení, obnova tokenu
a oba seedy.
Výjimka je hledání uživatele, které tenanta ještě znát nemůže — přihlášení, obnova tokenu,
ověření API tokenu a oba seedy.
- Access token nese tenant claim i subject claim.
- Zápis doplní `TenantId` a `UserId` nové entity z kontextu a odmítne řádek, který jmenuje
cizího tenanta nebo cizího uživatele.
Expand All @@ -47,7 +49,7 @@ Data nesmí utéct mezi tenanty; únik je kritická chyba.
přes celé nasazení a ukládá se oříznutý a malými písmeny, takže přihlášení nerozlišuje
velikost písmen.
- Konvenční test hlídá, že žádná tenantová entita filtr nepostrádá a že mimo tenant stojí jen
Account, Tenant a refresh token (SDD-003).
Account, Tenant, refresh token a API token (SDD-003).

### Autentizace

Expand All @@ -57,7 +59,7 @@ Heslo je uložené jako hash odolný proti hrubé síle. Access token nese tenan
z řádku uživatele při každém vydání, obnovu tokenu včetně. CSRF obranu drží `SameSite=Strict`,
same-origin a kontrola `Sec-Fetch-Site` (případně `Origin`) na zápisech do `/api`: zápis mimo
vlastní origin odpoví 403. Antiforgery token není. Hlavička `Authorization: Bearer` zůstává
přijatá pro klienty mimo prohlížeč.
přijatá pro klienty mimo prohlížeč; nese access token, nebo API token.

Přihlášení vydrží 30 dní od posledního použití a nejdéle 90 dní od přihlášení. Access token
platí 15 minut a aplikace ho obnovuje sama; obnova z více záložek téhož prohlížeče uživatele
Expand All @@ -67,6 +69,28 @@ Pět po sobě jdoucích neúspěšných přihlášení účet na 15 minut uzamkn
423 (SDD-004) a úspěšné přihlášení počitadlo vynuluje. Uzamčení blokuje jen přihlášení heslem;
živá přihlášení neukončí a jejich obnova běží dál.

### API tokeny

Integrace a AI agent se přihlašují osobním API tokenem. Token patří jednomu uživateli a jedná
jako on: tenanta, uživatele i roli bere z řádku uživatele při každém požadavku. Token nese název
(nejvýše 64 znaků), oprávnění „Jen čtení“ nebo „Čtení i zápis“ a povinnou platnost 30, 90 nebo
365 dní. Uživatel drží nejvýše 20 neprošlých tokenů; další založení odpoví 400.

Token je `evc_` a 256 náhodných bitů v base64url. Ukáže se jednou, při založení; uloží se jen
jeho hash. Posílá se v hlavičce `Authorization: Bearer` a schéma vybere jeho prefix; token
v hlavičce vyhraje nad cookie přihlášení. Neznámý, zrušený nebo prošlý token odpoví 401. Zrušení
token smaže a platí od dalšího požadavku, nic token necachuje.

- Token jen pro čtení odpoví 403 na každý zápis: POST, PUT, PATCH, DELETE.
- Na `/api/auth/**` a na správu tokenů odpoví token 403; obojí zůstává přihlášení v prohlížeči.
- Uzamčení účtu token nezastaví, stejně jako živé přihlášení.
- Požadavky s tokenem limit požadavků nemají (SDD-005).
- „Naposledy použit“ se zapíše nejvýše jednou za minutu.
- Logovací scope požadavku s tokenem nese id a název tokenu (SDD-002); jiný záznam o změnách
agenta není. Token sám se do logu nedostane nikdy.

Tokeny spravuje sekce nastavení API tokeny (SDD-019).

## Rozhodnutí

- Uživatelé v tenantu: více uživatelů sdílí tenant / 1 uživatel = 1 tenant. Platí
Expand All @@ -79,6 +103,10 @@ Pět po sobě jdoucích neúspěšných přihlášení účet na 15 minut uzamkn
- Plnění `TenantId` a `UserId`: volající / zápis sám. Platí zápis sám, jen na nové řádce.
- Změna a smazání cizího záznamu uvnitř tenantu: jen vlastník / kdokoli z tenantu. Platí kdokoli
z tenantu, komentář jen jeho autor (SDD-013).
- Uložení API tokenu: pomalý hash jako u hesla / SHA-256. Platí SHA-256: 256 náhodných bitů nejde
uhodnout, a ověření běží při každém požadavku.
- API token na `/api/auth/**` a na správě tokenů: 401 / 403. Platí 403: token je platný, jen tam
nesmí.

## Dopady

Expand Down
6 changes: 4 additions & 2 deletions docs/sdd/sdd-016-navigace-a-vzhled.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ URL nesou UUID entit, nikdy jejich čísla:
| `/acts/{actId}/edit` | editace úkonu |
| `/contacts` | kontakty |
| `/contacts/{id}` | detail kontaktu |
| `/settings` | nastavení (SDD-019) |
| `/settings/export` | export dat tenanta (SDD-019, SDD-022) |
| `/settings` | přesměruje na `/settings/labels` |
| `/settings/labels` | nastavení, štítky (SDD-019) |
| `/settings/api-tokens` | nastavení, API tokeny (SDD-019) |
| `/settings/export` | nastavení, export dat tenanta (SDD-019, SDD-022) |
| `/login` | přihlášení |

Neznámá routa vykreslí stav nenalezeno uvnitř aplikace, neznámé id v routě prázdný stav
Expand Down
36 changes: 23 additions & 13 deletions docs/sdd/sdd-019-stitky.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

- **Stav:** platí
- **Milníky:** M8
- **Související SDD:** [007](sdd-007-domenovy-model.md), [009](sdd-009-spisy.md),
[010](sdd-010-ukony.md), [016](sdd-016-navigace-a-vzhled.md), [022](sdd-022-export.md)
- **Související SDD:** [006](sdd-006-tenance-a-ucty.md), [007](sdd-007-domenovy-model.md),
[009](sdd-009-spisy.md), [010](sdd-010-ukony.md), [016](sdd-016-navigace-a-vzhled.md),
[022](sdd-022-export.md)

## Rozsah

Entita štítku, jeho přidělení spisu a úkonu, sekce nastavení a zobrazení štítků. Stránky spisu
drží SDD-009, stránky úkonu SDD-010, routy a menu SDD-016.
Entita štítku, jeho přidělení spisu a úkonu, nastavení a jeho sekce a zobrazení štítků. Stránky
spisu drží SDD-009, stránky úkonu SDD-010, routy a menu SDD-016.

## Popis

Expand All @@ -25,18 +26,26 @@ ničem. Tutéž dvojici nese nejvýše jednou. Spis ani úkon nemá počet ští

### Nastavení

`/settings` je agenda nastavení tenantu se sekcemi Štítky (`/settings`) a Export
(`/settings/export`). Na desktopu je mezi sekcemi svislý panel, na mobilu řada pilulek.
Nastavení nese sekce Štítky (`/settings/labels`), API tokeny (`/settings/api-tokens`) a Export
(`/settings/export`), každou na vlastní stránce se společnou navigací sekcí. Na desktopu je
navigace svislým panelem, na mobilu řadou pilulek. `/settings` přesměruje na Štítky.

Sekce Štítky ukazuje štítky seřazené podle názvu, u každého počet spisů a počet úkonů, které ho
nesou, a nechá štítek založit, přejmenovat, přebarvit a smazat. Název, který už nese jiný štítek,
se odmítne 409 (SDD-004). Smazání štítku ho sejme ze všech spisů a úkonů, které ho nesly; spis ani
úkon tím nezaniká. Potvrzuje se jako každé mazání a potvrzení jmenuje, kolik spisů a úkonů štítek
nese.

Sekce API tokeny ukazuje tokeny přihlášeného uživatele (SDD-006), nejnovější nahoře: název,
oprávnění, kdy token vznikl, do kdy platí a kdy byl naposledy použit. Prošlý token nese
„Vypršel“, dosud nepoužitý „Nepoužit“. „Nový token“ bere název (povinný), oprávnění (výchozí
Jen čtení) a platnost 30, 90 nebo 365 dní (výchozí 90). Po založení se token ukáže jedinkrát,
s tlačítkem pro zkopírování a upozorněním, že ho uživatel znovu neuvidí. „Zrušit“ token zruší po
potvrzení jako každé mazání; zrušený token ze seznamu zmizí.

Sekce Export popíše, co archiv nese a co ne, a nese tlačítko Exportovat vše s názvem souboru,
pod kterým se archiv stáhne. Export řídí [SDD-022](sdd-022-export.md).

Sekce Štítky ukazuje štítky
seřazené podle názvu, u každého počet spisů a počet úkonů, které ho nesou, a nechá štítek
založit, přejmenovat, přebarvit a smazat. Název, který už nese jiný štítek, se odmítne 409
(SDD-004). Smazání štítku ho sejme ze všech spisů a úkonů, které ho nesly; spis ani úkon tím
nezaniká. Potvrzuje se jako každé mazání a potvrzení jmenuje, kolik spisů a úkonů štítek nese.

### Přidělení

Štítky se přidělují na dvou místech:
Expand Down Expand Up @@ -67,7 +76,8 @@ spisu nebo úkonu, na každé šířce.
- Smazání štítku, který něco nese: zákaz / smazání i s jeho přiděleními. Platí smazání
i s přiděleními; spis ani úkon, který štítek nesl, tím nezaniká.
- Zápis přidělení: přidání a odebrání po jednom / celá množina. Platí celá množina.
- Nastavení: vlastní stránka / sekce v profilu uživatele. Platí vlastní stránka `/settings`.
- Nastavení: vlastní stránka / sekce v profilu uživatele. Platí vlastní stránky pod `/settings/`,
jedna na sekci.

## Dopady

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using EvilBrains.EvilCase.Domain.ApiTokens;

namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;

public sealed record ApiTokenCreateRequest
{
[Required]
[StringLength(ApiTokenLimits.MaxNameLength)]
public required string Name { get; init; }

public required ApiTokenScope Scope { get; init; }

/// <summary>
/// One of <see cref="ApiTokenLimits.LifetimeDays"/>.
/// </summary>
[AllowedValues(30, 90, 365)]
public required int ExpiresInDays { get; init; }
}
11 changes: 11 additions & 0 deletions src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreated.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;

public sealed record ApiTokenCreated
{
public required ApiTokenItem Token { get; init; }

/// <summary>
/// The only response that carries the token; the server keeps its hash alone.
/// </summary>
public required string Secret { get; init; }
}
18 changes: 18 additions & 0 deletions src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using EvilBrains.EvilCase.Domain.ApiTokens;

namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;

public sealed record ApiTokenItem
{
public required Guid ApiTokenId { get; init; }

public required string Name { get; init; }

public required ApiTokenScope Scope { get; init; }

public required DateTime Created { get; init; }

public required DateTime Expires { get; init; }

public DateTime? LastUsed { get; init; }
}
21 changes: 21 additions & 0 deletions src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenLimits.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;

public static class ApiTokenLimits
{
/// <summary>
/// Mirrors <c>ApiToken.Name</c>'s <c>MaxLength</c>.
/// </summary>
public const int MaxNameLength = 64;

/// <summary>
/// Unexpired tokens one user may hold.
/// </summary>
public const int MaxActive = 20;

public const int DefaultLifetimeDays = 90;

/// <summary>
/// <see cref="ApiTokenCreateRequest.ExpiresInDays"/> names the same values.
/// </summary>
public static readonly IReadOnlyList<int> LifetimeDays = [30, DefaultLifetimeDays, 365];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace EvilBrains.EvilCase.Api.Contract.ApiTokens;

public sealed record ApiTokenListResponse
{
public required IReadOnlyList<ApiTokenItem> Items { get; init; }
}
Loading
Loading