diff --git a/Backend.Tests/Otel/OtelKernelTests.cs b/Backend.Tests/Otel/OtelKernelTests.cs index d21a66f663..a2abfbb190 100644 --- a/Backend.Tests/Otel/OtelKernelTests.cs +++ b/Backend.Tests/Otel/OtelKernelTests.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Linq; using Backend.Tests.Mocks; -using BackendFramework.Otel; using Microsoft.AspNetCore.Http; using NUnit.Framework; using static BackendFramework.Otel.OtelKernel; @@ -12,9 +11,6 @@ namespace Backend.Tests.Otel { public class OtelKernelTests : IDisposable { - private const string FrontendSessionIdKey = "sessionId"; - private const string OtelSessionIdKey = "sessionId"; - private const string OtelSessionBaggageKey = "sessionBaggage"; private LocationEnricher _locationEnricher = null!; public void Dispose() @@ -32,41 +28,63 @@ protected virtual void Dispose(bool disposing) } [Test] - public void BuildersSetSessionBaggageFromHeader() + public void BuildersSetBaggageFromHeaderAllAnalytics() { // Arrange var httpContext = new DefaultHttpContext(); - httpContext.Request.Headers[FrontendSessionIdKey] = "123"; + httpContext.Request.Headers[AnalyticsOnHeader] = "true"; + httpContext.Request.Headers[SessionIdHeader] = "123"; var activity = new Activity("testActivity").Start(); // Act + TrackConsent(activity, httpContext.Request); TrackSession(activity, httpContext.Request); // Assert - Assert.That(activity.Baggage.Any(_ => _.Key == OtelSessionBaggageKey)); + Assert.That(activity.Baggage.Any(_ => _.Key == ConsentBaggage)); + Assert.That(activity.Baggage.Any(_ => _.Key == SessionIdBaggage)); } [Test] - public void OnEndSetsSessionTagFromBaggage() + public void BuildersSetBaggageFromHeaderNecessaryAnalytics() { // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers[AnalyticsOnHeader] = "false"; var activity = new Activity("testActivity").Start(); - activity.SetBaggage(OtelSessionBaggageKey, "test session id"); // Act - _locationEnricher.OnEnd(activity); + TrackConsent(activity, httpContext.Request); + TrackSession(activity, httpContext.Request); // Assert - Assert.That(activity.Tags.Any(_ => _.Key == OtelSessionIdKey)); + Assert.That(activity.Baggage.Any(_ => _.Key == ConsentBaggage)); + Assert.That(!activity.Baggage.Any(_ => _.Key == SessionIdBaggage)); } + [Test] + public void OnEndSetsTagsFromBaggage() + { + // Arrange + var activity = new Activity("testActivity").Start(); + activity.SetBaggage(ConsentBaggage, "true"); + activity.SetBaggage(SessionIdBaggage, "test session id"); + + // Act + _locationEnricher.OnEnd(activity); + + // Assert + Assert.That(activity.Tags.Any(_ => _.Key == ConsentTag)); + Assert.That(activity.Tags.Any(_ => _.Key == SessionIdTag)); + } [Test] - public void OnEndSetsLocationTags() + public void OnEndSetsLocationTagsAllAnalytics() { // Arrange _locationEnricher = new LocationEnricher(new LocationProviderMock()); var activity = new Activity("testActivity").Start(); + activity.SetBaggage(ConsentBaggage, "true"); // Act _locationEnricher.OnEnd(activity); @@ -81,19 +99,21 @@ public void OnEndSetsLocationTags() Assert.That(activity.Tags, Is.SupersetOf(testLocation)); } - public void OnEndRedactsIp() + [Test] + public void OnEndSetsLocationTagsNecessaryAnalytics() { // Arrange _locationEnricher = new LocationEnricher(new LocationProviderMock()); var activity = new Activity("testActivity").Start(); - activity.SetTag("url.full", $"{LocationProvider.locationGetterUri}100.0.0.0"); + activity.SetBaggage(ConsentBaggage, "false"); // Act _locationEnricher.OnEnd(activity); // Assert - Assert.That(activity.Tags.Any(_ => _.Key == "url.full" && _.Value == "")); - Assert.That(activity.Tags.Any(_ => _.Key == "url.redacted.ip" && _.Value == LocationProvider.locationGetterUri)); + Assert.That(!activity.Tags.Any(_ => _.Key == "country")); + Assert.That(!activity.Tags.Any(_ => _.Key == "regionName")); + Assert.That(!activity.Tags.Any(_ => _.Key == "city")); } } } diff --git a/Backend/Models/User.cs b/Backend/Models/User.cs index 1814dd8758..80e62510b7 100644 --- a/Backend/Models/User.cs +++ b/Backend/Models/User.cs @@ -66,6 +66,19 @@ public class User [BsonElement("username")] public string Username { get; set; } + /// + /// Is false if user rejects analytics, true otherwise. + /// User can update consent anytime. + /// + [BsonElement("analyticsOn")] + public bool AnalyticsOn { get; set; } + + /// + /// Is set permanently to true once user first accepts or rejects analytics upon login. + /// + [BsonElement("answeredConsent")] + public bool AnsweredConsent { get; set; } + [BsonElement("uiLang")] public string UILang { get; set; } @@ -97,6 +110,8 @@ public User() Agreement = false; Password = ""; Username = ""; + AnalyticsOn = true; + AnsweredConsent = false; UILang = ""; GlossSuggestion = OffOnSetting.On; Token = ""; @@ -119,6 +134,8 @@ public User Clone() Agreement = Agreement, Password = Password, Username = Username, + AnalyticsOn = AnalyticsOn, + AnsweredConsent = AnsweredConsent, UILang = UILang, GlossSuggestion = GlossSuggestion, Token = Token, @@ -141,6 +158,8 @@ public bool ContentEquals(User other) other.Agreement == Agreement && other.Password.Equals(Password, StringComparison.Ordinal) && other.Username.Equals(Username, StringComparison.Ordinal) && + other.AnalyticsOn == AnalyticsOn && + other.AnsweredConsent == AnsweredConsent && other.UILang.Equals(UILang, StringComparison.Ordinal) && other.GlossSuggestion.Equals(GlossSuggestion) && other.Token.Equals(Token, StringComparison.Ordinal) && @@ -178,6 +197,8 @@ public override int GetHashCode() hash.Add(Agreement); hash.Add(Password); hash.Add(Username); + hash.Add(AnalyticsOn); + hash.Add(AnsweredConsent); hash.Add(UILang); hash.Add(GlossSuggestion); hash.Add(Token); diff --git a/Backend/Otel/OtelKernel.cs b/Backend/Otel/OtelKernel.cs index 74842e3da1..72fa4ead5c 100644 --- a/Backend/Otel/OtelKernel.cs +++ b/Backend/Otel/OtelKernel.cs @@ -17,6 +17,12 @@ namespace BackendFramework.Otel public static class OtelKernel { public const string SourceName = "Backend-Otel"; + public const string AnalyticsOnHeader = "analyticsOn"; + public const string SessionIdHeader = "sessionId"; + public const string ConsentBaggage = "consentBaggage"; + public const string SessionIdBaggage = "sessionIdBaggage"; + public const string ConsentTag = "otelConsent"; + public const string SessionIdTag = "sessionId"; public static void AddOpenTelemetryInstrumentation(this IServiceCollection services) { @@ -33,12 +39,19 @@ public static void AddOpenTelemetryInstrumentation(this IServiceCollection servi ); } + internal static void TrackConsent(Activity activity, HttpRequest request) + { + request.Headers.TryGetValue(AnalyticsOnHeader, out var consentString); + var consent = bool.Parse(consentString!); + activity.SetBaggage(ConsentBaggage, consent.ToString()); + } + internal static void TrackSession(Activity activity, HttpRequest request) { - var sessionId = request.Headers.TryGetValue("sessionId", out var values) ? values.FirstOrDefault() : null; + var sessionId = request.Headers.TryGetValue(SessionIdHeader, out var values) ? values.FirstOrDefault() : null; if (sessionId is not null) { - activity.SetBaggage("sessionBaggage", sessionId); + activity.SetBaggage(SessionIdBaggage, sessionId); } } @@ -67,6 +80,7 @@ private static void AspNetCoreBuilder(AspNetCoreTraceInstrumentationOptions opti options.EnrichWithHttpRequest = (activity, request) => { GetContentLengthAspNet(activity, request.Headers, "inbound.http.request.body.size"); + TrackConsent(activity, request); TrackSession(activity, request); }; options.EnrichWithHttpResponse = (activity, response) => @@ -98,22 +112,20 @@ internal class LocationEnricher(ILocationProvider locationProvider) : BaseProces { public override async void OnEnd(Activity data) { - var uriPath = (string?)data.GetTagItem("url.full"); - var locationUri = LocationProvider.locationGetterUri; - if (uriPath is null || !uriPath.Contains(locationUri)) + var consentString = data.GetBaggageItem(ConsentBaggage); + data.AddTag(ConsentTag, consentString); + if (bool.TryParse(consentString, out bool consent) && consent) { - var location = await locationProvider.GetLocation(); - data?.AddTag("country", location?.Country); - data?.AddTag("regionName", location?.RegionName); - data?.AddTag("city", location?.City); - } - data?.SetTag("sessionId", data?.GetBaggageItem("sessionBaggage")); - if (uriPath is not null && uriPath.Contains(locationUri)) - { - // When getting location externally, url.full includes site URI and user IP. - // In such cases, only add url without IP information to traces. - data?.SetTag("url.full", ""); - data?.SetTag("url.redacted.ip", LocationProvider.locationGetterUri); + var uriPath = (string?)data.GetTagItem("url.full"); + var locationUri = LocationProvider.locationGetterUri; + if (uriPath is null || !uriPath.Contains(locationUri)) + { + var location = await locationProvider.GetLocation(); + data.AddTag("country", location?.Country); + data.AddTag("regionName", location?.RegionName); + data.AddTag("city", location?.City); + } + data.AddTag(SessionIdTag, data.GetBaggageItem(SessionIdBaggage)); } } } diff --git a/Backend/Repositories/UserRepository.cs b/Backend/Repositories/UserRepository.cs index 0ab2f006bb..928b6c20b3 100644 --- a/Backend/Repositories/UserRepository.cs +++ b/Backend/Repositories/UserRepository.cs @@ -196,6 +196,8 @@ public async Task Update(string userId, User user, bool updateIs .Set(x => x.ProjectRoles, user.ProjectRoles) .Set(x => x.Agreement, user.Agreement) .Set(x => x.Username, user.Username) + .Set(x => x.AnalyticsOn, user.AnalyticsOn) + .Set(x => x.AnsweredConsent, user.AnsweredConsent) .Set(x => x.UILang, user.UILang) .Set(x => x.GlossSuggestion, user.GlossSuggestion); diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index c5e1850b71..2c9c4a90d7 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -33,6 +33,14 @@ "pressEnter": "Press Enter to save word", "vernacular": "Vernacular" }, + "analyticsConsent": { + "consentModal": { + "acceptAllBtn": "Yes, allow all analytics", + "acceptNecessaryBtn": "No, necessary analytics only", + "description": "The Combine stores basic info about your current session and usage for maintenance purposes. This info is necessary and isn't shared with anybody. The Combine also uses additional analytics to compile anonymized statistics. Do you consent to our usage of additional analytics?", + "title": "Analytics on The Combine" + } + }, "appBar": { "dataEntry": "Data Entry", "dataCleanup": "Data Cleanup", @@ -129,12 +137,6 @@ "userSettings": { "analyticsConsent": { "button": "Change consent", - "consentModal": { - "acceptAllBtn": "Yes, allow analytics cookies", - "acceptNecessaryBtn": "No, reject analytics cookies", - "description": "The Combine stores basic info about your current session on your device. This info is necessary and isn't shared with anybody. The Combine also uses analytics cookies, which are only for us to fix bugs and compile anonymized statistics. Do you consent to our usage of analytics cookies?", - "title": "Cookies on The Combine" - }, "consentNo": "You have not consented to our use of analytics cookies.", "consentYes": "You have consented to our use of analytics cookies.", "title": "Analytics cookies" diff --git a/src/api/models/user.ts b/src/api/models/user.ts index b88fa4d43a..beac9e1573 100644 --- a/src/api/models/user.ts +++ b/src/api/models/user.ts @@ -92,6 +92,18 @@ export interface User { * @memberof User */ username: string; + /** + * + * @type {boolean} + * @memberof User + */ + analyticsOn?: boolean; + /** + * + * @type {boolean} + * @memberof User + */ + answeredConsent?: boolean; /** * * @type {string} diff --git a/src/backend/index.ts b/src/backend/index.ts index 23d86c3352..855b7607e9 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -54,7 +54,13 @@ const whiteListedErrorUrls = [ // Create an axios instance to allow for attaching interceptors to it. const axiosInstance = axios.create({ baseURL: apiBaseURL }); axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => { - config.headers.sessionId = getSessionId(); + const consent = LocalStorage.getCurrentUser()?.analyticsOn; + if (consent === false) { + config.headers.analyticsOn = `${false}`; + } else { + config.headers.analyticsOn = `${true}`; + config.headers.sessionId = getSessionId(); + } return config; }); axiosInstance.interceptors.response.use(undefined, (err: AxiosError) => { diff --git a/src/components/AnalyticsConsent/index.tsx b/src/components/AnalyticsConsent/index.tsx new file mode 100644 index 0000000000..6888243d8c --- /dev/null +++ b/src/components/AnalyticsConsent/index.tsx @@ -0,0 +1,79 @@ +import { Button, Grid, Theme, Typography, useMediaQuery } from "@mui/material"; +import Drawer from "@mui/material/Drawer"; +import { ReactElement } from "react"; +import { useTranslation } from "react-i18next"; + +import { themeColors } from "types/theme"; + +interface ConsentProps { + onChangeConsent: (consentVal?: boolean) => void; + required: boolean; +} + +export default function AnalyticsConsent(props: ConsentProps): ReactElement { + const { t } = useTranslation(); + + const acceptAnalytics = (): void => props.onChangeConsent(true); + const rejectAnalytics = (): void => props.onChangeConsent(false); + const clickedAway = (): void => props.onChangeConsent(undefined); + + const isXs = useMediaQuery((th) => th.breakpoints.only("xs")); + + function ConsentButton(props: { + onClick: () => void; + text: string; + }): ReactElement { + return ( + + ); + } + + return ( + + + + + {t("analyticsConsent.consentModal.title")} + + + {t("analyticsConsent.consentModal.description")} + + + + + + + + + + + + + ); +} diff --git a/src/components/App/AppLoggedIn.tsx b/src/components/App/AppLoggedIn.tsx index 5fb0c49417..9c398a2b9d 100644 --- a/src/components/App/AppLoggedIn.tsx +++ b/src/components/App/AppLoggedIn.tsx @@ -4,6 +4,9 @@ import { Theme, ThemeProvider, createTheme } from "@mui/material/styles"; import { ReactElement, useEffect, useMemo, useState } from "react"; import { Route, Routes } from "react-router-dom"; +import { updateUser } from "backend"; +import { getCurrentUser } from "backend/localStorage"; +import AnalyticsConsent from "components/AnalyticsConsent"; import DatePickersLocalizationProvider from "components/App/DatePickersLocalizationProvider"; import SignalRHub from "components/App/SignalRHub"; import AppBar from "components/AppBar/AppBarComponent"; @@ -47,6 +50,18 @@ export default function AppWithBar(): ReactElement { const projFonts = useMemo(() => new ProjectFonts(proj), [proj]); const [styleOverrides, setStyleOverrides] = useState(); + const [answeredConsent, setAnsweredConsent] = useState( + getCurrentUser()?.answeredConsent + ); + + async function handleConsentChange(analyticsOn?: boolean): Promise { + await updateUser({ + ...getCurrentUser()!, + analyticsOn, + answeredConsent: true, + }); + setAnsweredConsent(true); + } useEffect(() => { updateLangFromUser(); @@ -83,6 +98,9 @@ export default function AppWithBar(): ReactElement { + {!answeredConsent && ( + + )} } /> } /> diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index 7d906a381c..d66ae6d3d1 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -3,7 +3,6 @@ import { RouterProvider } from "react-router-dom"; import AnnouncementBanner from "components/AnnouncementBanner"; import UpperRightToastContainer from "components/Toast/UpperRightToastContainer"; -import CookieConsent from "cookies/CookieConsent"; import router from "router/browserRouter"; /** @@ -13,7 +12,6 @@ export default function App(): ReactElement { return (
}> - diff --git a/src/components/UserSettings/UserSettings.tsx b/src/components/UserSettings/UserSettings.tsx index 8d6f69e0f5..7d23e8a093 100644 --- a/src/components/UserSettings/UserSettings.tsx +++ b/src/components/UserSettings/UserSettings.tsx @@ -13,16 +13,15 @@ import { import { enqueueSnackbar } from "notistack"; import { FormEvent, Fragment, ReactElement, useState } from "react"; import { useTranslation } from "react-i18next"; -import { show } from "vanilla-cookieconsent"; import { OffOnSetting, User } from "api/models"; import { isEmailTaken, updateUser } from "backend"; import { getAvatar, getCurrentUser } from "backend/localStorage"; +import AnalyticsConsent from "components/AnalyticsConsent"; import { asyncLoadSemanticDomains } from "components/Project/ProjectActions"; import ClickableAvatar from "components/UserSettings/ClickableAvatar"; import { updateLangFromUser } from "i18n"; -import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; -import { StoreState } from "rootRedux/types"; +import { useAppDispatch } from "rootRedux/hooks"; import theme from "types/theme"; import { uiWritingSystems } from "types/writingSystem"; @@ -58,13 +57,11 @@ export function UserSettings(props: { }): ReactElement { const dispatch = useAppDispatch(); - const analyticsConsent = useAppSelector( - (state: StoreState) => state.analyticsState.consent - ); - const [name, setName] = useState(props.user.name); const [phone, setPhone] = useState(props.user.phone); const [email, setEmail] = useState(props.user.email); + const [displayConsent, setDisplayConsent] = useState(false); + const [analyticsOn, setAnalyticsOn] = useState(props.user.analyticsOn); const [uiLang, setUiLang] = useState(props.user.uiLang ?? ""); const [glossSuggestion, setGlossSuggestion] = useState( props.user.glossSuggestion @@ -80,10 +77,16 @@ export function UserSettings(props: { return unchanged || !(await isEmailTaken(unicodeEmail)); } + const handleConsentChange = (consentVal?: boolean): void => { + setAnalyticsOn(consentVal ?? analyticsOn); + setDisplayConsent(false); + }; + const disabled = name === props.user.name && phone === props.user.phone && punycode.toUnicode(email) === props.user.email && + analyticsOn === props.user.analyticsOn && uiLang === (props.user.uiLang ?? "") && glossSuggestion === props.user.glossSuggestion; @@ -95,6 +98,7 @@ export function UserSettings(props: { name, phone, email: punycode.toUnicode(email), + analyticsOn, uiLang, glossSuggestion, hasAvatar: !!avatar, @@ -286,20 +290,29 @@ export function UserSettings(props: { {t( - analyticsConsent + analyticsOn ? "userSettings.analyticsConsent.consentYes" : "userSettings.analyticsConsent.consentNo" )} + + + + {displayConsent && ( + + )} diff --git a/src/cookies/CookieConsent.tsx b/src/cookies/CookieConsent.tsx deleted file mode 100644 index 613f6f6ea7..0000000000 --- a/src/cookies/CookieConsent.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Fragment, type ReactElement } from "react"; - -import useCookieConsent from "cookies/useCookieConsent"; - -/** Empty component for running useCookieConsent within a , - * because it depends on i18n localization loading first. */ -export default function CookieConsent(): ReactElement { - useCookieConsent(); - return ; -} diff --git a/src/cookies/cc.css b/src/cookies/cc.css deleted file mode 100644 index 39a08b42bd..0000000000 --- a/src/cookies/cc.css +++ /dev/null @@ -1,12 +0,0 @@ -#cc-main { - --primary: #1e88e5; /* themeColors.primary: blue[600] */ - --dark-shade: #0d47a1; /* themeColors.darkShade: blue[900] */ - - --cc-btn-primary-bg: var(--primary); - --cc-btn-primary-border-color: var(--primary); - --cc-btn-primary-hover-bg: var(--dark-shade); - --cc-btn-primary-hover-border-color: var(--dark-shade); - --cc-font-family: "Noto Sans", "Open Sans", Roboto, Helvetica, Arial, sans-serif; - --cc-primary-color: var(--primary); - --cc-secondary-color: #000000 -} diff --git a/src/cookies/useCookieConsent.tsx b/src/cookies/useCookieConsent.tsx deleted file mode 100644 index 5469409426..0000000000 --- a/src/cookies/useCookieConsent.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useCallback, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { eraseCookies, run } from "vanilla-cookieconsent"; - -import "vanilla-cookieconsent/dist/cookieconsent.css"; -import "cookies/cc.css"; - -import { useAppDispatch } from "rootRedux/hooks"; -import { updateConsent } from "types/Redux/analytics"; - -export default function useCookieConsent(): void { - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - - const updateAnalytics = useCallback( - (param: { cookie: CookieConsent.CookieValue }): void => { - console.info("C is for Cookie..."); - dispatch(updateConsent()); - if (!param.cookie.categories.includes("analytics")) { - eraseCookies(/^(?!cookie_consent$)/); // Only keep cookie with name "cookie_consent" - } - }, - [dispatch] - ); - - useEffect(() => { - run({ - categories: { analytics: {}, necessary: {} }, - cookie: { expiresAfterDays: 365, name: "cookie_consent" }, - guiOptions: { consentModal: { layout: "bar inline" } }, - language: { - default: "i18n", - translations: { - i18n: { - consentModal: { - acceptAllBtn: t( - "userSettings.analyticsConsent.consentModal.acceptAllBtn" - ), - acceptNecessaryBtn: t( - "userSettings.analyticsConsent.consentModal.acceptNecessaryBtn" - ), - description: t( - "userSettings.analyticsConsent.consentModal.description" - ), - title: t("userSettings.analyticsConsent.consentModal.title"), - }, - preferencesModal: { sections: [] }, - }, - }, - }, - onChange: updateAnalytics, - onFirstConsent: updateAnalytics, - }).then(() => dispatch(updateConsent())); - }, [dispatch, t, updateAnalytics]); -}