From 1af217f6fb1d0ee0339cbb63a09d3bebccb6dcbc Mon Sep 17 00:00:00 2001 From: Alec Perkins Date: Tue, 27 Oct 2026 07:10:27 -0400 Subject: [PATCH] Add accounts and organizations (Phase 1) Sign-in via django-allauth: emailed one-time codes, GitHub, Discord, and Forgejo instances as OpenID Connect apps registered in admin. Regular users never have a password; sysadmins sign in only at /admin/ and are redirected there from the normal sign-in. Organizations at the URL root with owner and collaborator roles, a per-member project-create grant, and invites by username or email. Email invites carry a bearer token stored only as a hash. The last owner can't leave, be removed or be demoted. Permission checks live in intent/access.py. Co-Authored-By: Claude Opus 5.5 --- config/settings/base.py | 38 +++ config/settings/dev.py | 2 + config/urls.py | 7 +- docs/concept.md | 2 +- docs/plan.md | 7 +- intent/access.py | 37 +++ intent/accounts/adapter.py | 13 + intent/accounts/admin.py | 5 +- intent/accounts/validators.py | 10 + intent/core/views.py | 8 +- intent/orgs/__init__.py | 0 intent/orgs/admin.py | 51 ++++ intent/orgs/apps.py | 6 + intent/orgs/forms.py | 31 +++ intent/orgs/migrations/0001_initial.py | 78 ++++++ intent/orgs/migrations/__init__.py | 0 intent/orgs/models.py | 151 ++++++++++++ intent/orgs/services.py | 150 +++++++++++ intent/orgs/urls.py | 22 ++ intent/orgs/views.py | 162 ++++++++++++ pyproject.toml | 1 + static/css/intent.css | 25 ++ templates/account/login.html | 20 ++ templates/allauth/layouts/base.html | 5 + templates/base.html | 25 +- templates/core/home.html | 24 ++ templates/orgs/detail.html | 23 ++ templates/orgs/email/invite.txt | 7 + templates/orgs/invite.html | 20 ++ templates/orgs/members.html | 102 ++++++++ templates/orgs/my_invites.html | 26 ++ tests/conftest.py | 41 +++ tests/test_access.py | 41 +++ tests/test_accounts.py | 52 ++++ tests/test_org_services.py | 99 ++++++++ tests/test_org_views.py | 113 +++++++++ uv.lock | 329 +++++++++++++++++++++++++ 37 files changed, 1725 insertions(+), 8 deletions(-) create mode 100644 intent/access.py create mode 100644 intent/accounts/adapter.py create mode 100644 intent/accounts/validators.py create mode 100644 intent/orgs/__init__.py create mode 100644 intent/orgs/admin.py create mode 100644 intent/orgs/apps.py create mode 100644 intent/orgs/forms.py create mode 100644 intent/orgs/migrations/0001_initial.py create mode 100644 intent/orgs/migrations/__init__.py create mode 100644 intent/orgs/models.py create mode 100644 intent/orgs/services.py create mode 100644 intent/orgs/urls.py create mode 100644 intent/orgs/views.py create mode 100644 templates/account/login.html create mode 100644 templates/allauth/layouts/base.html create mode 100644 templates/orgs/detail.html create mode 100644 templates/orgs/email/invite.txt create mode 100644 templates/orgs/invite.html create mode 100644 templates/orgs/members.html create mode 100644 templates/orgs/my_invites.html create mode 100644 tests/conftest.py create mode 100644 tests/test_access.py create mode 100644 tests/test_accounts.py create mode 100644 tests/test_org_services.py create mode 100644 tests/test_org_views.py diff --git a/config/settings/base.py b/config/settings/base.py index 4577b3b..23d604e 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -17,8 +17,16 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "allauth", + "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.discord", + "allauth.socialaccount.providers.github", + # Forgejo instances are OpenID Connect apps, one SocialApp per instance, added in admin. + "allauth.socialaccount.providers.openid_connect", "intent.core", "intent.accounts", + "intent.orgs", ] MIDDLEWARE = [ @@ -29,6 +37,7 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "allauth.account.middleware.AccountMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] @@ -68,6 +77,35 @@ DATABASES = { AUTH_USER_MODEL = "accounts.User" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +AUTHENTICATION_BACKENDS = [ + # Password login, used only by sysadmins at /admin/. + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", +] + +LOGIN_URL = "account_login" +LOGIN_REDIRECT_URL = "home" + +# Regular users never have a password: they sign in with an emailed code or a linked provider. +ACCOUNT_ADAPTER = "intent.accounts.adapter.AccountAdapter" +ACCOUNT_LOGIN_METHODS = {"email"} +ACCOUNT_SIGNUP_FIELDS = ["email*", "username*"] +ACCOUNT_USERNAME_VALIDATORS = "intent.accounts.validators.username_validators" +ACCOUNT_EMAIL_VERIFICATION = "mandatory" +ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED = True +ACCOUNT_EMAIL_VERIFICATION_BY_CODE_SUPPORTS_RESEND = True +ACCOUNT_LOGIN_BY_CODE_ENABLED = True +ACCOUNT_LOGIN_BY_CODE_SUPPORTS_RESEND = True +ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 600 +ACCOUNT_LOGOUT_ON_GET = False +SOCIALACCOUNT_PROVIDERS = { + "github": {"SCOPE": ["read:user", "user:email"]}, +} + +# Provider is TBD (see concept.md); console output until then. +EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend") +DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Intent ") + AUTH_PASSWORD_VALIDATORS = [ {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, diff --git a/config/settings/dev.py b/config/settings/dev.py index 0d7099a..4cdc7b7 100644 --- a/config/settings/dev.py +++ b/config/settings/dev.py @@ -3,3 +3,5 @@ from .base import * # noqa: F403 DEBUG = True SECRET_KEY = SECRET_KEY or "dev-insecure-secret-key" # noqa: F405 ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"] + +DATA_DIR.mkdir(exist_ok=True) # noqa: F405 diff --git a/config/urls.py b/config/urls.py index f52b438..c45c799 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,9 +1,14 @@ from django.contrib import admin -from django.urls import path +from django.urls import include, path from intent.core import views +from intent.orgs.urls import invite_urlpatterns urlpatterns = [ path("", views.home, name="home"), path("admin/", admin.site.urls), + path("accounts/", include("allauth.urls")), + path("invites/", include(invite_urlpatterns)), + # Last: organizations own the rest of the URL space (see RESERVED_SLUGS). + path("/", include("intent.orgs.urls")), ] diff --git a/docs/concept.md b/docs/concept.md index 57b968f..402c541 100644 --- a/docs/concept.md +++ b/docs/concept.md @@ -19,7 +19,7 @@ Feature history is like git history, each edit is a commit with notes explaining For V1.1: after making changes to a feature, the user can optionally create issues back on the forgejo instance to do the implementation (optionally passing agent-specific instructions on top of links back to the Intent content). (Flow is mostly one-way, Intent -> Forgejo, but delivery status does propagate back to Intent via commit messages. Issues on Forgejo do not propagate back to Intent.) -Users can authenticate using Forgejo, GitHub, Discord, or email (magic link) (and multiple of these by linking their auths together). System admin users can use password authentication (these users are separate from regular users and cannot act within projects). +Users can authenticate using Forgejo, GitHub, Discord, or email (one-time sign-in code) (and multiple of these by linking their auths together). System admin users can use password authentication (these users are separate from regular users and cannot act within projects). Tenancy: multiple organizations and teams, potentially many public users diff --git a/docs/plan.md b/docs/plan.md index 6446879..61a511c 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -34,14 +34,15 @@ Decisions made in the concept are not repeated. These fill the gaps. | Thread → issue | Original commenter or any team member. Thread gets `promoted_to` issue; issue body is prefilled with the thread text and links back. | | Deleted users | Content remains, attributed to a "ghost" user. | | Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. | -| Invites | By email or existing username. Email invites create a pending invite, accepted via a signed link that also logs the user in (magic link). Expire after 14 days. The last org owner cannot be removed or demoted. | +| Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and whoever holds it may accept with any account. Email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Expire after 14 days. The last org owner cannot be removed, demoted or leave. | +| URLs | Organizations live at the root (`//`, later `///`); app routes are reserved slugs. | ### Stack | Topic | Default | |---|---| | Runtime | Python 3.13, Django 5.2 LTS, `uv` for dependencies, `ruff` for lint/format. | -| Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider configured per Forgejo instance (Forgejo supports OIDC), email login-by-code for magic links. Multiple social accounts per user via allauth's connect flow. | +| Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider per Forgejo instance (Forgejo supports OIDC). Sysadmins register each provider as a SocialApp in Django admin, so adding a Forgejo instance needs no deploy. Email sign-in is allauth's login-by-code: the "magic link" is an emailed one-time code, not a clickable link. Signup asks for email and username only; regular users never get a password. Multiple social accounts per user via allauth's connect flow (no automatic linking by email). Usernames: letters, digits, `-`, `_`, max 39. | | Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). | | Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. | | Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `