Settings.jsx
1 import { useState, useEffect, useRef } from "react"; 2 import { 3 Grid, styled, Box, Card, Divider, TextField, Button, 4 Switch, FormControlLabel, FormHelperText, Typography, Select, MenuItem, InputLabel, FormControl, 5 Collapse, IconButton 6 } from "@mui/material"; 7 import useAuth from "app/hooks/useAuth"; 8 import Breadcrumb from "app/components/Breadcrumb"; 9 import { useTranslation } from "react-i18next"; 10 import { toast } from "react-toastify"; 11 import { usePlatformCapabilities } from "app/contexts/PlatformContext"; 12 import api from "app/utils/api"; 13 import { Settings as SettingsIcon, Storage, Security, ExpandMore, ExpandLess } from "@mui/icons-material"; 14 import { H4 } from "app/components/Typography"; 15 import ProjectTabNav from "app/views/projects/components/ProjectTabNav"; 16 17 const Container = styled("div")(({ theme }) => ({ 18 margin: 10, 19 [theme.breakpoints.down("sm")]: { margin: 16 }, 20 "& .breadcrumb": { marginBottom: 30, [theme.breakpoints.down("sm")]: { marginBottom: 16 } } 21 })); 22 23 const FlexBox = styled(Box)({ 24 display: "flex", 25 alignItems: "center" 26 }); 27 28 export default function SettingsPage() { 29 const { t } = useTranslation(); 30 const auth = useAuth(); 31 const { refreshCapabilities } = usePlatformCapabilities(); 32 const [active, setActive] = useState("general"); 33 34 const TABS = [ 35 { key: "general", name: t("settings.sections.general"), Icon: SettingsIcon }, 36 { key: "authentication", name: t("settings.sections.authentication"), Icon: Security }, 37 ]; 38 39 const [form, setForm] = useState({ 40 app_name: "RESTai", 41 hide_branding: false, 42 proxy_enabled: false, 43 proxy_url: "", 44 proxy_key: "", 45 proxy_team_id: "", 46 max_audio_upload_size: 10, 47 data_retention_days: 0, 48 currency: "EUR", 49 redis_host: "", 50 redis_port: "6379", 51 redis_password: "", 52 redis_database: "0", 53 auth_disable_local: false, 54 sso_auto_create_user: false, 55 sso_allowed_domains: "*", 56 sso_google_client_id: "", 57 sso_google_client_secret: "", 58 sso_google_redirect_uri: "", 59 sso_google_scope: "openid email profile", 60 sso_microsoft_client_id: "", 61 sso_microsoft_client_secret: "", 62 sso_microsoft_tenant_id: "", 63 sso_microsoft_redirect_uri: "", 64 sso_microsoft_scope: "openid email profile", 65 sso_github_client_id: "", 66 sso_github_client_secret: "", 67 sso_github_redirect_uri: "", 68 sso_github_scope: "user:email", 69 sso_oidc_client_id: "", 70 sso_oidc_client_secret: "", 71 sso_oidc_provider_url: "", 72 sso_oidc_redirect_uri: "", 73 sso_oidc_scopes: "openid email profile", 74 sso_oidc_provider_name: "SSO", 75 sso_auto_restricted: true, 76 sso_auto_team_id: "", 77 sso_oidc_email_claim: "email", 78 mcp_enabled: false, 79 docker_enabled: false, 80 docker_url: "", 81 docker_image: "python:3.12-slim", 82 docker_timeout: 900, 83 docker_network: "none", 84 docker_read_only: true, 85 browser_enabled: false, 86 browser_image: "mcr.microsoft.com/playwright/python:v1.48.0-jammy", 87 browser_network: "bridge", 88 browser_timeout: 900, 89 system_llm: "", 90 enforce_2fa: false, 91 }); 92 const [teams, setTeams] = useState([]); 93 const [llms, setLlms] = useState([]); 94 const [saving, setSaving] = useState(false); 95 const [dockerTest, setDockerTest] = useState(null); // null | "testing" | {status, detail} 96 const [telemetryEnabled, setTelemetryEnabled] = useState(null); 97 const [expanded, setExpanded] = useState({ google: false, microsoft: false, github: false, oidc: false }); 98 99 // Refs for deep-linkable sections. Hash like `#microsoft` auto-opens 100 // the Authentication tab, expands the Microsoft card, and scrolls to 101 // it. Useful for copy-pasting "go to my SSO setup" links in Slack / 102 // support tickets. 103 const sectionRefs = { 104 google: useRef(null), 105 microsoft: useRef(null), 106 github: useRef(null), 107 oidc: useRef(null), 108 }; 109 110 // Map each deep-linkable hash to the tab it lives on. Extend this 111 // when you add a new section that deserves a bookmark. 112 const _SECTION_TAB = { 113 google: "authentication", 114 microsoft: "authentication", 115 github: "authentication", 116 oidc: "authentication", 117 }; 118 119 const toggleExpanded = (section) => () => { 120 setExpanded((prev) => ({ ...prev, [section]: !prev[section] })); 121 // Reflect the current section in the URL so the user can bookmark / 122 // share it. `replaceState` instead of `pushState` so expand/collapse 123 // clicks don't pollute history. 124 if (typeof window !== "undefined") { 125 const targetHash = expanded[section] ? "" : `#${section}`; 126 window.history.replaceState(null, "", window.location.pathname + window.location.search + targetHash); 127 } 128 }; 129 130 // Drive the tab + section state from `window.location.hash`. Runs on 131 // mount and on hashchange so users who paste a deep-link mid-session 132 // (or hit the browser back button) land on the right section. 133 useEffect(() => { 134 const applyHash = () => { 135 const hash = (window.location.hash || "").replace(/^#/, ""); 136 if (!hash) return; 137 const targetTab = _SECTION_TAB[hash]; 138 if (targetTab) setActive(targetTab); 139 if (hash in sectionRefs) { 140 setExpanded((prev) => ({ ...prev, [hash]: true })); 141 // Give React a beat to render the expanded card before scrolling. 142 setTimeout(() => { 143 sectionRefs[hash].current?.scrollIntoView({ behavior: "smooth", block: "start" }); 144 }, 80); 145 } 146 }; 147 applyHash(); 148 window.addEventListener("hashchange", applyHash); 149 return () => window.removeEventListener("hashchange", applyHash); 150 // eslint-disable-next-line react-hooks/exhaustive-deps 151 }, []); 152 153 const fetchSettings = () => { 154 api.get("/settings", auth.user.token) 155 .then((data) => setForm(data)) 156 .catch(() => {}); 157 }; 158 159 useEffect(() => { 160 document.title = "RESTai - Settings"; 161 fetchSettings(); 162 api.get("/teams", auth.user.token).then((d) => setTeams(d.teams || [])).catch(() => {}); 163 api.get("/llms", auth.user.token).then((d) => setLlms(Array.isArray(d) ? d : (d?.llms || []))).catch(() => {}); 164 api.get("/version", auth.user.token, { silent: true }).then((d) => { if (d) setTelemetryEnabled(d.telemetry); }).catch(() => {}); 165 }, []); 166 167 const handleChange = (field) => (e) => { 168 const value = e.target.type === "checkbox" ? e.target.checked : e.target.value; 169 setForm((prev) => ({ ...prev, [field]: value })); 170 }; 171 172 const handleSave = () => { 173 setSaving(true); 174 const body = { ...form }; 175 body.max_audio_upload_size = parseInt(body.max_audio_upload_size, 10) || 10; 176 body.data_retention_days = parseInt(body.data_retention_days, 10) || 0; 177 body.docker_timeout = parseInt(body.docker_timeout, 10) || 900; 178 179 api.patch("/settings", body, auth.user.token) 180 .then((data) => { 181 setForm(data); 182 toast.success(t("settings.saved")); 183 refreshCapabilities(); 184 }) 185 .catch(() => {}) 186 .finally(() => setSaving(false)); 187 }; 188 189 const CollapsibleCardHeader = ({ icon: Icon, title, section }) => ( 190 <FlexBox sx={{ cursor: "pointer" }} onClick={toggleExpanded(section)}> 191 <Icon sx={{ ml: 2 }} /> 192 <H4 sx={{ p: 2, flex: 1 }}>{title}</H4> 193 <IconButton size="small" sx={{ mr: 2 }}> 194 {expanded[section] ? <ExpandLess /> : <ExpandMore />} 195 </IconButton> 196 </FlexBox> 197 ); 198 199 return ( 200 <Container> 201 <Box className="breadcrumb"> 202 <Breadcrumb routeSegments={[{ name: t("settings.title"), path: "/settings" }]} /> 203 </Box> 204 205 <Grid container spacing={3}> 206 <Grid item md={2} xs={12}> 207 <ProjectTabNav tabs={TABS} active={active} setActive={setActive} /> 208 </Grid> 209 210 <Grid item md={10} xs={12}> 211 {/* ===== GENERAL TAB ===== */} 212 {active === "general" && ( 213 <Grid container spacing={3}> 214 {/* App */} 215 <Grid item xs={12}> 216 <Card elevation={1} sx={{ p: 3 }}> 217 <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>{t("settings.sections.platform")}</Typography> 218 <Grid container spacing={3}> 219 <Grid item xs={12} md={6}> 220 <TextField fullWidth label={t("settings.fields.appName")} value={form.app_name} onChange={handleChange("app_name")} /> 221 </Grid> 222 <Grid item xs={12} md={3}> 223 <FormControlLabel 224 control={<Switch checked={form.hide_branding} onChange={handleChange("hide_branding")} />} 225 label={t("settings.fields.hideBranding")} 226 /> 227 </Grid> 228 <Grid item xs={12} md={3}> 229 <FormControl fullWidth> 230 <InputLabel>{t("settings.fields.currency")}</InputLabel> 231 <Select value={form.currency} label={t("settings.fields.currency")} onChange={handleChange("currency")}> 232 <MenuItem value="USD">USD ($)</MenuItem> 233 <MenuItem value="EUR">EUR (€)</MenuItem> 234 </Select> 235 </FormControl> 236 </Grid> 237 <Grid item xs={12} md={6}> 238 <FormControl fullWidth> 239 <InputLabel>{t("settings.fields.systemLlm")}</InputLabel> 240 <Select 241 value={form.system_llm ?? ""} 242 label={t("settings.fields.systemLlm")} 243 onChange={handleChange("system_llm")} 244 > 245 <MenuItem value=""><em>{t("common.none")}</em></MenuItem> 246 {llms.map((l) => ( 247 <MenuItem key={l.id || l.name} value={l.name}>{l.name}</MenuItem> 248 ))} 249 </Select> 250 </FormControl> 251 <Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}> 252 {t("settings.helpers.systemLlm")} 253 </Typography> 254 </Grid> 255 </Grid> 256 </Card> 257 </Grid> 258 259 {/* LLM Proxy */} 260 <Grid item xs={12}> 261 <Card elevation={1} sx={{ p: 3 }}> 262 <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>{t("settings.sections.proxy")}</Typography> 263 <Grid container spacing={3}> 264 <Grid item xs={12}> 265 <FormControlLabel 266 control={<Switch checked={form.proxy_enabled} onChange={handleChange("proxy_enabled")} />} 267 label={t("settings.fields.enableProxy")} 268 /> 269 </Grid> 270 {form.proxy_enabled && ( 271 <> 272 <Grid item xs={12} md={4}> 273 <TextField fullWidth label={t("settings.fields.proxyUrl")} value={form.proxy_url} onChange={handleChange("proxy_url")} /> 274 </Grid> 275 <Grid item xs={12} md={4}> 276 <TextField fullWidth label={t("settings.fields.proxyKey")} type="password" value={form.proxy_key} onChange={handleChange("proxy_key")} /> 277 </Grid> 278 <Grid item xs={12} md={4}> 279 <TextField fullWidth label={t("settings.fields.proxyTeamId")} value={form.proxy_team_id} onChange={handleChange("proxy_team_id")} /> 280 </Grid> 281 </> 282 )} 283 </Grid> 284 </Card> 285 </Grid> 286 287 {/* Limits */} 288 <Grid item xs={12}> 289 <Card elevation={1} sx={{ p: 3 }}> 290 <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>{t("settings.sections.limits")}</Typography> 291 <Grid container spacing={3}> 292 <Grid item xs={12} md={6}> 293 <TextField fullWidth label={t("settings.fields.maxAudioUploadSize")} type="number" inputProps={{ min: 1 }} 294 value={form.max_audio_upload_size} onChange={handleChange("max_audio_upload_size")} /> 295 </Grid> 296 <Grid item xs={12} md={6}> 297 <TextField fullWidth label={t("settings.fields.dataRetentionDays")} type="number" inputProps={{ min: 0 }} 298 value={form.data_retention_days} onChange={handleChange("data_retention_days")} 299 helperText={t("settings.helpers.retention")} /> 300 </Grid> 301 </Grid> 302 </Card> 303 </Grid> 304 305 {/* Redis */} 306 <Grid item xs={12}> 307 <Card elevation={1} sx={{ p: 3 }}> 308 <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>{t("settings.sections.redis")}</Typography> 309 <Grid container spacing={3}> 310 <Grid item xs={12} md={6}> 311 <TextField fullWidth label={t("settings.fields.redisHost")} placeholder={t("settings.fields.redisHostPlaceholder")} 312 value={form.redis_host} onChange={handleChange("redis_host")} /> 313 </Grid> 314 <Grid item xs={12} md={2}> 315 <TextField fullWidth label={t("settings.fields.redisPort")} value={form.redis_port} onChange={handleChange("redis_port")} /> 316 </Grid> 317 <Grid item xs={12} md={2}> 318 <TextField fullWidth label={t("settings.fields.redisPassword")} type="password" value={form.redis_password} onChange={handleChange("redis_password")} /> 319 </Grid> 320 <Grid item xs={12} md={2}> 321 <TextField fullWidth label={t("settings.fields.redisDatabase")} value={form.redis_database} onChange={handleChange("redis_database")} /> 322 </Grid> 323 </Grid> 324 </Card> 325 </Grid> 326 327 {/* MCP */} 328 <Grid item xs={12}> 329 <Card elevation={1} sx={{ p: 3 }}> 330 <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>{t("settings.sections.mcp")}</Typography> 331 <FormControlLabel 332 control={<Switch checked={form.mcp_enabled} onChange={handleChange("mcp_enabled")} />} 333 label={t("settings.fields.enableMcp")} 334 /> 335 <Typography variant="caption" color="text.secondary" display="block"> 336 Expose projects as MCP tools at /mcp/sse. Requires server restart. 337 </Typography> 338 </Card> 339 </Grid> 340 341 {/* Docker */} 342 <Grid item xs={12}> 343 <Card elevation={1} sx={{ p: 3 }}> 344 <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>{t("settings.sections.docker")}</Typography> 345 <FormControlLabel 346 control={<Switch checked={form.docker_enabled} onChange={handleChange("docker_enabled")} />} 347 label={t("settings.fields.enableDocker")} 348 /> 349 <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 2 }}> 350 Each chat session gets its own isolated container that persists across commands and is automatically removed after the idle timeout. 351 </Typography> 352 {form.docker_enabled && ( 353 <Grid container spacing={3}> 354 <Grid item xs={12} md={6}> 355 <TextField fullWidth label={t("settings.fields.dockerUrl")} 356 value={form.docker_url || ""} 357 onChange={handleChange("docker_url")} 358 placeholder={t("settings.fields.dockerUrlPlaceholder")} 359 /> 360 </Grid> 361 <Grid item xs={12} md={6}> 362 <TextField fullWidth label={t("settings.fields.containerImage")} 363 value={form.docker_image ?? "python:3.12-slim"} 364 onChange={handleChange("docker_image")} 365 helperText={t("settings.helpers.containerImage")} 366 /> 367 </Grid> 368 <Grid item xs={12} md={6}> 369 <TextField fullWidth label={t("settings.fields.idleTimeout")} type="number" inputProps={{ min: 60 }} 370 value={form.docker_timeout || 900} 371 onChange={handleChange("docker_timeout")} 372 helperText={t("settings.helpers.dockerTimeout")} 373 /> 374 </Grid> 375 <Grid item xs={12} md={6}> 376 <TextField fullWidth label={t("settings.fields.networkMode")} 377 value={form.docker_network ?? "none"} 378 onChange={handleChange("docker_network")} 379 helperText={'"none" for no network access, "bridge" for internet access, or a custom network name'} 380 /> 381 </Grid> 382 <Grid item xs={12}> 383 <FormControlLabel 384 control={<Switch checked={!!form.docker_read_only} onChange={handleChange("docker_read_only")} />} 385 label={t("settings.fields.readOnlyRootfs")} 386 /> 387 <FormHelperText sx={{ mt: -0.5, ml: 4 }}> 388 Safer sandbox. Disable to let the LLM run <code>pip install</code> and write to the container filesystem. 389 </FormHelperText> 390 </Grid> 391 <Grid item xs={12}> 392 <Button 393 variant="outlined" 394 size="small" 395 disabled={!form.docker_url || dockerTest === "testing"} 396 onClick={() => { 397 setDockerTest("testing"); 398 api.post("/settings/docker/test", {}, auth.user.token) 399 .then((d) => setDockerTest({ status: "ok", detail: `Connected (Docker ${d.server_version})` })) 400 .catch((e) => setDockerTest({ status: "error", detail: e?.detail || "Connection failed" })); 401 }} 402 > 403 {dockerTest === "testing" ? t("settings.fields.testing") : t("settings.fields.testDocker")} 404 </Button> 405 {dockerTest && dockerTest !== "testing" && ( 406 <Typography 407 variant="caption" 408 sx={{ ml: 2, color: dockerTest.status === "ok" ? "success.main" : "error.main" }} 409 > 410 {dockerTest.detail} 411 </Typography> 412 )} 413 </Grid> 414 </Grid> 415 )} 416 </Card> 417 </Grid> 418 419 {/* Agentic Browser — Playwright + Chromium per-chat container */} 420 <Grid item xs={12}> 421 <Card elevation={1} sx={{ p: 3 }}> 422 <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>{t("settings.sections.browser")}</Typography> 423 <Grid container spacing={3}> 424 <Grid item xs={12}> 425 <FormControlLabel 426 control={<Switch checked={!!form.browser_enabled} onChange={handleChange("browser_enabled")} />} 427 label={t("settings.fields.enableBrowser")} 428 /> 429 <FormHelperText sx={{ mt: -0.5, ml: 4 }}> 430 Powers the <code>browser_*</code> builtin tools (goto, fill, click, screenshot, etc.). 431 Reuses the Docker daemon configured above. Image is pulled on first use. 432 </FormHelperText> 433 </Grid> 434 {form.browser_enabled && ( 435 <> 436 <Grid item xs={12} md={6}> 437 <TextField 438 fullWidth 439 label={t("settings.fields.browserImage")} 440 value={form.browser_image ?? "mcr.microsoft.com/playwright/python:v1.48.0-jammy"} 441 onChange={handleChange("browser_image")} 442 helperText={t("settings.helpers.browserImage")} 443 /> 444 </Grid> 445 <Grid item xs={12} md={3}> 446 <TextField 447 fullWidth 448 label={t("settings.fields.browserNetwork")} 449 value={form.browser_network ?? "bridge"} 450 onChange={handleChange("browser_network")} 451 helperText={"'bridge' for outbound (required — Chromium fetches remote sites). 'none' = offline only."} 452 /> 453 </Grid> 454 <Grid item xs={12} md={3}> 455 <TextField 456 fullWidth 457 type="number" 458 label={t("settings.fields.browserTimeout")} 459 inputProps={{ min: 60 }} 460 value={form.browser_timeout ?? 900} 461 onChange={handleChange("browser_timeout")} 462 helperText={t("settings.helpers.browserTimeout")} 463 /> 464 </Grid> 465 </> 466 )} 467 </Grid> 468 </Card> 469 </Grid> 470 </Grid> 471 )} 472 473 {/* ===== AUTHENTICATION TAB ===== */} 474 {active === "authentication" && ( 475 <Grid container spacing={3}> 476 {/* Core auth settings */} 477 <Grid item xs={12}> 478 <Card elevation={1} sx={{ p: 3 }}> 479 <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>{t("settings.sections.localAuth")}</Typography> 480 <Grid container spacing={3}> 481 <Grid item xs={12} md={4}> 482 <FormControlLabel 483 control={<Switch checked={form.auth_disable_local} onChange={handleChange("auth_disable_local")} />} 484 label={t("settings.fields.disableLocalAuth")} 485 /> 486 <Typography variant="caption" color="text.secondary" display="block"> 487 When enabled, only SSO login is allowed 488 </Typography> 489 </Grid> 490 <Grid item xs={12} md={4}> 491 <FormControlLabel 492 control={<Switch checked={form.enforce_2fa} onChange={handleChange("enforce_2fa")} />} 493 label={t("settings.fields.enforce2fa")} 494 /> 495 <Typography variant="caption" color="text.secondary" display="block"> 496 Require TOTP two-factor authentication for all local users 497 </Typography> 498 </Grid> 499 </Grid> 500 </Card> 501 </Grid> 502 503 {/* SSO general */} 504 <Grid item xs={12}> 505 <Card elevation={1} sx={{ p: 3 }}> 506 <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>Single Sign-On</Typography> 507 <Grid container spacing={3}> 508 <Grid item xs={12} md={4}> 509 <FormControlLabel 510 control={<Switch checked={form.sso_auto_create_user} onChange={handleChange("sso_auto_create_user")} />} 511 label={t("settings.fields.autoCreateUsers")} 512 /> 513 <Typography variant="caption" color="text.secondary" display="block"> 514 Automatically create user accounts on first SSO login 515 </Typography> 516 </Grid> 517 <Grid item xs={12} md={4}> 518 <TextField fullWidth label={t("settings.fields.allowedDomains")} value={form.sso_allowed_domains} 519 onChange={handleChange("sso_allowed_domains")} helperText={t("settings.helpers.allowedDomains")} /> 520 </Grid> 521 <Grid item xs={12} md={4}> 522 <FormControlLabel 523 control={<Switch checked={form.sso_auto_restricted} onChange={handleChange("sso_auto_restricted")} />} 524 label={t("settings.fields.restrictNewUsers")} 525 /> 526 <Typography variant="caption" color="text.secondary" display="block"> 527 Auto-created SSO users will be in restricted (read-only) mode 528 </Typography> 529 </Grid> 530 <Grid item xs={12} md={4}> 531 <FormControl fullWidth> 532 <InputLabel>{t("settings.fields.defaultTeam")}</InputLabel> 533 <Select value={form.sso_auto_team_id || ""} onChange={handleChange("sso_auto_team_id")} label={t("settings.fields.defaultTeam")}> 534 <MenuItem value="">{t("common.none")}</MenuItem> 535 {teams.map((team) => ( 536 <MenuItem key={team.id} value={String(team.id)}>{team.name}</MenuItem> 537 ))} 538 </Select> 539 </FormControl> 540 <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}> 541 {t("settings.helpers.autoTeam")} 542 </Typography> 543 </Grid> 544 </Grid> 545 </Card> 546 </Grid> 547 548 {/* SSO — Google */} 549 <Grid item xs={12}> 550 <Card elevation={1} ref={sectionRefs.google}> 551 <CollapsibleCardHeader icon={Security} title="Google" section="google" /> 552 <Collapse in={expanded.google}> 553 <Divider /> 554 <Box sx={{ p: 3 }}> 555 <Grid container spacing={3}> 556 <Grid item xs={12} md={6}> 557 <TextField fullWidth label={t("settings.fields.clientId")} value={form.sso_google_client_id} onChange={handleChange("sso_google_client_id")} /> 558 </Grid> 559 <Grid item xs={12} md={6}> 560 <TextField fullWidth label={t("settings.fields.clientSecret")} type="password" value={form.sso_google_client_secret} onChange={handleChange("sso_google_client_secret")} /> 561 </Grid> 562 <Grid item xs={12} md={6}> 563 <TextField fullWidth label={t("settings.fields.redirectUri")} value={form.sso_google_redirect_uri} onChange={handleChange("sso_google_redirect_uri")} /> 564 </Grid> 565 <Grid item xs={12} md={6}> 566 <TextField fullWidth label={t("settings.fields.scope")} value={form.sso_google_scope} onChange={handleChange("sso_google_scope")} /> 567 </Grid> 568 </Grid> 569 </Box> 570 </Collapse> 571 </Card> 572 </Grid> 573 574 {/* SSO — Microsoft */} 575 <Grid item xs={12}> 576 <Card elevation={1} ref={sectionRefs.microsoft}> 577 <CollapsibleCardHeader icon={Security} title="Microsoft" section="microsoft" /> 578 <Collapse in={expanded.microsoft}> 579 <Divider /> 580 <Box sx={{ p: 3 }}> 581 <Grid container spacing={3}> 582 <Grid item xs={12} md={6}> 583 <TextField fullWidth label={t("settings.fields.clientId")} value={form.sso_microsoft_client_id} onChange={handleChange("sso_microsoft_client_id")} /> 584 </Grid> 585 <Grid item xs={12} md={6}> 586 <TextField fullWidth label={t("settings.fields.clientSecret")} type="password" value={form.sso_microsoft_client_secret} onChange={handleChange("sso_microsoft_client_secret")} /> 587 </Grid> 588 <Grid item xs={12} md={6}> 589 <TextField fullWidth label={t("settings.fields.tenantId")} value={form.sso_microsoft_tenant_id} onChange={handleChange("sso_microsoft_tenant_id")} /> 590 </Grid> 591 <Grid item xs={12} md={6}> 592 <TextField fullWidth label={t("settings.fields.redirectUri")} value={form.sso_microsoft_redirect_uri} onChange={handleChange("sso_microsoft_redirect_uri")} /> 593 </Grid> 594 <Grid item xs={12} md={6}> 595 <TextField fullWidth label={t("settings.fields.scope")} value={form.sso_microsoft_scope} onChange={handleChange("sso_microsoft_scope")} /> 596 </Grid> 597 </Grid> 598 </Box> 599 </Collapse> 600 </Card> 601 </Grid> 602 603 {/* SSO — GitHub */} 604 <Grid item xs={12}> 605 <Card elevation={1} ref={sectionRefs.github}> 606 <CollapsibleCardHeader icon={Security} title="GitHub" section="github" /> 607 <Collapse in={expanded.github}> 608 <Divider /> 609 <Box sx={{ p: 3 }}> 610 <Grid container spacing={3}> 611 <Grid item xs={12} md={6}> 612 <TextField fullWidth label={t("settings.fields.clientId")} value={form.sso_github_client_id} onChange={handleChange("sso_github_client_id")} /> 613 </Grid> 614 <Grid item xs={12} md={6}> 615 <TextField fullWidth label={t("settings.fields.clientSecret")} type="password" value={form.sso_github_client_secret} onChange={handleChange("sso_github_client_secret")} /> 616 </Grid> 617 <Grid item xs={12} md={6}> 618 <TextField fullWidth label={t("settings.fields.redirectUri")} value={form.sso_github_redirect_uri} onChange={handleChange("sso_github_redirect_uri")} /> 619 </Grid> 620 <Grid item xs={12} md={6}> 621 <TextField fullWidth label={t("settings.fields.scope")} value={form.sso_github_scope} onChange={handleChange("sso_github_scope")} /> 622 </Grid> 623 </Grid> 624 </Box> 625 </Collapse> 626 </Card> 627 </Grid> 628 629 {/* SSO — Generic OIDC */} 630 <Grid item xs={12}> 631 <Card elevation={1} ref={sectionRefs.oidc}> 632 <CollapsibleCardHeader icon={Security} title={t("settings.sections.oidc")} section="oidc" /> 633 <Collapse in={expanded.oidc}> 634 <Divider /> 635 <Box sx={{ p: 3 }}> 636 <Grid container spacing={3}> 637 <Grid item xs={12} md={6}> 638 <TextField fullWidth label={t("settings.fields.clientId")} value={form.sso_oidc_client_id} onChange={handleChange("sso_oidc_client_id")} /> 639 </Grid> 640 <Grid item xs={12} md={6}> 641 <TextField fullWidth label={t("settings.fields.clientSecret")} type="password" value={form.sso_oidc_client_secret} onChange={handleChange("sso_oidc_client_secret")} /> 642 </Grid> 643 <Grid item xs={12} md={6}> 644 <TextField fullWidth label={t("settings.fields.providerUrl")} value={form.sso_oidc_provider_url} 645 onChange={handleChange("sso_oidc_provider_url")} helperText={t("settings.helpers.oidcProviderUrl")} /> 646 </Grid> 647 <Grid item xs={12} md={6}> 648 <TextField fullWidth label={t("settings.fields.redirectUri")} value={form.sso_oidc_redirect_uri} onChange={handleChange("sso_oidc_redirect_uri")} /> 649 </Grid> 650 <Grid item xs={12} md={6}> 651 <TextField fullWidth label={t("settings.fields.scopes")} value={form.sso_oidc_scopes} onChange={handleChange("sso_oidc_scopes")} /> 652 </Grid> 653 <Grid item xs={12} md={6}> 654 <TextField fullWidth label={t("settings.fields.providerName")} value={form.sso_oidc_provider_name} 655 onChange={handleChange("sso_oidc_provider_name")} helperText={t("settings.helpers.oidcProviderName")} /> 656 </Grid> 657 <Grid item xs={12} md={6}> 658 <TextField fullWidth label={t("settings.fields.emailClaim")} value={form.sso_oidc_email_claim} onChange={handleChange("sso_oidc_email_claim")} /> 659 </Grid> 660 </Grid> 661 </Box> 662 </Collapse> 663 </Card> 664 </Grid> 665 </Grid> 666 )} 667 668 {/* Telemetry */} 669 <Grid item xs={12}> 670 <Card elevation={1} sx={{ p: 3 }}> 671 <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>{t("settings.sections.telemetry")}</Typography> 672 <Typography variant="body2" color="text.secondary"> 673 {t("settings.telemetry.description")} 674 </Typography> 675 <Typography variant="body2" sx={{ mt: 1 }}> 676 {t("settings.helpers.telemetryStatus", { status: telemetryEnabled === null ? "..." : telemetryEnabled ? t("settings.status.enabled") : t("settings.status.disabled") })} 677 </Typography> 678 <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}> 679 {t("settings.telemetry.optOut")} 680 </Typography> 681 </Card> 682 </Grid> 683 684 {/* Save button — always visible */} 685 <Box sx={{ display: "flex", justifyContent: "flex-end", mt: 3 }}> 686 <Button variant="contained" color="primary" onClick={handleSave} disabled={saving}> 687 {saving ? t("settings.helpers.saving") : t("settings.helpers.saveSettings")} 688 </Button> 689 </Box> 690 </Grid> 691 </Grid> 692 </Container> 693 ); 694 }