From da08ff6e9afc6ce421d8cfd05edf797172e9948d Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Sun, 2 Nov 2025 23:47:00 -0500 Subject: [PATCH 1/6] Feat: Add Institutional Login Page Message --- .../context/migrations/0001_login_notice.py | 25 ++++++ specifyweb/backend/context/models.py | 18 ++++- specifyweb/backend/context/views.py | 76 +++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 specifyweb/backend/context/migrations/0001_login_notice.py 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..81a1d362209 --- /dev/null +++ b/specifyweb/backend/context/migrations/0001_login_notice.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('specify', '0040_components'), + ] + + operations = [ + migrations.CreateModel( + name='LoginNotice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField(blank=True, default='')), + ('is_enabled', models.BooleanField(default=False)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'login_notice', + }, + ), + ] diff --git a/specifyweb/backend/context/models.py b/specifyweb/backend/context/models.py index 71a83623907..a041aadf193 100644 --- a/specifyweb/backend/context/models.py +++ b/specifyweb/backend/context/models.py @@ -1,3 +1,19 @@ from django.db import models -# Create your models here. + +class LoginNotice(models.Model): + """ + Stores the optional institution-wide notice displayed on the login screen. + """ + + content = models.TextField(blank=True, default='') + is_enabled = models.BooleanField(default=False) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'login_notice' + + def __str__(self) -> str: + 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/views.py b/specifyweb/backend/context/views.py index bed109ce506..b50f52a5371 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,80 @@ def domain(request): return HttpResponse(json.dumps(domain), content_type='application/json') + +def _get_login_notice() -> LoginNotice: + notice, _created = LoginNotice.objects.get_or_create(id=1) + 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(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": [ { From 3d261123063ff4922d7f45886261a00b788d0dcc Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Mon, 3 Nov 2025 04:52:05 +0000 Subject: [PATCH 2/6] Lint code with ESLint and Prettier Triggered by da08ff6e9afc6ce421d8cfd05edf797172e9948d on branch refs/heads/issue-3211 --- .../AppResources/TabDefinitions.tsx | 8 ++++-- .../__tests__/AppResourcesAside.test.tsx | 20 ++++++------- .../components/AppResources/filtersHelpers.ts | 5 +++- .../lib/components/AppResources/tree.ts | 1 - .../lib/components/AppResources/types.tsx | 3 +- .../lib/components/Attachments/attachments.ts | 9 +++--- .../lib/components/FormFields/Field.tsx | 2 +- .../lib/components/Preferences/Editor.tsx | 28 +++++++++++-------- .../Preferences/GlobalDefinitions.ts | 8 ++++-- .../lib/components/Preferences/Renderers.tsx | 9 ++---- .../Preferences/globalPreferencesActions.ts | 3 +- .../Preferences/globalPreferencesResource.ts | 21 ++++++-------- .../lib/components/Preferences/index.tsx | 9 +++--- .../frontend/js_src/lib/localization/forms.ts | 28 +++++++++---------- .../lib/localization/preferences.behavior.ts | 12 ++++---- .../js_src/lib/localization/stats.tsx | 3 +- .../frontend/js_src/lib/utils/ajax/index.ts | 2 +- 17 files changed, 91 insertions(+), 80 deletions(-) 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/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/Renderers.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx index 407c46cf6ec..aed603fbe98 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx @@ -333,12 +333,9 @@ export function DefaultPreferenceItemRender({ ))} - {f.maybe( - selectedValueDefinition?.description, - (description) => ( -

{description}

- ) - )} + {f.maybe(selectedValueDefinition?.description, (description) => ( +

{description}

+ ))} ) : parser?.type === 'checkbox' ? ( { - const rawValues = globalPreferences.getRaw() as Partial; + const rawValues = + globalPreferences.getRaw() as Partial; const fallback = getGlobalPreferenceFallback(); const metadata = getGlobalPreferencesMetadata(); diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesResource.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesResource.ts index 26e53feae08..9ca4f533716 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesResource.ts +++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesResource.ts @@ -30,9 +30,7 @@ export const setGlobalPreferencesResourceId = ( globalPreferencesResourceId = id; }; -export const buildGlobalPreferencesPayload = ( - data: string -): IR => +export const buildGlobalPreferencesPayload = (data: string): IR => keysToLowerCase({ name: 'GlobalPreferences', mimeType: 'text/plain', @@ -69,16 +67,13 @@ export const upsertGlobalPreferencesResource = async ({ } if (shouldCreate) { - const { data, status } = await ajax( - GLOBAL_RESOURCE_URL, - { - method: 'POST', - body: payload, - headers: { Accept: 'application/json' }, - errorMode, - expectedErrors: [Http.CREATED], - } - ); + const { data, status } = await ajax(GLOBAL_RESOURCE_URL, { + method: 'POST', + body: payload, + headers: { Accept: 'application/json' }, + errorMode, + expectedErrors: [Http.CREATED], + }); setGlobalPreferencesResourceId( status === Http.CREATED && typeof data?.id === 'number' ? data.id diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx index a9bcd61708e..748e97ec030 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx @@ -156,12 +156,11 @@ function Preferences({ (prefType === 'global' ? saveGlobalPreferences() : basePreferences.awaitSynced() + ).then(() => + needsRestart + ? globalThis.location.assign('/specify/') + : navigate('/specify/') ) - .then(() => - needsRestart - ? globalThis.location.assign('/specify/') - : navigate('/specify/') - ) ) } > diff --git a/specifyweb/frontend/js_src/lib/localization/forms.ts b/specifyweb/frontend/js_src/lib/localization/forms.ts index 6ca2d7b86c1..464de5a3141 100644 --- a/specifyweb/frontend/js_src/lib/localization/forms.ts +++ b/specifyweb/frontend/js_src/lib/localization/forms.ts @@ -1042,22 +1042,22 @@ export const formsText = createDictionary({ 'pt-br': 'Numeração automática', }, autoNumberByYear: { - "en-us": "Auto-number by year", - "de-ch": "Auto-Nummer nach Jahr", - "es-es": "Auto-número por año", - "fr-fr": "Auto-numéro par année", - "ru-ru": "Автонумерация по году", - "uk-ua": "Автонумерація за роком", - "pt-br": "", + 'en-us': 'Auto-number by year', + 'de-ch': 'Auto-Nummer nach Jahr', + 'es-es': 'Auto-número por año', + 'fr-fr': 'Auto-numéro par année', + 'ru-ru': 'Автонумерация по году', + 'uk-ua': 'Автонумерація за роком', + 'pt-br': '', }, autoNumber: { - "en-us": "Auto-number", - "de-ch": "Auto-Nummer", - "es-es": "Auto-número", - "fr-fr": "Auto-numéro", - "ru-ru": "Автонумерация", - "uk-ua": "Автонумерація", - "pt-br": "", + 'en-us': 'Auto-number', + 'de-ch': 'Auto-Nummer', + 'es-es': 'Auto-número', + 'fr-fr': 'Auto-numéro', + 'ru-ru': 'Автонумерация', + 'uk-ua': 'Автонумерація', + 'pt-br': '', }, editFormDefinition: { 'en-us': 'Edit Form Definition', diff --git a/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts b/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts index 9296e8b3137..793e0cf9171 100644 --- a/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts +++ b/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts @@ -1013,8 +1013,7 @@ export const preferencesBehaviorDictionary = { 'en-us': 'Enable Audit Log', }, enableAuditLogDescription: { - 'en-us': - 'Globally enables or disables the audit log for all collections.', + 'en-us': 'Globally enables or disables the audit log for all collections.', }, logFieldLevelChanges: { 'en-us': 'Log field-level changes', @@ -1027,19 +1026,22 @@ export const preferencesBehaviorDictionary = { 'en-us': 'Full date format', }, fullDateFormatDescription: { - 'en-us': 'This determines the date format used for full dates in the WorkBench, queries, and data exports.', + 'en-us': + 'This determines the date format used for full dates in the WorkBench, queries, and data exports.', }, monthYearDateFormat: { 'en-us': 'Month/year date format', }, monthYearDateFormatDescription: { - 'en-us': 'Choose how partial dates that only include a month and year should be displayed.', + 'en-us': + 'Choose how partial dates that only include a month and year should be displayed.', }, attachmentThumbnailSize: { 'en-us': 'Attachment thumbnail size (px)', }, attachmentThumbnailSizeDescription: { - 'en-us': 'Set the pixel dimensions used when generating attachment preview thumbnails.', + 'en-us': + 'Set the pixel dimensions used when generating attachment preview thumbnails.', }, rememberDialogSizes: { 'en-us': 'Remember dialog window sizes', diff --git a/specifyweb/frontend/js_src/lib/localization/stats.tsx b/specifyweb/frontend/js_src/lib/localization/stats.tsx index e531316937b..e71a12cd578 100644 --- a/specifyweb/frontend/js_src/lib/localization/stats.tsx +++ b/specifyweb/frontend/js_src/lib/localization/stats.tsx @@ -327,7 +327,8 @@ export const statsText = createDictionary({ 'en-us': 'Auto-Refresh Rate (Hours)', }, autoRefreshRateDescription: { - 'en-us': 'The time interval, in hours, at which the statistics page will automatically refresh its data. Default is 24.', + 'en-us': + 'The time interval, in hours, at which the statistics page will automatically refresh its data. Default is 24.', }, }); /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts index 644e28b4ebd..d1a3ba23ad6 100644 --- a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts +++ b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts @@ -114,7 +114,7 @@ export async function ajax( /** * When running in a test environment, mock the calls rather than make * actual requests - */ + */ // REFACTOR: replace this with a mock if (process.env.NODE_ENV === 'test') { if (ajaxMockModulePromise === undefined) From b5583150fe940e81dc338f645f7acfc8381ecdcb Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Mon, 3 Nov 2025 12:55:06 -0500 Subject: [PATCH 3/6] added table schema with more meaningful table and colum names --- .../backend/context/migrations/0001_login_notice.py | 9 +++++++-- specifyweb/backend/context/models.py | 12 ++++++++++-- specifyweb/backend/context/urls.py | 2 ++ specifyweb/backend/context/views.py | 7 +++++-- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/specifyweb/backend/context/migrations/0001_login_notice.py b/specifyweb/backend/context/migrations/0001_login_notice.py index 81a1d362209..b52c6432a14 100644 --- a/specifyweb/backend/context/migrations/0001_login_notice.py +++ b/specifyweb/backend/context/migrations/0001_login_notice.py @@ -13,13 +13,18 @@ class Migration(migrations.Migration): migrations.CreateModel( name='LoginNotice', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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': 'login_notice', + 'db_table': 'spglobalmessages', }, ), + migrations.AddConstraint( + model_name='loginnotice', + constraint=models.UniqueConstraint(fields=('scope',), name='spglobalmessages_scope_unique'), + ), ] diff --git a/specifyweb/backend/context/models.py b/specifyweb/backend/context/models.py index a041aadf193..2f88f58a283 100644 --- a/specifyweb/backend/context/models.py +++ b/specifyweb/backend/context/models.py @@ -6,14 +6,22 @@ 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 = 'login_notice' + db_table = 'spglobalmessages' + constraints = [ + models.UniqueConstraint(fields=['scope'], name='spglobalmessages_scope_unique') + ] - def __str__(self) -> str: + 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/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 b50f52a5371..9985ab8333e 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -354,7 +354,10 @@ def domain(request): def _get_login_notice() -> LoginNotice: - notice, _created = LoginNotice.objects.get_or_create(id=1) + notice, _created = LoginNotice.objects.get_or_create( + scope='login', + defaults={'content': '', 'is_enabled': False}, + ) return notice @@ -365,7 +368,7 @@ def login_notice(request): Public endpoint that returns the sanitized login notice HTML. """ - notice = LoginNotice.objects.filter(is_enabled=True).first() + notice = LoginNotice.objects.filter(scope='login', is_enabled=True).first() if notice is None: return HttpResponse(status=204) From fd1ec2b2600592ac24aaf5fc7f66ba08751cf312 Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Mon, 3 Nov 2025 14:19:28 -0500 Subject: [PATCH 4/6] Expose login notice overlay and render banner on login screen --- .../components/Header/userToolDefinitions.ts | 6 +++ .../js_src/lib/components/Login/OicLogin.tsx | 5 +- .../js_src/lib/components/Login/index.tsx | 52 ++++++++++++++++++- .../lib/components/Router/OverlayRoutes.tsx | 9 ++++ .../lib/localization/preferences.content.ts | 25 +++++++++ 5 files changed, 95 insertions(+), 2 deletions(-) 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/Router/OverlayRoutes.tsx b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx index 86550dc04d1..3b1cfa02467 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx @@ -13,6 +13,7 @@ import { treeText } from '../../localization/tree'; import { userText } from '../../localization/user'; import { welcomeText } from '../../localization/welcome'; import { wbText } from '../../localization/workbench'; +import { preferencesText } from '../../localization/preferences'; import type { RA } from '../../utils/types'; import { Redirect } from './Redirect'; import type { EnhancedRoute } from './RouterUtils'; @@ -46,6 +47,14 @@ export const overlayRoutes: RA = [ ({ UserToolsOverlay }) => UserToolsOverlay ), }, + { + path: 'login-notice', + title: preferencesText.loginPageNotice(), + element: () => + import('../Preferences/LoginNoticeOverlay').then( + ({ LoginNoticeOverlay }) => LoginNoticeOverlay + ), + }, { path: 'simple-search', title: headerText.simpleSearch(), diff --git a/specifyweb/frontend/js_src/lib/localization/preferences.content.ts b/specifyweb/frontend/js_src/lib/localization/preferences.content.ts index aa371bc7816..cde2ee68dc4 100644 --- a/specifyweb/frontend/js_src/lib/localization/preferences.content.ts +++ b/specifyweb/frontend/js_src/lib/localization/preferences.content.ts @@ -185,6 +185,31 @@ export const preferencesContentDictionary = { 'uk-ua': 'Додайте рядок пошуку на головну сторінку', 'pt-br': 'Adicionar barra de pesquisa na página inicial', }, + loginPageNotice: { + 'en-us': 'Login Page Notice', + }, + loginPageNoticeDescription: { + 'en-us': + 'Show a short message above the login form for all users of this institution.', + }, + loginPageNoticeEnabled: { + 'en-us': 'Show on login page', + }, + loginPageNoticePlaceholder: { + 'en-us': 'Welcome to Specify. Please contact the admin desk for assistance.', + }, + loginPageNoticeSaving: { + 'en-us': 'Saving notice…', + }, + loginPageNoticeSaved: { + 'en-us': 'Login notice saved.', + }, + loginPageNoticeLoadError: { + 'en-us': 'Unable to load the current login notice.', + }, + loginPageNoticeSaveError: { + 'en-us': 'Unable to save the login notice. Please try again.', + }, } as const; export const preferencesContentText = createDictionary( From f9f9d48f53147fa899b9220434ee62e0572a4079 Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Mon, 3 Nov 2025 14:21:22 -0500 Subject: [PATCH 5/6] Add login notice editor overlay and sanitizer tests --- .../backend/context/migrations/__init__.py | 1 + specifyweb/backend/context/sanitizers.py | 118 ++++++++++++ .../context/tests/test_login_notice.py | 77 ++++++++ .../Preferences/LoginNoticeOverlay.tsx | 79 ++++++++ .../Preferences/LoginNoticePreference.tsx | 177 ++++++++++++++++++ .../components/Preferences/loginNoticeApi.ts | 35 ++++ 6 files changed, 487 insertions(+) create mode 100644 specifyweb/backend/context/migrations/__init__.py create mode 100644 specifyweb/backend/context/sanitizers.py create mode 100644 specifyweb/backend/context/tests/test_login_notice.py create mode 100644 specifyweb/frontend/js_src/lib/components/Preferences/LoginNoticeOverlay.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/Preferences/LoginNoticePreference.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/Preferences/loginNoticeApi.ts 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/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/frontend/js_src/lib/components/Preferences/LoginNoticeOverlay.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/LoginNoticeOverlay.tsx new file mode 100644 index 00000000000..e13f50c29cb --- /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..1c75d9ba14f --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Preferences/LoginNoticePreference.tsx @@ -0,0 +1,177 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { preferencesText } from '../../localization/preferences'; +import { Input, Label, Textarea } from '../Atoms/Form'; +import { ErrorMessage } from '../Atoms'; +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((prev) => + typeof prev === 'object' ? { ...prev, enabled } : { enabled, content: '' } + ); + }, []); + + const setContent = React.useCallback((value: string) => { + setState((prev) => + typeof prev === 'object' + ? { + ...prev, + 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()} + +