diff --git a/backend/api/newbook.py b/backend/api/newbook.py index 985b1e0..2cc0762 100644 --- a/backend/api/newbook.py +++ b/backend/api/newbook.py @@ -45,6 +45,7 @@ class NewbookSettingsResponse(BaseModel): newbook_dinner_gl_codes: str | None newbook_breakfast_vat_rate: Decimal | None newbook_dinner_vat_rate: Decimal | None + use_global_newbook: bool = False class Config: from_attributes = True @@ -63,6 +64,7 @@ class NewbookSettingsUpdate(BaseModel): newbook_dinner_gl_codes: str | None = None newbook_breakfast_vat_rate: Decimal | None = None newbook_dinner_vat_rate: Decimal | None = None + use_global_newbook: bool | None = None class GLAccountResponse(BaseModel): @@ -232,7 +234,8 @@ async def get_newbook_settings( newbook_breakfast_gl_codes=settings.newbook_breakfast_gl_codes, newbook_dinner_gl_codes=settings.newbook_dinner_gl_codes, newbook_breakfast_vat_rate=settings.newbook_breakfast_vat_rate, - newbook_dinner_vat_rate=settings.newbook_dinner_vat_rate + newbook_dinner_vat_rate=settings.newbook_dinner_vat_rate, + use_global_newbook=settings.use_global_newbook, ) @@ -285,7 +288,8 @@ async def update_newbook_settings( newbook_breakfast_gl_codes=settings.newbook_breakfast_gl_codes, newbook_dinner_gl_codes=settings.newbook_dinner_gl_codes, newbook_breakfast_vat_rate=settings.newbook_breakfast_vat_rate, - newbook_dinner_vat_rate=settings.newbook_dinner_vat_rate + newbook_dinner_vat_rate=settings.newbook_dinner_vat_rate, + use_global_newbook=settings.use_global_newbook, ) @@ -334,6 +338,9 @@ async def test_newbook_connection( if not settings: raise HTTPException(status_code=404, detail="Settings not found") + from services.global_settings_service import apply_global_overrides + await apply_global_overrides(settings) + if not all([ settings.newbook_api_username, settings.newbook_api_password, diff --git a/backend/api/resos.py b/backend/api/resos.py index 9d9a9b2..4879cc1 100644 --- a/backend/api/resos.py +++ b/backend/api/resos.py @@ -45,6 +45,7 @@ class ResosSettingsResponse(BaseModel): resos_arrival_widget_service_filter: str | None # Service type: breakfast/lunch/dinner/other sambapos_food_gl_codes: str | None # Phase 8.1 sambapos_beverage_gl_codes: str | None # Phase 8.1 + use_global_resos: bool = False class Config: from_attributes = True @@ -67,6 +68,7 @@ class ResosSettingsUpdate(BaseModel): resos_arrival_widget_service_filter: str | None = None # Opening hour ID for arrivals widget filter sambapos_food_gl_codes: str | None = None # Phase 8.1 sambapos_beverage_gl_codes: str | None = None # Phase 8.1 + use_global_resos: bool | None = None class DailyStatsResponse(BaseModel): @@ -149,7 +151,8 @@ async def get_resos_settings( resos_flag_icon_mapping=settings.resos_flag_icon_mapping, resos_arrival_widget_service_filter=settings.resos_arrival_widget_service_filter, sambapos_food_gl_codes=settings.sambapos_food_gl_codes, # Phase 8.1 - sambapos_beverage_gl_codes=settings.sambapos_beverage_gl_codes # Phase 8.1 + sambapos_beverage_gl_codes=settings.sambapos_beverage_gl_codes, # Phase 8.1 + use_global_resos=settings.use_global_resos or False, ) @@ -203,6 +206,8 @@ async def update_resos_settings( settings.sambapos_food_gl_codes = update.sambapos_food_gl_codes if update.sambapos_beverage_gl_codes is not None: settings.sambapos_beverage_gl_codes = update.sambapos_beverage_gl_codes + if update.use_global_resos is not None: + settings.use_global_resos = update.use_global_resos await db.commit() logger.info(f"[Resos PATCH] After commit - upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}") @@ -220,6 +225,9 @@ async def test_resos_connection( ) settings = result.scalar_one() + from services.global_settings_service import apply_global_overrides + await apply_global_overrides(settings) + if not settings.resos_api_key: raise HTTPException(status_code=400, detail="Resos API key not configured") diff --git a/backend/api/sambapos.py b/backend/api/sambapos.py index 06d9c86..1cd65fb 100644 --- a/backend/api/sambapos.py +++ b/backend/api/sambapos.py @@ -27,6 +27,7 @@ class SambaPOSSettingsResponse(BaseModel): sambapos_db_password_set: bool sambapos_tracked_categories: list[str] sambapos_excluded_items: list[str] + use_global_sambapos: bool = False class Config: from_attributes = True @@ -38,6 +39,7 @@ class SambaPOSSettingsUpdate(BaseModel): sambapos_db_name: str | None = None sambapos_db_username: str | None = None sambapos_db_password: str | None = None + use_global_sambapos: bool | None = None class CategoryResponse(BaseModel): @@ -91,7 +93,8 @@ async def get_sambapos_settings( sambapos_db_username=settings.sambapos_db_username, sambapos_db_password_set=bool(settings.sambapos_db_password), sambapos_tracked_categories=tracked_categories, - sambapos_excluded_items=excluded_items + sambapos_excluded_items=excluded_items, + use_global_sambapos=settings.use_global_sambapos or False, ) @@ -136,7 +139,8 @@ async def update_sambapos_settings( sambapos_db_username=settings.sambapos_db_username, sambapos_db_password_set=bool(settings.sambapos_db_password), sambapos_tracked_categories=tracked_categories, - sambapos_excluded_items=excluded_items + sambapos_excluded_items=excluded_items, + use_global_sambapos=settings.use_global_sambapos or False, ) @@ -154,6 +158,9 @@ async def test_sambapos_connection( if not settings: raise HTTPException(status_code=404, detail="Settings not found") + from services.global_settings_service import apply_global_overrides + await apply_global_overrides(settings) + if not all([ settings.sambapos_db_host, settings.sambapos_db_name, diff --git a/backend/api/settings.py b/backend/api/settings.py index a88603e..a22fb93 100644 --- a/backend/api/settings.py +++ b/backend/api/settings.py @@ -49,6 +49,8 @@ class SettingsResponse(BaseModel): llm_confidence_threshold: float | None = None llm_monthly_token_limit: int = 500000 llm_features_enabled: dict | None = None + # Global stack settings delegation + use_global_smtp: bool = False class Config: from_attributes = True @@ -92,6 +94,8 @@ class SettingsUpdate(BaseModel): llm_confidence_threshold: float | None = None llm_monthly_token_limit: int | None = None llm_features_enabled: dict | None = None + # Global stack settings delegation + use_global_smtp: bool | None = None @router.get("/", response_model=SettingsResponse) @@ -158,6 +162,7 @@ def _build_settings_response(settings: KitchenSettings) -> SettingsResponse: llm_confidence_threshold=float(settings.llm_confidence_threshold) if settings.llm_confidence_threshold else None, llm_monthly_token_limit=settings.llm_monthly_token_limit, llm_features_enabled=settings.llm_features_enabled, + use_global_smtp=settings.use_global_smtp, ) @@ -237,7 +242,13 @@ async def test_smtp_connection( ) settings = result.scalar_one_or_none() - if not settings or not settings.smtp_host or not settings.smtp_from_email: + if not settings: + raise HTTPException(status_code=400, detail="SMTP not configured") + + from services.global_settings_service import apply_global_overrides + await apply_global_overrides(settings) + + if not settings.smtp_host or not settings.smtp_from_email: raise HTTPException( status_code=400, detail="SMTP not fully configured. Please set SMTP host and from email." @@ -404,6 +415,7 @@ class NextcloudSettingsResponse(BaseModel): nextcloud_base_path: str | None nextcloud_enabled: bool nextcloud_delete_local: bool + use_global_nextcloud: bool = False class Config: from_attributes = True @@ -416,6 +428,7 @@ class NextcloudSettingsUpdate(BaseModel): nextcloud_base_path: str | None = None nextcloud_enabled: bool | None = None nextcloud_delete_local: bool | None = None + use_global_nextcloud: bool | None = None class NextcloudStatsResponse(BaseModel): @@ -450,7 +463,8 @@ async def get_nextcloud_settings( nextcloud_password_set=False, nextcloud_base_path="/Kitchen Invoices", nextcloud_enabled=False, - nextcloud_delete_local=False + nextcloud_delete_local=False, + use_global_nextcloud=False, ) return NextcloudSettingsResponse( @@ -459,7 +473,8 @@ async def get_nextcloud_settings( nextcloud_password_set=bool(settings.nextcloud_password), nextcloud_base_path=settings.nextcloud_base_path, nextcloud_enabled=settings.nextcloud_enabled, - nextcloud_delete_local=settings.nextcloud_delete_local + nextcloud_delete_local=settings.nextcloud_delete_local, + use_global_nextcloud=settings.use_global_nextcloud, ) @@ -499,10 +514,20 @@ async def update_nextcloud_settings( nextcloud_password_set=bool(settings.nextcloud_password), nextcloud_base_path=settings.nextcloud_base_path, nextcloud_enabled=settings.nextcloud_enabled, - nextcloud_delete_local=settings.nextcloud_delete_local + nextcloud_delete_local=settings.nextcloud_delete_local, + use_global_nextcloud=settings.use_global_nextcloud, ) +@router.get("/global-status") +async def get_global_status( + current_user: User = Depends(get_current_user), +): + """Check which integrations are configured in the central stack settings service.""" + from services.global_settings_service import check_global_status + return await check_global_status() + + @router.post("/nextcloud/test") async def test_nextcloud_connection( current_user: User = Depends(get_current_user), @@ -516,7 +541,13 @@ async def test_nextcloud_connection( ) settings = result.scalar_one_or_none() - if not settings or not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]): + if not settings: + raise HTTPException(status_code=400, detail="Nextcloud not fully configured") + + from services.global_settings_service import apply_global_overrides + await apply_global_overrides(settings) + + if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]): raise HTTPException(status_code=400, detail="Nextcloud not fully configured") nc = NextcloudService( diff --git a/backend/main.py b/backend/main.py index c071c8e..578e4a6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -71,6 +71,7 @@ from migrations.add_recipe_text_flag_dismissals import migrate as run_recipe_tex from migrations.add_llm_infrastructure import migrate as run_llm_infrastructure_migration from migrations.add_changelog_invoice_link import migrate as run_changelog_invoice_link_migration from migrations.add_sambapos_portion_name import migrate as run_sambapos_portion_name_migration +from migrations.add_global_settings_flags import migrate as run_global_settings_flags_migration from scheduler import start_scheduler, stop_scheduler @@ -144,6 +145,7 @@ async def lifespan(app: FastAPI): await _run("LLM infrastructure", run_llm_infrastructure_migration) await _run("Changelog invoice link", run_changelog_invoice_link_migration) await _run("SambaPOS portion name", run_sambapos_portion_name_migration) + await _run("Global settings flags", run_global_settings_flags_migration) start_scheduler() diff --git a/backend/migrations/add_global_settings_flags.py b/backend/migrations/add_global_settings_flags.py new file mode 100644 index 0000000..f888b15 --- /dev/null +++ b/backend/migrations/add_global_settings_flags.py @@ -0,0 +1,24 @@ +"""Migration: Add use_global_* flags to kitchen_settings for central stack credential delegation.""" +import asyncio +from sqlalchemy import text +from database import engine + + +async def migrate(): + async with engine.begin() as conn: + for col in [ + "ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS use_global_smtp BOOLEAN DEFAULT FALSE", + "ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS use_global_nextcloud BOOLEAN DEFAULT FALSE", + "ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS use_global_newbook BOOLEAN DEFAULT FALSE", + "ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS use_global_resos BOOLEAN DEFAULT FALSE", + "ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS use_global_sambapos BOOLEAN DEFAULT FALSE", + ]: + try: + await conn.execute(text(col)) + except Exception: + pass + print("+ Global settings flags migration complete") + + +if __name__ == "__main__": + asyncio.run(migrate()) diff --git a/backend/models/settings.py b/backend/models/settings.py index 06b978e..2f31cec 100644 --- a/backend/models/settings.py +++ b/backend/models/settings.py @@ -214,6 +214,13 @@ class KitchenSettings(Base): "dispute_email": True, "duplicate_detection": True, "supplier_alias": True, "yield_estimation": True }) + # Global settings flags — use central stack settings auth credentials for each integration + use_global_smtp: Mapped[bool] = mapped_column(Boolean, default=False) + use_global_nextcloud: Mapped[bool] = mapped_column(Boolean, default=False) + use_global_newbook: Mapped[bool] = mapped_column(Boolean, default=False) + use_global_resos: Mapped[bool] = mapped_column(Boolean, default=False) + use_global_sambapos: Mapped[bool] = mapped_column(Boolean, default=False) + # Internal API key (for in-house apps like menu display plugin) api_key: Mapped[str | None] = mapped_column(String(100), nullable=True) api_key_enabled: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/backend/services/file_archival_service.py b/backend/services/file_archival_service.py index c69957f..3972795 100644 --- a/backend/services/file_archival_service.py +++ b/backend/services/file_archival_service.py @@ -31,11 +31,15 @@ class FileArchivalService: self.kitchen_id = kitchen_id async def get_settings(self) -> Optional[KitchenSettings]: - """Get kitchen settings""" + """Get kitchen settings, with global credential overrides applied in memory.""" result = await self.db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id) ) - return result.scalar_one_or_none() + settings = result.scalar_one_or_none() + if settings: + from services.global_settings_service import apply_global_overrides + await apply_global_overrides(settings) + return settings async def is_ready_for_archival(self, invoice: Invoice) -> bool: """ diff --git a/backend/services/global_settings_service.py b/backend/services/global_settings_service.py new file mode 100644 index 0000000..a87aa58 --- /dev/null +++ b/backend/services/global_settings_service.py @@ -0,0 +1,97 @@ +""" +Fetch integration credentials from the central stack settings service. + +When a kitchen setting has use_global_=True, the backend reads credentials +from the settings service (SETTINGS_URL) instead of the local kitchen_settings row. +apply_global_overrides() modifies the KitchenSettings object IN MEMORY only — it does +not commit anything to the DB. +""" +import os +import logging +import httpx + +logger = logging.getLogger(__name__) + +SETTINGS_URL = os.getenv("SETTINGS_URL", "").rstrip("/") +STACK_INTERNAL_SECRET = os.getenv("STACK_INTERNAL_SECRET", "") + + +async def get_global_integration(slug: str) -> dict | None: + """Fetch unmasked credentials for an integration from the central settings service.""" + if not SETTINGS_URL or not STACK_INTERNAL_SECRET: + return None + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get( + f"{SETTINGS_URL}/api/internal/integration/{slug}", + headers={"Authorization": f"Bearer {STACK_INTERNAL_SECRET}"}, + ) + if resp.status_code == 200: + return resp.json() + logger.warning("Global settings fetch for %s returned %s", slug, resp.status_code) + except Exception as exc: + logger.warning("Global settings fetch for %s failed: %s", slug, exc) + return None + + +async def check_global_status() -> dict[str, bool]: + """Return whether each integration is configured in the central settings service.""" + # slug → list of secret fields that must be non-empty to count as "configured" + required = { + "smtp": ["pass"], + "nextcloud": ["password"], + "newbook": ["api_key"], + "resos": ["api_key"], + "sambapos": ["password"], + } + results: dict[str, bool] = {} + for slug, fields in required.items(): + creds = await get_global_integration(slug) + results[slug] = bool(creds and all(creds.get(f) for f in fields)) + return results + + +async def apply_global_overrides(settings) -> None: + """ + In-memory only: override credential fields on a KitchenSettings instance + where the corresponding use_global_* flag is True. + + Attribute assignments do NOT persist to the DB — the session is never committed + as a result of this call. + """ + if getattr(settings, "use_global_smtp", False): + creds = await get_global_integration("smtp") + if creds: + settings.smtp_host = creds.get("host") or settings.smtp_host + settings.smtp_port = int(creds.get("port") or settings.smtp_port or 587) + settings.smtp_username = creds.get("user") or settings.smtp_username + settings.smtp_password = creds.get("pass") or settings.smtp_password + + if getattr(settings, "use_global_nextcloud", False): + creds = await get_global_integration("nextcloud") + if creds: + settings.nextcloud_host = creds.get("base_url") or settings.nextcloud_host + settings.nextcloud_username = creds.get("username") or settings.nextcloud_username + settings.nextcloud_password = creds.get("password") or settings.nextcloud_password + + if getattr(settings, "use_global_newbook", False): + creds = await get_global_integration("newbook") + if creds: + settings.newbook_api_username = creds.get("username") or settings.newbook_api_username + settings.newbook_api_password = creds.get("password") or settings.newbook_api_password + settings.newbook_api_key = creds.get("api_key") or settings.newbook_api_key + settings.newbook_api_region = creds.get("region") or settings.newbook_api_region + + if getattr(settings, "use_global_resos", False): + creds = await get_global_integration("resos") + if creds: + settings.resos_api_key = creds.get("api_key") or settings.resos_api_key + + if getattr(settings, "use_global_sambapos", False): + creds = await get_global_integration("sambapos") + if creds: + settings.sambapos_db_host = creds.get("host") or settings.sambapos_db_host + settings.sambapos_db_port = creds.get("port") or settings.sambapos_db_port + settings.sambapos_db_name = creds.get("database") or settings.sambapos_db_name + settings.sambapos_db_username = creds.get("username") or settings.sambapos_db_username + settings.sambapos_db_password = creds.get("password") or settings.sambapos_db_password diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index b0dbaf5..e7c35bc 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -41,6 +41,7 @@ interface SettingsData { llm_confidence_threshold: number | null llm_monthly_token_limit: number llm_features_enabled: Record | null + use_global_smtp: boolean } interface NewbookSettingsData { @@ -58,6 +59,7 @@ interface NewbookSettingsData { newbook_dinner_gl_codes: string | null newbook_breakfast_vat_rate: string | null newbook_dinner_vat_rate: string | null + use_global_newbook: boolean } interface ResosSettingsData { @@ -78,6 +80,7 @@ interface ResosSettingsData { resos_manual_breakfast_periods: Array<{day: number; start: string; end: string}> | null sambapos_food_gl_codes: string | null // Phase 8.1 sambapos_beverage_gl_codes: string | null // Phase 8.1 + use_global_resos: boolean } interface GLAccount { @@ -121,6 +124,7 @@ interface SambaPOSSettingsData { sambapos_db_password_set: boolean sambapos_tracked_categories: string[] sambapos_excluded_items: string[] + use_global_sambapos: boolean } interface SambaPOSCategory { @@ -171,6 +175,7 @@ interface NextcloudSettingsData { nextcloud_base_path: string | null nextcloud_enabled: boolean nextcloud_delete_local: boolean + use_global_nextcloud: boolean } interface NextcloudStatsData { @@ -426,6 +431,13 @@ export default function Settings() { const [budgetSaveMessage, setBudgetSaveMessage] = useState(null) // Nextcloud state + // Global settings flags — use central stack credentials + const [useGlobalSmtp, setUseGlobalSmtp] = useState(false) + const [useGlobalNextcloud, setUseGlobalNextcloud] = useState(false) + const [useGlobalNewbook, setUseGlobalNewbook] = useState(false) + const [useGlobalResos, setUseGlobalResos] = useState(false) + const [useGlobalSambapos, setUseGlobalSambapos] = useState(false) + const [nextcloudHost, setNextcloudHost] = useState('') const [nextcloudUsername, setNextcloudUsername] = useState('') const [nextcloudPassword, setNextcloudPassword] = useState('') @@ -774,6 +786,20 @@ export default function Settings() { enabled: !!token, }) + // Fetch which integrations are configured in the central stack settings service + const { data: globalStatus } = useQuery>({ + queryKey: ['global-integration-status'], + queryFn: async () => { + const res = await fetch('/kitchen/api/settings/global-status', { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) return {} + return res.json() + }, + enabled: !!token, + staleTime: 60000, + }) + // Fetch Nextcloud archive stats const { data: nextcloudStats } = useQuery({ queryKey: ['nextcloud-stats'], @@ -906,6 +932,8 @@ export default function Settings() { setOcrUseWeightAsQuantity(settings.ocr_use_weight_as_quantity || false) // Cost distribution setCostDistMaxDays(settings.cost_distribution_max_days ?? 90) + // Global flags + setUseGlobalSmtp(settings.use_global_smtp ?? false) // LLM settings — LLM FEATURE setLlmEnabled(settings.llm_enabled ?? false) setLlmModel(settings.llm_model || 'claude-haiku-4-5-20251001') @@ -923,6 +951,7 @@ export default function Settings() { setNextcloudBasePath(nextcloudSettings.nextcloud_base_path || '/Kitchen Invoices') setNextcloudEnabled(nextcloudSettings.nextcloud_enabled) setNextcloudDeleteLocal(nextcloudSettings.nextcloud_delete_local) + setUseGlobalNextcloud(nextcloudSettings.use_global_nextcloud ?? false) } }, [nextcloudSettings]) @@ -974,6 +1003,7 @@ export default function Settings() { // Convert decimal rate to percentage for display (e.g., 0.10 -> 10) setNewbookBreakfastVatRate(newbookSettings.newbook_breakfast_vat_rate ? String(parseFloat(newbookSettings.newbook_breakfast_vat_rate) * 100) : '10') setNewbookDinnerVatRate(newbookSettings.newbook_dinner_vat_rate ? String(parseFloat(newbookSettings.newbook_dinner_vat_rate) * 100) : '10') + setUseGlobalNewbook(newbookSettings.use_global_newbook ?? false) } }, [newbookSettings]) @@ -987,6 +1017,7 @@ export default function Settings() { // Use array to preserve order setSambaSelectedCourses(sambaSettings.sambapos_tracked_categories || []) setSambaExcludedItems(new Set(sambaSettings.sambapos_excluded_items || [])) + setUseGlobalSambapos(sambaSettings.use_global_sambapos ?? false) } }, [sambaSettings]) @@ -1048,6 +1079,7 @@ export default function Settings() { // Note: sync settings (auto_sync_enabled, upcoming_sync_enabled, upcoming_sync_interval) // are now read directly from resosSettings, not local state setResosLargeGroupThreshold(resosSettings.resos_large_group_threshold || 8) + setUseGlobalResos(resosSettings.use_global_resos ?? false) setResosNoteKeywords(resosSettings.resos_note_keywords || '') setResosAllergyKeywords(resosSettings.resos_allergy_keywords || '') setCustomFieldMapping(resosSettings.resos_custom_field_mapping || {}) @@ -2510,6 +2542,7 @@ export default function Settings() { ocr_filter_subtotal_rows: ocrFilterSubtotalRows, ocr_use_weight_as_quantity: ocrUseWeightAsQuantity, cost_distribution_max_days: costDistMaxDays, + use_global_smtp: useGlobalSmtp, } if (azureKey) { data.azure_key = azureKey @@ -2522,6 +2555,7 @@ export default function Settings() { newbook_api_username: newbookUsername, newbook_api_region: newbookRegion, newbook_instance_id: newbookInstanceId, + use_global_newbook: useGlobalNewbook, } if (newbookPassword) data.newbook_api_password = newbookPassword if (newbookApiKey) data.newbook_api_key = newbookApiKey @@ -2535,6 +2569,7 @@ export default function Settings() { sambapos_db_port: parseInt(sambaDbPort) || 1433, sambapos_db_name: sambaDbName, sambapos_db_username: sambaDbUsername, + use_global_sambapos: useGlobalSambapos, } if (sambaDbPassword) { data.sambapos_db_password = sambaDbPassword @@ -3250,6 +3285,9 @@ export default function Settings() { {settings?.azure_key_set && !azureKey && Key is configured} +

+ Azure Document Intelligence uses app-specific credentials (not shared with the main stack). +

{/* OCR Post-Processing Block */} @@ -3314,6 +3352,22 @@ export default function Settings() { {/* SMTP Settings Block */}

SMTP Settings

+ + {/* Global settings toggle */} + +
@@ -4002,6 +4061,22 @@ export default function Settings() { {/* API Credentials Block */}

API Credentials

+ + {/* Global settings toggle */} + +