diff --git a/specifyweb/backend/context/migrations/0001_login_notice.py b/specifyweb/backend/context/migrations/0001_login_notice.py new file mode 100644 index 00000000000..b52c6432a14 --- /dev/null +++ b/specifyweb/backend/context/migrations/0001_login_notice.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('specify', '0040_components'), + ] + + operations = [ + migrations.CreateModel( + name='LoginNotice', + fields=[ + ('sp_global_messages_id', models.AutoField(db_column='SpGlobalMessagesID', primary_key=True, serialize=False)), + ('scope', models.TextField(default='login')), + ('content', models.TextField(blank=True, default='')), + ('is_enabled', models.BooleanField(default=False)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'spglobalmessages', + }, + ), + migrations.AddConstraint( + model_name='loginnotice', + constraint=models.UniqueConstraint(fields=('scope',), name='spglobalmessages_scope_unique'), + ), + ] diff --git a/specifyweb/backend/context/migrations/__init__.py b/specifyweb/backend/context/migrations/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/specifyweb/backend/context/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/specifyweb/backend/context/models.py b/specifyweb/backend/context/models.py index 71a83623907..2f88f58a283 100644 --- a/specifyweb/backend/context/models.py +++ b/specifyweb/backend/context/models.py @@ -1,3 +1,27 @@ from django.db import models -# Create your models here. + +class LoginNotice(models.Model): + """ + Stores the optional institution-wide notice displayed on the login screen. + """ + + sp_global_messages_id = models.AutoField( + primary_key=True, + db_column='SpGlobalMessagesID', + ) + scope = models.TextField(default='login') + content = models.TextField(blank=True, default='') + is_enabled = models.BooleanField(default=False) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'spglobalmessages' + constraints = [ + models.UniqueConstraint(fields=['scope'], name='spglobalmessages_scope_unique') + ] + + def __str__(self) -> str: # pragma: no cover - helpful in admin/debug + state = 'enabled' if self.is_enabled else 'disabled' + preview = (self.content or '').strip().replace('\n', ' ')[:40] + return f'Login notice ({state}): {preview}' diff --git a/specifyweb/backend/context/sanitizers.py b/specifyweb/backend/context/sanitizers.py new file mode 100644 index 00000000000..ca5d957218b --- /dev/null +++ b/specifyweb/backend/context/sanitizers.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from html import escape +from html.parser import HTMLParser +from typing import Iterable, List, Sequence, Tuple +from urllib.parse import urlsplit + +# Allow a conservative subset of HTML tags for the login notice. +_ALLOWED_TAGS = { + 'a', + 'br', + 'em', + 'i', + 'li', + 'ol', + 'p', + 'strong', + 'u', + 'ul', +} + +_SELF_CLOSING_TAGS = {'br'} + +_ALLOWED_ATTRS = { + 'a': {'href', 'title'}, +} + +_ALLOWED_SCHEMES = {'http', 'https', 'mailto'} + + +def _is_safe_url(value: str | None) -> bool: + if value is None: + return False + stripped = value.strip() + if not stripped: + return False + parsed = urlsplit(stripped) + if parsed.scheme == '': + # Treat relative URLs as safe. + return not stripped.lower().startswith('javascript:') + return parsed.scheme.lower() in _ALLOWED_SCHEMES + + +class _LoginNoticeSanitizer(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._parts: List[str] = [] + + def handle_starttag(self, tag: str, attrs: Sequence[Tuple[str, str | None]]) -> None: + if tag not in _ALLOWED_TAGS: + return + attributes = self._sanitize_attrs(tag, attrs) + self._parts.append(self._build_start_tag(tag, attributes)) + + def handle_startendtag(self, tag: str, attrs: Sequence[Tuple[str, str | None]]) -> None: + if tag not in _ALLOWED_TAGS: + return + attributes = self._sanitize_attrs(tag, attrs) + self._parts.append(self._build_start_tag(tag, attributes, self_closing=True)) + + def handle_endtag(self, tag: str) -> None: + if tag not in _ALLOWED_TAGS or tag in _SELF_CLOSING_TAGS: + return + self._parts.append(f'') + + def handle_data(self, data: str) -> None: + self._parts.append(escape(data)) + + def handle_entityref(self, name: str) -> None: # pragma: no cover - defensive + self._parts.append(f'&{name};') + + def handle_charref(self, name: str) -> None: # pragma: no cover - defensive + self._parts.append(f'&#{name};') + + def handle_comment(self, data: str) -> None: + # Strip HTML comments entirely. + return + + def get_html(self) -> str: + return ''.join(self._parts) + + def _sanitize_attrs( + self, + tag: str, + attrs: Sequence[Tuple[str, str | None]], + ) -> Iterable[Tuple[str, str]]: + allowed = _ALLOWED_ATTRS.get(tag, set()) + for name, value in attrs: + if name not in allowed: + continue + if tag == 'a' and name == 'href' and not _is_safe_url(value): + continue + if value is None: + continue + yield name, escape(value, quote=True) + + def _build_start_tag( + self, + tag: str, + attrs: Iterable[Tuple[str, str]], + self_closing: bool = False, + ) -> str: + rendered_attrs = ' '.join(f'{name}="{value}"' for name, value in attrs) + suffix = ' /' if self_closing and tag not in _SELF_CLOSING_TAGS else '' + if rendered_attrs: + return f'<{tag} {rendered_attrs}{suffix}>' + return f'<{tag}{suffix}>' + + +def sanitize_login_notice_html(raw_html: str) -> str: + """ + Sanitize the provided HTML string for safe display on the login screen. + """ + + parser = _LoginNoticeSanitizer() + parser.feed(raw_html or '') + parser.close() + return parser.get_html() diff --git a/specifyweb/backend/context/tests/test_login_notice.py b/specifyweb/backend/context/tests/test_login_notice.py new file mode 100644 index 00000000000..a810d6fe4e9 --- /dev/null +++ b/specifyweb/backend/context/tests/test_login_notice.py @@ -0,0 +1,77 @@ +import json + +from django.test import Client + +from specifyweb.backend.context.models import LoginNotice +from specifyweb.backend.context.sanitizers import sanitize_login_notice_html +from specifyweb.specify.tests.test_api import ApiTests + + +class LoginNoticeTests(ApiTests): + def setUp(self) -> None: + super().setUp() + self.client = Client() + + def test_public_endpoint_returns_204_when_disabled(self) -> None: + response = self.client.get('/context/login_notice/') + self.assertEqual(response.status_code, 204) + + LoginNotice.objects.create(content='', is_enabled=False) + response = self.client.get('/context/login_notice/') + self.assertEqual(response.status_code, 204) + + def test_public_endpoint_returns_sanitized_content(self) -> None: + LoginNotice.objects.create( + content='

Hello

', + is_enabled=True, + ) + + response = self.client.get('/context/login_notice/') + self.assertEqual(response.status_code, 200) + payload = json.loads(response.content) + self.assertEqual(payload['message'], '

Hello

alert(1)') + + def test_manage_requires_administrator(self) -> None: + non_admin = self.specifyuser.__class__.objects.create( + isloggedin=False, + isloggedinreport=False, + name='readonly', + password='205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C', + ) + client = Client() + client.force_login(non_admin) + + response = client.get('/context/login_notice/manage/') + self.assertEqual(response.status_code, 403) + + def test_manage_update_sanitizes_and_persists(self) -> None: + self.client.force_login(self.specifyuser) + payload = { + 'enabled': True, + 'content': '

Welcome

', + } + + response = self.client.put( + '/context/login_notice/manage/', + data=json.dumps(payload), + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertTrue(data['enabled']) + self.assertEqual( + data['content'], + sanitize_login_notice_html(payload['content']), + ) + + notice = LoginNotice.objects.get(scope='login') + self.assertTrue(notice.is_enabled) + self.assertEqual( + notice.content, + sanitize_login_notice_html(payload['content']), + ) + + public_response = self.client.get('/context/login_notice/') + self.assertEqual(public_response.status_code, 200) + public_payload = json.loads(public_response.content) + self.assertEqual(public_payload['message'], notice.content) diff --git a/specifyweb/backend/context/urls.py b/specifyweb/backend/context/urls.py index 59ee69b16c1..df744fc96c3 100644 --- a/specifyweb/backend/context/urls.py +++ b/specifyweb/backend/context/urls.py @@ -19,6 +19,8 @@ re_path(r'^api_endpoints.json$', views.api_endpoints), re_path(r'^api_endpoints_all.json$', views.api_endpoints_all), re_path(r'^user.json$', views.user), + path('login_notice/manage/', views.manage_login_notice), + path('login_notice/', views.login_notice), re_path(r'^system_info.json$', views.system_info), re_path(r'^server_time.json$', views.get_server_time), re_path(r'^domain.json$', views.domain), diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index bed109ce506..9985ab8333e 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -33,10 +33,12 @@ from specifyweb.specify.api.serializers import uri_for_model from specifyweb.specify.utils.specify_jar import specify_jar from specifyweb.specify.views import login_maybe_required, openapi +from .models import LoginNotice from .app_resource import get_app_resource, FORM_RESOURCE_EXCLUDED_LST from .remote_prefs import get_remote_prefs from .schema_localization import get_schema_languages, get_schema_localization from .viewsets import get_views +from .sanitizers import sanitize_login_notice_html def set_collection_cookie(response, collection_id): # pragma: no cover @@ -350,6 +352,83 @@ def domain(request): return HttpResponse(json.dumps(domain), content_type='application/json') + +def _get_login_notice() -> LoginNotice: + notice, _created = LoginNotice.objects.get_or_create( + scope='login', + defaults={'content': '', 'is_enabled': False}, + ) + return notice + + +@require_http_methods(['GET']) +@cache_control(max_age=60, public=True) +def login_notice(request): + """ + Public endpoint that returns the sanitized login notice HTML. + """ + + notice = LoginNotice.objects.filter(scope='login', is_enabled=True).first() + if notice is None: + return HttpResponse(status=204) + + html = notice.content.strip() + if not html: + return HttpResponse(status=204) + + return JsonResponse({'message': html}) + + +@login_maybe_required +@require_http_methods(['GET', 'PUT']) +@never_cache +def manage_login_notice(request): + """ + Allow institution administrators to view and update the login notice. + """ + + if not request.specify_user.is_admin(): + return HttpResponseForbidden() + + notice = _get_login_notice() + + if request.method == 'GET': + return JsonResponse( + { + 'enabled': notice.is_enabled and notice.content.strip() != '', + 'content': notice.content, + 'updated_at': notice.updated_at.isoformat(), + } + ) + + try: + payload = json.loads(request.body or '{}') + except json.JSONDecodeError: + return HttpResponseBadRequest('Invalid JSON payload.') + + content = payload.get('content', '') + enabled = payload.get('enabled', False) + + if not isinstance(content, str): + return HttpResponseBadRequest('"content" must be a string.') + if not isinstance(enabled, bool): + return HttpResponseBadRequest('"enabled" must be a boolean.') + + sanitized = sanitize_login_notice_html(content) + is_enabled = enabled and sanitized.strip() != '' + + notice.content = sanitized + notice.is_enabled = is_enabled + notice.save(update_fields=['content', 'is_enabled', 'updated_at']) + + return JsonResponse( + { + 'enabled': notice.is_enabled, + 'content': notice.content, + 'updated_at': notice.updated_at.isoformat(), + } + ) + @openapi(schema={ "parameters": [ { diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx index 3e04a934719..6e55da0a27f 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx @@ -27,7 +27,11 @@ import { DataObjectFormatter } from '../Formatters'; import { formattersSpec } from '../Formatters/spec'; import { FormEditor } from '../FormEditor'; import { viewSetsSpec } from '../FormEditor/spec'; -import { UserPreferencesEditor, CollectionPreferencesEditor, GlobalPreferencesEditor } from '../Preferences/Editor'; +import { + UserPreferencesEditor, + CollectionPreferencesEditor, + GlobalPreferencesEditor, +} from '../Preferences/Editor'; import { useDarkMode } from '../Preferences/Hooks'; import type { BaseSpec } from '../Syncer'; import type { SimpleXmlNode } from '../Syncer/xmlToJson'; @@ -189,4 +193,4 @@ export const visualAppResourceEditors = f.store< otherJsonResource: undefined, otherPropertiesResource: undefined, otherAppResources: undefined, -})); \ No newline at end of file +})); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx index 0450a42493f..90d0a6de808 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx @@ -130,16 +130,16 @@ describe('AppResourcesAside (expanded case)', () => { unmount: unmountExpandedll, container: expandedContainer, } = mount( - - - - ); + + + + ); const expandedAllFragment = asFragmentAllExpanded().textContent; diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts index e265a8aeb4c..171a3b16ee8 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts @@ -75,7 +75,10 @@ export const getAppResourceType = ( if (matchedType !== undefined) return matchedType; - if (normalize(resource.name) === 'preferences' && normalize(resource.mimeType) === undefined) + if ( + normalize(resource.name) === 'preferences' && + normalize(resource.mimeType) === undefined + ) return 'otherPropertiesResource'; return 'otherAppResources'; diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts b/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts index e971d58a6aa..a32e51945d6 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts @@ -1,4 +1,3 @@ - import { resourcesText } from '../../localization/resources'; import { userText } from '../../localization/user'; import type { RA } from '../../utils/types'; diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx index 2eaa19c6e39..fa9c7815162 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx @@ -114,7 +114,8 @@ export const appResourceSubTypes = ensure>()({ remotePreferences: { mimeType: 'text/x-java-properties', name: 'preferences', - documentationUrl: 'https://discourse.specifysoftware.org/t/specify-7-global-preferences/3100', + documentationUrl: + 'https://discourse.specifysoftware.org/t/specify-7-global-preferences/3100', icon: icons.cog, label: resourcesText.globalPreferences(), scope: ['global'], diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index ac94e79001c..c230debaeff 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts @@ -301,15 +301,14 @@ export async function uploadFile( } async function getAttachmentPublicDefault(): Promise { - const collectionPrefKey = - 'attachment.is_public_default' as const; + const collectionPrefKey = 'attachment.is_public_default' as const; const collectionId = schema.domainLevelIds.collection; try { const collectionPreferences = await ensureCollectionPreferencesLoaded(); const rawValue = - collectionPreferences - .getRaw() - ?.general?.attachments?.['attachment.is_public_default']; + collectionPreferences.getRaw()?.general?.attachments?.[ + 'attachment.is_public_default' + ]; if (typeof rawValue === 'boolean') return rawValue; return collectionPreferences.get( 'general', diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx index 023306db8c6..1d1f69191a2 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx @@ -266,4 +266,4 @@ export function useRightAlignClassName( globalThis.navigator.userAgent.toLowerCase().includes('webkit') ? `text-right ${isReadOnly ? '' : 'pr-6'}` : ''; -} \ No newline at end of file +} diff --git a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts index d74d58da810..dafeaa4d5c4 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts @@ -100,6 +100,12 @@ const rawUserTools = ensure>>>()({ hasPermission(`/tree/edit/${toLowerCase(treeName)}`, 'repair') ), }, + loginNotice: { + title: preferencesText.loginPageNotice(), + url: '/specify/overlay/login-notice/', + icon: icons.informationCircle, + enabled: () => userInformation.isadmin, + }, generateMasterKey: { title: userText.generateMasterKey(), url: '/specify/overlay/master-key/', diff --git a/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx b/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx index 28a27614f26..56735d75627 100644 --- a/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx +++ b/specifyweb/frontend/js_src/lib/components/Login/OicLogin.tsx @@ -15,7 +15,7 @@ import { Link } from '../Atoms/Link'; import { Submit } from '../Atoms/Submit'; import { SplashScreen } from '../Core/SplashScreen'; import { formatUrl } from '../Router/queryString'; -import { LoginLanguageChooser } from './index'; +import { LoginLanguageChooser, LoginNoticeBanner } from './index'; export type OicProvider = { readonly provider: string; @@ -25,6 +25,7 @@ export type OicProvider = { export function OicLogin({ data, nextUrl, + loginNotice, }: { readonly data: { readonly inviteToken: '' | { readonly username: string }; @@ -33,12 +34,14 @@ export function OicLogin({ readonly csrfToken: string; }; readonly nextUrl: string; + readonly loginNotice?: string; }): JSX.Element { const providerRef = React.useRef(null); const formRef = React.useRef(null); const [next = ''] = useSearchParameter('next'); return ( +
{typeof data.inviteToken === 'object' && ( diff --git a/specifyweb/frontend/js_src/lib/components/Login/index.tsx b/specifyweb/frontend/js_src/lib/components/Login/index.tsx index 59f98d09be9..fe889cb762b 100644 --- a/specifyweb/frontend/js_src/lib/components/Login/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Login/index.tsx @@ -12,6 +12,7 @@ import { userText } from '../../localization/user'; import type { Language } from '../../localization/utils/config'; import { devLanguage, LANGUAGE } from '../../localization/utils/config'; import { ajax } from '../../utils/ajax'; +import { Http } from '../../utils/ajax/definitions'; import { parseDjangoDump } from '../../utils/ajax/csrfToken'; import type { RA } from '../../utils/types'; import { ErrorMessage } from '../Atoms'; @@ -43,6 +44,37 @@ export function Login(): JSX.Element { ), true ); + const [loginNotice] = useAsyncState( + React.useCallback(async () => { + try { + const { data, status } = await ajax<{ readonly message: string }>( + '/context/login_notice/', + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + errorMode: 'silent', + expectedErrors: [Http.NO_CONTENT], + } + ); + if (status === Http.NO_CONTENT) return undefined; + return data?.message ?? undefined; + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'response' in error && + (error as { readonly response?: Response }).response?.status === + Http.NO_CONTENT + ) + return undefined; + console.error('Failed to fetch login notice:', error); + return undefined; + } + }, []), + false + ); return React.useMemo(() => { const nextUrl = parseDjangoDump('next-url') ?? '/specify/'; @@ -61,6 +93,7 @@ export function Login(): JSX.Element { languages: parseDjangoDump('languages') ?? [], csrfToken: parseDjangoDump('csrf-token') ?? '', }} + loginNotice={loginNotice} nextUrl={ // REFACTOR: use parseUrl() and formatUrl() instead nextUrl.startsWith(nextDestination) @@ -78,6 +111,7 @@ export function Login(): JSX.Element { languages: parseDjangoDump('languages') ?? [], csrfToken: parseDjangoDump('csrf-token') ?? '', }} + loginNotice={loginNotice} nextUrl={ nextUrl.startsWith(nextDestination) ? nextUrl @@ -85,11 +119,24 @@ export function Login(): JSX.Element { } /> ); - }, [isNewUser]); + }, [isNewUser, loginNotice]); } const nextDestination = '/accounts/choose_collection/?next='; +export function LoginNoticeBanner({ + notice, +}: { + readonly notice?: string; +}): JSX.Element | null { + if (typeof notice !== 'string' || notice.trim().length === 0) return null; + return ( +
+
+
+ ); +} + export function LoginLanguageChooser({ languages, }: { @@ -115,6 +162,7 @@ export function LoginLanguageChooser({ function LegacyLogin({ data, nextUrl, + loginNotice, }: { readonly data: { readonly formErrors: RA; @@ -130,6 +178,7 @@ function LegacyLogin({ readonly csrfToken: string; }; readonly nextUrl: string; + readonly loginNotice?: string; }): JSX.Element { const [formErrors] = React.useState(data.formErrors); @@ -140,6 +189,7 @@ function LegacyLogin({ return ( + {commonText.language()} diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx index 475357d5770..0ab2f50a08a 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx @@ -34,9 +34,7 @@ type PreferencesEditorConfig = { readonly dependencyResolver?: ( inputs: EditorDependencies ) => React.DependencyList; - readonly parse?: ( - data: string | null - ) => { + readonly parse?: (data: string | null) => { readonly raw: PartialPreferences; readonly metadata?: unknown; }; @@ -59,7 +57,9 @@ const parseJsonPreferences = ( readonly raw: PartialPreferences; readonly metadata?: undefined; } => ({ - raw: JSON.parse(data === null || data.length === 0 ? '{}' : data) as PartialPreferences, + raw: JSON.parse( + data === null || data.length === 0 ? '{}' : data + ) as PartialPreferences, }); const serializeJsonPreferences = ( @@ -80,7 +80,9 @@ const parseGlobalPreferenceData = ( } => { const { raw, metadata } = parseGlobalPreferences(data); return { - raw: raw as unknown as PartialPreferences, + raw: raw as unknown as PartialPreferences< + typeof globalPreferenceDefinitions + >, metadata, }; }; @@ -122,8 +124,7 @@ function createPreferencesEditor( const dependencies = dependencyResolver({ data, onChange }); const parse = config.parse ?? - ((rawData: string | null) => - parseJsonPreferences(rawData)); + ((rawData: string | null) => parseJsonPreferences(rawData)); const serialize = config.serialize ?? ((raw: PartialPreferences, metadata: unknown) => @@ -149,14 +150,17 @@ function createPreferencesEditor( syncChanges: false, }); - preferences.setRaw(initialRaw as PartialPreferences as PartialPreferences); + preferences.setRaw( + initialRaw as PartialPreferences as PartialPreferences + ); preferences.events.on('update', () => { const result = serialize( preferences.getRaw() as PartialPreferences, metadataRef.current ); - if (result.metadata !== undefined) metadataRef.current = result.metadata; + if (result.metadata !== undefined) + metadataRef.current = result.metadata; onChange(result.data); }); @@ -219,8 +223,10 @@ export const CollectionPreferencesEditor = createPreferencesEditor({ developmentGlobal: 'editingCollectionPreferences', prefType: 'collection', dependencyResolver: ({ data, onChange }) => [data, onChange], - parse: (data) => parseJsonPreferences(data), - serialize: (raw) => serializeJsonPreferences(raw), + parse: (data) => + parseJsonPreferences(data), + serialize: (raw) => + serializeJsonPreferences(raw), }); export const GlobalPreferencesEditor = createPreferencesEditor({ diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts index b0a3c0577ff..7594e81fcf5 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts @@ -21,10 +21,14 @@ export const FULL_DATE_FORMAT_OPTIONS = [ 'dd/MM/yyyy', ] as const; -export const MONTH_YEAR_FORMAT_OPTIONS = ['YYYY-MM', 'MM/YYYY', 'YYYY/MM'] as const; +export const MONTH_YEAR_FORMAT_OPTIONS = [ + 'YYYY-MM', + 'MM/YYYY', + 'YYYY/MM', +] as const; export const globalPreferenceDefinitions = { -formatting: { + formatting: { title: preferencesText.formatting(), subCategories: { formatting: { diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/LoginNoticeOverlay.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/LoginNoticeOverlay.tsx new file mode 100644 index 00000000000..5748e97978a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Preferences/LoginNoticeOverlay.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import { commonText } from '../../localization/common'; +import { preferencesText } from '../../localization/preferences'; +import { Button } from '../Atoms/Button'; +import { icons } from '../Atoms/Icons'; +import { LoadingContext } from '../Core/Contexts'; +import { Dialog } from '../Molecules/Dialog'; +import { OverlayContext } from '../Router/Router'; +import { + LoginNoticeForm, + useLoginNoticeEditor, +} from './LoginNoticePreference'; + +export function LoginNoticeOverlay(): JSX.Element { + const handleClose = React.useContext(OverlayContext); + const loading = React.useContext(LoadingContext); + const { + state, + isLoading, + isSaving, + error, + hasChanges, + setContent, + setEnabled, + save, + } = useLoginNoticeEditor(); + const [hasSaved, markSaved, resetSaved] = useBooleanState(); + + const handleSave = React.useCallback(() => { + resetSaved(); + loading( + save() + .then(() => markSaved()) + .catch((error) => { + throw error; + }) + ); + }, [loading, markSaved, resetSaved, save]); + + return ( + + {commonText.close()} + + {commonText.save()} + + + } + header={preferencesText.loginPageNotice()} + icon={icons.informationCircle} + onClose={handleClose} + > + { + resetSaved(); + setContent(value); + }} + onEnabledChange={(value) => { + resetSaved(); + setEnabled(value); + }} + /> + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/LoginNoticePreference.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/LoginNoticePreference.tsx new file mode 100644 index 00000000000..d31bee0104a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Preferences/LoginNoticePreference.tsx @@ -0,0 +1,178 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { preferencesText } from '../../localization/preferences'; +import { ErrorMessage } from '../Atoms'; +import { Input, Label, Textarea } from '../Atoms/Form'; +import { + fetchLoginNoticeSettings, + updateLoginNoticeSettings, +} from './loginNoticeApi'; + +export type LoginNoticeState = { + readonly enabled: boolean; + readonly content: string; +}; + +type LoginNoticeEditorResult = { + readonly state: LoginNoticeState | undefined; + readonly isLoading: boolean; + readonly isSaving: boolean; + readonly error: string | undefined; + readonly hasChanges: boolean; + readonly setEnabled: (enabled: boolean) => void; + readonly setContent: (value: string) => void; + readonly save: () => Promise; +}; + +export function useLoginNoticeEditor(): LoginNoticeEditorResult { + const [state, setState] = React.useState(); + const [isLoading, setIsLoading] = React.useState(true); + const [isSaving, setIsSaving] = React.useState(false); + const [error, setError] = React.useState(); + const initialRef = React.useRef(); + + React.useEffect(() => { + let isMounted = true; + setIsLoading(true); + fetchLoginNoticeSettings() + .then((data) => { + if (!isMounted) return; + const sanitized: LoginNoticeState = { + enabled: data.enabled, + content: data.content, + }; + initialRef.current = sanitized; + setState(sanitized); + setError(undefined); + }) + .catch((fetchError) => { + console.error('Failed to load login notice settings', fetchError); + if (isMounted) setError(preferencesText.loginPageNoticeLoadError()); + }) + .finally(() => { + if (isMounted) setIsLoading(false); + }); + return () => { + isMounted = false; + }; + }, []); + + const hasChanges = + state !== undefined && + initialRef.current !== undefined && + (initialRef.current.enabled !== state.enabled || + initialRef.current.content !== state.content); + + const setEnabled = React.useCallback((enabled: boolean) => { + setState((previous) => + typeof previous === 'object' + ? { ...previous, enabled } + : { enabled, content: '' } + ); + }, []); + + const setContent = React.useCallback((value: string) => { + setState((previous) => + typeof previous === 'object' + ? { + ...previous, + content: value, + } + : { enabled: false, content: value } + ); + }, []); + + const save = React.useCallback(async () => { + if (!hasChanges || state === undefined) return; + setIsSaving(true); + setError(undefined); + try { + const updated = await updateLoginNoticeSettings(state); + const sanitized: LoginNoticeState = { + enabled: updated.enabled, + content: updated.content, + }; + initialRef.current = sanitized; + setState(sanitized); + } catch (saveError) { + console.error('Failed to save login notice', saveError); + setError(preferencesText.loginPageNoticeSaveError()); + throw saveError; + } finally { + setIsSaving(false); + } + }, [hasChanges, state]); + + return { + state, + isLoading, + isSaving, + error, + hasChanges, + setEnabled, + setContent, + save, + }; +} + +export function LoginNoticeForm({ + description, + error, + successMessage, + isLoading, + isSaving, + state, + onEnabledChange, + onContentChange, + savingLabel, +}: { + readonly description?: string; + readonly error?: string; + readonly successMessage?: string; + readonly isLoading: boolean; + readonly isSaving: boolean; + readonly state: LoginNoticeState | undefined; + readonly onEnabledChange: (enabled: boolean) => void; + readonly onContentChange: (content: string) => void; + readonly savingLabel?: string; +}): JSX.Element { + return ( +
+ {description !== undefined && ( +

{description}

+ )} + {error !== undefined && {error}} + {successMessage !== undefined && error === undefined && ( +

+ {successMessage} +

+ )} + {isLoading || state === undefined ? ( +

{commonText.loading()}

+ ) : ( + <> + + + {preferencesText.loginPageNoticeEnabled()} + +