mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
6 Commits
feat/acp-i
...
cloud-1.23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21b04affe | ||
|
|
fc0b69dbf8 | ||
|
|
82492f1769 | ||
|
|
50907e1500 | ||
|
|
4ee63d5bd2 | ||
|
|
97b173979e |
@@ -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')
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
279
enterprise/tests/unit/test_migration_108_add_agent_settings.py
Normal file
279
enterprise/tests/unit/test_migration_108_add_agent_settings.py
Normal 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'
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
28
frontend/src/components/features/guards/onboarding-guard.tsx
Normal file
28
frontend/src/components/features/guards/onboarding-guard.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
21
frontend/src/hooks/query/use-onboarding-status.ts
Normal file
21
frontend/src/hooks/query/use-onboarding-status.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 dall’amministratore 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 l’administrateur 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": "これらの組織のデフォルト設定が最初に適用されます。メンバーは許可されている範囲で、自分の認証情報や個人設定を追加できます。",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user