Przejdź do głównej zawartości

Wyszukiwanie jest dostępne tylko w buildach produkcyjnych. Spróbuj zbudować i uruchomić aplikację, aby przetestować lokalnie.

App Directory — kontrakt /api/dealerid/user-apps

DealerID renderuje dashboard /account/apps agregując karty z wszystkich aplikacji platformy. Każda apka, która chce się tam pojawić, wystawia endpoint /api/dealerid/user-apps (lub inny URL — wystarczy wpisać go w oauth2_client.app_directory_url). DealerID woła go server-to-server, podpisując request krótkim JWT z dedykowanym scope.

Bez tego endpointu DealerID musiałby albo:

  1. trzymać własną kopię „kto ma dostęp do której apki” (== mirror tabeli membership każdej apki, dryft, koszmar),
  2. albo zwracać statyczny katalog aplikacji bez kontekstu „twoich” Applications (user musiałby zgadywać).

Z endpointem każdy /account/apps request to live, real-world snapshot dostępów usera. CRM zwraca N kart (po jednej na każdą Application widoczną dla usera — patrz Multi-tenancy: Aplikacje), BMS zwraca M, itd.

sequenceDiagram
    autonumber
    actor U as User
    participant DID as DealerID<br/>(/account/apps)
    participant CRM as crm-api<br/>(/api/dealerid/user-apps)
    participant BMS as bms-api<br/>(/api/dealerid/user-apps)
    participant DB as DealerID DB<br/>(oauth2_client)

    U->>DID: GET /account/apps
    DID->>DB: SELECT identifier, name, app_directory_url<br/>FROM oauth2_client<br/>WHERE active = TRUE<br/>AND app_directory_url IS NOT NULL
    DB-->>DID: [crm-frontend, bms-frontend, ...]

    Note over DID: Mint short-lived JWT<br/>RS256, 5 min TTL,<br/>sub=user.email,<br/>scopes=["app.directory.read"]<br/>(NIE wysyła X-Organization-Id —<br/>backend wyciąga app context z hosta)

    par parallel I/O — filtrowanie per oauth2_client.app_directory_url
        DID->>CRM: GET /api/dealerid/user-apps<br/>Authorization: Bearer <JWT><br/>timeout: 2s
        CRM->>CRM: JwtAuthenticator → AuthenticatedUser<br/>(lazy provision iam_user)
        CRM->>CRM: enumerate Applications<br/>(per kind + membership)
        CRM-->>DID: 200 { apps: [{name, host, role, status, ...}] }
    and
        DID->>BMS: GET /api/dealerid/user-apps<br/>Authorization: Bearer <JWT>
        BMS-->>DID: 200 { apps: [...] }
    end

    Note over DID: Aggreguje wpisy,<br/>dorzuca lokalne APP_PRESENTATION<br/>(logo monogram + color)
    DID-->>U: HTML dashboard z N×M kartami

Zmiana vs poprzedni model: DealerID nie wysyła już X-Organization-Id przy discovery call. Backend (CRM) wyciąga context aplikacji wyłącznie z Host headera w kolejnych requestach po userze. JWT z scope app.directory.read służy wyłącznie do autoryzacji discovery callu — bez context tenanta, apka sama enumeruje wszystkie Applications widoczne dla usera.

GET /api/dealerid/user-apps
Host: <twoja apka, internal network>
Authorization: Bearer <JWT minted by DealerID>
Accept: application/json

JWT (struktura, z HttpAppDirectoryFetcher::mintToken()):

{
"iss": "dealerid",
"aud": "dealercrm-apps",
"sub": "lukasz.kopcza@grupadealer.pl", // email usera, dla którego DealerID pyta
"iat": 1715900000,
"nbf": 1715900000,
"exp": 1715900300, // 5 min TTL
"scopes": ["app.directory.read"]
}

Podpis: RS256, tym samym kluczem prywatnym, którym DealerID podpisuje normalne access tokeny. Twoja apka weryfikuje tym samym publicznym kluczem (env OAUTH_PUBLIC_KEY) — nie potrzebujesz osobnej infry kryptograficznej.

Uwaga: scope app.directory.read jest dedykowany M2M (server-to-server). Nigdy nie wystawiaj go w SPA tokenu — client_id w SPA nie powinien być w stanie go zażądać. To jest token mintowany wyłącznie wewnętrznie przez DealerID.

// HTTP/1.1 200 OK
// Content-Type: application/json
{
"apps": [
{
"name": "Gezet Główny", // co user zobaczy na karcie
"host": "gezet.localhost:3001", // dokąd user trafia po kliknięciu (BFF wczyta Host)
"role": "2 role", // bezpieczny opis roli (display only, nie permission check)
"status": "active", // "active" | "suspended" | "pending"
"subtitle": "Gezet Sp. z o.o.", // nazwa organizacji
"lastSeen": null, // ISO 8601 lub null jeśli nie wiadomo
"brandingSkinSlug": "default" // dziedziczony z organization.branding_skin_slug
},
{
"name": "Gezet — Renault",
"host": "gezet-renault.localhost:3001",
"role": "1 rola",
"status": "active",
"subtitle": "Gezet Sp. z o.o.",
"lastSeen": null,
"brandingSkinSlug": "renault"
}
]
}
PoleTypWymaganeOpis
namestringtakDisplay name karty. CRM łączy nazwę organizacji + ew. brand (np. „Gezet — Renault”).
hoststringtakHost, na który trafia user po kliknięciu („Idź do apki”). Z portem dla dev. Nie URL — sam host. DealerID skleja https:// (lub http:// dla *.localhost). To jednoznacznie wyznacza Application po stronie apki.
rolestringtakKrótki, human-readable opis. Nie powinien zawierać wrażliwych szczegółów permission. Może być np. „Admin”, „2 role”, „Staff”.
statusstringtakJeden z "active", "suspended", "pending". DealerID może wyszarzać karty suspended/pending. CRM dziś zwraca tylko "active" (suspended są filtrowane wcześniej).
subtitlestring?nieDrugorzędna linijka (np. pełna nazwa organizacji). Pomijaj jeśli zduplikowałoby name.
lastSeenstring?nieTimestamp ISO 8601 ostatniej aktywności usera w danej apce / Application. CRM dziś zwraca null (nie trackujemy). Może być wzbogacone w przyszłości.
brandingSkinSlugstring?nieSkin (logo/paleta) — apka downstream ustawia data-skin="..." po loginie. Wartość dziedziczona z organization.branding_skin_slug (od organization_id lub importer_id zależnie od kind). Patrz Multi-tenancy: Aplikacje.

Uwaga: applicationKind (dealer_main / importer_main / importer_network / staff) nie jest polem response. To atrybut karty aplikacji nadawany lokalnie po stronie DealerID (na podstawie kontekstu oauth2_client.identifier + heurystyk). Apka downstream nie musi tego ujawniać.

  • Lista może być pusta ({ "apps": [] }) — user nie ma żadnego membership w twojej apce. To nie błąd. DealerID po prostu nie pokaże twojej apki dla tego usera.
  • Best-effort: jeśli twoja apka padnie / timeout / 5xx, DealerID po prostu pomija ją w agregacie. Inne apki nadal pojawią się userowi. Nie blokuj dashboardu — return 200 ze pustą listą jest preferowane nad błędem, jeśli wiesz, że odpowiedź jest „użytkownik nie ma dostępu”.
  • Staff platformy (is_internal_staff) zwykle dostaje wszystko (bypass membership) — w CRM oznacza to wszystkie Applications × wszystkie organizations. Patrz implementacja CRM poniżej.
  • Każda Application widoczna dla usera = osobny wpis. User z 3 dealerami zobaczy 3+ kart CRM (po jednej na każdą Application, do której ma dostęp przez membership). To jest cecha, nie bug — pozwala mu wybrać kontekst pracy bez przelogowywania.

apps/crm-api/src/Module/Iam/Ui/Http/UserAppsController.php:

#[Route('/api/dealerid/user-apps', name: 'iam_user_apps', methods: ['GET'])]
public function __invoke(): JsonResponse
{
$user = $this->currentUserProvider->current();
if (null === $user) {
return new JsonResponse(['type' => '/errors/401', 'title' => 'Unauthorized'], 401);
}
$entries = [];
if ($user->isStaff) {
// Staff: widzimy wszystkie aktywne Applications (bypass membership scope).
// Dla kind=staff zwracamy wpis platformowy; dla pozostałych kindów — po jednym wpisie na
// Application × organization w której jesteśmy uprawnieni (= wszystkie, bo staff).
$apps = $this->applicationFinder->list(active: true);
foreach ($apps as $app) {
$brandingSkin = $this->resolveBrandingSkin($app); // dziedziczone z org per kind
$entries[] = $this->renderEntry($app, 'Staff', $brandingSkin);
}
} else {
// Regular user — membership-driven listing.
// Dla każdej Application sprawdzamy, czy user ma odpowiednie membership:
// - kind=dealer_main / importer_main → membership w application.organization_id
// - kind=importer_network → membership w application.organization_id (dealer)
// - kind=staff → nigdy (regular user nie widzi staff Application)
foreach ($user->memberships as $m) {
if ('active' !== $m->status) continue;
$apps = $this->applicationFinder->listForOrganization(
organizationId: $m->organizationId,
active: true,
);
foreach ($apps as $app) {
if ('staff' === $app->kind) continue;
$brandingSkin = $this->resolveBrandingSkin($app);
$entries[] = $this->renderEntry(
$app,
$this->primaryRoleName($m->roleIds),
$brandingSkin,
);
}
}
}
return new JsonResponse(['apps' => $entries]);
}
private function resolveBrandingSkin(ApplicationView $app): ?string
{
// Branding dziedziczony z organization (jedno źródło per firma):
// - kind=importer_network → bierzemy z importera (application.importer_id)
// - pozostałe kindy → z organization_id
$orgId = 'importer_network' === $app->kind ? $app->importerId : $app->organizationId;
return $this->organizationFinder->findById($orgId)?->brandingSkinSlug;
}

Application access validator — bypass dla /api/dealerid/*

Dział zatytułowany „Application access validator — bypass dla /api/dealerid/*”

W CRM, ApplicationAccessValidator (apps/crm-api/src/Module/Iam/Infrastructure/Security/ApplicationAccessValidator.php) resolve’uje Application z Host headera dla /api/* i sprawdza membership. Ale dla discovery endpointu DealerID nie ma konkretnej Application — chcemy enumeracja wszystkich. Zwolnienie:

private const array EXEMPT_PATHS = [
'/api/me',
'/api/docs',
'/api/public',
// DealerID server-to-server discovery — bez application context, scope `app.directory.read`.
'/api/dealerid',
];

Jeśli twoja apka też używa walidatora — pamiętaj o tym wyjątku.

  • Paginationapps to flat list. Realistycznie user ma ≤ 50 wpisów; reuse tej listy bez paginacji jest OK.
  • Filtrowanie po statusie / org — DealerID dostaje wszystko, sam zrobi grouping na froncie.
  • Polish layout / order — DealerID sortuje po name alfabetycznie. Twoja apka nie musi sortować.
  • Logos / colors — patrz Rejestracja aplikacji → APP_PRESENTATION. Apka nie wystawia własnego logo / koloru — trzymane spójnie po stronie DealerID.