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:
Tejas
2026-06-15 18:33:50 +05:30
committed by GitHub
25 changed files with 362 additions and 133 deletions
+3 -3
View File
@@ -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 |
+9 -9
View File
@@ -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", ...}`.
---
+8 -8
View File
@@ -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`
+6 -6
View File
@@ -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]:
+2 -2
View File
@@ -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)
+4 -3
View File
@@ -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"),
+23 -4
View File
@@ -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()
+34
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+6 -4
View File
@@ -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()
+4 -2
View File
@@ -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"])
+29
View File
@@ -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)
-10
View File
@@ -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
+40
View File
@@ -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
+1 -1
View File
@@ -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({
+2 -2
View File
@@ -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}
+10 -10
View File
@@ -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>
+116 -10
View File
@@ -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
View File
@@ -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",