memgres 0.13.2__tar.gz → 0.14.0__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.
- {memgres-0.13.2 → memgres-0.14.0}/PKG-INFO +1 -1
- {memgres-0.13.2 → memgres-0.14.0}/memgres/_version.py +1 -1
- {memgres-0.13.2 → memgres-0.14.0}/memgres/identity.py +19 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/mcp_server.py +5 -1
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/people.py +144 -2
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/routes.py +7 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/spaces.py +32 -1
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/app.css +21 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/account.js +2 -2
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/admin.js +59 -11
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/memory.js +46 -1
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/people.js +33 -4
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/tokens.js +68 -16
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/locales/en.json +41 -1
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/locales/ru.json +41 -1
- {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/PKG-INFO +1 -1
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_identity_integration.py +21 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_web_people.py +118 -1
- {memgres-0.13.2 → memgres-0.14.0}/LICENSE +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/README.md +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/__init__.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/admin.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/admin_cli.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/blame.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/bootstrap.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/config.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/delimiters.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/diffing.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/embed_worker.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/embeddings.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/healthcheck.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/indexing.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/info.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/lines.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/links.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0008_service_roles.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0012_user_profile.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0013_hash_version.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0016_valid_at.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0017_memory_link.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0018_links_built.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0019_memory_usage.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0021_enrollment_key.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0022_user_disabled.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0023_relink_after_parser_fix.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/migrations/0024_web_panel.sql +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/paths.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/periodic.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/reembed.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/relink.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/schema.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/search.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/segments.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/server.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/store.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/tags.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/token_cli.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/vector/__init__.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/vector/base.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/vector/pgvector.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/vector/qdrant.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/__init__.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/admission.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/auth_routes.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/memory.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/oidc.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/oidc_config.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/sessions.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/OFL-JetBrainsMono.txt +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/OFL-Onest.txt +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/jetbrainsmono-cyrillic-ext.woff2 +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/jetbrainsmono-cyrillic.woff2 +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/onest-cyrillic-ext.woff2 +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/onest-cyrillic.woff2 +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/onest-latin-ext.woff2 +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts/onest-latin.woff2 +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/fonts.css +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/index.html +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/api.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/app.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/gate.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/i18n.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/md.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/signins.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/space.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/ui.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/js/viz.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/LICENSE-d3.txt +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/LICENSE-dompurify.txt +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/LICENSE-marked.txt +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/d3.min.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/marked.esm.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/static/vendor/purify.es.js +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/web/tokens.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres/worker.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/SOURCES.txt +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/entry_points.txt +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/pyproject.toml +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/setup.cfg +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_admin_two_way.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_blame_integration.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_chunk_index.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_config.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_diffing.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_embed_worker.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_embeddings.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_enrollment.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_healthcheck.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_lexical_match.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_limits.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_links.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_list.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_admin_tools.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_error_messages.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_http_transport.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_tool_visibility.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_mcp_tool_visibility_http.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_multi_space_search.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_path_addressing.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_replace_build.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_require_title.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_required_fields.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_retention.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_roles_bootstrap.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_search_integration.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_security_followups.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_security_integration.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_segments.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_segments_store.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_server_info.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_server_integration.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_snippets.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_store_integration.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_tags.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_token_sink.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_usage.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_valid_at.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_web_memory.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_web_oidc.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_web_session.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_web_tokens.py +0 -0
- {memgres-0.13.2 → memgres-0.14.0}/tests/test_write_ergonomics.py +0 -0
|
@@ -1006,6 +1006,10 @@ def create_own_namespace(conn, principal: Principal, name: str, *,
|
|
|
1006
1006
|
had worked. Creation is now something you ask for, which makes a typo an
|
|
1007
1007
|
error rather than a place — and gives the `can_create_namespace` right a
|
|
1008
1008
|
single point to be enforced at.
|
|
1009
|
+
|
|
1010
|
+
Unlike :func:`create_namespace`, which the provisioning doors call and which
|
|
1011
|
+
hands back an existing space of the same name so a script can be re-run,
|
|
1012
|
+
this door REFUSES that name: whoever called it asked for a new space.
|
|
1009
1013
|
"""
|
|
1010
1014
|
if principal.user_id is None:
|
|
1011
1015
|
if not principal.provisional:
|
|
@@ -1027,6 +1031,21 @@ def create_own_namespace(conn, principal: Principal, name: str, *,
|
|
|
1027
1031
|
if not can_create_namespace(conn, principal):
|
|
1028
1032
|
raise AuthError("you may not create namespaces — ask an admin to create "
|
|
1029
1033
|
"one for you or share theirs")
|
|
1034
|
+
# `create_namespace` is an idempotent upsert, which is right for the
|
|
1035
|
+
# provisioning doors (a script must be re-runnable) and wrong for this one.
|
|
1036
|
+
# Here the caller is an agent or a person who said "create", and being
|
|
1037
|
+
# handed a space they already had — with `description` and `instruction`
|
|
1038
|
+
# silently NOT applied, because the upsert does nothing on conflict — looks
|
|
1039
|
+
# exactly like success. The refusal names the space, so a retry after a
|
|
1040
|
+
# dropped connection is answered rather than left to guess.
|
|
1041
|
+
with conn.cursor() as cur:
|
|
1042
|
+
cur.execute("SELECT id FROM namespace WHERE owner_user_id=%s AND name=%s",
|
|
1043
|
+
(principal.user_id, name))
|
|
1044
|
+
row = cur.fetchone()
|
|
1045
|
+
if row is not None:
|
|
1046
|
+
raise SpaceAmbiguous(
|
|
1047
|
+
f"you already own a namespace called '{name}' ({row[0]}) — write to "
|
|
1048
|
+
f"it, or pick another name")
|
|
1030
1049
|
# the alias collision and the per-account cap are enforced in
|
|
1031
1050
|
# `create_namespace`, so every door gets them
|
|
1032
1051
|
return create_namespace(conn, principal.user_id, name,
|
|
@@ -718,7 +718,11 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
718
718
|
"""Create a namespace of your own. Nothing creates one implicitly, so a
|
|
719
719
|
mistyped `space` is an error rather than a new empty space your write
|
|
720
720
|
silently lands in. `description` says what belongs here and `instruction`
|
|
721
|
-
tells an agent how to use it. Needs the right to create namespaces.
|
|
721
|
+
tells an agent how to use it. Needs the right to create namespaces.
|
|
722
|
+
|
|
723
|
+
A name you already own is refused, and the error names that space: this
|
|
724
|
+
never hands you an existing namespace as if it were new. To change what
|
|
725
|
+
an existing one says, edit it instead of creating it again."""
|
|
722
726
|
with pool.connection() as conn, conn.transaction():
|
|
723
727
|
nsid = identity.create_own_namespace(
|
|
724
728
|
conn, _principal(conn, _token(ctx)), name,
|
|
@@ -9,6 +9,13 @@ Who sees what:
|
|
|
9
9
|
administrator's account, and only a superadmin hands out roles;
|
|
10
10
|
* **someone you share a space with** — who they are (name, department,
|
|
11
11
|
position) and what they wrote in the spaces you can both read;
|
|
12
|
+
* **what someone wrote** — the activity chart and the list of recent edits —
|
|
13
|
+
is only ever shown where the VIEWER can read too: a superadmin reads every
|
|
14
|
+
space, everyone else (a user_manager included) only the spaces they are in.
|
|
15
|
+
so the PAGE never displays records from a space the viewer cannot open.
|
|
16
|
+
That is a rule about this page, not a containment guarantee about the role:
|
|
17
|
+
an administrator who can mint a token FOR someone can read what that someone
|
|
18
|
+
reads, which docs/TENANCY.md states outright;
|
|
12
19
|
* **anyone else** — not found. There is no directory for ordinary users.
|
|
13
20
|
|
|
14
21
|
Activity is writes only, taken from memory history: what someone created,
|
|
@@ -93,6 +100,38 @@ def activity(conn, user_id: str, *, spaces: Optional[list]) -> dict:
|
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
|
|
103
|
+
RECENT_EDITS = 30
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def recent_edits(conn, user_id: str, *, spaces: Optional[list]) -> list:
|
|
107
|
+
"""The person's latest changes, newest first — record, space, kind, when.
|
|
108
|
+
``spaces=None`` means every space (a superadmin looking)."""
|
|
109
|
+
params: list = [user_id]
|
|
110
|
+
where = ""
|
|
111
|
+
if spaces is not None:
|
|
112
|
+
if not spaces:
|
|
113
|
+
return []
|
|
114
|
+
where = " AND m.namespace = ANY(%s::text[])"
|
|
115
|
+
params.append(spaces)
|
|
116
|
+
params.append(RECENT_EDITS)
|
|
117
|
+
with conn.cursor() as cur:
|
|
118
|
+
cur.execute(
|
|
119
|
+
"SELECT h.created_at, h.op, m.id::text, m.namespace, m.path::text, m.title, n.name "
|
|
120
|
+
"FROM memory_history h JOIN memory m ON m.id = h.memory_id "
|
|
121
|
+
"JOIN namespace n ON n.id::text = m.namespace "
|
|
122
|
+
"WHERE h.author_user_id = %s" + where + " ORDER BY h.created_at DESC, h.id DESC LIMIT %s", params)
|
|
123
|
+
return [{"at": at, "op": op, "record_id": rid, "space_id": ns, "path": path, "title": title or "",
|
|
124
|
+
"space": sname} for at, op, rid, ns, path, title, sname in cur.fetchall()]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _viewer_scope(conn, viewer) -> Optional[list]:
|
|
128
|
+
"""The spaces whose contents this viewer may see: every one for a superadmin
|
|
129
|
+
(its role reads them all), otherwise its own."""
|
|
130
|
+
if viewer.user_id is None or viewer.role == "superadmin":
|
|
131
|
+
return None
|
|
132
|
+
return _reachable(conn, viewer.user_id)
|
|
133
|
+
|
|
134
|
+
|
|
96
135
|
def _account(cur, user_id: str):
|
|
97
136
|
cur.execute("SELECT id, name, full_name, email, department, position, role, "
|
|
98
137
|
"disabled_at IS NOT NULL, created_at, can_create_namespace FROM app_user WHERE id = %s",
|
|
@@ -127,7 +166,8 @@ def profile(conn, viewer, target_id: str, *, providers: dict) -> dict:
|
|
|
127
166
|
(together,))
|
|
128
167
|
spaces = [{"id": i, "name": n} for i, n in cur.fetchall()]
|
|
129
168
|
return {"view": "colleague", "person": person, "shared_spaces": spaces,
|
|
130
|
-
"activity": activity(conn, str(uid), spaces=together)
|
|
169
|
+
"activity": activity(conn, str(uid), spaces=together),
|
|
170
|
+
"recent": recent_edits(conn, str(uid), spaces=together)}
|
|
131
171
|
|
|
132
172
|
# who holds authority on the server is not a colleague's business
|
|
133
173
|
person.update({"role": role, "email": email, "disabled": bool(disabled), "created_at": created,
|
|
@@ -141,6 +181,7 @@ def profile(conn, viewer, target_id: str, *, providers: dict) -> dict:
|
|
|
141
181
|
# tokens and sign-in methods are not theirs to see (admin.list_tokens
|
|
142
182
|
# refuses the same thing).
|
|
143
183
|
may_manage = is_self or viewer.role == "superadmin" or role not in ADMIN_ROLES
|
|
184
|
+
scope = _viewer_scope(conn, viewer)
|
|
144
185
|
out = {
|
|
145
186
|
"view": "self" if is_self else "admin",
|
|
146
187
|
"person": person,
|
|
@@ -149,7 +190,8 @@ def profile(conn, viewer, target_id: str, *, providers: dict) -> dict:
|
|
|
149
190
|
"signins": admission.identities(conn, str(uid), providers) if may_manage else None,
|
|
150
191
|
"tokens": tokens.list_own(conn, str(uid)) if may_manage else None,
|
|
151
192
|
"sessions": live_sessions,
|
|
152
|
-
"activity": activity(conn, str(uid), spaces=
|
|
193
|
+
"activity": activity(conn, str(uid), spaces=scope),
|
|
194
|
+
"recent": recent_edits(conn, str(uid), spaces=scope),
|
|
153
195
|
}
|
|
154
196
|
if not is_self:
|
|
155
197
|
# what this administrator may do to this account, by the rules that
|
|
@@ -275,6 +317,86 @@ def edit(conn, p, user_id: str, changes: dict) -> None:
|
|
|
275
317
|
_guard(lambda: admin.edit_user(conn, p, user_id=user_id, **fields))
|
|
276
318
|
|
|
277
319
|
|
|
320
|
+
def create_person(conn, p, fields: dict) -> str:
|
|
321
|
+
"""An account made by an administrator — for someone who will sign in later
|
|
322
|
+
(the email is how their first sign-in finds it) or for a service that only
|
|
323
|
+
ever uses tokens. It gets no spaces and no rights: those are separate acts."""
|
|
324
|
+
clean = {}
|
|
325
|
+
for k in ("name", "full_name", "email", "department", "position"):
|
|
326
|
+
v = fields.get(k)
|
|
327
|
+
if v is None:
|
|
328
|
+
continue
|
|
329
|
+
if not isinstance(v, str):
|
|
330
|
+
raise Refused(422, f"{k} must be text")
|
|
331
|
+
v = v.strip()
|
|
332
|
+
if len(v) > 200:
|
|
333
|
+
raise Refused(422, f"{k} is longer than 200 characters")
|
|
334
|
+
if v:
|
|
335
|
+
clean[k] = v
|
|
336
|
+
if "email" in clean:
|
|
337
|
+
from .spaces import Refused as SpaceRefused, normalize_email
|
|
338
|
+
try:
|
|
339
|
+
clean["email"] = normalize_email(clean["email"])
|
|
340
|
+
except SpaceRefused as e:
|
|
341
|
+
raise Refused(422, str(e)) from None
|
|
342
|
+
with conn.cursor() as cur:
|
|
343
|
+
cur.execute("SELECT 1 FROM app_user WHERE lower(email) = lower(%s)", (clean["email"],))
|
|
344
|
+
if cur.fetchone():
|
|
345
|
+
raise Refused(409, "another account already has this email")
|
|
346
|
+
if not (clean.get("full_name") or clean.get("email") or clean.get("name")):
|
|
347
|
+
raise Refused(422, "give the account a name or an email")
|
|
348
|
+
name = clean.pop("name", None) or clean.get("email") or clean.get("full_name")
|
|
349
|
+
return _guard(lambda: admin.create_user(conn, p, name=name, **clean))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def set_can_create_spaces(conn, p, user_id: str, allowed: bool) -> None:
|
|
353
|
+
user_id = _uuid(user_id)
|
|
354
|
+
_guard(lambda: admin.set_can_create_namespace(conn, p, user_id=user_id, allowed=bool(allowed)))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def issue_for(conn, p, user_id: str, *, label: str, permission: str,
|
|
358
|
+
namespace_id: Optional[str], expires_days: int) -> dict:
|
|
359
|
+
"""A token for someone else — the same shape a person gets for themselves
|
|
360
|
+
(read or write, always expiring, optionally one of their spaces), minted by
|
|
361
|
+
an administrator for an account that cannot do it itself: a service, or
|
|
362
|
+
someone who has not signed in yet. The secret is shown once, to the
|
|
363
|
+
administrator, who hands it over — the same exemption from
|
|
364
|
+
``MEMGRES_TOKEN_SINK`` the self-service door takes, and for the same reason:
|
|
365
|
+
the sink keeps secrets out of *agent* transcripts, and the reader here is a
|
|
366
|
+
human looking at a dialog that says "copy it now".
|
|
367
|
+
|
|
368
|
+
The cap on live tokens is the account's, not the issuer's: an administrator
|
|
369
|
+
minting for someone cannot fill the table past what that person could fill
|
|
370
|
+
it to themselves."""
|
|
371
|
+
from .tokens import EXPIRY_CHOICES, MAX_LABEL, MAX_LIVE_TOKENS, PERMISSIONS
|
|
372
|
+
user_id = _uuid(user_id)
|
|
373
|
+
label = (label or "").strip()
|
|
374
|
+
if len(label) > MAX_LABEL:
|
|
375
|
+
raise Refused(422, f"the label is longer than {MAX_LABEL} characters")
|
|
376
|
+
if permission not in PERMISSIONS:
|
|
377
|
+
raise Refused(422, "access must be read or write")
|
|
378
|
+
if expires_days not in EXPIRY_CHOICES:
|
|
379
|
+
raise Refused(422, f"expiry must be one of {', '.join(map(str, EXPIRY_CHOICES))} days")
|
|
380
|
+
if namespace_id:
|
|
381
|
+
namespace_id = _uuid(namespace_id)
|
|
382
|
+
if identity.reaches(conn, user_id, namespace_id) is None:
|
|
383
|
+
raise Refused(422, "that person cannot open this space — add them to it first")
|
|
384
|
+
with conn.cursor() as cur:
|
|
385
|
+
cur.execute("SELECT 1 FROM app_user WHERE id = %s", (user_id,))
|
|
386
|
+
if cur.fetchone() is None:
|
|
387
|
+
raise Refused(404, "not found")
|
|
388
|
+
cur.execute("SELECT count(*) FROM token WHERE user_id = %s AND revoked_at IS NULL "
|
|
389
|
+
"AND (expires_at IS NULL OR expires_at > now())", (user_id,))
|
|
390
|
+
if cur.fetchone()[0] >= MAX_LIVE_TOKENS:
|
|
391
|
+
raise Refused(409, f"that account already has {MAX_LIVE_TOKENS} active tokens — "
|
|
392
|
+
"revoke some it no longer uses")
|
|
393
|
+
out = _guard(lambda: admin.issue_token(conn, p, user_id=user_id, namespace_id=namespace_id or None,
|
|
394
|
+
permission=permission, label=label, expires_days=expires_days,
|
|
395
|
+
defer_delivery=True))
|
|
396
|
+
return {"id": out["id"], "token": out["secret"], "permission": permission,
|
|
397
|
+
"namespace_id": namespace_id or None, "expires_days": expires_days}
|
|
398
|
+
|
|
399
|
+
|
|
278
400
|
# ─── record history (for authors) ────────────────────────────────────────────
|
|
279
401
|
def record_history(store, principal, space_id: str, record_id: str) -> list:
|
|
280
402
|
"""Who changed a record and when — the way into a person's profile from
|
|
@@ -324,6 +446,26 @@ def mount(app, cfg, pool, panel, providers, make_store) -> None:
|
|
|
324
446
|
_run(lambda conn: edit(conn, p, user_id, changes))
|
|
325
447
|
return {"user_id": user_id}
|
|
326
448
|
|
|
449
|
+
@app.post("/ui/api/admin/people", status_code=201)
|
|
450
|
+
def new_person(request: Request, fields: dict = Body(...)):
|
|
451
|
+
p = control_principal(_admin(request, changing=True))
|
|
452
|
+
return {"id": _run(lambda conn: create_person(conn, p, fields))}
|
|
453
|
+
|
|
454
|
+
@app.post("/ui/api/admin/people/{user_id}/can-create-spaces")
|
|
455
|
+
def can_create(user_id: str, request: Request, allowed: bool = Body(..., embed=True)):
|
|
456
|
+
p = control_principal(_admin(request, changing=True))
|
|
457
|
+
_run(lambda conn: set_can_create_spaces(conn, p, user_id, allowed))
|
|
458
|
+
return {"user_id": user_id, "can_create_namespace": bool(allowed)}
|
|
459
|
+
|
|
460
|
+
@app.post("/ui/api/admin/people/{user_id}/tokens", status_code=201)
|
|
461
|
+
def token_for(user_id: str, request: Request, label: str = Body("", embed=True),
|
|
462
|
+
permission: str = Body("write", embed=True),
|
|
463
|
+
namespace_id: Optional[str] = Body(None, embed=True),
|
|
464
|
+
expires_days: int = Body(90, embed=True)):
|
|
465
|
+
p = control_principal(_admin(request, changing=True))
|
|
466
|
+
return _run(lambda conn: issue_for(conn, p, user_id, label=label, permission=permission,
|
|
467
|
+
namespace_id=namespace_id, expires_days=expires_days))
|
|
468
|
+
|
|
327
469
|
@app.post("/ui/api/admin/people/{user_id}/role")
|
|
328
470
|
def set_role(user_id: str, request: Request, role: str = Body(..., embed=True)):
|
|
329
471
|
p = control_principal(_admin(request, changing=True))
|
|
@@ -149,8 +149,11 @@ def mount(app, cfg, pool, make_store, *, oidc_fetch=None) -> None:
|
|
|
149
149
|
|
|
150
150
|
def _session_view(s) -> dict:
|
|
151
151
|
pending = 0
|
|
152
|
+
can_create = False
|
|
152
153
|
with pool.connection() as conn:
|
|
153
154
|
me = sessions.profile(conn, s.user_id)
|
|
155
|
+
if s.user_id is not None:
|
|
156
|
+
can_create = identity.can_create_namespace(conn, sessions.control_principal(s))
|
|
154
157
|
if s.role in identity.ADMIN_ROLES:
|
|
155
158
|
with conn.cursor() as cur:
|
|
156
159
|
cur.execute("SELECT count(*) FROM signin_request WHERE status = 'pending'")
|
|
@@ -168,8 +171,12 @@ def mount(app, cfg, pool, make_store, *, oidc_fetch=None) -> None:
|
|
|
168
171
|
"memory": s.user_id is not None,
|
|
169
172
|
"tokens": s.user_id is not None,
|
|
170
173
|
"admin": s.role in identity.ADMIN_ROLES,
|
|
174
|
+
# the right to make a space of one's own, as the control plane reads it
|
|
175
|
+
"create_space": s.user_id is not None and can_create,
|
|
171
176
|
},
|
|
172
177
|
"locales": list(LOCALES),
|
|
178
|
+
# where agents connect, for the token dialogs (the panel cannot work it out)
|
|
179
|
+
"mcp_url": cfg.mcp_public_url or None,
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
def _client_key(request: Request) -> str:
|
|
@@ -7,7 +7,9 @@ second copy of them:
|
|
|
7
7
|
* members, invitations and requests of a space — its administrators (the owner,
|
|
8
8
|
an ``admin`` member, a superadmin);
|
|
9
9
|
* handing the space to someone else — the owner or a superadmin;
|
|
10
|
-
* asking to join, or leaving — the person themselves
|
|
10
|
+
* asking to join, or leaving — the person themselves;
|
|
11
|
+
* making a space of your own — anyone the deployment granted that right
|
|
12
|
+
(``can_create_namespace``, a switch on the person's page).
|
|
11
13
|
|
|
12
14
|
**An invitation opens a space, never the server.** Inviting an address that
|
|
13
15
|
already has an account adds that account at once. Otherwise the invitation waits
|
|
@@ -265,6 +267,29 @@ def transfer(conn, p, space_id: str, user_id: str, *, keep_me: bool) -> dict:
|
|
|
265
267
|
raise Refused(409, str(e)) from None
|
|
266
268
|
|
|
267
269
|
|
|
270
|
+
def create_own(conn, p, *, name: str, description: str) -> dict:
|
|
271
|
+
"""A space of one's own. The right is the control plane's
|
|
272
|
+
(``can_create_namespace``); everything else — the name's shape, the cap on
|
|
273
|
+
how many one account may own — is enforced where namespaces are created."""
|
|
274
|
+
name = (name or "").strip()
|
|
275
|
+
if not name:
|
|
276
|
+
raise Refused(422, "a space needs a name")
|
|
277
|
+
if len(name) > 100:
|
|
278
|
+
raise Refused(422, "the name is longer than 100 characters")
|
|
279
|
+
description = (description or "").strip()[:500]
|
|
280
|
+
if p.user_id is None:
|
|
281
|
+
raise Refused(409, "the administrator token has no account to own a space")
|
|
282
|
+
try:
|
|
283
|
+
nsid = identity.create_own_namespace(conn, p, name, description=description)
|
|
284
|
+
except identity.SpaceAmbiguous as e: # a name you already own, or the cap
|
|
285
|
+
raise Refused(409, str(e)) from None
|
|
286
|
+
except identity.AuthError as e:
|
|
287
|
+
raise Refused(403, str(e)) from None
|
|
288
|
+
except ValueError as e:
|
|
289
|
+
raise Refused(422, str(e)) from None
|
|
290
|
+
return {"id": nsid, "name": name}
|
|
291
|
+
|
|
292
|
+
|
|
268
293
|
# ─── asking to join ──────────────────────────────────────────────────────────
|
|
269
294
|
def my_request(conn, user_id: str, space_id: str) -> dict:
|
|
270
295
|
"""The caller's own request for a space, if any — answered from the caller's
|
|
@@ -399,6 +424,12 @@ def mount(app, cfg, pool, panel) -> None:
|
|
|
399
424
|
except Refused as e:
|
|
400
425
|
raise HTTPException(e.status, str(e))
|
|
401
426
|
|
|
427
|
+
@app.post("/ui/api/spaces", status_code=201)
|
|
428
|
+
def make_space(request: Request, name: str = Body(..., embed=True),
|
|
429
|
+
description: str = Body("", embed=True)):
|
|
430
|
+
s = panel["changing"](request)
|
|
431
|
+
return _run(lambda conn: create_own(conn, control_principal(s), name=name, description=description))
|
|
432
|
+
|
|
402
433
|
@app.get("/ui/api/spaces/{space_id}/members")
|
|
403
434
|
def members(space_id: str, request: Request):
|
|
404
435
|
p = control_principal(panel["session"](request))
|
|
@@ -489,3 +489,24 @@ a.person:hover { color: var(--focus); box-shadow: inset 0 -1px 0 var(--focus); }
|
|
|
489
489
|
@media (prefers-reduced-motion: reduce) { .spinner, .search.busy::before { animation-duration: 2.4s; } }
|
|
490
490
|
/* the results header — the query and × — stays put while the list scrolls under it */
|
|
491
491
|
.results > .res-head { position: sticky; top: -14px; z-index: 2; margin: -14px -14px 0; padding: 14px 14px 10px; background: var(--panel); border-radius: 14px 14px 0 0; box-shadow: 0 1px 0 var(--line-soft); }
|
|
492
|
+
|
|
493
|
+
/* people directory pages, recent edits, switches, connection how-to */
|
|
494
|
+
.pager { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
|
495
|
+
.pager .note { margin-right: auto; }
|
|
496
|
+
.recent { display: grid; gap: 2px; }
|
|
497
|
+
.rrow { display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 10px; align-items: baseline; padding: 7px 8px; border-radius: 8px; text-decoration: none; color: var(--text); }
|
|
498
|
+
.rrow:hover { background: var(--panel-2); }
|
|
499
|
+
.rrow .op { font: 500 11px var(--mono); color: var(--muted); }
|
|
500
|
+
.rrow .rt { min-width: 0; }
|
|
501
|
+
.rrow .rt b { display: block; font-weight: 500; color: var(--bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
502
|
+
.rrow .rt small { display: flex; align-items: center; gap: 6px; font: 11.5px var(--mono); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
503
|
+
.rrow time { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
|
504
|
+
.switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--text); }
|
|
505
|
+
.switch input { width: 16px; height: 16px; accent-color: var(--admin); }
|
|
506
|
+
.howto .steps { margin: 0; padding-left: 1.3em; display: grid; gap: 10px; }
|
|
507
|
+
.howto .steps li { line-height: 1.55; }
|
|
508
|
+
.howto .snips { margin: 8px 0 6px; }
|
|
509
|
+
.howto pre.code { margin-top: 6px; }
|
|
510
|
+
@media (max-width: 760px) { .rrow { grid-template-columns: minmax(0, 1fr) auto; } .rrow .op { display: none; } }
|
|
511
|
+
.every-link.make { color: var(--focus); text-decoration-color: rgba(79,214,242,.4); }
|
|
512
|
+
.every-link.make:hover { text-decoration-color: var(--focus); }
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { get, patch } from "./api.js";
|
|
4
4
|
import { fillLanguageSelect, t } from "./i18n.js";
|
|
5
|
-
import { activityCard, spacesCard } from "./people.js";
|
|
5
|
+
import { activityCard, recentCard, spacesCard } from "./people.js";
|
|
6
6
|
import { $, esc, toast } from "./ui.js";
|
|
7
7
|
|
|
8
8
|
export function renderAccount(root, ctx) {
|
|
@@ -35,7 +35,7 @@ export function renderAccount(root, ctx) {
|
|
|
35
35
|
<div class="pane" id="acc-more"></div>`;
|
|
36
36
|
get(`/people/${encodeURIComponent(me.id)}`).then((data) => {
|
|
37
37
|
const more = $("#acc-more", box);
|
|
38
|
-
if (more) more.innerHTML = activityCard(data.activity, { note: t("act.noteSelf") }) + spacesCard(data.spaces);
|
|
38
|
+
if (more) more.innerHTML = activityCard(data.activity, { note: t("act.noteSelf") }) + recentCard(data.recent, { note: t("recent.noteSelf") }) + spacesCard(data.spaces);
|
|
39
39
|
}).catch(() => {});
|
|
40
40
|
const select = $("#acc-lang", box);
|
|
41
41
|
fillLanguageSelect(select, me.ui_language || "auto");
|
|
@@ -93,29 +93,30 @@ async function renderRequests(box, ctx) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// ─── the directory ───────────────────────────────────────────────────────────
|
|
96
|
+
const PAGE = 25;
|
|
97
|
+
|
|
96
98
|
async function renderPeople(box) {
|
|
97
|
-
box.innerHTML = `<div class="card"><div class="card-h"><h3>${esc(t("adm.people"))}</h3
|
|
99
|
+
box.innerHTML = `<div class="card"><div class="card-h"><h3>${esc(t("adm.people"))}</h3>
|
|
100
|
+
<button class="btn adminbtn small" id="pp-new">${esc(t("adm.newPerson"))}</button></div>
|
|
98
101
|
<div class="filter" style="max-width:none;margin-bottom:12px"><svg width="12" height="12" viewBox="0 0 16 16" aria-hidden="true"><circle cx="7" cy="7" r="4.6" fill="none" stroke="#707aa0" stroke-width="1.6"/><path d="m10.4 10.4 3.4 3.4" stroke="#707aa0" stroke-width="1.6" stroke-linecap="round"/></svg>
|
|
99
102
|
<input id="pp-q" type="search" autocomplete="off" placeholder="${esc(t("people.searchPh"))}" aria-label="${esc(t("people.searchPh"))}"></div>
|
|
100
103
|
<div class="x"><table id="pp-table"><tbody><tr><td class="note">…</td></tr></tbody></table></div>
|
|
101
|
-
<div class="
|
|
104
|
+
<div class="pager" id="pp-pager"></div>
|
|
102
105
|
</div>
|
|
103
106
|
<p class="note">${esc(t("adm.peopleNote"))}</p>`;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const load = async (append = false) => {
|
|
107
|
+
let offset = 0, total = 0, seq = 0, timer = null;
|
|
108
|
+
const load = async () => {
|
|
107
109
|
const my = ++seq, q = $("#pp-q", box).value.trim();
|
|
108
110
|
let got;
|
|
109
111
|
try {
|
|
110
|
-
got = await get(`/admin/people?limit=${PAGE}&offset=${
|
|
112
|
+
got = await get(`/admin/people?limit=${PAGE}&offset=${offset}&q=${encodeURIComponent(q)}`);
|
|
111
113
|
} catch {
|
|
112
114
|
if (my === seq) $("#pp-table", box).innerHTML = `<tbody><tr><td class="note">${esc(t("err.network"))}</td></tr></tbody>`;
|
|
113
115
|
return;
|
|
114
116
|
}
|
|
115
117
|
if (my !== seq) return;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
$("#pp-more", box).hidden = rows.length >= got.total;
|
|
118
|
+
total = got.total;
|
|
119
|
+
const rows = got.people;
|
|
119
120
|
$("#pp-table", box).innerHTML = rows.length ? `<thead><tr>${["adm.col.person", "acc.role", "adm.col.signins", "adm.col.lastSignin", "adm.col.lastWrite", "tok.status"].map((k) => `<th>${esc(t(k))}</th>`).join("")}</tr></thead>
|
|
120
121
|
<tbody>${rows.map((p) => `<tr>
|
|
121
122
|
<td class="strong">${personLink(p.id, personName(p))}<small class="cell-sub">${esc([p.email, p.department].filter(Boolean).join(" · "))}</small></td>
|
|
@@ -125,8 +126,55 @@ async function renderPeople(box) {
|
|
|
125
126
|
<td>${esc(p.last_write_at ? ago(p.last_write_at) : t("tok.never"))}</td>
|
|
126
127
|
<td><span class="dot ${p.disabled ? "off" : "on"}"><i></i>${esc(t(p.disabled ? "people.disabled" : "people.active"))}</span></td>
|
|
127
128
|
</tr>`).join("")}</tbody>` : `<tbody><tr><td class="note">${esc(t("people.noneFound"))}</td></tr></tbody>`;
|
|
129
|
+
const from = total ? offset + 1 : 0, to = offset + rows.length;
|
|
130
|
+
$("#pp-pager", box).innerHTML = `<span class="note">${esc(t("adm.range", { from: nf(from), to: nf(to), total: nf(total) }))}</span>
|
|
131
|
+
<button class="btn small quiet" data-page="-1" ${offset === 0 ? "disabled" : ""} aria-label="${esc(t("adm.prev"))}">‹ ${esc(t("adm.prev"))}</button>
|
|
132
|
+
<button class="btn small quiet" data-page="1" ${to >= total ? "disabled" : ""} aria-label="${esc(t("adm.next"))}">${esc(t("adm.next"))} ›</button>`;
|
|
128
133
|
};
|
|
129
|
-
$("#pp-q", box).addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => load(), 220); });
|
|
130
|
-
$("#pp-
|
|
134
|
+
$("#pp-q", box).addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => { offset = 0; load(); }, 220); });
|
|
135
|
+
$("#pp-pager", box).addEventListener("click", (e) => {
|
|
136
|
+
const b = e.target.closest("[data-page]");
|
|
137
|
+
if (!b || b.disabled) return;
|
|
138
|
+
offset = Math.max(0, offset + Number(b.dataset.page) * PAGE);
|
|
139
|
+
load();
|
|
140
|
+
});
|
|
141
|
+
$("#pp-new", box).onclick = () => newPersonDialog();
|
|
131
142
|
await load();
|
|
132
143
|
}
|
|
144
|
+
|
|
145
|
+
function newPersonDialog() {
|
|
146
|
+
const veil = document.createElement("div");
|
|
147
|
+
veil.className = "veil";
|
|
148
|
+
const field = (k, label, type = "text") => `<div class="field"><label for="np-${k}">${esc(label)}</label><input id="np-${k}" type="${type}" maxlength="200"></div>`;
|
|
149
|
+
veil.innerHTML = `<form class="dialog" role="dialog" aria-modal="true" aria-labelledby="np-title">
|
|
150
|
+
<h3 id="np-title">${esc(t("adm.newPerson"))}</h3>
|
|
151
|
+
${field("full_name", t("acc.name"))}
|
|
152
|
+
${field("email", t("acc.email"), "email")}
|
|
153
|
+
<div class="two">${field("department", t("people.department"))}${field("position", t("people.position"))}</div>
|
|
154
|
+
<p class="note">${esc(t("adm.newPersonNote"))}</p>
|
|
155
|
+
<p class="err" id="np-err" role="alert" hidden></p>
|
|
156
|
+
<div class="row" style="justify-content:flex-end"><button type="button" class="btn quiet" data-x>${esc(t("common.cancel"))}</button>
|
|
157
|
+
<button type="submit" class="btn adminbtn">${esc(t("adm.create"))}</button></div>
|
|
158
|
+
</form>`;
|
|
159
|
+
document.body.append(veil);
|
|
160
|
+
const close = () => veil.remove();
|
|
161
|
+
veil.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
|
|
162
|
+
veil.addEventListener("click", (e) => { if (e.target === veil || e.target.closest("[data-x]")) close(); });
|
|
163
|
+
$("#np-full_name", veil).focus();
|
|
164
|
+
$("form", veil).addEventListener("submit", async (e) => {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
const body = {};
|
|
167
|
+
for (const k of ["full_name", "email", "department", "position"]) body[k] = $("#np-" + k, veil).value.trim();
|
|
168
|
+
try {
|
|
169
|
+
const { id } = await post("/admin/people", body);
|
|
170
|
+
close();
|
|
171
|
+
toast(t("adm.created", { name: body.full_name || body.email }));
|
|
172
|
+
history.pushState(null, "", `/people?id=${encodeURIComponent(id)}`);
|
|
173
|
+
window.memgresPanel?.render?.();
|
|
174
|
+
} catch (ex) {
|
|
175
|
+
const err = $("#np-err", veil);
|
|
176
|
+
err.textContent = ex instanceof ApiError && ex.detail ? String(ex.detail) : t("err.save");
|
|
177
|
+
err.hidden = false;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -34,6 +34,9 @@ export async function renderMemory(root, ctx) {
|
|
|
34
34
|
if (!target) { showEmpty(); return; }
|
|
35
35
|
if (!m.space || m.space.id !== target.id || !m.graph) await openSpace(target);
|
|
36
36
|
else { mountViz(); inspect(); }
|
|
37
|
+
// a link to one record (from a person's recent edits): open it where it is
|
|
38
|
+
const rec = new URLSearchParams(location.search).get("record");
|
|
39
|
+
if (rec && m.byRecord.has(rec)) select(m.byRecord.get(rec), { centre: true });
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
// The sidebar's list of spaces, on every page — not only while memory is open.
|
|
@@ -95,6 +98,46 @@ function forget(id) {
|
|
|
95
98
|
else renderSpaceList();
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
// A space of your own — for whoever the deployment granted that right.
|
|
102
|
+
function makeSpaceDialog() {
|
|
103
|
+
const veil = document.createElement("div");
|
|
104
|
+
veil.className = "veil";
|
|
105
|
+
veil.innerHTML = `<form class="dialog" role="dialog" aria-modal="true" aria-labelledby="ms-title">
|
|
106
|
+
<h3 id="ms-title">${esc(t("sp.make"))}</h3>
|
|
107
|
+
<div class="field"><label for="ms-name">${esc(t("sp.makeName"))}</label>
|
|
108
|
+
<input id="ms-name" maxlength="100" required placeholder="${esc(t("sp.makeNamePh"))}"></div>
|
|
109
|
+
<div class="field"><label for="ms-desc">${esc(t("sp.makeDesc"))}</label>
|
|
110
|
+
<input id="ms-desc" maxlength="500" placeholder="${esc(t("sp.makeDescPh"))}"></div>
|
|
111
|
+
<p class="note">${esc(t("sp.makeNote"))}</p>
|
|
112
|
+
<p class="err" id="ms-err" role="alert" hidden></p>
|
|
113
|
+
<div class="row" style="justify-content:flex-end"><button type="button" class="btn quiet" data-x>${esc(t("common.cancel"))}</button>
|
|
114
|
+
<button type="submit" class="btn primary">${esc(t("sp.makeGo"))}</button></div>
|
|
115
|
+
</form>`;
|
|
116
|
+
document.body.append(veil);
|
|
117
|
+
const close = () => veil.remove();
|
|
118
|
+
veil.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
|
|
119
|
+
veil.addEventListener("click", (e) => { if (e.target === veil || e.target.closest("[data-x]")) close(); });
|
|
120
|
+
$("#ms-name", veil).focus();
|
|
121
|
+
$("form", veil).addEventListener("submit", async (e) => {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
const submit = $("button[type=submit]", veil);
|
|
124
|
+
submit.disabled = true;
|
|
125
|
+
try {
|
|
126
|
+
const made = await post("/spaces", { name: $("#ms-name", veil).value.trim(), description: $("#ms-desc", veil).value.trim() });
|
|
127
|
+
close();
|
|
128
|
+
toast(t("sp.made", { name: made.name }));
|
|
129
|
+
m.loaded = false;
|
|
130
|
+
$("#shell").classList.remove("drawer");
|
|
131
|
+
m.navigate(`/memory?space=${encodeURIComponent(made.id)}`);
|
|
132
|
+
} catch (ex) {
|
|
133
|
+
const err = $("#ms-err", veil);
|
|
134
|
+
err.textContent = ex instanceof ApiError && ex.detail ? String(ex.detail) : t("err.save");
|
|
135
|
+
err.hidden = false;
|
|
136
|
+
submit.disabled = false;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
98
141
|
function openEverySpace() {
|
|
99
142
|
const veil = document.createElement("div");
|
|
100
143
|
veil.className = "veil";
|
|
@@ -247,6 +290,7 @@ function wireSidebar() {
|
|
|
247
290
|
const x = e.target.closest("[data-forget]");
|
|
248
291
|
if (x) { e.preventDefault(); e.stopPropagation(); forget(x.dataset.forget); return; }
|
|
249
292
|
if (e.target.closest("[data-every]")) openEverySpace();
|
|
293
|
+
if (e.target.closest("[data-make]")) makeSpaceDialog();
|
|
250
294
|
}, true);
|
|
251
295
|
$("#space-filter").addEventListener("input", renderSpaceList);
|
|
252
296
|
}
|
|
@@ -299,7 +343,8 @@ function renderSpaceList() {
|
|
|
299
343
|
const role = visiting.length ? `<div class="sec-h visiting-h" title="${esc(t("every.groupWhy"))}"><span>${esc(t("every.group"))}</span></div>` + visiting.map((s) =>
|
|
300
344
|
`<a class="spaceitem visiting" role="listitem" href="/memory?space=${encodeURIComponent(s.id)}" data-space="${esc(s.id)}" style="--c:${spaceColor(s)}" aria-current="${m.space?.id === s.id}" title="${esc(t("every.itemWhy", { name: s.name }))}">${hexIcon(spaceColor(s), true, 16)}<span class="nm">${esc(s.name)}</span><button class="forget" data-forget="${esc(s.id)}" aria-label="${esc(t("every.forget", { name: s.name }))}">×</button></a>`).join("") : "";
|
|
301
345
|
const every = isSuper() ? `<button class="every-link" data-every>${esc(t("every.link"))}</button>` : "";
|
|
302
|
-
|
|
346
|
+
const make = m.session?.can?.create_space ? `<button class="every-link make" data-make>${esc(t("sp.make"))}</button>` : "";
|
|
347
|
+
$("#spacelist").innerHTML = own + role + make + every;
|
|
303
348
|
if (m.space) $("#mbar-space").innerHTML = `${hexIcon(spaceColor(m.space), false, 14)}${esc(m.space.name)}`;
|
|
304
349
|
}
|
|
305
350
|
|