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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,21 @@ ROLLBAR_CLIENT_ACCESS_TOKEN=
# DJANGO_BPP_PORT_APP=8000
# DJANGO_BPP_PORT_HTTP=1080
# DJANGO_BPP_PORT_HTTPS=10443

#
# Widget wsparcia Freshdesk (docs/administrator/widget-wsparcia-freshdesk.md)
#
# Osobne widgety dla zalogowanych i anonimów; pusty ID = brak widgetu.
# ID to liczba z kodu osadzenia widgetu w Freshdesku (.../widgets/<ID>.js).
# Sekret JWT (Freshdesk: Admin > Account > Security > Widget settings)
# włącza uwierzytelnianie zalogowanych użytkowników w widgecie (tokeny
# wydaje /freshdesk/widget-token/). Sekret jest wspólny dla całego konta
# Freshdesk - kto go zna, podpisze token z dowolnym e-mailem. Nie commituj.
#

# ID widgetu dla zalogowanych (domyślnie widget iplweb):
# DJANGO_BPP_FRESHDESK_WIDGET_ID=205000000433
# ID widgetu dla niezalogowanych (domyślnie pusty = brak zakładki "Pomoc"):
# DJANGO_BPP_FRESHDESK_WIDGET_ID_ANON=
# Sekret JWT (domyślnie pusty = bez uwierzytelniania, tylko prefill):
# DJANGO_BPP_FRESHDESK_WIDGET_JWT_SECRET=
99 changes: 99 additions & 0 deletions docs/administrator/widget-wsparcia-freshdesk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Widget wsparcia Freshdesk

W prawym dolnym rogu stron BPP (frontend i moduł redagowania) wyświetla się
zakładka **„Pomoc"**. Otwiera ona formularz zgłoszenia z systemu Freshdesk
(widget Freshworks). Skrypt widgetu jest pobierany z serwera Freshworks dopiero
po najechaniu na zakładkę lub jej kliknięciu.

BPP używa **dwóch osobnych widgetów** Freshdesk:

| Kto | Widget | Uwierzytelnienie |
|-----|--------|------------------|
| użytkownik **zalogowany** | `DJANGO_BPP_FRESHDESK_WIDGET_ID` | token JWT (jeżeli ustawiono sekret), w przeciwnym razie wstępne wypełnienie imienia i e-maila |
| użytkownik **anonimowy** | `DJANGO_BPP_FRESHDESK_WIDGET_ID_ANON` | brak — ochronę przed spamem (captcha) konfiguruje się w ustawieniach tego widgetu w Freshdesku |

Do każdego zgłoszenia trafia adres strony, z której je wysłano (pole
`cf_adres_url`).

## Zmienne konfiguracyjne

Zmienne ustawia się w pliku `.env` (patrz
[Ustawienia zaawansowane](zaawansowane.md)) lub w środowisku kontenera.
W instalacjach opartych o [bpp-deploy](https://github.com/iplweb/bpp-deploy)
jest to plik `${BPP_CONFIGS_DIR}/.env` — trafia on zarówno do appservera, jak
i do workerów; po zmianie wystarczy `make up`.

| Zmienna | Domyślnie | Znaczenie |
|---------|-----------|-----------|
| `DJANGO_BPP_FRESHDESK_WIDGET_ID` | `205000000433` | ID widgetu dla użytkowników zalogowanych. Pusta wartość wyłącza widget dla zalogowanych. |
| `DJANGO_BPP_FRESHDESK_WIDGET_ID_ANON` | *(pusta)* | ID widgetu dla użytkowników niezalogowanych. Pusta wartość = anonimowi nie widzą zakładki „Pomoc". |
| `DJANGO_BPP_FRESHDESK_WIDGET_JWT_SECRET` | *(pusta)* | Wspólny sekret do podpisywania tokenów JWT widgetu. Pusta wartość = brak uwierzytelnienia JWT. |

ID widgetu to liczba z kodu osadzenia (embed code), który Freshdesk pokazuje
w ustawieniach widgetu — fragment `'widget_id': 205000000433` lub adres
`.../widgets/205000000433.js`.

Przykład:

```bash
DJANGO_BPP_FRESHDESK_WIDGET_ID=205000000433
DJANGO_BPP_FRESHDESK_WIDGET_ID_ANON=205000000999
DJANGO_BPP_FRESHDESK_WIDGET_JWT_SECRET=wklej-tu-shared-secret-z-freshdeska
```

!!! warning "Sekret JWT jest poufny"
Kto zna sekret, może podpisać token z dowolnym adresem e-mail i wysyłać
zgłoszenia w imieniu dowolnej osoby. Trzymaj go wyłącznie w `.env` /
sekretach deploymentu, nigdy w repozytorium. Sekret nie trafia do HTML-a
stron — tokeny są generowane po stronie serwera.

Sekret jest **jeden na całe konto Freshdesk**, a nie na instancję BPP.
Ustawienie go na wielu serwerach oznacza, że administrator dowolnego
z nich (każdy, kto czyta `.env`) może podszyć się pod użytkownika
dowolnej innej instancji. Włączaj JWT tylko na serwerach, nad którymi
masz pełną kontrolę.

## Uwierzytelnianie JWT użytkowników zalogowanych

Gdy ustawiono `DJANGO_BPP_FRESHDESK_WIDGET_JWT_SECRET`, zalogowany użytkownik
jest automatycznie uwierzytelniany w widgecie
([dokumentacja Freshdesk](https://support.freshdesk.com/support/solutions/articles/50000000978)):

1. Po najechaniu na zakładkę „Pomoc" (lub kliknięciu) przeglądarka pobiera token z adresu
`/freshdesk/widget-token/` (dostępny tylko dla zalogowanych).
2. Token (HS256) zawiera imię i nazwisko, adres e-mail użytkownika BPP oraz
czas wygaśnięcia (1 godzina; Freshdesk dopuszcza maksymalnie 2 godziny).
3. Przed wygaśnięciem tokenu widget sam prosi o nowy — kolejne zapytanie do
tego samego adresu.

Sekret znajdziesz w Freshdesku w **Admin → Account → Security → Widget
settings** (w Freshdesk Omni: **Admin → Data and Security → Ticket Security**).

Uwagi:

- Token poświadcza adres e-mail **zapisany w koncie BPP**. BPP nie weryfikuje
tego adresu (wpisuje go administrator albo import), więc „uwierzytelniony"
oznacza „zalogowany do BPP z takim adresem w profilu".
- Przy włączonym JWT widget dla zalogowanych **nigdy nie otwiera się bez
tokenu**. Jeżeli tokenu nie da się uzyskać — użytkownik BPP **nie ma adresu
e-mail** (Freshdesk wymaga go w tokenie) albo zapytanie o token się nie
powiodło:
- gdy ustawiono `DJANGO_BPP_FRESHDESK_WIDGET_ID_ANON` — ładuje się
**widget anonimowy** (z jego captchą), a imię i e-mail (jeśli są) zostają
tylko wstępnie wpisane w formularz, bez podpisu,
- w przeciwnym razie skrypt Freshworks nie jest w ogóle ładowany, a
zakładka „Pomoc" kieruje do [support.iplweb.pl](https://support.iplweb.pl).
- Żeby Freshdesk sam odrzucał niepodpisane zgłoszenia (np. spreparowane
ręcznie w przeglądarce), w ustawieniach widgetu dla zalogowanych ogranicz
formularz kontaktowy i artykuły bazy wiedzy wyłącznie do uwierzytelnionych
użytkowników.

## Widget dla użytkowników anonimowych

Anonimowy widget jest osobnym widgetem w Freshdesku (inne ID). Włącz w nim
captchę i ewentualnie ogranicz pola formularza — po stronie BPP nie jest
potrzebna żadna dodatkowa konfiguracja poza `DJANGO_BPP_FRESHDESK_WIDGET_ID_ANON`.
Zakładka dla anonimów pojawia się na stronach publicznych oraz na stronie
logowania do modułu redagowania. Przy pustej zmiennej (domyślnie) anonimowi
nie widzą zakładki „Pomoc" — tak jak w wersjach sprzed wprowadzenia
osobnych widgetów.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ nav:
- Integracja z DSpace: administrator/integracja-dspace.md
- REST API: administrator/rest-api.md
- Eksport CERIF / OpenAIRE: administrator/eksport-cerif.md
- Widget wsparcia Freshdesk: administrator/widget-wsparcia-freshdesk.md
- Konfiguracja zaawansowana: administrator/zaawansowane.md
- API: api/index.md
- Dla deweloperów i agentów:
Expand Down
39 changes: 39 additions & 0 deletions src/bpp/context_processors/freshdesk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from django.conf import settings

from django_bpp.freshdesk import WIDGET_SCRIPT_URL, jwt_enabled


def freshdesk_widget(request):
"""Konfiguracja widgetu wsparcia Freshdesk dla bieżącego użytkownika.

Zalogowani dostają widget ``FRESHDESK_WIDGET_ID`` (z uwierzytelnieniem JWT,
jeżeli skonfigurowano sekret), anonimowi — osobny widget
``FRESHDESK_WIDGET_ID_ANON``. Pusty ID = brak widgetu (``None``).
Zalogowany przy JWT dostaje też dane widgetu anonimowego — ładuje się on,
gdy tokenu nie da się uzyskać (np. konto bez e-maila).
Sam sekret nigdy nie trafia do kontekstu szablonu.
"""
user = getattr(request, "user", None)
authenticated = bool(user is not None and user.is_authenticated)

if authenticated:
widget_id = settings.FRESHDESK_WIDGET_ID
else:
widget_id = settings.FRESHDESK_WIDGET_ID_ANON

if not widget_id:
return {"FRESHDESK_WIDGET": None}

jwt = authenticated and jwt_enabled()
anon_id = settings.FRESHDESK_WIDGET_ID_ANON if jwt else ""

return {
"FRESHDESK_WIDGET": {
"widget_id": widget_id,
"script_url": WIDGET_SCRIPT_URL.format(widget_id),
"authenticated": authenticated,
"jwt": jwt,
"anon_widget_id": anon_id or None,
"anon_script_url": WIDGET_SCRIPT_URL.format(anon_id) if anon_id else None,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Widget wsparcia Freshdesk: osobne widgety dla zalogowanych i anonimowych użytkowników (zmienne ``DJANGO_BPP_FRESHDESK_WIDGET_ID`` / ``DJANGO_BPP_FRESHDESK_WIDGET_ID_ANON``), a zalogowani są uwierzytelniani w widgecie tokenem JWT, gdy ustawiono ``DJANGO_BPP_FRESHDESK_WIDGET_JWT_SECRET``. Przy włączonym JWT widget dla zalogowanych nie otwiera się bez tokenu — zamiast niego ładuje się widget anonimowy, a gdy go nie skonfigurowano, zakładka „Pomoc” kieruje do support.iplweb.pl.
126 changes: 114 additions & 12 deletions src/bpp/static/bpp/js/support-tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
// (jako ulamek szerokosci okna) trafia do localStorage. Przeciagniecie nie
// otwiera widgetu.
// - Skrot "?" otwiera widget, ale tylko gdy fokus NIE jest w polu do pisania.
// - Zalogowany uzytkownik przy wlaczonym JWT (data-token-url) jest
// uwierzytelniany przez FreshworksWidget("authenticate") tokenem z serwera;
// token odnawia callback widgetu. Skrypt widgetu laduje sie dopiero PO
// tokenie; brak tokenu (brak e-maila w koncie, blad sieci) = widget
// anonimowy (data-anon-widget-*, z captcha), a gdy go nie skonfigurowano -
// stan bledu, czyli support.iplweb.pl. Nigdy niepodpisany formularz
// w widgecie dla zalogowanych.
// Bez JWT - tylko "identify" (imie i e-mail wpisane w formularz).
// Anonim (brak data-user-*) - ani jedno, ani drugie.
(function () {
"use strict";

Expand Down Expand Up @@ -85,6 +94,10 @@
// Freshworks nie udostepnia zdarzen zamkniecia widgetu (brak metody
// 'on' w API bootstrapu), wiec stan sledzimy sami.
var widgetOpen = false;
// Spelnia sie, gdy uwierzytelnienie (JWT) jest juz w kolejce widgetu -
// "open" musi isc PO "authenticate", inaczej formularz otworzy sie
// dla niezalogowanego. Wartosc false = tokenu nie bedzie.
var authReady = Promise.resolve(true);

function showLoading() {
button.classList.add("is-loading");
Expand All @@ -99,33 +112,117 @@
label.textContent = defaultLabel;
}

function setError() {
function setError(reason) {
state = "error";
widgetOpen = false;
button.classList.remove("is-loading");
button.removeAttribute("aria-busy");
button.classList.add("is-error");
button.setAttribute(
"title",
"Nie udało się załadować widgetu wsparcia. "
+ "Kliknij, aby przejść do support.iplweb.pl"
(typeof reason === "string"
? reason
: "Nie udało się załadować widgetu wsparcia.")
+ " Kliknij, aby przejść do support.iplweb.pl"
);
label.textContent = "support.iplweb.pl";
}

// Wstrzyknij skrypt widgetu (bez otwierania). Wywolania trafiaja do
// kolejki stuba i zostana wykonane w tej kolejnosci po zaladowaniu.
function openFallback() {
window.open(button.dataset.fallbackUrl, "_blank", "noopener");
}

// Token JWT z serwera albo null (brak e-maila w koncie, blad sieci).
function fetchToken() {
return fetch(button.dataset.tokenUrl, {
credentials: "same-origin",
headers: { Accept: "application/json" }
}).then(function (response) {
return response.ok ? response.json() : null;
}).then(function (data) {
return (data && data.freshworks_widget_auth_token) || null;
}).catch(function (err) {
console.warn("Freshdesk: nie udalo sie pobrac tokenu", err);
return null;
});
}

// Widget wola ten callback przed wygasnieciem tokenu.
function renewToken() {
fetchToken().then(function (token) {
if (token) {
window.FreshworksWidget("authenticate", {
token: token,
callback: renewToken
});
}
});
}

function identify() {
if (!button.dataset.userEmail) {
return;
}
window.FreshworksWidget("identify", "ticketForm", {
name: button.dataset.userName || "",
email: button.dataset.userEmail
});
}

// Zakolejkuj konfiguracje widgetu i (po tokenie, gdy JWT) zaladuj go.
function startLoading() {
state = "loading";
window.FreshworksWidget("hide", "launcher");
window.FreshworksWidget("prefill", "ticketForm", {
custom_fields: { cf_adres_url: window.location.href }
});
window.FreshworksWidget("identify", "ticketForm", {
name: button.dataset.userName || "",
email: button.dataset.userEmail || ""
});
if (button.dataset.tokenUrl) {
authReady = fetchToken().then(function (token) {
if (!token && button.dataset.anonWidgetSrc) {
// Widget dla zalogowanych wymaga podpisanych danych -
// bez tokenu ladujemy zamiast niego widget anonimowy.
// fwSettings skrypt czyta dopiero przy ladowaniu.
window.fwSettings.widget_id = Number(
button.dataset.anonWidgetId
);
button.dataset.widgetSrc =
button.dataset.anonWidgetSrc;
identify();
injectScript();
return true;
}
if (!token) {
// Nie ma ani tokenu, ani widgetu anonimowego -
// odsylamy do supportu.
var wasOpening = widgetOpen;
setError(
"Nie udało się potwierdzić Twojej tożsamości "
+ "w widgecie wsparcia."
);
if (wasOpening) {
openFallback();
}
return false;
}
window.FreshworksWidget("authenticate", {
token: token,
callback: renewToken
});
injectScript();
return true;
});
} else {
identify();
injectScript();
}
}

// Wstrzyknij skrypt widgetu (bez otwierania). Wywolania trafiaja do
// kolejki stuba i zostana wykonane w tej kolejnosci po zaladowaniu.
function injectScript() {
if (state !== "loading") {
return;
}
var script = document.createElement("script");
script.type = "text/javascript";
script.async = true;
Expand Down Expand Up @@ -156,9 +253,14 @@

function openWidget() {
updateWidgetAnchor();
// Przed zaladowaniem skryptu "open" czeka w kolejce stuba.
window.FreshworksWidget("open");
widgetOpen = true;
// Przed zaladowaniem skryptu "open" czeka w kolejce stuba.
authReady.then(function (ok) {
// Zamkniete, zanim dojechal token - nie otwieraj.
if (ok && widgetOpen) {
window.FreshworksWidget("open");
}
});
}

// Schowaj okno do zakladki, a dopiero potem zamknij widget -
Expand Down Expand Up @@ -202,7 +304,7 @@

function toggleWidget() {
if (state === "error") {
window.open(button.dataset.fallbackUrl, "_blank", "noopener");
openFallback();
return;
}
if (state === "idle") {
Expand Down
Loading
Loading