diff --git a/backend/core/urls.py b/backend/core/urls.py index 75cd024e3..5d8b67ba6 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -84,6 +84,7 @@ path("get_metrics/", get_metrics_view, name="get_metrics_view"), path("agg_data/", get_agg_data, name="get_agg_data"), path("composer_data/", get_composer_data, name="get_composer_data"), + path("preferences/", UpdatePreferences.as_view(), name="preferences"), path("i18n/", include("django.conf.urls.i18n")), path( "accounts/saml/", include("iam.sso.saml.urls") diff --git a/backend/core/views.py b/backend/core/views.py index 76f6d5de3..2bd40b450 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1278,6 +1278,15 @@ def get_composer_data(request): return Response({"result": data}) +class UpdatePreferences(APIView): + def get(self, request): + return Response(request.user.preferences) + + def patch(self, request): + request.user.update_preferences(request.data) + return Response(status=status.HTTP_200_OK) + + # Compliance Assessment diff --git a/backend/iam/migrations/0009_user_preferences.py b/backend/iam/migrations/0009_user_preferences.py new file mode 100644 index 000000000..217364831 --- /dev/null +++ b/backend/iam/migrations/0009_user_preferences.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1 on 2024-10-10 11:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("iam", "0008_user_is_third_party"), + ] + + 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 ef46e912a..456b23dca 100644 --- a/backend/iam/models.py +++ b/backend/iam/models.py @@ -2,8 +2,9 @@ Inspired from Azure IAM model""" from collections import defaultdict -from typing import Any, List, Self, Tuple +from typing import Any, Dict, List, Self, Tuple import uuid +from django.forms import JSONField from django.utils import timezone from django.db import models from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager @@ -358,6 +359,7 @@ class User(AbstractBaseUser, AbstractBaseModel, FolderMixin): "granted to each of their user groups." ), ) + preferences = models.JSONField(default=dict) objects = CaseInsensitiveUserManager() # USERNAME_FIELD is used as the unique identifier for the user @@ -366,6 +368,9 @@ class User(AbstractBaseUser, AbstractBaseModel, FolderMixin): USERNAME_FIELD = "email" REQUIRED_FIELDS = [] + # This is the set of of keys allowed in the preferences JSONField + PREFERENCE_SET = {"lang"} + class Meta: """for Model""" @@ -474,6 +479,12 @@ def get_user_groups(self): """get the list of user groups containing the user in the form (group_name, builtin)""" return [(x.__str__(), x.builtin) for x in self.user_groups.all()] + def update_preferences(self, new_preferences: Dict[str, Any]): + for key, value in new_preferences.items(): + if key in self.PREFERENCE_SET: + self.preferences[key] = value + self.save() + @property def has_backup_permission(self) -> bool: return RoleAssignment.is_access_allowed( diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 169adb675..44b999e57 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -78,7 +78,7 @@ export const handle: Handle = async ({ event, resolve }) => { const errorId = new URL(event.request.url).searchParams.get('error'); if (errorId) { - setLanguageTag(event.cookies.get('ciso_lang') || generalSettings.lang || 'en'); + setLanguageTag(event.cookies.get('ciso_lang') || 'en'); setFlash({ type: 'error', message: safeTranslate(errorId) }, event); redirect(302, '/login'); } diff --git a/frontend/src/lib/components/SideBar/SideBarFooter.svelte b/frontend/src/lib/components/SideBar/SideBarFooter.svelte index f4526a37b..ec69f24a8 100644 --- a/frontend/src/lib/components/SideBar/SideBarFooter.svelte +++ b/frontend/src/lib/components/SideBar/SideBarFooter.svelte @@ -47,6 +47,12 @@ value = event?.target?.value; setLanguageTag(value); // sessionStorage.setItem('lang', value); + fetch('/api/preferences', { + method: 'PATCH', + body: JSON.stringify({ + lang: value + }) + }); setCookie('ciso_lang', value); window.location.reload(); } diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index e448a8d67..655f3db6b 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -9,8 +9,17 @@ import { pageTitle, clientSideToast } from '$lib/utils/stores'; import { getCookie, deleteCookie } from '$lib/utils/cookies'; import { browser } from '$app/environment'; + import { page } from '$app/stores'; + import { onMount } from 'svelte'; import * as m from '$paraglide/messages'; + onMount(() => { + if ($page.url.searchParams.has('refresh')) { + $page.url.searchParams.delete('refresh'); + window.location.href = $page.url.href; + } + }); + let sidebarOpen = true; $: classesSidebarOpen = (open: boolean) => (open ? 'ml-64' : 'ml-7'); diff --git a/frontend/src/routes/(authentication)/login/+page.server.ts b/frontend/src/routes/(authentication)/login/+page.server.ts index ece7a90c4..4f110d9ea 100644 --- a/frontend/src/routes/(authentication)/login/+page.server.ts +++ b/frontend/src/routes/(authentication)/login/+page.server.ts @@ -66,6 +66,21 @@ export const actions: Actions = { secure: true }); - redirect(302, getSecureRedirect(url.searchParams.get('next')) || '/analytics'); + const preferencesRes = await fetch(`${BASE_API_URL}/preferences/`); + const preferences = await preferencesRes.json(); + + const currentLang = cookies.get('ciso_lang') || 'en'; + const preferedLang = preferences.lang; + + if (preferedLang && currentLang !== preferedLang) { + cookies.set('ciso_lang', preferedLang, { + httpOnly: false, + sameSite: 'lax', + path: '/', + secure: true + }); + } + + redirect(302, getSecureRedirect(url.searchParams.get('next') || '/analytics') + '?refresh=1'); } }; diff --git a/frontend/src/routes/api/preferences/+server.ts b/frontend/src/routes/api/preferences/+server.ts new file mode 100644 index 000000000..f2940f720 --- /dev/null +++ b/frontend/src/routes/api/preferences/+server.ts @@ -0,0 +1,14 @@ +import { BASE_API_URL } from '$lib/utils/constants'; +import type { RequestHandler } from './$types'; + +export const PATCH: RequestHandler = async ({ fetch, request }) => { + const preferences = await request.json(); + const requestInitOptions: RequestInit = { + method: 'PATCH', + body: JSON.stringify(preferences) + }; + + const endpoint = `${BASE_API_URL}/preferences/`; + await fetch(endpoint, requestInitOptions); + return new Response(); +};