diff --git a/Backend.Tests/Otel/OtelKernelTests.cs b/Backend.Tests/Otel/OtelKernelTests.cs index d21a66f663..1663b77edd 100644 --- a/Backend.Tests/Otel/OtelKernelTests.cs +++ b/Backend.Tests/Otel/OtelKernelTests.cs @@ -12,9 +12,14 @@ namespace Backend.Tests.Otel { public class OtelKernelTests : IDisposable { + + private const string FrontendConsentKey = "otelConsent"; private const string FrontendSessionIdKey = "sessionId"; + private const string OtelConsentKey = "otelConsent"; private const string OtelSessionIdKey = "sessionId"; + private const string OtelConsentBaggageKey = "otelConsentBaggage"; private const string OtelSessionBaggageKey = "sessionBaggage"; + private LocationEnricher _locationEnricher = null!; public void Dispose() @@ -32,41 +37,46 @@ protected virtual void Dispose(bool disposing) } [Test] - public void BuildersSetSessionBaggageFromHeader() + public void BuildersSetConsentAndSessionBaggageFromHeader() { // Arrange var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers[FrontendConsentKey] = "true"; httpContext.Request.Headers[FrontendSessionIdKey] = "123"; var activity = new Activity("testActivity").Start(); // Act + TrackConsent(activity, httpContext.Request); TrackSession(activity, httpContext.Request); // Assert + Assert.That(activity.Baggage.Any(_ => _.Key == OtelConsentBaggageKey)); Assert.That(activity.Baggage.Any(_ => _.Key == OtelSessionBaggageKey)); } [Test] - public void OnEndSetsSessionTagFromBaggage() + public void OnEndSetsConsentAndSessionTagFromBaggage() { // Arrange var activity = new Activity("testActivity").Start(); + activity.SetBaggage(OtelConsentBaggageKey, "true"); activity.SetBaggage(OtelSessionBaggageKey, "test session id"); // Act _locationEnricher.OnEnd(activity); // Assert + Assert.That(activity.Tags.Any(_ => _.Key == OtelConsentKey)); Assert.That(activity.Tags.Any(_ => _.Key == OtelSessionIdKey)); } - [Test] public void OnEndSetsLocationTags() { // Arrange _locationEnricher = new LocationEnricher(new LocationProviderMock()); var activity = new Activity("testActivity").Start(); + activity.SetBaggage(OtelConsentBaggageKey, "true"); // Act _locationEnricher.OnEnd(activity); @@ -81,11 +91,13 @@ public void OnEndSetsLocationTags() Assert.That(activity.Tags, Is.SupersetOf(testLocation)); } + [Test] public void OnEndRedactsIp() { // Arrange _locationEnricher = new LocationEnricher(new LocationProviderMock()); var activity = new Activity("testActivity").Start(); + activity.SetBaggage(OtelConsentBaggageKey, "true"); activity.SetTag("url.full", $"{LocationProvider.locationGetterUri}100.0.0.0"); // Act diff --git a/Backend/Models/User.cs b/Backend/Models/User.cs index d6407b1416..10c12604cf 100644 --- a/Backend/Models/User.cs +++ b/Backend/Models/User.cs @@ -66,6 +66,12 @@ public class User [BsonElement("username")] public string Username { get; set; } + [BsonElement("otelConsent")] + public bool OtelConsent { get; set; } + + [BsonElement("answeredConsent")] + public bool AnsweredConsent { get; set; } + [BsonElement("uiLang")] public string UILang { get; set; } @@ -97,6 +103,8 @@ public User() Agreement = false; Password = ""; Username = ""; + OtelConsent = false; + AnsweredConsent = false; UILang = ""; GlossSuggestion = AutocompleteSetting.On; Token = ""; @@ -119,6 +127,8 @@ public User Clone() Agreement = Agreement, Password = Password, Username = Username, + OtelConsent = OtelConsent, + AnsweredConsent = AnsweredConsent, UILang = UILang, GlossSuggestion = GlossSuggestion, Token = Token, @@ -141,6 +151,8 @@ public bool ContentEquals(User other) other.Agreement == Agreement && other.Password.Equals(Password, StringComparison.Ordinal) && other.Username.Equals(Username, StringComparison.Ordinal) && + other.OtelConsent == OtelConsent && + other.AnsweredConsent == AnsweredConsent && other.UILang.Equals(UILang, StringComparison.Ordinal) && other.GlossSuggestion.Equals(GlossSuggestion) && other.Token.Equals(Token, StringComparison.Ordinal) && @@ -178,6 +190,8 @@ public override int GetHashCode() hash.Add(Agreement); hash.Add(Password); hash.Add(Username); + hash.Add(OtelConsent); + 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..88725313df 100644 --- a/Backend/Otel/OtelKernel.cs +++ b/Backend/Otel/OtelKernel.cs @@ -33,6 +33,12 @@ public static void AddOpenTelemetryInstrumentation(this IServiceCollection servi ); } + internal static void TrackConsent(Activity activity, HttpRequest request) + { + var consent = request.Headers.TryGetValue("otelConsent", out var values) ? bool.TryParse(values.FirstOrDefault(), out bool _) : false; + activity.SetBaggage("otelConsentBaggage", consent.ToString()); + } + internal static void TrackSession(Activity activity, HttpRequest request) { var sessionId = request.Headers.TryGetValue("sessionId", out var values) ? values.FirstOrDefault() : null; @@ -67,6 +73,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 +105,28 @@ 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 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)) + var consentString = data?.GetBaggageItem("otelConsentBaggage"); + data?.AddTag("otelConsent", consentString); + var consent = bool.TryParse(consentString, out bool value) ? value : false; + if (consent) { - // 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?.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); + } } } } diff --git a/Backend/Repositories/UserRepository.cs b/Backend/Repositories/UserRepository.cs index 0ab2f006bb..35e3d8e44f 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.OtelConsent, user.OtelConsent) + .Set(x => x.AnsweredConsent, user.AnsweredConsent) .Set(x => x.UILang, user.UILang) .Set(x => x.GlossSuggestion, user.GlossSuggestion); diff --git a/src/api/models/user.ts b/src/api/models/user.ts index fef3669aef..7b3cc9a52d 100644 --- a/src/api/models/user.ts +++ b/src/api/models/user.ts @@ -94,26 +94,38 @@ export interface User { username: string; /** * - * @type {string} + * @type {boolean} * @memberof User */ - uiLang?: string | null; + otelConsent?: boolean; /** * - * @type {string} + * @type {boolean} * @memberof User */ - token: string; + answeredConsent?: boolean; /** * - * @type {boolean} + * @type {string} * @memberof User */ - isAdmin: boolean; + uiLang?: string | null; /** * * @type {AutocompleteSetting} * @memberof User */ glossSuggestion: AutocompleteSetting; + /** + * + * @type {string} + * @memberof User + */ + token: string; + /** + * + * @type {boolean} + * @memberof User + */ + isAdmin: boolean; } diff --git a/src/backend/index.ts b/src/backend/index.ts index 33bf1c40bc..2e72a5ec23 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -54,7 +54,10 @@ 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(); + LocalStorage.getCurrentUser()?.otelConsent + ? ((config.headers.otelConsent = true), + (config.headers.sessionId = getSessionId())) + : (config.headers.otelConsent = false); return config; }); axiosInstance.interceptors.response.use(undefined, (err: AxiosError) => { diff --git a/src/components/AnalyticsConsent/AnalyticsConsent.tsx b/src/components/AnalyticsConsent/AnalyticsConsent.tsx new file mode 100644 index 0000000000..6e4ed373cc --- /dev/null +++ b/src/components/AnalyticsConsent/AnalyticsConsent.tsx @@ -0,0 +1,34 @@ +import { Button } from "@mui/material"; +import Drawer from "@mui/material/Drawer"; +import { ReactElement, useState } from "react"; + +interface ConsentProps { + onChangeConsent: (consentVal: boolean) => void; +} + +export function AnalyticsConsent(props: ConsentProps): ReactElement { + + const [responded, setResponded] = useState(false); + const acceptAnalytics = (): void => { + + setResponded(true); + props.onChangeConsent(true); + }; + const rejectAnalytics = (): void => { + + setResponded(false); + props.onChangeConsent(false); + }; + + + return ( +
+ + MyDrawer! + + + + +
+ ); +} diff --git a/src/components/App/AppLoggedIn.tsx b/src/components/App/AppLoggedIn.tsx index 5fb0c49417..46367202a8 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/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(otelConsent: boolean): Promise { + await updateUser({ + ...getCurrentUser()!, + otelConsent, + answeredConsent: true, + }); + setAnsweredConsent(true); + } useEffect(() => { updateLangFromUser(); @@ -83,6 +98,11 @@ export default function AppWithBar(): ReactElement { + {answeredConsent ? null : ( + + )} } /> } /> 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 ecf98a692c..07661cd473 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 { AutocompleteSetting, User } from "api/models"; import { isEmailTaken, updateUser } from "backend"; import { getAvatar, getCurrentUser } from "backend/localStorage"; +import { AnalyticsConsent } from "components/AnalyticsConsent/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,10 @@ 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 [otelConsent, setOtelConsent] = useState(props.user.otelConsent); const [uiLang, setUiLang] = useState(props.user.uiLang ?? ""); const [glossSuggestion, setGlossSuggestion] = useState( props.user.glossSuggestion @@ -80,10 +76,19 @@ export function UserSettings(props: { return unchanged || !(await isEmailTaken(unicodeEmail)); } + const [displayConsent, setDisplayConsent] = useState(false); + const show = (): void => setDisplayConsent(true); + + const handleConsentChange = (consentVal: boolean): void => { + setOtelConsent(consentVal); + setDisplayConsent(false); + }; + const disabled = name === props.user.name && phone === props.user.phone && punycode.toUnicode(email) === props.user.email && + otelConsent === props.user.otelConsent && uiLang === (props.user.uiLang ?? "") && glossSuggestion === props.user.glossSuggestion; @@ -95,6 +100,7 @@ export function UserSettings(props: { name, phone, email: punycode.toUnicode(email), + otelConsent, uiLang, glossSuggestion, hasAvatar: !!avatar, @@ -286,7 +292,7 @@ export function UserSettings(props: { {t( - analyticsConsent + otelConsent ? "userSettings.analyticsConsent.consentYes" : "userSettings.analyticsConsent.consentNo" )} @@ -294,11 +300,16 @@ export function UserSettings(props: { + {displayConsent ? ( + + ) : null} diff --git a/src/types/user.ts b/src/types/user.ts index 29d6d0177c..55ad01aeb1 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -15,6 +15,7 @@ export function newUser(name = "", username = "", password = ""): User { glossSuggestion: AutocompleteSetting.On, token: "", isAdmin: false, + answeredConsent: false, }; }