Compare commits

...

44 Commits

Author SHA1 Message Date
Herculino Trotta fee40fd527 Merge pull request #560 from eitchtee/weblate
Translations update from Weblate
2026-07-05 20:35:03 -03:00
Herculino Trotta f59e53f6dc locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (777 of 777 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2026-07-05 23:34:35 +00:00
Dimitri Decrock 2b379987ac locale(Dutch): update translation
Currently translated at 100.0% (777 of 777 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2026-07-05 15:57:19 +00:00
eitchtee 4b8ccf426d chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-07-04 15:39:07 +00:00
Herculino Trotta 4805ce9e04 Merge pull request #557 from obervinov/obervinov/app-owned-oauth-api-tokens
feat(api): add API tokens and OAuth2 client support for external integrations
2026-07-04 12:38:44 -03:00
Herculino Trotta 845a8d846b Merge pull request #556 from eitchtee/dependabot/uv/cryptography-48.0.1
build(deps): bump cryptography from 48.0.0 to 48.0.1
2026-07-04 12:38:02 -03:00
Herculino Trotta 1497500c4f Merge pull request #559 from eitchtee/weblate
Translations update from Weblate
2026-07-04 12:37:46 -03:00
obervinov 9e9e60ccec fix: copy the raw API token from the input value
The copy button passed the token through Django's escapejs filter into the
hyperscript writeText() call, which turns every "-" into -. hyperscript
does not decode \u escapes, so any token containing "-" (common with
token_urlsafe) was copied corrupted and failed auth on paste. Copy from the
input's value instead, which holds the unescaped raw token.
2026-06-30 01:02:54 +04:00
obervinov ca14f77f41 test: cover demo-mode block on revoked-token delete
Parity with the existing demo-mode tests for token create/revoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 00:00:10 +04:00
obervinov 0fb37a59fa feat: add delete button for revoked API tokens
Revoked tokens previously stayed in the list with no way to remove them.
Adds a delete action (hard delete, scoped to the owner, gated behind
demo mode) shown on revoked rows, alongside the existing revoke action on
active ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:55:38 +04:00
sorcierwax e74d9177df locale(French): update translation
Currently translated at 100.0% (739 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2026-06-27 21:57:19 +00:00
Herculino Trotta 106d721279 feat: add demo mode tests to ensure API is disabled on it 2026-06-27 18:02:31 -03:00
Herculino Trotta d0e9c05283 feat: disable oauth and token creation while on demo mode 2026-06-27 18:02:03 -03:00
sorcierwax 4e16831f4d locale(French): update translation
Currently translated at 97.1% (718 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2026-06-27 20:57:19 +00:00
Herculino Trotta 7f5a91c11f fix: wrong Guam timezone string 2026-06-27 17:56:24 -03:00
Herculino Trotta 009a7038c8 style: improve api token box look 2026-06-27 17:56:05 -03:00
obervinov 4273c541c5 Add API tokens and OAuth2 client support for external integrations
- Personal API tokens (model, user-settings UI, admin, management command,
  DRF auth class) for non-interactive API access from automations like n8n.
  Raw token shown once; only a SHA-256 hash is stored; last_used_at writes
  are throttled.
- OAuth2 authorization server via django-oauth-toolkit with authorization
  server metadata and optional, off-by-default Dynamic Client Registration
  (RFC 7591), so remote OAuth/MCP clients can authenticate and self-register.
- Tests for token auth, DCR gating and the management commands, plus
  .env.example and README documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:15:31 +04:00
dependabot[bot] 5c4cb16a0a build(deps): bump cryptography from 48.0.0 to 48.0.1
Bumps [cryptography](https://github.com/pyca/cryptography) from 48.0.0 to 48.0.1.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/48.0.0...48.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 01:11:02 +00:00
Herculino Trotta 9641e169f2 Merge pull request #554 from eitchtee/dependabot/npm_and_yarn/frontend/multi-f57e1e291f
build(deps): bump esbuild and vite in /frontend
2026-06-12 23:42:06 -03:00
Herculino Trotta 25ff0214ab Merge pull request #555 from eitchtee/feat/tom-select-improvements
feat(tom-select): clear input after picking on a multi-item select
2026-06-12 23:38:31 -03:00
Herculino Trotta 0f9d333834 Merge pull request #553 from eitchtee/weblate
Translations update from Weblate
2026-06-12 23:37:59 -03:00
Herculino Trotta ae115cca15 feat(tom-select): clear input after picking on a multi-item select 2026-06-12 23:32:17 -03:00
dependabot[bot] bb23ac6df9 build(deps): bump esbuild and vite in /frontend
Removes [esbuild](https://github.com/evanw/esbuild). It's no longer used after updating ancestor dependency [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). These dependencies need to be updated together.


Removes `esbuild`

Updates `vite` from 7.3.2 to 8.0.16
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version:
  dependency-type: indirect
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-12 23:11:26 +00:00
Pawel Augustyn 7db0fcf097 locale(Polish): update translation
Currently translated at 73.8% (546 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-06-08 07:57:18 +00:00
Pawel Augustyn 02896f21ed locale(Polish): update translation
Currently translated at 73.8% (546 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-06-08 06:57:18 +00:00
Pawel Augustyn 5082c17d0f locale(Polish): update translation
Currently translated at 73.7% (545 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-06-08 05:57:18 +00:00
Herculino Trotta 33570296e0 Merge pull request #552 from eitchtee/weblate
Translations update from Weblate
2026-06-07 17:21:15 -03:00
Herculino Trotta fc99491f78 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (739 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2026-06-07 14:57:19 +00:00
Dimitri Decrock cb0d379261 locale(Dutch): update translation
Currently translated at 100.0% (739 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2026-06-07 08:57:18 +00:00
eitchtee 524e390a62 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-06-06 08:15:55 +00:00
Herculino Trotta db7e22b627 Merge pull request #551 from eitchtee/chore/bump-deps
chore: bump python and node dependencies
2026-06-06 05:15:27 -03:00
Herculino Trotta 6987b54dba chore: bump python and node dependencies 2026-06-06 05:14:26 -03:00
Herculino Trotta b44563b09b Merge pull request #550 from eitchtee/dependabot/npm_and_yarn/frontend/postcss-8.5.15
build(deps): bump postcss from 8.5.6 to 8.5.15 in /frontend
2026-06-06 04:42:23 -03:00
Herculino Trotta e839f31104 Merge pull request #549 from eitchtee/dependabot/uv/django-5.2.14
build(deps): bump django from 5.2.13 to 5.2.14
2026-06-06 04:42:09 -03:00
Herculino Trotta 2282625790 Merge pull request #548 from eitchtee/dependabot/uv/idna-3.15
build(deps): bump idna from 3.11 to 3.15
2026-06-06 04:41:50 -03:00
Herculino Trotta aa8b559152 Merge pull request #547 from eitchtee/dependabot/uv/mistune-3.2.1
build(deps): bump mistune from 3.1.4 to 3.2.1
2026-06-06 04:41:37 -03:00
Herculino Trotta e1862b8241 Merge pull request #546 from eitchtee/dependabot/uv/urllib3-2.7.0
build(deps): bump urllib3 from 2.6.3 to 2.7.0
2026-06-06 04:41:22 -03:00
eitchtee eb6be8548c chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-06-06 07:41:13 +00:00
Herculino Trotta 6ee4e21939 Merge pull request #545 from eitchtee/feat/attatchments
feat(transactions): add attatchments
2026-06-06 04:40:49 -03:00
dependabot[bot] bdd8aed891 build(deps): bump postcss from 8.5.6 to 8.5.15 in /frontend
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.15.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.15)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.15
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 07:39:37 +00:00
dependabot[bot] 801b2a9edd build(deps): bump django from 5.2.13 to 5.2.14
Bumps [django](https://github.com/django/django) from 5.2.13 to 5.2.14.
- [Commits](https://github.com/django/django/compare/5.2.13...5.2.14)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.2.14
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 07:39:25 +00:00
dependabot[bot] 968499f1ab build(deps): bump idna from 3.11 to 3.15
Bumps [idna](https://github.com/kjd/idna) from 3.11 to 3.15.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](https://github.com/kjd/idna/compare/v3.11...v3.15)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 07:37:29 +00:00
dependabot[bot] 5b351821b1 build(deps): bump mistune from 3.1.4 to 3.2.1
Bumps [mistune](https://github.com/lepture/mistune) from 3.1.4 to 3.2.1.
- [Release notes](https://github.com/lepture/mistune/releases)
- [Changelog](https://github.com/lepture/mistune/blob/main/docs/changes.rst)
- [Commits](https://github.com/lepture/mistune/compare/v3.1.4...v3.2.1)

---
updated-dependencies:
- dependency-name: mistune
  dependency-version: 3.2.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 07:36:43 +00:00
dependabot[bot] 7b49072848 build(deps): bump urllib3 from 2.6.3 to 2.7.0
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.3 to 2.7.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 07:36:28 +00:00
45 changed files with 9999 additions and 5134 deletions
+18
View File
@@ -38,3 +38,21 @@ TASK_WORKERS=1 # This only work if you're using the single container option. Inc
#OIDC_CLIENT_SECRET="" #OIDC_CLIENT_SECRET=""
#OIDC_SERVER_URL="" #OIDC_SERVER_URL=""
#OIDC_ALLOW_SIGNUP=true #OIDC_ALLOW_SIGNUP=true
# Personal access tokens. How often (seconds) a token's last_used_at is rewritten.
#API_TOKEN_LAST_USED_UPDATE_INTERVAL=600
# MCP OAuth Application. Uncomment to auto-create/update the OAuth client
# used by remote MCP integrations after migrations complete.
#MCP_OAUTH_CLIENT_NAME="WYGIWYH MCP"
#MCP_OAUTH_CLIENT_ID="mcp-wygiwyh"
#MCP_OAUTH_CLIENT_SECRET="<INSERT A SAFE SECRET HERE>"
#MCP_OAUTH_REDIRECT_URIS="http://127.0.0.1:8765/callback"
#MCP_OAUTH_SKIP_AUTHORIZATION=false
# Dynamic Client Registration (RFC 7591). Disabled by default because an open
# registration endpoint lets anyone create OAuth applications. Enable only if
# remote MCP clients must self-register, and optionally require an initial
# access token (sent as "Authorization: Bearer <token>" on /oauth/register/).
#OAUTH2_DCR_ENABLED=false
#OAUTH2_DCR_INITIAL_ACCESS_TOKEN=""
+43
View File
@@ -182,6 +182,49 @@ When configuring your OIDC provider, you will need to provide a callback URL (al
Replace `https://your.wygiwyh.domain` with the actual URL where your WYGIWYH instance is accessible. And `<OIDC_CLIENT_NAME>` with the slugfied value set in OIDC_CLIENT_NAME or the default `openid-connect` if you haven't set this variable. Replace `https://your.wygiwyh.domain` with the actual URL where your WYGIWYH instance is accessible. And `<OIDC_CLIENT_NAME>` with the slugfied value set in OIDC_CLIENT_NAME or the default `openid-connect` if you haven't set this variable.
### API Tokens for n8n and other automations
If you need a stable non-browser credential for automations such as `n8n`, WYGIWYH can also issue its own user-bound API tokens. This avoids Keycloak login flows and can be used directly against `/api/`.
Create a token from the container or application shell:
```bash
python manage.py create_api_token you@example.com --name n8n
```
Optional expiration:
```bash
python manage.py create_api_token you@example.com --name n8n --expires-in-days 90
```
The command prints the raw token **once**. Store it in your secret manager and use it like this:
```bash
curl -H "Authorization: Token wygiwyh_pat_<key>.<secret>" \
https://your.wygiwyh.domain/api/accounts/
```
Recommended usage for automation is a dedicated WYGIWYH user such as `n8n@...`, so API ownership and audit trails stay separate from your interactive account.
### MCP OAuth Application Bootstrap
If you want WYGIWYH to act as the OAuth authorization server for a remote MCP server, you can let the container create or update the OAuth application automatically on startup.
Set these environment variables:
| Variable | Description |
|---|---|
| `MCP_OAUTH_CLIENT_NAME` | Optional display name for the OAuth client. Defaults to `WYGIWYH MCP`. |
| `MCP_OAUTH_CLIENT_ID` | Client ID that will be created or updated in `django-oauth-toolkit`. |
| `MCP_OAUTH_CLIENT_SECRET` | Client secret for that OAuth application. |
| `MCP_OAUTH_REDIRECT_URIS` | Space-separated redirect URIs allowed for the MCP OAuth client. |
| `MCP_OAUTH_SKIP_AUTHORIZATION` | Set to `true` to bypass the consent screen. Defaults to `false`. |
When these variables are present, startup runs `python manage.py setup_oauth` after migrations and keeps the OAuth application in sync without needing a manual Django admin step.
WYGIWYH also exposes OAuth Dynamic Client Registration at `/.well-known/oauth-authorization-server` via `registration_endpoint`, so MCP clients that support RFC 7591 can self-register instead of relying on a pre-created `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET`. The current implementation supports `authorization_code` + PKCE clients using `none`, `client_secret_basic`, or `client_secret_post` token endpoint auth methods.
# How it works # How it works
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information. Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
+33 -2
View File
@@ -72,6 +72,7 @@ INSTALLED_APPS = [
"rest_framework", "rest_framework",
"rest_framework.authtoken", "rest_framework.authtoken",
"drf_spectacular", "drf_spectacular",
"oauth2_provider",
"django_cotton", "django_cotton",
"apps.rules.apps.RulesConfig", "apps.rules.apps.RulesConfig",
"apps.calendar_view.apps.CalendarViewConfig", "apps.calendar_view.apps.CalendarViewConfig",
@@ -344,6 +345,11 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/login/" LOGIN_URL = "/login/"
LOGOUT_REDIRECT_URL = "/login/" LOGOUT_REDIRECT_URL = "/login/"
# Public base URL advertised in OAuth metadata. Falls back to the first entry
# of the existing space-separated URL env var, then to the request host.
PUBLIC_BASE_URL = (
os.getenv("PUBLIC_BASE_URL", "") or os.getenv("URL", "").split(" ")[0]
).rstrip("/")
# Allauth settings # Allauth settings
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
@@ -382,6 +388,12 @@ SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter" ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
SOCIALACCOUNT_ADAPTER = "apps.users.adapters.AutoConnectSocialAccountAdapter" SOCIALACCOUNT_ADAPTER = "apps.users.adapters.AutoConnectSocialAccountAdapter"
# Personal access tokens. last_used_at is only rewritten once per interval to
# avoid a database write on every authenticated request.
API_TOKEN_LAST_USED_UPDATE_INTERVAL = int(
os.getenv("API_TOKEN_LAST_USED_UPDATE_INTERVAL", "600")
)
# CRISPY FORMS # CRISPY FORMS
CRISPY_ALLOWED_TEMPLATE_PACKS = [ CRISPY_ALLOWED_TEMPLATE_PACKS = [
"crispy_forms/pure_text", "crispy_forms/pure_text",
@@ -446,14 +458,33 @@ REST_FRAMEWORK = {
"rest_framework.filters.OrderingFilter", "rest_framework.filters.OrderingFilter",
], ],
"DEFAULT_AUTHENTICATION_CLASSES": [ "DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.BasicAuthentication", "oauth2_provider.contrib.rest_framework.OAuth2Authentication",
"rest_framework.authentication.SessionAuthentication", "apps.api.authentication.APITokenAuthentication",
"rest_framework.authentication.TokenAuthentication", "rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.BasicAuthentication",
], ],
"DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination", "DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
} }
OAUTH2_PROVIDER = {
"PKCE_REQUIRED": True,
"ACCESS_TOKEN_EXPIRE_SECONDS": int(
os.getenv("OAUTH2_ACCESS_TOKEN_EXPIRE_SECONDS", "3600")
),
"SCOPES": {
"mcp": "Access WYGIWYH from MCP clients.",
},
}
# Dynamic Client Registration (RFC 7591). Disabled by default: an open
# registration endpoint lets anyone create OAuth applications. Enable it only
# when remote MCP clients must self-register, and optionally require an initial
# access token presented as `Authorization: Bearer <token>`.
OAUTH2_DCR_ENABLED = os.getenv("OAUTH2_DCR_ENABLED", "false").lower() == "true"
OAUTH2_DCR_INITIAL_ACCESS_TOKEN = os.getenv("OAUTH2_DCR_INITIAL_ACCESS_TOKEN", "")
SPECTACULAR_SETTINGS = { SPECTACULAR_SETTINGS = {
"TITLE": "WYGIWYH API", "TITLE": "WYGIWYH API",
"DESCRIPTION": "A no-frills expense tracker", "DESCRIPTION": "A no-frills expense tracker",
+37
View File
@@ -22,6 +22,29 @@ from drf_spectacular.views import (
SpectacularSwaggerView, SpectacularSwaggerView,
) )
from allauth.socialaccount.providers.openid_connect.views import login, callback from allauth.socialaccount.providers.openid_connect.views import login, callback
from apps.common.decorators.demo import disabled_on_demo
from apps.common.oauth_views import (
authorization_server_metadata,
dynamic_client_registration,
)
from oauth2_provider import urls as _dot_urls
def _decorate_included(patterns, decorator):
"""Apply ``decorator`` to every view callback inside an included URLconf.
django.urls does not support decorating ``include()`` directly, so we wrap
each URLPattern's callback here. The OAuth2 endpoints issue credentials, so
gate them behind the same DEMO-mode guard used elsewhere.
"""
wrapped = []
for pattern in patterns:
pattern.callback = decorator(pattern.callback)
wrapped.append(pattern)
return wrapped
_oauth_patterns = _decorate_included(_dot_urls.urlpatterns, disabled_on_demo)
urlpatterns = [ urlpatterns = [
@@ -39,6 +62,20 @@ urlpatterns = [
name="swagger-ui", name="swagger-ui",
), ),
path("auth/", include("allauth.urls")), # allauth urls path("auth/", include("allauth.urls")), # allauth urls
path(
"oauth/",
include((_oauth_patterns, _dot_urls.app_name), namespace="oauth2_provider"),
),
path(
".well-known/oauth-authorization-server",
disabled_on_demo(authorization_server_metadata),
name="oauth-authorization-server-metadata",
),
path(
"oauth/register/",
disabled_on_demo(dynamic_client_registration),
name="oauth-dynamic-client-registration",
),
# path("auth/oidc/<str:provider_id>/login/", login, name="openid_connect_login"), # path("auth/oidc/<str:provider_id>/login/", login, name="openid_connect_login"),
# path( # path(
# "auth/oidc/<str:provider_id>/login/callback/", # "auth/oidc/<str:provider_id>/login/callback/",
+64
View File
@@ -0,0 +1,64 @@
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed
from apps.users.models import APIToken
class APITokenAuthentication(BaseAuthentication):
keyword = "Token"
def authenticate(self, request):
auth = get_authorization_header(request).split()
if not auth or auth[0].lower() != self.keyword.lower().encode():
return None
if len(auth) != 2:
raise AuthenticationFailed("Invalid API token header.")
try:
raw_token = auth[1].decode("utf-8")
except UnicodeDecodeError as exc:
raise AuthenticationFailed("Invalid API token header.") from exc
# Only claim tokens carrying our prefix; otherwise return None so the
# request falls through to other authenticators (e.g. DRF's built-in
# TokenAuthentication, which shares the "Token" keyword).
if not raw_token.startswith(APIToken.TOKEN_PREFIX):
return None
try:
token_key, token_secret = APIToken.parse_raw_token(raw_token)
except ValueError as exc:
raise AuthenticationFailed("Invalid API token.") from exc
token = APIToken.objects.select_related("user").filter(token_key=token_key).first()
if token is None or not token.check_secret(token_secret):
raise AuthenticationFailed("Invalid API token.")
if token.revoked_at is not None:
raise AuthenticationFailed("API token has been revoked.")
if token.is_expired():
raise AuthenticationFailed("API token has expired.")
if not token.user.is_active:
raise AuthenticationFailed("User account is disabled.")
self._touch_last_used(token)
return (token.user, token)
@staticmethod
def _touch_last_used(token):
# Avoid a write on every request: only refresh once per interval.
now = timezone.now()
interval = settings.API_TOKEN_LAST_USED_UPDATE_INTERVAL
if (
token.last_used_at is None
or (now - token.last_used_at) >= timedelta(seconds=interval)
):
token.last_used_at = now
token.save(update_fields=["last_used_at"])
def authenticate_header(self, request):
return self.keyword
+109
View File
@@ -0,0 +1,109 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase, override_settings
from django.utils import timezone
from rest_framework.exceptions import AuthenticationFailed
from apps.api.authentication import APITokenAuthentication
from apps.users.models import APIToken
class APITokenAuthenticationTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.authentication = APITokenAuthentication()
self.user = get_user_model().objects.create_user(
email="automation@example.com",
password="test-password",
)
def test_returns_none_without_token_header(self):
request = self.factory.get("/api/accounts/")
self.assertIsNone(self.authentication.authenticate(request))
def test_authenticates_valid_api_token(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
authenticated_user, authenticated_token = self.authentication.authenticate(request)
self.assertEqual(authenticated_user, self.user)
self.assertEqual(authenticated_token.pk, token.pk)
token.refresh_from_db()
self.assertIsNotNone(token.last_used_at)
def test_rejects_expired_api_token(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
token.expires_at = timezone.now() - timedelta(minutes=1)
token.save(update_fields=["expires_at"])
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
with self.assertRaisesRegex(AuthenticationFailed, "expired"):
self.authentication.authenticate(request)
def test_rejects_revoked_api_token(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
token.revoked_at = timezone.now()
token.save(update_fields=["revoked_at"])
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
with self.assertRaisesRegex(AuthenticationFailed, "revoked"):
self.authentication.authenticate(request)
def test_stores_secret_as_sha256_not_raw(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
_key, secret = APIToken.parse_raw_token(raw_token)
self.assertNotIn(secret, token.token_hash)
self.assertEqual(len(token.token_hash), 64)
self.assertTrue(token.check_secret(secret))
def test_falls_through_for_non_prefixed_token(self):
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION="Token deadbeefdeadbeefdeadbeef",
)
# Not our prefix: return None so another authenticator can handle it.
self.assertIsNone(self.authentication.authenticate(request))
@override_settings(API_TOKEN_LAST_USED_UPDATE_INTERVAL=600)
def test_last_used_at_is_throttled_within_interval(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
self.authentication.authenticate(request)
token.refresh_from_db()
first_used = token.last_used_at
self.assertIsNotNone(first_used)
self.authentication.authenticate(request)
token.refresh_from_db()
self.assertEqual(token.last_used_at, first_used)
@override_settings(API_TOKEN_LAST_USED_UPDATE_INTERVAL=0)
def test_last_used_at_updates_after_interval(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
token.last_used_at = timezone.now() - timedelta(minutes=5)
token.save(update_fields=["last_used_at"])
stale = token.last_used_at
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
self.authentication.authenticate(request)
token.refresh_from_db()
self.assertGreater(token.last_used_at, stale)
+166
View File
@@ -0,0 +1,166 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from oauth2_provider.models import get_access_token_model, get_application_model
from apps.users.models import APIToken
User = get_user_model()
Application = get_application_model()
AccessToken = get_access_token_model()
@override_settings(DEMO=True)
class DemoModeAPITests(TestCase):
"""The DEMO-mode gate (apps.api.permissions.NotInDemoMode) must reject
API access regardless of the authentication method used, including the
PAT and OAuth2 backends introduced for MCP integrations."""
def setUp(self):
self.user = User.objects.create_user(
email="demo@example.com",
password="test-password",
)
def test_pat_cannot_access_api_in_demo_mode(self):
_token, raw_token = APIToken.objects.create_token(
user=self.user, name="n8n"
)
response = self.client.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
self.assertEqual(response.status_code, 403)
def test_oauth_access_token_cannot_access_api_in_demo_mode(self):
app = Application.objects.create(
name="Test Client",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
redirect_uris="http://127.0.0.1:8765/callback",
client_secret="secret",
)
access_token = AccessToken.objects.create(
user=self.user,
scope="mcp",
expires=timezone.now() + timedelta(hours=1),
token="demo-oauth-access-token-xyz",
application=app,
)
response = self.client.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Bearer {access_token.token}",
)
self.assertEqual(response.status_code, 403)
def test_superuser_pat_can_access_api_in_demo_mode(self):
admin = User.objects.create_superuser(
email="admin@example.com",
password="test-password",
)
_token, raw_token = APIToken.objects.create_token(
user=admin, name="admin"
)
response = self.client.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
# NotInDemoMode grants superusers access in DEMO mode; the request is
# authenticated by the PAT, so the API responds normally (never 403).
self.assertNotEqual(response.status_code, 403)
@override_settings(DEMO=True)
class DemoModeOAuthEndpointTests(TestCase):
"""OAuth2 issuance and discovery endpoints must be disabled in DEMO mode
so demo tenants cannot obtain (or even discover) credentials."""
def setUp(self):
self.user = User.objects.create_user(
email="demo@example.com",
password="test-password",
)
def test_oauth_authorize_rejects_non_superuser_in_demo_mode(self):
self.client.force_login(self.user)
response = self.client.get(reverse("oauth2_provider:authorize"))
self.assertEqual(response.status_code, 403)
def test_oauth_token_rejects_non_superuser_in_demo_mode(self):
self.client.force_login(self.user)
response = self.client.post(reverse("oauth2_provider:token"))
self.assertEqual(response.status_code, 403)
def test_oauth_authorization_server_metadata_rejects_in_demo_mode(self):
response = self.client.get(reverse("oauth-authorization-server-metadata"))
self.assertEqual(response.status_code, 403)
def test_oauth_dynamic_client_registration_rejects_in_demo_mode(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data="{}",
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
@override_settings(DEMO=True)
class DemoModeAPITokenViewsTests(TestCase):
"""The PAT management UI must be disabled in DEMO mode just like the
other mutating user views."""
def setUp(self):
self.user = User.objects.create_user(
email="demo@example.com",
password="test-password",
)
self.client.force_login(self.user)
self.htmx_headers = {"HTTP_HX_REQUEST": "true"}
def test_cannot_create_api_token_from_ui_in_demo_mode(self):
response = self.client.post(
reverse("user_api_token_add"),
{"name": "n8n", "expires_in_days": "30"},
**self.htmx_headers,
)
self.assertEqual(response.status_code, 403)
self.assertEqual(APIToken.objects.count(), 0)
def test_cannot_revoke_api_token_from_ui_in_demo_mode(self):
token, _ = APIToken.objects.create_token(user=self.user, name="n8n")
response = self.client.delete(
reverse("user_api_token_revoke", kwargs={"token_id": token.id}),
**self.htmx_headers,
)
self.assertEqual(response.status_code, 403)
token.refresh_from_db()
self.assertIsNone(token.revoked_at)
def test_cannot_delete_api_token_from_ui_in_demo_mode(self):
token, _ = APIToken.objects.create_token(user=self.user, name="n8n")
response = self.client.delete(
reverse("user_api_token_delete", kwargs={"token_id": token.id}),
**self.htmx_headers,
)
self.assertEqual(response.status_code, 403)
self.assertTrue(APIToken.objects.filter(id=token.id).exists())
@@ -0,0 +1,58 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from apps.users.models import APIToken
class Command(BaseCommand):
help = "Creates a hashed API token for a WYGIWYH user and prints the raw token once."
def add_arguments(self, parser):
parser.add_argument("email", help="WYGIWYH user email that will own this token.")
parser.add_argument(
"--name",
default="n8n",
help="Human-readable token name. Defaults to 'n8n'.",
)
parser.add_argument(
"--expires-in-days",
type=int,
default=None,
help="Optional token lifetime in whole days.",
)
def handle(self, *args, **options):
email = options["email"].strip()
name = options["name"].strip()
expires_in_days = options["expires_in_days"]
if not email:
raise CommandError("Email is required.")
if not name:
raise CommandError("Token name cannot be empty.")
if expires_in_days is not None and expires_in_days <= 0:
raise CommandError("--expires-in-days must be greater than zero.")
user = get_user_model().objects.filter(email__iexact=email).first()
if user is None:
raise CommandError(f"No WYGIWYH user exists for '{email}'.")
expires_at = None
if expires_in_days is not None:
expires_at = timezone.now() + timedelta(days=expires_in_days)
token, raw_token = APIToken.objects.create_token(
user=user,
name=name,
expires_at=expires_at,
)
self.stdout.write(
self.style.SUCCESS(
f"Created API token '{token.name}' for {user.email} ({token.token_key})."
)
)
self.stdout.write(raw_token)
@@ -0,0 +1,129 @@
import os
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from oauth2_provider.models import get_application_model
Application = get_application_model()
def _get_env(name: str) -> str:
return os.getenv(name, "").strip()
def _get_bool_env(name: str, default: bool = False) -> bool:
raw = _get_env(name)
if not raw:
return default
return raw.lower() in {"1", "true", "yes", "on"}
class Command(BaseCommand):
help = (
"Creates or updates the OAuth application used by MCP clients when "
"MCP_OAUTH_CLIENT_* environment variables are configured."
)
def handle(self, *args, **options):
client_id = _get_env("MCP_OAUTH_CLIENT_ID")
client_secret = _get_env("MCP_OAUTH_CLIENT_SECRET")
redirect_uris = " ".join(_get_env("MCP_OAUTH_REDIRECT_URIS").split())
name = _get_env("MCP_OAUTH_CLIENT_NAME") or "WYGIWYH MCP"
skip_authorization = _get_bool_env("MCP_OAUTH_SKIP_AUTHORIZATION", default=False)
if not any([client_id, client_secret, redirect_uris]):
self.stdout.write(
self.style.NOTICE(
"MCP OAuth client env vars are not set. Skipping OAuth application setup."
)
)
return
missing = []
if not client_id:
missing.append("MCP_OAUTH_CLIENT_ID")
if not client_secret:
missing.append("MCP_OAUTH_CLIENT_SECRET")
if not redirect_uris:
missing.append("MCP_OAUTH_REDIRECT_URIS")
if missing:
raise CommandError(
"Missing required MCP OAuth settings: " + ", ".join(missing)
)
application, created = Application.objects.get_or_create(
client_id=client_id,
defaults={
"name": name,
"client_type": Application.CLIENT_CONFIDENTIAL,
"authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE,
"redirect_uris": redirect_uris,
"skip_authorization": skip_authorization,
"client_secret": client_secret,
"hash_client_secret": True,
},
)
updated_fields = []
if application.name != name:
application.name = name
updated_fields.append("name")
if application.client_type != Application.CLIENT_CONFIDENTIAL:
application.client_type = Application.CLIENT_CONFIDENTIAL
updated_fields.append("client_type")
if (
application.authorization_grant_type
!= Application.GRANT_AUTHORIZATION_CODE
):
application.authorization_grant_type = Application.GRANT_AUTHORIZATION_CODE
updated_fields.append("authorization_grant_type")
if application.redirect_uris != redirect_uris:
application.redirect_uris = redirect_uris
updated_fields.append("redirect_uris")
if application.skip_authorization != skip_authorization:
application.skip_authorization = skip_authorization
updated_fields.append("skip_authorization")
if application.hash_client_secret is not True:
application.hash_client_secret = True
updated_fields.append("hash_client_secret")
if not application.client_secret or not check_password(
client_secret,
application.client_secret,
):
application.client_secret = client_secret
updated_fields.append("client_secret")
try:
application.full_clean()
except ValidationError as exc:
errors = "; ".join(
f"{field}: {', '.join(messages)}"
for field, messages in exc.message_dict.items()
)
raise CommandError(f"Invalid MCP OAuth application settings: {errors}") from exc
if created:
application.save()
self.stdout.write(
self.style.SUCCESS(
f"Created MCP OAuth application '{application.client_id}'."
)
)
return
if updated_fields:
application.save(update_fields=updated_fields)
self.stdout.write(
self.style.SUCCESS(
f"Updated MCP OAuth application '{application.client_id}'."
)
)
return
self.stdout.write(
self.style.SUCCESS(
f"MCP OAuth application '{application.client_id}' is already up to date."
)
)
+253
View File
@@ -0,0 +1,253 @@
import hmac
import json
import time
from secrets import token_urlsafe
from django.conf import settings
from django.core.exceptions import ValidationError
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from oauth2_provider.models import get_application_model
Application = get_application_model()
SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS = {
"none": Application.CLIENT_PUBLIC,
"client_secret_basic": Application.CLIENT_CONFIDENTIAL,
"client_secret_post": Application.CLIENT_CONFIDENTIAL,
}
SUPPORTED_GRANT_TYPES = {"authorization_code", "refresh_token"}
SUPPORTED_RESPONSE_TYPES = {"code"}
def _base_url(request):
return settings.PUBLIC_BASE_URL or request.build_absolute_uri("/").rstrip("/")
def _json_error(error, error_description, status=400):
response = JsonResponse(
{"error": error, "error_description": error_description},
status=status,
)
response["Cache-Control"] = "no-store"
response["Pragma"] = "no-cache"
return response
def _set_no_store_headers(response):
response["Cache-Control"] = "no-store"
response["Pragma"] = "no-cache"
return response
def _parse_json_request_body(request):
try:
payload = json.loads(request.body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("Request body must be valid JSON.") from exc
if not isinstance(payload, dict):
raise ValueError("Request body must be a JSON object.")
return payload
def _get_string_list(payload, field_name, *, required=False, default=None):
value = payload.get(field_name, default)
if value is None:
if required:
raise ValueError(f"'{field_name}' is required.")
return None
if not isinstance(value, list) or not value:
raise ValueError(f"'{field_name}' must be a non-empty array of strings.")
normalized = []
for item in value:
if not isinstance(item, str) or not item.strip():
raise ValueError(f"'{field_name}' must contain only non-empty strings.")
normalized.append(item.strip())
return normalized
def _get_supported_scopes():
return set(settings.OAUTH2_PROVIDER.get("SCOPES", {}).keys())
def _dcr_initial_access_token_ok(request):
"""Validate the optional RFC 7591 initial access token, if one is configured."""
expected = settings.OAUTH2_DCR_INITIAL_ACCESS_TOKEN
if not expected:
return True
header = request.META.get("HTTP_AUTHORIZATION", "")
scheme, _, value = header.partition(" ")
if scheme.lower() != "bearer" or not value:
return False
return hmac.compare_digest(value, expected)
@require_http_methods(["GET"])
def authorization_server_metadata(request):
base_url = _base_url(request)
metadata = {
"issuer": base_url,
"authorization_endpoint": f"{base_url}/oauth/authorize/",
"token_endpoint": f"{base_url}/oauth/token/",
"revocation_endpoint": f"{base_url}/oauth/revoke_token/",
"introspection_endpoint": f"{base_url}/oauth/introspect/",
"scopes_supported": sorted(settings.OAUTH2_PROVIDER["SCOPES"].keys()),
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": [
"none",
"client_secret_basic",
"client_secret_post",
],
"code_challenge_methods_supported": ["S256"],
}
# Only advertise registration when DCR is actually enabled.
if settings.OAUTH2_DCR_ENABLED:
metadata["registration_endpoint"] = f"{base_url}/oauth/register/"
return JsonResponse(metadata)
@csrf_exempt
@require_http_methods(["POST"])
def dynamic_client_registration(request):
if not settings.OAUTH2_DCR_ENABLED:
return _json_error(
"not_found",
"Dynamic client registration is disabled.",
status=404,
)
if not _dcr_initial_access_token_ok(request):
return _json_error(
"invalid_token",
"A valid initial access token is required to register a client.",
status=401,
)
try:
payload = _parse_json_request_body(request)
redirect_uris = _get_string_list(payload, "redirect_uris", required=True)
grant_types = _get_string_list(
payload,
"grant_types",
default=["authorization_code"],
)
response_types = _get_string_list(
payload,
"response_types",
default=["code"],
)
except ValueError as exc:
return _json_error("invalid_client_metadata", str(exc))
unsupported_grant_types = sorted(set(grant_types) - SUPPORTED_GRANT_TYPES)
if unsupported_grant_types:
return _json_error(
"invalid_client_metadata",
"Unsupported grant_types: " + ", ".join(unsupported_grant_types),
)
if "authorization_code" not in grant_types:
return _json_error(
"invalid_client_metadata",
"grant_types must include 'authorization_code'.",
)
unsupported_response_types = sorted(set(response_types) - SUPPORTED_RESPONSE_TYPES)
if unsupported_response_types:
return _json_error(
"invalid_client_metadata",
"Unsupported response_types: "
+ ", ".join(unsupported_response_types),
)
if "code" not in response_types:
return _json_error(
"invalid_client_metadata",
"response_types must include 'code'.",
)
token_endpoint_auth_method = payload.get(
"token_endpoint_auth_method",
"client_secret_basic",
)
if token_endpoint_auth_method not in SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS:
return _json_error(
"invalid_client_metadata",
"Unsupported token_endpoint_auth_method: "
+ token_endpoint_auth_method,
)
supported_scopes = _get_supported_scopes()
raw_scope = payload.get("scope", "mcp")
if not isinstance(raw_scope, str):
return _json_error(
"invalid_client_metadata",
"'scope' must be a space-delimited string.",
)
requested_scope = raw_scope.strip() or "mcp"
requested_scopes = set(requested_scope.split())
unsupported_scopes = sorted(requested_scopes - supported_scopes)
if unsupported_scopes:
return _json_error(
"invalid_client_metadata",
"Unsupported scope values: " + ", ".join(unsupported_scopes),
)
client_name = str(payload.get("client_name", "Dynamic MCP Client")).strip()
if not client_name:
client_name = "Dynamic MCP Client"
client_secret = None
client_type = SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS[token_endpoint_auth_method]
if client_type == Application.CLIENT_CONFIDENTIAL:
client_secret = token_urlsafe(48)
application = Application(
name=client_name,
client_type=client_type,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
redirect_uris=" ".join(redirect_uris),
skip_authorization=False,
hash_client_secret=True,
client_secret=client_secret or "",
)
try:
application.full_clean()
except ValidationError as exc:
errors = []
for field, messages in exc.message_dict.items():
errors.extend(f"{field}: {message}" for message in messages)
return _json_error(
"invalid_client_metadata",
"; ".join(errors),
)
application.save()
response_payload = {
"client_id": application.client_id,
"client_id_issued_at": int(time.time()),
"client_name": client_name,
"redirect_uris": redirect_uris,
# Report what was actually provisioned, not the raw request echo. The app
# is created with the authorization_code grant; refresh_token is implicit
# to that grant in django-oauth-toolkit rather than a separate capability.
"grant_types": sorted(set(grant_types) & SUPPORTED_GRANT_TYPES),
"response_types": sorted(set(response_types) & SUPPORTED_RESPONSE_TYPES),
"scope": " ".join(sorted(requested_scopes)),
"token_endpoint_auth_method": token_endpoint_auth_method,
}
if client_secret is not None:
response_payload["client_secret"] = client_secret
response_payload["client_secret_expires_at"] = 0
return _set_no_store_headers(JsonResponse(response_payload, status=201))
+298
View File
@@ -0,0 +1,298 @@
import os
import json
from io import StringIO
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import check_password
from django.core.management import call_command
from django.test import SimpleTestCase, TestCase, override_settings
from django.utils import timezone
from django.urls import reverse
from oauth2_provider.models import get_application_model
from apps.users.models import APIToken
Application = get_application_model()
@override_settings(
PUBLIC_BASE_URL="https://wygiwyh.example.com",
SECRET_KEY="test-secret-key",
OAUTH2_PROVIDER={"SCOPES": {"mcp": "Access WYGIWYH from MCP clients."}},
)
class AuthorizationServerMetadataTests(SimpleTestCase):
@override_settings(OAUTH2_DCR_ENABLED=True)
def test_returns_oauth_authorization_server_metadata(self):
response = self.client.get(reverse("oauth-authorization-server-metadata"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["issuer"], "https://wygiwyh.example.com")
self.assertEqual(
response.json()["authorization_endpoint"],
"https://wygiwyh.example.com/oauth/authorize/",
)
self.assertEqual(
response.json()["registration_endpoint"],
"https://wygiwyh.example.com/oauth/register/",
)
self.assertEqual(response.json()["scopes_supported"], ["mcp"])
self.assertIn("none", response.json()["token_endpoint_auth_methods_supported"])
@override_settings(OAUTH2_DCR_ENABLED=False)
def test_omits_registration_endpoint_when_dcr_disabled(self):
response = self.client.get(reverse("oauth-authorization-server-metadata"))
self.assertEqual(response.status_code, 200)
self.assertNotIn("registration_endpoint", response.json())
@override_settings(
PUBLIC_BASE_URL="https://wygiwyh.example.com",
SECRET_KEY="test-secret-key",
OAUTH2_PROVIDER={"SCOPES": {"mcp": "Access WYGIWYH from MCP clients."}},
OAUTH2_DCR_ENABLED=True,
OAUTH2_DCR_INITIAL_ACCESS_TOKEN="",
)
class DynamicClientRegistrationTests(TestCase):
def test_registers_public_client_for_pkce_flow(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps(
{
"client_name": "Copilot MCP",
"redirect_uris": ["http://127.0.0.1:8765/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "mcp",
"token_endpoint_auth_method": "none",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
payload = response.json()
self.assertEqual(payload["client_name"], "Copilot MCP")
self.assertEqual(
payload["redirect_uris"],
["http://127.0.0.1:8765/callback"],
)
self.assertEqual(
payload["grant_types"],
["authorization_code", "refresh_token"],
)
self.assertEqual(payload["response_types"], ["code"])
self.assertEqual(payload["scope"], "mcp")
self.assertEqual(payload["token_endpoint_auth_method"], "none")
self.assertNotIn("client_secret", payload)
application = Application.objects.get(client_id=payload["client_id"])
self.assertEqual(application.name, "Copilot MCP")
self.assertEqual(application.client_type, Application.CLIENT_PUBLIC)
self.assertEqual(
application.authorization_grant_type,
Application.GRANT_AUTHORIZATION_CODE,
)
self.assertEqual(
application.redirect_uris,
"http://127.0.0.1:8765/callback",
)
def test_registers_confidential_client_with_generated_secret(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps(
{
"client_name": "Confidential MCP",
"redirect_uris": ["http://127.0.0.1:8765/callback"],
"token_endpoint_auth_method": "client_secret_basic",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
payload = response.json()
self.assertEqual(payload["token_endpoint_auth_method"], "client_secret_basic")
self.assertEqual(payload["scope"], "mcp")
self.assertEqual(payload["client_secret_expires_at"], 0)
self.assertTrue(payload["client_secret"])
application = Application.objects.get(client_id=payload["client_id"])
self.assertEqual(application.client_type, Application.CLIENT_CONFIDENTIAL)
self.assertTrue(check_password(payload["client_secret"], application.client_secret))
def test_rejects_unsupported_token_auth_method(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps(
{
"redirect_uris": ["http://127.0.0.1:8765/callback"],
"token_endpoint_auth_method": "private_key_jwt",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "invalid_client_metadata")
self.assertIn("token_endpoint_auth_method", response.json()["error_description"])
def test_rejects_missing_redirect_uris(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps({"client_name": "No redirect"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "invalid_client_metadata")
self.assertIn("redirect_uris", response.json()["error_description"])
@override_settings(OAUTH2_DCR_ENABLED=False)
def test_returns_404_when_dcr_disabled(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps({"redirect_uris": ["http://127.0.0.1:8765/callback"]}),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
self.assertEqual(Application.objects.count(), 0)
@override_settings(
PUBLIC_BASE_URL="https://wygiwyh.example.com",
SECRET_KEY="test-secret-key",
OAUTH2_PROVIDER={"SCOPES": {"mcp": "Access WYGIWYH from MCP clients."}},
OAUTH2_DCR_ENABLED=True,
OAUTH2_DCR_INITIAL_ACCESS_TOKEN="s3cret-iat",
)
class DynamicClientRegistrationInitialAccessTokenTests(TestCase):
def test_rejects_registration_without_initial_access_token(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps({"redirect_uris": ["http://127.0.0.1:8765/callback"]}),
content_type="application/json",
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["error"], "invalid_token")
self.assertEqual(Application.objects.count(), 0)
def test_allows_registration_with_initial_access_token(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps(
{
"redirect_uris": ["http://127.0.0.1:8765/callback"],
"token_endpoint_auth_method": "none",
}
),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer s3cret-iat",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Application.objects.count(), 1)
class SetupOAuthCommandTests(TestCase):
@patch.dict(
os.environ,
{
"MCP_OAUTH_CLIENT_ID": "mcp-wygiwyh",
"MCP_OAUTH_CLIENT_SECRET": "super-secret",
"MCP_OAUTH_REDIRECT_URIS": "http://127.0.0.1:8765/callback",
},
clear=False,
)
def test_creates_mcp_oauth_application(self):
call_command("setup_oauth")
application = Application.objects.get(client_id="mcp-wygiwyh")
self.assertEqual(application.name, "WYGIWYH MCP")
self.assertEqual(application.client_type, Application.CLIENT_CONFIDENTIAL)
self.assertEqual(
application.authorization_grant_type,
Application.GRANT_AUTHORIZATION_CODE,
)
self.assertEqual(
application.redirect_uris,
"http://127.0.0.1:8765/callback",
)
self.assertFalse(application.skip_authorization)
self.assertTrue(check_password("super-secret", application.client_secret))
@patch.dict(
os.environ,
{
"MCP_OAUTH_CLIENT_ID": "mcp-wygiwyh",
"MCP_OAUTH_CLIENT_SECRET": "new-secret",
"MCP_OAUTH_REDIRECT_URIS": "http://127.0.0.1:8765/callback http://localhost:8765/callback",
"MCP_OAUTH_CLIENT_NAME": "WYGIWYH MCP Production",
"MCP_OAUTH_SKIP_AUTHORIZATION": "true",
},
clear=False,
)
def test_updates_existing_mcp_oauth_application(self):
Application.objects.create(
client_id="mcp-wygiwyh",
client_secret="old-secret",
name="Old Name",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
redirect_uris="http://127.0.0.1:8765/callback",
skip_authorization=False,
)
call_command("setup_oauth")
application = Application.objects.get(client_id="mcp-wygiwyh")
self.assertEqual(application.name, "WYGIWYH MCP Production")
self.assertEqual(
application.redirect_uris,
"http://127.0.0.1:8765/callback http://localhost:8765/callback",
)
self.assertTrue(application.skip_authorization)
self.assertTrue(check_password("new-secret", application.client_secret))
class CreateAPITokenCommandTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
email="n8n@example.com",
password="test-password",
)
def test_creates_hashed_api_token_and_prints_raw_value(self):
stdout = StringIO()
call_command(
"create_api_token",
self.user.email,
"--name",
"n8n sync",
stdout=stdout,
)
token = APIToken.objects.get(user=self.user, name="n8n sync")
lines = [line.strip() for line in stdout.getvalue().splitlines() if line.strip()]
raw_token = lines[-1]
self.assertTrue(raw_token.startswith(APIToken.TOKEN_PREFIX))
self.assertNotEqual(token.token_hash, raw_token)
self.assertTrue(token.check_secret(APIToken.parse_raw_token(raw_token)[1]))
def test_supports_expiring_tokens(self):
call_command(
"create_api_token",
self.user.email,
"--expires-in-days",
"7",
)
token = APIToken.objects.get(user=self.user)
self.assertIsNotNone(token.expires_at)
self.assertGreater(token.expires_at, timezone.now())
+37 -1
View File
@@ -4,13 +4,19 @@ from django.contrib.auth.forms import (
UserCreationForm, UserCreationForm,
AdminPasswordChangeForm, AdminPasswordChangeForm,
) )
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from apps.users.models import User, UserSettings from apps.users.models import APIToken, User, UserSettings
@admin.action(description=_("Revoke selected API tokens"))
def revoke_api_tokens(modeladmin, request, queryset):
queryset.update(revoked_at=timezone.now())
admin.site.unregister(Group) admin.site.unregister(Group)
@@ -77,3 +83,33 @@ class GroupAdmin(BaseGroupAdmin, ModelAdmin):
admin.site.register(UserSettings) admin.site.register(UserSettings)
@admin.register(APIToken)
class APITokenAdmin(admin.ModelAdmin):
actions = [revoke_api_tokens]
list_display = (
"name",
"user",
"token_key",
"created_at",
"last_used_at",
"expires_at",
"revoked_at",
)
search_fields = ("name", "user__email", "token_key")
# Never expose the secret hash in the form; it must not be editable.
exclude = ("token_hash",)
readonly_fields = (
"user",
"name",
"token_key",
"created_at",
"updated_at",
"last_used_at",
"expires_at",
"revoked_at",
)
def has_add_permission(self, request):
return False
+50
View File
@@ -1,4 +1,7 @@
from datetime import timedelta
from apps.common.middleware.thread_local import get_current_user from apps.common.middleware.thread_local import get_current_user
from apps.users.models import APIToken
from apps.common.widgets.crispy.submit import NoClassSubmit from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect from apps.common.widgets.tom_select import TomSelect
from apps.users.models import UserSettings from apps.users.models import UserSettings
@@ -16,6 +19,7 @@ from django.contrib.auth.forms import (
UsernameField, UsernameField,
) )
from django.db import transaction from django.db import transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -427,3 +431,49 @@ class UserAddForm(UserCreationForm):
if commit: if commit:
user.save() user.save()
return user return user
class APITokenCreateForm(forms.Form):
name = forms.CharField(
max_length=255,
label=_("Token name"),
help_text=_(
"Use a descriptive name such as n8n, Home Assistant, or backup job."
),
)
expires_in_days = forms.IntegerField(
required=False,
min_value=1,
label=_("Expires in days"),
help_text=_("Leave empty for a non-expiring token."),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"name",
"expires_in_days",
FormActions(
NoClassSubmit(
"submit",
_("Create token"),
css_class="btn btn-primary",
),
),
)
def save(self, user):
expires_in_days = self.cleaned_data.get("expires_in_days")
expires_at = None
if expires_in_days:
expires_at = timezone.now() + timedelta(days=expires_in_days)
return APIToken.objects.create_token(
user=user,
name=self.cleaned_data["name"],
expires_at=expires_at,
)
@@ -0,0 +1,36 @@
# Generated by Django 5.2.15 on 2026-06-24 09:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0025_alter_usersettings_default_account'),
]
operations = [
migrations.CreateModel(
name='APIToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('token_key', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='Token key')),
('token_hash', models.CharField(max_length=255, verbose_name='Token hash')),
('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='Last used at')),
('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Expires at')),
('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='Revoked at')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'API token',
'verbose_name_plural': 'API tokens',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['user', 'revoked_at'], name='users_apito_user_id_73edec_idx'), models.Index(fields=['expires_at'], name='users_apito_expires_2b737c_idx')],
},
),
]
File diff suppressed because one or more lines are too long
+119 -2
View File
@@ -1,9 +1,14 @@
import hashlib
import hmac
import secrets
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser, Group from django.contrib.auth.models import AbstractUser, Group
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import IntegrityError, models, transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.users.managers import UserManager from apps.users.managers import UserManager
@@ -410,7 +415,7 @@ timezones = [
("Pacific/Galapagos", "Pacific/Galapagos"), ("Pacific/Galapagos", "Pacific/Galapagos"),
("Pacific/Gambier", "Pacific/Gambier"), ("Pacific/Gambier", "Pacific/Gambier"),
("Pacific/Guadalcanal", "Pacific/Guadalcanal"), ("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
("P2025-06-29T01:43:14.671389745Z acific/Guam", "Pacific/Guam"), ("Pacific/Guam", "Pacific/Guam"),
("Pacific/Honolulu", "Pacific/Honolulu"), ("Pacific/Honolulu", "Pacific/Honolulu"),
("Pacific/Kanton", "Pacific/Kanton"), ("Pacific/Kanton", "Pacific/Kanton"),
("Pacific/Kiritimati", "Pacific/Kiritimati"), ("Pacific/Kiritimati", "Pacific/Kiritimati"),
@@ -524,3 +529,115 @@ class UserSettings(models.Model):
def clean(self): def clean(self):
super().clean() super().clean()
class APITokenManager(models.Manager):
def create_token(self, *, user, name: str, expires_at=None):
token_secret = secrets.token_urlsafe(32)
token_hash = self.model.hash_secret(token_secret)
# token_key is unique; the pre-check in generate_token_key still leaves a
# tiny race window under concurrency, so retry on the unique-constraint
# violation with a fresh key instead of failing the request.
last_error = None
for _ in range(5):
token = self.model(
user=user,
name=name,
token_key=self.model.generate_token_key(),
token_hash=token_hash,
expires_at=expires_at,
)
token.full_clean()
try:
with transaction.atomic():
token.save()
except IntegrityError as exc:
last_error = exc
continue
return token, token.build_raw_token(token_secret)
raise last_error
class APIToken(models.Model):
TOKEN_PREFIX = "wygiwyh_pat_"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="api_tokens",
verbose_name=_("User"),
)
name = models.CharField(max_length=255, verbose_name=_("Name"))
token_key = models.CharField(
max_length=16,
unique=True,
db_index=True,
verbose_name=_("Token key"),
)
token_hash = models.CharField(max_length=255, verbose_name=_("Token hash"))
last_used_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Last used at"),
)
expires_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Expires at"),
)
revoked_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Revoked at"),
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at"))
objects = APITokenManager()
class Meta:
indexes = [
models.Index(fields=["user", "revoked_at"]),
models.Index(fields=["expires_at"]),
]
ordering = ["-created_at"]
verbose_name = _("API token")
verbose_name_plural = _("API tokens")
def __str__(self):
return f"{self.user} / {self.name}"
@classmethod
def generate_token_key(cls) -> str:
while True:
candidate = secrets.token_hex(8)
if not cls.objects.filter(token_key=candidate).exists():
return candidate
@classmethod
def parse_raw_token(cls, raw_token: str):
if not raw_token.startswith(cls.TOKEN_PREFIX):
raise ValueError("Token is missing the expected prefix.")
payload = raw_token.removeprefix(cls.TOKEN_PREFIX)
token_key, separator, token_secret = payload.partition(".")
if not separator or not token_key or not token_secret:
raise ValueError("Token is malformed.")
return token_key, token_secret
def build_raw_token(self, token_secret: str) -> str:
return f"{self.TOKEN_PREFIX}{self.token_key}.{token_secret}"
@staticmethod
def hash_secret(token_secret: str) -> str:
# The secret is a 256-bit random value (secrets.token_urlsafe(32)), so a
# single SHA-256 is sufficient and avoids a slow KDF on every request.
return hashlib.sha256(token_secret.encode("utf-8")).hexdigest()
def check_secret(self, raw_secret: str) -> bool:
return hmac.compare_digest(self.token_hash, self.hash_secret(raw_secret))
def is_expired(self) -> bool:
return self.expires_at is not None and self.expires_at <= timezone.now()
+73
View File
@@ -0,0 +1,73 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.users.models import APIToken
class UserAPITokenViewsTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
email="user@example.com",
password="test-password",
)
self.client.force_login(self.user)
self.htmx_headers = {"HTTP_HX_REQUEST": "true"}
def test_user_settings_renders_api_token_section(self):
response = self.client.get(reverse("user_settings"), **self.htmx_headers)
self.assertContains(response, "API Tokens")
self.assertContains(response, reverse("user_api_token_add"))
def test_can_create_api_token_from_ui(self):
response = self.client.post(
reverse("user_api_token_add"),
{"name": "n8n", "expires_in_days": "30"},
**self.htmx_headers,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Copy this token now")
self.assertEqual(APIToken.objects.filter(user=self.user, name="n8n").count(), 1)
def test_can_revoke_own_api_token(self):
token, _ = APIToken.objects.create_token(user=self.user, name="n8n")
response = self.client.delete(
reverse("user_api_token_revoke", kwargs={"token_id": token.id}),
**self.htmx_headers,
)
self.assertEqual(response.status_code, 200)
token.refresh_from_db()
self.assertIsNotNone(token.revoked_at)
self.assertContains(response, "Revoked")
def test_can_delete_revoked_api_token(self):
token, _ = APIToken.objects.create_token(user=self.user, name="n8n")
token.revoked_at = timezone.now()
token.save(update_fields=["revoked_at"])
response = self.client.delete(
reverse("user_api_token_delete", kwargs={"token_id": token.id}),
**self.htmx_headers,
)
self.assertEqual(response.status_code, 200)
self.assertFalse(APIToken.objects.filter(id=token.id).exists())
def test_cannot_delete_other_users_api_token(self):
other = get_user_model().objects.create_user(
email="other@example.com", password="test-password"
)
token, _ = APIToken.objects.create_token(user=other, name="theirs")
response = self.client.delete(
reverse("user_api_token_delete", kwargs={"token_id": token.id}),
**self.htmx_headers,
)
self.assertEqual(response.status_code, 404)
self.assertTrue(APIToken.objects.filter(id=token.id).exists())
+15
View File
@@ -32,6 +32,21 @@ urlpatterns = [
views.update_settings, views.update_settings,
name="user_settings", name="user_settings",
), ),
path(
"user/api-tokens/add/",
views.api_token_add,
name="user_api_token_add",
),
path(
"user/api-tokens/<int:token_id>/revoke/",
views.api_token_revoke,
name="user_api_token_revoke",
),
path(
"user/api-tokens/<int:token_id>/delete/",
views.api_token_delete,
name="user_api_token_delete",
),
path( path(
"users/", "users/",
views.users_index, views.users_index,
+66 -2
View File
@@ -2,12 +2,13 @@ from apps.common.decorators.demo import disabled_on_demo
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.common.decorators.user import htmx_login_required, is_superuser from apps.common.decorators.user import htmx_login_required, is_superuser
from apps.users.forms import ( from apps.users.forms import (
APITokenCreateForm,
LoginForm, LoginForm,
UserAddForm, UserAddForm,
UserSettingsForm, UserSettingsForm,
UserUpdateForm, UserUpdateForm,
) )
from apps.users.models import UserSettings from apps.users.models import APIToken, UserSettings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model, logout from django.contrib.auth import get_user_model, logout
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@@ -18,6 +19,7 @@ from django.core.exceptions import PermissionDenied
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
@@ -112,7 +114,69 @@ def update_settings(request):
else: else:
form = UserSettingsForm(instance=user_settings) form = UserSettingsForm(instance=user_settings)
return render(request, "users/fragments/user_settings.html", {"form": form}) return render(
request,
"users/fragments/user_settings.html",
{
"form": form,
"api_token_form": APITokenCreateForm(),
"api_tokens": request.user.api_tokens.all(),
},
)
def _render_api_tokens(request, *, form=None, raw_token=None):
return render(
request,
"users/fragments/api_tokens.html",
{
"api_token_form": form or APITokenCreateForm(),
"api_tokens": request.user.api_tokens.all(),
"raw_token": raw_token,
},
)
@only_htmx
@htmx_login_required
@disabled_on_demo
@require_http_methods(["POST"])
def api_token_add(request):
form = APITokenCreateForm(request.POST)
if form.is_valid():
_token, raw_token = form.save(user=request.user)
messages.success(request, _("API token created successfully"))
return _render_api_tokens(
request,
form=APITokenCreateForm(),
raw_token=raw_token,
)
return _render_api_tokens(request, form=form)
@only_htmx
@htmx_login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def api_token_revoke(request, token_id):
token = get_object_or_404(APIToken, id=token_id, user=request.user)
if token.revoked_at is None:
token.revoked_at = timezone.now()
token.save(update_fields=["revoked_at"])
messages.success(request, _("API token revoked successfully"))
return _render_api_tokens(request)
@only_htmx
@htmx_login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def api_token_delete(request, token_id):
token = get_object_or_404(APIToken, id=token_id, user=request.user)
token.delete()
messages.success(request, _("API token deleted successfully"))
return _render_api_tokens(request)
@only_htmx @only_htmx
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -34,8 +34,7 @@
{% if demo_mode %} {% if demo_mode %}
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve> <div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
<div class="alert alert-warning my-3 relative" role="alert"> <div class="alert alert-warning my-3 relative" role="alert">
<strong>{% trans "This is a demo!" %}</strong> {% trans "Any data you add here will be wiped in 24hrs or less" <strong>{% trans "This is a demo!" %}</strong>{% trans "Any data you add here will be wiped in 24hrs or less" %}
%}
<button type="button" class="btn btn-sm btn-ghost absolute right-2 top-1/2 -translate-y-1/2" <button type="button" class="btn btn-sm btn-ghost absolute right-2 top-1/2 -translate-y-1/2"
onclick="this.parentElement.style.display='none'" aria-label="Close">✕</button> onclick="this.parentElement.style.display='none'" aria-label="Close">✕</button>
</div> </div>
@@ -54,4 +53,4 @@
{% endblock extra_js_body %} {% endblock extra_js_body %}
</body> </body>
</html> </html>
@@ -0,0 +1,116 @@
{% load crispy_forms_tags %}
{% load i18n %}
<div class="mb-3">
<div class="text-lg font-bold font-mono">{% translate "API Tokens" %}</div>
<p class="text-sm opacity-70">
{% translate "Use these tokens for automations such as n8n. The raw token is shown only once after creation." %}
</p>
</div>
{% if raw_token %}
<div class="bg-primary-content p-3 rounded-box">
<div class="w-full">
<div class="font-semibold mb-1">{% translate "Copy this token now" %}</div>
<p class="text-sm opacity-80 mb-3">
{% translate "It will not be shown again after this response." %}
</p>
<div class="join w-full">
<input id="raw-token-value"
type="text"
readonly
value="{{ raw_token }}"
class="input input-sm join-item w-full"
_="on focus call me.select()" />
<button type="button"
class="btn btn-sm btn-secondary join-item"
_="on click call navigator.clipboard.writeText(#raw-token-value.value)
then put 'Copied!' into me
then wait 1.5s
then put 'Copy' into me">
{% translate "Copy" %}
</button>
</div>
</div>
</div>
{% endif %}
<form hx-post="{% url 'user_api_token_add' %}" hx-target="#api-token-settings" hx-swap="innerHTML" novalidate>
{% crispy api_token_form %}
</form>
{% if api_tokens %}
<div class="overflow-x-auto mt-4">
<table class="table table-zebra">
<tbody>
{% for token in api_tokens %}
<tr>
<td>
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium font-mono">{{ token.name }}</span>
{% if token.revoked_at %}
<span class="badge badge-sm badge-ghost">{% translate "Revoked" %}</span>
{% else %}
<span class="badge badge-sm badge-success">{% translate "Active" %}</span>
{% endif %}
</div>
<div class="text-xs opacity-60 font-mono break-all mt-1">{{ token.token_key }}</div>
<div class="text-xs opacity-60">
{% blocktranslate with created=token.created_at|date:"Y-m-d" %}Created {{ created }}{% endblocktranslate %}
·
{% if token.last_used_at %}
{% blocktranslate with used=token.last_used_at|date:"Y-m-d H:i" %}last used {{ used }}{% endblocktranslate %}
{% else %}
{% translate "never used" %}
{% endif %}
·
{% if token.expires_at %}
{% blocktranslate with expires=token.expires_at|date:"Y-m-d" %}expires {{ expires }}{% endblocktranslate %}
{% else %}
{% translate "no expiry" %}
{% endif %}
</div>
</td>
<td class="table-col-auto text-right align-top">
{% if not token.revoked_at %}
<a class="btn btn-error btn-sm"
role="button"
data-tippy-content="{% translate 'Revoke' %}"
hx-delete="{% url 'user_api_token_revoke' token_id=token.id %}"
hx-target="#api-token-settings"
hx-swap="innerHTML"
hx-trigger="confirmed"
data-bypass-on-ctrl="true"
data-title="{% translate 'Revoke token?' %}"
data-text="{% translate 'This token will stop working immediately.' %}"
data-confirm-text="{% translate 'Yes, revoke it!' %}"
_="install prompt_swal">
<i class="fa-solid fa-ban fa-fw"></i>
</a>
{% else %}
<a class="btn btn-error btn-sm"
role="button"
data-tippy-content="{% translate 'Delete' %}"
hx-delete="{% url 'user_api_token_delete' token_id=token.id %}"
hx-target="#api-token-settings"
hx-swap="innerHTML"
hx-trigger="confirmed"
data-bypass-on-ctrl="true"
data-title="{% translate 'Delete token?' %}"
data-text="{% translate 'This permanently removes the token from the list. It cannot be undone.' %}"
data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="mt-4">
<c-msg.empty title="{% translate "No API tokens" %}" remove-padding></c-msg.empty>
</div>
{% endif %}
@@ -8,4 +8,8 @@
<form hx-post="{% url 'user_settings' %}" hx-target="#generic-offcanvas" novalidate> <form hx-post="{% url 'user_settings' %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %} {% crispy form %}
</form> </form>
<div class="divider my-6"></div>
<div id="api-token-settings">
{% include "users/fragments/api_tokens.html" %}
</div>
{% endblock %} {% endblock %}
+1
View File
@@ -15,5 +15,6 @@ python manage.py migrate
touch /tmp/migrations_complete touch /tmp/migrations_complete
python manage.py setup_users python manage.py setup_users
python manage.py setup_oauth
exec python manage.py runserver 0.0.0.0:$INTERNAL_PORT exec python manage.py runserver 0.0.0.0:$INTERNAL_PORT
+1
View File
@@ -16,5 +16,6 @@ python manage.py migrate
touch /tmp/migrations_complete touch /tmp/migrations_complete
python manage.py setup_users python manage.py setup_users
python manage.py setup_oauth
exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:$INTERNAL_PORT --timeout 600 exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:$INTERNAL_PORT --timeout 600
+744 -1402
View File
File diff suppressed because it is too large Load Diff
+19 -19
View File
@@ -16,35 +16,35 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@alpinejs/collapse": "^3.15.1", "@alpinejs/collapse": "^3.15.12",
"@alpinejs/mask": "^3.15.1", "@alpinejs/mask": "^3.15.12",
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.1.0", "@fortawesome/fontawesome-free": "^7.2.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-commonjs": "^29.0.3",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.3.0",
"air-datepicker": "^3.6.0", "air-datepicker": "^3.6.0",
"alpinejs": "^3.15.1", "alpinejs": "^3.15.12",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.5.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-chart-sankey": "^0.14.0", "chartjs-chart-sankey": "^0.14.3",
"daisyui": "5.5.19", "daisyui": "5.5.20",
"htmx.org": "^2.0.8", "htmx.org": "^2.0.10",
"hyperscript.org": "^0.9.14", "hyperscript.org": "^0.9.91",
"mathjs": "^15.2.0", "mathjs": "^15.2.0",
"postcss": "^8.5.6", "postcss": "^8.5.15",
"pulltorefreshjs": "^0.1.22", "pulltorefreshjs": "^0.1.22",
"sass": "^1.94.0", "sass": "^1.100.0",
"sweetalert2": "^11.26.3", "sweetalert2": "^11.26.25",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.3.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tom-select": "^2.4.3", "tom-select": "^2.6.1",
"tw-bootstrap-grid": "^1.3.2", "tw-bootstrap-grid": "^1.4.0",
"vite": "7.3.2" "vite": "8.0.16"
}, },
"resolutions": { "resolutions": {
"rollup": "npm:@rollup/wasm-node" "rollup": "npm:@rollup/wasm-node"
} }
} }
+1 -5
View File
@@ -1,4 +1,4 @@
import _hyperscript from 'hyperscript.org'; import 'hyperscript.org';
import './_htmx.js'; import './_htmx.js';
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import mask from '@alpinejs/mask'; import mask from '@alpinejs/mask';
@@ -6,10 +6,6 @@ import collapse from '@alpinejs/collapse'
import { create, all } from 'mathjs'; import { create, all } from 'mathjs';
window.Alpine = Alpine; window.Alpine = Alpine;
if (!window._hyperscript) {
window._hyperscript = _hyperscript;
_hyperscript.browserInit();
}
window.math = create(all, { window.math = create(all, {
number: 'BigNumber', number: 'BigNumber',
}); });
+4 -1
View File
@@ -60,12 +60,15 @@ window.TomSelect = function createDynamicTomSelect(element) {
] ]
}); });
}, },
onDropdownOpen: function () { onDropdownOpen: function () {
schedulePopperUpdate(this); schedulePopperUpdate(this);
}, },
onItemAdd: function () { onItemAdd: function () {
if (this.settings.mode === 'multi') {
this.setTextboxValue();
this.refreshOptions(false);
}
schedulePopperUpdate(this); schedulePopperUpdate(this);
}, },
onItemRemove: function () { onItemRemove: function () {
+19 -18
View File
@@ -5,34 +5,35 @@ description = "An opinionated and powerful finance tracker."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"crispy-bootstrap5==2025.6", "crispy-bootstrap5==2026.3",
"django~=5.2.13", "django~=5.2.15",
"django-allauth[socialaccount]~=65.14.1", "django-allauth[socialaccount]~=65.18.0",
"django-browser-reload==1.21.0", "django-browser-reload==1.21.0",
"django-cachalot~=2.8.0", "django-cachalot~=2.9.0",
"django-cotton<2.3.0", "django-cotton~=2.7.2",
"django-crispy-forms==2.5", "django-crispy-forms==2.6",
"django-debug-toolbar==6.1.0", "django-debug-toolbar==6.3.0",
"django-filter==25.2", "django-filter==25.2",
"django-hijack==3.7.4", "django-hijack==3.7.8",
"django-import-export~=4.3.9", "django-import-export~=4.4.1",
"django-oauth-toolkit~=3.0.1",
"django-pwa~=2.0.1", "django-pwa~=2.0.1",
"django-vite==3.1.0", "django-vite==3.1.0",
"djangorestframework~=3.16.0", "djangorestframework~=3.17.1",
"drf-spectacular~=0.29.0", "drf-spectacular~=0.29.0",
"gunicorn==23.0.0", "gunicorn==26.0.0",
"mistune~=3.1.3", "mistune~=3.2.1",
"openpyxl~=3.1.5", "openpyxl~=3.1.5",
"procrastinate[django]~=3.5.3", "procrastinate[django]~=3.8.1",
"psycopg[binary,pool]==3.3.3", "psycopg[binary,pool]==3.3.4",
"pydantic~=2.12.3", "pydantic~=2.13.4",
"python-dateutil~=2.9.0.post0", "python-dateutil~=2.9.0.post0",
"pytz>=2025.2", "pytz>=2025.2",
"pyyaml~=6.0.2", "pyyaml~=6.0.2",
"requests~=2.33.0", "requests~=2.34.2",
"simpleeval~=1.0.3", "simpleeval~=1.0.3",
"watchfiles==1.1.1", "watchfiles==1.2.0",
"whitenoise[brotli]==6.11.0", "whitenoise[brotli]==6.12.0",
"xlrd~=2.0.1", "xlrd~=2.0.1",
] ]
Generated
+643 -546
View File
File diff suppressed because it is too large Load Diff