diff --git a/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyAdminController.java b/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyAdminController.java new file mode 100644 index 0000000..2a0b5b7 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyAdminController.java @@ -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 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) { + } +} diff --git a/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyCreated.java b/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyCreated.java new file mode 100644 index 0000000..b23a1ee --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyCreated.java @@ -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) { +} diff --git a/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyService.java b/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyService.java new file mode 100644 index 0000000..e473421 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyService.java @@ -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 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 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); + } + } +} diff --git a/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyView.java b/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyView.java new file mode 100644 index 0000000..f49f5a5 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/apikey/ApiKeyView.java @@ -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()); + } +} diff --git a/backend/src/main/java/com/softspm/webinar/apikey/PublicApiController.java b/backend/src/main/java/com/softspm/webinar/apikey/PublicApiController.java new file mode 100644 index 0000000..8f1bba4 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/apikey/PublicApiController.java @@ -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 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 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."); + } + } +} diff --git a/backend/src/main/java/com/softspm/webinar/apikey/PublicApiDtos.java b/backend/src/main/java/com/softspm/webinar/apikey/PublicApiDtos.java new file mode 100644 index 0000000..c59ee5e --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/apikey/PublicApiDtos.java @@ -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) { + } +} diff --git a/backend/src/main/java/com/softspm/webinar/apikey/PublicApiService.java b/backend/src/main/java/com/softspm/webinar/apikey/PublicApiService.java new file mode 100644 index 0000000..76b8303 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/apikey/PublicApiService.java @@ -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 activeWebinars() { + List active = webinars.findActive(OffsetDateTime.now()); + Map 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 activeCourses() { + List published = courses.findPublished(); + Map 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 registeredCounts(List list) { + if (list.isEmpty()) { + return Map.of(); + } + List ids = list.stream().map(Webinar::getId).toList(); + Map 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 enrolledCounts(List list) { + if (list.isEmpty()) { + return Map.of(); + } + List ids = list.stream().map(Course::getId).toList(); + Map result = new HashMap<>(); + addCounts(result, enrollments.countByCourseIds(ids, EnrollmentStatus.ACTIVE)); + addCounts(result, enrollments.countByCourseIds(ids, EnrollmentStatus.COMPLETED)); + return result; + } + + private void addCounts(Map target, List 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; + } +} diff --git a/backend/src/main/java/com/softspm/webinar/config/SecurityConfig.java b/backend/src/main/java/com/softspm/webinar/config/SecurityConfig.java index 1636161..94148ea 100644 --- a/backend/src/main/java/com/softspm/webinar/config/SecurityConfig.java +++ b/backend/src/main/java/com/softspm/webinar/config/SecurityConfig.java @@ -159,6 +159,13 @@ public class SecurityConfig { auth.requestMatchers("/admin/banners/**").hasRole("ADMIN"); auth.requestMatchers("/admin/settings/**").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 auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR"); diff --git a/backend/src/main/java/com/softspm/webinar/domain/ApiKey.java b/backend/src/main/java/com/softspm/webinar/domain/ApiKey.java new file mode 100644 index 0000000..6021b85 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/domain/ApiKey.java @@ -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; + } + } +} diff --git a/backend/src/main/java/com/softspm/webinar/repository/ApiKeyRepository.java b/backend/src/main/java/com/softspm/webinar/repository/ApiKeyRepository.java new file mode 100644 index 0000000..5d87520 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/repository/ApiKeyRepository.java @@ -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 { + + /** + * 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 findByKeyHashAndActiveTrue(String keyHash); + + boolean existsByKeyHash(String keyHash); + + /** Lista do panelu, najnowsze na gorze. */ + List findAllByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/com/softspm/webinar/repository/WebinarRepository.java b/backend/src/main/java/com/softspm/webinar/repository/WebinarRepository.java index 87eec3c..eed6d5c 100644 --- a/backend/src/main/java/com/softspm/webinar/repository/WebinarRepository.java +++ b/backend/src/main/java/com/softspm/webinar/repository/WebinarRepository.java @@ -95,6 +95,23 @@ public interface WebinarRepository extends JpaRepository { """) List 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 findActive(@Param("now") OffsetDateTime now); + /** * Lista webinarow z wyszukiwaniem po tytule i filtrami. * diff --git a/backend/src/main/java/com/softspm/webinar/web/ApiExceptionHandler.java b/backend/src/main/java/com/softspm/webinar/web/ApiExceptionHandler.java index b9e7aa6..36cadb8 100644 --- a/backend/src/main/java/com/softspm/webinar/web/ApiExceptionHandler.java +++ b/backend/src/main/java/com/softspm/webinar/web/ApiExceptionHandler.java @@ -43,6 +43,16 @@ public class ApiExceptionHandler { 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 handleUnauthorized(UnauthorizedException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiError.of(e.getMessage())); + } + /** Bledy adnotacji walidacyjnych na polach DTO. */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleFieldErrors(MethodArgumentNotValidException e) { diff --git a/backend/src/main/java/com/softspm/webinar/web/UnauthorizedException.java b/backend/src/main/java/com/softspm/webinar/web/UnauthorizedException.java new file mode 100644 index 0000000..5546139 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/web/UnauthorizedException.java @@ -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); + } +} diff --git a/backend/src/main/resources/db/migration/V30__api_keys.sql b/backend/src/main/resources/db/migration/V30__api_keys.sql new file mode 100644 index 0000000..9057854 --- /dev/null +++ b/backend/src/main/resources/db/migration/V30__api_keys.sql @@ -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 diff --git a/docs/api_public_v1.html b/docs/api_public_v1.html new file mode 100644 index 0000000..ffcae59 --- /dev/null +++ b/docs/api_public_v1.html @@ -0,0 +1,307 @@ + + + + + + Manual API - Publiczne API v1 (webinary i kursy) + + + +
+
+

Manual programisty: Publiczne API v1

+

Dokumentacja API tylko do odczytu z listą aktywnych webinarów i aktywnych kursów platformy szkoleniowej.

+ GET + application/json + Auth: nagłówek X-API-Key +
+ +
+

1. Informacje ogólne

+ + + + + + +
Adres bazowyhttps://TWOJA-DOMENA/api/public/v1
Metoda HTTPGET
Format odpowiedziapplication/json
KodowanieUTF-8
WersjonowanieWersja jest w ścieżce (/v1/). Zmiany łamiące trafią do /v2/, a /v1/ zostanie niezmienione.
+

Adres TWOJA-DOMENA to domena instancji (ta sama, na której działa strona zapisu). API jest wyłącznie do odczytu: nie tworzy ani nie zmienia danych.

+
+ +
+

2. Autoryzacja

+

Klucz API należy przesłać w nagłówku HTTP X-API-Key. Klucz generuje administrator w panelu (Ustawienia → Klucze API) i przekazuje go odbiorcy.

+
X-API-Key: TWOJ_KLUCZ_API
+
+ Klucz jest pokazywany jeden raz – 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. +
+

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.

+
+ +
+

3. Endpoint: aktywne webinary

+ + + +
AdresGET /api/public/v1/webinars
ZwracaTablicę JSON aktywnych webinarów (opublikowane i jeszcze niezakończone), posortowaną według daty rozpoczęcia rosnąco.
+ +

Pola pozycji

+ + + + + + + + + + + + + + + + +
PoleTypOpis
slugstringIdentyfikator w adresie strony zapisu.
titlestringTytuł webinaru.
descriptionstring / nullKrótki opis (zajawka). Bez znaczników HTML.
startsAtstring (ISO 8601)Termin rozpoczęcia z przesunięciem strefy, np. 2026-09-17T09:00:00+02:00.
endsAtstring (ISO 8601)Termin zakończenia.
timezonestringStrefa czasowa webinaru, np. Europe/Warsaw.
registrationOpenbooleanIntencja organizatora co do otwarcia zapisów.
capacityinteger / nullLimit miejsc. null oznacza brak limitu.
registeredCountintegerLiczba zajętych miejsc (bez rezygnacji).
registrationUrlstring / nullPełny adres strony zapisu. null, gdy w instancji nie ustawiono publicznego adresu.
+
+ +
+

4. Endpoint: aktywne kursy

+ + + +
AdresGET /api/public/v1/courses
ZwracaTablicę JSON opublikowanych kursów, w kolejności ustalonej przez administratora.
+ +

Pola pozycji

+ + + + + + + + + + + + + + + + + + +
PoleTypOpis
slugstringIdentyfikator w adresie strony kursu.
titlestringTytuł kursu.
subtitlestring / nullPodtytuł.
descriptionstring / nullKrótki opis.
categorystring / nullKategoria kursu.
levelstring / nullPoziom: BASIC, INTERMEDIATE lub ADVANCED.
durationMinutesinteger / nullŁączny czas materiału w minutach. null, gdy nie podano.
freebooleantrue dla kursu bezpłatnego.
priceGrossnumber / nullCena brutto. null dla kursu darmowego.
currencystringWaluta ceny, np. PLN.
enrolledCountintegerLiczba kursantów (zapisy aktywne i ukończone).
courseUrlstring / nullPełny adres strony kursu. null, gdy nie ustawiono publicznego adresu.
+
+ +
+

5. Przykład zapytania (cURL)

+
curl -X GET "https://TWOJA-DOMENA/api/public/v1/webinars" \
+  -H "X-API-Key: TWOJ_KLUCZ_API"
+
curl -X GET "https://TWOJA-DOMENA/api/public/v1/courses" \
+  -H "X-API-Key: TWOJ_KLUCZ_API"
+
+ +
+

6. Przykład zapytania (JavaScript / fetch)

+
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");
+
+ +
+

7. Przykład zapytania (PHP)

+
<?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";
+    }
+}
+
+ +
+

8. Odpowiedzi API

+ +

8.1 Sukces (HTTP 200) – webinary

+
[
+  {
+    "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"
+  }
+]
+ +

8.2 Sukces (HTTP 200) – kursy

+
[
+  {
+    "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"
+  }
+]
+

Brak pozycji zwraca pustą tablicę [] ze statusem 200 – to nie jest błąd.

+ +

8.3 Błędy

+

Ciało błędu ma postać { "message": "...", "fields": {} }.

+ + + + + + + +
HTTPKomunikatZnaczenie
401Nieprawidłowy lub brakujący klucz API.Brak nagłówka X-API-Key, pusty, błędny albo wyłączony klucz. Jeden komunikat dla wszystkich tych przypadków.
404Nie znaleziono zasobu pod tym adresem.Zły adres endpointu.
405Metoda ... nie jest obsługiwana pod tym adresem.Użyto innej metody niż GET.
+
+ +
+

9. Dobre praktyki po stronie klienta

+
    +
  • Wysyłaj żądania wyłącznie przez HTTPS.
  • +
  • Trzymaj klucz po stronie serwera. Nie umieszczaj go w kodzie strony ani w aplikacji przeglądarkowej, gdzie każdy go odczyta.
  • +
  • Maskuj X-API-Key w logach aplikacyjnych.
  • +
  • Buforuj odpowiedzi po swojej stronie (np. 1–5 minut). Dane zmieniają się rzadko, a odpytywanie co sekundę jest zbędne.
  • +
  • Przy statusie 401 nie ponawiaj automatycznie – sprawdź, czy klucz nie został wyłączony lub usunięty.
  • +
  • Przy błędach 5xx zastosuj ponawianie z limitem prób i narastającym odstępem (backoff).
  • +
+
+ +
+

10. Checklista testów integracyjnych

+
    +
  • Poprawne żądanie z ważnym kluczem zwraca HTTP 200 i tablicę JSON.
  • +
  • Brak nagłówka X-API-Key zwraca HTTP 401.
  • +
  • Błędny klucz zwraca HTTP 401.
  • +
  • Wyłączenie klucza w panelu natychmiast skutkuje HTTP 401.
  • +
  • Żądanie metodą POST pod adres endpointu zwraca HTTP 405.
  • +
  • Instancja bez aktywnych pozycji zwraca pustą tablicę [] ze statusem 200.
  • +
+
+
+ + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b0befd2..33ab27b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,6 +50,7 @@ import { ConsentSettingsPage } from './pages/settings/ConsentSettingsPage' import { CompanySettingsPage } from './pages/settings/CompanySettingsPage' import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage' import { UserListPage } from './pages/users/UserListPage' +import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage' import { AcademyChoicePage, AcademyPasswordResetPage, @@ -330,6 +331,14 @@ export function App() { } /> + + + + } + /> {/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */} } /> diff --git a/frontend/src/academy/AcademyHomePage.tsx b/frontend/src/academy/AcademyHomePage.tsx index b4ab01c..634b805 100644 --- a/frontend/src/academy/AcademyHomePage.tsx +++ b/frontend/src/academy/AcademyHomePage.tsx @@ -11,7 +11,7 @@ import { CourseCard, formatPrice, mediaUrl } from './AcademyBits' import { HomeWebinarCard } from './HomeWebinarCard' import { useStudentAuth } from './StudentAuthContext' 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' @@ -33,6 +33,9 @@ type PriceFilter = 'all' | 'free' | 'paid' */ export function AcademyHomePage() { const { student } = useStudentAuth() + // widocznosc placeholderow sekcji: admin i moderator widza podpowiedzi + // w miejscu pustych sekcji, gosc nie + const isStaff = useIsStaff() const [filter, setFilter] = useState('all') const [category, setCategory] = useState('') @@ -41,9 +44,12 @@ export function AcademyHomePage() { queryFn: async () => (await publicClient.get('/public/academy/home')).data, }) + // tytul karty to sama nazwa marki (APP_BRAND_NAME); haslo hero zostaje + // opisem zajawki, ale nie tytulem strony useDocumentMeta({ - title: data?.heroTitle ?? 'Akademia', - description: data?.heroText, + title: 'Akademia', + description: data?.heroText ?? data?.heroTitle, + brandOnly: true, }) const courses = useMemo(() => { @@ -301,88 +307,117 @@ export function AcademyHomePage() { )} - {/* ============ OPINIE I PYTANIA: dwie kolumny, tlo biale ============ */} - -
- {/* --- opinie --- */} -
- {data.reviews.length === 0 ? ( - - ) : ( - <> -

- {data.reviewsTitle} -

-
- {data.reviews.map((r) => ( -
- -
- {r.body} -
-
-

{r.title}

- {r.subtitle &&

{r.subtitle}

} -
-
- ))} -
- - )} -
+ {/* ============ OPINIE I PYTANIA: tlo biale ============ */} + {/* + Sekcje opinii i FAQ stoja obok siebie tylko wtedy, gdy OBIE maja co + pokazac. Dla goscia placeholder sie nie renderuje, wiec pusta sekcja + znika calkiem, a druga zajmuje PELNA SZEROKOSC i jest wyrownana do + lewej, jak reszta sekcji strony. Bez tego jedyna widoczna sekcja + (np. samo FAQ) laduje w waskiej prawej kolumnie siatki, obok pustego + miejsca po niewidocznych opiniach. + */} + {(() => { + const showReviews = data.reviews.length > 0 || isStaff + const showFaq = data.faq.length > 0 || isStaff + if (!showReviews && !showFaq) return null + const twoColumns = showReviews && showFaq - {/* --- FAQ --- */} -
- {data.faq.length === 0 ? ( - - ) : ( - <> -

- {data.faqTitle} -

-
- {data.faq.map((f) => ( -
- {/* Element details, a nie wlasny stan: dziala bez JavaScriptu, - obsluguje klawiature i jest czytany przez czytniki ekranu - bez dopisywania atrybutow aria. */} - - - {f.title} - {f.sample && } - - - - {f.body && ( -
- {f.body} -
- )} -
- ))} + return ( + +
+ {/* --- opinie --- */} + {showReviews && ( +
+ {data.reviews.length === 0 ? ( + + ) : ( + <> +

+ {data.reviewsTitle} +

+
+ {data.reviews.map((r) => ( +
+ +
+ {r.body} +
+
+

{r.title}

+ {r.subtitle && ( +

{r.subtitle}

+ )} +
+
+ ))} +
+ + )}
- - )} -
-
- + )} + + {/* --- FAQ --- */} + {showFaq && ( +
+ {data.faq.length === 0 ? ( + + ) : ( + <> +

+ {data.faqTitle} +

+
+ {data.faq.map((f) => ( +
+ {/* Element details, a nie wlasny stan: dziala bez JavaScriptu, + obsluguje klawiature i jest czytany przez czytniki ekranu + bez dopisywania atrybutow aria. */} + + + {f.title} + {f.sample && ( + + )} + + + + {f.body && ( +
+ {f.body} +
+ )} +
+ ))} +
+ + )} +
+ )} +
+ + ) + })()} {/* ================= CENNIK: tlo szare ================= */} {paidCourses.length > 0 && ( diff --git a/frontend/src/academy/AcademyLayout.tsx b/frontend/src/academy/AcademyLayout.tsx index 3804257..bd724ce 100644 --- a/frontend/src/academy/AcademyLayout.tsx +++ b/frontend/src/academy/AcademyLayout.tsx @@ -290,11 +290,10 @@ export function AcademyFooter({
-
+
© {year} {brandName || 'Platforma szkoleniowa'}. Wszystkie prawa zastrzeżone. - Obsługiwane przez platformę webinarową
diff --git a/frontend/src/academy/HomeWebinarCard.tsx b/frontend/src/academy/HomeWebinarCard.tsx index cac14d0..a5cdff7 100644 --- a/frontend/src/academy/HomeWebinarCard.tsx +++ b/frontend/src/academy/HomeWebinarCard.tsx @@ -1,5 +1,4 @@ import { useState } from 'react' -import { useNavigate } from 'react-router-dom' import type { UpcomingWebinar } from '../api/types' import { Icon } from '../components/Icon' 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": * przycisk, ktory nie robi tego, co obiecuje, jest gorszy niz jedno klikniecie * 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 }) { - const navigate = useNavigate() const [email, setEmail] = useState('') 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, * wiec nie ma tu ryzyka przekierowania na obcy adres. */ - navigate( - trimmed - ? `/w/${webinar.slug}?email=${encodeURIComponent(trimmed)}` - : `/w/${webinar.slug}`, - ) + const url = trimmed + ? `/w/${webinar.slug}?email=${encodeURIComponent(trimmed)}` + : `/w/${webinar.slug}` + window.open(url, '_blank', 'noopener,noreferrer') } return ( diff --git a/frontend/src/academy/StudentDashboardPage.tsx b/frontend/src/academy/StudentDashboardPage.tsx index 1e2d426..dec73a0 100644 --- a/frontend/src/academy/StudentDashboardPage.tsx +++ b/frontend/src/academy/StudentDashboardPage.tsx @@ -19,7 +19,6 @@ import { BrandMark } from '../components/BrandMark' import { formatWithZone } from '../lib/dates' import { CourseCover, ProgressBar } from './AcademyBits' import { useStudentAuth } from './StudentAuthContext' -import { STUDENT_ROUTES } from '../lib/routes' type Tab = 'kursy' | 'webinary' | 'certyfikaty' | 'dane' @@ -41,7 +40,8 @@ export function StudentDashboardPage() { const handleLogout = () => { logout() - navigate(STUDENT_ROUTES.login, { replace: true }) + // po wylogowaniu na strone glowna, nie na formularz logowania + navigate('/', { replace: true }) } return ( diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index ce67235..6ae08c2 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -41,6 +41,7 @@ import { Plus, Play, Presentation, + RefreshCw, Save, Search, Send, @@ -109,6 +110,7 @@ const ICONS = { plus: Plus, play: Play, presentation: Presentation, + refresh: RefreshCw, save: Save, search: Search, send: Send, diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 8635b29..4c45366 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -46,6 +46,19 @@ export function Layout() { 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 useEffect(() => { setMenuOpen(false) @@ -176,7 +189,7 @@ export function Layout() { + + + + Klucze chronią publiczne API tylko do odczytu: + {' '} + GET /api/public/v1/webinars + {' '}i{' '} + GET /api/public/v1/courses. + {' '}Odbiorca przekazuje klucz w nagłówku{' '} + X-API-Key. + {' '}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{' '} + „wygeneruj ponownie” + {' '}(ikona odświeżania) — powstaje nowy klucz, a stary przestaje działać. + + + {/* Surowy klucz po utworzeniu: pokazany jeden raz. */} + {created && ( +
+
+ +
+

+ Nowy klucz dla „{created.apiKey.name}” +

+

+ {created.key} +

+

+ Klucz jest pokazany tylko raz. Skopiuj go teraz i przekaż odbiorcy bezpiecznym + kanałem. Po zamknięciu nie da się go odczytać ponownie. +

+
+ +
+
+ +
+
+ )} + + {creating && ( + +
+ + setName(e.target.value)} + placeholder="np. Strona firmowa" + autoFocus + required + /> + +
+ + +
+
+
+ )} + + {query.data.length === 0 ? ( + + ) : ( +
+ {/* karty na telefonie, tabela od duzego ekranu */} +
    + {query.data.map((key) => ( +
  • +
    +
    +

    {key.name}

    +

    {key.prefix}…

    +
    + {key.active ? ( + Aktywny + ) : ( + Wyłączony + )} +
    +

    + Ostatnio użyty: {key.lastUsedAt ? formatLocal(key.lastUsedAt) : 'nigdy'} +

    +
    + { + if ( + window.confirm( + `Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`, + ) + ) { + regenerate.mutate(key.id) + } + }} + disabled={regenerate.isPending} + /> + setActive.mutate({ id: key.id, active: !key.active })} + disabled={setActive.isPending} + /> + { + 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" + /> +
    +
  • + ))} +
+ +
+ + + + + + + + + + + + + {query.data.map((key) => ( + + + + + + + + + ))} + +
NazwaKluczStanOstatnio użytyUtworzonyAkcje
{key.name}{key.prefix}… + {key.active ? ( + Aktywny + ) : ( + Wyłączony + )} + + {key.lastUsedAt ? formatLocal(key.lastUsedAt) : 'nigdy'} + + {formatLocal(key.createdAt)} + +
+ + + +
+
+
+
+ )} + + ) +} diff --git a/frontend/src/pages/public/CertificateVerifyPage.tsx b/frontend/src/pages/public/CertificateVerifyPage.tsx index 0ae7e20..659a744 100644 --- a/frontend/src/pages/public/CertificateVerifyPage.tsx +++ b/frontend/src/pages/public/CertificateVerifyPage.tsx @@ -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 { publicClient } from '../../api/publicClient' import type { CertificateVerification } from '../../api/types' 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' /** * 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 * ------------------------------------------------------------------------ * Strona jest otwarta dla kazdego, kto zna numer, wiec pokazuje **wylacznie** * 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. - * Pracodawca sprawdzajacy dokument ma potwierdzic, ze jest prawdziwy, a nie - * poznac dane osoby, ktorej nie zna, ani pobrac cudzy certyfikat. + * WEJSCIE BEZ NUMERU POKAZUJE FORMULARZ, NIE BLAD + * ------------------------------------------------------------------------ + * 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() { /* @@ -26,8 +40,97 @@ export function CertificateVerifyPage() { * by sie nie dopasowala. `*` lapie cala reszte sciezki wraz z ukosnikami. */ const params = useParams() - const number = decodeURIComponent(params['*'] ?? '') + const number = decodeURIComponent(params['*'] ?? '').trim() + return ( + +
+ + + Strona główna + + + {number ? : } +
+
+ ) +} + +/** + * 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 ( +
+
+ +

Weryfikacja certyfikatu

+

+ Wpisz numer widniejący na dokumencie, aby sprawdzić jego autentyczność. +

+
+ +
+ setValue(event.target.value)} + placeholder="np. 2026/1" + aria-label="Numer certyfikatu" + className="hx-data flex-1" + autoFocus + /> + +
+ + {/* + Skad wziac numer. Sprawdzajacy z zewnatrz (pracodawca) trzyma dokument + w reku i moze nie wiedziec, ktora liczba na nim to numer do weryfikacji. + */} +
+

+ + Skąd wziąć numer certyfikatu? +

+

+ Numer widnieje na samym dokumencie (PDF lub wydruku), zwykle w formacie + rok/numer, na przykład + 2026/1. Znajdziesz go w treści + certyfikatu, przy dacie wystawienia. Przepisz go dokładnie, razem + z ukośnikiem. +

+
+ +

+ Chcesz zobaczyć własne certyfikaty? Znajdziesz je po zalogowaniu na swoim koncie. +

+
+ ) +} + +/** Wynik weryfikacji dla podanego numeru. */ +function VerifyResult({ number }: { number: string }) { const query = useQuery({ queryKey: ['certificate-verify', number], queryFn: async () => { @@ -42,71 +145,72 @@ export function CertificateVerifyPage() { if (query.isPending) { return ( - -
- -
-
+
+ +
) } if (query.isError) { return ( - -
- -

- Nie znaleźliśmy takiego certyfikatu -

-

- Sprawdź, czy numer został przepisany dokładnie tak, jak widnieje na dokumencie. -

-
-
+
+ +

+ Nie znaleźliśmy takiego certyfikatu +

+

+ Sprawdź, czy numer został przepisany dokładnie tak, jak widnieje na dokumencie. +

+
+ + + +
+
) } const data = query.data return ( - -
-
- {data.valid ? ( - <> - -

Certyfikat jest ważny

- - ) : ( - <> - -

- Certyfikat został unieważniony -

-

- Dokument o tym numerze istnieje, ale wystawca wycofał go z obiegu. -

- - )} -
+
+
+ {data.valid ? ( + <> + +

Certyfikat jest ważny

+ + ) : ( + <> + +

+ Certyfikat został unieważniony +

+

+ Dokument o tym numerze istnieje, ale wystawca wycofał go z obiegu. +

+ + )} +
-
- - - - -
+
+ + + + +
- {/* - Jawne powiedzenie, czego tu nie ma. Sprawdzajacy widzi skrocone imie - i moze uznac to za blad; to jest decyzja, nie usterka. - */} -

- Ze względu na ochronę danych osobowych pokazujemy imię i pierwszą literę nazwiska. - Pełne dane znajdują się na dokumencie, który posiada uczestnik. -

-
- + {/* + Jawne powiedzenie, czego tu nie ma. Sprawdzajacy widzi skrocone imie + i moze uznac to za blad; to jest decyzja, nie usterka. + */} +

+ Ze względu na ochronę danych osobowych pokazujemy imię i pierwszą literę nazwiska. + Pełne dane znajdują się na dokumencie, który posiada uczestnik. +

+
) }