API + kosmetyka
Deploy PROD / deploy (push) Successful in 25s

This commit is contained in:
2026-09-02 11:55:59 +02:00
parent 7b4d367a06
commit 9bd59d60bb
26 changed files with 1818 additions and 159 deletions
+9
View File
@@ -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() {
</RequireAdmin>
}
/>
<Route
path="klucze-api"
element={
<RequireAdmin>
<ApiKeyListPage />
</RequireAdmin>
}
/>
{/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */}
<Route path="*" element={<Navigate to={panelPath()} replace />} />
</Route>
+118 -83
View File
@@ -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<PriceFilter>('all')
const [category, setCategory] = useState<string>('')
@@ -41,9 +44,12 @@ export function AcademyHomePage() {
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({
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() {
</Band>
)}
{/* ============ OPINIE I PYTANIA: dwie kolumny, tlo biale ============ */}
<Band tone="white">
<div className="grid items-start gap-10 py-12 sm:py-14 lg:grid-cols-2 lg:gap-14">
{/* --- opinie --- */}
<div>
{data.reviews.length === 0 ? (
<AdminSectionHint
flat
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')}
/>
) : (
<>
<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>
{/* ============ 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 --- */}
<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>
))}
return (
<Band tone="white">
<div
className={`grid items-start gap-10 py-12 sm:py-14 lg:gap-14 ${
twoColumns ? 'lg:grid-cols-2' : ''
}`}
>
{/* --- opinie --- */}
{showReviews && (
<div>
{data.reviews.length === 0 ? (
<AdminSectionHint
flat
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')}
/>
) : (
<>
<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>
</>
)}
</div>
</div>
</Band>
)}
{/* --- FAQ --- */}
{showFaq && (
<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 ================= */}
{paidCourses.length > 0 && (
+1 -2
View File
@@ -290,11 +290,10 @@ export function AcademyFooter({
</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>
&copy; {year} {brandName || 'Platforma szkoleniowa'}. Wszystkie prawa zastrzeżone.
</span>
<span className="hx-data">Obsługiwane przez platformę webinarową</span>
</div>
</div>
</footer>
+11 -7
View File
@@ -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 (
@@ -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 (
+2
View File
@@ -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,
+14 -1
View File
@@ -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() {
</div>
<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"
>
<Icon name="log-out" size={18} />
+1
View File
@@ -106,6 +106,7 @@ export const NAV_COMMON: NavItem[] = [
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] },
{ to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', 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. */
+33 -5
View File
@@ -1,22 +1,50 @@
import { useEffect } from 'react'
import { usePublicConfig } from './usePublicConfig'
type Meta = {
title: 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.
*
* 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.
* Komunikatory pobierajace zajawke bez uruchamiania JavaScriptu zobacza wartosci
* z index.html. Pelna zajawka per webinar wymagalaby renderowania po stronie
* 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(() => {
const previousTitle = document.title
document.title = title
document.title = fullTitle
const tags: HTMLMetaElement[] = []
@@ -31,11 +59,11 @@ export function useDocumentMeta({ title, description }: Meta) {
tag.setAttribute('content', content)
}
setMeta('property', 'og:title', title)
setMeta('property', 'og:title', fullTitle)
setMeta('property', 'og:type', 'website')
setMeta('property', 'og:url', window.location.href)
setMeta('name', 'twitter:card', 'summary')
setMeta('name', 'twitter:title', title)
setMeta('name', 'twitter:title', fullTitle)
if (description) {
setMeta('name', 'description', description)
@@ -48,5 +76,5 @@ export function useDocumentMeta({ title, description }: Meta) {
// usuwamy tylko tagi dodane przez ten hook, istniejace zostaja
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 { 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 (
<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({
queryKey: ['certificate-verify', number],
queryFn: async () => {
@@ -42,71 +145,72 @@ export function CertificateVerifyPage() {
if (query.isPending) {
return (
<PublicLayout>
<div className="flex justify-center py-16">
<Icon name="loader" size={28} className="animate-spin text-slate-400" />
</div>
</PublicLayout>
<div className="flex justify-center py-16">
<Icon name="loader" size={28} className="animate-spin text-slate-400" />
</div>
)
}
if (query.isError) {
return (
<PublicLayout>
<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" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">
Nie znaleźliśmy takiego certyfikatu
</h1>
<p className="mt-1 text-sm text-slate-600">
Sprawdź, czy numer został przepisany dokładnie tak, jak widnieje na dokumencie.
</p>
</section>
</PublicLayout>
<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" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">
Nie znaleźliśmy takiego certyfikatu
</h1>
<p className="mt-1 text-sm text-slate-600">
Sprawdź, czy numer został przepisany dokładnie tak, jak widnieje na dokumencie.
</p>
<div className="mt-5">
<Link to="/certyfikat">
<Button variant="secondary" size="sm" icon="arrow-left">
Wpisz numer ponownie
</Button>
</Link>
</div>
</section>
)
}
const data = query.data
return (
<PublicLayout>
<section className="rounded-xl border border-slate-200 bg-white p-6">
<div className="text-center">
{data.valid ? (
<>
<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>
</>
) : (
<>
<Icon name="x-circle" size={40} className="mx-auto text-alarm-500" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">
Certyfikat został unieważniony
</h1>
<p className="mt-1 text-sm text-slate-600">
Dokument o tym numerze istnieje, ale wystawca wycofał go z obiegu.
</p>
</>
)}
</div>
<section className="rounded-xl border border-slate-200 bg-white p-6">
<div className="text-center">
{data.valid ? (
<>
<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>
</>
) : (
<>
<Icon name="x-circle" size={40} className="mx-auto text-alarm-500" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">
Certyfikat został unieważniony
</h1>
<p className="mt-1 text-sm text-slate-600">
Dokument o tym numerze istnieje, ale wystawca wycofał go z obiegu.
</p>
</>
)}
</div>
<dl className="mt-6 divide-y divide-slate-100 border-t border-slate-100">
<Row label="Numer certyfikatu" value={data.number} mono />
<Row label={data.forCourse ? 'Kurs' : 'Szkolenie'} value={data.subjectTitle} />
<Row label="Uczestnik" value={data.participantName} />
<Row label="Data wystawienia" value={formatLocal(data.issuedAt)} mono />
</dl>
<dl className="mt-6 divide-y divide-slate-100 border-t border-slate-100">
<Row label="Numer certyfikatu" value={data.number} mono />
<Row label={data.forCourse ? 'Kurs' : 'Szkolenie'} value={data.subjectTitle} />
<Row label="Uczestnik" value={data.participantName} />
<Row label="Data wystawienia" value={formatLocal(data.issuedAt)} mono />
</dl>
{/*
Jawne powiedzenie, czego tu nie ma. Sprawdzajacy widzi skrocone imie
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">
Ze względu na ochronę danych osobowych pokazujemy imię i pierwszą literę nazwiska.
Pełne dane znajdują się na dokumencie, który posiada uczestnik.
</p>
</section>
</PublicLayout>
{/*
Jawne powiedzenie, czego tu nie ma. Sprawdzajacy widzi skrocone imie
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">
Ze względu na ochronę danych osobowych pokazujemy imię i pierwszą literę nazwiska.
Pełne dane znajdują się na dokumencie, który posiada uczestnik.
</p>
</section>
)
}