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.
Files changed (50) hide show
  1. simple_module_tenants-0.0.35/.gitignore +66 -0
  2. simple_module_tenants-0.0.35/LICENSE +19 -0
  3. simple_module_tenants-0.0.35/PKG-INFO +104 -0
  4. simple_module_tenants-0.0.35/README.md +82 -0
  5. simple_module_tenants-0.0.35/package.json +19 -0
  6. simple_module_tenants-0.0.35/pyproject.toml +51 -0
  7. simple_module_tenants-0.0.35/tenants/__init__.py +1 -0
  8. simple_module_tenants-0.0.35/tenants/_owner_guard.py +68 -0
  9. simple_module_tenants-0.0.35/tenants/components/AdminTenantsTable.tsx +116 -0
  10. simple_module_tenants-0.0.35/tenants/components/CreateOrgForm.tsx +90 -0
  11. simple_module_tenants-0.0.35/tenants/components/InvitationsList.tsx +90 -0
  12. simple_module_tenants-0.0.35/tenants/components/InviteForm.tsx +138 -0
  13. simple_module_tenants-0.0.35/tenants/components/MembersTable.tsx +215 -0
  14. simple_module_tenants-0.0.35/tenants/components/MembershipList.tsx +98 -0
  15. simple_module_tenants-0.0.35/tenants/constants.py +61 -0
  16. simple_module_tenants-0.0.35/tenants/contracts/__init__.py +15 -0
  17. simple_module_tenants-0.0.35/tenants/contracts/entitlements.py +53 -0
  18. simple_module_tenants-0.0.35/tenants/contracts/events.py +57 -0
  19. simple_module_tenants-0.0.35/tenants/contracts/schemas.py +113 -0
  20. simple_module_tenants-0.0.35/tenants/deps.py +82 -0
  21. simple_module_tenants-0.0.35/tenants/endpoints/__init__.py +0 -0
  22. simple_module_tenants-0.0.35/tenants/endpoints/admin.py +64 -0
  23. simple_module_tenants-0.0.35/tenants/endpoints/api.py +175 -0
  24. simple_module_tenants-0.0.35/tenants/endpoints/views.py +117 -0
  25. simple_module_tenants-0.0.35/tenants/errors.py +70 -0
  26. simple_module_tenants-0.0.35/tenants/hooks/useTenantErrors.ts +44 -0
  27. simple_module_tenants-0.0.35/tenants/host_resolver.py +86 -0
  28. simple_module_tenants-0.0.35/tenants/invitations.py +141 -0
  29. simple_module_tenants-0.0.35/tenants/locales/en.json +139 -0
  30. simple_module_tenants-0.0.35/tenants/models.py +85 -0
  31. simple_module_tenants-0.0.35/tenants/module.py +141 -0
  32. simple_module_tenants-0.0.35/tenants/pages/AcceptInvitation.tsx +114 -0
  33. simple_module_tenants-0.0.35/tenants/pages/AdminBrowse.tsx +109 -0
  34. simple_module_tenants-0.0.35/tenants/pages/Index.tsx +71 -0
  35. simple_module_tenants-0.0.35/tenants/pages/Members.tsx +79 -0
  36. simple_module_tenants-0.0.35/tenants/py.typed +0 -0
  37. simple_module_tenants-0.0.35/tenants/resolver.py +219 -0
  38. simple_module_tenants-0.0.35/tenants/service.py +291 -0
  39. simple_module_tenants-0.0.35/tenants/services.py +20 -0
  40. simple_module_tenants-0.0.35/tenants/settings.py +33 -0
  41. simple_module_tenants-0.0.35/tenants/shared_props.py +29 -0
  42. simple_module_tenants-0.0.35/tests/conftest.py +59 -0
  43. simple_module_tenants-0.0.35/tests/test_boot.py +8 -0
  44. simple_module_tenants-0.0.35/tests/test_hardening.py +137 -0
  45. simple_module_tenants-0.0.35/tests/test_invitations.py +100 -0
  46. simple_module_tenants-0.0.35/tests/test_isolation_e2e.py +127 -0
  47. simple_module_tenants-0.0.35/tests/test_owner_race.py +102 -0
  48. simple_module_tenants-0.0.35/tests/test_subdomains.py +95 -0
  49. simple_module_tenants-0.0.35/tests/test_tenants_api.py +112 -0
  50. 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
+ }