diff --git a/CHANGELOG.md b/CHANGELOG.md index 6520e9f..651b123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ the git tags (`vX.Y.Z`) and `daybook_project.__version__`. _Nothing yet._ +## [0.2.0] — 2026-09-04 + +### Added +- **Multiple named API keys per user.** Each user can now hold several keys — + one per agent or script — named and revocable individually, managed from a + new **Settings → API Keys** page (a table with name, key, created, last-used, + and per-row revoke). Keys are viewable on demand (`dbk_`-prefixed). +- **Gated key-provisioning API** for automated test installations: + `GET/POST /api/keys` and `DELETE /api/keys/{id}`, controlled by + `DAYBOOK_KEY_PROVISIONING_API` (unset → follows `DJANGO_DEBUG`, so **off in + deployment**). Advertised as `key_provisioning_api` in `/api/capabilities`; + returns 403 when disabled. +- API keys track `last_used_at` (throttled to one write/minute) so stale keys + are visible before you revoke them. + +### Changed +- Auth now resolves bearer tokens against the new `ApiKey` model instead of the + single DRF `authtoken.Token`. Existing tokens are migrated 1:1 into named + ("default") keys with the same value — no client reconfiguration needed. +- Settings → Account no longer manages a single token; it links to API Keys. + Creating a user (Settings → Users) issues an initial named key. + ## [0.1.1] — 2026-09-03 ### Fixed @@ -63,6 +85,7 @@ First tagged release. - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a runbook (`deploy/cloud_agent_deploy.md`) and health check. -[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...HEAD +[0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0 [0.1.1]: https://git.cygnus.systems/peter/daybook/compare/v0.1.0...v0.1.1 [0.1.0]: https://git.cygnus.systems/peter/daybook/releases/tag/v0.1.0 diff --git a/artifacts/migrations/0014_apikey.py b/artifacts/migrations/0014_apikey.py new file mode 100644 index 0000000..09e31ce --- /dev/null +++ b/artifacts/migrations/0014_apikey.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.15 on 2026-09-04 09:35 + +import artifacts.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0013_artifact_share_token'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ApiKey', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('key', models.CharField(db_index=True, default=artifacts.models.generate_api_key, max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_used_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['created_at'], + }, + ), + ] diff --git a/artifacts/migrations/0015_migrate_tokens_to_apikeys.py b/artifacts/migrations/0015_migrate_tokens_to_apikeys.py new file mode 100644 index 0000000..46bb6e7 --- /dev/null +++ b/artifacts/migrations/0015_migrate_tokens_to_apikeys.py @@ -0,0 +1,32 @@ +from django.db import migrations + + +def copy_tokens_to_apikeys(apps, schema_editor): + """Carry every existing DRF authtoken over to a named ApiKey so keys issued + before the multi-key feature keep authenticating (incl. the permanent test + user and any deployed agents). Same key string -> no client reconfiguration.""" + Token = apps.get_model("authtoken", "Token") + ApiKey = apps.get_model("artifacts", "ApiKey") + for token in Token.objects.all(): + if ApiKey.objects.filter(key=token.key).exists(): + continue + api_key = ApiKey.objects.create(user_id=token.user_id, name="default", key=token.key) + # created_at is auto_now_add, so set it after the fact to preserve the + # original issue date (queryset.update bypasses auto_now_add). + ApiKey.objects.filter(pk=api_key.pk).update(created_at=token.created) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("artifacts", "0014_apikey"), + ("authtoken", "0004_alter_tokenproxy_options"), + ] + + operations = [ + migrations.RunPython(copy_tokens_to_apikeys, noop), + ] diff --git a/artifacts/models.py b/artifacts/models.py index 446454f..e77f143 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -1,7 +1,14 @@ +import secrets + from django.conf import settings from django.db import models +def generate_api_key(): + """`dbk_` prefix (recognizable as a Daybook key) + 40 hex chars.""" + return "dbk_" + secrets.token_hex(20) + + class Artifact(models.Model): HTML_FRAGMENT = "html_fragment" HTML_FULL = "html_full" @@ -92,6 +99,28 @@ class UserProfile(models.Model): return f"{self.user} quota={self.quota_bytes}" +class ApiKey(models.Model): + """A named, per-user API key for ingest. A user can hold several — one per + agent or script — and revoke them individually. Stored and shown in + plaintext: this is a self-hosted, single-operator app and keys are + viewable on demand by design. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_keys" + ) + name = models.CharField(max_length=100) + key = models.CharField(max_length=64, unique=True, default=generate_api_key, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + last_used_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["created_at"] + + def __str__(self): + return f"{self.name} ({self.user})" + + class Asset(models.Model): """An image uploaded for embedding inside an artifact's content. diff --git a/artifacts/urls.py b/artifacts/urls.py index dae96dc..1cbd657 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -15,10 +15,13 @@ urlpatterns = [ path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"), path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), path("api/skill", views.SkillView.as_view(), name="skill-api"), + path("api/keys", views.ApiKeyListCreateView.as_view(), name="api-keys"), + path("api/keys/", views.ApiKeyDetailView.as_view(), name="api-key-detail"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), path("settings/", views.settings_home, name="settings-home"), path("settings/account/", views.settings_account, name="settings-account"), + path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"), path("settings/users/", views.settings_users, name="settings-users"), path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"), path("settings/storage/", views.settings_storage, name="settings-storage"), diff --git a/artifacts/views.py b/artifacts/views.py index a6ec867..aa3bfd0 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -29,14 +29,13 @@ from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.http import require_POST -from rest_framework.authtoken.models import Token from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView from .demo_data import create_demo_artifacts from .forms import DaybookLoginForm -from .models import Artifact, Asset, TagColor +from .models import ApiKey, Artifact, Asset, TagColor from .storage import ( QuotaExceeded, SIDECAR_SUFFIXES, @@ -309,6 +308,17 @@ class CapabilitiesView(APIView): return HttpResponseForbidden("Login or a valid API token is required") from daybook_project import __version__ + methods = { + "/api/artifacts": ["GET", "POST"], + "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], + "/api/assets": ["POST"], + "/api/capabilities": ["GET"], + "/api/skill": ["GET"], + } + if settings.DAYBOOK_KEY_PROVISIONING_API: + methods["/api/keys"] = ["GET", "POST"] + methods["/api/keys/{id}"] = ["DELETE"] + return Response( { "version": __version__, @@ -318,14 +328,9 @@ class CapabilitiesView(APIView): "raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL, Artifact.JSON, Artifact.CSV], "source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES], "auth": "bearer_token", - "methods": { - "/api/artifacts": ["GET", "POST"], - "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], - "/api/assets": ["POST"], - "/api/capabilities": ["GET"], - "/api/skill": ["GET"], - }, + "methods": methods, "skill_url": request.build_absolute_uri(reverse("skill-api")), + "key_provisioning_api": settings.DAYBOOK_KEY_PROVISIONING_API, } ) @@ -373,6 +378,71 @@ class SkillView(APIView): return HttpResponse(skill, content_type="text/markdown; charset=utf-8") +def serialize_api_key(api_key): + return { + "id": api_key.pk, + "name": api_key.name, + "key": api_key.key, + "created_at": api_key.created_at.isoformat(), + "last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None, + } + + +class ApiKeyListCreateView(APIView): + """Programmatic API-key management, for automated test installations that + provision their own agents. Gated by settings.DAYBOOK_KEY_PROVISIONING_API, + which is OFF in deployment (it hands out key material over the API). When + off, every method returns 403. Authenticated by session or an existing + bearer token, and only ever operates on that user's own keys. + + GET /api/keys -> list this user's keys (incl. key values) + POST /api/keys -> create {"name": ...} -> the new key + """ + + def _resolve_user(self, request): + if request.user.is_authenticated: + return request.user + return resolve_token_user(request) + + def get(self, request): + if not settings.DAYBOOK_KEY_PROVISIONING_API: + return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403) + user = self._resolve_user(request) + if user is None: + return HttpResponseForbidden("Login or a valid API token is required") + return Response([serialize_api_key(k) for k in user.api_keys.all()]) + + def post(self, request): + if not settings.DAYBOOK_KEY_PROVISIONING_API: + return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403) + user = self._resolve_user(request) + if user is None: + return HttpResponseForbidden("Login or a valid API token is required") + name = (request.data.get("name") or "").strip() + if not name: + return Response({"detail": "A non-empty 'name' is required."}, status=400) + api_key = ApiKey.objects.create(user=user, name=name) + return Response(serialize_api_key(api_key), status=201) + + +class ApiKeyDetailView(APIView): + """Revoke a single key by id, same gating and auth as the list/create view. + + DELETE /api/keys/{id} + """ + + def delete(self, request, key_id): + if not settings.DAYBOOK_KEY_PROVISIONING_API: + return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403) + user = request.user if request.user.is_authenticated else resolve_token_user(request) + if user is None: + return HttpResponseForbidden("Login or a valid API token is required") + deleted, _ = ApiKey.objects.filter(pk=key_id, user=user).delete() + if not deleted: + raise Http404("No such key") + return Response(status=204) + + FEED_WINDOW_DAYS = 7 @@ -992,9 +1062,15 @@ def resolve_token_user(request): if not key: return None try: - return Token.objects.select_related("user").get(key=key).user - except Token.DoesNotExist: + api_key = ApiKey.objects.select_related("user").get(key=key) + except ApiKey.DoesNotExist: return None + # Stamp last-used so stale keys are visible in the UI, but throttle the + # write to at most once a minute to avoid a DB write on every request. + now = timezone.now() + if api_key.last_used_at is None or (now - api_key.last_used_at).total_seconds() > 60: + ApiKey.objects.filter(pk=api_key.pk).update(last_used_at=now) + return api_key.user def normalize_tags(tags): @@ -1250,16 +1326,36 @@ def settings_home(request): @login_required def settings_account(request): - token, _ = Token.objects.get_or_create(user=request.user) - if request.method == "POST" and request.POST.get("action") == "regenerate": - token.delete() - token = Token.objects.create(user=request.user) - messages.success(request, "API token regenerated.") - return redirect("settings-account") return render( request, "artifacts/settings_account.html", - {"token": token, "active_section": "account"}, + {"active_section": "account", "key_count": request.user.api_keys.count()}, + ) + + +@login_required +def settings_api_keys(request): + if request.method == "POST": + action = request.POST.get("action") + if action == "create": + name = (request.POST.get("name") or "").strip() + if not name: + messages.error(request, "Give the key a name so you know which agent it's for.") + else: + ApiKey.objects.create(user=request.user, name=name) + messages.success(request, f"Created API key '{name}'.") + elif action == "revoke": + key_id = request.POST.get("key_id") or "" + deleted, _ = ApiKey.objects.filter(pk=key_id, user=request.user).delete() + if deleted: + messages.success(request, "API key revoked.") + else: + messages.error(request, "No such key.") + return redirect("settings-api-keys") + return render( + request, + "artifacts/settings_api_keys.html", + {"active_section": "api-keys", "api_keys": request.user.api_keys.all()}, ) @@ -1269,7 +1365,7 @@ def settings_users(request): return HttpResponseForbidden("Admin access required") created_user = None - created_token = None + created_key = None if request.method == "POST": username = request.POST.get("username", "").strip() password = request.POST.get("password", "") @@ -1283,7 +1379,7 @@ def settings_users(request): if grant_admin: created_user.is_superuser = True created_user.save(update_fields=["is_superuser"]) - created_token = Token.objects.create(user=created_user) + created_key = ApiKey.objects.create(user=created_user, name="default") messages.success(request, f"Created user '{username}'.") return render( @@ -1293,7 +1389,7 @@ def settings_users(request): "active_section": "users", "users": User.objects.order_by("username"), "created_user": created_user, - "created_token": created_token, + "created_key": created_key, }, ) diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index e27f5fc..b5b7a6a 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # Single source of truth for the app version. Bump this, commit, then tag the # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # GET /api/capabilities ("version"). -__version__ = "0.1.1" +__version__ = "0.2.0" diff --git a/daybook_project/settings.py b/daybook_project/settings.py index ee85175..c8987a1 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -9,6 +9,17 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me") DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"} ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()] +# Allow creating/listing/revoking API keys over the ingest API (POST /api/keys). +# Meant for automated test installations that provision their own agents. It +# exposes key material over the API, so it is OFF in deployment. Unset, it +# follows DEBUG (on locally, off in production); set DAYBOOK_KEY_PROVISIONING_API +# explicitly (0/1) to override either way. +_key_api_env = os.environ.get("DAYBOOK_KEY_PROVISIONING_API") +if _key_api_env is None: + DAYBOOK_KEY_PROVISIONING_API = DEBUG +else: + DAYBOOK_KEY_PROVISIONING_API = _key_api_env.lower() in {"1", "true", "yes", "on"} + # Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.): # Django otherwise thinks every request is plain HTTP, which breaks CSRF checks # on login/POST. The proxy must forward X-Forwarded-Proto: https. diff --git a/deploy/env.example b/deploy/env.example index 5fe9ca3..1eb6d11 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -6,6 +6,11 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example # "CSRF verification failed" because Django can't tell the original request # was HTTPS. Set to the public HTTPS origin(s) users actually visit: DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example +# Leave OFF in deployment. When on, POST /api/keys can create/list/revoke API +# keys over the network (it returns key material), which is only appropriate +# for automated test installations. Unset, it follows DJANGO_DEBUG — so with +# DJANGO_DEBUG=0 above it is already off. Set to 1 only on a throwaway test box. +# DAYBOOK_KEY_PROVISIONING_API=0 DAYBOOK_DATA_DIR=/data DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store diff --git a/docs/agent_quickstart.md b/docs/agent_quickstart.md index d6ba619..0c78356 100644 --- a/docs/agent_quickstart.md +++ b/docs/agent_quickstart.md @@ -2,10 +2,11 @@ This is the shortest path for an agent to publish and retrieve artifacts from Daybook. -## 0. Get Your API Token +## 0. Get Your API Key -Log in to the app in a browser and copy your personal token from **Settings → Account** -(`http://127.0.0.1:8001/settings/account/`). Every artifact you post with it lands in your +Log in to the app in a browser and create a personal key under **Settings → API Keys** +(`http://127.0.0.1:8001/settings/api-keys/`). Name it after the agent so you can revoke it +later. Every artifact you post with it lands in your feed, and only yours. ## 1. Check Capabilities diff --git a/docs/api.md b/docs/api.md index b3778c2..581f8f5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,13 +8,13 @@ Base URL while running locally: http://127.0.0.1:8001 ``` -Authentication for write endpoints (POST/PATCH) — a personal per-user API token, from Settings → Account in the app: +Authentication for write endpoints (POST/PATCH/DELETE) — a personal API key, created in the app under Settings → API Keys. Each user can hold several (one per agent/script) and revoke them individually: ```http -Authorization: Bearer +Authorization: Bearer ``` -Every artifact belongs to the user whose token authenticated the POST — feeds are private per user. +Every artifact belongs to the user whose key authenticated the POST — feeds are private per user. Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a logged-in browser session, not a bearer token — visit the app in a browser and log in first. @@ -575,9 +575,40 @@ curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/ Discover it programmatically via `skill_url` in the capabilities response. +## API Keys (provisioning) + +Keys are normally created and revoked in the app under **Settings → API Keys**. +For automated test installations, a programmatic surface can provision them +too — but it is **gated** and **off in deployment**, because it returns key +material over the API. + +The gate is `DAYBOOK_KEY_PROVISIONING_API`. Unset, it follows `DJANGO_DEBUG` +(on locally, off in production); set it explicitly (`0`/`1`) to override. +`GET /api/capabilities` reports the current state as `key_provisioning_api`, +and only lists the `/api/keys` methods when it's enabled. When disabled, every +method below returns `403`. + +```http +GET /api/keys # list this user's keys (incl. key values) +POST /api/keys # body {"name": "hermes-agent"} -> the new key +DELETE /api/keys/{id} # revoke one key +``` + +Authenticated by a browser session **or** an existing bearer key, and only +ever operates on the authenticated user's own keys. + +```bash +# Create a new named key (test installations only) +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "hermes-agent"}' \ + http://127.0.0.1:8001/api/keys +``` + ## Current MVP Constraints -- Each user has a personal ingest API token; there is no shared token. +- Each user can hold several personal API keys (Settings → API Keys), each + individually revocable; there is no shared key. - Reader/browser access requires login; feeds are private per user (except per-artifact public share links — see Sharing above). - Content modification/revisions are deferred (`PATCH` edits metadata only, diff --git a/docs/deployment.md b/docs/deployment.md index aafbfad..cf83192 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -64,9 +64,10 @@ DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store ``` -There is no shared API token to configure — Daybook uses per-user tokens -(DRF `Token` model) issued from Settings → Account once a user exists. Do not -commit `deploy/.env`. +There is no shared API token to configure — Daybook uses per-user API keys +created under Settings → API Keys once a user exists (each user can hold +several, one per agent, each individually revocable). Do not commit +`deploy/.env`. Create the persistent data directory and bring the stack up: @@ -94,7 +95,7 @@ that's expected; it confirms the app is up and enforcing auth. (Don't add `curl -f` — it treats the 403 as an error. The container healthcheck uses the login page, which is 200 without auth.) Open `http://:8000/` in a browser and log in with the superuser you just -created, then grab a personal token from Settings → Account and use it for a +created, then create a personal key under Settings → API Keys and use it for a smoke-test post: ```bash @@ -191,8 +192,8 @@ Caddy: header_up X-Forwarded-Proto https - `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy. - `GET /api/capabilities` returns 403 without auth, 200 with a valid token or session (check the status code — don't use `curl -f`, which errors on 403). -- At least one superuser exists (`manage.py createsuperuser`) with a token - from Settings → Account. +- At least one superuser exists (`manage.py createsuperuser`) with a key + from Settings → API Keys. ## Known MVP Constraints diff --git a/docs/llm_posting_helper.md b/docs/llm_posting_helper.md index 6cafeb6..8fc4194 100644 --- a/docs/llm_posting_helper.md +++ b/docs/llm_posting_helper.md @@ -24,7 +24,7 @@ DAYBOOK_TOKEN=your-personal-api-token DAYBOOK_SOURCE=agent ``` -`DAYBOOK_TOKEN` is a personal per-user token, from Settings → Account in the app — not a token shared across users. Override these per environment. +`DAYBOOK_TOKEN` is a personal API key, created under Settings → API Keys in the app (one per agent, individually revocable) — not a key shared across users. Override these per environment. ## Commands diff --git a/skills/daybook-posting/SKILL.md b/skills/daybook-posting/SKILL.md index c972af9..12dd9ff 100644 --- a/skills/daybook-posting/SKILL.md +++ b/skills/daybook-posting/SKILL.md @@ -18,7 +18,7 @@ support; a `.env` file in the working directory is not read automatically. ```text DAYBOOK_URL=https://your-daybook-instance.example.com # your server's base URL -DAYBOOK_TOKEN=your-personal-api-token # from Settings -> Account +DAYBOOK_TOKEN=your-personal-api-key # from Settings -> API Keys DAYBOOK_SOURCE=agent # a stable label for what's posting ``` @@ -27,9 +27,9 @@ DAYBOOK_SOURCE=agent # a stable label for wha `http://127.0.0.1:8001`, which is only correct when Daybook is running locally on the same machine (its development default). For any remote or deployed instance you **must** set this. -- **`DAYBOOK_TOKEN`** — your personal per-user API token, from Settings → - Account in the app (not a token shared by everyone). Artifacts posted with - your token land only in your feed. +- **`DAYBOOK_TOKEN`** — a personal API key, created in the app under Settings → + API Keys (name one per agent so you can revoke them individually). Not a key + shared by everyone; artifacts posted with it land only in your feed. - **`DAYBOOK_SOURCE`** — a free-text label for whatever is posting (agent-profile, script, or person name). Optional; can also be passed per call with `--source`. diff --git a/static/daybook.css b/static/daybook.css index 37002a3..203eacf 100644 --- a/static/daybook.css +++ b/static/daybook.css @@ -990,6 +990,64 @@ kbd { word-break: break-all; } +.muted { + color: var(--muted); +} + +.api-key-create { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.api-key-create label { + display: flex; + flex-direction: column; + font-weight: 600; + gap: 0.3rem; +} + +.api-key-table { + border-collapse: collapse; + width: 100%; +} + +.api-key-table th, +.api-key-table td { + border-bottom: 1px solid var(--accent-border); + padding: 0.55rem 0.7rem; + text-align: left; + vertical-align: middle; +} + +.api-key-table th { + color: var(--muted); + font-size: 0.85em; + font-weight: 600; +} + +.api-key-cell { + align-items: center; + display: flex; + gap: 0.5rem; +} + +.api-key-cell .token-value { + font-size: 0.85em; +} + +.api-key-copy { + flex-shrink: 0; +} + +.api-key-revoke { + background: var(--danger-bg); + border: 1px solid var(--danger-text); + color: var(--danger-text); +} + .about-head { align-items: center; display: flex; diff --git a/templates/artifacts/settings_account.html b/templates/artifacts/settings_account.html index c5cec85..f8329b8 100644 --- a/templates/artifacts/settings_account.html +++ b/templates/artifacts/settings_account.html @@ -4,12 +4,10 @@

Account

Username: {{ request.user.username }}

-

API Token

-

Use this token as Authorization: Bearer <token> when posting artifacts.

-

{{ token.key }}

-
- {% csrf_token %} - - -
+

API Keys

+

+ Posting to Daybook uses named API keys — one per agent or script, each + revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}. +

+

Manage API keys →

{% endblock %} diff --git a/templates/artifacts/settings_api_keys.html b/templates/artifacts/settings_api_keys.html new file mode 100644 index 0000000..99dd069 --- /dev/null +++ b/templates/artifacts/settings_api_keys.html @@ -0,0 +1,66 @@ +{% extends "artifacts/settings_base.html" %} + +{% block settings_content %} +

API Keys

+

+ One key per agent or script. Name each key so you know which agent it's + for, copy it into that agent's DAYBOOK_TOKEN, and revoke keys + individually here. Use a key as + Authorization: Bearer <key> when posting. +

+ +

Create a key

+
+ {% csrf_token %} + + + +
+ +

Your keys

+ {% if api_keys %} + + + + + + {% for k in api_keys %} + + + + + + + + {% endfor %} + +
NameKeyCreatedLast used
{{ k.name }} + {{ k.key }} + + {{ k.created_at|date:"Y-m-d" }}{% if k.last_used_at %}{{ k.last_used_at|date:"Y-m-d H:i" }}{% else %}never{% endif %} +
+ {% csrf_token %} + + + +
+
+ {% else %} +

No keys yet. Create one above.

+ {% endif %} + + +{% endblock %} diff --git a/templates/artifacts/settings_base.html b/templates/artifacts/settings_base.html index f88f06f..40b3c01 100644 --- a/templates/artifacts/settings_base.html +++ b/templates/artifacts/settings_base.html @@ -25,6 +25,7 @@