mirror of
https://github.com/agentrhq/authsome.git
synced 2026-09-19 01:34:19 +08:00
Merge pull request #433 from agentrhq/feature/expand-settings-page
refactor: rename identity to agent throughout CLI and UI for improved terminology consistency
This commit is contained in:
@@ -73,9 +73,9 @@ Docs say default: `~/.authsome/logs/authsome.log`.
|
||||
|
||||
---
|
||||
|
||||
### 1e. `profile` command exists but is not documented
|
||||
### 1e. Legacy `profile` command is removed
|
||||
|
||||
`authsome profile` with subcommands `create` and `use` appears in the CLI but is absent from `docs/site/reference/cli.mdx`.
|
||||
The local signing-key command surface is `authsome agent create` and `authsome agent use`.
|
||||
|
||||
---
|
||||
|
||||
@@ -224,7 +224,7 @@ If the behavior differs for bundled vs. custom providers, the `--help` text shou
|
||||
| P1 | Fix `--quiet` — stop suppressing data output | Medium |
|
||||
| P1 | Fix `--force` on `register` — imply `--yes` or document split | Small |
|
||||
| P1 | Add `connection set-default` subgroup (or alias to match docs) | Small |
|
||||
| P1 | Document `profile` command, `shell` export format, corrected `--log-file` path | Small |
|
||||
| P1 | Document `shell` export format and corrected `--log-file` path | Small |
|
||||
| P2 | Resolve `inspect` vs `get` overlap — pick a clear model | Medium |
|
||||
| P2 | Add human-readable default to `inspect` and `daemon status` | Medium |
|
||||
| P2 | Fix `daemon stop` — wait for actual stop before returning | Medium |
|
||||
|
||||
@@ -39,17 +39,17 @@ uv run authsome whoami
|
||||
**Expected (first run):** the command prints a claim URL to stderr, opens it in a
|
||||
browser, and blocks while polling:
|
||||
```
|
||||
Open this URL in your browser to claim this identity:
|
||||
Open this URL in your browser to claim this agent:
|
||||
http://127.0.0.1:7998/claim?token=claim_<token>
|
||||
```
|
||||
|
||||
**Human action:**
|
||||
1. The browser opens the claim page automatically (open the printed URL yourself if it doesn't, e.g. on a headless box).
|
||||
2. Register with an email + password — or log in if the account already exists. The first account on a fresh server becomes the **admin** Principal.
|
||||
3. Confirm that the displayed identity handle is yours.
|
||||
3. Confirm that the displayed agent handle is yours.
|
||||
4. The CLI unblocks and `whoami` prints your context. Subsequent commands reuse the accepted claim — no browser step.
|
||||
|
||||
**Expected (after claim):** a JSON object (`{"v": 1, ...}`) with key fields `authsome_version`, `home_directory`, `profile` (registered non-default identity handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `configured_encryption_mode`, `effective_encryption_source`, `encryption_backend`, `vault_status` (`OK`), `connected_providers_count` (`0`), `connected_providers` (`[]`), and `issues` (`[]`).
|
||||
**Expected (after claim):** a JSON object (`{"v": 1, ...}`) with key fields `authsome_version`, `home_directory`, `agent` (registered non-default agent handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `configured_encryption_mode`, `effective_encryption_source`, `encryption_backend`, `vault_status` (`OK`), `connected_providers_count` (`0`), `connected_providers` (`[]`), and `issues` (`[]`).
|
||||
|
||||
```bash
|
||||
uv run authsome doctor
|
||||
@@ -327,20 +327,20 @@ uv run authsome provider list # github connection gone
|
||||
|
||||
---
|
||||
|
||||
## 15. Profiles
|
||||
## 15. Agents
|
||||
|
||||
```bash
|
||||
uv run authsome profile create --handle work
|
||||
uv run authsome agent create --handle work
|
||||
```
|
||||
|
||||
**Expected:** `{"v": 1, "status": "created", "profile": "work", "did": "did:key:...", ...}`. A new local Ed25519 keypair; the next protected command for this profile triggers its own browser claim.
|
||||
**Expected:** `{"v": 1, "status": "created", "agent": "work", "did": "did:key:...", ...}`. A new local Ed25519 keypair; the next protected command for this agent triggers its own browser claim.
|
||||
|
||||
```bash
|
||||
uv run authsome profile use work
|
||||
uv run authsome whoami # profile reflects "work" (claim required on first use)
|
||||
uv run authsome agent use work
|
||||
uv run authsome whoami # agent reflects "work" (claim required on first use)
|
||||
```
|
||||
|
||||
**Expected:** `profile use` → `{"status": "active", "profile": "work", ...}`.
|
||||
**Expected:** `agent use` -> `{"status": "active", "agent": "work", ...}`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress
|
||||
| `connections` | Inspect and manage stored provider connections. |
|
||||
| `daemon` | Manage the local Authsome daemon. |
|
||||
| `doctor` | Run health checks on directory layout and encryption. |
|
||||
| `init` | Initialize local storage and register a fresh profile. |
|
||||
| `agent` | Manage local agents backed by signing keys. |
|
||||
| `init` | Initialize local storage and register a fresh agent. |
|
||||
| `log` | View structured audit entries or the raw client debug log. |
|
||||
| `login <provider>` | Authenticate with PROVIDER using the configured flow. |
|
||||
| `logout <provider>` | Log out of the specified PROVIDER connection. |
|
||||
| `profile` | Manage local profiles backed by identity keys. |
|
||||
| `provider` | Manage provider definitions and provider-level operations. |
|
||||
| `run -- <cmd>` | Run COMMAND as a subprocess injected with authentication credentials. |
|
||||
| `scan` | Scan env files and process env for provider API keys. |
|
||||
@@ -38,8 +38,8 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress
|
||||
### `init` / `whoami` / `doctor`
|
||||
|
||||
```bash
|
||||
authsome init # initialize local storage and register profile
|
||||
authsome whoami # show identity context and encryption mode
|
||||
authsome init # initialize local storage and register agent
|
||||
authsome whoami # show agent context and encryption mode
|
||||
authsome doctor # run health checks
|
||||
authsome doctor --json # structured output for monitoring
|
||||
```
|
||||
@@ -153,14 +153,14 @@ Sets the default connection for a provider. The proxy and library calls use the
|
||||
authsome connections set-default github work
|
||||
```
|
||||
|
||||
### `profile`
|
||||
### `agent`
|
||||
|
||||
```bash
|
||||
authsome profile create # create a new local profile keypair
|
||||
authsome profile use # switch the active local profile
|
||||
authsome agent create # create a new local agent keypair
|
||||
authsome agent use # switch the active local agent
|
||||
```
|
||||
|
||||
Profiles are backed by Ed25519 identity keys at `~/.authsome/identities/`. Each profile has its own credential namespace in the vault.
|
||||
Agents are backed by Ed25519 signing keys at `~/.authsome/identities/`. Credentials are scoped to the active vault, not to the agent key.
|
||||
|
||||
### `daemon`
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ def raise_for_error(response: httpx.Response) -> None:
|
||||
try:
|
||||
data = response.json()
|
||||
if response.status_code == status.HTTP_401_UNAUTHORIZED and data.get("detail") == "Unknown identity handle":
|
||||
raise err_mod.IdentityNotRegisteredError("current identity") from exc
|
||||
raise err_mod.IdentityNotRegisteredError("current agent") from exc
|
||||
error_name = data.get("error")
|
||||
message = data.get("message")
|
||||
if error_name and message:
|
||||
@@ -175,15 +175,15 @@ class AuthsomeApiClient:
|
||||
self._open_claim_url(claim_url)
|
||||
await self._poll_claim_completion(runtime.handle)
|
||||
elif reg_status == "rejected":
|
||||
raise RuntimeError(f"Identity '{runtime.handle}' claim was rejected by the server")
|
||||
raise RuntimeError(f"Agent '{runtime.handle}' claim was rejected by the server")
|
||||
|
||||
def _open_claim_url(self, claim_url: str) -> None:
|
||||
print(f"Open this URL in your browser to claim this identity:\n {claim_url}", file=sys.stderr)
|
||||
print(f"Open this URL in your browser to claim this agent:\n {claim_url}", file=sys.stderr)
|
||||
with suppress(Exception):
|
||||
webbrowser.open(claim_url)
|
||||
|
||||
async def _poll_claim_completion(self, handle: str, *, timeout_seconds: int = 300) -> None:
|
||||
print("Waiting for identity to be claimed...", file=sys.stderr)
|
||||
print("Waiting for agent to be claimed...", file=sys.stderr)
|
||||
deadline = asyncio.get_running_loop().time() + timeout_seconds
|
||||
while True:
|
||||
status = await self.get_identity_status(handle)
|
||||
@@ -191,9 +191,9 @@ class AuthsomeApiClient:
|
||||
if reg_status == "claimed":
|
||||
return
|
||||
if reg_status == "rejected":
|
||||
raise RuntimeError(f"Identity '{handle}' claim was rejected")
|
||||
raise RuntimeError(f"Agent '{handle}' claim was rejected")
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise TimeoutError(f"Timed out waiting for identity '{handle}' to be claimed")
|
||||
raise TimeoutError(f"Timed out waiting for agent '{handle}' to be claimed")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _get(self, path: str, *, protected: bool = True) -> dict[str, Any]:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""CLI command registration."""
|
||||
|
||||
import authsome.cli.commands.agent as agent_module
|
||||
import authsome.cli.commands.connections as connections_module
|
||||
import authsome.cli.commands.core as core_module
|
||||
import authsome.cli.commands.daemon as daemon_module
|
||||
import authsome.cli.commands.profile as profile_module
|
||||
import authsome.cli.commands.provider as provider_module
|
||||
|
||||
|
||||
@@ -19,5 +19,5 @@ def register_commands(cli) -> None:
|
||||
cli.add_command(core_module.log_cmd)
|
||||
cli.add_command(provider_module.provider)
|
||||
cli.add_command(connections_module.connections)
|
||||
cli.add_command(profile_module.profile)
|
||||
cli.add_command(agent_module.agent)
|
||||
cli.add_command(daemon_module.daemon)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Profile CLI commands."""
|
||||
"""Local agent CLI commands."""
|
||||
|
||||
import click
|
||||
|
||||
@@ -9,23 +9,19 @@ from authsome.cli.identity import RuntimeIdentity
|
||||
from authsome.config import get_authsome_config
|
||||
|
||||
|
||||
@click.group(name="profile")
|
||||
def profile() -> None:
|
||||
"""Manage local profiles backed by identity keys."""
|
||||
@click.group(name="agent")
|
||||
def agent() -> None:
|
||||
"""Manage local agents backed by signing keys."""
|
||||
|
||||
|
||||
@profile.command(name="create")
|
||||
@click.option("--handle", default=None, metavar="HANDLE", help="Create or reuse a specific local profile handle.")
|
||||
@auth_command
|
||||
async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None:
|
||||
"""Create a local profile keypair."""
|
||||
async def _create_agent(ctx_obj: ContextObj, handle: str | None) -> None:
|
||||
home = get_authsome_config().home
|
||||
identity = RuntimeIdentity.create(home, handle)
|
||||
|
||||
data = {
|
||||
"status": "created",
|
||||
"home": str(home),
|
||||
"profile": identity.handle,
|
||||
"agent": identity.handle,
|
||||
"did": identity.did,
|
||||
"registration_status": "local",
|
||||
"switched": True,
|
||||
@@ -33,18 +29,30 @@ async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None:
|
||||
ctx_obj.print_json(data)
|
||||
|
||||
|
||||
@profile.command(name="use")
|
||||
@click.argument("handle")
|
||||
@auth_command
|
||||
async def profile_use(ctx_obj: ContextObj, handle: str) -> None:
|
||||
"""Select the active local profile."""
|
||||
async def _use_agent(ctx_obj: ContextObj, handle: str) -> None:
|
||||
home = get_authsome_config().home
|
||||
identity = RuntimeIdentity.from_filesystem(home, handle)
|
||||
ClientConfig.load(home).model_copy(update={"active_identity": identity.handle}).save(home)
|
||||
|
||||
data = {
|
||||
"status": "active",
|
||||
"profile": identity.handle,
|
||||
"agent": identity.handle,
|
||||
"did": identity.did,
|
||||
}
|
||||
ctx_obj.print_json(data)
|
||||
|
||||
|
||||
@agent.command(name="create")
|
||||
@click.option("--handle", default=None, metavar="HANDLE", help="Create or reuse a specific local agent handle.")
|
||||
@auth_command
|
||||
async def agent_create(ctx_obj: ContextObj, handle: str | None) -> None:
|
||||
"""Create a local agent keypair."""
|
||||
await _create_agent(ctx_obj, handle)
|
||||
|
||||
|
||||
@agent.command(name="use")
|
||||
@click.argument("handle")
|
||||
@auth_command
|
||||
async def agent_use(ctx_obj: ContextObj, handle: str) -> None:
|
||||
"""Select the active local agent."""
|
||||
await _use_agent(ctx_obj, handle)
|
||||
@@ -269,7 +269,7 @@ async def run(ctx_obj: ContextObj, command: tuple[str]) -> None:
|
||||
@click.command()
|
||||
@auth_command
|
||||
async def init(ctx_obj: ContextObj) -> None:
|
||||
"""Initialize local storage and register a fresh profile."""
|
||||
"""Initialize local storage and register a fresh agent."""
|
||||
home = get_authsome_config().home
|
||||
RuntimeIdentity.ensure_local(home)
|
||||
|
||||
@@ -280,7 +280,7 @@ async def init(ctx_obj: ContextObj) -> None:
|
||||
data = {
|
||||
"status": "initialized",
|
||||
"home": str(home),
|
||||
"profile": identity.handle,
|
||||
"agent": identity.handle,
|
||||
"did": identity.did,
|
||||
"registration_status": "registered",
|
||||
"configured_encryption_mode": whoami_data.get("configured_encryption_mode"),
|
||||
@@ -319,10 +319,11 @@ async def whoami(ctx_obj: ContextObj) -> None:
|
||||
issues.append(f"connections: {exc}")
|
||||
vault_status = "ERROR"
|
||||
|
||||
agent = whoami_data.get("identity", whoami_data.get("active_identity"))
|
||||
data = {
|
||||
"authsome_version": whoami_data["version"],
|
||||
"home_directory": whoami_data["home"],
|
||||
"profile": whoami_data.get("identity", whoami_data.get("active_identity")),
|
||||
"agent": agent,
|
||||
"principal_id": whoami_data.get("principal_id"),
|
||||
"vault_id": whoami_data.get("vault_id"),
|
||||
"did": whoami_data.get("did"),
|
||||
|
||||
@@ -64,12 +64,31 @@ class AccountAuthService:
|
||||
principal = await self._principals.get_by_email(self._normalize_email(email))
|
||||
if principal is None or not principal.password_hash:
|
||||
raise ValueError("Invalid email or password")
|
||||
try:
|
||||
self._hasher.verify(principal.password_hash, password)
|
||||
except (VerificationError, VerifyMismatchError) as exc:
|
||||
raise ValueError("Invalid email or password") from exc
|
||||
self._verify_password(principal.password_hash, password, message="Invalid email or password")
|
||||
return self._sessions.create_browser_session(principal_id=principal.principal_id, email=principal.email)
|
||||
|
||||
async def change_password(
|
||||
self,
|
||||
*,
|
||||
principal_id: str,
|
||||
current_password: str,
|
||||
new_password: str,
|
||||
) -> PrincipalRecord:
|
||||
principal = await self._principals.get(principal_id)
|
||||
if principal is None or not principal.password_hash:
|
||||
raise ValueError("Invalid current password")
|
||||
self._verify_password(principal.password_hash, current_password, message="Invalid current password")
|
||||
self._validate_password(new_password)
|
||||
return await self._principals.update_password(principal_id, password_hash=self._hasher.hash(new_password))
|
||||
|
||||
def _verify_password(self, password_hash: str, password: str, *, message: str) -> None:
|
||||
try:
|
||||
self._hasher.verify(password_hash, password)
|
||||
except (VerificationError, VerifyMismatchError) as exc:
|
||||
raise ValueError(message) from exc
|
||||
except ValueError as exc:
|
||||
raise ValueError(message) from exc
|
||||
|
||||
@staticmethod
|
||||
def _normalize_email(email: str) -> str:
|
||||
normalized = email.strip().lower()
|
||||
|
||||
@@ -102,6 +102,11 @@ def _account_auth_next_url(value: Any) -> str:
|
||||
return next_url
|
||||
|
||||
|
||||
def _append_query(url: str, values: dict[str, str]) -> str:
|
||||
separator = "&" if "?" in url else "?"
|
||||
return f"{url}{separator}{urlencode(values)}"
|
||||
|
||||
|
||||
@router.post("/auth/providers/{provider_name}/connect", include_in_schema=False)
|
||||
async def connect_provider( # noqa: PLR0913
|
||||
provider_name: str,
|
||||
@@ -243,6 +248,35 @@ async def register_account(
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/auth/password", include_in_schema=False)
|
||||
async def change_account_password(request: Request) -> Response:
|
||||
await resolve_ui_request_identity(request)
|
||||
principal_id = getattr(request.state, "ui_principal_id", None)
|
||||
form = await request.form()
|
||||
next_url = _account_auth_next_url(form.get("next") or "/settings?tab=security")
|
||||
if not principal_id:
|
||||
return RedirectResponse(url=_account_auth_entry_url(next_url), status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
try:
|
||||
await request.app.state.account_auth_service.change_password(
|
||||
principal_id=principal_id,
|
||||
current_password=str(form.get("current_password", "")),
|
||||
new_password=str(form.get("new_password", "")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return RedirectResponse(
|
||||
url=_append_query(next_url, {"password_error": str(exc)}),
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
audit.emit_event("account.password_changed", principal_id=principal_id, status="success")
|
||||
capture_event(getattr(request.state, "ui_email", ""), "account_password_changed", {"principal_id": principal_id})
|
||||
return RedirectResponse(
|
||||
url=_append_query(next_url, {"password_changed": "1"}),
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/login", include_in_schema=False)
|
||||
async def login_account(
|
||||
request: Request,
|
||||
|
||||
@@ -216,7 +216,6 @@ class _StoreAuditExporter(LogRecordExporter):
|
||||
future = asyncio.run_coroutine_threadsafe(self._registry.insert_many(rows), self._loop)
|
||||
with self._lock:
|
||||
self._futures.append(future)
|
||||
future.result()
|
||||
except Exception as exc:
|
||||
logger.warning("Could not persist audit events: {}", exc)
|
||||
return LogRecordExportResult.FAILURE
|
||||
|
||||
@@ -244,7 +244,7 @@ async def test_unregistered_identity_registers_on_first_use(monkeypatch, tmp_pat
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrapped_identity_is_saved_as_active_profile(monkeypatch, tmp_path: Path) -> None:
|
||||
async def test_bootstrapped_identity_is_saved_as_active_agent(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
|
||||
captured: dict = {}
|
||||
|
||||
@@ -322,7 +322,7 @@ async def test_env_identity_private_key_without_handle_errors(monkeypatch, tmp_p
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_identity_does_not_update_active_profile(monkeypatch, tmp_path: Path) -> None:
|
||||
async def test_env_identity_does_not_update_active_agent(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
|
||||
stored = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
|
||||
ClientConfig(active_identity=stored.handle).save(tmp_path)
|
||||
|
||||
+24
-25
@@ -1,4 +1,4 @@
|
||||
"""Tests for `authsome profile` commands."""
|
||||
"""Tests for `authsome agent` commands."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -8,48 +8,47 @@ from authsome.cli.identity import RuntimeIdentity
|
||||
from authsome.cli.main import cli
|
||||
|
||||
|
||||
class TestProfileCommands:
|
||||
"""Tests for local profile management commands."""
|
||||
class TestAgentCommands:
|
||||
"""Tests for local agent management commands."""
|
||||
|
||||
def test_profile_create_writes_local_keypair(self, runner, mock_client, tmp_path: Path) -> None:
|
||||
def test_root_help_shows_agent_not_legacy_profile(self, runner) -> None:
|
||||
result = runner.invoke(cli, ["--log-file", "", "--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "agent" in result.output
|
||||
assert "profile" not in result.output
|
||||
|
||||
def test_profile_command_is_removed(self, runner) -> None:
|
||||
result = runner.invoke(cli, ["--log-file", "", "profile", "--help"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "No such command 'profile'" in result.output
|
||||
|
||||
def test_agent_create_writes_local_keypair(self, runner, mock_client, tmp_path: Path) -> None:
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"],
|
||||
["--log-file", "", "agent", "create", "--handle", "steady-wisely-boldly-0042"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(result.output)
|
||||
assert data["status"] == "created"
|
||||
assert data["profile"] == "steady-wisely-boldly-0042"
|
||||
assert data["agent"] == "steady-wisely-boldly-0042"
|
||||
assert data["switched"] is True
|
||||
stored = RuntimeIdentity.from_filesystem(tmp_path, "steady-wisely-boldly-0042")
|
||||
assert stored.did == data["did"]
|
||||
assert ClientConfig.load(tmp_path).active_identity == stored.handle
|
||||
|
||||
def test_profile_create_switches_active_profile(self, runner, mock_client, tmp_path: Path) -> None:
|
||||
runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"])
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--log-file", "", "profile", "create", "--handle", "rapid-brightly-firmly-0007"],
|
||||
)
|
||||
|
||||
data = json.loads(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert data["status"] == "created"
|
||||
assert data["profile"] == "rapid-brightly-firmly-0007"
|
||||
assert data["switched"] is True
|
||||
assert ClientConfig.load(tmp_path).active_identity == "rapid-brightly-firmly-0007"
|
||||
|
||||
def test_profile_use_sets_active_identity(self, runner, mock_client, tmp_path: Path) -> None:
|
||||
runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"])
|
||||
runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "rapid-brightly-firmly-0007"])
|
||||
def test_agent_use_sets_active_agent(self, runner, mock_client, tmp_path: Path) -> None:
|
||||
runner.invoke(cli, ["--log-file", "", "agent", "create", "--handle", "steady-wisely-boldly-0042"])
|
||||
runner.invoke(cli, ["--log-file", "", "agent", "create", "--handle", "rapid-brightly-firmly-0007"])
|
||||
stored = RuntimeIdentity.from_filesystem(tmp_path, "steady-wisely-boldly-0042")
|
||||
|
||||
result = runner.invoke(cli, ["--log-file", "", "profile", "use", "steady-wisely-boldly-0042"])
|
||||
result = runner.invoke(cli, ["--log-file", "", "agent", "use", "steady-wisely-boldly-0042"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(result.output)
|
||||
assert data["status"] == "active"
|
||||
assert data["profile"] == stored.handle
|
||||
assert data["agent"] == stored.handle
|
||||
assert data["did"] == stored.did
|
||||
assert ClientConfig.load(tmp_path).active_identity == stored.handle
|
||||
|
||||
@@ -26,7 +26,8 @@ def test_init_removes_legacy_default_state_and_registers_identity(
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(result.output)
|
||||
assert data["profile"] != "default"
|
||||
assert data["agent"] != "default"
|
||||
assert "profile" not in data
|
||||
assert data["registration_status"] == "registered"
|
||||
assert data["configured_encryption_mode"] == "auto"
|
||||
assert data["effective_encryption_source"] == "local_key"
|
||||
@@ -38,10 +39,10 @@ def test_init_removes_legacy_default_state_and_registers_identity(
|
||||
|
||||
config_data = ClientConfig.load(tmp_path)
|
||||
assert config_data.version == __version__
|
||||
assert config_data.active_identity == data["profile"]
|
||||
assert config_data.active_identity == data["agent"]
|
||||
|
||||
|
||||
def test_init_skips_registration_for_registered_active_profile(
|
||||
def test_init_skips_registration_for_registered_active_agent(
|
||||
runner,
|
||||
mock_client,
|
||||
tmp_path: Path,
|
||||
@@ -53,6 +54,7 @@ def test_init_skips_registration_for_registered_active_profile(
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(result.output)
|
||||
assert data["profile"] == identity.handle
|
||||
assert data["agent"] == identity.handle
|
||||
assert "profile" not in data
|
||||
assert data["configured_encryption_mode"] == "auto"
|
||||
mock_client.ensure_identity_ready.assert_called_once()
|
||||
|
||||
@@ -40,7 +40,8 @@ class TestWhoamiCommand:
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(result.output)
|
||||
assert data["authsome_version"] == "1.2.3"
|
||||
assert data["profile"] == "steady-wisely-boldly-0042"
|
||||
assert data["agent"] == "steady-wisely-boldly-0042"
|
||||
assert "profile" not in data
|
||||
assert data["principal_id"] == "principal_1"
|
||||
assert data["vault_id"] == "vault_default"
|
||||
assert data["vault_status"] == "OK"
|
||||
@@ -90,7 +91,8 @@ class TestWhoamiCommand:
|
||||
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["profile"] == "steady-wisely-boldly-0042"
|
||||
assert data["agent"] == "steady-wisely-boldly-0042"
|
||||
assert "profile" not in data
|
||||
assert data["vault_status"] == "ERROR"
|
||||
assert data["connected_providers_count"] == 0
|
||||
assert any("connections:" in issue for issue in data["issues"])
|
||||
|
||||
@@ -98,3 +98,32 @@ async def test_login_rejects_wrong_password(tmp_path: Path) -> None:
|
||||
await service.login(email="dev@example.com", password="wrong-password")
|
||||
finally:
|
||||
await _close(store)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password_requires_current_password_and_updates_login(tmp_path: Path) -> None:
|
||||
service, store = await _service(tmp_path)
|
||||
|
||||
try:
|
||||
principal = await service.register(email="dev@example.com", password="password-1")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid current password"):
|
||||
await service.change_password(
|
||||
principal_id=principal.principal_id,
|
||||
current_password="wrong-password",
|
||||
new_password="password-2",
|
||||
)
|
||||
|
||||
await service.change_password(
|
||||
principal_id=principal.principal_id,
|
||||
current_password="password-1",
|
||||
new_password="password-2",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid email or password"):
|
||||
await service.login(email="dev@example.com", password="password-1")
|
||||
|
||||
session = await service.login(email="dev@example.com", password="password-2")
|
||||
assert session.principal_id == principal.principal_id
|
||||
finally:
|
||||
await _close(store)
|
||||
|
||||
@@ -15,13 +15,3 @@ def test_root_health_alias_matches_api_health(monkeypatch, tmp_path) -> None:
|
||||
assert root.status_code == status.HTTP_200_OK
|
||||
assert root.json()["status"] == "ok"
|
||||
assert root.json()["version"] == api.json()["version"]
|
||||
|
||||
|
||||
def test_api_health_route_is_registered_once(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
|
||||
|
||||
with create_server_test_client() as client:
|
||||
openapi = client.get("/openapi.json")
|
||||
|
||||
assert openapi.status_code == status.HTTP_200_OK
|
||||
assert list(openapi.json()["paths"]).count("/api/health") == 1
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from fastapi import status
|
||||
|
||||
from tests.server.helpers import create_server_test_client
|
||||
|
||||
|
||||
def test_browser_session_can_change_account_password(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
|
||||
|
||||
with create_server_test_client() as client:
|
||||
registered = client.post(
|
||||
"/api/auth/register",
|
||||
data={"email": "dev@example.com", "password": "password-1", "next": "/settings?tab=security"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
response = client.post(
|
||||
"/api/auth/password",
|
||||
data={
|
||||
"current_password": "password-1",
|
||||
"new_password": "password-2",
|
||||
"next": "/settings?tab=security",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
client.post("/api/logout", follow_redirects=False)
|
||||
old_login = client.post(
|
||||
"/api/auth/login",
|
||||
data={"email": "dev@example.com", "password": "password-1", "next": "/"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
new_login = client.post(
|
||||
"/api/auth/login",
|
||||
data={"email": "dev@example.com", "password": "password-2", "next": "/"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert registered.status_code == status.HTTP_303_SEE_OTHER
|
||||
assert response.status_code == status.HTTP_303_SEE_OTHER
|
||||
assert response.headers["location"] == "/settings?tab=security&password_changed=1"
|
||||
assert old_login.headers["location"] == "/login?next=%2F&error=Invalid+email+or+password&tab=login"
|
||||
assert new_login.status_code == status.HTTP_303_SEE_OTHER
|
||||
@@ -17,7 +17,7 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Authsome Dashboard",
|
||||
description: "Local dashboard for Authsome identities, providers, and connections.",
|
||||
description: "Local dashboard for Authsome agents, providers, and connections.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -134,7 +134,7 @@ export function AuthsomeClaim({ token }: { token: string }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<AuthFlowShell
|
||||
description="Checking this identity claim."
|
||||
description="Checking this agent claim."
|
||||
title="Loading claim"
|
||||
/>
|
||||
);
|
||||
@@ -156,7 +156,7 @@ export function AuthsomeClaim({ token }: { token: string }) {
|
||||
return (
|
||||
<AuthFlowShell
|
||||
description={`Confirm that ${data.identity} should be linked to ${data.email || "this account"}.`}
|
||||
title="Claim identity"
|
||||
title="Claim agent"
|
||||
>
|
||||
<form action={`/api/claim/${encodeURIComponent(token)}/confirm`} method="post">
|
||||
<Button className="w-full" type="submit">
|
||||
|
||||
@@ -121,7 +121,7 @@ export function ConnectionDetailBody({
|
||||
<KeyValue label="Status" value={data.status} />
|
||||
<KeyValue label="Auth Type" value={data.auth_type} />
|
||||
<KeyValue label="Principal ID" value={data.principal_id || "-"} />
|
||||
<KeyValue label="Identity" value={data.identity || "-"} />
|
||||
<KeyValue label="Agent" value={data.identity || "-"} />
|
||||
<KeyValue label="Scopes" value={data.scopes.join(", ") || "-"} />
|
||||
<KeyValue label="Token Type" value={data.token_type || "-"} />
|
||||
<KeyValue label="Obtained" value={data.obtained_at || "-"} />
|
||||
|
||||
@@ -122,7 +122,7 @@ function GlobalConnectionsSection({
|
||||
<Card className="shadow-none border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle>Global Connections</CardTitle>
|
||||
<CardDescription>Deployment-wide fallback connections available to accepted identities.</CardDescription>
|
||||
<CardDescription>Deployment-wide fallback connections available to accepted agents.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{connections.length ? (
|
||||
|
||||
@@ -215,7 +215,7 @@ export function AppSidebar({
|
||||
</SidebarMenu>
|
||||
<SidebarSeparator />
|
||||
<div className="px-2 py-1">
|
||||
<div className="truncate text-sm font-medium">{data.account.email || data.account.identity}</div>
|
||||
<div className="truncate text-sm font-medium">{data.account.email || data.account.agent}</div>
|
||||
{data.account.roleLabel ? (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{data.account.roleLabel}</div>
|
||||
) : null}
|
||||
|
||||
@@ -45,23 +45,23 @@ export function DashboardView({ data }: { data: DashboardData }) {
|
||||
<div className="mb-4">
|
||||
<h2 className="text-base font-semibold">Agents</h2>
|
||||
</div>
|
||||
{data.identities.length ? (
|
||||
{data.agents.length ? (
|
||||
<div className="grid gap-2">
|
||||
{data.identities.map((identity) => (
|
||||
{data.agents.map((agent) => (
|
||||
<div
|
||||
className="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3"
|
||||
key={identity.handle}
|
||||
key={agent.handle}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<UserRound className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{identity.handle}</span>
|
||||
<span className="text-sm font-medium">{agent.handle}</span>
|
||||
</div>
|
||||
{identity.isActive ? <Badge variant="outline">Active</Badge> : null}
|
||||
{agent.isActive ? <Badge variant="outline">Active</Badge> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<PageEmptyState title="No identities found" />
|
||||
<PageEmptyState title="No agents found" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -102,7 +102,7 @@ export function AgentsView({ data }: { data: DashboardData }) {
|
||||
<SectionHeader description="Local Ed25519 key pairs (agents) claimed to this account." title="Agents" />
|
||||
<Card className="shadow-none border-border/50">
|
||||
<CardContent className="p-0">
|
||||
{data.identities.length ? (
|
||||
{data.agents.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -110,14 +110,14 @@ export function AgentsView({ data }: { data: DashboardData }) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.identities.map((identity) => (
|
||||
<TableRow key={identity.handle}>
|
||||
{data.agents.map((agent) => (
|
||||
<TableRow key={agent.handle}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<UserRound className="size-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="font-medium">{identity.handle}</span>
|
||||
<span className="font-medium">{agent.handle}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,22 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Settings, ShieldCheck, Users, Vault } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ExternalLink, Info, KeyRound, Settings, ShieldCheck, Users, Vault } from "lucide-react";
|
||||
|
||||
import { SectionHeader } from "@/components/dashboard/section-header";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { DashboardData } from "@/lib/authsome-api";
|
||||
|
||||
const SETTINGS_TABS = new Set(["account", "about", "security"]);
|
||||
|
||||
export function SettingsView({ data }: { data: DashboardData }) {
|
||||
const searchParams = useSearchParams();
|
||||
const requestedTab = searchParams.get("tab") || "account";
|
||||
const defaultTab = SETTINGS_TABS.has(requestedTab) ? requestedTab : "account";
|
||||
const passwordChanged = searchParams.get("password_changed") === "1";
|
||||
const passwordError = searchParams.get("password_error");
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<SectionHeader description="Local daemon and account context." title="Settings" />
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<SettingsAccountCard data={data} />
|
||||
<SettingsVaultCard data={data} />
|
||||
<SettingsDaemonCard data={data} />
|
||||
<SettingsSecurityCard data={data} />
|
||||
</div>
|
||||
<SectionHeader description="Account, runtime, and security context." title="Settings" />
|
||||
<Tabs className="gap-5" defaultValue={defaultTab}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-3 md:w-fit">
|
||||
<TabsTrigger value="account">General</TabsTrigger>
|
||||
<TabsTrigger value="about">About</TabsTrigger>
|
||||
<TabsTrigger value="security">Security</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="grid gap-4 lg:grid-cols-2" value="account">
|
||||
<SettingsAccountCard data={data} />
|
||||
<SettingsVaultCard data={data} />
|
||||
</TabsContent>
|
||||
<TabsContent className="grid gap-4 lg:grid-cols-2" value="about">
|
||||
<SettingsDaemonCard data={data} />
|
||||
<SettingsAboutCard />
|
||||
</TabsContent>
|
||||
<TabsContent className="grid gap-4 lg:grid-cols-2" value="security">
|
||||
<SettingsSecurityCard data={data} />
|
||||
<SettingsPasswordCard passwordChanged={passwordChanged} passwordError={passwordError} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,8 +97,41 @@ function SettingsDaemonCard({ data }: { data: DashboardData }) {
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<SettingsKeyValue label="Version" value={data.version} />
|
||||
<SettingsKeyValue label="Last Activity" value={data.lastActivity || "-"} />
|
||||
<SettingsKeyValue label="Active Identity" value={data.account.identity || "-"} />
|
||||
<SettingsKeyValue label="Latest Token Expiry" value={data.latestTokenExpiry || "-"} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsAboutCard() {
|
||||
return (
|
||||
<Card className="shadow-none border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
About
|
||||
</CardTitle>
|
||||
<CardDescription>Project resources and release references.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
className={buttonVariants({ size: "sm", variant: "outline" })}
|
||||
href="https://authsome.ai/docs"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Docs
|
||||
<ExternalLink />
|
||||
</Link>
|
||||
<Link
|
||||
className={buttonVariants({ size: "sm", variant: "outline" })}
|
||||
href="https://github.com/agentrhq/authsome/releases"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Releases
|
||||
<ExternalLink />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -97,6 +156,53 @@ function SettingsSecurityCard({ data }: { data: DashboardData }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPasswordCard({
|
||||
passwordChanged,
|
||||
passwordError,
|
||||
}: {
|
||||
passwordChanged: boolean;
|
||||
passwordError: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Card className="shadow-none border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
Password
|
||||
</CardTitle>
|
||||
<CardDescription>Hosted account credential.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action="/api/auth/password" className="grid gap-4" method="post">
|
||||
<input name="next" type="hidden" value="/settings?tab=security" />
|
||||
{passwordChanged ? (
|
||||
<div className="rounded-lg border border-emerald-800 bg-emerald-950/30 px-3 py-2 text-sm text-emerald-300">
|
||||
Password updated.
|
||||
</div>
|
||||
) : null}
|
||||
{passwordError ? (
|
||||
<div className="rounded-lg border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{passwordError}
|
||||
</div>
|
||||
) : null}
|
||||
<label className="grid gap-1 text-sm font-medium">
|
||||
Current password
|
||||
<Input autoComplete="current-password" name="current_password" required type="password" />
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm font-medium">
|
||||
New password
|
||||
<Input autoComplete="new-password" minLength={8} name="new_password" required type="password" />
|
||||
</label>
|
||||
<Button className="w-fit" size="sm" type="submit">
|
||||
<KeyRound />
|
||||
Change password
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsKeyValue({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
|
||||
+12
-12
@@ -34,7 +34,7 @@ export type GlobalConnectionRow = ConnectionRow & {
|
||||
accountLabel: string | null;
|
||||
};
|
||||
|
||||
export type IdentityRow = {
|
||||
export type AgentRow = {
|
||||
handle: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
@@ -70,15 +70,15 @@ export type DashboardData = {
|
||||
roleLabel: string | null;
|
||||
isAdmin: boolean;
|
||||
principalId: string | null;
|
||||
identity: string | null;
|
||||
agent: string | null;
|
||||
};
|
||||
stats: DashboardStats;
|
||||
lastActivity: string;
|
||||
latestTokenExpiry: string;
|
||||
providers: ProviderView[];
|
||||
connectedProviders: ProviderView[];
|
||||
connections: ConnectionRow[];
|
||||
globalConnections: GlobalConnectionRow[];
|
||||
identities: IdentityRow[];
|
||||
agents: AgentRow[];
|
||||
vault: {
|
||||
vaultId: string | null;
|
||||
handle: string;
|
||||
@@ -467,7 +467,7 @@ function formatRelative(value: string | null | undefined): string | null {
|
||||
return direction === "in" ? `in ${label}` : `${label} ago`;
|
||||
}
|
||||
|
||||
function lastActivity(data: ConnectionsResponse): string {
|
||||
function latestTokenExpiry(data: ConnectionsResponse): string {
|
||||
const latest = data.connections
|
||||
.flatMap((group) => group.connections)
|
||||
.map((connection) => connection.expires_at)
|
||||
@@ -549,10 +549,10 @@ export async function fetchDashboard(): Promise<DashboardData> {
|
||||
const connections = buildConnectionRows(connectionsData, providers);
|
||||
const globalConnections = buildGlobalConnectionRows(connectionsData);
|
||||
const connectedProviders = providers.filter((provider) => provider.status !== "available");
|
||||
const activeIdentity = whoami.identity || whoami.active_identity || null;
|
||||
const identityHandles = new Set(identitiesData.identities.map((identity) => identity.handle));
|
||||
if (activeIdentity) {
|
||||
identityHandles.add(activeIdentity);
|
||||
const activeAgent = whoami.identity || whoami.active_identity || null;
|
||||
const agentHandles = new Set(identitiesData.identities.map((identity) => identity.handle));
|
||||
if (activeAgent) {
|
||||
agentHandles.add(activeAgent);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -562,7 +562,7 @@ export async function fetchDashboard(): Promise<DashboardData> {
|
||||
roleLabel: roleLabel(whoami.principal_role),
|
||||
isAdmin,
|
||||
principalId: whoami.principal_id || null,
|
||||
identity: activeIdentity,
|
||||
agent: activeAgent,
|
||||
},
|
||||
stats: {
|
||||
connected: connectedProviders.length,
|
||||
@@ -570,12 +570,12 @@ export async function fetchDashboard(): Promise<DashboardData> {
|
||||
oauth: connectedProviders.filter((provider) => provider.authType === "oauth2").length,
|
||||
apiKey: connectedProviders.filter((provider) => provider.authType === "api_key").length,
|
||||
},
|
||||
lastActivity: lastActivity(connectionsData),
|
||||
latestTokenExpiry: latestTokenExpiry(connectionsData),
|
||||
providers,
|
||||
connectedProviders: connectedProviders.slice(0, 6),
|
||||
connections,
|
||||
globalConnections,
|
||||
identities: Array.from(identityHandles, (handle) => ({ handle, isActive: handle === activeIdentity })),
|
||||
agents: Array.from(agentHandles, (handle) => ({ handle, isActive: handle === activeAgent })),
|
||||
vault: {
|
||||
vaultId: whoami.vault_id || null,
|
||||
handle: "default",
|
||||
|
||||
Reference in New Issue
Block a user