From d7be79f9fb7e755f0d22fd3b982e37becbc022cf Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Fri, 11 Sep 2026 15:24:20 +0200 Subject: [PATCH 1/2] Document new dtable-web endpoints --- intro/changelog.md | 10 ++ team_admin_account_operations.yaml | 124 ++++++++++++++ .../test_team_apps/test_listManagedApps.json | 20 +++ .../test_team_apps/test_listUsableApps.json | 20 +++ .../test_listBasesSharedToUser.json | 25 +++ tests/test_team_apps.py | 161 ++++++++++++++++++ tests/test_team_bases.py | 84 +++++++++ 7 files changed, 444 insertions(+) create mode 100644 tests/__snapshots__/test_team_apps/test_listManagedApps.json create mode 100644 tests/__snapshots__/test_team_apps/test_listUsableApps.json create mode 100644 tests/__snapshots__/test_team_bases/test_listBasesSharedToUser.json create mode 100644 tests/test_team_apps.py create mode 100644 tests/test_team_bases.py diff --git a/intro/changelog.md b/intro/changelog.md index a4a0a63..71f78b4 100644 --- a/intro/changelog.md +++ b/intro/changelog.md @@ -14,6 +14,16 @@ slug: changelog Listed below are all the changes to the SeaTable API. Each date corresponds to a new version of SeaTable Server Enterprise Edition. If you’re looking for changes beyond the API, see the SeaTable [Changelog](https://seatable.com/changelog) or check out the [SeaTable Blog](https://seatable.com/blog) for detailed release notes. +## Version 7.0 + +> 📘 New requests +> +> **Account Operations - Team Admin** +> +> - [List Managed Apps](/reference/listmanagedapps) get +> - [List Usable Apps](/reference/listusableapps) get +> - [List Bases Shared to User](/reference/listbasessharedtouser-1) get + ## Version 6.2 (21.07.2026) > 🚧 Breaking changes diff --git a/team_admin_account_operations.yaml b/team_admin_account_operations.yaml index d5346c7..22c623d 100644 --- a/team_admin_account_operations.yaml +++ b/team_admin_account_operations.yaml @@ -715,6 +715,88 @@ paths: example: success: true + /api/v2.1/org/{org_id}/admin/users/{user_id}/managed-external-apps/: + get: + tags: + - Users + summary: List Managed Apps + operationId: listManagedApps + description: >- + List all the universal apps a certain user manages, i.e. the apps in + which the user has the `admin` role. Inactive apps and apps of deleted + bases are not included. + security: + - AccountTokenAuth: [] + parameters: + - $ref: "#/components/parameters/org_id" + - $ref: "#/components/parameters/user_id" + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + example: + managed_apps: + - app_user_id: 12 + app_id: 4 + app_name: Customer Portal + app_uuid: 25ed3722-7988-4389-8095-9a46373cd8a5 + app_config: '{"app_type": "universal-app", "app_name": "Customer Portal", "settings": {"pages": [], "navigation": []}}' + role_name: admin + permission: rw + dtable_uuid: c56624fd757f4f4bae7de76235aeb1f0 + dtable_name: CRM + workspace_id: 2 + link: https://cloud.seatable.io/external-apps/25ed3722-7988-4389-8095-9a46373cd8a5/ + edit_link: https://cloud.seatable.io/dtable/external-apps-edit/25ed3722-7988-4389-8095-9a46373cd8a5/ + joined_at: '2025-11-28T14:16:30+01:00' + count: 1 + + /api/v2.1/org/{org_id}/admin/users/{user_id}/can-use-external-apps/: + get: + tags: + - Users + summary: List Usable Apps + operationId: listUsableApps + description: >- + List all the universal apps a certain user can use, i.e. the apps in + which the user has a role other than `admin`. Inactive apps and apps + of deleted bases are not included. + security: + - AccountTokenAuth: [] + parameters: + - $ref: "#/components/parameters/org_id" + - $ref: "#/components/parameters/user_id" + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + example: + can_use_apps: + - app_user_id: 13 + app_id: 4 + app_name: Customer Portal + app_uuid: 25ed3722-7988-4389-8095-9a46373cd8a5 + app_config: '{"app_type": "universal-app", "app_name": "Customer Portal", "settings": {"pages": [], "navigation": []}}' + role_name: default + permission: rw + dtable_uuid: c56624fd757f4f4bae7de76235aeb1f0 + dtable_name: CRM + workspace_id: 2 + link: https://cloud.seatable.io/external-apps/25ed3722-7988-4389-8095-9a46373cd8a5/ + edit_link: https://cloud.seatable.io/dtable/external-apps-edit/25ed3722-7988-4389-8095-9a46373cd8a5/ + joined_at: '2025-11-28T14:16:30+01:00' + count: 1 + # Bases /api/v2.1/org/{org_id}/admin/dtables/: get: @@ -837,6 +919,48 @@ paths: owner_deleted: false rows_count: 0 count: 2 + /api/v2.1/org/{org_id}/admin/users/{user_id}/shared-dtables/: + get: + tags: + - Bases + summary: List Bases Shared to User + operationId: listBasesSharedToUser + description: List all the bases shared to a certain user by the user's ID. + security: + - AccountTokenAuth: [] + parameters: + - $ref: "#/components/parameters/org_id" + - $ref: "#/components/parameters/user_id" + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + example: + dtable_list: + - id: 8 + workspace_id: 2 + uuid: 3a6e34b7-c9e1-4c2f-8b0e-2f5d3f21c7a4 + name: CRM + created_at: '2025-11-28T14:16:30+01:00' + updated_at: '2025-11-28T14:17:04+01:00' + color: "#E91E63" + text_color: null + icon: icon-user-interview + is_encrypted: false + in_storage: true + backend: js + creator: org-1-admin + modifier: org-1-admin + file_size: 1389 + rows_count: 7 + from_user: e38487b4357e40c8b1359223ba87a1af@auth.local + from_user_name: org-1-admin + count: 1 /api/v2.1/org/{org_id}/admin/dtables/{base_uuid}/: get: tags: diff --git a/tests/__snapshots__/test_team_apps/test_listManagedApps.json b/tests/__snapshots__/test_team_apps/test_listManagedApps.json new file mode 100644 index 0000000..b10455e --- /dev/null +++ b/tests/__snapshots__/test_team_apps/test_listManagedApps.json @@ -0,0 +1,20 @@ +{ + "count": 1, + "managed_apps": [ + { + "app_config": "{\"app_type\": \"universal-app\", \"app_name\": \"Test App\", \"settings\": {\"pages\": [], \"navigation\": []}}", + "app_id": "int", + "app_name": "Test App", + "app_user_id": "int", + "app_uuid": "str", + "dtable_name": "ManagedAppsTest", + "dtable_uuid": "str", + "edit_link": "str", + "joined_at": "str", + "link": "str", + "permission": "rw", + "role_name": "admin", + "workspace_id": "int" + } + ] +} diff --git a/tests/__snapshots__/test_team_apps/test_listUsableApps.json b/tests/__snapshots__/test_team_apps/test_listUsableApps.json new file mode 100644 index 0000000..0e994ff --- /dev/null +++ b/tests/__snapshots__/test_team_apps/test_listUsableApps.json @@ -0,0 +1,20 @@ +{ + "can_use_apps": [ + { + "app_config": "{\"app_type\": \"universal-app\", \"app_name\": \"Test App\", \"settings\": {\"pages\": [], \"navigation\": []}}", + "app_id": "int", + "app_name": "Test App", + "app_user_id": "int", + "app_uuid": "str", + "dtable_name": "UsableAppsTest", + "dtable_uuid": "str", + "edit_link": "str", + "joined_at": "str", + "link": "str", + "permission": "rw", + "role_name": "default", + "workspace_id": "int" + } + ], + "count": 1 +} diff --git a/tests/__snapshots__/test_team_bases/test_listBasesSharedToUser.json b/tests/__snapshots__/test_team_bases/test_listBasesSharedToUser.json new file mode 100644 index 0000000..133785c --- /dev/null +++ b/tests/__snapshots__/test_team_bases/test_listBasesSharedToUser.json @@ -0,0 +1,25 @@ +{ + "count": 1, + "dtable_list": [ + { + "backend": "js", + "color": null, + "created_at": "str", + "creator": "str", + "file_size": "int", + "from_user": "str", + "from_user_name": "str", + "icon": null, + "id": "int", + "in_storage": true, + "is_encrypted": false, + "modifier": "str", + "name": "SharedBasesTest", + "rows_count": 0, + "text_color": null, + "updated_at": "str", + "uuid": "str", + "workspace_id": "int" + } + ] +} diff --git a/tests/test_team_apps.py b/tests/test_team_apps.py new file mode 100644 index 0000000..2e9764a --- /dev/null +++ b/tests/test_team_apps.py @@ -0,0 +1,161 @@ +import json +import pytest +import requests +from conftest import ( + BASE_URL, Secret, TeamAdmin, system_admin_account_operations, team_admin_account_operations, + user_account_operations, +) +from schemathesis import Case +from syrupy.assertion import SnapshotAssertion +from syrupy.matchers import path_type + +pytestmark = pytest.mark.needs_large_license + + +def test_listManagedApps(team: TeamAdmin, system_admin_account_token: Secret, snapshot_json: SnapshotAssertion): + """Create a universal app, make the team admin its admin and verify it appears in listManagedApps.""" + headers = {'Authorization': f'Bearer {team.account_token}'} + path_parameters = {'org_id': team.team_id} + + # Custom app roles require the "advanced customization" permission, which the default team role lacks + case: Case = system_admin_account_operations.find_operation_by_id('updateTeam') \ + .Case(path_parameters=path_parameters, body={'role': 'org_enterprise'}) + response = case.call(headers={'Authorization': f'Bearer {system_admin_account_token.value}'}) + assert response.status_code == 200 + + # The team admin will be the app's admin, so get their own user ID + case: Case = user_account_operations.find_operation_by_id('getAccountInfo').Case() + response = case.call(headers=headers) + assert response.status_code == 200 + user_id = response.json()['email'] + + # Create a base in the team admin's personal workspace + case: Case = user_account_operations.find_operation_by_id('listWorkspaces').Case() + response = case.call(headers=headers) + assert response.status_code == 200 + ws_id = next(w for w in response.json()['workspace_list'] if w.get('type') == 'personal')['id'] + + body = {'workspace_id': ws_id, 'name': 'ManagedAppsTest'} + case: Case = user_account_operations.find_operation_by_id('createBase').Case(body=body) + response = case.call(headers=headers) + assert response.status_code == 201 + base_uuid = response.json()['table']['uuid'] + + # Create a universal app, an "admin" role and add the team admin with that role (endpoints not in the spec) + app_config = json.dumps({'app_type': 'universal-app', 'app_name': 'Test App', 'settings': {'pages': [], 'navigation': []}}) + response = requests.post( + f'{BASE_URL}/api/v2.1/workspace/{ws_id}/dtable/ManagedAppsTest/external-apps/', + headers=headers, data={'app_type': 'universal-app', 'app_config': app_config}, + ) + assert response.status_code == 201 + app_uuid = response.json()['external_app']['app_uuid'] + + response = requests.post( + f'{BASE_URL}/api/v2.1/universal-apps/{app_uuid}/app-roles/', + headers=headers, data={'role_name': 'admin', 'permission': 'rw'}, + ) + assert response.status_code == 200 + role_id = response.json()['app_role']['id'] + + response = requests.post( + f'{BASE_URL}/api/v2.1/universal-apps/{app_uuid}/app-users/', + headers=headers, data={'app_user': user_id, 'app_role_id': role_id}, + ) + assert response.status_code == 200 + + # List the user's managed apps + case: Case = team_admin_account_operations.find_operation_by_id('listManagedApps') \ + .Case(path_parameters={'org_id': team.team_id, 'user_id': user_id}) + response = case.call(headers=headers) + + assert response.status_code == 200 + + data = response.json() + assert data['count'] == 1 + assert len(data['managed_apps']) == 1 + + managed_app = data['managed_apps'][0] + assert managed_app['app_uuid'] == app_uuid + assert managed_app['dtable_uuid'] == base_uuid.replace('-', '') + + matcher = path_type({ + r"managed_apps\.\d+\.app_user_id": (int,), + r"managed_apps\.\d+\.app_id": (int,), + r"managed_apps\.\d+\.app_uuid": (str,), + r"managed_apps\.\d+\.dtable_uuid": (str,), + r"managed_apps\.\d+\.workspace_id": (int,), + r"managed_apps\.\d+\.link": (str,), + r"managed_apps\.\d+\.edit_link": (str,), + r"managed_apps\.\d+\.joined_at": (str,), + }, regex=True) + + assert snapshot_json(matcher=matcher) == data + + +def test_listUsableApps(team: TeamAdmin, snapshot_json: SnapshotAssertion): + """Create a universal app, add the team admin with the default role and verify it appears in listUsableApps.""" + headers = {'Authorization': f'Bearer {team.account_token}'} + + case: Case = user_account_operations.find_operation_by_id('getAccountInfo').Case() + response = case.call(headers=headers) + assert response.status_code == 200 + user_id = response.json()['email'] + + # Create a base in the team admin's personal workspace + case: Case = user_account_operations.find_operation_by_id('listWorkspaces').Case() + response = case.call(headers=headers) + assert response.status_code == 200 + ws_id = next(w for w in response.json()['workspace_list'] if w.get('type') == 'personal')['id'] + + body = {'workspace_id': ws_id, 'name': 'UsableAppsTest'} + case: Case = user_account_operations.find_operation_by_id('createBase').Case(body=body) + response = case.call(headers=headers) + assert response.status_code == 201 + base_uuid = response.json()['table']['uuid'] + + # Create a universal app and add the team admin with its auto-created "default" role (endpoints not in the spec) + app_config = json.dumps({'app_type': 'universal-app', 'app_name': 'Test App', 'settings': {'pages': [], 'navigation': []}}) + response = requests.post( + f'{BASE_URL}/api/v2.1/workspace/{ws_id}/dtable/UsableAppsTest/external-apps/', + headers=headers, data={'app_type': 'universal-app', 'app_config': app_config}, + ) + assert response.status_code == 201 + app_uuid = response.json()['external_app']['app_uuid'] + + response = requests.get(f'{BASE_URL}/api/v2.1/universal-apps/{app_uuid}/app-roles/', headers=headers) + assert response.status_code == 200 + role_id = next(r['id'] for r in response.json()['app_roles'] if r['role_name'] == 'default') + + response = requests.post( + f'{BASE_URL}/api/v2.1/universal-apps/{app_uuid}/app-users/', + headers=headers, data={'app_user': user_id, 'app_role_id': role_id}, + ) + assert response.status_code == 200 + + # List the apps the user can use + case: Case = team_admin_account_operations.find_operation_by_id('listUsableApps') \ + .Case(path_parameters={'org_id': team.team_id, 'user_id': user_id}) + response = case.call(headers=headers) + + assert response.status_code == 200 + + data = response.json() + assert data['count'] == 1 + assert len(data['can_use_apps']) == 1 + + usable_app = data['can_use_apps'][0] + assert usable_app['app_uuid'] == app_uuid + assert usable_app['dtable_uuid'] == base_uuid.replace('-', '') + + matcher = path_type({ + r"can_use_apps\.\d+\.app_user_id": (int,), + r"can_use_apps\.\d+\.app_id": (int,), + r"can_use_apps\.\d+\.app_uuid": (str,), + r"can_use_apps\.\d+\.dtable_uuid": (str,), + r"can_use_apps\.\d+\.workspace_id": (int,), + r"can_use_apps\.\d+\.link": (str,), + r"can_use_apps\.\d+\.edit_link": (str,), + r"can_use_apps\.\d+\.joined_at": (str,), + }, regex=True) + + assert snapshot_json(matcher=matcher) == data diff --git a/tests/test_team_bases.py b/tests/test_team_bases.py new file mode 100644 index 0000000..52545cd --- /dev/null +++ b/tests/test_team_bases.py @@ -0,0 +1,84 @@ +import pytest +from conftest import ( + TeamAdmin, team_admin_account_operations, user_account_operations, generate_password, +) +from random import randint +from schemathesis import Case +from syrupy.assertion import SnapshotAssertion +from syrupy.matchers import path_type + +pytestmark = pytest.mark.needs_large_license + + +def test_listBasesSharedToUser(team: TeamAdmin, snapshot_json: SnapshotAssertion): + """Share a base from the team admin to a team member and verify it appears in listBasesSharedToUser.""" + headers = {'Authorization': f'Bearer {team.account_token}'} + path_parameters = {'org_id': team.team_id} + + # Add a user to the team (emails are reserved forever, so use a unique one per run) + body = { + 'email': f'shared-bases-test-{randint(1, 1000000)}@example.com', + 'name': 'Shared Bases Test', + 'password': generate_password(), + 'with_workspace': True, + } + case: Case = team_admin_account_operations.find_operation_by_id('addUser') \ + .Case(path_parameters=path_parameters, body=body) + response = case.call(headers=headers) + assert response.status_code == 200 + user_id = response.json()['email'] + + # Get the team admin's own user ID and personal workspace + case: Case = user_account_operations.find_operation_by_id('getAccountInfo').Case() + response = case.call(headers=headers) + assert response.status_code == 200 + admin_user_id = response.json()['email'] + + case: Case = user_account_operations.find_operation_by_id('listWorkspaces').Case() + response = case.call(headers=headers) + assert response.status_code == 200 + ws_id = next(w for w in response.json()['workspace_list'] if w.get('type') == 'personal')['id'] + + body = {'workspace_id': ws_id, 'name': 'SharedBasesTest'} + case: Case = user_account_operations.find_operation_by_id('createBase').Case(body=body) + response = case.call(headers=headers) + assert response.status_code == 201 + base_uuid = response.json()['table']['uuid'] + + # Share the base to the new user + body = {'email': user_id, 'permission': 'r'} + case: Case = user_account_operations.find_operation_by_id('createUserShare') \ + .Case(path_parameters={'workspace_id': ws_id, 'base_name': 'SharedBasesTest'}, body=body) + response = case.call(headers=headers) + assert response.status_code == 201 + + # List bases shared to the user + case: Case = team_admin_account_operations.find_operation_by_id('listBasesSharedToUser') \ + .Case(path_parameters={'org_id': team.team_id, 'user_id': user_id}) + response = case.call(headers=headers) + + assert response.status_code == 200 + + data = response.json() + assert data['count'] == 1 + assert len(data['dtable_list']) == 1 + + shared_base = data['dtable_list'][0] + assert shared_base['uuid'] == base_uuid + assert shared_base['from_user'] == admin_user_id + + matcher = path_type({ + r"dtable_list\.\d+\.id": (int,), + r"dtable_list\.\d+\.workspace_id": (int,), + r"dtable_list\.\d+\.uuid": (str,), + r"dtable_list\.\d+\.created_at": (str,), + r"dtable_list\.\d+\.updated_at": (str,), + r"dtable_list\.\d+\.creator": (str,), + r"dtable_list\.\d+\.modifier": (str,), + r"dtable_list\.\d+\.file_size": (int,), + r"dtable_list\.\d+\.from_user": (str,), + r"dtable_list\.\d+\.from_user_name": (str,), + }, regex=True) + + assert snapshot_json(matcher=matcher) == data + From 362b39eba4be2c0b8024afa74d17ad8757bb3072 Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Fri, 11 Sep 2026 15:33:15 +0200 Subject: [PATCH 2/2] API returns last_login = null when creating a user --- team_admin_account_operations.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/team_admin_account_operations.yaml b/team_admin_account_operations.yaml index 22c623d..ac79657 100644 --- a/team_admin_account_operations.yaml +++ b/team_admin_account_operations.yaml @@ -451,6 +451,7 @@ paths: type: string last_login: type: string + nullable: true self_usage: type: integer quota: