diff --git a/CONTEXT.md b/CONTEXT.md index 50d7899..ba657f8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -200,7 +200,7 @@ _Avoid_: vault_name, vault_handle **VaultHandle**: Human-readable name for a Vault (e.g., `default`). Used in UIs and CLI; the VaultId is authoritative in storage. -**IdentityClaimRecord**: Binding from an Identity (Handle) to a Principal (PrincipalId) with a `ClaimStatus`. Created during `authsome init --email`. Vault access is gated until the claim is accepted. +**IdentityClaimRecord**: Binding from an Identity (Handle) to a Principal (PrincipalId) with a `ClaimStatus`. Created when an authenticated Principal confirms the browser claim that `authsome init` initiates. Vault access is gated until the claim is accepted. _Avoid_: Claim, IdentityRegistration (as claim), join request **ClaimStatus**: Lifecycle state: `pending` → `accepted` | `rejected`. @@ -209,9 +209,7 @@ _Avoid_: Claim, IdentityRegistration (as claim), join request ## Initialization & Claim Flow -**Local mode**: `authsome init` creates an Identity, auto-accepts its claim under the implicit local Principal, and creates the default Vault. No email required. - -**Hosted mode**: `authsome init --email manoj@example.com` creates an Identity, creates or finds the Principal by email, and registers an `IdentityClaimRecord` with `claim_status = pending`. A human reviews the claim in the UI and accepts or rejects it. All vault operations return `403` until the claim is accepted. +There is a single flow for every deployment — no deployment mode (see ADR 0007). `authsome init` creates an Identity and registers it; the daemon returns `registration_status = "claim_required"` with a browser **claim URL**. The user opens the URL and registers (or logs in) with **email + password**: the first Principal created on a server becomes `admin`, all subsequent Principals are `user`. The authenticated Principal then confirms the claim, which binds the Identity to the Principal and creates the Principal's default Vault. Until the claim is `accepted`, all vault operations return `403`. The CLI opens the claim URL automatically and polls for completion (and prints the URL to stderr for headless use). --- diff --git a/docs/adr/0006-principal-roles-admin-user.md b/docs/adr/0006-principal-roles-admin-user.md index 16f8d65..36f1625 100644 --- a/docs/adr/0006-principal-roles-admin-user.md +++ b/docs/adr/0006-principal-roles-admin-user.md @@ -8,7 +8,7 @@ Principals need an authorization tier to gate deployment-level operations (audit **Separate `principal_roles` table** — considered for future extensibility (multiple roles per principal). Rejected as premature: the role model is binary and a join adds complexity without benefit today. -**Default admin account created at server init** — would require deciding on credentials before any real user exists. Rejected in favour of first-user-becomes-admin, which is zero-config and correct for both local and hosted deployments. +**Default admin account created at server init** — would require deciding on credentials before any real user exists. Rejected in favour of first-user-becomes-admin: the first principal to register is admin, all subsequent principals are users. (Originally justified as "zero-config" for an implicit local principal; that implicit-principal path was later removed when local and hosted were unified into a single registration + claim flow — see ADR 0007. First-principal-is-admin remains the rule, now reached the same way in every deployment.) ## Consequences diff --git a/docs/adr/0007-unified-deployment-flow.md b/docs/adr/0007-unified-deployment-flow.md new file mode 100644 index 0000000..a1fe4a8 --- /dev/null +++ b/docs/adr/0007-unified-deployment-flow.md @@ -0,0 +1,36 @@ +# Unified deployment flow: one registration + claim path for every deployment + +The daemon previously branched on a deployment mode (`AUTHSOME_DEPLOYMENT_MODE`, defaulting to `local`) selected at startup, and ran two parallel implementations of nearly every ownership concept: + +- **Local**: every Identity collapsed onto one synthetic Principal (`local@authsome.internal`); no claim was required; the Ed25519 PoP key was the only credential. Implemented by `LocalOwnershipResolver` and `LocalIdentityBootstrapService`. +- **Hosted**: each account was its own Principal authenticated by email+password in the browser; an Identity had to be explicitly claimed and accepted. Implemented by `HostedOwnershipResolver` and `HostedIdentityBootstrapService`. + +Two code paths doubled the test surface, scattered `if get_deployment_mode() == "hosted"` branches across `dependencies.py`, `routes/_deps.py`, `routes/ui.py`, `routes/health.py`, and `credential_service.py`, and — most importantly — meant "local and hosted behave the same" was asserted in prose but never enforced in code, so the two paths were free to drift. The synthetic local Principal also had a latent wart: a second local Identity silently inherited the *admin* Principal and its Vault, which the hosted model would never allow. + +## Decision + +Collapse to a single flow, identical for every deployment. There is no deployment mode. + +1. `authsome init` generates the Ed25519 Identity and registers it; the daemon returns `registration_status = "claim_required"` with a browser **claim URL**. +2. The user opens the claim URL and **registers (or logs in) with email + password**. The first Principal created on a server becomes **admin** (ADR 0006); all subsequent Principals are users. +3. The authenticated Principal **confirms the claim**, binding the Identity to the Principal and creating the Principal's default Vault. +4. PoP-authenticated calls are then authorized; `OwnershipResolver.resolve` requires an *accepted* claim. + +The one irreducible difference between a single-machine and a networked deployment — *who authenticates the claiming Principal* — is resolved by always requiring the email+password browser step. Local is no longer special-cased; the local user is simply the first to sign up and therefore the admin. + +The CLI (`AuthsomeApiClient.ensure_identity_ready`) was already mode-agnostic: it reacts to `claim_required` by opening the browser and polling, so it required no behavioural change. It now prints the claim URL to stderr so headless users can complete the step manually. + +This supersedes the local/hosted split described in ADR 0003 (§ "local mode … hosted mode") and the "zero-config local" rationale in ADR 0006. + +## Considered alternatives + +**Single resolver with an injected claim-acceptance policy** (local auto-accepts, hosted requires a human). Keeps one code path while preserving local's zero-config experience. Rejected by explicit product decision: we want local and hosted to be byte-for-byte identical, with no second path or policy seam to maintain. + +**Keep two resolvers, only harden with tests + docs.** Smallest change, but leaves the drift risk and duplicate test surface in place. Rejected. + +## Consequences + +- `AUTHSOME_DEPLOYMENT_MODE`, `get_deployment_mode()`, `LOCAL_PRINCIPAL_EMAIL`, the `Local*`/`Hosted*` resolver and bootstrap classes, and the `AuthService(deployment_mode=...)` parameter are all removed. `OwnershipResolver` and `IdentityBootstrapService` are single concrete classes. +- Admin enforcement is purely role-based: `_ensure_admin_operation_allowed` / `_ensure_provider_client_mutation_allowed` raise for any non-admin Principal, in every deployment (previously non-admins were implicitly allowed in local mode because the sole local Principal was always admin). +- The server-rendered UI always requires a hosted browser session; the local filesystem-identity UI path is gone. `HealthResponse.mode` is removed. +- **Breaking change.** Existing local installs have an Identity registered with no accepted claim and data under the `local@authsome.internal` Principal/Vault. After upgrading, those Identities are rejected until the user registers a Principal (email+password) and claims the Identity. A migration that rebinds the existing local Vault to a freshly registered Principal is possible but is intentionally out of scope here. diff --git a/docs/internal/manual-testing.md b/docs/internal/manual-testing.md index c0160da..32ee3eb 100644 --- a/docs/internal/manual-testing.md +++ b/docs/internal/manual-testing.md @@ -1,6 +1,8 @@ # Manual Testing Guide -This guide walks through the full CLI surface. Run these after any significant change to verify that commands, flows, and output modes work end-to-end. +This guide walks through the full CLI surface. Run these after any significant change to verify that commands, flows, and output work end-to-end. + +> **Output is always JSON.** Every command prints a JSON object wrapped as `{"v": 1, ...}` to stdout. There is no `--json` flag and no human-readable table mode. `--quiet` suppresses non-essential stderr messages only; it does not change the JSON on stdout. ## Prerequisites @@ -13,7 +15,12 @@ uv run authsome --version --- -## 1. Initialization +## 1. Initialization & First-Run Claim + +There is a single flow for every deployment (see ADR 0007): the first protected +command registers your Identity and then **blocks until you claim it in the +browser** with an email + password account. The first account created on a fresh +server becomes the **admin** Principal; every later account is a regular user. ```bash # Kill daemon and start fresh (optional — skip to keep existing config) @@ -22,25 +29,28 @@ kill $(lsof -ti :7998) 2>/dev/null; rm -rf ~/.authsome uv run authsome whoami ``` -**Expected:** Home directory, registered non-default identity handle, principal ID, vault ID, DID, encryption mode, daemon URL, and connected provider count (0). - -```bash -uv run authsome whoami --json +**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 register and claim this identity: + http://127.0.0.1:7998/claim/claim_ ``` -**Expected:** Same data as structured JSON. Key fields: `home_directory`, `profile`, `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `encryption_backend`, `connected_providers_count`. +**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. +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 `home_directory`, `profile` (registered non-default identity handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `encryption_backend`, `vault_status` (`OK`), and `connected_providers_count` (`0`). ```bash uv run authsome doctor ``` -**Expected:** Exit code `0`; `OK` printed for `spec_version`, `identity`, `providers`, `connections`, `vault`, `integrity`. A `Warnings:` block may appear (e.g. "no active provider connections found") — that is normal on a fresh install. - -```bash -uv run authsome doctor --json -``` +**Expected:** Exit code `0`; JSON `{"v": 1, "status": "ready", "checks": {"spec_version": "ok", "store": "ok", "identity": "ok", "providers": "ok", "connections": "ok", "vault": "ok", "integrity": "ok"}, "issues": [], "warnings": [...]}`. The `warnings` array is non-empty on a fresh install with no connections (e.g. "no active provider connections found"). A non-`ready` status exits `1`. -**Expected:** `{"status": "ready", "checks": {"spec_version": "ok", ...}, "issues": [], "warnings": [...]}`. The `warnings` array is non-empty on a fresh install with no connections. +> **Tip:** `authsome init` performs the same register + claim flow explicitly and prints an `{"status": "initialized", ...}` payload. --- @@ -52,22 +62,18 @@ uv run authsome doctor --json uv run authsome login resend ``` -**Expected:** Terminal prints a session URL and returns immediately: -``` -Visit: http://127.0.0.1:7998/auth/sessions//input -Login process started. The connection will be updated automatically once complete. -``` +**Expected:** JSON with `status: "started"`, `provider: "resend"`, `connection: "default"`, a `session_id`, and an `auth_url` pointing at the daemon input page. The URL opens in a browser automatically. **Human action:** -1. Open the printed URL in a browser (it opens automatically if a browser is available) +1. Open the printed `auth_url` in a browser (it opens automatically if a browser is available) 2. Paste your Resend API key into the field and click **Submit** 3. The browser confirms success ```bash -uv run authsome list +uv run authsome provider list ``` -**Expected:** `resend` listed with status `connected`. +**Expected:** `resend` appears under `bundled` with a non-empty `connections` array whose entry shows `status: "connected"`. --- @@ -79,23 +85,19 @@ uv run authsome list uv run authsome login github ``` -**Expected:** Terminal prints a session URL and returns immediately: -``` -Visit: http://127.0.0.1:7998/auth/sessions//input -Login process started. The connection will be updated automatically once complete. -``` +**Expected:** JSON with `status: "started"`, a `session_id`, and an `auth_url`. The URL opens in a browser automatically. **Human action:** -1. Open the printed URL in a browser +1. Open the printed `auth_url` in a browser 2. Optionally enter your GitHub OAuth App Client ID and Secret (leave blank for the public flow) 3. Click **Continue** — the browser redirects to `https://github.com/login/oauth/authorize?...` 4. Click **Authorize** on GitHub; the daemon captures the callback ```bash -uv run authsome list +uv run authsome provider list ``` -**Expected:** `github` listed with status `connected`. +**Expected:** `github` shows a connection with `status: "connected"`. --- @@ -107,111 +109,77 @@ uv run authsome list uv run authsome login github --flow device_code ``` -**Expected:** Terminal prints a session URL and returns immediately. +**Expected:** JSON with `status: "started"`, a `session_id`, and an `auth_url`. **Human action:** -1. Open the printed URL in a browser +1. Open the printed `auth_url` in a browser 2. Leave **Client ID** blank; click **Continue** 3. The page shows a verification URL and a user code 4. Open the verification URL, enter the user code, and authorize on GitHub ```bash -uv run authsome list +uv run authsome provider list ``` -**Expected:** `github` listed with status `connected`. +**Expected:** `github` shows a connection with `status: "connected"`. --- -## 5. List - -```bash -uv run authsome list -``` - -**Expected:** Table of all providers with columns `Provider`, `Source`, `Auth`, `Connection`, `Status`, `Expires`. Connected ones show connection name and status. Header line shows total and connected counts. +## 5. Provider List ```bash -uv run authsome list --json +uv run authsome provider list ``` -**Expected:** JSON with `bundled` and `custom` arrays; each connected provider has a non-empty `connections` array. +**Expected:** JSON with `bundled` and `custom` arrays. Each provider entry has `name`, `display_name`, `auth_type`, `source`, and a `connections` array; connected providers have a non-empty `connections` array with `connection_name`, `is_default`, `status`, and (for OAuth) `scopes`/`expires_at`. --- -## 6. Get +## 6. Connection Inspect ```bash -uv run authsome get github +uv run authsome connections inspect github ``` -**Expected:** Connection metadata; sensitive fields show `***REDACTED***`. +**Expected:** The connection record as JSON with sensitive fields redacted (`***REDACTED***`). There is no flag to reveal secrets via the CLI; inspect is always redacted. ```bash -uv run authsome get github --show-secret +uv run authsome connections inspect github --field status ``` -**Expected:** Actual token value printed; warning about shell history printed to stderr. +**Expected:** `{"v": 1, "status": "connected"}`. ```bash -uv run authsome get github --field status +uv run authsome connections inspect github --field scopes ``` -**Expected:** Prints `connected`. +**Expected:** `{"v": 1, "scopes": [...]}` — the granted scope list. ```bash -uv run authsome get github --field scopes +uv run authsome connections inspect github --connection default ``` -**Expected:** Prints the scope list. - -```bash -uv run authsome get github --json -``` - -**Expected:** Connection record as JSON. +**Expected:** Same record, scoped to the named connection. An unknown `--field` returns `{"error": "FieldNotFound", ...}` and exits `1`. --- -## 7. Inspect - -```bash -uv run authsome inspect github -``` - -**Expected:** Full provider definition (URLs, flow config, scopes) as JSON; `connections` array shows active connections. - -```bash -uv run authsome inspect resend -``` - -**Expected:** Provider definition with `api_key` config block and `connections` array. - ---- - -## 8. Export - -```bash -uv run authsome export github --format env -``` - -**Expected:** `GITHUB_ACCESS_TOKEN=` printed; warning about shell history. +## 7. Provider Inspect ```bash -uv run authsome export github --format shell +uv run authsome provider inspect github ``` -**Expected:** `export GITHUB_ACCESS_TOKEN=`. +**Expected:** Full provider definition (URLs, flow config, scopes) as JSON; a `connections` array lists active connections. ```bash -uv run authsome export github --format json +uv run authsome provider inspect resend ``` -**Expected:** `{"GITHUB_ACCESS_TOKEN": ""}`. +**Expected:** Provider definition with an `api_key` config block and a `connections` array. --- -## 9. Proxy Run +## 8. Proxy Run **Prerequisite:** `github` must be connected (complete §3 first). @@ -220,49 +188,43 @@ uv run authsome export github --format json uv run authsome run --quiet curl -s https://api.github.com/user ``` -**Expected:** JSON response from GitHub containing a `login` field with your GitHub username. No proxy log noise (suppressed by `--quiet`). +**Expected:** JSON response from GitHub containing a `login` field with your GitHub username. No proxy log noise (suppressed by `--quiet`). The subprocess exit code is propagated. --- -## 10. Log +## 9. Log ```bash uv run authsome log ``` -**Expected:** Human-readable table of recent audit entries with columns `Timestamp`, `Event`, `Provider`, `Status`. Shows "No audit entries found." if empty. +**Expected:** JSON with `v`, `log_file` path, and an `entries` array of parsed audit event objects (each with `timestamp`, `event`, `provider`, `status`). Empty `entries` on a fresh install. ```bash uv run authsome log -n 5 ``` -**Expected:** Last 5 entries only (same table format). - -```bash -uv run authsome log -n 5 --json -``` - -**Expected:** JSON object with `v`, `log_file` path, and `entries` array of parsed audit event objects, each with `timestamp`, `event`, `provider`, `status`. +**Expected:** Same shape, limited to the last 5 audit entries. ```bash uv run authsome log --raw -n 10 ``` -**Expected:** Last 10 lines of the raw client debug log (loguru format). +**Expected:** JSON with `log_file` and an `entries` array containing the last 10 raw client debug log lines (loguru format). --- -## 11. Connection Management +## 10. Connection Management ```bash -uv run authsome set-default github default +uv run authsome connections set-default github default ``` -**Expected:** Confirmation that `default` is now the default connection for `github`. +**Expected:** `{"v": 1, "status": "ok", "provider": "github", "default_connection": "default"}`. --- -## 12. Custom Provider Registration +## 11. Custom Provider Registration ```bash cat > /tmp/test-provider.json << 'EOF' @@ -277,113 +239,157 @@ cat > /tmp/test-provider.json << 'EOF' } EOF -uv run authsome register /tmp/test-provider.json +uv run authsome provider register /tmp/test-provider.json ``` -**Expected:** Confirmation prompt → provider registered. No `api_url` means no reachability check. +**Expected:** `{"v": 1, "status": "registered", "provider": "test-custom", "warnings": [...]}`. No `api_url` means no reachability warning. Registering a provider requires the **admin** Principal (the first/only account in a fresh install is admin). ```bash -uv run authsome inspect test-custom +uv run authsome provider inspect test-custom ``` **Expected:** Provider definition printed as JSON; `connections` is empty. ```bash -uv run authsome list | grep test-custom +uv run authsome provider list # then look for the test-custom entry under "custom" ``` -**Expected:** Listed under `custom` source, `not_connected`. +**Expected:** `test-custom` appears in the `custom` array with an empty `connections` array. ```bash -# Register again to test --force (overwrites without prompting) -uv run authsome register /tmp/test-provider.json --force +# Register again with --force to overwrite, and --yes to skip the confirmation prompt +uv run authsome provider register /tmp/test-provider.json --force --yes ``` -**Expected:** Registers immediately, no confirmation prompt, no error. +**Expected:** Re-registers without error; `status: "registered"`. ```bash -uv run authsome remove test-custom +uv run authsome provider remove test-custom ``` -**Expected:** `Removed provider test-custom.` +**Expected:** `{"v": 1, "status": "removed", "provider": "test-custom"}`. ```bash -uv run authsome list | grep test-custom +uv run authsome provider list # confirm test-custom is gone from "custom" ``` -**Expected:** No output (provider gone). +**Expected:** No `test-custom` entry. --- -## 13. Logout and Revoke +## 12. Logout and Revoke ```bash -# Logout removes local record only +# Logout removes the local connection record only uv run authsome logout github -uv run authsome list # github → not_connected +uv run authsome provider list # github connection gone # Re-login uv run authsome login github -# Revoke removes local record and calls provider revocation endpoint -uv run authsome revoke github -uv run authsome list # github → not_connected +# Revoke deletes all stored connections/secrets for the provider (admin only) +uv run authsome provider revoke github +uv run authsome provider list # github connection gone +``` + +**Expected:** `logout` → `{"status": "logged_out", ...}`; `revoke` → `{"status": "revoked", "provider": "github"}`. A non-admin Principal is rejected with an `OperationNotAllowedError`. + +--- + +## 13. Scan (env import) + +```bash +# Report drift between env vars and stored connections (does not modify state) +uv run authsome scan ``` +**Expected:** JSON with `connection`, `import: false`, `configured_count`, `imported_count: 0`, and a `results` array describing per-provider drift status (`env_only`, `authsome_only`, `env_and_authsome_match`, `both_missing`, etc.). `scan` rejects `--quiet`. + +```bash +# Import detected API keys from env without prompting +uv run authsome scan --import +``` + +**Expected:** `import: true` with `imported_count` reflecting newly imported keys; matching keys are reported as `skipped_unchanged`. + --- -## 14. Daemon +## 14. Profiles + +```bash +uv run authsome profile 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. + +```bash +uv run authsome profile use work +uv run authsome whoami # profile reflects "work" (claim required on first use) +``` + +**Expected:** `profile use` → `{"status": "active", "profile": "work", ...}`. + +--- + +## 15. Daemon ```bash uv run authsome daemon status ``` -**Expected:** JSON showing `running: true`, health checks all `ok`, PID, and log file path. The `health` block includes `version`, `mode`, `encryption_backend`. +**Expected:** JSON showing `running: true`, health checks all `ok`, PID, and log file path. The `health` block includes `version` and `encryption_backend`. ```bash uv run authsome daemon stop -uv run authsome daemon status +uv run authsome daemon status # running: false ``` -**Expected:** "Daemon stopped successfully"; `running: false` after stop. +**Expected:** `{"status": "stopped", "message": "..."}`; `running: false` after stop. > **Note:** If no PID record exists (e.g. after `rm -rf ~/.authsome`), `daemon stop` falls back to finding the process by port and kills it. ```bash uv run authsome daemon start -uv run authsome daemon status +uv run authsome daemon status # running: true +``` + +**Expected:** `{"status": "started", ...}`; `running: true` after start. + +```bash +uv run authsome daemon restart +uv run authsome daemon logs -n 20 ``` -**Expected:** "Daemon started successfully"; `running: true` after start. +**Expected:** `restart` → `{"status": "restarted", ...}`; `logs` → JSON with `log_file` and the last 20 daemon log lines. --- -## 15. Global Flags +## 16. Global Flags ```bash -# Quiet: suppress informational output -uv run authsome --quiet list +# Quiet: suppress non-essential stderr messages (JSON stdout unchanged) +uv run authsome --quiet provider list ``` -**Expected:** Provider table only; no "Providers: N total" banner. +**Expected:** The same provider JSON on stdout; informational/stderr chatter suppressed. ```bash -# No color: plain text output -uv run authsome --no-color list +# No color: disable ANSI colors +uv run authsome --no-color provider list ``` -**Expected:** Same table without ANSI color codes. +**Expected:** Same JSON without ANSI color codes. ```bash -# Verbose: debug logging to stderr -uv run authsome --verbose get github +# Verbose: DEBUG logging to stderr +uv run authsome --verbose connections inspect github ``` -**Expected:** DEBUG log lines on stderr in addition to normal stdout output. +**Expected:** DEBUG log lines on stderr in addition to the normal JSON stdout. --- -## 16. Error Handling +## 17. Error Handling ```bash # Non-existent provider @@ -393,7 +399,7 @@ uv run authsome login doesnotexist 2>&1; echo "exit: $?" **Expected:** `ProviderNotFoundError`, exit code `4`. ```bash -uv run authsome inspect doesnotexist 2>&1; echo "exit: $?" +uv run authsome provider inspect doesnotexist 2>&1; echo "exit: $?" ``` **Expected:** `ProviderNotFoundError`, exit code `4`. @@ -406,15 +412,15 @@ uv run authsome logout doesnotexist 2>&1; echo "exit: $?" ```bash # Missing required argument -uv run authsome get 2>&1; echo "exit: $?" +uv run authsome connections inspect 2>&1; echo "exit: $?" ``` **Expected:** Usage error, exit code `2`. ```bash -# Get on a disconnected provider +# Inspect a disconnected provider uv run authsome logout resend -uv run authsome get resend 2>&1; echo "exit: $?" +uv run authsome connections inspect resend 2>&1; echo "exit: $?" ``` **Expected:** `ConnectionNotFoundError`, exit code `3`. diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index d27e7a6..3b0ba0c 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -5,6 +5,7 @@ import asyncio import json import os +import sys import webbrowser from collections.abc import Mapping from pathlib import Path @@ -134,7 +135,12 @@ async def _proof_headers(self, method: str, path: str, body: bytes) -> dict[str, return {"Authorization": f"{POP_AUTH_SCHEME} {token}"} async def ensure_identity_ready(self) -> RuntimeIdentity: - """Ensure the acting identity is registered and, in hosted mode, claimed.""" + """Ensure the acting identity is registered and claimed by a principal. + + A freshly registered identity must be claimed by a principal before it + can make authenticated calls; the daemon returns a browser claim URL + which is opened here while we poll for completion. + """ runtime = self._runtime_identity() if runtime.source is IdentitySource.ENV: return await self._ensure_env_identity_ready(runtime) @@ -159,10 +165,7 @@ async def ensure_identity_ready(self) -> RuntimeIdentity: status = await self.register_identity(identity.handle, identity.did) claim_url = status.get("claim_url") if claim_url: - try: - webbrowser.open(claim_url) - except Exception: - pass + self._open_claim_url(claim_url) await self._poll_claim_completion(identity.handle) identity = mark_claimed(self._home, identity.handle) return self._runtime_for_handle(identity.handle) @@ -186,13 +189,21 @@ async def _ensure_env_identity_ready(self, identity: RuntimeIdentity) -> Runtime status = await self.register_identity(identity.handle, identity.did) claim_url = status.get("claim_url") if claim_url: - try: - webbrowser.open(claim_url) - except Exception: - pass + self._open_claim_url(claim_url) await self._poll_claim_completion(identity.handle) return identity + def _open_claim_url(self, claim_url: str) -> None: + """Surface the browser claim URL (so headless users can open it) and try to launch it.""" + print( + f"Open this URL in your browser to register and claim this identity:\n {claim_url}", + file=sys.stderr, + ) + try: + webbrowser.open(claim_url) + except Exception: + pass + async def _poll_claim_completion(self, handle: str, *, timeout_seconds: int = 300) -> dict[str, Any]: deadline = asyncio.get_running_loop().time() + timeout_seconds while True: diff --git a/src/authsome/server/credential_service.py b/src/authsome/server/credential_service.py index 895b1fc..c297cd7 100644 --- a/src/authsome/server/credential_service.py +++ b/src/authsome/server/credential_service.py @@ -82,14 +82,12 @@ def __init__( principal_id: str | None = None, principal_role: PrincipalRole = PrincipalRole.USER, vault_id: str | None = None, - deployment_mode: str = "local", ) -> None: self._vault = vault self._identity = identity self._principal_id = principal_id self._principal_role = principal_role self._vault_id = vault_id - self._deployment_mode = "hosted" if deployment_mode == "hosted" else "local" self._provider_definitions = provider_definitions self._bundled: dict[str, ProviderDefinition] = self._load_bundled_providers() @@ -201,22 +199,20 @@ async def remove_provider(self, name: str) -> bool: def _ensure_admin_operation_allowed(self, operation: str, provider: str) -> None: if self._principal_role == PrincipalRole.ADMIN: return - if self._deployment_mode == "hosted": - raise OperationNotAllowedError( - operation, - f"{operation} is not allowed in hosted deployments", - provider=provider, - ) + raise OperationNotAllowedError( + operation, + f"{operation} requires an admin principal", + provider=provider, + ) def _ensure_provider_client_mutation_allowed(self, provider: str) -> None: if self._principal_role == PrincipalRole.ADMIN: return - if self._deployment_mode == "hosted": - raise OperationNotAllowedError( - "login", - "provider client configuration is not allowed in hosted deployments", - provider=provider, - ) + raise OperationNotAllowedError( + "login", + "provider client configuration requires an admin principal", + provider=provider, + ) def _validate_provider(self, definition: ProviderDefinition) -> None: if not is_filesystem_safe(definition.name): @@ -765,7 +761,6 @@ async def revoke(self, provider: str, vault_ids: list[str] | None = None) -> Non principal_id=self._principal_id, principal_role=self._principal_role, vault_id=vault_id, - deployment_mode=self._deployment_mode, provider_definitions=self._provider_definitions, ) meta_key = build_store_key(vault=vault_id, provider=provider, record_type="metadata") diff --git a/src/authsome/server/dependencies.py b/src/authsome/server/dependencies.py index 011d1c2..e0ab7d5 100644 --- a/src/authsome/server/dependencies.py +++ b/src/authsome/server/dependencies.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from pathlib import Path from typing import TYPE_CHECKING, Any @@ -18,12 +17,8 @@ from authsome.paths import get_server_home as _get_server_home from authsome.paths import get_server_log_path as _get_server_log_path from authsome.server.hosted_auth import HostedAccountService -from authsome.server.identity_bootstrap import ( - HostedIdentityBootstrapService, - IdentityBootstrapService, - LocalIdentityBootstrapService, -) -from authsome.server.ownership import HostedOwnershipResolver, LocalOwnershipResolver, OwnershipResolver +from authsome.server.identity_bootstrap import IdentityBootstrapService +from authsome.server.ownership import OwnershipResolver from authsome.server.secrets import load_master_secret, load_ui_session_signing_secret from authsome.server.store import ServerStore from authsome.server.store import create_server_store as _create_server_store @@ -58,12 +53,6 @@ def get_server_base_url() -> str: return build_server_base_url() -def get_deployment_mode() -> str: - """Return the daemon deployment mode.""" - mode = os.environ.get("AUTHSOME_DEPLOYMENT_MODE", "local").strip().lower() - return "hosted" if mode == "hosted" else "local" - - async def get_local_ui_identity(home: Path | None = None) -> str: """Resolve the local active identity handle for the server-rendered UI.""" identity = await current_from_home(home or get_authsome_home()) @@ -120,7 +109,6 @@ async def create_auth_service( vault=vault, identity=identity, vault_id=vault_id, - deployment_mode=get_deployment_mode(), provider_definitions=store.provider_definitions, ) @@ -135,16 +123,10 @@ def create_hosted_account_service(store: ServerStore) -> HostedAccountService: def create_ownership_resolver(store: ServerStore) -> OwnershipResolver: - if get_deployment_mode() == "hosted": - return HostedOwnershipResolver( - principals=store.principals, - vaults=store.vaults, - claims=store.identity_claims, - bindings=store.principal_vault_bindings, - ) - return LocalOwnershipResolver( + return OwnershipResolver( principals=store.principals, vaults=store.vaults, + claims=store.identity_claims, bindings=store.principal_vault_bindings, ) @@ -156,11 +138,9 @@ def create_identity_bootstrap_service( store: ServerStore, server_base_url: str | None = None, ) -> IdentityBootstrapService: - if get_deployment_mode() == "hosted": - return HostedIdentityBootstrapService( - registry=identity_registry, - claims=store.identity_claims, - ui_sessions=ui_sessions, - server_base_url=server_base_url or get_server_base_url(), - ) - return LocalIdentityBootstrapService(registry=identity_registry) + return IdentityBootstrapService( + registry=identity_registry, + claims=store.identity_claims, + ui_sessions=ui_sessions, + server_base_url=server_base_url or get_server_base_url(), + ) diff --git a/src/authsome/server/identity_bootstrap.py b/src/authsome/server/identity_bootstrap.py index bf07418..f63f9f6 100644 --- a/src/authsome/server/identity_bootstrap.py +++ b/src/authsome/server/identity_bootstrap.py @@ -1,8 +1,7 @@ -"""Deployment-specific identity bootstrap behavior.""" +"""Identity bootstrap: register identities and report their claim readiness.""" from __future__ import annotations -from abc import ABC, abstractmethod from dataclasses import dataclass from authsome.identity.principal import ClaimStatus @@ -22,7 +21,7 @@ class IdentityBootstrapStatus: claim_url: str = "" def to_payload(self) -> dict[str, str]: - """Serialize a route payload without exposing deployment-specific logic.""" + """Serialize a route payload.""" payload = { "status": "registered", "identity": self.identity, @@ -36,40 +35,13 @@ def to_payload(self) -> dict[str, str]: return payload -class IdentityBootstrapService(ABC): - """Register identities and report their readiness state.""" - - def __init__(self, *, registry: IdentityRegistry) -> None: - self._registry = registry - - async def register_identity(self, *, handle: str, did: str) -> IdentityBootstrapStatus: - registration = await self._registry.register(handle=handle, did=did) - return await self._build_status(registration) - - async def get_identity_status(self, *, handle: str) -> IdentityBootstrapStatus | None: - registration = await self._registry.resolve(handle) - if registration is None: - return None - return await self._build_status(registration) - - @abstractmethod - async def _build_status(self, registration: IdentityRegistration) -> IdentityBootstrapStatus: - """Return a normalized readiness status for a registered identity.""" - - -class LocalIdentityBootstrapService(IdentityBootstrapService): - """Bootstrap service for local deployments with implicit ownership.""" - - async def _build_status(self, registration: IdentityRegistration) -> IdentityBootstrapStatus: - return IdentityBootstrapStatus( - identity=registration.handle, - did=registration.did, - registration_status="registered", - ) +class IdentityBootstrapService: + """Register identities and report their claim readiness state. - -class HostedIdentityBootstrapService(IdentityBootstrapService): - """Bootstrap service for hosted deployments requiring explicit claims.""" + Every identity must be claimed by an authenticated principal. A freshly + registered identity is reported as ``claim_required`` with a browser claim + URL; once a principal claims and accepts it the status becomes ``claimed``. + """ def __init__( self, @@ -79,11 +51,21 @@ def __init__( ui_sessions: UiSessionStore, server_base_url: str, ) -> None: - super().__init__(registry=registry) + self._registry = registry self._claims = claims self._ui_sessions = ui_sessions self._server_base_url = server_base_url.rstrip("/") + async def register_identity(self, *, handle: str, did: str) -> IdentityBootstrapStatus: + registration = await self._registry.register(handle=handle, did=did) + return await self._build_status(registration) + + async def get_identity_status(self, *, handle: str) -> IdentityBootstrapStatus | None: + registration = await self._registry.resolve(handle) + if registration is None: + return None + return await self._build_status(registration) + async def _build_status(self, registration: IdentityRegistration) -> IdentityBootstrapStatus: claim = await self._claims.resolve(registration.handle) if claim is None: diff --git a/src/authsome/server/ownership.py b/src/authsome/server/ownership.py index 64d550b..8d56030 100644 --- a/src/authsome/server/ownership.py +++ b/src/authsome/server/ownership.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import ABC, abstractmethod from dataclasses import dataclass from authsome.identity.principal import ClaimStatus, PrincipalRole @@ -13,8 +12,6 @@ VaultRegistry, ) -LOCAL_PRINCIPAL_EMAIL = "local@authsome.internal" - @dataclass(frozen=True) class ResolvedOwnership: @@ -26,18 +23,6 @@ class ResolvedOwnership: role: PrincipalRole -class OwnershipResolver(ABC): - """Resolve principal and vault context for an acting identity.""" - - @abstractmethod - async def resolve(self, *, identity: str) -> ResolvedOwnership: - """Resolve the principal and vault for an identity.""" - - async def claim_identity_for_principal(self, *, identity: str, principal_id: str) -> ResolvedOwnership: - """Claim an identity for an authenticated principal.""" - raise NotImplementedError - - async def ensure_principal_default_vault( *, principal_id: str, @@ -52,42 +37,14 @@ async def ensure_principal_default_vault( return binding.vault_id -class LocalOwnershipResolver(OwnershipResolver): - """Ownership resolver for local deployments with implicit ownership.""" - - def __init__( - self, - *, - principals: PrincipalRegistry, - vaults: VaultRegistry, - bindings: PrincipalVaultBindingRegistry, - ) -> None: - self._principals = principals - self._vaults = vaults - self._bindings = bindings - - async def resolve(self, *, identity: str) -> ResolvedOwnership: - principal = await self._principals.get_by_email(LOCAL_PRINCIPAL_EMAIL) - if principal is None: - principal = await self._principals.create_by_email(LOCAL_PRINCIPAL_EMAIL) - vault_id = await ensure_principal_default_vault( - principal_id=principal.principal_id, - vaults=self._vaults, - bindings=self._bindings, - ) - return ResolvedOwnership( - identity=identity, - principal_id=principal.principal_id, - vault_id=vault_id, - role=principal.role, - ) - - async def claim_identity_for_principal(self, *, identity: str, principal_id: str) -> ResolvedOwnership: - return await self.resolve(identity=identity) - +class OwnershipResolver: + """Resolve principal and vault context for an acting identity. -class HostedOwnershipResolver(OwnershipResolver): - """Ownership resolver for hosted deployments with explicit claims.""" + Every identity must be claimed by a principal and the claim accepted before + vault access is granted. This is the single resolution path for all + deployments — the claiming principal is authenticated out of band (browser + email+password), and the first principal created on a server is admin. + """ def __init__( self, @@ -120,6 +77,7 @@ async def resolve(self, *, identity: str) -> ResolvedOwnership: ) async def claim_identity_for_principal(self, *, identity: str, principal_id: str) -> ResolvedOwnership: + """Claim an identity for an authenticated principal and accept it.""" vault_id = await ensure_principal_default_vault( principal_id=principal_id, vaults=self._vaults, diff --git a/src/authsome/server/routes/_deps.py b/src/authsome/server/routes/_deps.py index 39b41a4..11b64d0 100644 --- a/src/authsome/server/routes/_deps.py +++ b/src/authsome/server/routes/_deps.py @@ -5,11 +5,9 @@ from fastapi import HTTPException, Request from authsome.auth.sessions import AuthSessionStore -from authsome.identity import current_from_home from authsome.identity.principal import PrincipalRole from authsome.identity.proof import POP_AUTH_SCHEME, ProofValidationError, validate_proof_jwt from authsome.server.credential_service import AuthService -from authsome.server.dependencies import get_deployment_mode from authsome.server.store.repositories import VaultRegistry from authsome.server.ui_sessions import UiSessionStore @@ -33,7 +31,6 @@ async def get_auth_service( principal_id=resolved.principal_id, principal_role=resolved.role, vault_id=resolved.vault_id, - deployment_mode=get_deployment_mode(), provider_definitions=request.app.state.provider_definition_repository, ) @@ -52,7 +49,6 @@ async def get_auth_service( principal_id=principal_id, principal_role=principal.role, vault_id=binding.vault_id, - deployment_mode=get_deployment_mode(), provider_definitions=request.app.state.provider_definition_repository, ) @@ -171,19 +167,7 @@ def get_ui_sessions(request: Request) -> UiSessionStore: async def resolve_ui_request_identity(request: Request) -> str | None: - """Resolve the identity bound to a browser UI request.""" - if get_deployment_mode() != "hosted": - identity = await current_from_home(request.app.state.store.home) - request.state.ui_identity = identity.handle - try: - resolved = await request.app.state.ownership_resolver.resolve(identity=identity.handle) - request.state.ui_principal_id = resolved.principal_id - request.state.ui_principal_role = resolved.role - except ValueError: - request.state.ui_principal_id = None - request.state.ui_principal_role = None - return identity.handle - + """Resolve the principal bound to a browser UI request via its session cookie.""" cookie_value = request.cookies.get(UI_SESSION_COOKIE_NAME) if not cookie_value: return None diff --git a/src/authsome/server/routes/health.py b/src/authsome/server/routes/health.py index eb6e1ad..d2872a0 100644 --- a/src/authsome/server/routes/health.py +++ b/src/authsome/server/routes/health.py @@ -2,13 +2,10 @@ from __future__ import annotations -from typing import Literal, cast - from fastapi import APIRouter, Depends, Request from authsome import __version__ from authsome.server.credential_service import AuthService -from authsome.server.dependencies import get_deployment_mode from authsome.server.routes._deps import get_protected_auth_service, get_server_base_url from authsome.server.schemas import HealthResponse, ReadyResponse from authsome.utils import connection_is_active @@ -26,13 +23,10 @@ def _describe_vault_encryption(vault) -> tuple[str, str]: @router.get("/health", response_model=HealthResponse) def health(request: Request) -> HealthResponse: - mode = get_deployment_mode() - response_mode = cast(Literal["local", "hosted"], mode if mode == "hosted" else "local") effective_source, backend_description = _describe_vault_encryption(request.app.state.vault) return HealthResponse( status="ok", version=__version__, - mode=response_mode, configured_encryption_mode=effective_source, effective_encryption_source=effective_source, encryption_backend=backend_description, diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index 1283d63..c359289 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -24,7 +24,6 @@ from authsome.auth.sessions import AuthSession, AuthSessionStore from authsome.identity.principal import PrincipalRole from authsome.server.credential_service import AuthService -from authsome.server.dependencies import get_deployment_mode from authsome.server.routes._deps import ( UI_SESSION_COOKIE_NAME, get_auth_service, @@ -68,27 +67,20 @@ def _field_payloads(session: AuthSession) -> list[dict[str, Any]]: return [dict(field) for field in fields] -def _is_hosted_ui() -> bool: - return get_deployment_mode() == "hosted" - - def _ui_cookie_secure(server_base_url: str) -> bool: return server_base_url.startswith("https://") def _ui_policy(request: Request, auth: AuthService | None = None) -> dict[str, Any]: - hosted = _is_hosted_ui() role = auth.principal_role if auth is not None else getattr(request.state, "ui_principal_role", None) - show_provider_client_details = not hosted or role == PrincipalRole.ADMIN + is_admin = role == PrincipalRole.ADMIN return { - "ui_mode": "hosted" if hosted else "local", - "show_provider_client_details": show_provider_client_details, - "provider_management_label": ( - "OAuth application managed by Authsome" - if hosted and not show_provider_client_details - else "OAuth Application" - ), - "show_hosted_identity": hosted, + "is_admin": is_admin, + "role_label": role.value.title() if role else None, + "show_admin_sections": is_admin, + "show_provider_client_details": is_admin, + "provider_management_label": ("OAuth Application" if is_admin else "OAuth application managed by Authsome"), + "show_hosted_identity": True, } @@ -109,13 +101,10 @@ async def _resolve_ui_auth(request: Request, *, next_url: str | None = None) -> if auth is not None: return auth - if _is_hosted_ui(): - target = _hosted_auth_next_url(next_url or request.query_params.get("next") or request.url.path) - if request.method == "GET" and request.url.path == "/": - raise UiAuthRequiredError(_hosted_auth_page_response(request.app.state.ui_sessions, next_url=target)) - raise UiAuthRequiredError(RedirectResponse(url=_hosted_auth_entry_url(target), status_code=303)) - - raise UiAuthRequiredError(_ui_session_expired_response()) + target = _hosted_auth_next_url(next_url or request.query_params.get("next") or request.url.path) + if request.method == "GET" and request.url.path == "/": + raise UiAuthRequiredError(_hosted_auth_page_response(request.app.state.ui_sessions, next_url=target)) + raise UiAuthRequiredError(RedirectResponse(url=_hosted_auth_entry_url(target), status_code=303)) def require_ui_auth(next_url: str | None = None) -> Callable[[Request], Awaitable[AuthService]]: @@ -225,6 +214,60 @@ def _format_relative(when: datetime | None) -> str | None: return f"{direction} {amount} {unit}{plural}" if direction == "in" else f"{amount} {unit}{plural} ago" +def _format_audit_time(value: Any) -> str: + if not value: + return "-" + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return str(value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC).strftime("%Y-%m-%d %H:%M UTC") + + +def _humanize_audit_event(value: Any) -> str: + event = str(value or "audit_event").replace("_", " ").replace("-", " ").strip() + return event[:1].upper() + event[1:] if event else "Audit event" + + +def _audit_event_rows(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + known = { + "event_id", + "timestamp", + "event", + "source", + "principal_id", + "identity", + "provider", + "connection", + "status", + } + rows: list[dict[str, Any]] = [] + for entry in entries: + provider = entry.get("provider") + connection = entry.get("connection") + metadata = {key: value for key, value in entry.items() if key not in known and value is not None} + target = " / ".join(str(part) for part in (provider, connection) if part) or "Authsome" + rows.append( + { + "event_id": entry.get("event_id") or "-", + "time": _format_audit_time(entry.get("timestamp")), + "event": _humanize_audit_event(entry.get("event")), + "source": entry.get("source") or "internal", + "actor": entry.get("identity") or entry.get("principal_id") or "system", + "target": target, + "status": entry.get("status") or "-", + "metadata": metadata, + } + ) + return rows + + +def _admin_required_response(title: str, message: str) -> HTMLResponse: + return HTMLResponse(pages.message_page(title, message), status_code=403) + + def _provider_status(provider_name: str, connection_summaries: list[dict[str, Any]]) -> str: """Map a connection list to a single status string for the overview cards.""" if not connection_summaries: @@ -290,7 +333,6 @@ async def _provider_connection_groups( identity=identity, principal_id=principal_id, vault_id=binding.vault_id, - deployment_mode=get_deployment_mode(), provider_definitions=request.app.state.provider_definition_repository, ) provider_connections = next( @@ -503,11 +545,8 @@ async def identity_page( request: Request, auth: AuthService = Depends(require_ui_auth("/identity")), ) -> Response: - if _is_hosted_ui(): - claims = await request.app.state.identity_claim_registry.list_for_principal(request.state.ui_principal_id) - identities = [{"handle": claim.identity_handle, "is_active": False} for claim in claims] - else: - identities = [{"handle": auth.identity, "is_active": True}] + claims = await request.app.state.identity_claim_registry.list_for_principal(request.state.ui_principal_id) + identities = [{"handle": claim.identity_handle, "is_active": False} for claim in claims] return templates.TemplateResponse( request, "identity.html", @@ -521,6 +560,32 @@ async def identity_page( ) +@router.get("/audit", response_class=HTMLResponse) +async def audit_page( + request: Request, + auth: AuthService = Depends(require_ui_auth("/audit")), +) -> Response: + if auth.principal_role != PrincipalRole.ADMIN: + return _admin_required_response( + "Admin access required", + "Audit events are available only to administrators.", + ) + + entries = request.app.state.audit_log.list_events(limit=100) + rows = _audit_event_rows(entries) + return templates.TemplateResponse( + request, + "audit.html", + _page_context( + request, + "audit", + auth=auth, + audit_events=rows, + audit_total=len(rows), + ), + ) + + @router.get("/apps/{provider_name}", response_class=HTMLResponse) async def app_detail( provider_name: str, @@ -532,7 +597,7 @@ async def app_detail( redirect_uri = build_callback_url(server_base_url) api_url = _provider_api_url_label(provider) policy = _ui_policy(request, auth) - if not policy["show_provider_client_details"] and _is_hosted_ui(): + if not policy["show_provider_client_details"]: return templates.TemplateResponse( request, "app_detail_managed.html", @@ -643,8 +708,7 @@ async def connect_app( session.payload["force"] = force session.payload["callback_url_override"] = build_callback_url(server_base_url) session.payload["return_url"] = f"{server_base_url.rstrip('/')}/apps/{provider_name}" - if _is_hosted_ui(): - session.payload["ui_session_required"] = True + session.payload["ui_session_required"] = True if not force: try: @@ -690,7 +754,12 @@ async def configure_provider( """Open the provider configuration flow for deployment-scoped credentials.""" provider = await auth.get_provider(provider_name) policy = _ui_policy(request, auth) - if provider.auth_type != AuthType.OAUTH2 or (not policy["show_provider_client_details"] and _is_hosted_ui()): + if not policy["show_provider_client_details"]: + return _admin_required_response( + "Admin access required", + "Provider configuration is available only to administrators.", + ) + if provider.auth_type != AuthType.OAUTH2: return _redirect(request, f"/apps/{provider_name}") session = await sessions.create( @@ -750,7 +819,7 @@ async def claim_identity_page( return _ui_session_expired_response(status_code=404) await resolve_ui_request_identity(request) - if _is_hosted_ui() and getattr(request.state, "ui_principal_id", None) is None: + if getattr(request.state, "ui_principal_id", None) is None: return HTMLResponse(pages.hosted_claim_auth_page(token=token, identity=pending.identity)) email = getattr(request.state, "ui_email", None) or "this account" diff --git a/src/authsome/server/schemas.py b/src/authsome/server/schemas.py index 23f4d74..c490001 100644 --- a/src/authsome/server/schemas.py +++ b/src/authsome/server/schemas.py @@ -15,7 +15,6 @@ class HealthResponse(BaseModel): status: Literal["ok"] version: str - mode: Literal["local", "hosted"] = "local" configured_encryption_mode: str | None = None effective_encryption_source: str | None = None encryption_backend: str | None = None diff --git a/src/authsome/ui/static/style.css b/src/authsome/ui/static/style.css index a2fb694..8704117 100644 --- a/src/authsome/ui/static/style.css +++ b/src/authsome/ui/static/style.css @@ -1,475 +1,706 @@ :root { - --bg-body: #000000; - --bg-sidebar: #050505; - --bg-card: #0a0a0a; - --bg-card-hover: #141414; - --border: #27272a; - --border-subtle: #1f1f22; - --accent: #83ca16; - --accent-soft: rgba(131, 202, 22, 0.12); - --accent-strong: rgba(131, 202, 22, 0.22); - --text: #ededed; - --text-secondary: #d4d4d8; - --text-muted: #a1a1aa; - --warn: #ffb34f; - --warn-soft: rgba(255, 179, 79, 0.13); - --danger: #ff7b72; - --danger-soft: rgba(255, 123, 114, 0.12); - --radius-sm: 6px; - --radius-md: 8px; - --radius-lg: 10px; - --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; -} - -* { box-sizing: border-box; } - -html, body { + --bg-body: #f5f6f8; + --bg-sidebar: #ffffff; + --bg-surface: #ffffff; + --bg-subtle: #eef2f5; + --bg-muted: #e6ebf0; + --border: #d7dde5; + --border-strong: #b9c3cf; + --accent: #1f6f5b; + --accent-dark: #155041; + --accent-soft: #e5f1ed; + --blue: #2f5d88; + --blue-soft: #e7eef6; + --warn: #9a5b13; + --warn-soft: #fff3dd; + --danger: #a33a3a; + --danger-soft: #f9e4e4; + --text: #16202a; + --text-secondary: #3d4a57; + --text-muted: #6d7a86; + --shadow: 0 1px 2px rgba(22, 32, 42, 0.06); + --radius-sm: 5px; + --radius-md: 6px; + --radius-lg: 8px; + --font-sans: Aptos, "Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { margin: 0; padding: 0; + min-height: 100vh; background: var(--bg-body); color: var(--text); font-family: var(--font-sans); font-size: 14px; line-height: 1.5; - min-height: 100vh; } -a { color: inherit; text-decoration: none; } - body { display: grid; - grid-template-columns: 220px 1fr; - min-height: 100vh; + grid-template-columns: 232px minmax(0, 1fr); } -button { font-family: inherit; } +a { + color: inherit; + text-decoration: none; +} + +button, +input { + font: inherit; +} -/* ───────── Sidebar ───────── */ .sidebar { - background: var(--bg-sidebar); - border-right: 1px solid var(--border-subtle); - padding: 18px 14px 16px; - display: flex; - flex-direction: column; - gap: 6px; position: sticky; top: 0; height: 100vh; + display: flex; + flex-direction: column; + gap: 6px; + padding: 18px 14px 16px; + background: var(--bg-sidebar); + border-right: 1px solid var(--border); } + .brand { - font-weight: 700; + padding: 5px 10px 16px; font-size: 15px; - padding: 6px 10px 14px; - letter-spacing: -0.01em; + font-weight: 700; + letter-spacing: 0; } + .brand::after { content: "."; color: var(--accent); } -.nav-primary, .nav-secondary { + +.nav-primary, +.nav-secondary { display: flex; flex-direction: column; gap: 2px; } -.nav-spacer { flex: 1; } + +.nav-spacer { + flex: 1; +} + .nav-item { display: flex; align-items: center; gap: 10px; + min-height: 36px; padding: 8px 10px; - border-radius: var(--radius-sm); - color: var(--text-secondary); border: 1px solid transparent; + border-radius: var(--radius-md); + color: var(--text-secondary); font-size: 13px; - transition: background-color 80ms ease, border-color 80ms ease, color 80ms ease; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; } + .nav-item:hover { - background: rgba(131, 202, 22, 0.04); - color: var(--accent); + background: var(--bg-subtle); + color: var(--text); } + .nav-item.active { - background: rgba(131, 202, 22, 0.06); - border-color: var(--border); - color: var(--accent); + background: var(--accent-soft); + border-color: #b8d9cf; + color: var(--accent-dark); + font-weight: 600; +} + +.nav-item.disabled { + opacity: 0.45; + pointer-events: none; +} + +.nav-item svg { + width: 16px; + height: 16px; + flex-shrink: 0; } -.nav-item.disabled { opacity: 0.45; pointer-events: none; } -.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; } + .brand-foot { - font-size: 11px; - color: var(--text-muted); padding: 8px 10px 2px; + color: var(--text-muted); + font-size: 11px; } -/* ───────── Main ───────── */ .main { min-width: 0; display: flex; flex-direction: column; } + .topbar { + min-height: 57px; display: flex; - justify-content: flex-end; - padding: 14px 28px 0; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 30px; + background: rgba(255, 255, 255, 0.88); + border-bottom: 1px solid var(--border); + backdrop-filter: blur(8px); } -.topbar-actions { + +.topbar-meta, +.topbar-actions, +.page-actions { display: flex; - gap: 6px; -} -.icon-btn { - background: transparent; - border: 1px solid var(--border); - color: var(--text-secondary); - width: 30px; - height: 30px; - border-radius: var(--radius-sm); - display: inline-flex; align-items: center; - justify-content: center; - cursor: pointer; - transition: background-color 80ms ease, color 80ms ease; + gap: 8px; +} + +.topbar-actions { + justify-content: flex-end; +} + +.topbar-actions form { + margin: 0; +} + +.meta { + color: var(--text-muted); + font-size: 12.5px; } -.icon-btn:hover { background: var(--bg-card-hover); color: var(--text); } -.icon-btn svg { width: 16px; height: 16px; } .page { - padding: 22px 28px 40px; - max-width: 1240px; width: 100%; + max-width: 1220px; + padding: 26px 30px 48px; } -/* ───────── Page header ───────── */ .page-header { display: flex; - justify-content: space-between; align-items: flex-end; - gap: 12px; + justify-content: space-between; + gap: 16px; margin-bottom: 22px; } + .eyebrow { + margin-bottom: 4px; color: var(--text-muted); - font-size: 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0; text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 4px; } -h1 { - font-size: 22px; + +h1, +h2 { margin: 0; - font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: 0; +} + +h1 { + font-size: 24px; + line-height: 1.2; + font-weight: 700; } + h2 { font-size: 14px; - font-weight: 600; - margin: 0; - letter-spacing: -0.005em; + font-weight: 700; } + .subtitle { + margin: 5px 0 0; + color: var(--text-muted); + font-size: 13px; +} + +.muted, +.subtle { color: var(--text-muted); +} + +.link { + color: var(--accent-dark); font-size: 13px; - margin: 4px 0 0; + font-weight: 600; +} + +.link:hover { + text-decoration: underline; +} + +.btn, +.icon-btn { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-surface); + color: var(--text); + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; } -.page-actions { display: flex; gap: 8px; } -/* ───────── Buttons ───────── */ .btn { display: inline-flex; align-items: center; + justify-content: center; gap: 6px; + min-height: 34px; padding: 7px 12px; - border-radius: var(--radius-sm); font-size: 13px; - font-weight: 500; - border: 1px solid var(--border); - background: var(--bg-card); - color: var(--text); - cursor: pointer; - transition: background-color 80ms ease, border-color 80ms ease, color 80ms ease; + font-weight: 600; + white-space: nowrap; +} + +.btn:hover, +.icon-btn:hover { + background: var(--bg-subtle); + border-color: var(--border-strong); +} + +.btn svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.btn[disabled] { + cursor: not-allowed; + opacity: 0.55; +} + +.btn-sm { + min-height: 30px; + padding: 5px 10px; + font-size: 12px; } -.btn:hover { background: var(--bg-card-hover); } -.btn svg { width: 14px; height: 14px; flex-shrink: 0; } -.btn[disabled] { opacity: 0.55; cursor: not-allowed; } -.btn-sm { padding: 5px 10px; font-size: 12px; } + .btn-primary { - background: var(--accent-soft); - color: var(--accent); + background: var(--accent); border-color: var(--accent); - font-weight: 600; + color: #ffffff; } + .btn-primary:hover { - background: var(--accent-strong); - border-color: var(--accent); + background: var(--accent-dark); + border-color: var(--accent-dark); } -.btn-secondary { background: transparent; } -.btn-secondary:hover { - background: var(--accent-soft); - border-color: var(--accent); - color: var(--accent); + +.btn-secondary { + background: var(--bg-surface); + color: var(--text-secondary); } + .btn-light { - background: #f1f4f9; - color: #000000; - border-color: #f1f4f9; - font-weight: 600; + background: var(--blue-soft); + border-color: #c8d6e5; + color: var(--blue); } -.btn-light:hover { background: #fff; border-color: #fff; } + .btn-warn-ghost { - background: transparent; + background: var(--warn-soft); + border-color: #eed09f; color: var(--warn); - border-color: var(--warn-soft); } -.btn-warn-ghost:hover { background: var(--warn-soft); } + .btn-danger-ghost { - background: transparent; + background: var(--danger-soft); + border-color: #efc3c3; color: var(--danger); - border-color: var(--danger-soft); } -.btn-danger-ghost:hover { background: var(--danger-soft); } -.btn-block { width: 100%; justify-content: center; } -/* ───────── Stat cards ───────── */ +.btn-block { + width: 100%; +} + +.icon-btn { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0; +} + +.icon-btn svg { + width: 15px; + height: 15px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-subtle); + color: var(--text-secondary); + font-size: 11.5px; + font-weight: 600; + line-height: 1.35; + white-space: nowrap; +} + +.pill-accent { + background: var(--accent-soft); + border-color: #b8d9cf; + color: var(--accent-dark); +} + +.pill-neutral { + background: var(--bg-subtle); + border-color: var(--border); + color: var(--text-secondary); +} + +.pill-warn { + background: var(--warn-soft); + border-color: #efd4aa; + color: var(--warn); +} + .stat-grid { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 26px; } -.stat-card { - background: var(--bg-card); + +.stat-card, +.card, +.panel, +.notice-band, +.provider-card, +.app-card, +.empty, +.docs-card, +.connection-row { + background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 16px 18px; + box-shadow: var(--shadow); +} + +.stat-card { + min-height: 118px; display: flex; flex-direction: column; gap: 6px; - min-height: 122px; + padding: 16px 18px; } -.stat-label { + +.stat-label, +.card-title { color: var(--text-muted); - font-size: 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0; text-transform: uppercase; - letter-spacing: 0.05em; } + .stat-value { - font-size: 30px; - font-weight: 600; - letter-spacing: -0.02em; + font-size: 31px; line-height: 1.1; + font-weight: 700; + letter-spacing: 0; +} + +.stat-value-sm { + font-size: 19px; + line-height: 1.25; } -.stat-value-sm { font-size: 18px; font-weight: 600; } + .stat-foot { - font-size: 12px; - color: var(--text-muted); margin-top: auto; + color: var(--text-muted); + font-size: 12px; +} + +.section { + margin-bottom: 26px; } -.stat-foot.accent { color: var(--accent); } -/* ───────── Sections ───────── */ -.section { margin-bottom: 26px; } .section-header { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; + gap: 12px; margin-bottom: 12px; } -.link { - color: var(--accent); - font-size: 13px; - font-weight: 500; -} -.link:hover { text-decoration: underline; } -.muted { color: var(--text-muted); } -/* ───────── Provider cards (overview) ───────── */ -.provider-grid { +.provider-grid, +.app-grid { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 14px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; } + .provider-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 16px; + min-height: 166px; display: flex; flex-direction: column; - gap: 8px; - transition: border-color 80ms ease, background-color 80ms ease; + gap: 9px; + padding: 15px; + transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease; } -.provider-card:hover { - border-color: var(--accent-strong); - background: var(--bg-card-hover); + +.provider-card:hover, +.app-card:hover, +.connection-row:hover, +.docs-card:hover { + border-color: var(--border-strong); + box-shadow: 0 2px 7px rgba(22, 32, 42, 0.08); } + .provider-card-top { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; + gap: 8px; } + .logo { width: 32px; height: 32px; - border-radius: var(--radius-sm); - background: var(--accent-soft); - color: var(--accent); + border-radius: var(--radius-md); display: inline-flex; align-items: center; justify-content: center; - font-weight: 600; + flex: 0 0 auto; + background: var(--blue-soft); + color: var(--blue); font-size: 13px; + font-weight: 700; +} + +.logo-lg { + width: 44px; + height: 44px; + font-size: 16px; +} + +.provider-card-name { + color: var(--text); + font-size: 14px; + font-weight: 700; +} + +.provider-card-desc { + min-height: 18px; + color: var(--text-muted); + font-size: 12.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.logo-lg { width: 44px; height: 44px; font-size: 16px; } -.provider-card-name { font-weight: 600; font-size: 14px; } -.provider-card-desc { color: var(--text-muted); font-size: 12.5px; min-height: 18px; } + .provider-card-foot { - margin-top: 6px; + margin-top: auto; padding-top: 10px; - border-top: 1px solid var(--border-subtle); + border-top: 1px solid var(--border); display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; + gap: 10px; color: var(--text-muted); font-size: 12px; } -.icon-gear { color: var(--text-muted); display: inline-flex; } -/* ───────── Pills / badges ───────── */ -.pill { +.icon-gear { display: inline-flex; - align-items: center; - gap: 4px; - padding: 3px 8px; - border-radius: 999px; - font-size: 11.5px; - font-weight: 500; - border: 1px solid transparent; - line-height: 1.4; + color: var(--text-muted); } -.pill-accent { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-strong); } -.pill-warn { background: var(--warn-soft); color: var(--warn); border-color: rgba(255,179,79,0.25); } -/* ───────── CTA card ───────── */ -.cta-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 22px 24px; +.notice-band { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; gap: 18px; + padding: 18px 20px; +} + +.notice-title { + margin-bottom: 3px; + font-size: 14px; + font-weight: 700; +} + +.notice-body { + max-width: 680px; + margin: 0; + color: var(--text-muted); + font-size: 13px; } -.cta-title { font-size: 16px; font-weight: 600; margin-bottom: 4px; } -.cta-body { color: var(--text-muted); font-size: 13px; max-width: 560px; margin: 0; } -/* ───────── Connections list ───────── */ .toolbar { display: flex; - gap: 12px; align-items: center; + gap: 12px; margin-bottom: 18px; } + .search { - flex: 1; + min-height: 38px; display: flex; align-items: center; gap: 8px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-sm); + flex: 1; padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-surface); color: var(--text-muted); + box-shadow: var(--shadow); } + .search input { - flex: 1; - background: transparent; + width: 100%; + min-width: 0; border: 0; outline: 0; + background: transparent; color: var(--text); - font-family: inherit; font-size: 13px; } + .filter-pills { display: flex; gap: 4px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-sm); padding: 3px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-surface); } + .filter-pill { - background: transparent; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 29px; + padding: 5px 11px; border: 0; + border-radius: var(--radius-md); + background: transparent; color: var(--text-secondary); - padding: 5px 11px; - border-radius: var(--radius-sm); - font-size: 12.5px; cursor: pointer; - display: inline-flex; - gap: 6px; - align-items: center; + font-size: 12.5px; } -.filter-pill:hover { color: var(--text); } -.filter-pill.active { background: var(--bg-body); color: var(--text); } -.filter-pill .count { color: var(--accent); font-weight: 600; font-size: 12px; } -.app-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; +.filter-pill.active { + background: var(--bg-subtle); + color: var(--text); +} + +.filter-pill .count { + color: var(--accent-dark); + font-size: 12px; + font-weight: 700; } + .app-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-md); - padding: 12px 14px; + position: relative; + min-height: 76px; display: flex; align-items: center; gap: 12px; - position: relative; - transition: border-color 80ms ease, background-color 80ms ease; + padding: 13px 14px; } + +.app-card.is-connected { + border-color: #b8d9cf; +} + .app-card-hit { position: absolute; inset: 0; - border-radius: inherit; z-index: 1; + border-radius: inherit; +} + +.app-card-text { + min-width: 0; + flex: 1; +} + +.app-card-name { + overflow: hidden; + color: var(--text); + font-size: 13.5px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-card-sub { + color: var(--text-muted); + font-size: 12px; } -.app-card:hover { background: var(--bg-card-hover); border-color: var(--accent-strong); } -.app-card.is-connected { border-color: var(--accent-strong); } -.app-card-text { flex: 1; min-width: 0; } -.app-card-name { font-weight: 500; font-size: 13.5px; } -.app-card-sub { color: var(--text-muted); font-size: 12px; } + .inline-action { - margin: 0; position: relative; z-index: 2; + margin: 0; +} + +.as-button { + cursor: pointer; } -.as-button { cursor: pointer; } .empty { - background: var(--bg-card); - border: 1px dashed var(--border); - border-radius: var(--radius-lg); - padding: 40px 24px; - text-align: center; + padding: 38px 24px; color: var(--text-muted); + text-align: center; +} + +.empty-grid { + grid-column: 1 / -1; +} + +.empty-title { + margin-bottom: 4px; + color: var(--text); + font-size: 14px; + font-weight: 700; +} + +.empty-body { + margin-bottom: 14px; + font-size: 13px; +} + +.empty .btn { + margin-top: 4px; +} + +.hidden { + display: none !important; } -.empty-grid { grid-column: 1 / -1; } -.empty-title { color: var(--text); font-weight: 600; margin-bottom: 4px; font-size: 14px; } -.empty-body { font-size: 13px; margin-bottom: 14px; } -.empty .btn { margin-top: 4px; } -.hidden { display: none !important; } -/* ───────── App detail ───────── */ .breadcrumb { + margin-bottom: 12px; color: var(--text-muted); font-size: 12.5px; - margin-bottom: 12px; } -.breadcrumb a { color: var(--text-secondary); } -.breadcrumb a:hover { color: var(--accent); } -.breadcrumb .sep { margin: 0 6px; opacity: 0.6; } + +.breadcrumb a { + color: var(--text-secondary); +} + +.breadcrumb a:hover { + color: var(--accent-dark); +} + +.breadcrumb .sep { + margin: 0 6px; + opacity: 0.6; +} .detail-header { display: flex; @@ -477,169 +708,201 @@ h2 { gap: 14px; margin-bottom: 18px; } + .detail-title { display: flex; + flex-wrap: wrap; align-items: center; gap: 10px; - font-size: 22px; - margin: 0; + font-size: 23px; } + .detail-meta { + margin-top: 3px; color: var(--text-muted); font-size: 12.5px; - margin-top: 2px; } .detail-grid { display: grid; - grid-template-columns: 1fr 240px; + grid-template-columns: minmax(0, 1fr) 260px; gap: 18px; align-items: start; } -.detail-main { display: flex; flex-direction: column; gap: 14px; } -.detail-side { display: flex; flex-direction: column; gap: 14px; position: sticky; top: 14px; } + +.detail-main, +.detail-side, +.connection-list, +.action-stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.detail-side { + position: sticky; + top: 72px; +} .docs-card { display: flex; align-items: center; gap: 10px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); padding: 12px 14px; - color: var(--text); - transition: background-color 80ms ease, border-color 80ms ease; -} -.docs-card:hover { - background: var(--bg-card-hover); - border-color: var(--accent-strong); } + .docs-icon { width: 30px; height: 30px; - border-radius: var(--radius-sm); - background: var(--accent-soft); - color: var(--accent); + border-radius: var(--radius-md); display: inline-flex; align-items: center; justify-content: center; - flex-shrink: 0; + flex: 0 0 auto; + background: var(--accent-soft); + color: var(--accent-dark); +} + +.docs-icon svg { + width: 16px; + height: 16px; } -.docs-icon svg { width: 16px; height: 16px; } + .docs-title { display: block; font-size: 13px; - font-weight: 600; + font-weight: 700; } + .docs-subtitle { display: block; + margin-top: 1px; color: var(--text-muted); font-size: 12px; - margin-top: 1px; } -.card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); +.card, +.panel { padding: 16px 18px; } + .card-title { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-muted); margin-bottom: 10px; } -.card-body { margin: 0; color: var(--text-secondary); font-size: 13px; } -.field { margin-bottom: 12px; } -.field:last-child { margin-bottom: 0; } +.card-body { + margin: 0; + color: var(--text-secondary); + font-size: 13px; +} + +.card-warn { + background: var(--warn-soft); + border-color: #efd4aa; +} + +.field { + margin-bottom: 12px; +} + +.field:last-child { + margin-bottom: 0; +} + .field-label { + margin-bottom: 4px; color: var(--text-muted); font-size: 12px; - margin-bottom: 4px; + font-weight: 600; } + .field-row { + min-height: 34px; display: flex; align-items: center; gap: 6px; - background: var(--bg-body); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); padding: 7px 10px; - min-height: 32px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: #fbfcfd; } + .field-row .mono { + min-width: 0; flex: 1; - color: var(--text-secondary); overflow: hidden; + color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; } -.mono { font-family: var(--font-mono); font-size: 12.5px; } + +.mono { + font-family: var(--font-mono); + font-size: 12.5px; +} + +.mask { + letter-spacing: 0; +} .scope-row { display: flex; flex-wrap: wrap; gap: 6px; } + .scope { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px 4px 10px; + border: 1px solid #b8d9cf; border-radius: 999px; background: var(--accent-soft); - color: var(--accent); + color: var(--accent-dark); font-family: var(--font-mono); font-size: 12px; - border: 1px solid var(--accent-strong); } + .scope-x { - background: transparent; + padding: 0 2px; border: 0; - color: var(--accent); - font-size: 14px; + background: transparent; + color: var(--accent-dark); cursor: pointer; - padding: 0 2px; + font-size: 14px; line-height: 1; } + .scope.scope-add { - background: transparent; - color: var(--text-muted); - border-style: dashed; border-color: var(--border); + border-style: dashed; + background: var(--bg-surface); + color: var(--text-muted); cursor: pointer; } .kv { display: grid; - grid-template-columns: max-content 1fr; + grid-template-columns: max-content minmax(0, 1fr); column-gap: 12px; row-gap: 8px; margin: 0; font-size: 12.5px; } -.kv dt { color: var(--text-muted); } -.kv dd { margin: 0; color: var(--text); } -.action-stack { - display: flex; - flex-direction: column; - gap: 8px; +.kv dt { + color: var(--text-muted); } -.action-stack form { margin: 0; } -.card-warn { - border-color: rgba(255,179,79,0.28); - background: linear-gradient(180deg, rgba(255,179,79,0.08), rgba(255,179,79,0.03)); +.kv dd { + min-width: 0; + margin: 0; + color: var(--text); } -.connection-list { - display: flex; - flex-direction: column; - gap: 10px; +.action-stack form { + margin: 0; } .connection-row { @@ -647,16 +910,7 @@ h2 { align-items: center; justify-content: space-between; gap: 12px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-md); padding: 12px 14px; - transition: border-color 80ms ease, background-color 80ms ease; -} - -.connection-row:hover { - background: var(--bg-card-hover); - border-color: var(--accent-strong); } .connection-row-main { @@ -667,51 +921,210 @@ h2 { } .connection-row-name { - font-weight: 600; - font-size: 13.5px; color: var(--text); + font-size: 13.5px; + font-weight: 700; } .connection-row-meta { + overflow: hidden; color: var(--text-muted); font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; } .connection-row-readonly { padding: 10px 12px; + box-shadow: none; } .connection-group + .connection-group { margin-top: 14px; } +.identity-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} + +.identity-row:first-child { + padding-top: 0; +} + +.identity-row:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.identity-name { + color: var(--text); + font-weight: 700; +} + +.identity-meta { + color: var(--text-muted); + font-size: 12px; +} + +.table-wrap { + width: 100%; + overflow-x: auto; +} + +.data-table { + width: 100%; + min-width: 760px; + border-collapse: collapse; + font-size: 13px; +} + +.data-table th, +.data-table td { + padding: 11px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +.data-table th { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.data-table tbody tr:last-child td { + border-bottom: 0; +} + +.table-primary { + color: var(--text); + font-weight: 700; +} + +.table-secondary { + margin-top: 2px; + color: var(--text-muted); + font-size: 11.5px; +} + +.audit-table td:nth-child(1) { + white-space: nowrap; +} + +.audit-metadata-row td { + padding-top: 0; + background: #fbfcfd; +} + +.metadata-list { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + margin: 0; + color: var(--text-muted); + font-size: 12px; +} + +.metadata-list div { + display: inline-flex; + gap: 5px; +} + +.metadata-list dt { + color: var(--text-muted); + font-weight: 700; +} + +.metadata-list dd { + margin: 0; + color: var(--text-secondary); +} + .login-modal { width: min(420px, calc(100vw - 32px)); + padding: 0; border: 1px solid var(--border); border-radius: var(--radius-lg); - background: var(--bg-card); + background: var(--bg-surface); color: var(--text); - padding: 0; + box-shadow: 0 18px 44px rgba(22, 32, 42, 0.22); } .login-modal::backdrop { - background: rgba(0, 0, 0, 0.66); + background: rgba(20, 29, 38, 0.44); } .login-modal form { display: flex; flex-direction: column; gap: 14px; - padding: 18px; margin: 0; + padding: 18px; } .login-modal-input { width: 100%; - background: var(--bg-body); + padding: 9px 10px; border: 1px solid var(--border); - border-radius: var(--radius-sm); + border-radius: var(--radius-md); + background: #fbfcfd; color: var(--text); - padding: 9px 10px; font: inherit; } + +@media (max-width: 980px) { + body { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .nav-primary, + .nav-secondary { + flex-flow: row wrap; + } + + .nav-spacer, + .brand-foot { + display: none; + } + + .topbar, + .page-header, + .notice-band { + align-items: flex-start; + flex-direction: column; + } + + .topbar-actions, + .page-actions { + flex-wrap: wrap; + } + + .page { + padding: 22px 18px 36px; + } + + .stat-grid, + .provider-grid, + .app-grid, + .detail-grid { + grid-template-columns: 1fr; + } + + .detail-side { + position: static; + } +} diff --git a/src/authsome/ui/templates/_layout.html b/src/authsome/ui/templates/_layout.html index 389c29b..c5fac9c 100644 --- a/src/authsome/ui/templates/_layout.html +++ b/src/authsome/ui/templates/_layout.html @@ -2,7 +2,7 @@ - + {% block title %}Authsome{% endblock %} · Authsome @@ -42,6 +42,17 @@ Identity + {% if show_admin_sections %} + + + Audit + + {% endif %}