Compare commits

...

6 Commits

Author SHA1 Message Date
HeyItsChloe
d21b04affe FE: Restore flag to block traffic to /onboarding (#14166) 2026-04-27 20:37:45 -04:00
Hiep Le
fc0b69dbf8 fix: enforce onboarding completion on every navigation (#14142)
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-04-27 20:37:40 -04:00
Graham Neubig
82492f1769 fix: normalize legacy MCP config in migration 108 (#14116)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-24 13:12:16 -04:00
Graham Neubig
50907e1500 fix: preserve LLM and MCP settings in migration 108 (#14112)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-24 10:40:20 -04:00
Hiep Le
4ee63d5bd2 fix(frontend): show members a read-only badge on org-defaults pages (#14098) 2026-04-23 12:57:19 -04:00
Graham Neubig
97b173979e Fix enterprise migration 108 settings mapping (#14088)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-23 12:51:03 -04:00
24 changed files with 1294 additions and 162 deletions

View File

@@ -6,7 +6,8 @@ Create Date: 2026-03-22 00:00:00.000000
"""
from typing import Sequence, Union
from collections.abc import Mapping
from typing import Any, Sequence, Union
import sqlalchemy as sa
from alembic import op
@@ -21,6 +22,330 @@ depends_on: Union[str, Sequence[str], None] = None
_EMPTY_JSON = sa.text("'{}'::json")
def _deep_merge(
base: dict[str, Any], overrides: Mapping[str, Any] | None
) -> dict[str, Any]:
merged = dict(base)
for key, value in (overrides or {}).items():
existing = merged.get(key)
if isinstance(existing, dict) and isinstance(value, Mapping):
merged[key] = _deep_merge(existing, value)
else:
merged[key] = value
return merged
def _strip_none_and_empty(value: Any) -> Any:
if isinstance(value, Mapping):
cleaned: dict[str, Any] = {}
for key, item in value.items():
cleaned_item = _strip_none_and_empty(item)
if cleaned_item is None:
continue
if isinstance(cleaned_item, dict) and not cleaned_item:
continue
cleaned[key] = cleaned_item
return cleaned
return value
def _next_server_name(existing: Mapping[str, Any], base_name: str) -> str:
if base_name not in existing:
return base_name
suffix = 1
while f'{base_name}_{suffix}' in existing:
suffix += 1
return f'{base_name}_{suffix}'
def _normalize_mcp_config(value: Any) -> Any:
if not isinstance(value, Mapping):
return value
raw_mcp_servers = value.get('mcpServers')
if isinstance(raw_mcp_servers, Mapping):
mcp_servers = dict(raw_mcp_servers)
return {'mcpServers': mcp_servers} if mcp_servers else None
if not any(
key in value for key in ('sse_servers', 'stdio_servers', 'shttp_servers')
):
return value
servers: dict[str, dict[str, Any]] = {}
for entry in value.get('sse_servers', []) or []:
if isinstance(entry, str):
entry = {'url': entry}
if not isinstance(entry, Mapping) or not isinstance(entry.get('url'), str):
continue
server: dict[str, Any] = {'url': entry['url'], 'transport': 'sse'}
if entry.get('api_key') is not None:
server['auth'] = entry.get('api_key')
servers[_next_server_name(servers, 'sse')] = server
for entry in value.get('shttp_servers', []) or []:
if isinstance(entry, str):
entry = {'url': entry}
if not isinstance(entry, Mapping) or not isinstance(entry.get('url'), str):
continue
server = {'url': entry['url']}
if entry.get('api_key') is not None:
server['auth'] = entry.get('api_key')
if entry.get('timeout') is not None:
server['timeout'] = entry.get('timeout')
servers[_next_server_name(servers, 'shttp')] = server
for entry in value.get('stdio_servers', []) or []:
if not isinstance(entry, Mapping) or not isinstance(entry.get('command'), str):
continue
server = {'command': entry['command']}
if entry.get('args') is not None:
server['args'] = entry.get('args')
if entry.get('env') is not None:
server['env'] = entry.get('env')
base_name = entry.get('name') if isinstance(entry.get('name'), str) else 'stdio'
servers[_next_server_name(servers, base_name)] = server
return {'mcpServers': servers} if servers else None
def _legacy_api_key(auth_value: Any) -> str | None:
if isinstance(auth_value, str) and auth_value != 'oauth':
return auth_value
return None
def _to_legacy_mcp_config(value: Any) -> Any:
if not isinstance(value, Mapping):
return value
raw_mcp_servers = value.get('mcpServers')
if not isinstance(raw_mcp_servers, Mapping):
return value
legacy: dict[str, list[Any]] = {
'sse_servers': [],
'stdio_servers': [],
'shttp_servers': [],
}
for server_name, server_config in raw_mcp_servers.items():
if not isinstance(server_config, Mapping):
continue
url = server_config.get('url')
if isinstance(url, str):
entry: dict[str, Any] = {'url': url}
api_key = _legacy_api_key(server_config.get('auth'))
if api_key is not None:
entry['api_key'] = api_key
if server_config.get('transport') == 'sse':
legacy['sse_servers'].append(entry)
else:
if server_config.get('timeout') is not None:
entry['timeout'] = server_config.get('timeout')
legacy['shttp_servers'].append(entry)
continue
command = server_config.get('command')
if not isinstance(command, str):
continue
entry = {'name': server_name, 'command': command}
if server_config.get('args') is not None:
entry['args'] = server_config.get('args')
if server_config.get('env') is not None:
entry['env'] = server_config.get('env')
legacy['stdio_servers'].append(entry)
return legacy
def _normalize_nested_mcp_config(settings: Mapping[str, Any] | None) -> dict[str, Any]:
normalized = dict(settings or {})
mcp_config = _normalize_mcp_config(normalized.get('mcp_config'))
if mcp_config is None:
normalized.pop('mcp_config', None)
else:
normalized['mcp_config'] = mcp_config
return normalized
def _build_user_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'schema_version': 1,
'agent': row['agent'],
'llm': {
'model': row['llm_model'],
'base_url': row['llm_base_url'],
},
'condenser': {
'enabled': row['enable_default_condenser'],
'max_size': row['condenser_max_size'],
},
'mcp_config': _normalize_mcp_config(row['mcp_config']),
}
)
merged = _deep_merge(
generated,
_normalize_nested_mcp_config(row.get('agent_settings')),
)
return _normalize_nested_mcp_config(merged)
def _build_user_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'max_iterations': row['max_iterations'],
'confirmation_mode': row['confirmation_mode'],
'security_analyzer': row['security_analyzer'],
}
)
return _deep_merge(generated, row.get('conversation_settings') or {})
def _build_org_member_agent_settings_diff(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'schema_version': 1,
'llm': {
'model': row['llm_model'],
'base_url': row['llm_base_url'],
},
'mcp_config': _normalize_mcp_config(row['mcp_config']),
}
)
merged = _deep_merge(
generated,
_normalize_nested_mcp_config(row.get('agent_settings_diff')),
)
return _normalize_nested_mcp_config(merged)
def _build_org_member_conversation_settings_diff(
row: Mapping[str, Any],
) -> dict[str, Any]:
generated = _strip_none_and_empty({'max_iterations': row['max_iterations']})
return _deep_merge(generated, row.get('conversation_settings_diff') or {})
def _build_org_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'schema_version': 1,
'agent': row['agent'],
'llm': {
'model': row['default_llm_model'],
'base_url': row['default_llm_base_url'],
},
'condenser': {
'enabled': row['enable_default_condenser'],
'max_size': row['condenser_max_size'],
},
'mcp_config': _normalize_mcp_config(row['mcp_config']),
}
)
merged = _deep_merge(
generated,
_normalize_nested_mcp_config(row.get('agent_settings')),
)
return _normalize_nested_mcp_config(merged)
def _build_org_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'max_iterations': row['default_max_iterations'],
'confirmation_mode': row['confirmation_mode'],
'security_analyzer': row['security_analyzer'],
}
)
return _deep_merge(generated, row.get('conversation_settings') or {})
def _get_nested_value(data: Mapping[str, Any] | None, *path: str) -> Any:
current: Any = data or {}
for key in path:
if not isinstance(current, Mapping) or key not in current:
return None
current = current[key]
return current
def _legacy_user_settings_values(row: Mapping[str, Any]) -> dict[str, Any]:
agent_settings = row.get('agent_settings') or {}
conversation_settings = row.get('conversation_settings') or {}
condenser_enabled = _get_nested_value(agent_settings, 'condenser', 'enabled')
return {
'agent': _get_nested_value(agent_settings, 'agent'),
'max_iterations': _get_nested_value(conversation_settings, 'max_iterations'),
'security_analyzer': _get_nested_value(
conversation_settings, 'security_analyzer'
),
'confirmation_mode': _get_nested_value(
conversation_settings, 'confirmation_mode'
),
'llm_model': _get_nested_value(agent_settings, 'llm', 'model'),
'llm_base_url': _get_nested_value(agent_settings, 'llm', 'base_url'),
'enable_default_condenser': (
True if condenser_enabled is None else condenser_enabled
),
'condenser_max_size': _get_nested_value(
agent_settings, 'condenser', 'max_size'
),
}
def _legacy_org_member_values(row: Mapping[str, Any]) -> dict[str, Any]:
agent_settings_diff = row.get('agent_settings_diff') or {}
conversation_settings_diff = row.get('conversation_settings_diff') or {}
return {
'llm_model': _get_nested_value(agent_settings_diff, 'llm', 'model'),
'llm_base_url': _get_nested_value(agent_settings_diff, 'llm', 'base_url'),
'max_iterations': _get_nested_value(
conversation_settings_diff, 'max_iterations'
),
'mcp_config': _to_legacy_mcp_config(
_get_nested_value(agent_settings_diff, 'mcp_config')
),
}
def _legacy_org_values(row: Mapping[str, Any]) -> dict[str, Any]:
agent_settings = row.get('agent_settings') or {}
conversation_settings = row.get('conversation_settings') or {}
condenser_enabled = _get_nested_value(agent_settings, 'condenser', 'enabled')
return {
'agent': _get_nested_value(agent_settings, 'agent'),
'default_max_iterations': _get_nested_value(
conversation_settings, 'max_iterations'
),
'security_analyzer': _get_nested_value(
conversation_settings, 'security_analyzer'
),
'confirmation_mode': _get_nested_value(
conversation_settings, 'confirmation_mode'
),
'default_llm_model': _get_nested_value(agent_settings, 'llm', 'model'),
'default_llm_base_url': _get_nested_value(agent_settings, 'llm', 'base_url'),
'enable_default_condenser': (
True if condenser_enabled is None else condenser_enabled
),
'mcp_config': _to_legacy_mcp_config(
_get_nested_value(agent_settings, 'mcp_config')
),
'condenser_max_size': _get_nested_value(
agent_settings, 'condenser', 'max_size'
),
}
def upgrade() -> None:
op.add_column(
'user_settings',
@@ -82,63 +407,125 @@ def upgrade() -> None:
),
)
op.execute(
sa.text(
"""
UPDATE user_settings
SET agent_settings = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'agent', agent,
'llm.model', llm_model,
'llm.base_url', llm_base_url,
'verification.confirmation_mode', confirmation_mode,
'verification.security_analyzer', security_analyzer,
'condenser.enabled', enable_default_condenser,
'condenser.max_size', condenser_max_size,
'max_iterations', max_iterations
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
)::json
"""
)
bind = op.get_bind()
user_settings_table = sa.table(
'user_settings',
sa.column('id', sa.Integer()),
sa.column('agent', sa.String()),
sa.column('max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('condenser_max_size', sa.Integer()),
sa.column('mcp_config', sa.JSON()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
)
op.execute(
sa.text(
"""
UPDATE org_member
SET agent_settings_diff = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'llm.model', llm_model,
'llm.base_url', llm_base_url,
'max_iterations', max_iterations,
'mcp_config', mcp_config
) || COALESCE(agent_settings_diff::jsonb, '{}'::jsonb)
)::json
"""
user_settings_rows = bind.execute(
sa.select(
user_settings_table.c.id,
user_settings_table.c.agent,
user_settings_table.c.max_iterations,
user_settings_table.c.security_analyzer,
user_settings_table.c.confirmation_mode,
user_settings_table.c.llm_model,
user_settings_table.c.llm_base_url,
user_settings_table.c.enable_default_condenser,
user_settings_table.c.condenser_max_size,
user_settings_table.c.mcp_config,
user_settings_table.c.agent_settings,
user_settings_table.c.conversation_settings,
)
)
op.execute(
sa.text(
"""
UPDATE org
SET agent_settings = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'agent', agent,
'llm.model', default_llm_model,
'llm.base_url', default_llm_base_url,
'verification.confirmation_mode', confirmation_mode,
'verification.security_analyzer', security_analyzer,
'condenser.enabled', enable_default_condenser,
'condenser.max_size', condenser_max_size,
'max_iterations', default_max_iterations,
'mcp_config', mcp_config
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
)::json
"""
).mappings()
for row in user_settings_rows:
bind.execute(
user_settings_table.update()
.where(user_settings_table.c.id == row['id'])
.values(
agent_settings=_build_user_agent_settings(row),
conversation_settings=_build_user_conversation_settings(row),
)
)
org_member_table = sa.table(
'org_member',
sa.column('org_id', sa.Uuid()),
sa.column('user_id', sa.Uuid()),
sa.column('max_iterations', sa.Integer()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('mcp_config', sa.JSON()),
sa.column('agent_settings_diff', sa.JSON()),
sa.column('conversation_settings_diff', sa.JSON()),
)
org_member_rows = bind.execute(
sa.select(
org_member_table.c.org_id,
org_member_table.c.user_id,
org_member_table.c.max_iterations,
org_member_table.c.llm_model,
org_member_table.c.llm_base_url,
org_member_table.c.mcp_config,
org_member_table.c.agent_settings_diff,
org_member_table.c.conversation_settings_diff,
)
).mappings()
for row in org_member_rows:
bind.execute(
org_member_table.update()
.where(org_member_table.c.org_id == row['org_id'])
.where(org_member_table.c.user_id == row['user_id'])
.values(
agent_settings_diff=_build_org_member_agent_settings_diff(row),
conversation_settings_diff=_build_org_member_conversation_settings_diff(
row
),
)
)
org_table = sa.table(
'org',
sa.column('id', sa.Uuid()),
sa.column('agent', sa.String()),
sa.column('default_max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('default_llm_model', sa.String()),
sa.column('default_llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('mcp_config', sa.JSON()),
sa.column('condenser_max_size', sa.Integer()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
)
org_rows = bind.execute(
sa.select(
org_table.c.id,
org_table.c.agent,
org_table.c.default_max_iterations,
org_table.c.security_analyzer,
org_table.c.confirmation_mode,
org_table.c.default_llm_model,
org_table.c.default_llm_base_url,
org_table.c.enable_default_condenser,
org_table.c.mcp_config,
org_table.c.condenser_max_size,
org_table.c.agent_settings,
org_table.c.conversation_settings,
)
).mappings()
for row in org_rows:
bind.execute(
org_table.update()
.where(org_table.c.id == row['id'])
.values(
agent_settings=_build_org_agent_settings(row),
conversation_settings=_build_org_conversation_settings(row),
)
)
op.alter_column('user_settings', 'agent_settings', server_default=None)
op.alter_column('user_settings', 'conversation_settings', server_default=None)
@@ -223,73 +610,92 @@ def downgrade() -> None:
op.add_column('org', sa.Column('mcp_config', sa.JSON(), nullable=True))
op.add_column('org', sa.Column('condenser_max_size', sa.Integer(), nullable=True))
op.execute(
sa.text(
"""
UPDATE user_settings
SET
agent = agent_settings ->> 'agent',
max_iterations = NULLIF(agent_settings ->> 'max_iterations', '')::integer,
security_analyzer =
agent_settings ->> 'verification.security_analyzer',
confirmation_mode = CASE
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
ELSE NULL
END,
llm_model = agent_settings ->> 'llm.model',
llm_base_url = agent_settings ->> 'llm.base_url',
enable_default_condenser = CASE
WHEN agent_settings::jsonb ? 'condenser.enabled'
THEN (agent_settings ->> 'condenser.enabled')::boolean
ELSE TRUE
END,
condenser_max_size =
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
"""
)
bind = op.get_bind()
user_settings_table = sa.table(
'user_settings',
sa.column('id', sa.Integer()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
sa.column('agent', sa.String()),
sa.column('max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('condenser_max_size', sa.Integer()),
)
op.execute(
sa.text(
"""
UPDATE org_member
SET
llm_model = agent_settings_diff ->> 'llm.model',
llm_base_url = agent_settings_diff ->> 'llm.base_url',
max_iterations =
NULLIF(agent_settings_diff ->> 'max_iterations', '')::integer,
mcp_config = agent_settings_diff -> 'mcp_config'
"""
user_settings_rows = bind.execute(
sa.select(
user_settings_table.c.id,
user_settings_table.c.agent_settings,
user_settings_table.c.conversation_settings,
)
)
op.execute(
sa.text(
"""
UPDATE org
SET
agent = agent_settings ->> 'agent',
default_max_iterations =
NULLIF(agent_settings ->> 'max_iterations', '')::integer,
security_analyzer =
agent_settings ->> 'verification.security_analyzer',
confirmation_mode = CASE
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
ELSE NULL
END,
default_llm_model = agent_settings ->> 'llm.model',
default_llm_base_url = agent_settings ->> 'llm.base_url',
enable_default_condenser = CASE
WHEN agent_settings::jsonb ? 'condenser.enabled'
THEN (agent_settings ->> 'condenser.enabled')::boolean
ELSE TRUE
END,
mcp_config = agent_settings -> 'mcp_config',
condenser_max_size =
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
"""
).mappings()
for row in user_settings_rows:
bind.execute(
user_settings_table.update()
.where(user_settings_table.c.id == row['id'])
.values(**_legacy_user_settings_values(row))
)
org_member_table = sa.table(
'org_member',
sa.column('org_id', sa.Uuid()),
sa.column('user_id', sa.Uuid()),
sa.column('agent_settings_diff', sa.JSON()),
sa.column('conversation_settings_diff', sa.JSON()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('max_iterations', sa.Integer()),
sa.column('mcp_config', sa.JSON()),
)
org_member_rows = bind.execute(
sa.select(
org_member_table.c.org_id,
org_member_table.c.user_id,
org_member_table.c.agent_settings_diff,
org_member_table.c.conversation_settings_diff,
)
).mappings()
for row in org_member_rows:
bind.execute(
org_member_table.update()
.where(org_member_table.c.org_id == row['org_id'])
.where(org_member_table.c.user_id == row['user_id'])
.values(**_legacy_org_member_values(row))
)
org_table = sa.table(
'org',
sa.column('id', sa.Uuid()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
sa.column('agent', sa.String()),
sa.column('default_max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('default_llm_model', sa.String()),
sa.column('default_llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('mcp_config', sa.JSON()),
sa.column('condenser_max_size', sa.Integer()),
)
org_rows = bind.execute(
sa.select(
org_table.c.id,
org_table.c.agent_settings,
org_table.c.conversation_settings,
)
).mappings()
for row in org_rows:
bind.execute(
org_table.update()
.where(org_table.c.id == row['id'])
.values(**_legacy_org_values(row))
)
op.drop_column('org', 'agent_settings')
op.drop_column('org', 'conversation_settings')
op.drop_column('org', '_llm_api_key')

View File

@@ -703,6 +703,41 @@ async def accept_tos(request: Request):
return response
@api_router.get('/onboarding_status')
async def onboarding_status(request: Request):
"""Return whether the current user must still complete onboarding.
Kept as a dedicated endpoint instead of riding on ``GET /api/v1/settings``
(the natural home for fields like ``email_verified``) because the settings
response is heavyweight: ``SaasSettingsStore.load`` joins User, Org, and
OrgMember rows and deep-merges the org-level and member-level
``agent_settings`` before returning. Onboarding gating runs on every
protected-route navigation, so we need a lightweight read of a single
boolean rather than paying for the full settings aggregation.
"""
user_auth = cast(SaasUserAuth, await get_user_auth(request))
user_id = await user_auth.get_user_id()
if not user_id:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'User is not authenticated'},
)
user = await UserStore.get_user_by_id(user_id)
if not user:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'User not found'},
)
should_complete = await _should_redirect_to_onboarding(user_id, user)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'should_complete_onboarding': should_complete},
)
@api_router.post('/complete_onboarding')
async def complete_onboarding(request: Request):
"""Mark onboarding as completed for the current user."""

View File

@@ -230,19 +230,10 @@ class UserStore:
from storage.org_store import OrgStore
org_kwargs = OrgStore.get_kwargs_from_user_settings(decrypted_user_settings)
org_kwargs.pop('id', None)
# If the user has custom settings, keep the org defaults minimal.
if custom_settings:
org_kwargs['agent_settings'] = {
'schema_version': AGENT_SETTINGS_SCHEMA_VERSION,
'llm': {
'model': get_default_litellm_model(),
'base_url': LITE_LLM_API_URL,
},
}
org_kwargs['org_version'] = ORG_SETTINGS_VERSION
org_kwargs = UserStore._get_org_kwargs_for_migration(
decrypted_user_settings,
custom_settings=custom_settings,
)
for key, value in org_kwargs.items():
if hasattr(org, key):
@@ -1066,6 +1057,27 @@ class UserStore:
already_migrated=False,
)
@staticmethod
def _get_org_kwargs_for_migration(
user_settings: UserSettings, *, custom_settings: bool
) -> dict:
from storage.org_store import OrgStore
org_kwargs = OrgStore.get_kwargs_from_user_settings(user_settings)
org_kwargs.pop('id', None)
org_kwargs['org_version'] = ORG_SETTINGS_VERSION
if custom_settings:
org_kwargs['agent_settings'] = {
'schema_version': AGENT_SETTINGS_SCHEMA_VERSION,
'llm': {
'model': get_default_litellm_model(),
'base_url': LITE_LLM_API_URL,
},
}
return org_kwargs
@staticmethod
def _has_custom_settings(
user_settings: UserSettings, old_user_version: int | None

View File

@@ -4,8 +4,10 @@ Tests for:
- _should_redirect_to_onboarding() function
- _get_post_auth_redirect() function
- /complete_onboarding endpoint
- /onboarding_status endpoint
"""
import json
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
@@ -17,6 +19,7 @@ from server.routes.auth import (
_get_post_auth_redirect,
_should_redirect_to_onboarding,
complete_onboarding,
onboarding_status,
)
from storage.user import User
@@ -328,3 +331,78 @@ class TestCompleteOnboardingEndpoint:
await complete_onboarding(mock_request)
mock_mark_completed.assert_called_once_with(user_id)
class TestOnboardingStatusEndpoint:
"""Tests for the /onboarding_status API endpoint."""
@pytest.mark.asyncio
async def test_returns_401_when_not_authenticated(self, mock_request):
"""Unauthenticated requests return 401."""
mock_user_auth = MagicMock(spec=SaasUserAuth)
mock_user_auth.get_user_id = AsyncMock(return_value=None)
with patch(
'server.routes.auth.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
):
result = await onboarding_status(mock_request)
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_returns_true_for_new_cloud_user(self, mock_request, mock_user):
"""A cloud user whose onboarding is incomplete should be told to complete it."""
user_id = str(uuid.uuid4())
mock_user.onboarding_completed = False
mock_user_auth = MagicMock(spec=SaasUserAuth)
mock_user_auth.get_user_id = AsyncMock(return_value=user_id)
with (
patch(
'server.routes.auth.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.auth.UserStore.get_user_by_id',
new_callable=AsyncMock,
return_value=mock_user,
),
patch('server.routes.auth.DEPLOYMENT_MODE', 'cloud'),
):
result = await onboarding_status(mock_request)
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_200_OK
body = json.loads(result.body)
assert body == {'should_complete_onboarding': True}
@pytest.mark.asyncio
async def test_returns_false_for_completed_user(self, mock_request, mock_user):
"""A user who already completed onboarding should not be told to complete it."""
user_id = str(uuid.uuid4())
mock_user.onboarding_completed = True
mock_user_auth = MagicMock(spec=SaasUserAuth)
mock_user_auth.get_user_id = AsyncMock(return_value=user_id)
with (
patch(
'server.routes.auth.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.auth.UserStore.get_user_by_id',
new_callable=AsyncMock,
return_value=mock_user,
),
):
result = await onboarding_status(mock_request)
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_200_OK
body = json.loads(result.body)
assert body == {'should_complete_onboarding': False}

View File

@@ -0,0 +1,279 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from storage.user_settings import UserSettings
MIGRATION_PATH = (
Path(__file__).resolve().parents[2]
/ 'migrations'
/ 'versions'
/ '108_add_agent_settings_to_enterprise_settings.py'
)
spec = spec_from_file_location('migration_108', MIGRATION_PATH)
assert spec is not None and spec.loader is not None
migration_108 = module_from_spec(spec)
spec.loader.exec_module(migration_108)
def test_user_settings_are_split_into_agent_and_conversation_buckets():
row = {
'agent': 'CodeActAgent',
'max_iterations': 42,
'security_analyzer': 'llm',
'confirmation_mode': True,
'llm_model': 'anthropic/claude-sonnet-4-5-20250929',
'llm_base_url': 'https://api.example.com',
'enable_default_condenser': False,
'condenser_max_size': 128,
'mcp_config': {'mcpServers': {'admin': {'url': 'https://mcp.example.com'}}},
'agent_settings': {},
'conversation_settings': {},
}
agent_settings = migration_108._build_user_agent_settings(row)
conversation_settings = migration_108._build_user_conversation_settings(row)
assert agent_settings == {
'schema_version': 1,
'agent': 'CodeActAgent',
'llm': {
'model': 'anthropic/claude-sonnet-4-5-20250929',
'base_url': 'https://api.example.com',
},
'condenser': {'enabled': False, 'max_size': 128},
'mcp_config': {'mcpServers': {'admin': {'url': 'https://mcp.example.com'}}},
}
assert conversation_settings == {
'max_iterations': 42,
'confirmation_mode': True,
'security_analyzer': 'llm',
}
def test_user_settings_normalize_legacy_mcp_config():
row = {
'agent': 'CodeActAgent',
'max_iterations': 42,
'security_analyzer': 'llm',
'confirmation_mode': True,
'llm_model': 'anthropic/claude-sonnet-4-5-20250929',
'llm_base_url': 'https://api.example.com',
'enable_default_condenser': False,
'condenser_max_size': 128,
'mcp_config': {
'sse_servers': [],
'stdio_servers': [],
'shttp_servers': [
{'url': 'https://mcp.example.com', 'api_key': None, 'timeout': 60}
],
},
'agent_settings': {},
'conversation_settings': {},
}
assert migration_108._build_user_agent_settings(row) == {
'schema_version': 1,
'agent': 'CodeActAgent',
'llm': {
'model': 'anthropic/claude-sonnet-4-5-20250929',
'base_url': 'https://api.example.com',
},
'condenser': {'enabled': False, 'max_size': 128},
'mcp_config': {
'mcpServers': {'shttp': {'url': 'https://mcp.example.com', 'timeout': 60}}
},
}
def test_org_member_diffs_use_nested_llm_and_conversation_settings():
row = {
'max_iterations': 50,
'llm_model': 'openhands/claude-3',
'llm_base_url': 'https://proxy.example.com',
'mcp_config': {'mcpServers': {'admin': {'url': 'https://mcp.example.com'}}},
'agent_settings_diff': {},
'conversation_settings_diff': {},
}
agent_settings_diff = migration_108._build_org_member_agent_settings_diff(row)
conversation_settings_diff = (
migration_108._build_org_member_conversation_settings_diff(row)
)
assert agent_settings_diff == {
'schema_version': 1,
'llm': {
'model': 'openhands/claude-3',
'base_url': 'https://proxy.example.com',
},
'mcp_config': {'mcpServers': {'admin': {'url': 'https://mcp.example.com'}}},
}
assert conversation_settings_diff == {'max_iterations': 50}
def test_org_member_diffs_normalize_legacy_mcp_config():
row = {
'max_iterations': 50,
'llm_model': 'openhands/claude-3',
'llm_base_url': 'https://proxy.example.com',
'mcp_config': {
'sse_servers': [],
'stdio_servers': [],
'shttp_servers': [
{'url': 'https://mcp.deepwiki.com/mcp', 'api_key': None, 'timeout': 60}
],
},
'agent_settings_diff': {},
'conversation_settings_diff': {},
}
assert migration_108._build_org_member_agent_settings_diff(row) == {
'schema_version': 1,
'llm': {
'model': 'openhands/claude-3',
'base_url': 'https://proxy.example.com',
},
'mcp_config': {
'mcpServers': {
'shttp': {'url': 'https://mcp.deepwiki.com/mcp', 'timeout': 60}
}
},
}
def test_org_settings_are_split_into_agent_and_conversation_buckets():
row = {
'agent': 'CodeActAgent',
'default_max_iterations': 99,
'security_analyzer': 'auto',
'confirmation_mode': False,
'default_llm_model': 'anthropic/claude-3-7-sonnet',
'default_llm_base_url': 'https://api.example.com',
'enable_default_condenser': True,
'condenser_max_size': 256,
'mcp_config': {'mcpServers': {'org': {'url': 'https://org-mcp.example.com'}}},
'agent_settings': {},
'conversation_settings': {},
}
agent_settings = migration_108._build_org_agent_settings(row)
conversation_settings = migration_108._build_org_conversation_settings(row)
assert agent_settings == {
'schema_version': 1,
'agent': 'CodeActAgent',
'llm': {
'model': 'anthropic/claude-3-7-sonnet',
'base_url': 'https://api.example.com',
},
'condenser': {'enabled': True, 'max_size': 256},
'mcp_config': {'mcpServers': {'org': {'url': 'https://org-mcp.example.com'}}},
}
assert conversation_settings == {
'max_iterations': 99,
'confirmation_mode': False,
'security_analyzer': 'auto',
}
def test_downgrade_extracts_legacy_values_from_nested_settings():
row = {
'agent_settings': {
'schema_version': 1,
'agent': 'CodeActAgent',
'llm': {
'model': 'anthropic/claude-sonnet-4-5-20250929',
'base_url': 'https://api.example.com',
},
'condenser': {'enabled': False, 'max_size': 128},
},
'conversation_settings': {
'max_iterations': 42,
'confirmation_mode': True,
'security_analyzer': 'llm',
},
}
assert migration_108._legacy_user_settings_values(row) == {
'agent': 'CodeActAgent',
'max_iterations': 42,
'security_analyzer': 'llm',
'confirmation_mode': True,
'llm_model': 'anthropic/claude-sonnet-4-5-20250929',
'llm_base_url': 'https://api.example.com',
'enable_default_condenser': False,
'condenser_max_size': 128,
}
def test_downgrade_restores_legacy_mcp_config_from_sdk_settings():
row = {
'agent_settings_diff': {
'schema_version': 1,
'mcp_config': {
'mcpServers': {
'sse': {'url': 'https://mcp.example.com', 'transport': 'sse'},
'shttp': {
'url': 'https://mcp.deepwiki.com/mcp',
'timeout': 60,
},
'deepwiki-stdio': {
'command': 'npx',
'args': ['-y', 'deepwiki-mcp'],
'env': {'A': 'B'},
},
}
},
},
'conversation_settings_diff': {},
}
assert migration_108._legacy_org_member_values(row)['mcp_config'] == {
'sse_servers': [{'url': 'https://mcp.example.com'}],
'stdio_servers': [
{
'name': 'deepwiki-stdio',
'command': 'npx',
'args': ['-y', 'deepwiki-mcp'],
'env': {'A': 'B'},
}
],
'shttp_servers': [{'url': 'https://mcp.deepwiki.com/mcp', 'timeout': 60}],
}
def test_migrated_payload_loads_via_user_settings_to_settings():
row = {
'agent': 'CodeActAgent',
'max_iterations': 42,
'security_analyzer': 'llm',
'confirmation_mode': True,
'llm_model': 'anthropic/claude-sonnet-4-5-20250929',
'llm_base_url': 'https://api.example.com',
'enable_default_condenser': False,
'condenser_max_size': 128,
'mcp_config': {'mcpServers': {'admin': {'url': 'https://mcp.example.com'}}},
'agent_settings': {},
'conversation_settings': {},
}
user_settings = UserSettings(
agent_settings=migration_108._build_user_agent_settings(row),
conversation_settings=migration_108._build_user_conversation_settings(row),
)
settings = user_settings.to_settings()
assert settings.agent_settings.agent == 'CodeActAgent'
assert settings.agent_settings.llm.model == 'anthropic/claude-sonnet-4-5-20250929'
assert settings.agent_settings.llm.base_url == 'https://api.example.com'
assert settings.agent_settings.condenser.enabled is False
assert settings.agent_settings.condenser.max_size == 128
assert settings.agent_settings.mcp_config is not None
assert (
settings.agent_settings.mcp_config.mcpServers['admin'].url
== 'https://mcp.example.com'
)
assert settings.conversation_settings.max_iterations == 42
assert settings.conversation_settings.confirmation_mode is True
assert settings.conversation_settings.security_analyzer == 'llm'

View File

@@ -695,6 +695,66 @@ async def test_list_users(async_session_maker):
assert user_id2 in user_ids
def test_get_org_kwargs_for_migration_preserves_existing_llm_when_not_custom():
from server.constants import ORG_SETTINGS_VERSION
from storage.user_settings import UserSettings
user_settings = UserSettings(
keycloak_user_id='test',
user_version=3,
agent_settings={
'schema_version': 1,
'llm': {
'model': 'anthropic/claude-sonnet-4-5-20250929',
'base_url': 'https://api.anthropic.com/v1',
},
},
conversation_settings={'max_iterations': 42},
)
org_kwargs = UserStore._get_org_kwargs_for_migration(
user_settings, custom_settings=False
)
assert org_kwargs['org_version'] == ORG_SETTINGS_VERSION
assert org_kwargs['agent_settings'] == user_settings.agent_settings
assert org_kwargs['conversation_settings'] == user_settings.conversation_settings
def test_get_org_kwargs_for_migration_uses_minimal_org_defaults_for_custom_llm():
from server.constants import (
LITE_LLM_API_URL,
ORG_SETTINGS_VERSION,
get_default_litellm_model,
)
from storage.user_settings import UserSettings
user_settings = UserSettings(
keycloak_user_id='test',
user_version=3,
agent_settings={
'schema_version': 1,
'llm': {
'model': 'anthropic/claude-sonnet-4-5-20250929',
'base_url': 'https://api.anthropic.com/v1',
},
},
)
org_kwargs = UserStore._get_org_kwargs_for_migration(
user_settings, custom_settings=True
)
assert org_kwargs['org_version'] == ORG_SETTINGS_VERSION
assert org_kwargs['agent_settings'] == {
'schema_version': 1,
'llm': {
'model': get_default_litellm_model(),
'base_url': LITE_LLM_API_URL,
},
}
# --- Tests for _has_custom_settings ---

View File

@@ -6,6 +6,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next";
import i18n from "i18next";
import OnboardingForm, { clientLoader } from "#/routes/onboarding-form";
import AuthService from "#/api/auth-service/auth-service.api";
import { onboardingService } from "#/api/onboarding-service/onboarding-service.api";
const mockMutate = vi.fn();
const mockNavigate = vi.fn();
@@ -56,6 +58,12 @@ vi.mock("#/api/option-service/option-service.api", () => ({
},
}));
// Mock feature flag - enable onboarding by default for tests
const mockEnableOnboarding = vi.fn(() => true);
vi.mock("#/utils/feature-flags", () => ({
ENABLE_ONBOARDING: () => mockEnableOnboarding(),
}));
const renderOnboardingForm = async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -555,14 +563,64 @@ describe("OnboardingForm - Self-Hosted Mode", () => {
});
});
describe("OnboardingForm - redirect when already onboarded", () => {
beforeEach(() => {
mockMutate.mockClear();
mockNavigate.mockClear();
mockUseMe.mockReturnValue({ data: { role: "member" } });
loaderData = {
config: {
app_mode: "saas",
feature_flags: { deployment_mode: "cloud" },
},
};
mockGetConfig.mockResolvedValue({
app_mode: "saas",
feature_flags: { deployment_mode: "cloud" },
});
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
});
it("should navigate to / when the backend reports onboarding is already complete", async () => {
// Arrange
vi.spyOn(onboardingService, "getStatus").mockResolvedValue({
should_complete_onboarding: false,
});
// Act
await renderOnboardingForm();
// Assert
await vi.waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
});
describe("onboarding-form clientLoader", () => {
beforeEach(() => {
mockQueryClientGetData.mockReset();
mockQueryClientSetData.mockReset();
mockGetConfig.mockReset();
mockEnableOnboarding.mockReturnValue(true);
});
describe("redirect behavior", () => {
it("should redirect to / when ENABLE_ONBOARDING feature flag is false", async () => {
mockEnableOnboarding.mockReturnValue(false);
const saasConfig = {
app_mode: "saas",
feature_flags: { deployment_mode: "cloud" },
};
mockQueryClientGetData.mockReturnValue(saasConfig);
const result = await clientLoader();
expect(result).toBeDefined();
expect((result as Response).status).toBe(302);
expect((result as Response).headers.get("Location")).toBe("/");
});
it("should redirect to / when app_mode is oss", async () => {
const ossConfig = {
app_mode: "oss",

View File

@@ -22,4 +22,17 @@ describe("OrgWideSettingsBadge", () => {
const icon = badge.querySelector("svg");
expect(icon).toBeInTheDocument();
});
it("should render the managed-by-admin i18n key when variant is set", () => {
// Arrange & Act
render(<OrgWideSettingsBadge variant="managed-by-admin" />);
// Assert
expect(
screen.getByText("SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE"),
).toBeInTheDocument();
expect(
screen.queryByText("SETTINGS$ORG_WIDE_SETTING_BADGE"),
).not.toBeInTheDocument();
});
});

View File

@@ -27,12 +27,6 @@ describe("useIsOnIntermediatePage", () => {
expect(result.current).toBe(true);
});
it("should return true when on /onboarding page", () => {
useLocationMock.mockReturnValue({ pathname: "/onboarding" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(true);
});
it("should return true when on /information-request page", () => {
useLocationMock.mockReturnValue({ pathname: "/information-request" });
const { result } = renderHook(() => useIsOnIntermediatePage());
@@ -52,6 +46,12 @@ describe("useIsOnIntermediatePage", () => {
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(false);
});
it("should return false when on /onboarding page so settings/auth queries can fire", () => {
useLocationMock.mockReturnValue({ pathname: "/onboarding" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(false);
});
});
describe("handles edge cases", () => {

View File

@@ -258,7 +258,7 @@ describe("LlmSettingsScreen", () => {
it("keeps Advanced visible but hides All in SaaS mode for the default LLM route schema", async () => {
vi.spyOn(
organizationService,
"getOrganizationAgentSettings",
"getOrganizationSettings",
).mockResolvedValue(
buildSettings({
agent_settings: {

View File

@@ -6,6 +6,7 @@ import MainApp from "#/routes/root-layout";
import OptionService from "#/api/option-service/option-service.api";
import AuthService from "#/api/auth-service/auth-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { onboardingService } from "#/api/onboarding-service/onboarding-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
vi.mock("#/hooks/use-github-auth-url", () => ({
@@ -51,6 +52,12 @@ vi.mock("#/hooks/use-invitation", () => ({
}),
}));
// Mock feature flags - enable onboarding for tests that need it
vi.mock("#/utils/feature-flags", () => ({
ENABLE_ONBOARDING: () => true,
ENABLE_AUTOMATIONS: () => false,
}));
function LoginStub() {
const [searchParams] = useSearchParams();
const emailVerificationRequired =
@@ -111,6 +118,23 @@ const RouterStubWithLogin = createRoutesStub([
},
]);
const RouterStubWithOnboarding = createRoutesStub([
{
Component: MainApp,
path: "/",
children: [
{
Component: () => <div data-testid="outlet-content" />,
path: "/",
},
],
},
{
Component: () => <div data-testid="onboarding-page" />,
path: "/onboarding",
},
]);
const RouterStubWithDeviceVerify = createRoutesStub([
{
Component: MainApp,
@@ -193,6 +217,10 @@ describe("MainApp", () => {
MOCK_DEFAULT_USER_SETTINGS,
);
vi.spyOn(onboardingService, "getStatus").mockResolvedValue({
should_complete_onboarding: false,
});
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
@@ -553,4 +581,25 @@ describe("MainApp", () => {
);
});
});
describe("Onboarding redirect", () => {
it("should redirect authenticated SaaS users with incomplete onboarding to /onboarding", async () => {
// Arrange: backend reports onboarding still required.
vi.spyOn(onboardingService, "getStatus").mockResolvedValue({
should_complete_onboarding: true,
});
// Act: render the home page.
renderWithLoginStub(RouterStubWithOnboarding, ["/"]);
// Assert: user lands on /onboarding instead of the home outlet.
await waitFor(
() => {
expect(screen.getByTestId("onboarding-page")).toBeInTheDocument();
},
{ timeout: 2000 },
);
expect(screen.queryByTestId("outlet-content")).not.toBeInTheDocument();
});
});
});

View File

@@ -49,6 +49,10 @@ vi.mock("react-i18next", async () => {
SETTINGS$NAV_BILLING: "Billing",
SETTINGS$TITLE: "Settings",
COMMON$LANGUAGE_MODEL_LLM: "LLM",
SETTINGS$ORG_WIDE_SETTING_BADGE:
"This setting affects the whole organization",
SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE:
"This setting is managed by your organization administrator",
};
return translations[key] || key;
},
@@ -651,7 +655,10 @@ describe("Settings Screen", () => {
});
useSelectedOrganizationStore.setState({ organizationId: "1" });
// Pre-populate user data in cache so useMe() returns admin role immediately
mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" }));
mockQueryClient.setQueryData(
["organizations", "1", "me"],
createMockUser({ role: "admin", org_id: "1" }),
);
renderSettingsScreen();
@@ -729,7 +736,10 @@ describe("Settings Screen", () => {
});
useSelectedOrganizationStore.setState({ organizationId: "1" });
// Pre-populate user data in cache so useMe() returns admin role immediately
mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" }));
mockQueryClient.setQueryData(
["organizations", "1", "me"],
createMockUser({ role: "admin", org_id: "1" }),
);
renderSettingsScreen();
@@ -861,22 +871,33 @@ describe("Settings Screen", () => {
renderSettingsScreen(path);
expect(
await screen.findByTestId("org-wide-settings-badge"),
).toBeInTheDocument();
const badge = await screen.findByTestId("org-wide-settings-badge");
expect(badge).toBeInTheDocument();
expect(badge).toHaveTextContent(
"This setting affects the whole organization",
);
},
);
it("renders the badge on /settings/org-defaults for a non-admin member of a team org (read-only view)", async () => {
seedSaasOrgContext(MOCK_TEAM_ORG_ACME, { role: "member" });
it.each([
"/settings/org-defaults",
"/settings/org-defaults/condenser",
"/settings/org-defaults/verification",
])(
"renders the managed-by-admin badge on %s for a member of a team org (read-only view)",
async (path) => {
seedSaasOrgContext(MOCK_TEAM_ORG_ACME, { role: "member" });
renderSettingsScreen("/settings/org-defaults");
renderSettingsScreen(path);
await screen.findByTestId("org-default-llm-settings-screen");
expect(
await screen.findByTestId("org-wide-settings-badge"),
).toBeInTheDocument();
});
const badge = await screen.findByTestId("org-wide-settings-badge");
await waitFor(() => {
expect(badge).toHaveTextContent(
"This setting is managed by your organization administrator",
);
});
},
);
it("does not render the badge on /settings/org-defaults when the selected organization is a personal org", async () => {
seedSaasOrgContext(MOCK_PERSONAL_ORG, { role: "admin" });

View File

@@ -0,0 +1,14 @@
import { openHands } from "../open-hands-axios";
export type OnboardingStatusResponse = {
should_complete_onboarding: boolean;
};
export const onboardingService = {
getStatus: async (): Promise<OnboardingStatusResponse> => {
const { data } = await openHands.get<OnboardingStatusResponse>(
"/api/onboarding_status",
);
return data;
},
};

View File

@@ -0,0 +1,28 @@
import React from "react";
import { useLocation, useNavigate } from "react-router";
import { useOnboardingStatus } from "#/hooks/query/use-onboarding-status";
import { ENABLE_ONBOARDING } from "#/utils/feature-flags";
/**
* Forces SaaS users with incomplete onboarding to /onboarding before they can
* access any protected route. Mirrors EmailVerificationGuard.
*/
export function OnboardingGuard({ children }: { children: React.ReactNode }) {
const { data, isLoading } = useOnboardingStatus();
const navigate = useNavigate();
const { pathname } = useLocation();
React.useEffect(() => {
if (isLoading) return;
// Only redirect to onboarding if the feature flag is enabled
if (
ENABLE_ONBOARDING() &&
data?.should_complete_onboarding &&
pathname !== "/onboarding"
) {
navigate("/onboarding", { replace: true });
}
}, [data?.should_complete_onboarding, isLoading, pathname, navigate]);
return children;
}

View File

@@ -3,9 +3,22 @@ import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import InfoCircleIcon from "#/icons/info-circle.svg?react";
export function OrgWideSettingsBadge() {
export type OrgWideSettingsBadgeVariant = "org-wide" | "managed-by-admin";
interface OrgWideSettingsBadgeProps {
variant?: OrgWideSettingsBadgeVariant;
}
export function OrgWideSettingsBadge({
variant = "org-wide",
}: OrgWideSettingsBadgeProps) {
const { t } = useTranslation();
const i18nKey =
variant === "managed-by-admin"
? I18nKey.SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE
: I18nKey.SETTINGS$ORG_WIDE_SETTING_BADGE;
return (
<div
data-testid="org-wide-settings-badge"
@@ -13,7 +26,7 @@ export function OrgWideSettingsBadge() {
>
<InfoCircleIcon width={12} height={12} className="text-[#8c8c8c]" />
<Typography.Text className="text-[11px] font-medium text-[#8c8c8c] leading-5">
{t(I18nKey.SETTINGS$ORG_WIDE_SETTING_BADGE)}
{t(i18nKey)}
</Typography.Text>
</div>
);

View File

@@ -19,6 +19,7 @@ export const useSubmitOnboarding = () => {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["onboarding-status"] });
const finalRedirectUrl = "/";
// Check if the redirect URL is an external URL (starts with http or https)

View File

@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import { onboardingService } from "#/api/onboarding-service/onboarding-service.api";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
export const useOnboardingStatus = () => {
const { data: config } = useConfig();
const { data: isAuthed } = useIsAuthed();
return useQuery({
queryKey: ["onboarding-status"],
queryFn: onboardingService.getStatus,
enabled: config?.app_mode === "saas" && !!isAuthed,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 15,
retry: false,
meta: {
disableToast: true,
},
});
};

View File

@@ -1,10 +1,6 @@
import { useLocation } from "react-router";
const INTERMEDIATE_PAGE_PATHS = [
"/accept-tos",
"/onboarding",
"/information-request",
];
const INTERMEDIATE_PAGE_PATHS = ["/accept-tos", "/information-request"];
/**
* Checks if the current page is an intermediate page.

View File

@@ -288,6 +288,7 @@ export enum I18nKey {
SETTINGS$ORG_SETTINGS_HEADER = "SETTINGS$ORG_SETTINGS_HEADER",
SETTINGS$PERSONAL_SETTINGS_HEADER = "SETTINGS$PERSONAL_SETTINGS_HEADER",
SETTINGS$ORG_WIDE_SETTING_BADGE = "SETTINGS$ORG_WIDE_SETTING_BADGE",
SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE = "SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE",
SETTINGS$ORG_DEFAULTS_INFO = "SETTINGS$ORG_DEFAULTS_INFO",
SETTINGS$PERSONAL_AGENT_INFO = "SETTINGS$PERSONAL_AGENT_INFO",
SETTINGS$GITHUB = "SETTINGS$GITHUB",

View File

@@ -4895,6 +4895,23 @@
"uk": "Це налаштування впливає на всю організацію",
"ca": "Aquesta configuració afecta tota l'organització"
},
"SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE": {
"en": "This setting is managed by your organization administrator",
"ja": "この設定は組織の管理者によって管理されています",
"zh-CN": "此设置由您的组织管理员管理",
"zh-TW": "此設定由您的組織管理員管理",
"ko-KR": "이 설정은 조직 관리자가 관리합니다",
"no": "Denne innstillingen administreres av organisasjonsadministratoren din",
"it": "Questa impostazione è gestita dallamministratore della tua organizzazione",
"pt": "Esta configuração é gerida pelo administrador da sua organização",
"es": "Esta configuración está gestionada por el administrador de tu organización",
"ar": "يتم إدارة هذا الإعداد من قبل مسؤول مؤسستك",
"fr": "Ce paramètre est géré par ladministrateur de votre organisation",
"tr": "Bu ayar kuruluş yöneticiniz tarafından yönetilmektedir",
"de": "Diese Einstellung wird von Ihrem Organisationsadministrator verwaltet",
"uk": "Це налаштування керується адміністратором вашої організації",
"ca": "Aquesta configuració està gestionada per l'administrador de la teva organització"
},
"SETTINGS$ORG_DEFAULTS_INFO": {
"en": "These organization defaults are applied first. Members can still add their own credentials or personal overrides where allowed.",
"ja": "これらの組織のデフォルト設定が最初に適用されます。メンバーは許可されている範囲で、自分の認証情報や個人設定を追加できます。",

View File

@@ -7,6 +7,7 @@ import { BrandButton } from "#/components/features/settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
import { useSubmitOnboarding } from "#/hooks/mutation/use-submit-onboarding";
import { useOnboardingStatus } from "#/hooks/query/use-onboarding-status";
import { useTracking } from "#/hooks/use-tracking";
import { cn } from "#/utils/utils";
import { useMe } from "#/hooks/query/use-me";
@@ -21,8 +22,14 @@ import {
} from "#/api/option-service/option.types";
import { queryClient } from "#/query-client-config";
import OptionService from "#/api/option-service/option-service.api";
import { ENABLE_ONBOARDING } from "#/utils/feature-flags";
export const clientLoader = async () => {
// Check feature flag FIRST (sync) to block access immediately without flash
if (!ENABLE_ONBOARDING()) {
return redirect("/");
}
let config = queryClient.getQueryData<WebClientConfig>(["web-client-config"]);
if (!config) {
config = await OptionService.getConfig();
@@ -82,9 +89,22 @@ function OnboardingForm() {
const loaderData = useLoaderData<typeof clientLoader>();
const config = loaderData?.config;
const { data: me } = useMe();
const { data: onboardingStatus, isLoading: isOnboardingStatusLoading } =
useOnboardingStatus();
const { mutate: submitOnboarding } = useSubmitOnboarding();
const { trackOnboardingCompleted } = useTracking();
React.useEffect(() => {
if (isOnboardingStatusLoading) return;
if (onboardingStatus?.should_complete_onboarding === false) {
navigate("/", { replace: true });
}
}, [
onboardingStatus?.should_complete_onboarding,
isOnboardingStatusLoading,
navigate,
]);
const onboardingAppMode: OnboardingAppMode = getOnboardingAppMode(
config?.feature_flags?.deployment_mode,
);

View File

@@ -26,6 +26,7 @@ import { useSyncPostHogConsent } from "#/hooks/use-sync-posthog-consent";
import { useAutoSelectOrganization } from "#/hooks/use-auto-select-organization";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
import { OnboardingGuard } from "#/components/features/guards/onboarding-guard";
import { AlertBanner } from "#/components/features/alerts/alert-banner";
import { cn } from "#/utils/utils";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -278,9 +279,11 @@ export default function MainApp() {
id="root-outlet"
className="flex-1 relative overflow-auto custom-scrollbar"
>
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>
<OnboardingGuard>
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>
</OnboardingGuard>
</div>
</div>

View File

@@ -20,6 +20,7 @@ import {
} from "#/utils/settings-utils";
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
import { useConfig } from "#/hooks/query/use-config";
import { useMe } from "#/hooks/query/use-me";
import { OrgWideSettingsBadge } from "#/components/features/settings/org-wide-settings-badge";
const SAAS_ONLY_PATHS = [
@@ -131,12 +132,15 @@ function SettingsScreen() {
const navItems = useSettingsNavItems();
const { data: config } = useConfig();
const { isTeamOrg } = useOrgTypeAndAccess();
const { data: me } = useMe();
// Determine if we should show the org-wide settings badge
// Only show for Admin/Owner roles on LLM/org-defaults pages in team orgs
const isOrgWideBadgePath = ORG_WIDE_BADGE_PATHS.has(location.pathname);
const isSaasMode = config?.app_mode === "saas";
const shouldShowOrgWideBadge = isOrgWideBadgePath && isTeamOrg && isSaasMode;
// Members see a read-only message; Admins/Owners see the org-wide notice.
const orgWideBadgeVariant =
me?.role === "member" ? "managed-by-admin" : "org-wide";
// Current section title for the main content area
const currentSectionTitle = useMemo(() => {
@@ -165,7 +169,9 @@ function SettingsScreen() {
{!shouldHideTitle && (
<div className="flex items-center gap-3 flex-wrap">
<Typography.H2>{t(currentSectionTitle)}</Typography.H2>
{shouldShowOrgWideBadge && <OrgWideSettingsBadge />}
{shouldShowOrgWideBadge && (
<OrgWideSettingsBadge variant={orgWideBadgeVariant} />
)}
</div>
)}
<div className="flex-1 overflow-auto custom-scrollbar-always">

View File

@@ -20,3 +20,4 @@ export const ENABLE_TRAJECTORY_REPLAY = () =>
export const ENABLE_SANDBOX_GROUPING = () =>
loadFeatureFlag("SANDBOX_GROUPING");
export const ENABLE_AUTOMATIONS = () => loadFeatureFlag("AUTOMATIONS");
export const ENABLE_ONBOARDING = () => loadFeatureFlag("ONBOARDING");