Wire Azure OCR to central settings toggle; remove redundant Users section

- settings/src/integrations/schema.js: rename azure from 'Azure AD' to
  'Azure Document Intelligence', swap fields to endpoint + api_key
- Add use_global_azure column (migration + model)
- global_settings_service: add azure to check_global_status and apply_global_overrides
- api/settings.py: expose use_global_azure in response/update; apply overrides
  in test_azure_connection before credential check
- Settings.tsx: add 'Use credentials from main stack settings' toggle for
  Azure OCR section (endpoint/key disabled when on, test button enabled when
  global is configured); remove Users section (managed centrally via auth
  service), clean up UserData interface, users query and mutations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 22:58:32 +00:00
parent d7898ba897
commit 1564a66283
5 changed files with 44 additions and 161 deletions

View file

@ -51,6 +51,7 @@ class SettingsResponse(BaseModel):
llm_features_enabled: dict | None = None llm_features_enabled: dict | None = None
# Global stack settings delegation # Global stack settings delegation
use_global_smtp: bool = False use_global_smtp: bool = False
use_global_azure: bool = False
class Config: class Config:
from_attributes = True from_attributes = True
@ -96,6 +97,7 @@ class SettingsUpdate(BaseModel):
llm_features_enabled: dict | None = None llm_features_enabled: dict | None = None
# Global stack settings delegation # Global stack settings delegation
use_global_smtp: bool | None = None use_global_smtp: bool | None = None
use_global_azure: bool | None = None
@router.get("/", response_model=SettingsResponse) @router.get("/", response_model=SettingsResponse)
@ -163,6 +165,7 @@ def _build_settings_response(settings: KitchenSettings) -> SettingsResponse:
llm_monthly_token_limit=settings.llm_monthly_token_limit, llm_monthly_token_limit=settings.llm_monthly_token_limit,
llm_features_enabled=settings.llm_features_enabled, llm_features_enabled=settings.llm_features_enabled,
use_global_smtp=settings.use_global_smtp, use_global_smtp=settings.use_global_smtp,
use_global_azure=settings.use_global_azure,
) )
@ -205,7 +208,13 @@ async def test_azure_connection(
) )
settings = result.scalar_one_or_none() settings = result.scalar_one_or_none()
if not settings or not settings.azure_endpoint or not settings.azure_key: if not settings:
raise HTTPException(status_code=400, detail="Azure credentials not configured")
from services.global_settings_service import apply_global_overrides
await apply_global_overrides(settings)
if not settings.azure_endpoint or not settings.azure_key:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Azure credentials not configured" detail="Azure credentials not configured"

View file

@ -12,6 +12,7 @@ async def migrate():
"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_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_resos BOOLEAN DEFAULT FALSE",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS use_global_sambapos BOOLEAN DEFAULT FALSE", "ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS use_global_sambapos BOOLEAN DEFAULT FALSE",
"ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS use_global_azure BOOLEAN DEFAULT FALSE",
]: ]:
try: try:
await conn.execute(text(col)) await conn.execute(text(col))

View file

@ -220,6 +220,7 @@ class KitchenSettings(Base):
use_global_newbook: 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_resos: Mapped[bool] = mapped_column(Boolean, default=False)
use_global_sambapos: Mapped[bool] = mapped_column(Boolean, default=False) use_global_sambapos: Mapped[bool] = mapped_column(Boolean, default=False)
use_global_azure: Mapped[bool] = mapped_column(Boolean, default=False)
# Internal API key (for in-house apps like menu display plugin) # Internal API key (for in-house apps like menu display plugin)
api_key: Mapped[str | None] = mapped_column(String(100), nullable=True) api_key: Mapped[str | None] = mapped_column(String(100), nullable=True)

View file

@ -43,6 +43,7 @@ async def check_global_status() -> dict[str, bool]:
"newbook": ["api_key"], "newbook": ["api_key"],
"resos": ["api_key"], "resos": ["api_key"],
"sambapos": ["password"], "sambapos": ["password"],
"azure": ["api_key"],
} }
results: dict[str, bool] = {} results: dict[str, bool] = {}
for slug, fields in required.items(): for slug, fields in required.items():
@ -95,3 +96,9 @@ async def apply_global_overrides(settings) -> None:
settings.sambapos_db_name = creds.get("database") or settings.sambapos_db_name 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_username = creds.get("username") or settings.sambapos_db_username
settings.sambapos_db_password = creds.get("password") or settings.sambapos_db_password settings.sambapos_db_password = creds.get("password") or settings.sambapos_db_password
if getattr(settings, "use_global_azure", False):
creds = await get_global_integration("azure")
if creds:
settings.azure_endpoint = creds.get("endpoint") or settings.azure_endpoint
settings.azure_key = creds.get("api_key") or settings.azure_key

View file

@ -42,6 +42,7 @@ interface SettingsData {
llm_monthly_token_limit: number llm_monthly_token_limit: number
llm_features_enabled: Record<string, boolean> | null llm_features_enabled: Record<string, boolean> | null
use_global_smtp: boolean use_global_smtp: boolean
use_global_azure: boolean
} }
interface NewbookSettingsData { interface NewbookSettingsData {
@ -105,16 +106,7 @@ interface RoomCategory {
display_order: number display_order: number
} }
interface UserData { type SettingsSection = 'account' | 'access' | 'display' | 'azure' | 'email' | 'inbox' | 'dext' | 'newbook' | 'resos' | 'sambapos' | 'kds' | 'budget' | 'kitchen' | 'suppliers' | 'search' | 'nextcloud' | 'backup' | 'food_flags' | 'allergen_keywords' | 'ingredient_categories' | 'recipe_sections' | 'dish_courses' | 'api_access' | 'llm' | 'data'
id: number
email: string
name: string | null
is_active: boolean
is_admin: boolean
created_at: string
}
type SettingsSection = 'account' | 'users' | 'access' | 'display' | 'azure' | 'email' | 'inbox' | 'dext' | 'newbook' | 'resos' | 'sambapos' | 'kds' | 'budget' | 'kitchen' | 'suppliers' | 'search' | 'nextcloud' | 'backup' | 'food_flags' | 'allergen_keywords' | 'ingredient_categories' | 'recipe_sections' | 'dish_courses' | 'api_access' | 'llm' | 'data'
interface SambaPOSSettingsData { interface SambaPOSSettingsData {
sambapos_db_host: string | null sambapos_db_host: string | null
@ -290,6 +282,7 @@ export default function Settings() {
const [azureEndpoint, setAzureEndpoint] = useState('') const [azureEndpoint, setAzureEndpoint] = useState('')
const [azureKey, setAzureKey] = useState('') const [azureKey, setAzureKey] = useState('')
const [azureTestStatus, setAzureTestStatus] = useState<string | null>(null) const [azureTestStatus, setAzureTestStatus] = useState<string | null>(null)
const [useGlobalAzure, setUseGlobalAzure] = useState(false)
// OCR post-processing options // OCR post-processing options
const [ocrCleanProductCodes, setOcrCleanProductCodes] = useState(false) const [ocrCleanProductCodes, setOcrCleanProductCodes] = useState(false)
const [ocrFilterSubtotalRows, setOcrFilterSubtotalRows] = useState(false) const [ocrFilterSubtotalRows, setOcrFilterSubtotalRows] = useState(false)
@ -611,21 +604,6 @@ export default function Settings() {
}, },
}) })
// Fetch users (admin only)
const { data: users } = useQuery<UserData[]>({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('/auth/users', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) {
if (res.status === 403) return []
throw new Error('Failed to fetch users')
}
return res.json()
},
enabled: !!user?.is_admin,
})
// Fetch SambaPOS settings // Fetch SambaPOS settings
const { data: sambaSettings } = useQuery<SambaPOSSettingsData>({ const { data: sambaSettings } = useQuery<SambaPOSSettingsData>({
@ -905,6 +883,7 @@ export default function Settings() {
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
setAzureEndpoint(settings.azure_endpoint || '') setAzureEndpoint(settings.azure_endpoint || '')
setUseGlobalAzure(settings.use_global_azure)
setCurrencySymbol(settings.currency_symbol) setCurrencySymbol(settings.currency_symbol)
setDateFormat(settings.date_format) setDateFormat(settings.date_format)
setHighQuantityThreshold(settings.high_quantity_threshold) setHighQuantityThreshold(settings.high_quantity_threshold)
@ -1662,57 +1641,6 @@ export default function Settings() {
}, },
}) })
const toggleUserMutation = useMutation({
mutationFn: async (userId: number) => {
const res = await fetch(`/auth/users/${userId}/toggle-active`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Failed to toggle user')
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
const deleteUserMutation = useMutation({
mutationFn: async (userId: number) => {
const res = await fetch(`/auth/users/${userId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Failed to delete user')
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
const toggleAdminMutation = useMutation({
mutationFn: async (userId: number) => {
const res = await fetch(`/auth/users/${userId}/toggle-admin`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Failed to toggle admin status')
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
const savePageRestrictionsMutation = useMutation({ const savePageRestrictionsMutation = useMutation({
mutationFn: async (pages: string[]) => { mutationFn: async (pages: string[]) => {
const res = await fetch('/kitchen/api/settings/page-restrictions', { const res = await fetch('/kitchen/api/settings/page-restrictions', {
@ -2543,6 +2471,7 @@ export default function Settings() {
ocr_use_weight_as_quantity: ocrUseWeightAsQuantity, ocr_use_weight_as_quantity: ocrUseWeightAsQuantity,
cost_distribution_max_days: costDistMaxDays, cost_distribution_max_days: costDistMaxDays,
use_global_smtp: useGlobalSmtp, use_global_smtp: useGlobalSmtp,
use_global_azure: useGlobalAzure,
} }
if (azureKey) { if (azureKey) {
data.azure_key = azureKey data.azure_key = azureKey
@ -2758,7 +2687,6 @@ export default function Settings() {
const sidebarItems: { id: SettingsSection; label: string; adminOnly?: boolean; href?: string; restrictPath?: string }[] = [ const sidebarItems: { id: SettingsSection; label: string; adminOnly?: boolean; href?: string; restrictPath?: string }[] = [
{ id: 'account', label: 'Account' }, { id: 'account', label: 'Account' },
{ id: 'users', label: 'Users', adminOnly: true, restrictPath: '/settings-users' },
{ id: 'access', label: 'Access Control', adminOnly: true, restrictPath: '/settings-access' }, { id: 'access', label: 'Access Control', adminOnly: true, restrictPath: '/settings-access' },
{ id: 'display', label: 'Display', restrictPath: '/settings-display' }, { id: 'display', label: 'Display', restrictPath: '/settings-display' },
{ id: 'azure', label: 'Azure OCR', restrictPath: '/settings-azure' }, { id: 'azure', label: 'Azure OCR', restrictPath: '/settings-azure' },
@ -2933,82 +2861,6 @@ export default function Settings() {
</div> </div>
)} )}
{/* Users Section (Admin Only) */}
{activeSection === 'users' && user?.is_admin && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>User Management</h2>
<p style={styles.hint}>Manage users who have access to this kitchen.</p>
{/* Users List Block */}
<div style={styles.settingsBlock}>
<h3 style={styles.blockTitle}>Users</h3>
{users && users.length > 0 && (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Email</th>
<th style={styles.th}>Name</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Role</th>
<th style={styles.th}>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} style={!u.is_active ? styles.disabledRow : undefined}>
<td style={styles.td}>{u.email}</td>
<td style={styles.td}>{u.name || '-'}</td>
<td style={styles.td}>
<span style={u.is_active ? styles.activeStatus : styles.inactiveStatus}>
{u.is_active ? 'Active' : 'Disabled'}
</span>
</td>
<td style={styles.td}>{u.is_admin ? 'Admin' : 'User'}</td>
<td style={styles.td}>
{u.id !== user.id ? (
<div style={styles.actionButtons}>
<button
onClick={() => toggleUserMutation.mutate(u.id)}
style={u.is_active ? styles.disableBtn : styles.enableBtn}
>
{u.is_active ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => {
if (confirm(`${u.is_admin ? 'Remove admin rights from' : 'Make admin'} ${u.email}?`)) {
toggleAdminMutation.mutate(u.id)
}
}}
style={u.is_admin ? styles.demoteBtn : styles.promoteBtn}
>
{u.is_admin ? 'Demote' : 'Make Admin'}
</button>
{!u.is_admin && (
<button
onClick={() => {
if (confirm(`Delete user ${u.email}?`)) {
deleteUserMutation.mutate(u.id)
}
}}
style={styles.deleteBtn}
>
Delete
</button>
)}
</div>
) : (
<span style={styles.youLabel}>(You)</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
{/* Access Control Section (Admin Only) */} {/* Access Control Section (Admin Only) */}
{activeSection === 'access' && user?.is_admin && ( {activeSection === 'access' && user?.is_admin && (
<div style={styles.section}> <div style={styles.section}>
@ -3110,7 +2962,6 @@ export default function Settings() {
<div style={styles.checkboxGroup}> <div style={styles.checkboxGroup}>
{[ {[
{ path: '/settings', label: 'Settings Page (entire page)' }, { path: '/settings', label: 'Settings Page (entire page)' },
{ path: '/settings-users', label: 'Users Management' },
{ path: '/settings-access', label: 'Access Control' }, { path: '/settings-access', label: 'Access Control' },
{ path: '/settings-display', label: 'Display Settings' }, { path: '/settings-display', label: 'Display Settings' },
{ path: '/settings-azure', label: 'Azure OCR' }, { path: '/settings-azure', label: 'Azure OCR' },
@ -3262,6 +3113,21 @@ export default function Settings() {
{/* API Configuration Block */} {/* API Configuration Block */}
<div style={styles.settingsBlock}> <div style={styles.settingsBlock}>
<h3 style={styles.blockTitle}>API Configuration</h3> <h3 style={styles.blockTitle}>API Configuration</h3>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={useGlobalAzure}
onChange={(e) => setUseGlobalAzure(e.target.checked)}
/>
<span style={{ fontWeight: 500 }}>Use credentials from main stack settings</span>
{useGlobalAzure && (
<span style={{ fontSize: '0.8rem', padding: '0.15rem 0.5rem', borderRadius: '10px',
background: globalStatus?.azure ? '#d4edda' : '#fff3cd',
color: globalStatus?.azure ? '#155724' : '#856404' }}>
{globalStatus?.azure ? 'Configured' : 'Not configured in stack settings'}
</span>
)}
</label>
<div style={styles.form}> <div style={styles.form}>
<label style={styles.label}> <label style={styles.label}>
Endpoint URL Endpoint URL
@ -3269,7 +3135,8 @@ export default function Settings() {
type="text" type="text"
value={azureEndpoint} value={azureEndpoint}
onChange={(e) => setAzureEndpoint(e.target.value)} onChange={(e) => setAzureEndpoint(e.target.value)}
style={styles.input} disabled={useGlobalAzure}
style={{ ...styles.input, ...(useGlobalAzure ? { opacity: 0.4, pointerEvents: 'none' as const } : {}) }}
placeholder="https://your-resource.cognitiveservices.azure.com/" placeholder="https://your-resource.cognitiveservices.azure.com/"
/> />
</label> </label>
@ -3279,15 +3146,13 @@ export default function Settings() {
type="password" type="password"
value={azureKey} value={azureKey}
onChange={(e) => setAzureKey(e.target.value)} onChange={(e) => setAzureKey(e.target.value)}
style={styles.input} disabled={useGlobalAzure}
style={{ ...styles.input, ...(useGlobalAzure ? { opacity: 0.4, pointerEvents: 'none' as const } : {}) }}
placeholder={settings?.azure_key_set ? '••••••••••••••••' : 'Enter your API key'} placeholder={settings?.azure_key_set ? '••••••••••••••••' : 'Enter your API key'}
/> />
{settings?.azure_key_set && !azureKey && <span style={styles.keyStatus}>Key is configured</span>} {settings?.azure_key_set && !azureKey && <span style={styles.keyStatus}>Key is configured</span>}
</label> </label>
</div> </div>
<p style={{ ...styles.hint, marginTop: '0.75rem' }}>
Azure Document Intelligence uses app-specific credentials (not shared with the main stack).
</p>
</div> </div>
{/* OCR Post-Processing Block */} {/* OCR Post-Processing Block */}
@ -3328,7 +3193,7 @@ export default function Settings() {
{/* Actions - outside blocks */} {/* Actions - outside blocks */}
<div style={styles.buttonRow}> <div style={styles.buttonRow}>
<button onClick={() => azureTestMutation.mutate()} style={styles.testBtn} disabled={!settings?.azure_key_set}> <button onClick={() => azureTestMutation.mutate()} style={styles.testBtn} disabled={!settings?.azure_key_set && !(useGlobalAzure && globalStatus?.azure)}>
Test Connection Test Connection
</button> </button>
<button onClick={handleSaveSettings} style={styles.saveBtn} disabled={updateMutation.isPending}> <button onClick={handleSaveSettings} style={styles.saveBtn} disabled={updateMutation.isPending}>