simple-module-tenants 0.0.35__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simple_module_tenants-0.0.35/.gitignore +66 -0
- simple_module_tenants-0.0.35/LICENSE +19 -0
- simple_module_tenants-0.0.35/PKG-INFO +104 -0
- simple_module_tenants-0.0.35/README.md +82 -0
- simple_module_tenants-0.0.35/package.json +19 -0
- simple_module_tenants-0.0.35/pyproject.toml +51 -0
- simple_module_tenants-0.0.35/tenants/__init__.py +1 -0
- simple_module_tenants-0.0.35/tenants/_owner_guard.py +68 -0
- simple_module_tenants-0.0.35/tenants/components/AdminTenantsTable.tsx +116 -0
- simple_module_tenants-0.0.35/tenants/components/CreateOrgForm.tsx +90 -0
- simple_module_tenants-0.0.35/tenants/components/InvitationsList.tsx +90 -0
- simple_module_tenants-0.0.35/tenants/components/InviteForm.tsx +138 -0
- simple_module_tenants-0.0.35/tenants/components/MembersTable.tsx +215 -0
- simple_module_tenants-0.0.35/tenants/components/MembershipList.tsx +98 -0
- simple_module_tenants-0.0.35/tenants/constants.py +61 -0
- simple_module_tenants-0.0.35/tenants/contracts/__init__.py +15 -0
- simple_module_tenants-0.0.35/tenants/contracts/entitlements.py +53 -0
- simple_module_tenants-0.0.35/tenants/contracts/events.py +57 -0
- simple_module_tenants-0.0.35/tenants/contracts/schemas.py +113 -0
- simple_module_tenants-0.0.35/tenants/deps.py +82 -0
- simple_module_tenants-0.0.35/tenants/endpoints/__init__.py +0 -0
- simple_module_tenants-0.0.35/tenants/endpoints/admin.py +64 -0
- simple_module_tenants-0.0.35/tenants/endpoints/api.py +175 -0
- simple_module_tenants-0.0.35/tenants/endpoints/views.py +117 -0
- simple_module_tenants-0.0.35/tenants/errors.py +70 -0
- simple_module_tenants-0.0.35/tenants/hooks/useTenantErrors.ts +44 -0
- simple_module_tenants-0.0.35/tenants/host_resolver.py +86 -0
- simple_module_tenants-0.0.35/tenants/invitations.py +141 -0
- simple_module_tenants-0.0.35/tenants/locales/en.json +139 -0
- simple_module_tenants-0.0.35/tenants/models.py +85 -0
- simple_module_tenants-0.0.35/tenants/module.py +141 -0
- simple_module_tenants-0.0.35/tenants/pages/AcceptInvitation.tsx +114 -0
- simple_module_tenants-0.0.35/tenants/pages/AdminBrowse.tsx +109 -0
- simple_module_tenants-0.0.35/tenants/pages/Index.tsx +71 -0
- simple_module_tenants-0.0.35/tenants/pages/Members.tsx +79 -0
- simple_module_tenants-0.0.35/tenants/py.typed +0 -0
- simple_module_tenants-0.0.35/tenants/resolver.py +219 -0
- simple_module_tenants-0.0.35/tenants/service.py +291 -0
- simple_module_tenants-0.0.35/tenants/services.py +20 -0
- simple_module_tenants-0.0.35/tenants/settings.py +33 -0
- simple_module_tenants-0.0.35/tenants/shared_props.py +29 -0
- simple_module_tenants-0.0.35/tests/conftest.py +59 -0
- simple_module_tenants-0.0.35/tests/test_boot.py +8 -0
- simple_module_tenants-0.0.35/tests/test_hardening.py +137 -0
- simple_module_tenants-0.0.35/tests/test_invitations.py +100 -0
- simple_module_tenants-0.0.35/tests/test_isolation_e2e.py +127 -0
- simple_module_tenants-0.0.35/tests/test_owner_race.py +102 -0
- simple_module_tenants-0.0.35/tests/test_subdomains.py +95 -0
- simple_module_tenants-0.0.35/tests/test_tenants_api.py +112 -0
- simple_module_tenants-0.0.35/tsconfig.json +10 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# UV
|
|
11
|
+
uv.lock
|
|
12
|
+
|
|
13
|
+
# Node
|
|
14
|
+
node_modules/
|
|
15
|
+
|
|
16
|
+
# IDE
|
|
17
|
+
.idea/
|
|
18
|
+
.vscode/
|
|
19
|
+
*.swp
|
|
20
|
+
*.swo
|
|
21
|
+
|
|
22
|
+
# Environment
|
|
23
|
+
.env
|
|
24
|
+
|
|
25
|
+
# Database
|
|
26
|
+
*.db
|
|
27
|
+
*.sqlite3
|
|
28
|
+
|
|
29
|
+
# Module-managed runtime state (e.g. uploaded dataset files,
|
|
30
|
+
# default storage_dir for SM_DATASETS_STORAGE_DIR).
|
|
31
|
+
var/
|
|
32
|
+
|
|
33
|
+
# file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
|
|
34
|
+
uploads/
|
|
35
|
+
|
|
36
|
+
# Vite
|
|
37
|
+
host/static/dist/
|
|
38
|
+
|
|
39
|
+
# VitePress (docs)
|
|
40
|
+
docs/.vitepress/cache/
|
|
41
|
+
docs/.vitepress/dist/
|
|
42
|
+
|
|
43
|
+
# Auto-generated frontend module manifest (regenerated by the host at boot
|
|
44
|
+
# or via `make gen-pages`).
|
|
45
|
+
host/client_app/modules.manifest.json
|
|
46
|
+
host/client_app/modules.generated.ts
|
|
47
|
+
host/client_app/modules.generated.css
|
|
48
|
+
host/client_app/modules.assets.json
|
|
49
|
+
|
|
50
|
+
# Worktrees
|
|
51
|
+
.worktrees/
|
|
52
|
+
|
|
53
|
+
# Performance profiles
|
|
54
|
+
.memray/
|
|
55
|
+
.benchmarks/
|
|
56
|
+
|
|
57
|
+
# OS
|
|
58
|
+
.DS_Store
|
|
59
|
+
Thumbs.db
|
|
60
|
+
|
|
61
|
+
.playwright-cli/*
|
|
62
|
+
.playwright-mcp/*
|
|
63
|
+
host/client_app/.playwright-cli/*
|
|
64
|
+
.superpowers/
|
|
65
|
+
.qa/
|
|
66
|
+
.verify/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: simple_module_tenants
|
|
3
|
+
Version: 0.0.35
|
|
4
|
+
Summary: Organisations, memberships and tenant resolution for simple_module SaaS installs
|
|
5
|
+
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
7
|
+
Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: simple-module,tenants
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: cachetools>=5.3
|
|
15
|
+
Requires-Dist: simple-module-auth==0.0.35
|
|
16
|
+
Requires-Dist: simple-module-core==0.0.35
|
|
17
|
+
Requires-Dist: simple-module-db==0.0.35
|
|
18
|
+
Requires-Dist: simple-module-hosting==0.0.35
|
|
19
|
+
Requires-Dist: simple-module-inertia==0.0.35
|
|
20
|
+
Requires-Dist: simple-module-settings==0.0.35
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# simple_module_tenants
|
|
24
|
+
|
|
25
|
+
Organisations for [simple_module](https://github.com/antosubash/simple_module_python)
|
|
26
|
+
SaaS installs: tenants, many-to-many memberships with per-tenant roles,
|
|
27
|
+
email-bound invitations, and the tenant resolver that scopes every request.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install simple_module_tenants
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Add `simple_module_tenants` to your host's dependencies, then turn on
|
|
36
|
+
`multi_tenant` (Settings screen, or `SM_MULTI_TENANT=true`) and restart.
|
|
37
|
+
Without `multi_tenant` the module manages organisations but requests are not
|
|
38
|
+
scoped to them.
|
|
39
|
+
|
|
40
|
+
## What it does
|
|
41
|
+
|
|
42
|
+
- **Resolution.** Registers `app.state.tenant_resolver`. The session stores
|
|
43
|
+
the user's chosen tenant; every request re-validates it against a
|
|
44
|
+
membership (cached per process, dropped across workers through
|
|
45
|
+
`InvalidationBus`). Suspended tenants resolve to nothing.
|
|
46
|
+
- **Per-tenant roles.** A membership role (`owner`, `admin`, `member`) is added
|
|
47
|
+
to the principal as `tenant:<role>` for the active tenant only, so tenant
|
|
48
|
+
roles never reach platform permissions.
|
|
49
|
+
- **Fail-closed handling.** With `multi_tenant` on, a tenant-scoped query
|
|
50
|
+
with no tenant raises `TenantIsolationError`; this module turns that into a
|
|
51
|
+
redirect to `/tenants` (pages) or a `403 tenant_required` (API).
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
| Route | Permission | Purpose |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `GET /tenants/` | signed in | My organisations: switch, create |
|
|
58
|
+
| `GET /tenants/members` | `tenants.members.view` | Members and invitations of the active tenant |
|
|
59
|
+
| `GET /tenants/invitations/accept?token=` | signed in | Accept an invitation |
|
|
60
|
+
| `GET /admin/tenants/` | `tenants.platform.view` | Platform list of all tenants |
|
|
61
|
+
| `GET/POST /api/tenants/` | signed in | List mine / create |
|
|
62
|
+
| `POST /api/tenants/{id}/switch` | member of `{id}` | Change the active tenant |
|
|
63
|
+
| `/api/tenants/current/members[/{user_id}]` | `tenants.members.view` / `.manage` | List, change role, remove |
|
|
64
|
+
| `DELETE /api/tenants/current/membership` | member | Leave (not the last owner) |
|
|
65
|
+
| `/api/tenants/current/invitations[/{id}]` | `tenants.members.manage` | List, invite, revoke |
|
|
66
|
+
| `POST /api/tenants/invitations/accept` | signed in as the invited email | Join |
|
|
67
|
+
| `POST /api/tenants/admin/{id}/suspend` · `/reactivate` | `tenants.platform.manage` | Lifecycle |
|
|
68
|
+
|
|
69
|
+
Tenant-level routes act on the *active* tenant (`/current`), never on an id
|
|
70
|
+
from the URL.
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
DB-backed (Settings screen):
|
|
75
|
+
|
|
76
|
+
- `allow_self_service` (default on) — any signed-in user may create an
|
|
77
|
+
organisation.
|
|
78
|
+
- `invitation_ttl_hours` (default 72).
|
|
79
|
+
- `subdomain_base` (default empty) — with `example.com`, `acme.example.com`
|
|
80
|
+
resolves to the tenant whose slug is `acme`. Members get their role there;
|
|
81
|
+
anonymous visitors and signed-in non-members get the tenant on **public
|
|
82
|
+
routes only**; an unknown or suspended subdomain resolves to nothing.
|
|
83
|
+
- `public_base_url` (default empty) — origin invitation links are built on.
|
|
84
|
+
Empty makes them root-relative: they are never built from the request's
|
|
85
|
+
`Host` header, because the same link travels in `InvitationCreated` for a
|
|
86
|
+
mailer to send.
|
|
87
|
+
|
|
88
|
+
Management routes need both the permission and an owner/admin role *in the
|
|
89
|
+
active tenant*: a platform-wide grant does not make a plain member of a tenant
|
|
90
|
+
its manager (`403 tenant_manager_required`).
|
|
91
|
+
|
|
92
|
+
## Billing seams
|
|
93
|
+
|
|
94
|
+
The module ships no billing, but a billing module needs nothing more from it:
|
|
95
|
+
|
|
96
|
+
- `app.state.tenants.entitlements` — replace the default `UnlimitedEntitlements`
|
|
97
|
+
with an `EntitlementProvider` (`limit(tenant_id, key)`,
|
|
98
|
+
`has_feature(tenant_id, key)`). The module enforces `tenants.seats` on new
|
|
99
|
+
members and invitations; `EntitlementExceededError` maps to HTTP 402.
|
|
100
|
+
- `TenantService.set_status(tenant_id, TenantStatus.SUSPENDED | ACTIVE)` for
|
|
101
|
+
dunning.
|
|
102
|
+
- Events, published after commit: `TenantCreated`, `TenantStatusChanged`,
|
|
103
|
+
`MembershipAdded`, `MembershipRemoved`, `InvitationCreated` (also the hook a
|
|
104
|
+
mailer uses to deliver the invitation link).
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# simple_module_tenants
|
|
2
|
+
|
|
3
|
+
Organisations for [simple_module](https://github.com/antosubash/simple_module_python)
|
|
4
|
+
SaaS installs: tenants, many-to-many memberships with per-tenant roles,
|
|
5
|
+
email-bound invitations, and the tenant resolver that scopes every request.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install simple_module_tenants
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Add `simple_module_tenants` to your host's dependencies, then turn on
|
|
14
|
+
`multi_tenant` (Settings screen, or `SM_MULTI_TENANT=true`) and restart.
|
|
15
|
+
Without `multi_tenant` the module manages organisations but requests are not
|
|
16
|
+
scoped to them.
|
|
17
|
+
|
|
18
|
+
## What it does
|
|
19
|
+
|
|
20
|
+
- **Resolution.** Registers `app.state.tenant_resolver`. The session stores
|
|
21
|
+
the user's chosen tenant; every request re-validates it against a
|
|
22
|
+
membership (cached per process, dropped across workers through
|
|
23
|
+
`InvalidationBus`). Suspended tenants resolve to nothing.
|
|
24
|
+
- **Per-tenant roles.** A membership role (`owner`, `admin`, `member`) is added
|
|
25
|
+
to the principal as `tenant:<role>` for the active tenant only, so tenant
|
|
26
|
+
roles never reach platform permissions.
|
|
27
|
+
- **Fail-closed handling.** With `multi_tenant` on, a tenant-scoped query
|
|
28
|
+
with no tenant raises `TenantIsolationError`; this module turns that into a
|
|
29
|
+
redirect to `/tenants` (pages) or a `403 tenant_required` (API).
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
| Route | Permission | Purpose |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `GET /tenants/` | signed in | My organisations: switch, create |
|
|
36
|
+
| `GET /tenants/members` | `tenants.members.view` | Members and invitations of the active tenant |
|
|
37
|
+
| `GET /tenants/invitations/accept?token=` | signed in | Accept an invitation |
|
|
38
|
+
| `GET /admin/tenants/` | `tenants.platform.view` | Platform list of all tenants |
|
|
39
|
+
| `GET/POST /api/tenants/` | signed in | List mine / create |
|
|
40
|
+
| `POST /api/tenants/{id}/switch` | member of `{id}` | Change the active tenant |
|
|
41
|
+
| `/api/tenants/current/members[/{user_id}]` | `tenants.members.view` / `.manage` | List, change role, remove |
|
|
42
|
+
| `DELETE /api/tenants/current/membership` | member | Leave (not the last owner) |
|
|
43
|
+
| `/api/tenants/current/invitations[/{id}]` | `tenants.members.manage` | List, invite, revoke |
|
|
44
|
+
| `POST /api/tenants/invitations/accept` | signed in as the invited email | Join |
|
|
45
|
+
| `POST /api/tenants/admin/{id}/suspend` · `/reactivate` | `tenants.platform.manage` | Lifecycle |
|
|
46
|
+
|
|
47
|
+
Tenant-level routes act on the *active* tenant (`/current`), never on an id
|
|
48
|
+
from the URL.
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
DB-backed (Settings screen):
|
|
53
|
+
|
|
54
|
+
- `allow_self_service` (default on) — any signed-in user may create an
|
|
55
|
+
organisation.
|
|
56
|
+
- `invitation_ttl_hours` (default 72).
|
|
57
|
+
- `subdomain_base` (default empty) — with `example.com`, `acme.example.com`
|
|
58
|
+
resolves to the tenant whose slug is `acme`. Members get their role there;
|
|
59
|
+
anonymous visitors and signed-in non-members get the tenant on **public
|
|
60
|
+
routes only**; an unknown or suspended subdomain resolves to nothing.
|
|
61
|
+
- `public_base_url` (default empty) — origin invitation links are built on.
|
|
62
|
+
Empty makes them root-relative: they are never built from the request's
|
|
63
|
+
`Host` header, because the same link travels in `InvitationCreated` for a
|
|
64
|
+
mailer to send.
|
|
65
|
+
|
|
66
|
+
Management routes need both the permission and an owner/admin role *in the
|
|
67
|
+
active tenant*: a platform-wide grant does not make a plain member of a tenant
|
|
68
|
+
its manager (`403 tenant_manager_required`).
|
|
69
|
+
|
|
70
|
+
## Billing seams
|
|
71
|
+
|
|
72
|
+
The module ships no billing, but a billing module needs nothing more from it:
|
|
73
|
+
|
|
74
|
+
- `app.state.tenants.entitlements` — replace the default `UnlimitedEntitlements`
|
|
75
|
+
with an `EntitlementProvider` (`limit(tenant_id, key)`,
|
|
76
|
+
`has_feature(tenant_id, key)`). The module enforces `tenants.seats` on new
|
|
77
|
+
members and invitations; `EntitlementExceededError` maps to HTTP 402.
|
|
78
|
+
- `TenantService.set_status(tenant_id, TenantStatus.SUSPENDED | ACTIVE)` for
|
|
79
|
+
dunning.
|
|
80
|
+
- Events, published after commit: `TenantCreated`, `TenantStatusChanged`,
|
|
81
|
+
`MembershipAdded`, `MembershipRemoved`, `InvitationCreated` (also the hook a
|
|
82
|
+
mailer uses to deliver the invitation link).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/tenants",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the Tenants module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^3.7.0",
|
|
10
|
+
"@simple-module-py/ui": "*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@simple-module-py/tsconfig": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"lucide-react": "^1.43.0",
|
|
17
|
+
"sonner": "^2.0.8"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "simple_module_tenants"
|
|
3
|
+
version = "0.0.35"
|
|
4
|
+
description = "Organisations, memberships and tenant resolution for simple_module SaaS installs"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
|
|
10
|
+
keywords = ["simple-module", "tenants"]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"simple_module_core==0.0.35",
|
|
13
|
+
"simple_module_db==0.0.35",
|
|
14
|
+
"simple_module_hosting==0.0.35",
|
|
15
|
+
"simple_module_inertia==0.0.35",
|
|
16
|
+
"simple_module_settings==0.0.35",
|
|
17
|
+
"simple_module_auth==0.0.35",
|
|
18
|
+
"cachetools>=5.3",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.entry-points.simple_module]
|
|
22
|
+
tenants = "tenants.module:TenantsModule"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/antosubash/simple_module_python"
|
|
26
|
+
Repository = "https://github.com/antosubash/simple_module_python"
|
|
27
|
+
Issues = "https://github.com/antosubash/simple_module_python/issues"
|
|
28
|
+
Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md"
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
# The distribution name (simple_module_tenants) doesn't match the package
|
|
35
|
+
# directory (tenants), so hatchling can't infer what to ship — without
|
|
36
|
+
# this the wheel builds empty and the entry point fails to import.
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["tenants"]
|
|
39
|
+
|
|
40
|
+
# Ship the module-root package.json inside the wheel so the host can
|
|
41
|
+
# discover JS deps via importlib.resources after a pip install.
|
|
42
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
43
|
+
"package.json" = "tenants/package.json"
|
|
44
|
+
|
|
45
|
+
[tool.uv.sources]
|
|
46
|
+
simple_module_core = { workspace = true }
|
|
47
|
+
simple_module_db = { workspace = true }
|
|
48
|
+
simple_module_hosting = { workspace = true }
|
|
49
|
+
simple_module_inertia = { workspace = true }
|
|
50
|
+
simple_module_settings = { workspace = true }
|
|
51
|
+
simple_module_auth = { workspace = true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tenants module."""
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Owner-safe demotion and removal: the "at least one owner" rule, atomically.
|
|
2
|
+
|
|
3
|
+
Counting owners and then writing is a check-then-act race: two owners
|
|
4
|
+
demoting each other at once both see two owners and both succeed, leaving a
|
|
5
|
+
tenant nobody can ever administer again. So the rule lives in the write
|
|
6
|
+
itself — the UPDATE/DELETE only matches while *another* owner exists, and
|
|
7
|
+
zero affected rows means "this was the last owner".
|
|
8
|
+
|
|
9
|
+
On SQLite writers serialise and the condition is evaluated at write time,
|
|
10
|
+
which settles it. On Postgres (READ COMMITTED) two writes to different rows
|
|
11
|
+
would not block each other, so callers also hold the tenant row lock
|
|
12
|
+
(``TenantService.lock``) — the second request waits for the first to commit
|
|
13
|
+
and then evaluates the condition against it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from simple_module_db.listeners import SESSION_HAS_WRITES_KEY
|
|
19
|
+
from sqlalchemy import delete, exists, update
|
|
20
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
21
|
+
from sqlalchemy.orm import aliased
|
|
22
|
+
|
|
23
|
+
from tenants.constants import MembershipRole
|
|
24
|
+
from tenants.models import Membership
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _another_owner(membership: Membership):
|
|
28
|
+
other = aliased(Membership)
|
|
29
|
+
return exists().where(
|
|
30
|
+
other.tenant_id == membership.tenant_id,
|
|
31
|
+
other.role == MembershipRole.OWNER,
|
|
32
|
+
other.id != membership.id,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def demote_owner(db: AsyncSession, membership: Membership, role: str) -> bool:
|
|
37
|
+
"""Set ``role`` on an owner's membership unless they are the last owner."""
|
|
38
|
+
stmt = (
|
|
39
|
+
update(Membership)
|
|
40
|
+
.where(Membership.id == membership.id, _another_owner(membership))
|
|
41
|
+
.values(role=role)
|
|
42
|
+
.execution_options(synchronize_session=False)
|
|
43
|
+
)
|
|
44
|
+
applied = (await db.execute(stmt)).rowcount == 1
|
|
45
|
+
if applied:
|
|
46
|
+
_mark_written(db)
|
|
47
|
+
await db.refresh(membership)
|
|
48
|
+
return applied
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def remove_owner(db: AsyncSession, membership: Membership) -> bool:
|
|
52
|
+
"""Delete an owner's membership unless they are the last owner."""
|
|
53
|
+
stmt = (
|
|
54
|
+
delete(Membership)
|
|
55
|
+
.where(Membership.id == membership.id, _another_owner(membership))
|
|
56
|
+
.execution_options(synchronize_session=False)
|
|
57
|
+
)
|
|
58
|
+
applied = (await db.execute(stmt)).rowcount == 1
|
|
59
|
+
if applied:
|
|
60
|
+
_mark_written(db)
|
|
61
|
+
db.expunge(membership)
|
|
62
|
+
return applied
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _mark_written(db: AsyncSession) -> None:
|
|
66
|
+
# A Core-style statement is not a flush, so the request session would
|
|
67
|
+
# otherwise roll it back as a read-only request (#336).
|
|
68
|
+
db.sync_session.info[SESSION_HAS_WRITES_KEY] = True
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
2
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
3
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
4
|
+
import {
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableHead,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableRow,
|
|
11
|
+
} from '@simple-module-py/ui/components/ui/table';
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
import { toast } from 'sonner';
|
|
14
|
+
import { useTenantErrors } from '../hooks/useTenantErrors';
|
|
15
|
+
|
|
16
|
+
export interface AdminTenant {
|
|
17
|
+
id: string;
|
|
18
|
+
slug: string;
|
|
19
|
+
name: string;
|
|
20
|
+
status: 'active' | 'suspended';
|
|
21
|
+
created_at: string | null;
|
|
22
|
+
members: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
tenants: AdminTenant[];
|
|
27
|
+
onChanged: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STATUS_KEY = keys.tenants.status;
|
|
31
|
+
|
|
32
|
+
/** The platform-wide organisation roster on the admin Browse page. */
|
|
33
|
+
export function AdminTenantsTable({ tenants, onChanged }: Props) {
|
|
34
|
+
const { t } = useT();
|
|
35
|
+
const { describe } = useTenantErrors();
|
|
36
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
async function setStatus(tenant: AdminTenant, action: 'suspend' | 'reactivate') {
|
|
39
|
+
setBusyId(tenant.id);
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(`/api/tenants/admin/${tenant.id}/${action}`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
credentials: 'same-origin',
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
toast.error(await describe(response));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
toast.success(
|
|
50
|
+
action === 'suspend'
|
|
51
|
+
? t(keys.tenants.admin.toast_suspended)
|
|
52
|
+
: t(keys.tenants.admin.toast_reactivated),
|
|
53
|
+
);
|
|
54
|
+
onChanged();
|
|
55
|
+
} catch {
|
|
56
|
+
toast.error(t(keys.tenants.admin.toast_action_failed));
|
|
57
|
+
} finally {
|
|
58
|
+
setBusyId(null);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Table>
|
|
64
|
+
<TableHeader>
|
|
65
|
+
<TableRow>
|
|
66
|
+
<TableHead>{t(keys.tenants.admin.table_name)}</TableHead>
|
|
67
|
+
<TableHead>{t(keys.tenants.admin.table_slug)}</TableHead>
|
|
68
|
+
<TableHead>{t(keys.tenants.admin.table_status)}</TableHead>
|
|
69
|
+
<TableHead>{t(keys.tenants.admin.table_members)}</TableHead>
|
|
70
|
+
<TableHead>{t(keys.tenants.admin.table_created)}</TableHead>
|
|
71
|
+
<TableHead className="text-right">{t(keys.tenants.admin.table_actions)}</TableHead>
|
|
72
|
+
</TableRow>
|
|
73
|
+
</TableHeader>
|
|
74
|
+
<TableBody>
|
|
75
|
+
{tenants.map((tenant) => (
|
|
76
|
+
<TableRow key={tenant.id}>
|
|
77
|
+
<TableCell className="font-medium">{tenant.name}</TableCell>
|
|
78
|
+
<TableCell className="text-muted-foreground">{tenant.slug}</TableCell>
|
|
79
|
+
<TableCell>
|
|
80
|
+
<Badge variant={tenant.status === 'active' ? 'secondary' : 'destructive'}>
|
|
81
|
+
{t(STATUS_KEY[tenant.status])}
|
|
82
|
+
</Badge>
|
|
83
|
+
</TableCell>
|
|
84
|
+
<TableCell>{tenant.members}</TableCell>
|
|
85
|
+
<TableCell className="text-muted-foreground">
|
|
86
|
+
{tenant.created_at ? new Date(tenant.created_at).toLocaleDateString() : '—'}
|
|
87
|
+
</TableCell>
|
|
88
|
+
<TableCell className="text-right">
|
|
89
|
+
{tenant.status === 'active' ? (
|
|
90
|
+
<Button
|
|
91
|
+
variant="outline"
|
|
92
|
+
size="sm"
|
|
93
|
+
className="max-lg:min-h-11"
|
|
94
|
+
disabled={busyId === tenant.id}
|
|
95
|
+
onClick={() => setStatus(tenant, 'suspend')}
|
|
96
|
+
>
|
|
97
|
+
{t(keys.tenants.admin.suspend_button)}
|
|
98
|
+
</Button>
|
|
99
|
+
) : (
|
|
100
|
+
<Button
|
|
101
|
+
variant="outline"
|
|
102
|
+
size="sm"
|
|
103
|
+
className="max-lg:min-h-11"
|
|
104
|
+
disabled={busyId === tenant.id}
|
|
105
|
+
onClick={() => setStatus(tenant, 'reactivate')}
|
|
106
|
+
>
|
|
107
|
+
{t(keys.tenants.admin.reactivate_button)}
|
|
108
|
+
</Button>
|
|
109
|
+
)}
|
|
110
|
+
</TableCell>
|
|
111
|
+
</TableRow>
|
|
112
|
+
))}
|
|
113
|
+
</TableBody>
|
|
114
|
+
</Table>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
2
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@simple-module-py/ui/components/ui/card';
|
|
4
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
5
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
6
|
+
import type React from 'react';
|
|
7
|
+
import { useState } from 'react';
|
|
8
|
+
import { useTenantErrors } from '../hooks/useTenantErrors';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** Called after the API confirms creation, so the page can reload its props. */
|
|
12
|
+
onCreated: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The self-service "create an organisation" form on the Index page. */
|
|
16
|
+
export function CreateOrgForm({ onCreated }: Props) {
|
|
17
|
+
const { t } = useT();
|
|
18
|
+
const { describe } = useTenantErrors();
|
|
19
|
+
const [name, setName] = useState('');
|
|
20
|
+
const [slug, setSlug] = useState('');
|
|
21
|
+
const [submitting, setSubmitting] = useState(false);
|
|
22
|
+
const [error, setError] = useState<string | null>(null);
|
|
23
|
+
|
|
24
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
setSubmitting(true);
|
|
27
|
+
setError(null);
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch('/api/tenants/', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
credentials: 'same-origin',
|
|
33
|
+
body: JSON.stringify({ name, slug: slug || undefined }),
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
setError(await describe(response));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setName('');
|
|
40
|
+
setSlug('');
|
|
41
|
+
onCreated();
|
|
42
|
+
} catch {
|
|
43
|
+
setError(t(keys.tenants.errors.generic));
|
|
44
|
+
} finally {
|
|
45
|
+
setSubmitting(false);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Card>
|
|
51
|
+
<CardHeader>
|
|
52
|
+
<CardTitle>{t(keys.tenants.index.create_title)}</CardTitle>
|
|
53
|
+
</CardHeader>
|
|
54
|
+
<CardContent>
|
|
55
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
56
|
+
<div className="space-y-1.5">
|
|
57
|
+
<Label htmlFor="org-name">{t(keys.tenants.index.create_name_label)}</Label>
|
|
58
|
+
<Input
|
|
59
|
+
id="org-name"
|
|
60
|
+
value={name}
|
|
61
|
+
onChange={(e) => setName(e.target.value)}
|
|
62
|
+
placeholder={t(keys.tenants.index.create_name_placeholder)}
|
|
63
|
+
required
|
|
64
|
+
maxLength={200}
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="space-y-1.5">
|
|
68
|
+
<Label htmlFor="org-slug">{t(keys.tenants.index.create_slug_label)}</Label>
|
|
69
|
+
<Input
|
|
70
|
+
id="org-slug"
|
|
71
|
+
value={slug}
|
|
72
|
+
onChange={(e) => setSlug(e.target.value.toLowerCase())}
|
|
73
|
+
placeholder={t(keys.tenants.index.create_slug_placeholder)}
|
|
74
|
+
maxLength={50}
|
|
75
|
+
/>
|
|
76
|
+
<p className="text-xs text-muted-foreground">
|
|
77
|
+
{t(keys.tenants.index.create_slug_hint)}
|
|
78
|
+
</p>
|
|
79
|
+
</div>
|
|
80
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
81
|
+
<Button type="submit" disabled={submitting || !name.trim()} className="max-lg:min-h-11">
|
|
82
|
+
{submitting
|
|
83
|
+
? t(keys.tenants.index.create_submitting)
|
|
84
|
+
: t(keys.tenants.index.create_submit)}
|
|
85
|
+
</Button>
|
|
86
|
+
</form>
|
|
87
|
+
</CardContent>
|
|
88
|
+
</Card>
|
|
89
|
+
);
|
|
90
|
+
}
|