diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml index 1cb1db33b..54104981b 100644 --- a/.github/workflows/functional-tests.yml +++ b/.github/workflows/functional-tests.yml @@ -70,7 +70,6 @@ jobs: run: | touch .env echo PUBLIC_BACKEND_API_URL=http://localhost:8000/api >> .env - - name: Create backend environment variables file working-directory: ${{ env.backend-directory }} run: | diff --git a/.github/workflows/startup-tests.yml b/.github/workflows/startup-tests.yml index 3fb3bb1bd..ac3a48c24 100644 --- a/.github/workflows/startup-tests.yml +++ b/.github/workflows/startup-tests.yml @@ -136,7 +136,9 @@ jobs: working-directory: ${{ env.frontend-directory }} run: | response=$(curl -d "username=admin@tests.com&password=1234" -H "Origin: https://localhost:8443" https://localhost:8443/login\?/login -k) - server_reponse='{"type":"redirect","status":302,"location":""}' + server_reponse='{"type":"redirect","status":302,"location":"/"}' + echo "[SERVER_RESPONSE] $response" + echo "[EXPECTED_RESPONSE] $server_reponse" if [[ "$response" == "$server_reponse" ]]; then echo "Success" exit 0 @@ -265,7 +267,9 @@ jobs: working-directory: ${{ env.frontend-directory }} run: | response=$(curl -d "username=admin@tests.com&password=1234" -H "Origin: https://localhost:8443" https://localhost:8443/login\?/login -k) - server_reponse='{"type":"redirect","status":302,"location":""}' + server_reponse='{"type":"redirect","status":302,"location":"/"}' + echo "[SERVER_RESPONSE] $response" + echo "[EXPECTED_RESPONSE] $server_reponse" if [[ "$response" == "$server_reponse" ]]; then echo "Success" exit 0 diff --git a/backend/ciso_assistant/settings.py b/backend/ciso_assistant/settings.py index 708220052..bff8abcd7 100644 --- a/backend/ciso_assistant/settings.py +++ b/backend/ciso_assistant/settings.py @@ -306,7 +306,7 @@ def set_ciso_assistant_url(_, __, event_dict): ("es", "Spanish"), ("de", "German"), ("it", "Italian"), - ("nd", "Dutch"), + ("nl", "Dutch"), ("pl", "Polish"), ("pt", "Portuguese"), ("ar", "Arabic"), diff --git a/backend/core/urls.py b/backend/core/urls.py index d0aa4054d..57b55901e 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -83,6 +83,7 @@ path("iam/", include("iam.urls")), path("serdes/", include("serdes.urls")), path("settings/", include("global_settings.urls")), + path("user-preferences/", UserPreferencesView.as_view(), name="user-preferences"), path("ebios-rm/", include("ebios_rm.urls")), path("csrf/", get_csrf_token, name="get_csrf_token"), path("build/", get_build, name="get_build"), diff --git a/backend/core/views.py b/backend/core/views.py index be431f545..73a74924e 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -50,7 +50,6 @@ from rest_framework.renderers import JSONRenderer from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN from rest_framework.utils.serializer_helpers import ReturnDict from rest_framework.views import APIView from rest_framework.permissions import AllowAny @@ -299,7 +298,7 @@ def quality_check_detail(self, request, pk): } return Response(res) else: - return Response(status=HTTP_403_FORBIDDEN) + return Response(status=status.HTTP_403_FORBIDDEN) @action(detail=False, methods=["get"]) def ids(self, request): @@ -606,7 +605,7 @@ def quality_check_detail(self, request, pk): risk_assessment = self.get_object() return Response(risk_assessment.quality_check()) else: - return Response(status=HTTP_403_FORBIDDEN) + return Response(status=status.HTTP_403_FORBIDDEN) @action(detail=True, methods=["get"], name="Get treatment plan data") def plan(self, request, pk): @@ -639,7 +638,7 @@ def plan(self, request, pk): return Response(risk_assessment) else: - return Response(status=HTTP_403_FORBIDDEN) + return Response(status=status.HTTP_403_FORBIDDEN) @action(detail=True, name="Get treatment plan CSV") def treatment_plan_csv(self, request, pk): @@ -699,7 +698,9 @@ def treatment_plan_csv(self, request, pk): return response else: - return Response({"error": "Permission denied"}, status=HTTP_403_FORBIDDEN) + return Response( + {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN + ) @action(detail=True, name="Get risk assessment CSV") def risk_assessment_csv(self, request, pk): @@ -761,7 +762,9 @@ def risk_assessment_csv(self, request, pk): return response else: - return Response({"error": "Permission denied"}, status=HTTP_403_FORBIDDEN) + return Response( + {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN + ) @action(detail=True, name="Get risk assessment PDF") def risk_assessment_pdf(self, request, pk): @@ -1324,7 +1327,7 @@ def update(self, request, *args, **kwargs): _data = { "non_field_errors": "The justification can only be edited by the approver" } - return Response(data=_data, status=HTTP_400_BAD_REQUEST) + return Response(data=_data, status=status.HTTP_400_BAD_REQUEST) else: return super().update(request, *args, **kwargs) @@ -1436,7 +1439,7 @@ def update(self, request: Request, *args, **kwargs) -> Response: if str(admin_group.pk) not in new_user_groups: return Response( {"error": "attemptToRemoveOnlyAdminUserGroup"}, - status=HTTP_403_FORBIDDEN, + status=status.HTTP_403_FORBIDDEN, ) return super().update(request, *args, **kwargs) @@ -1448,7 +1451,7 @@ def destroy(self, request, *args, **kwargs): if number_of_admin_users == 1: return Response( {"error": "attemptToDeleteOnlyAdminAccountError"}, - status=HTTP_403_FORBIDDEN, + status=status.HTTP_403_FORBIDDEN, ) return super().destroy(request, *args, **kwargs) @@ -1677,6 +1680,29 @@ def my_assignments(self, request): ) +class UserPreferencesView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request) -> Response: + return Response(request.user.preferences, status=status.HTTP_200_OK) + + def patch(self, request) -> Response: + new_language = request.data.get("lang") + if new_language is None or new_language not in ( + lang[0] for lang in settings.LANGUAGES + ): + logger.error( + f"Error in UserPreferencesView: new_language={new_language} available languages={[lang[0] for lang in settings.LANGUAGES]}" + ) + return Response( + {"error": "This language doesn't exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) + request.user.preferences["lang"] = new_language + request.user.save() + return Response({}, status=status.HTTP_200_OK) + + @cache_page(60 * SHORT_CACHE_TTL) @vary_on_cookie @api_view(["GET"]) diff --git a/backend/iam/migrations/0009_create_allauth_emailaddress_objects.py b/backend/iam/migrations/0009_create_allauth_emailaddress_objects.py index e2d220c2b..0da343def 100644 --- a/backend/iam/migrations/0009_create_allauth_emailaddress_objects.py +++ b/backend/iam/migrations/0009_create_allauth_emailaddress_objects.py @@ -5,8 +5,8 @@ def create_emailaddress_objects(apps, schema_editor): try: - from allauth.account.models import EmailAddress - from iam.models import User + EmailAddress = apps.get_model("account", "EmailAddress") + User = apps.get_model("iam", "User") for user in User.objects.all(): EmailAddress.objects.create( diff --git a/backend/iam/migrations/0010_user_preferences.py b/backend/iam/migrations/0010_user_preferences.py new file mode 100644 index 000000000..6bd439eb1 --- /dev/null +++ b/backend/iam/migrations/0010_user_preferences.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.1 on 2024-12-04 10:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("iam", "0009_create_allauth_emailaddress_objects"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="preferences", + field=models.JSONField(default=dict), + ), + ] diff --git a/backend/iam/models.py b/backend/iam/models.py index 15b3725b8..e67ce56ec 100644 --- a/backend/iam/models.py +++ b/backend/iam/models.py @@ -343,6 +343,7 @@ class User(AbstractBaseUser, AbstractBaseModel, FolderMixin): first_name = models.CharField(_("first name"), max_length=150, blank=True) email = models.CharField(max_length=100, unique=True) first_login = models.BooleanField(default=True) + preferences = models.JSONField(default=dict) is_sso = models.BooleanField(default=False) is_third_party = models.BooleanField(default=False) is_active = models.BooleanField( diff --git a/backend/iam/views.py b/backend/iam/views.py index db7f7d5e8..ee057dea4 100644 --- a/backend/iam/views.py +++ b/backend/iam/views.py @@ -61,6 +61,7 @@ def post(self, request) -> Response: class CurrentUserView(views.APIView): + # Is this condition really necessary if we have permission_classes = [permissions.IsAuthenticated] ? permission_classes = [permissions.IsAuthenticated] def get(self, request) -> Response: diff --git a/enterprise/frontend/src/routes/+layout.svelte b/enterprise/frontend/src/routes/+layout.svelte index 80a90947f..56d4c5c56 100644 --- a/enterprise/frontend/src/routes/+layout.svelte +++ b/enterprise/frontend/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import '../app.postcss'; import '@fortawesome/fontawesome-free/css/all.min.css'; import ParaglideSvelte from './ParaglideJsProvider.svelte'; + import { browser } from '$app/environment'; import { computePosition, autoUpdate, offset, shift, flip, arrow } from '@floating-ui/dom'; @@ -97,6 +98,11 @@ ? `data:${$faviconB64.mimeType};base64, ${$faviconB64.data}` : favicon; }); + + $: if (browser && $page.url.searchParams.has('refresh')) { + $page.url.searchParams.delete('refresh'); + window.location.href = $page.url.href; + } diff --git a/frontend/src/lib/components/SideBar/SideBarFooter.svelte b/frontend/src/lib/components/SideBar/SideBarFooter.svelte index 222db555d..7f65e4adb 100644 --- a/frontend/src/lib/components/SideBar/SideBarFooter.svelte +++ b/frontend/src/lib/components/SideBar/SideBarFooter.svelte @@ -50,6 +50,12 @@ event.preventDefault(); value = event?.target?.value; setLanguageTag(value); + fetch('/api/user-preferences', { + method: 'PATCH', + body: JSON.stringify({ + lang: value + }) + }); // sessionStorage.setItem('lang', value); setCookie('ciso_lang', value); window.location.reload(); diff --git a/frontend/src/routes/(app)/+page.server.ts b/frontend/src/routes/(app)/+page.server.ts index d139b4b57..07b2f1091 100644 --- a/frontend/src/routes/(app)/+page.server.ts +++ b/frontend/src/routes/(app)/+page.server.ts @@ -1,6 +1,7 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async () => { - redirect(301, '/analytics'); +export const load: PageServerLoad = async ({ url }) => { + const queryParams = url.searchParams.has('refresh') ? '?refresh=1' : ''; + redirect(301, `/analytics${queryParams}`); }; diff --git a/frontend/src/routes/(authentication)/login/+page.server.ts b/frontend/src/routes/(authentication)/login/+page.server.ts index 0c5ab5392..7b118aa89 100644 --- a/frontend/src/routes/(authentication)/login/+page.server.ts +++ b/frontend/src/routes/(authentication)/login/+page.server.ts @@ -1,7 +1,6 @@ import { getSecureRedirect } from '$lib/utils/helpers'; import { ALLAUTH_API_URL, BASE_API_URL } from '$lib/utils/constants'; -import { csrfToken } from '$lib/utils/csrf'; import { loginSchema } from '$lib/utils/schemas'; import type { LoginRequestBody } from '$lib/utils/types'; import { fail, redirect, type Actions } from '@sveltejs/kit'; @@ -9,7 +8,6 @@ import { setError, superValidate } from 'sveltekit-superforms'; import { zod } from 'sveltekit-superforms/adapters'; import type { PageServerLoad } from './$types'; import { mfaAuthenticateSchema } from './mfa/utils/schemas'; -import { setFlash } from 'sveltekit-flash-message/server'; interface AuthenticationFlow { id: @@ -27,6 +25,15 @@ interface AuthenticationFlow { types: 'totp' | 'recovery_codes'; } +function makeRedirectURL(currentLang: string, preferedLang: string, url: URL): string { + const next = url.searchParams.get('next'); + const secureNext = getSecureRedirect(next) || '/'; + if (currentLang === preferedLang) { + return secureNext; + } + return secureNext ? `${secureNext}?refresh=1` : `/?refresh=1`; +} + export const load: PageServerLoad = async ({ fetch, request, locals }) => { // redirect user if already logged in if (locals.user) { @@ -74,29 +81,27 @@ export const actions: Actions = { }); return fail(res.status, { form }); } - if (res.status === 401) { + if (res.status === 401 && res.data) { // User is not authenticated - if (res.data) { - const flows: AuthenticationFlow[] = res.data.flows; - if (flows.length > 0) { - const mfaFlow = flows.find((flow) => flow.id === 'mfa_authenticate'); - const sessionToken = res.meta.session_token; - if (sessionToken) { - cookies.set('allauth_session_token', sessionToken, { - httpOnly: true, - sameSite: 'lax', - path: '/', - secure: true - }); - } - - if (mfaFlow) { - return { - form, - mfa: true, - mfaFlow - }; - } + const flows: AuthenticationFlow[] = res.data.flows; + if (flows.length > 0) { + const mfaFlow = flows.find((flow) => flow.id === 'mfa_authenticate'); + const sessionToken = res.meta.session_token; + if (sessionToken) { + cookies.set('allauth_session_token', sessionToken, { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: true + }); + } + + if (mfaFlow) { + return { + form, + mfa: true, + mfaFlow + }; } } } @@ -117,8 +122,22 @@ export const actions: Actions = { secure: true }); - const next = url.searchParams.get('next') || '/'; - redirect(302, getSecureRedirect(next)); + const preferencesRes = await fetch(`${BASE_API_URL}/user-preferences/`); + const preferences = await preferencesRes.json(); + + const currentLang = cookies.get('ciso_lang') || 'en'; + const preferedLang = preferences.lang || 'en'; + + if (currentLang !== preferedLang) { + cookies.set('ciso_lang', preferedLang, { + httpOnly: false, + sameSite: 'lax', + path: '/', + secure: true + }); + } + + redirect(302, makeRedirectURL(currentLang, preferedLang, url)); }, mfaAuthenticate: async (event) => { const formData = await event.request.formData(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index fc51d8241..51b1a7cc2 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -2,7 +2,7 @@ // Most of your app wide CSS should be put in this file import '../app.postcss'; import '@fortawesome/fontawesome-free/css/all.min.css'; - import ParaglideSvelte from './ParaglideJsProvider.svelte'; + import { browser } from '$app/environment'; import { computePosition, autoUpdate, offset, shift, flip, arrow } from '@floating-ui/dom'; @@ -77,6 +77,11 @@ createModal: { ref: CreateModal }, deleteConfirmModal: { ref: DeleteConfirmModal } }; + + $: if (browser && $page.url.searchParams.has('refresh')) { + $page.url.searchParams.delete('refresh'); + window.location.href = $page.url.href; + } diff --git a/frontend/src/routes/api/user-preferences/+server.ts b/frontend/src/routes/api/user-preferences/+server.ts new file mode 100644 index 000000000..3c58fdc79 --- /dev/null +++ b/frontend/src/routes/api/user-preferences/+server.ts @@ -0,0 +1,36 @@ +import { BASE_API_URL } from '$lib/utils/constants'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ fetch, request }) => { + const endpoint = `${BASE_API_URL}/user-preferences/`; + const req = await fetch(endpoint); + const status = await req.status; + const responseData = await req.json(); + + return new Response(JSON.stringify(responseData), { + status: status, + headers: { + 'Content-Type': 'application/json' + } + }); +}; + +export const PATCH: RequestHandler = async ({ fetch, request }) => { + const newPreferences = await request.text(); + const requestInitOptions: RequestInit = { + method: 'PATCH', + body: newPreferences + }; + + const endpoint = `${BASE_API_URL}/user-preferences/`; + const req = await fetch(endpoint, requestInitOptions); + const status = await req.status; + const responseData = await req.text(); + + return new Response(responseData, { + status: status, + headers: { + 'Content-Type': 'application/json' + } + }); +}; diff --git a/frontend/tests/functional/startup.test.ts b/frontend/tests/functional/startup.test.ts index a56fee59e..1dc86a631 100644 --- a/frontend/tests/functional/startup.test.ts +++ b/frontend/tests/functional/startup.test.ts @@ -5,11 +5,11 @@ test('startup tests', async ({ loginPage, analyticsPage, page }) => { await page.goto('/'); await loginPage.hasUrl(1); await loginPage.login(); - await analyticsPage.hasUrl(); + await analyticsPage.hasUrl(false); }); await test.step('proper redirection to the analytics page after login', async () => { - await analyticsPage.hasUrl(); + await analyticsPage.hasUrl(false); await analyticsPage.hasTitle(); }); }); diff --git a/frontend/tests/utils/base-page.ts b/frontend/tests/utils/base-page.ts index 0a6505446..e50b607b2 100644 --- a/frontend/tests/utils/base-page.ts +++ b/frontend/tests/utils/base-page.ts @@ -1,5 +1,15 @@ import { expect, type Locator, type Page } from './test-utils.js'; +/** + * Escape the characters of `string` to safely insert it in a regex. + * + * @param {string} string - The string to escape. + * @returns {string} The escaped string. + */ +function escapeRegex(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + export abstract class BasePage { readonly url: string; readonly name: string | RegExp; @@ -28,8 +38,17 @@ export abstract class BasePage { await expect.soft(this.pageTitle).toHaveText(title); } - async hasUrl() { - await expect(this.page).toHaveURL(this.url); + /** + * Check whether the browser's URL match the `this.url` value. + * + * @param {boolean} [strict=true] - Determines the URL matching mode. + * If `strict` is `true`, the function checks if `this.url` is strictly equal to the browser's URL. + * Otherwise, it checks if the browser's URL starts with `this.url`. + * @returns {void} + */ + async hasUrl(strict: boolean = true) { + const URLPattern = strict ? this.url : new RegExp(escapeRegex(this.url) + '.*'); + await expect(this.page).toHaveURL(URLPattern); } async hasBreadcrumbPath(paths: (string | RegExp)[], fullPath = true, origin = 'Home') {