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'{tag}>')
+
+ 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 (
+