@@ -0,0 +1,78 @@
|
|||||||
|
package com.softspm.webinar.apikey;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zarzadzanie kluczami API. Dostep wylacznie dla roli ADMIN, wymuszony regula
|
||||||
|
* w SecurityConfig na sciezce /admin/api-keys/**.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/api-keys")
|
||||||
|
@Tag(name = "Klucze API (admin)", description = "Zarzadzanie kluczami do publicznego API")
|
||||||
|
public class ApiKeyAdminController {
|
||||||
|
|
||||||
|
private final ApiKeyService service;
|
||||||
|
|
||||||
|
public ApiKeyAdminController(ApiKeyService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Lista kluczy API. Bez samego klucza, tylko prefiks.")
|
||||||
|
public List<ApiKeyView> list() {
|
||||||
|
return service.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Utworzenie klucza. Surowy klucz zwracany jeden raz.")
|
||||||
|
public ApiKeyCreated create(@Valid @RequestBody CreateRequest request) {
|
||||||
|
return service.create(request.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/regenerate")
|
||||||
|
@Operation(summary = "Wygenerowanie nowego klucza dla tego odbiorcy. Nowy klucz zwracany raz, stary przestaje dzialac.")
|
||||||
|
public ApiKeyCreated regenerate(@PathVariable Long id) {
|
||||||
|
return service.regenerate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/activate")
|
||||||
|
@Operation(summary = "Wlaczenie klucza")
|
||||||
|
public ApiKeyView activate(@PathVariable Long id) {
|
||||||
|
return service.setActive(id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/deactivate")
|
||||||
|
@Operation(summary = "Wylaczenie klucza. Odbiera dostep natychmiast.")
|
||||||
|
public ApiKeyView deactivate(@PathVariable Long id) {
|
||||||
|
return service.setActive(id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "Skasowanie klucza na trwale")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void delete(@PathVariable Long id) {
|
||||||
|
service.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zadanie utworzenia klucza: nazwa odbiorcy. */
|
||||||
|
public record CreateRequest(
|
||||||
|
@NotBlank(message = "Nazwa klucza jest wymagana")
|
||||||
|
@Size(max = 120, message = "Nazwa klucza jest zbyt długa")
|
||||||
|
String name) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.softspm.webinar.apikey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odpowiedz na utworzenie klucza: widok plus SUROWY klucz.
|
||||||
|
*
|
||||||
|
* Surowy klucz (`key`) jest w tej odpowiedzi jeden jedyny raz i nie da sie go
|
||||||
|
* pozniej odczytac: w bazie jest tylko skrot. Panel ma go od razu pokazac
|
||||||
|
* administratorowi do skopiowania i przekazania odbiorcy.
|
||||||
|
*/
|
||||||
|
public record ApiKeyCreated(ApiKeyView apiKey, String key) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package com.softspm.webinar.apikey;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.ApiKey;
|
||||||
|
import com.softspm.webinar.repository.ApiKeyRepository;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zarzadzanie kluczami API i ich weryfikacja.
|
||||||
|
*
|
||||||
|
* GENEROWANIE
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Klucz to 32 bajty (256 bitow) z SecureRandom, zakodowane base64url bez
|
||||||
|
* dopelnienia - 43 znaki bezpieczne w naglowku HTTP i w adresie. 256 bitow
|
||||||
|
* losowych czyni zgadywanie i atak slownikowy bezcelowymi.
|
||||||
|
*
|
||||||
|
* PRZECHOWYWANIE
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* W bazie jest WYLACZNIE SHA-256 klucza. Surowy klucz zwracamy raz, przy
|
||||||
|
* utworzeniu, i nigdzie go nie zapisujemy: wyciek bazy nie wydaje dzialajacych
|
||||||
|
* kluczy. Skrot jest bez soli, bo przy 256 bitach losowych sol nic nie wnosi,
|
||||||
|
* a deterministyczny skrot pozwala odnalezc klucz jednym zapytaniem.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ApiKeyService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ApiKeyService.class);
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
/** Dlugosc klucza w bajtach przed zakodowaniem. */
|
||||||
|
private static final int KEY_BYTES = 32;
|
||||||
|
|
||||||
|
/** Ile pierwszych znakow klucza zapisujemy jawnie, do rozpoznania w panelu. */
|
||||||
|
private static final int PREFIX_LENGTH = 8;
|
||||||
|
|
||||||
|
private final ApiKeyRepository repository;
|
||||||
|
|
||||||
|
public ApiKeyService(ApiKeyRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ApiKeyView> list() {
|
||||||
|
return repository.findAllByOrderByCreatedAtDesc().stream()
|
||||||
|
.map(ApiKeyView::of)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tworzy klucz i zwraca go RAZ w postaci surowej.
|
||||||
|
*
|
||||||
|
* Surowy klucz jest w odpowiedzi tylko teraz: kolejne odczyty listy pokaza
|
||||||
|
* juz wylacznie prefiks. Administrator ma go od razu przekazac odbiorcy.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ApiKeyCreated create(String rawName) {
|
||||||
|
String name = rawName == null ? "" : rawName.trim();
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
throw new ValidationException("Nazwa klucza jest wymagana");
|
||||||
|
}
|
||||||
|
if (name.length() > 120) {
|
||||||
|
throw new ValidationException("Nazwa klucza jest zbyt długa");
|
||||||
|
}
|
||||||
|
|
||||||
|
String secret = generateKey();
|
||||||
|
String hash = sha256Hex(secret);
|
||||||
|
|
||||||
|
// kolizja skrotu przy 256 bitach losowych jest praktycznie niemozliwa,
|
||||||
|
// ale unikalny indeks i tak jej pilnuje; sprawdzenie tutaj daje czytelny
|
||||||
|
// komunikat zamiast bledu integralnosci
|
||||||
|
if (repository.existsByKeyHash(hash)) {
|
||||||
|
throw new ValidationException("Wystąpił konflikt przy generowaniu klucza, spróbuj ponownie");
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiKey entity = new ApiKey();
|
||||||
|
entity.setName(name);
|
||||||
|
entity.setKeyHash(hash);
|
||||||
|
entity.setKeyPrefix(secret.substring(0, PREFIX_LENGTH));
|
||||||
|
entity.setActive(true);
|
||||||
|
|
||||||
|
ApiKey saved = repository.save(entity);
|
||||||
|
log.info("Utworzono klucz API '{}' (prefiks {})", saved.getName(), saved.getKeyPrefix());
|
||||||
|
|
||||||
|
// surowy klucz wychodzi wylacznie tutaj i nie jest nigdzie zapisywany
|
||||||
|
return new ApiKeyCreated(ApiKeyView.of(saved), secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generuje NOWY klucz dla istniejacego odbiorcy (rotacja).
|
||||||
|
*
|
||||||
|
* Nazwa i tozsamosc wpisu zostaja, ale poprzedni klucz przestaje dzialac
|
||||||
|
* natychmiast: podmieniamy skrot i prefiks, a `last_used_at` zerujemy, bo
|
||||||
|
* dotyczylo poprzedniego klucza. Surowy nowy klucz zwracamy raz, jak przy
|
||||||
|
* tworzeniu. Sluzy do odzyskania dostepu po zgubieniu klucza, ktorego z
|
||||||
|
* bazy odczytac sie nie da (jest tylko skrot).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ApiKeyCreated regenerate(Long id) {
|
||||||
|
ApiKey key = require(id);
|
||||||
|
|
||||||
|
String secret = generateKey();
|
||||||
|
String hash = sha256Hex(secret);
|
||||||
|
if (repository.existsByKeyHash(hash)) {
|
||||||
|
throw new ValidationException("Wystąpił konflikt przy generowaniu klucza, spróbuj ponownie");
|
||||||
|
}
|
||||||
|
|
||||||
|
key.setKeyHash(hash);
|
||||||
|
key.setKeyPrefix(secret.substring(0, PREFIX_LENGTH));
|
||||||
|
// poprzedni klucz przestaje dzialac; ostatnie uzycie dotyczylo jego
|
||||||
|
key.setLastUsedAt(null);
|
||||||
|
key.setActive(true);
|
||||||
|
key.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
|
||||||
|
log.info("Wygenerowano ponownie klucz API '{}' (nowy prefiks {})",
|
||||||
|
key.getName(), key.getKeyPrefix());
|
||||||
|
return new ApiKeyCreated(ApiKeyView.of(key), secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wlacza albo wylacza klucz. Wylaczony przestaje dzialac natychmiast. */
|
||||||
|
@Transactional
|
||||||
|
public ApiKeyView setActive(Long id, boolean active) {
|
||||||
|
ApiKey key = require(id);
|
||||||
|
key.setActive(active);
|
||||||
|
key.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
log.info("Klucz API '{}' (prefiks {}) {}",
|
||||||
|
key.getName(), key.getKeyPrefix(), active ? "wlaczony" : "wylaczony");
|
||||||
|
return ApiKeyView.of(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kasuje klucz na trwale. */
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
ApiKey key = require(id);
|
||||||
|
repository.delete(key);
|
||||||
|
log.info("Skasowano klucz API '{}' (prefiks {})", key.getName(), key.getKeyPrefix());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weryfikacja klucza z naglowka.
|
||||||
|
*
|
||||||
|
* Zwraca prawde dla klucza, ktory istnieje i jest aktywny. Przy trafieniu
|
||||||
|
* odnotowuje `last_used_at`, zeby w panelu bylo widac, czy klucz jeszcze
|
||||||
|
* zyje. Skrot liczymy zawsze, takze dla pustego wejscia, i porownanie idzie
|
||||||
|
* w bazie po indeksie, wiec sciezka nie ujawnia, czy sam skrot pasowal.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean verify(String rawKey) {
|
||||||
|
if (rawKey == null || rawKey.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String hash = sha256Hex(rawKey.trim());
|
||||||
|
Optional<ApiKey> found = repository.findByKeyHashAndActiveTrue(hash);
|
||||||
|
if (found.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ApiKey key = found.get();
|
||||||
|
key.setLastUsedAt(OffsetDateTime.now());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApiKey require(Long id) {
|
||||||
|
return repository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono klucza API"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 32 bajty z SecureRandom zakodowane base64url bez dopelnienia. */
|
||||||
|
private String generateKey() {
|
||||||
|
byte[] bytes = new byte[KEY_BYTES];
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 wejscia jako 64 znaki hex. */
|
||||||
|
static String sha256Hex(String input) {
|
||||||
|
try {
|
||||||
|
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder hex = new StringBuilder(digest.length * 2);
|
||||||
|
for (byte b : digest) {
|
||||||
|
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||||
|
hex.append(Character.forDigit(b & 0xF, 16));
|
||||||
|
}
|
||||||
|
return hex.toString();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
// SHA-256 jest w kazdej maszynie wirtualnej Javy: ten wyjatek nie wystapi
|
||||||
|
throw new IllegalStateException("Brak algorytmu SHA-256", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.softspm.webinar.apikey;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.ApiKey;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widok klucza API dla panelu.
|
||||||
|
*
|
||||||
|
* NIGDY nie zawiera samego klucza ani jego skrotu: klucz istnieje w postaci
|
||||||
|
* surowej wylacznie w chwili utworzenia (zwraca go `ApiKeyCreated`), a skrotu
|
||||||
|
* nie wolno wystawiac. Panel rozpoznaje klucz po `prefix`.
|
||||||
|
*/
|
||||||
|
public record ApiKeyView(
|
||||||
|
Long id,
|
||||||
|
String name,
|
||||||
|
String prefix,
|
||||||
|
boolean active,
|
||||||
|
OffsetDateTime lastUsedAt,
|
||||||
|
OffsetDateTime createdAt) {
|
||||||
|
|
||||||
|
public static ApiKeyView of(ApiKey entity) {
|
||||||
|
return new ApiKeyView(
|
||||||
|
entity.getId(),
|
||||||
|
entity.getName(),
|
||||||
|
entity.getKeyPrefix(),
|
||||||
|
entity.isActive(),
|
||||||
|
entity.getLastUsedAt(),
|
||||||
|
entity.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.softspm.webinar.apikey;
|
||||||
|
|
||||||
|
import com.softspm.webinar.web.UnauthorizedException;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publiczne API tylko do odczytu, chronione kluczem API.
|
||||||
|
*
|
||||||
|
* DOSTEP
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Sciezka jest pod /public/**, wiec Spring Security ja przepuszcza (permitAll).
|
||||||
|
* Uwierzytelnienie robi TEN kontroler, sprawdzajac naglowek X-API-Key przez
|
||||||
|
* ApiKeyService.verify. Ten sam wzorzec co webhooki dostawcy wideo: otwarte
|
||||||
|
* w Security, a sekret sprawdzany w kontrolerze. Brak naglowka, pusty, bledny
|
||||||
|
* albo wylaczony klucz daje jednakowo 401, zeby odpowiedz nie ujawniala,
|
||||||
|
* ktory z warunkow zawiodl.
|
||||||
|
*
|
||||||
|
* WERSJA W SCIEZCE
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* `/v1/` w adresie pozwala kiedys wystawic `/v2/` bez psucia integracji juz
|
||||||
|
* dzialajacych u odbiorcow.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/public/v1")
|
||||||
|
@Tag(name = "Publiczne API (klucz)", description = "Aktywne webinary i kursy, dostep przez naglowek X-API-Key")
|
||||||
|
public class PublicApiController {
|
||||||
|
|
||||||
|
/** Naglowek z kluczem. */
|
||||||
|
public static final String API_KEY_HEADER = "X-API-Key";
|
||||||
|
|
||||||
|
private final ApiKeyService apiKeys;
|
||||||
|
private final PublicApiService data;
|
||||||
|
|
||||||
|
public PublicApiController(ApiKeyService apiKeys, PublicApiService data) {
|
||||||
|
this.apiKeys = apiKeys;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/webinars")
|
||||||
|
@Operation(summary = "Aktywne webinary (opublikowane i niezakonczone). Wymaga X-API-Key.")
|
||||||
|
public List<PublicApiDtos.WebinarItem> webinars(
|
||||||
|
@RequestHeader(value = API_KEY_HEADER, required = false) String apiKey) {
|
||||||
|
authorize(apiKey);
|
||||||
|
return data.activeWebinars();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/courses")
|
||||||
|
@Operation(summary = "Aktywne kursy (opublikowane). Wymaga X-API-Key.")
|
||||||
|
public List<PublicApiDtos.CourseItem> courses(
|
||||||
|
@RequestHeader(value = API_KEY_HEADER, required = false) String apiKey) {
|
||||||
|
authorize(apiKey);
|
||||||
|
return data.activeCourses();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Odrzuca zadanie bez waznego klucza. Jeden komunikat dla wszystkich przyczyn. */
|
||||||
|
private void authorize(String apiKey) {
|
||||||
|
if (!apiKeys.verify(apiKey)) {
|
||||||
|
throw new UnauthorizedException("Nieprawidłowy lub brakujący klucz API.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.softspm.webinar.apikey;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widoki publicznego API z kluczem.
|
||||||
|
*
|
||||||
|
* ZWRACAMY WYLACZNIE DANE JAWNE PLUS STATYSTYKI.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* To sa te same dane, ktore i tak widzi gosc na stronie, powiekszone o liczby
|
||||||
|
* zapisanych i kursantow. NIGDY nie wolno dopisac tu danych uczestnikow,
|
||||||
|
* adresu `joinUrl` (link do sali), tresci platnych lekcji ani niczego, co nie
|
||||||
|
* jest publiczne. Kazde pole jest wypisane recznie, tak jak w PublicConfig:
|
||||||
|
* przez to nowe pole encji nie wycieka samym faktem dodania go do modelu.
|
||||||
|
*/
|
||||||
|
public final class PublicApiDtos {
|
||||||
|
|
||||||
|
private PublicApiDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktywny webinar. */
|
||||||
|
public record WebinarItem(
|
||||||
|
String slug,
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
OffsetDateTime startsAt,
|
||||||
|
OffsetDateTime endsAt,
|
||||||
|
String timezone,
|
||||||
|
boolean registrationOpen,
|
||||||
|
Integer capacity,
|
||||||
|
long registeredCount,
|
||||||
|
String registrationUrl) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktywny (opublikowany) kurs. */
|
||||||
|
public record CourseItem(
|
||||||
|
String slug,
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
String description,
|
||||||
|
String category,
|
||||||
|
String level,
|
||||||
|
Integer durationMinutes,
|
||||||
|
boolean free,
|
||||||
|
BigDecimal priceGross,
|
||||||
|
String currency,
|
||||||
|
long enrolledCount,
|
||||||
|
String courseUrl) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package com.softspm.webinar.apikey;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.Course;
|
||||||
|
import com.softspm.webinar.domain.EnrollmentStatus;
|
||||||
|
import com.softspm.webinar.domain.Webinar;
|
||||||
|
import com.softspm.webinar.repository.CourseRepository;
|
||||||
|
import com.softspm.webinar.repository.EnrollmentRepository;
|
||||||
|
import com.softspm.webinar.repository.RegistrationRepository;
|
||||||
|
import com.softspm.webinar.repository.WebinarRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sklada dane publicznego API: aktywne webinary i aktywne kursy, wraz ze
|
||||||
|
* statystykami.
|
||||||
|
*
|
||||||
|
* Wszystko dzieje sie w jednej transakcji, bo widoki sa skladane z encji
|
||||||
|
* pobieranych leniwie (granica transakcji: encje nie opuszczaja serwisu).
|
||||||
|
* Liczby zapisanych i kursantow ida ZAPYTANIAMI ZBIORCZYMI, nie po jednym na
|
||||||
|
* pozycje, tym samym wzorcem co listy w panelu.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PublicApiService {
|
||||||
|
|
||||||
|
private final WebinarRepository webinars;
|
||||||
|
private final CourseRepository courses;
|
||||||
|
private final RegistrationRepository registrations;
|
||||||
|
private final EnrollmentRepository enrollments;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public PublicApiService(WebinarRepository webinars,
|
||||||
|
CourseRepository courses,
|
||||||
|
RegistrationRepository registrations,
|
||||||
|
EnrollmentRepository enrollments,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.webinars = webinars;
|
||||||
|
this.courses = courses;
|
||||||
|
this.registrations = registrations;
|
||||||
|
this.enrollments = enrollments;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<PublicApiDtos.WebinarItem> activeWebinars() {
|
||||||
|
List<Webinar> active = webinars.findActive(OffsetDateTime.now());
|
||||||
|
Map<Long, Long> counts = registeredCounts(active);
|
||||||
|
String base = publicBaseUrl();
|
||||||
|
|
||||||
|
return active.stream()
|
||||||
|
.map(w -> new PublicApiDtos.WebinarItem(
|
||||||
|
w.getSlug(),
|
||||||
|
w.getTitle(),
|
||||||
|
w.getDescription(),
|
||||||
|
w.getStartsAt(),
|
||||||
|
w.getEndsAt(),
|
||||||
|
w.getTimezone(),
|
||||||
|
w.isRegistrationOpen(),
|
||||||
|
w.getCapacity(),
|
||||||
|
counts.getOrDefault(w.getId(), 0L),
|
||||||
|
base == null ? null : base + "/w/" + w.getSlug()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<PublicApiDtos.CourseItem> activeCourses() {
|
||||||
|
List<Course> published = courses.findPublished();
|
||||||
|
Map<Long, Long> counts = enrolledCounts(published);
|
||||||
|
String base = publicBaseUrl();
|
||||||
|
|
||||||
|
return published.stream()
|
||||||
|
.map(c -> new PublicApiDtos.CourseItem(
|
||||||
|
c.getSlug(),
|
||||||
|
c.getTitle(),
|
||||||
|
c.getSubtitle(),
|
||||||
|
c.getDescription(),
|
||||||
|
c.getCategory(),
|
||||||
|
c.getLevel() == null ? null : c.getLevel().name(),
|
||||||
|
c.getDurationMinutes(),
|
||||||
|
c.isFree(),
|
||||||
|
c.getPriceGross(),
|
||||||
|
c.getCurrency(),
|
||||||
|
counts.getOrDefault(c.getId(), 0L),
|
||||||
|
base == null ? null : base + "/kurs/" + c.getSlug()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liczba zajetych miejsc per webinar, jednym zapytaniem zbiorczym. */
|
||||||
|
private Map<Long, Long> registeredCounts(List<Webinar> list) {
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
List<Long> ids = list.stream().map(Webinar::getId).toList();
|
||||||
|
Map<Long, Long> result = new HashMap<>();
|
||||||
|
for (Object[] row : registrations.countActiveByWebinarIds(ids)) {
|
||||||
|
result.put((Long) row[0], (Long) row[1]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liczba kursantow per kurs: ACTIVE i COMPLETED razem.
|
||||||
|
*
|
||||||
|
* Oba stany oznaczaja realnego kursanta (dostep do materialow zostaje po
|
||||||
|
* ukonczeniu). CANCELLED i EXPIRED sie nie licza. `countByCourseIds`
|
||||||
|
* przyjmuje jeden status, wiec sumujemy dwa przebiegi.
|
||||||
|
*/
|
||||||
|
private Map<Long, Long> enrolledCounts(List<Course> list) {
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
List<Long> ids = list.stream().map(Course::getId).toList();
|
||||||
|
Map<Long, Long> result = new HashMap<>();
|
||||||
|
addCounts(result, enrollments.countByCourseIds(ids, EnrollmentStatus.ACTIVE));
|
||||||
|
addCounts(result, enrollments.countByCourseIds(ids, EnrollmentStatus.COMPLETED));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addCounts(Map<Long, Long> target, List<Object[]> rows) {
|
||||||
|
for (Object[] row : rows) {
|
||||||
|
Long id = (Long) row[0];
|
||||||
|
Long count = (Long) row[1];
|
||||||
|
target.merge(id, count, Long::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publiczny adres instancji do budowania linkow, albo null.
|
||||||
|
*
|
||||||
|
* Zwracamy null zamiast rzucac wyjatek: brak adresu nie moze wywrocic
|
||||||
|
* calego API, a pozycja bez linku nadal niesie sensowne dane. Tak samo
|
||||||
|
* jak SummaryService pomija link do nagrania, gdy adresu nie ma.
|
||||||
|
*/
|
||||||
|
private String publicBaseUrl() {
|
||||||
|
String url = settings.get(SettingKey.APP_PUBLIC_URL);
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = url.trim();
|
||||||
|
return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,13 @@ public class SecurityConfig {
|
|||||||
auth.requestMatchers("/admin/banners/**").hasRole("ADMIN");
|
auth.requestMatchers("/admin/banners/**").hasRole("ADMIN");
|
||||||
auth.requestMatchers("/admin/settings/**").hasRole("ADMIN");
|
auth.requestMatchers("/admin/settings/**").hasRole("ADMIN");
|
||||||
auth.requestMatchers("/admin/users/**").hasRole("ADMIN");
|
auth.requestMatchers("/admin/users/**").hasRole("ADMIN");
|
||||||
|
/*
|
||||||
|
* Klucze API otwieraja dostep do calego publicznego API,
|
||||||
|
* wiec zarzadza nimi wylacznie ADMIN, jak ustawieniami
|
||||||
|
* i kontami. Sam publiczny endpoint /public/v1/** jest w
|
||||||
|
* permitAll powyzej i sprawdza klucz w kontrolerze.
|
||||||
|
*/
|
||||||
|
auth.requestMatchers("/admin/api-keys/**").hasRole("ADMIN");
|
||||||
|
|
||||||
// pozostale operacje administracyjne takze dla moderatora
|
// pozostale operacje administracyjne takze dla moderatora
|
||||||
auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR");
|
auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR");
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.softspm.webinar.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klucz API do publicznego API tylko do odczytu.
|
||||||
|
*
|
||||||
|
* W bazie jest WYLACZNIE skrot SHA-256 klucza (`keyHash`), nigdy sam klucz.
|
||||||
|
* Surowy klucz istnieje tylko w chwili utworzenia i jest pokazany raz.
|
||||||
|
* `keyPrefix` to kilka pierwszych znakow klucza, trzymane jawnie wylacznie po
|
||||||
|
* to, zeby rozpoznac klucz na liscie w panelu.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "api_keys")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ApiKey {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** Nazwa odbiorcy, komu klucz wydano. */
|
||||||
|
@Column(name = "name", nullable = false, length = 120)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** SHA-256 klucza jako 64 znaki hex. */
|
||||||
|
@Column(name = "key_hash", nullable = false, length = 64)
|
||||||
|
private String keyHash;
|
||||||
|
|
||||||
|
/** Kilka pierwszych znakow klucza, jawnie, tylko do rozpoznania w panelu. */
|
||||||
|
@Column(name = "key_prefix", nullable = false, length = 16)
|
||||||
|
private String keyPrefix;
|
||||||
|
|
||||||
|
@Column(name = "active", nullable = false)
|
||||||
|
private boolean active = true;
|
||||||
|
|
||||||
|
@Column(name = "last_used_at")
|
||||||
|
private OffsetDateTime lastUsedAt;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private OffsetDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
if (this.createdAt == null) {
|
||||||
|
this.createdAt = now;
|
||||||
|
}
|
||||||
|
if (this.updatedAt == null) {
|
||||||
|
this.updatedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.softspm.webinar.repository;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.ApiKey;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klucz po skrocie, tylko aktywny.
|
||||||
|
*
|
||||||
|
* Weryfikacja liczy SHA-256 klucza z naglowka i szuka po nim: skrot jest
|
||||||
|
* deterministyczny (klucz ma 256 bitow losowych, wiec sol jest zbedna),
|
||||||
|
* dzieki czemu wyszukanie to jedno zapytanie po indeksie UNIQUE. Wylaczony
|
||||||
|
* klucz nie jest znajdowany, wiec dezaktywacja odbiera dostep natychmiast.
|
||||||
|
*/
|
||||||
|
Optional<ApiKey> findByKeyHashAndActiveTrue(String keyHash);
|
||||||
|
|
||||||
|
boolean existsByKeyHash(String keyHash);
|
||||||
|
|
||||||
|
/** Lista do panelu, najnowsze na gorze. */
|
||||||
|
List<ApiKey> findAllByOrderByCreatedAtDesc();
|
||||||
|
}
|
||||||
@@ -95,6 +95,23 @@ public interface WebinarRepository extends JpaRepository<Webinar, Long> {
|
|||||||
""")
|
""")
|
||||||
List<Webinar> findUpcoming(@Param("now") OffsetDateTime now, Pageable pageable);
|
List<Webinar> findUpcoming(@Param("now") OffsetDateTime now, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webinary aktywne dla publicznego API: opublikowane i jeszcze nie
|
||||||
|
* zakonczone.
|
||||||
|
*
|
||||||
|
* Podzial idzie po `endsAt`, nie po statusie FINISHED, dokladnie jak w
|
||||||
|
* panelu (nic w aplikacji nie ustawia FINISHED). W przeciwienstwie do
|
||||||
|
* `findUpcoming` warunkiem jest `endsAt > now`, nie `startsAt > now`, wiec
|
||||||
|
* trwajacy webinar takze jest aktywny.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
select w from Webinar w
|
||||||
|
where w.status = com.softspm.webinar.domain.WebinarStatus.PUBLISHED
|
||||||
|
and w.endsAt > :now
|
||||||
|
order by w.startsAt asc
|
||||||
|
""")
|
||||||
|
List<Webinar> findActive(@Param("now") OffsetDateTime now);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lista webinarow z wyszukiwaniem po tytule i filtrami.
|
* Lista webinarow z wyszukiwaniem po tytule i filtrami.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ public class ApiExceptionHandler {
|
|||||||
return ResponseEntity.badRequest().body(ApiError.of(e.getMessage()));
|
return ResponseEntity.badRequest().body(ApiError.of(e.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brak waznego klucza API w publicznym API. Zawsze 401 z tym samym
|
||||||
|
* komunikatem, niezaleznie od tego, czy naglowka nie bylo, czy klucz byl
|
||||||
|
* bledny albo wylaczony.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(UnauthorizedException.class)
|
||||||
|
public ResponseEntity<ApiError> handleUnauthorized(UnauthorizedException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiError.of(e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
/** Bledy adnotacji walidacyjnych na polach DTO. */
|
/** Bledy adnotacji walidacyjnych na polach DTO. */
|
||||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
public ResponseEntity<ApiError> handleFieldErrors(MethodArgumentNotValidException e) {
|
public ResponseEntity<ApiError> handleFieldErrors(MethodArgumentNotValidException e) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.softspm.webinar.web;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zadanie bez waznego uwierzytelnienia, mapowane na 401.
|
||||||
|
*
|
||||||
|
* Uzywane przez publiczne API z kluczem: brak naglowka, pusty albo bledny
|
||||||
|
* klucz oraz klucz wylaczony daja ten sam wynik, zeby odpowiedz nie ujawniala,
|
||||||
|
* ktory z tych warunkow zawiodl.
|
||||||
|
*/
|
||||||
|
public class UnauthorizedException extends RuntimeException {
|
||||||
|
|
||||||
|
public UnauthorizedException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Klucze API do publicznego, chronionego API tylko do odczytu.
|
||||||
|
--
|
||||||
|
-- DO CZEGO SLUZA
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Zewnetrzny odbiorca (inna strona klienta, integracja, agregator) pobiera
|
||||||
|
-- liste aktywnych webinarow i aktywnych kursow przez /api/public/v1/**,
|
||||||
|
-- podajac klucz w naglowku X-API-Key. Mozna wydac wiele kluczy dla roznych
|
||||||
|
-- odbiorcow, wlaczac je i wylaczac oraz kasowac niezaleznie.
|
||||||
|
--
|
||||||
|
-- W BAZIE JEST SKROT, NIE KLUCZ
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Trzymamy WYLACZNIE SHA-256 klucza (key_hash), nigdy samego klucza. Surowy
|
||||||
|
-- klucz pokazujemy jednorazowo w chwili utworzenia i nie da sie go odtworzyc:
|
||||||
|
-- wyciek bazy nie wydaje wtedy dzialajacych kluczy, dokladnie jak przy hasle.
|
||||||
|
-- Skrot jest deterministyczny (bez soli), bo klucz ma 256 bitow losowych z
|
||||||
|
-- SecureRandom - slownik teczowy nie ma czego zbudowac, a deterministyczny
|
||||||
|
-- skrot pozwala wyszukac klucz jednym zapytaniem przy weryfikacji.
|
||||||
|
--
|
||||||
|
-- key_prefix to kilka pierwszych znakow surowego klucza, przechowywane jawnie
|
||||||
|
-- WYLACZNIE do rozpoznania klucza w panelu ("ktory to z listy"). Sam prefiks
|
||||||
|
-- nie wystarcza do uwierzytelnienia.
|
||||||
|
--
|
||||||
|
-- CYKL ZYCIA
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- active - wylaczenie odbiera dostep odwracalnie, bez kasowania sladu.
|
||||||
|
-- last_used_at - kiedy klucz byl ostatnio uzyty (podglad, czy jeszcze zyje).
|
||||||
|
-- Kasowanie usuwa wiersz na trwale: to odbiorca, ktorego juz nie obslugujemy.
|
||||||
|
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
-- nazwa odbiorcy, zeby wiedziec, komu klucz wydano
|
||||||
|
name varchar(120) NOT NULL,
|
||||||
|
-- SHA-256 klucza jako 64 znaki hex, unikalny: jeden klucz = jeden wiersz
|
||||||
|
key_hash varchar(64) NOT NULL,
|
||||||
|
-- kilka pierwszych znakow klucza, jawnie, tylko do rozpoznania w panelu
|
||||||
|
key_prefix varchar(16) NOT NULL,
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
last_used_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_api_keys_key_hash UNIQUE (key_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- weryfikacja szuka po skrocie: indeks jest juz zapewniony przez UNIQUE wyzej,
|
||||||
|
-- wiec osobnego nie dodajemy
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Manual API - Publiczne API v1 (webinary i kursy)</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #122033;
|
||||||
|
--muted: #4f6277;
|
||||||
|
--border: #d7e0ea;
|
||||||
|
--accent: #0b66c3;
|
||||||
|
--ok: #1f8b4c;
|
||||||
|
--err: #b02a37;
|
||||||
|
--code-bg: #0f1b2a;
|
||||||
|
--code-text: #e5f0ff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Segoe UI", Tahoma, sans-serif;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; padding: 28px 16px 48px; }
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(120deg, #d9ecff 0%, #f7fbff 60%, #ffffff 100%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
h1, h2, h3 { margin: 0 0 10px; }
|
||||||
|
h1 { font-size: 1.7rem; }
|
||||||
|
h2 { font-size: 1.25rem; margin-top: 20px; }
|
||||||
|
h3 { font-size: 1.05rem; margin-top: 16px; }
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.badge.get { border-color: #9ac6f2; color: #084f97; }
|
||||||
|
.badge.json { border-color: #98d7ad; color: #166838; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||||
|
th, td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 7px;
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
th { background: #f7fafe; font-weight: 600; }
|
||||||
|
code {
|
||||||
|
background: #eef4fb;
|
||||||
|
border: 1px solid #dbe8f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--code-text);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.ok { color: var(--ok); font-weight: 600; }
|
||||||
|
.err { color: var(--err); font-weight: 600; }
|
||||||
|
ul { margin-top: 8px; }
|
||||||
|
li { margin-bottom: 6px; }
|
||||||
|
.note {
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
background: #eef5fd;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
h1 { font-size: 1.35rem; }
|
||||||
|
th, td { font-size: 0.9rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<section class="header">
|
||||||
|
<h1>Manual programisty: Publiczne API v1</h1>
|
||||||
|
<p class="muted">Dokumentacja API tylko do odczytu z listą aktywnych webinarów i aktywnych kursów platformy szkoleniowej.</p>
|
||||||
|
<span class="badge get">GET</span>
|
||||||
|
<span class="badge json">application/json</span>
|
||||||
|
<span class="badge">Auth: nagłówek X-API-Key</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>1. Informacje ogólne</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Adres bazowy</th><td><code>https://TWOJA-DOMENA/api/public/v1</code></td></tr>
|
||||||
|
<tr><th>Metoda HTTP</th><td><code>GET</code></td></tr>
|
||||||
|
<tr><th>Format odpowiedzi</th><td><code>application/json</code></td></tr>
|
||||||
|
<tr><th>Kodowanie</th><td>UTF-8</td></tr>
|
||||||
|
<tr><th>Wersjonowanie</th><td>Wersja jest w ścieżce (<code>/v1/</code>). Zmiany łamiące trafią do <code>/v2/</code>, a <code>/v1/</code> zostanie niezmienione.</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="muted">Adres <code>TWOJA-DOMENA</code> to domena instancji (ta sama, na której działa strona zapisu). API jest wyłącznie do odczytu: nie tworzy ani nie zmienia danych.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>2. Autoryzacja</h2>
|
||||||
|
<p>Klucz API należy przesłać w nagłówku HTTP <code>X-API-Key</code>. Klucz generuje administrator w panelu (Ustawienia → Klucze API) i przekazuje go odbiorcy.</p>
|
||||||
|
<pre>X-API-Key: TWOJ_KLUCZ_API</pre>
|
||||||
|
<div class="note">
|
||||||
|
<strong>Klucz jest pokazywany jeden raz</strong> – w chwili wygenerowania. W bazie przechowywany jest wyłącznie jego skrót (SHA-256), więc klucza nie da się odczytać ponownie. Jeśli zostanie zgubiony, wygeneruj nowy i usuń stary.
|
||||||
|
</div>
|
||||||
|
<p class="muted">Można wydać wiele kluczy dla różnych odbiorców i wyłączać je niezależnie. Wyłączenie lub usunięcie klucza odbiera dostęp natychmiast.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>3. Endpoint: aktywne webinary</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Adres</th><td><code>GET /api/public/v1/webinars</code></td></tr>
|
||||||
|
<tr><th>Zwraca</th><td>Tablicę JSON aktywnych webinarów (opublikowane i jeszcze niezakończone), posortowaną według daty rozpoczęcia rosnąco.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Pola pozycji</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Pole</th><th>Typ</th><th>Opis</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>slug</code></td><td>string</td><td>Identyfikator w adresie strony zapisu.</td></tr>
|
||||||
|
<tr><td><code>title</code></td><td>string</td><td>Tytuł webinaru.</td></tr>
|
||||||
|
<tr><td><code>description</code></td><td>string / null</td><td>Krótki opis (zajawka). Bez znaczników HTML.</td></tr>
|
||||||
|
<tr><td><code>startsAt</code></td><td>string (ISO 8601)</td><td>Termin rozpoczęcia z przesunięciem strefy, np. <code>2026-09-17T09:00:00+02:00</code>.</td></tr>
|
||||||
|
<tr><td><code>endsAt</code></td><td>string (ISO 8601)</td><td>Termin zakończenia.</td></tr>
|
||||||
|
<tr><td><code>timezone</code></td><td>string</td><td>Strefa czasowa webinaru, np. <code>Europe/Warsaw</code>.</td></tr>
|
||||||
|
<tr><td><code>registrationOpen</code></td><td>boolean</td><td>Intencja organizatora co do otwarcia zapisów.</td></tr>
|
||||||
|
<tr><td><code>capacity</code></td><td>integer / null</td><td>Limit miejsc. <code>null</code> oznacza brak limitu.</td></tr>
|
||||||
|
<tr><td><code>registeredCount</code></td><td>integer</td><td>Liczba zajętych miejsc (bez rezygnacji).</td></tr>
|
||||||
|
<tr><td><code>registrationUrl</code></td><td>string / null</td><td>Pełny adres strony zapisu. <code>null</code>, gdy w instancji nie ustawiono publicznego adresu.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>4. Endpoint: aktywne kursy</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Adres</th><td><code>GET /api/public/v1/courses</code></td></tr>
|
||||||
|
<tr><th>Zwraca</th><td>Tablicę JSON opublikowanych kursów, w kolejności ustalonej przez administratora.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Pola pozycji</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Pole</th><th>Typ</th><th>Opis</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>slug</code></td><td>string</td><td>Identyfikator w adresie strony kursu.</td></tr>
|
||||||
|
<tr><td><code>title</code></td><td>string</td><td>Tytuł kursu.</td></tr>
|
||||||
|
<tr><td><code>subtitle</code></td><td>string / null</td><td>Podtytuł.</td></tr>
|
||||||
|
<tr><td><code>description</code></td><td>string / null</td><td>Krótki opis.</td></tr>
|
||||||
|
<tr><td><code>category</code></td><td>string / null</td><td>Kategoria kursu.</td></tr>
|
||||||
|
<tr><td><code>level</code></td><td>string / null</td><td>Poziom: <code>BASIC</code>, <code>INTERMEDIATE</code> lub <code>ADVANCED</code>.</td></tr>
|
||||||
|
<tr><td><code>durationMinutes</code></td><td>integer / null</td><td>Łączny czas materiału w minutach. <code>null</code>, gdy nie podano.</td></tr>
|
||||||
|
<tr><td><code>free</code></td><td>boolean</td><td><code>true</code> dla kursu bezpłatnego.</td></tr>
|
||||||
|
<tr><td><code>priceGross</code></td><td>number / null</td><td>Cena brutto. <code>null</code> dla kursu darmowego.</td></tr>
|
||||||
|
<tr><td><code>currency</code></td><td>string</td><td>Waluta ceny, np. <code>PLN</code>.</td></tr>
|
||||||
|
<tr><td><code>enrolledCount</code></td><td>integer</td><td>Liczba kursantów (zapisy aktywne i ukończone).</td></tr>
|
||||||
|
<tr><td><code>courseUrl</code></td><td>string / null</td><td>Pełny adres strony kursu. <code>null</code>, gdy nie ustawiono publicznego adresu.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>5. Przykład zapytania (cURL)</h2>
|
||||||
|
<pre>curl -X GET "https://TWOJA-DOMENA/api/public/v1/webinars" \
|
||||||
|
-H "X-API-Key: TWOJ_KLUCZ_API"</pre>
|
||||||
|
<pre>curl -X GET "https://TWOJA-DOMENA/api/public/v1/courses" \
|
||||||
|
-H "X-API-Key: TWOJ_KLUCZ_API"</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>6. Przykład zapytania (JavaScript / fetch)</h2>
|
||||||
|
<pre>const response = await fetch("https://TWOJA-DOMENA/api/public/v1/webinars", {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "X-API-Key": "TWOJ_KLUCZ_API" }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("API zwrocilo status " + response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const webinars = await response.json();
|
||||||
|
console.log(webinars.length, "aktywnych webinarow");</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>7. Przykład zapytania (PHP)</h2>
|
||||||
|
<pre><?php
|
||||||
|
$ch = curl_init("https://TWOJA-DOMENA/api/public/v1/courses");
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-API-Key: TWOJ_KLUCZ_API"]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($status === 200) {
|
||||||
|
$courses = json_decode($body, true);
|
||||||
|
foreach ($courses as $course) {
|
||||||
|
echo $course["title"] . "\n";
|
||||||
|
}
|
||||||
|
}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>8. Odpowiedzi API</h2>
|
||||||
|
|
||||||
|
<h3 class="ok">8.1 Sukces (HTTP 200) – webinary</h3>
|
||||||
|
<pre>[
|
||||||
|
{
|
||||||
|
"slug": "audyt-sieci-20260917",
|
||||||
|
"title": "Audyt bezpieczenstwa sieci",
|
||||||
|
"description": "Jak wykryc luki, zanim zrobi to ktos inny.",
|
||||||
|
"startsAt": "2026-09-17T09:00:00+02:00",
|
||||||
|
"endsAt": "2026-09-17T10:30:00+02:00",
|
||||||
|
"timezone": "Europe/Warsaw",
|
||||||
|
"registrationOpen": true,
|
||||||
|
"capacity": 100,
|
||||||
|
"registeredCount": 42,
|
||||||
|
"registrationUrl": "https://TWOJA-DOMENA/w/audyt-sieci-20260917"
|
||||||
|
}
|
||||||
|
]</pre>
|
||||||
|
|
||||||
|
<h3 class="ok">8.2 Sukces (HTTP 200) – kursy</h3>
|
||||||
|
<pre>[
|
||||||
|
{
|
||||||
|
"slug": "podstawy-eksploatacji",
|
||||||
|
"title": "Podstawy eksploatacji sieci wodociagowej",
|
||||||
|
"subtitle": "Kurs dla poczatkujacych",
|
||||||
|
"description": "Od podstaw do samodzielnej pracy.",
|
||||||
|
"category": "Eksploatacja",
|
||||||
|
"level": "BASIC",
|
||||||
|
"durationMinutes": 180,
|
||||||
|
"free": false,
|
||||||
|
"priceGross": 490.00,
|
||||||
|
"currency": "PLN",
|
||||||
|
"enrolledCount": 128,
|
||||||
|
"courseUrl": "https://TWOJA-DOMENA/kurs/podstawy-eksploatacji"
|
||||||
|
}
|
||||||
|
]</pre>
|
||||||
|
<p class="muted">Brak pozycji zwraca pustą tablicę <code>[]</code> ze statusem 200 – to nie jest błąd.</p>
|
||||||
|
|
||||||
|
<h3 class="err">8.3 Błędy</h3>
|
||||||
|
<p class="muted">Ciało błędu ma postać <code>{ "message": "...", "fields": {} }</code>.</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>HTTP</th><th>Komunikat</th><th>Znaczenie</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>401</td><td><code>Nieprawidłowy lub brakujący klucz API.</code></td><td>Brak nagłówka <code>X-API-Key</code>, pusty, błędny albo wyłączony klucz. Jeden komunikat dla wszystkich tych przypadków.</td></tr>
|
||||||
|
<tr><td>404</td><td><code>Nie znaleziono zasobu pod tym adresem.</code></td><td>Zły adres endpointu.</td></tr>
|
||||||
|
<tr><td>405</td><td><code>Metoda ... nie jest obsługiwana pod tym adresem.</code></td><td>Użyto innej metody niż GET.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>9. Dobre praktyki po stronie klienta</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Wysyłaj żądania wyłącznie przez HTTPS.</li>
|
||||||
|
<li>Trzymaj klucz po stronie serwera. Nie umieszczaj go w kodzie strony ani w aplikacji przeglądarkowej, gdzie każdy go odczyta.</li>
|
||||||
|
<li>Maskuj <code>X-API-Key</code> w logach aplikacyjnych.</li>
|
||||||
|
<li>Buforuj odpowiedzi po swojej stronie (np. 1–5 minut). Dane zmieniają się rzadko, a odpytywanie co sekundę jest zbędne.</li>
|
||||||
|
<li>Przy statusie 401 nie ponawiaj automatycznie – sprawdź, czy klucz nie został wyłączony lub usunięty.</li>
|
||||||
|
<li>Przy błędach 5xx zastosuj ponawianie z limitem prób i narastającym odstępem (backoff).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>10. Checklista testów integracyjnych</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Poprawne żądanie z ważnym kluczem zwraca HTTP 200 i tablicę JSON.</li>
|
||||||
|
<li>Brak nagłówka <code>X-API-Key</code> zwraca HTTP 401.</li>
|
||||||
|
<li>Błędny klucz zwraca HTTP 401.</li>
|
||||||
|
<li>Wyłączenie klucza w panelu natychmiast skutkuje HTTP 401.</li>
|
||||||
|
<li>Żądanie metodą POST pod adres endpointu zwraca HTTP 405.</li>
|
||||||
|
<li>Instancja bez aktywnych pozycji zwraca pustą tablicę <code>[]</code> ze statusem 200.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -50,6 +50,7 @@ import { ConsentSettingsPage } from './pages/settings/ConsentSettingsPage'
|
|||||||
import { CompanySettingsPage } from './pages/settings/CompanySettingsPage'
|
import { CompanySettingsPage } from './pages/settings/CompanySettingsPage'
|
||||||
import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage'
|
import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage'
|
||||||
import { UserListPage } from './pages/users/UserListPage'
|
import { UserListPage } from './pages/users/UserListPage'
|
||||||
|
import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage'
|
||||||
import {
|
import {
|
||||||
AcademyChoicePage,
|
AcademyChoicePage,
|
||||||
AcademyPasswordResetPage,
|
AcademyPasswordResetPage,
|
||||||
@@ -330,6 +331,14 @@ export function App() {
|
|||||||
</RequireAdmin>
|
</RequireAdmin>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="klucze-api"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<ApiKeyListPage />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */}
|
{/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */}
|
||||||
<Route path="*" element={<Navigate to={panelPath()} replace />} />
|
<Route path="*" element={<Navigate to={panelPath()} replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CourseCard, formatPrice, mediaUrl } from './AcademyBits'
|
|||||||
import { HomeWebinarCard } from './HomeWebinarCard'
|
import { HomeWebinarCard } from './HomeWebinarCard'
|
||||||
import { useStudentAuth } from './StudentAuthContext'
|
import { useStudentAuth } from './StudentAuthContext'
|
||||||
import { STUDENT_ROUTES, panelPath } from '../lib/routes'
|
import { STUDENT_ROUTES, panelPath } from '../lib/routes'
|
||||||
import { AdminEmptyHint, AdminSectionHint, SampleBadge } from './AdminHint'
|
import { AdminEmptyHint, AdminSectionHint, SampleBadge, useIsStaff } from './AdminHint'
|
||||||
|
|
||||||
type PriceFilter = 'all' | 'free' | 'paid'
|
type PriceFilter = 'all' | 'free' | 'paid'
|
||||||
|
|
||||||
@@ -33,6 +33,9 @@ type PriceFilter = 'all' | 'free' | 'paid'
|
|||||||
*/
|
*/
|
||||||
export function AcademyHomePage() {
|
export function AcademyHomePage() {
|
||||||
const { student } = useStudentAuth()
|
const { student } = useStudentAuth()
|
||||||
|
// widocznosc placeholderow sekcji: admin i moderator widza podpowiedzi
|
||||||
|
// w miejscu pustych sekcji, gosc nie
|
||||||
|
const isStaff = useIsStaff()
|
||||||
const [filter, setFilter] = useState<PriceFilter>('all')
|
const [filter, setFilter] = useState<PriceFilter>('all')
|
||||||
const [category, setCategory] = useState<string>('')
|
const [category, setCategory] = useState<string>('')
|
||||||
|
|
||||||
@@ -41,9 +44,12 @@ export function AcademyHomePage() {
|
|||||||
queryFn: async () => (await publicClient.get<AcademyHome>('/public/academy/home')).data,
|
queryFn: async () => (await publicClient.get<AcademyHome>('/public/academy/home')).data,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// tytul karty to sama nazwa marki (APP_BRAND_NAME); haslo hero zostaje
|
||||||
|
// opisem zajawki, ale nie tytulem strony
|
||||||
useDocumentMeta({
|
useDocumentMeta({
|
||||||
title: data?.heroTitle ?? 'Akademia',
|
title: 'Akademia',
|
||||||
description: data?.heroText,
|
description: data?.heroText ?? data?.heroTitle,
|
||||||
|
brandOnly: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const courses = useMemo(() => {
|
const courses = useMemo(() => {
|
||||||
@@ -301,88 +307,117 @@ export function AcademyHomePage() {
|
|||||||
</Band>
|
</Band>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ============ OPINIE I PYTANIA: dwie kolumny, tlo biale ============ */}
|
{/* ============ OPINIE I PYTANIA: tlo biale ============ */}
|
||||||
<Band tone="white">
|
{/*
|
||||||
<div className="grid items-start gap-10 py-12 sm:py-14 lg:grid-cols-2 lg:gap-14">
|
Sekcje opinii i FAQ stoja obok siebie tylko wtedy, gdy OBIE maja co
|
||||||
{/* --- opinie --- */}
|
pokazac. Dla goscia placeholder sie nie renderuje, wiec pusta sekcja
|
||||||
<div>
|
znika calkiem, a druga zajmuje PELNA SZEROKOSC i jest wyrownana do
|
||||||
{data.reviews.length === 0 ? (
|
lewej, jak reszta sekcji strony. Bez tego jedyna widoczna sekcja
|
||||||
<AdminSectionHint
|
(np. samo FAQ) laduje w waskiej prawej kolumnie siatki, obok pustego
|
||||||
flat
|
miejsca po niewidocznych opiniach.
|
||||||
title={data.reviewsTitle}
|
*/}
|
||||||
what="Opinie osób, które ukończyły szkolenie. Publikuj wyłącznie te, na które masz zgodę autora: wymyślona opinia jest nieprawdą o nieistniejącej osobie, dlatego nie zakładamy tu przykładów."
|
{(() => {
|
||||||
to={panelPath('/ustawienia/strona-glowna')}
|
const showReviews = data.reviews.length > 0 || isStaff
|
||||||
/>
|
const showFaq = data.faq.length > 0 || isStaff
|
||||||
) : (
|
if (!showReviews && !showFaq) return null
|
||||||
<>
|
const twoColumns = showReviews && showFaq
|
||||||
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">
|
|
||||||
{data.reviewsTitle}
|
|
||||||
</h2>
|
|
||||||
<div className="mt-6 space-y-4">
|
|
||||||
{data.reviews.map((r) => (
|
|
||||||
<figure
|
|
||||||
key={r.id}
|
|
||||||
className="rounded-lg border border-slate-100 bg-slate-25 p-5"
|
|
||||||
>
|
|
||||||
<Icon name="message-square" size={18} className="text-aqua-500" />
|
|
||||||
<blockquote className="mt-2.5 text-sm leading-relaxed text-slate-700">
|
|
||||||
{r.body}
|
|
||||||
</blockquote>
|
|
||||||
<figcaption className="mt-3.5 border-t border-slate-200 pt-3">
|
|
||||||
<p className="text-sm font-semibold text-slate-900">{r.title}</p>
|
|
||||||
{r.subtitle && <p className="text-xs text-slate-500">{r.subtitle}</p>}
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* --- FAQ --- */}
|
return (
|
||||||
<div>
|
<Band tone="white">
|
||||||
{data.faq.length === 0 ? (
|
<div
|
||||||
<AdminSectionHint
|
className={`grid items-start gap-10 py-12 sm:py-14 lg:gap-14 ${
|
||||||
flat
|
twoColumns ? 'lg:grid-cols-2' : ''
|
||||||
title={data.faqTitle}
|
}`}
|
||||||
what="Pytania i odpowiedzi w rozwijanej liście. Przy pierwszym uruchomieniu zakładamy trzy przykładowe pytania o działanie platformy."
|
>
|
||||||
to={panelPath('/ustawienia/strona-glowna')}
|
{/* --- opinie --- */}
|
||||||
/>
|
{showReviews && (
|
||||||
) : (
|
<div>
|
||||||
<>
|
{data.reviews.length === 0 ? (
|
||||||
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">
|
<AdminSectionHint
|
||||||
{data.faqTitle}
|
flat
|
||||||
</h2>
|
title={data.reviewsTitle}
|
||||||
<div className="mt-6 divide-y divide-slate-100 overflow-hidden rounded-lg border border-slate-200 bg-white">
|
what="Opinie osób, które ukończyły szkolenie. Publikuj wyłącznie te, na które masz zgodę autora: wymyślona opinia jest nieprawdą o nieistniejącej osobie, dlatego nie zakładamy tu przykładów."
|
||||||
{data.faq.map((f) => (
|
to={panelPath('/ustawienia/strona-glowna')}
|
||||||
<details key={f.id} className="group">
|
/>
|
||||||
{/* Element details, a nie wlasny stan: dziala bez JavaScriptu,
|
) : (
|
||||||
obsluguje klawiature i jest czytany przez czytniki ekranu
|
<>
|
||||||
bez dopisywania atrybutow aria. */}
|
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">
|
||||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-sm font-medium text-slate-900 hover:bg-slate-25 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-blue-500/45">
|
{data.reviewsTitle}
|
||||||
<span className="min-w-0">
|
</h2>
|
||||||
{f.title}
|
<div className="mt-6 space-y-4">
|
||||||
{f.sample && <SampleBadge to={panelPath('/ustawienia/strona-glowna')} />}
|
{data.reviews.map((r) => (
|
||||||
</span>
|
<figure
|
||||||
<Icon
|
key={r.id}
|
||||||
name="chevron-down"
|
className="rounded-lg border border-slate-100 bg-slate-25 p-5"
|
||||||
size={18}
|
>
|
||||||
className="shrink-0 text-slate-400 transition-transform duration-fast group-open:rotate-180"
|
<Icon name="message-square" size={18} className="text-aqua-500" />
|
||||||
/>
|
<blockquote className="mt-2.5 text-sm leading-relaxed text-slate-700">
|
||||||
</summary>
|
{r.body}
|
||||||
{f.body && (
|
</blockquote>
|
||||||
<div className="px-5 pb-4 text-sm leading-relaxed text-slate-600">
|
<figcaption className="mt-3.5 border-t border-slate-200 pt-3">
|
||||||
{f.body}
|
<p className="text-sm font-semibold text-slate-900">{r.title}</p>
|
||||||
</div>
|
{r.subtitle && (
|
||||||
)}
|
<p className="text-xs text-slate-500">{r.subtitle}</p>
|
||||||
</details>
|
)}
|
||||||
))}
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
{/* --- FAQ --- */}
|
||||||
</div>
|
{showFaq && (
|
||||||
</Band>
|
<div>
|
||||||
|
{data.faq.length === 0 ? (
|
||||||
|
<AdminSectionHint
|
||||||
|
flat
|
||||||
|
title={data.faqTitle}
|
||||||
|
what="Pytania i odpowiedzi w rozwijanej liście. Przy pierwszym uruchomieniu zakładamy trzy przykładowe pytania o działanie platformy."
|
||||||
|
to={panelPath('/ustawienia/strona-glowna')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">
|
||||||
|
{data.faqTitle}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-6 divide-y divide-slate-100 overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||||
|
{data.faq.map((f) => (
|
||||||
|
<details key={f.id} className="group">
|
||||||
|
{/* Element details, a nie wlasny stan: dziala bez JavaScriptu,
|
||||||
|
obsluguje klawiature i jest czytany przez czytniki ekranu
|
||||||
|
bez dopisywania atrybutow aria. */}
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-sm font-medium text-slate-900 hover:bg-slate-25 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-blue-500/45">
|
||||||
|
<span className="min-w-0">
|
||||||
|
{f.title}
|
||||||
|
{f.sample && (
|
||||||
|
<SampleBadge to={panelPath('/ustawienia/strona-glowna')} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
name="chevron-down"
|
||||||
|
size={18}
|
||||||
|
className="shrink-0 text-slate-400 transition-transform duration-fast group-open:rotate-180"
|
||||||
|
/>
|
||||||
|
</summary>
|
||||||
|
{f.body && (
|
||||||
|
<div className="px-5 pb-4 text-sm leading-relaxed text-slate-600">
|
||||||
|
{f.body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Band>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ================= CENNIK: tlo szare ================= */}
|
{/* ================= CENNIK: tlo szare ================= */}
|
||||||
{paidCourses.length > 0 && (
|
{paidCourses.length > 0 && (
|
||||||
|
|||||||
@@ -290,11 +290,10 @@ export function AcademyFooter({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-9 flex flex-col gap-2 border-t border-white/10 pt-5 text-xs text-slate-500 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mt-9 border-t border-white/10 pt-5 text-xs text-slate-500">
|
||||||
<span>
|
<span>
|
||||||
© {year} {brandName || 'Platforma szkoleniowa'}. Wszystkie prawa zastrzeżone.
|
© {year} {brandName || 'Platforma szkoleniowa'}. Wszystkie prawa zastrzeżone.
|
||||||
</span>
|
</span>
|
||||||
<span className="hx-data">Obsługiwane przez platformę webinarową</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import type { UpcomingWebinar } from '../api/types'
|
import type { UpcomingWebinar } from '../api/types'
|
||||||
import { Icon } from '../components/Icon'
|
import { Icon } from '../components/Icon'
|
||||||
import { formatDateTimeParts } from '../lib/dates'
|
import { formatDateTimeParts } from '../lib/dates'
|
||||||
@@ -27,9 +26,15 @@ import { formatDateTimeParts } from '../lib/dates'
|
|||||||
* zapisuje. Przycisk mowi wprost "Przejdz do zapisu", a nie "Zapisz sie":
|
* zapisuje. Przycisk mowi wprost "Przejdz do zapisu", a nie "Zapisz sie":
|
||||||
* przycisk, ktory nie robi tego, co obiecuje, jest gorszy niz jedno klikniecie
|
* przycisk, ktory nie robi tego, co obiecuje, jest gorszy niz jedno klikniecie
|
||||||
* wiecej. Adres nie przepada, wiec uzytkownik nie pisze go drugi raz.
|
* wiecej. Adres nie przepada, wiec uzytkownik nie pisze go drugi raz.
|
||||||
|
*
|
||||||
|
* STRONA ZAPISU OTWIERA SIE W NOWEJ KARCIE.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Uzytkownik przegladajacy strone glowna nie traci jej z oczu: po zapisaniu
|
||||||
|
* moze wrocic do listy kolejnych webinarow. Dlatego zamiast `navigate` (ta
|
||||||
|
* sama karta) otwieramy `window.open` z `noopener,noreferrer` - nowa karta
|
||||||
|
* nie dostaje dostepu do `window.opener`.
|
||||||
*/
|
*/
|
||||||
export function HomeWebinarCard({ webinar }: { webinar: UpcomingWebinar }) {
|
export function HomeWebinarCard({ webinar }: { webinar: UpcomingWebinar }) {
|
||||||
const navigate = useNavigate()
|
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const when = formatDateTimeParts(webinar.startsAt, webinar.timezone)
|
const when = formatDateTimeParts(webinar.startsAt, webinar.timezone)
|
||||||
|
|
||||||
@@ -41,11 +46,10 @@ export function HomeWebinarCard({ webinar }: { webinar: UpcomingWebinar }) {
|
|||||||
* daje sie odswiezyc i podeslac. Nie buduje sie z niego zadnej sciezki,
|
* daje sie odswiezyc i podeslac. Nie buduje sie z niego zadnej sciezki,
|
||||||
* wiec nie ma tu ryzyka przekierowania na obcy adres.
|
* wiec nie ma tu ryzyka przekierowania na obcy adres.
|
||||||
*/
|
*/
|
||||||
navigate(
|
const url = trimmed
|
||||||
trimmed
|
? `/w/${webinar.slug}?email=${encodeURIComponent(trimmed)}`
|
||||||
? `/w/${webinar.slug}?email=${encodeURIComponent(trimmed)}`
|
: `/w/${webinar.slug}`
|
||||||
: `/w/${webinar.slug}`,
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { BrandMark } from '../components/BrandMark'
|
|||||||
import { formatWithZone } from '../lib/dates'
|
import { formatWithZone } from '../lib/dates'
|
||||||
import { CourseCover, ProgressBar } from './AcademyBits'
|
import { CourseCover, ProgressBar } from './AcademyBits'
|
||||||
import { useStudentAuth } from './StudentAuthContext'
|
import { useStudentAuth } from './StudentAuthContext'
|
||||||
import { STUDENT_ROUTES } from '../lib/routes'
|
|
||||||
|
|
||||||
type Tab = 'kursy' | 'webinary' | 'certyfikaty' | 'dane'
|
type Tab = 'kursy' | 'webinary' | 'certyfikaty' | 'dane'
|
||||||
|
|
||||||
@@ -41,7 +40,8 @@ export function StudentDashboardPage() {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
navigate(STUDENT_ROUTES.login, { replace: true })
|
// po wylogowaniu na strone glowna, nie na formularz logowania
|
||||||
|
navigate('/', { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Play,
|
Play,
|
||||||
Presentation,
|
Presentation,
|
||||||
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
Send,
|
Send,
|
||||||
@@ -109,6 +110,7 @@ const ICONS = {
|
|||||||
plus: Plus,
|
plus: Plus,
|
||||||
play: Play,
|
play: Play,
|
||||||
presentation: Presentation,
|
presentation: Presentation,
|
||||||
|
refresh: RefreshCw,
|
||||||
save: Save,
|
save: Save,
|
||||||
search: Search,
|
search: Search,
|
||||||
send: Send,
|
send: Send,
|
||||||
|
|||||||
@@ -46,6 +46,19 @@ export function Layout() {
|
|||||||
|
|
||||||
useInstanceFavicon()
|
useInstanceFavicon()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wylogowanie przenosi na strone glowna, nie na formularz logowania.
|
||||||
|
*
|
||||||
|
* Sam `logout` tylko czysci stan; bez jawnej nawigacji guard RequireAuth
|
||||||
|
* przerzucilby na /logowanie, gdy `user` znika. Nawigujemy na `/` PRZED
|
||||||
|
* wyczyszczeniem stanu, zeby uzytkownik trafil na strone publiczna, a nie
|
||||||
|
* na ekran logowania.
|
||||||
|
*/
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
logout()
|
||||||
|
}, [navigate, logout])
|
||||||
|
|
||||||
// zmiana ekranu zamyka menu, zeby nie zostawalo otwarte nad trescia
|
// zmiana ekranu zamyka menu, zeby nie zostawalo otwarte nad trescia
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
@@ -176,7 +189,7 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={logout}
|
onClick={handleLogout}
|
||||||
className="flex min-h-[44px] items-center gap-2 rounded-md px-3 text-sm font-medium text-slate-600 transition duration-fast ease-standard hover:bg-slate-50"
|
className="flex min-h-[44px] items-center gap-2 rounded-md px-3 text-sm font-medium text-slate-600 transition duration-fast ease-standard hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<Icon name="log-out" size={18} />
|
<Icon name="log-out" size={18} />
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export const NAV_COMMON: NavItem[] = [
|
|||||||
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] },
|
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] },
|
||||||
{ to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] },
|
{ to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] },
|
||||||
{ to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] },
|
{ to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] },
|
||||||
|
{ to: panelPath('/klucze-api'), label: 'Klucze API', icon: 'key', roles: ['ADMIN'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Czy rola widzi pozycje. Brak listy rol oznacza: kazdy zalogowany. */
|
/** Czy rola widzi pozycje. Brak listy rol oznacza: kazdy zalogowany. */
|
||||||
|
|||||||
@@ -1,22 +1,50 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { usePublicConfig } from './usePublicConfig'
|
||||||
|
|
||||||
type Meta = {
|
type Meta = {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
|
/**
|
||||||
|
* Strona glowna: tytul jest sama nazwa marki, bez doklejania jej po
|
||||||
|
* myslniku (inaczej wyszloby "Akademia Wodociagowca - Akademia Wodociagowca").
|
||||||
|
*/
|
||||||
|
brandOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ustawia tytul strony i podstawowe meta tagi.
|
* Ustawia tytul strony i podstawowe meta tagi.
|
||||||
*
|
*
|
||||||
|
* NAZWA MARKI W TYTULE KARTY
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Tytul karty przegladarki ZAWSZE zawiera nazwe marki z ustawien
|
||||||
|
* (`APP_BRAND_NAME`, pole "Nazwa wyswietlana w mailach i w naglowku"). Strona
|
||||||
|
* przekazuje wlasny tytul opisujacy JEJ tresc ("Kursy online", "Cennik"),
|
||||||
|
* a hook doklada do niego marke: "Kursy online - Akademia Wodociagowca".
|
||||||
|
* Dzieki temu w karcie widac i marke, i to, ktora strona jest otwarta, a nowy
|
||||||
|
* ekran nie musi o tym pamietac.
|
||||||
|
*
|
||||||
|
* Strona glowna podaje `brandOnly`, bo jej tytulem jest sama marka.
|
||||||
|
*
|
||||||
* Aplikacja jest SPA, wiec te tagi widzi dopiero klient po wykonaniu skryptu.
|
* Aplikacja jest SPA, wiec te tagi widzi dopiero klient po wykonaniu skryptu.
|
||||||
* Komunikatory pobierajace zajawke bez uruchamiania JavaScriptu zobacza wartosci
|
* Komunikatory pobierajace zajawke bez uruchamiania JavaScriptu zobacza wartosci
|
||||||
* z index.html. Pelna zajawka per webinar wymagalaby renderowania po stronie
|
* z index.html. Pelna zajawka per webinar wymagalaby renderowania po stronie
|
||||||
* serwera albo prerenderingu, co jest poza zakresem tego kroku.
|
* serwera albo prerenderingu, co jest poza zakresem tego kroku.
|
||||||
*/
|
*/
|
||||||
export function useDocumentMeta({ title, description }: Meta) {
|
export function useDocumentMeta({ title, description, brandOnly = false }: Meta) {
|
||||||
|
const { brandName } = usePublicConfig()
|
||||||
|
|
||||||
|
// dopoki config sie nie zaladuje, `brandName` jest pusty: uzywamy wtedy
|
||||||
|
// samego tytulu strony, zeby w karcie nie mignal wiszacy myslnik
|
||||||
|
const brand = brandName.trim()
|
||||||
|
const fullTitle = brandOnly
|
||||||
|
? brand || title
|
||||||
|
: brand
|
||||||
|
? `${title} - ${brand}`
|
||||||
|
: title
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousTitle = document.title
|
const previousTitle = document.title
|
||||||
document.title = title
|
document.title = fullTitle
|
||||||
|
|
||||||
const tags: HTMLMetaElement[] = []
|
const tags: HTMLMetaElement[] = []
|
||||||
|
|
||||||
@@ -31,11 +59,11 @@ export function useDocumentMeta({ title, description }: Meta) {
|
|||||||
tag.setAttribute('content', content)
|
tag.setAttribute('content', content)
|
||||||
}
|
}
|
||||||
|
|
||||||
setMeta('property', 'og:title', title)
|
setMeta('property', 'og:title', fullTitle)
|
||||||
setMeta('property', 'og:type', 'website')
|
setMeta('property', 'og:type', 'website')
|
||||||
setMeta('property', 'og:url', window.location.href)
|
setMeta('property', 'og:url', window.location.href)
|
||||||
setMeta('name', 'twitter:card', 'summary')
|
setMeta('name', 'twitter:card', 'summary')
|
||||||
setMeta('name', 'twitter:title', title)
|
setMeta('name', 'twitter:title', fullTitle)
|
||||||
|
|
||||||
if (description) {
|
if (description) {
|
||||||
setMeta('name', 'description', description)
|
setMeta('name', 'description', description)
|
||||||
@@ -48,5 +76,5 @@ export function useDocumentMeta({ title, description }: Meta) {
|
|||||||
// usuwamy tylko tagi dodane przez ten hook, istniejace zostaja
|
// usuwamy tylko tagi dodane przez ten hook, istniejace zostaja
|
||||||
tags.forEach((tag) => tag.remove())
|
tags.forEach((tag) => tag.remove())
|
||||||
}
|
}
|
||||||
}, [title, description])
|
}, [fullTitle, description])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||||
|
import { useToasts } from '../../components/Toasts'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
Field,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
} from '../../components/ui'
|
||||||
|
import { Icon } from '../../components/Icon'
|
||||||
|
import { formatLocal } from '../../lib/dates'
|
||||||
|
|
||||||
|
/** Klucz API zwracany na liscie: bez samego klucza, tylko prefiks. */
|
||||||
|
type ApiKey = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
prefix: string
|
||||||
|
active: boolean
|
||||||
|
lastUsedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Odpowiedz na utworzenie: widok plus surowy klucz pokazany raz. */
|
||||||
|
type ApiKeyCreated = {
|
||||||
|
apiKey: ApiKey
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zarzadzanie kluczami API do publicznego API.
|
||||||
|
*
|
||||||
|
* SUROWY KLUCZ POKAZANY RAZ. Backend trzyma wylacznie skrot, wiec po zamknieciu
|
||||||
|
* panelu klucza nie da sie odczytac ponownie: pokazujemy go od razu po
|
||||||
|
* utworzeniu, z przyciskiem kopiowania i wyraznym ostrzezeniem.
|
||||||
|
*/
|
||||||
|
export function ApiKeyListPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const toasts = useToasts()
|
||||||
|
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
/** Surowy klucz po utworzeniu, pokazywany jeden raz. */
|
||||||
|
const [created, setCreated] = useState<ApiKeyCreated | null>(null)
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['api-keys'],
|
||||||
|
queryFn: async () => (await client.get<ApiKey[]>('/admin/api-keys')).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async (payload: { name: string }) => {
|
||||||
|
const response = await client.post<ApiKeyCreated>('/admin/api-keys', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setCreated(data)
|
||||||
|
setCreating(false)
|
||||||
|
setName('')
|
||||||
|
setErrors({})
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setErrors(fieldErrors(error))
|
||||||
|
toasts.error(errorMessage(error, 'Nie udało się utworzyć klucza'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const regenerate = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const response = await client.post<ApiKeyCreated>(`/admin/api-keys/${id}/regenerate`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setCreated(data)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||||
|
},
|
||||||
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wygenerować klucza ponownie')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const setActive = useMutation({
|
||||||
|
mutationFn: ({ id, active }: { id: number; active: boolean }) =>
|
||||||
|
client.post(`/admin/api-keys/${id}/${active ? 'activate' : 'deactivate'}`),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
toasts.success(variables.active ? 'Klucz włączony' : 'Klucz wyłączony')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||||
|
},
|
||||||
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić stanu klucza')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: number) => client.delete(`/admin/api-keys/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toasts.success('Klucz usunięty')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||||
|
},
|
||||||
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć klucza')),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!name.trim()) {
|
||||||
|
setErrors({ name: 'Nazwa klucza jest wymagana' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
create.mutate({ name: name.trim() })
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyKey() {
|
||||||
|
if (!created) return
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(created.key)
|
||||||
|
.then(() => toasts.success('Klucz skopiowany do schowka'))
|
||||||
|
.catch(() => toasts.error('Nie udało się skopiować. Zaznacz i skopiuj ręcznie.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isPending) return <Spinner />
|
||||||
|
if (query.isError) {
|
||||||
|
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać kluczy API')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-slate-900">Klucze API</h1>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Dostęp do publicznego API z listą aktywnych webinarów i kursów
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="plus"
|
||||||
|
onClick={() => {
|
||||||
|
setCreating(true)
|
||||||
|
setName('')
|
||||||
|
setErrors({})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Nowy klucz
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert tone="info" icon="help-circle">
|
||||||
|
Klucze chronią publiczne API tylko do odczytu:
|
||||||
|
{' '}
|
||||||
|
<code className="rounded bg-white/70 px-1.5 py-0.5 text-xs">GET /api/public/v1/webinars</code>
|
||||||
|
{' '}i{' '}
|
||||||
|
<code className="rounded bg-white/70 px-1.5 py-0.5 text-xs">GET /api/public/v1/courses</code>.
|
||||||
|
{' '}Odbiorca przekazuje klucz w nagłówku{' '}
|
||||||
|
<code className="rounded bg-white/70 px-1.5 py-0.5 text-xs">X-API-Key</code>.
|
||||||
|
{' '}Możesz wydać wiele kluczy dla różnych odbiorców i wyłączać je niezależnie.
|
||||||
|
{' '}Klucz jest pokazywany tylko raz przy utworzeniu i nie da się go później
|
||||||
|
odczytać. Gdy zostanie zgubiony, użyj{' '}
|
||||||
|
<span className="whitespace-nowrap">„wygeneruj ponownie”</span>
|
||||||
|
{' '}(ikona odświeżania) — powstaje nowy klucz, a stary przestaje działać.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Surowy klucz po utworzeniu: pokazany jeden raz. */}
|
||||||
|
{created && (
|
||||||
|
<div className="rounded-lg border border-warn-500/25 bg-warn-50 p-4">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Icon name="key" className="mt-0.5 shrink-0 text-warn-600" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-semibold text-warn-600">
|
||||||
|
Nowy klucz dla „{created.apiKey.name}”
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 break-all rounded bg-white px-2 py-1.5 font-mono text-sm text-slate-900">
|
||||||
|
{created.key}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 text-xs text-warn-600">
|
||||||
|
Klucz jest pokazany tylko raz. Skopiuj go teraz i przekaż odbiorcy bezpiecznym
|
||||||
|
kanałem. Po zamknięciu nie da się go odczytać ponownie.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Button size="sm" variant="secondary" icon="copy" onClick={copyKey}>
|
||||||
|
Kopiuj klucz
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreated(null)}
|
||||||
|
className="-m-1 rounded p-1 text-warn-600 transition duration-fast ease-standard hover:text-warn-500"
|
||||||
|
aria-label="Zamknij"
|
||||||
|
>
|
||||||
|
<Icon name="x" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<Card title="Nowy klucz API">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Field
|
||||||
|
label="Nazwa odbiorcy"
|
||||||
|
error={errors.name}
|
||||||
|
hint="Do czego lub dla kogo klucz. Np. „Strona firmowa”, „Integracja CRM”."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="np. Strona firmowa"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCreating(false)
|
||||||
|
setErrors({})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Anuluj
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" icon="key" disabled={create.isPending}>
|
||||||
|
{create.isPending ? 'Generowanie...' : 'Wygeneruj klucz'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{query.data.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="key"
|
||||||
|
title="Brak kluczy API"
|
||||||
|
description="Utwórz pierwszy klucz, aby udostępnić publiczne API zewnętrznemu odbiorcy."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
|
||||||
|
{/* karty na telefonie, tabela od duzego ekranu */}
|
||||||
|
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||||
|
{query.data.map((key) => (
|
||||||
|
<li key={key.id} className="space-y-3 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-slate-900">{key.name}</p>
|
||||||
|
<p className="hx-data truncate text-sm text-slate-500">{key.prefix}…</p>
|
||||||
|
</div>
|
||||||
|
{key.active ? (
|
||||||
|
<Badge tone="ok" dot>Aktywny</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="alarm">Wyłączony</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Ostatnio użyty: {key.lastUsedAt ? formatLocal(key.lastUsedAt) : 'nigdy'}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<IconButton
|
||||||
|
icon="refresh"
|
||||||
|
label="Wygeneruj ponownie"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
regenerate.mutate(key.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={regenerate.isPending}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={key.active ? 'eye-off' : 'eye'}
|
||||||
|
label={key.active ? 'Wyłącz' : 'Włącz'}
|
||||||
|
onClick={() => setActive.mutate({ id: key.id, active: !key.active })}
|
||||||
|
disabled={setActive.isPending}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="trash"
|
||||||
|
label="Usuń"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) {
|
||||||
|
remove.mutate(key.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
className="text-alarm-600 hover:bg-alarm-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="hidden overflow-x-auto lg:block">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-semibold">Nazwa</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Klucz</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Stan</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Ostatnio użyty</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Utworzony</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold">Akcje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{query.data.map((key) => (
|
||||||
|
<tr key={key.id} className="hover:bg-slate-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-slate-900">{key.name}</td>
|
||||||
|
<td className="hx-data px-4 py-3 text-slate-500">{key.prefix}…</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{key.active ? (
|
||||||
|
<Badge tone="ok" dot>Aktywny</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="alarm">Wyłączony</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
|
||||||
|
{key.lastUsedAt ? formatLocal(key.lastUsedAt) : 'nigdy'}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
|
||||||
|
{formatLocal(key.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="refresh"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
regenerate.mutate(key.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={regenerate.isPending}
|
||||||
|
title="Wygeneruj ponownie"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Wygeneruj ponownie</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon={key.active ? 'eye-off' : 'eye'}
|
||||||
|
onClick={() => setActive.mutate({ id: key.id, active: !key.active })}
|
||||||
|
disabled={setActive.isPending}
|
||||||
|
title={key.active ? 'Wyłącz' : 'Włącz'}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{key.active ? 'Wyłącz' : 'Włącz'}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="trash"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) {
|
||||||
|
remove.mutate(key.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
title="Usuń"
|
||||||
|
className="text-alarm-600 hover:bg-alarm-50"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Usuń</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,23 +1,37 @@
|
|||||||
import { useParams } from 'react-router-dom'
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { publicClient } from '../../api/publicClient'
|
import { publicClient } from '../../api/publicClient'
|
||||||
import type { CertificateVerification } from '../../api/types'
|
import type { CertificateVerification } from '../../api/types'
|
||||||
import { Icon } from '../../components/Icon'
|
import { Icon } from '../../components/Icon'
|
||||||
import { PublicLayout } from './PublicLayout'
|
import { Button, Input } from '../../components/ui'
|
||||||
|
import { AcademyLayout } from '../../academy/AcademyLayout'
|
||||||
import { formatLocal } from '../../lib/dates'
|
import { formatLocal } from '../../lib/dates'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Weryfikacja certyfikatu.
|
* Weryfikacja certyfikatu.
|
||||||
*
|
*
|
||||||
|
* DO CZEGO SLUZY
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* To jest PUBLICZNA weryfikacja po numerze, dla osoby z zewnatrz (pracodawca,
|
||||||
|
* instytucja), ktora dostala dokument i chce potwierdzic jego autentycznosc.
|
||||||
|
* Celowo bez logowania: sprawdzajacy nie ma u nas konta, a numer widniejacy
|
||||||
|
* na dokumencie sluzy wlasnie do takiego sprawdzenia. To NIE jest miejsce,
|
||||||
|
* gdzie kursant oglada wlasne certyfikaty - te sa na pulpicie po zalogowaniu.
|
||||||
|
*
|
||||||
* CO POKAZUJEMY, A CZEGO NIE
|
* CO POKAZUJEMY, A CZEGO NIE
|
||||||
* ------------------------------------------------------------------------
|
* ------------------------------------------------------------------------
|
||||||
* Strona jest otwarta dla kazdego, kto zna numer, wiec pokazuje **wylacznie**
|
* Strona jest otwarta dla kazdego, kto zna numer, wiec pokazuje **wylacznie**
|
||||||
* to, co potrzebne do potwierdzenia autentycznosci: numer, tytul, date, imie
|
* to, co potrzebne do potwierdzenia autentycznosci: numer, tytul, date, imie
|
||||||
* z pierwsza litera nazwiska i informacje o waznosci.
|
* z pierwsza litera nazwiska i informacje o waznosci. Nie ma tu pelnego
|
||||||
|
* nazwiska, adresu e-mail ani mozliwosci pobrania pliku.
|
||||||
*
|
*
|
||||||
* Nie ma tu pelnego nazwiska, adresu e-mail ani mozliwosci pobrania pliku.
|
* WEJSCIE BEZ NUMERU POKAZUJE FORMULARZ, NIE BLAD
|
||||||
* Pracodawca sprawdzajacy dokument ma potwierdzic, ze jest prawdziwy, a nie
|
* ------------------------------------------------------------------------
|
||||||
* poznac dane osoby, ktorej nie zna, ani pobrac cudzy certyfikat.
|
* Trasa `/certyfikat/*` lapie takze gole `/certyfikat` (ze stopki). Bez numeru
|
||||||
|
* nie ma czego weryfikowac, wiec zamiast odpytywac API o pusty numer i straszyc
|
||||||
|
* czerwonym "nie znaleziono", pokazujemy pole do wpisania numeru. Komunikat
|
||||||
|
* bledu pojawia sie dopiero po realnym wpisaniu numeru, ktory nie istnieje.
|
||||||
*/
|
*/
|
||||||
export function CertificateVerifyPage() {
|
export function CertificateVerifyPage() {
|
||||||
/*
|
/*
|
||||||
@@ -26,8 +40,97 @@ export function CertificateVerifyPage() {
|
|||||||
* by sie nie dopasowala. `*` lapie cala reszte sciezki wraz z ukosnikami.
|
* by sie nie dopasowala. `*` lapie cala reszte sciezki wraz z ukosnikami.
|
||||||
*/
|
*/
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const number = decodeURIComponent(params['*'] ?? '')
|
const number = decodeURIComponent(params['*'] ?? '').trim()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AcademyLayout>
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-slate-500 transition-colors duration-fast hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size={16} />
|
||||||
|
Strona główna
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{number ? <VerifyResult number={number} /> : <VerifyForm />}
|
||||||
|
</div>
|
||||||
|
</AcademyLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formularz wpisania numeru.
|
||||||
|
*
|
||||||
|
* Po wyslaniu przechodzimy na `/certyfikat/{numer}`: numer trafia do adresu,
|
||||||
|
* wiec wynik da sie odswiezyc, dodac do zakladek i podeslac linkiem - tak samo
|
||||||
|
* jak przy wejsciu z linku na dokumencie.
|
||||||
|
*/
|
||||||
|
function VerifyForm() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
|
||||||
|
const submit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
// numer moze zawierac ukosnik ("2026/1") - trasa z wildcardem to obsluguje,
|
||||||
|
// ale poszczegolne segmenty koduje sie, zeby znaki specjalne nie rozbily URL
|
||||||
|
const encoded = trimmed.split('/').map(encodeURIComponent).join('/')
|
||||||
|
navigate(`/certyfikat/${encoded}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-slate-200 bg-white p-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<Icon name="award" size={36} className="mx-auto text-blue-500" />
|
||||||
|
<h1 className="mt-3 text-lg font-semibold text-slate-900">Weryfikacja certyfikatu</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Wpisz numer widniejący na dokumencie, aby sprawdzić jego autentyczność.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="mx-auto mt-6 flex max-w-md flex-col gap-3 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
placeholder="np. 2026/1"
|
||||||
|
aria-label="Numer certyfikatu"
|
||||||
|
className="hx-data flex-1"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" icon="search" disabled={!value.trim()}>
|
||||||
|
Sprawdź
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Skad wziac numer. Sprawdzajacy z zewnatrz (pracodawca) trzyma dokument
|
||||||
|
w reku i moze nie wiedziec, ktora liczba na nim to numer do weryfikacji.
|
||||||
|
*/}
|
||||||
|
<div className="mx-auto mt-6 max-w-md rounded-lg bg-slate-50 p-4 text-sm text-slate-600">
|
||||||
|
<p className="flex items-center gap-2 font-medium text-slate-700">
|
||||||
|
<Icon name="help-circle" size={16} className="text-slate-400" />
|
||||||
|
Skąd wziąć numer certyfikatu?
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 leading-relaxed">
|
||||||
|
Numer widnieje na samym dokumencie (PDF lub wydruku), zwykle w formacie
|
||||||
|
<span className="hx-data"> rok/numer</span>, na przykład
|
||||||
|
<span className="hx-data"> 2026/1</span>. Znajdziesz go w treści
|
||||||
|
certyfikatu, przy dacie wystawienia. Przepisz go dokładnie, razem
|
||||||
|
z ukośnikiem.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-5 border-t border-slate-100 pt-4 text-center text-xs text-slate-500">
|
||||||
|
Chcesz zobaczyć własne certyfikaty? Znajdziesz je po zalogowaniu na swoim koncie.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wynik weryfikacji dla podanego numeru. */
|
||||||
|
function VerifyResult({ number }: { number: string }) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['certificate-verify', number],
|
queryKey: ['certificate-verify', number],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -42,71 +145,72 @@ export function CertificateVerifyPage() {
|
|||||||
|
|
||||||
if (query.isPending) {
|
if (query.isPending) {
|
||||||
return (
|
return (
|
||||||
<PublicLayout>
|
<div className="flex justify-center py-16">
|
||||||
<div className="flex justify-center py-16">
|
<Icon name="loader" size={28} className="animate-spin text-slate-400" />
|
||||||
<Icon name="loader" size={28} className="animate-spin text-slate-400" />
|
</div>
|
||||||
</div>
|
|
||||||
</PublicLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.isError) {
|
if (query.isError) {
|
||||||
return (
|
return (
|
||||||
<PublicLayout>
|
<section className="rounded-xl border border-slate-200 bg-white p-6 text-center">
|
||||||
<section className="rounded-xl border border-slate-200 bg-white p-6 text-center">
|
<Icon name="x-circle" size={36} className="mx-auto text-alarm-500" />
|
||||||
<Icon name="x-circle" size={36} className="mx-auto text-alarm-500" />
|
<h1 className="mt-3 text-lg font-semibold text-slate-900">
|
||||||
<h1 className="mt-3 text-lg font-semibold text-slate-900">
|
Nie znaleźliśmy takiego certyfikatu
|
||||||
Nie znaleźliśmy takiego certyfikatu
|
</h1>
|
||||||
</h1>
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
<p className="mt-1 text-sm text-slate-600">
|
Sprawdź, czy numer został przepisany dokładnie tak, jak widnieje na dokumencie.
|
||||||
Sprawdź, czy numer został przepisany dokładnie tak, jak widnieje na dokumencie.
|
</p>
|
||||||
</p>
|
<div className="mt-5">
|
||||||
</section>
|
<Link to="/certyfikat">
|
||||||
</PublicLayout>
|
<Button variant="secondary" size="sm" icon="arrow-left">
|
||||||
|
Wpisz numer ponownie
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = query.data
|
const data = query.data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicLayout>
|
<section className="rounded-xl border border-slate-200 bg-white p-6">
|
||||||
<section className="rounded-xl border border-slate-200 bg-white p-6">
|
<div className="text-center">
|
||||||
<div className="text-center">
|
{data.valid ? (
|
||||||
{data.valid ? (
|
<>
|
||||||
<>
|
<Icon name="check-circle" size={40} className="mx-auto text-ok-500" />
|
||||||
<Icon name="check-circle" size={40} className="mx-auto text-ok-500" />
|
<h1 className="mt-3 text-lg font-semibold text-slate-900">Certyfikat jest ważny</h1>
|
||||||
<h1 className="mt-3 text-lg font-semibold text-slate-900">Certyfikat jest ważny</h1>
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
<Icon name="x-circle" size={40} className="mx-auto text-alarm-500" />
|
||||||
<Icon name="x-circle" size={40} className="mx-auto text-alarm-500" />
|
<h1 className="mt-3 text-lg font-semibold text-slate-900">
|
||||||
<h1 className="mt-3 text-lg font-semibold text-slate-900">
|
Certyfikat został unieważniony
|
||||||
Certyfikat został unieważniony
|
</h1>
|
||||||
</h1>
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
<p className="mt-1 text-sm text-slate-600">
|
Dokument o tym numerze istnieje, ale wystawca wycofał go z obiegu.
|
||||||
Dokument o tym numerze istnieje, ale wystawca wycofał go z obiegu.
|
</p>
|
||||||
</p>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="mt-6 divide-y divide-slate-100 border-t border-slate-100">
|
<dl className="mt-6 divide-y divide-slate-100 border-t border-slate-100">
|
||||||
<Row label="Numer certyfikatu" value={data.number} mono />
|
<Row label="Numer certyfikatu" value={data.number} mono />
|
||||||
<Row label={data.forCourse ? 'Kurs' : 'Szkolenie'} value={data.subjectTitle} />
|
<Row label={data.forCourse ? 'Kurs' : 'Szkolenie'} value={data.subjectTitle} />
|
||||||
<Row label="Uczestnik" value={data.participantName} />
|
<Row label="Uczestnik" value={data.participantName} />
|
||||||
<Row label="Data wystawienia" value={formatLocal(data.issuedAt)} mono />
|
<Row label="Data wystawienia" value={formatLocal(data.issuedAt)} mono />
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Jawne powiedzenie, czego tu nie ma. Sprawdzajacy widzi skrocone imie
|
Jawne powiedzenie, czego tu nie ma. Sprawdzajacy widzi skrocone imie
|
||||||
i moze uznac to za blad; to jest decyzja, nie usterka.
|
i moze uznac to za blad; to jest decyzja, nie usterka.
|
||||||
*/}
|
*/}
|
||||||
<p className="mt-5 border-t border-slate-100 pt-4 text-xs text-slate-500">
|
<p className="mt-5 border-t border-slate-100 pt-4 text-xs text-slate-500">
|
||||||
Ze względu na ochronę danych osobowych pokazujemy imię i pierwszą literę nazwiska.
|
Ze względu na ochronę danych osobowych pokazujemy imię i pierwszą literę nazwiska.
|
||||||
Pełne dane znajdują się na dokumencie, który posiada uczestnik.
|
Pełne dane znajdują się na dokumencie, który posiada uczestnik.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</PublicLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user