diff --git a/echo/directus/migrations/add_announcement_read_policy.py b/echo/directus/migrations/add_announcement_read_policy.py new file mode 100644 index 00000000..033f1fdd --- /dev/null +++ b/echo/directus/migrations/add_announcement_read_policy.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Idempotent migration: share the announcement read grant with every app role. + +The announcement, announcement_translations and announcement_activity read +permissions used to live only on the Basic User Policy, and that policy is +attached to the Basic User role alone. Every signed-in user on the Enterprise +User or Read-Only role got a Directus FORBIDDEN response the moment the sidebar +inbox polled for announcements, so those accounts saw no announcements and no +unread count. + +This script creates a dedicated read-only "Announcements" policy and attaches +it to the Enterprise User and Read-Only roles. The policy grants read on the +announcement content plus read/create/update on the per-user activity rows, so +the inbox and its unread count work while announcement content stays read-only. +Basic User keeps its own grant and is left untouched. + +Run it against a Directus instance, then pull the schema so the sync snapshot +matches: + + python3 add_announcement_read_policy.py \ + -u http://directus:8055 -e admin@dembrane.com -p admin + cd .. && bash sync.sh -u http://directus:8055 -e admin@dembrane.com -p admin pull + +Usage: + python3 add_announcement_read_policy.py \ + -u http://directus:8055 -e admin@dembrane.com -p admin [--dry-run] +""" + +from __future__ import annotations + +import sys +import json +import argparse +import urllib.error +import urllib.parse +import urllib.request + +POLICY_NAME = "Announcements" + +# Roles that load the app but were missing the announcement grant. Basic User +# already has it through its own policy, so it is not listed here. +TARGET_ROLE_NAMES = ["Enterprise User", "Read-Only"] + +CURRENT_USER = {"_and": [{"user_id": {"_eq": "$CURRENT_USER"}}]} + +ACTIVITY_FIELDS = [ + "id", + "user_created", + "created_at", + "sort", + "updated_at", + "user_updated", + "user_id", + "read", + "announcement_activity", +] + + +def permission_rows(policy_id: str) -> list[dict]: + """The exact grants the sidebar inbox needs, scoped to the current user.""" + return [ + { + "policy": policy_id, + "collection": "announcement", + "action": "read", + "permissions": { + "_or": [ + {"expires_at": {"_gte": "$NOW"}}, + {"expires_at": {"_null": True}}, + ] + }, + "validation": None, + "presets": None, + "fields": ["*"], + }, + { + "policy": policy_id, + "collection": "announcement_translations", + "action": "read", + "permissions": None, + "validation": None, + "presets": None, + "fields": ["id", "message", "title", "languages_code", "announcement_id"], + }, + { + "policy": policy_id, + "collection": "announcement_activity", + "action": "read", + "permissions": CURRENT_USER, + "validation": None, + "presets": None, + "fields": ACTIVITY_FIELDS, + }, + { + "policy": policy_id, + "collection": "announcement_activity", + "action": "create", + "permissions": None, + "validation": CURRENT_USER, + "presets": None, + "fields": ACTIVITY_FIELDS, + }, + { + "policy": policy_id, + "collection": "announcement_activity", + "action": "update", + "permissions": CURRENT_USER, + "validation": None, + "presets": None, + "fields": ACTIVITY_FIELDS, + }, + ] + + +class Directus: + def __init__(self, base_url: str, token: str, dry_run: bool = False): + self.base = base_url.rstrip("/") + self.token = token + self.dry_run = dry_run + + def _request(self, method: str, path: str, body: dict | None = None) -> dict: + url = f"{self.base}{path}" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", f"Bearer {self.token}") + if data is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req) as resp: + raw = resp.read().decode() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + detail = e.read().decode() + raise RuntimeError(f"{method} {path} -> {e.code}: {detail}") from None + + def get(self, path: str) -> dict: + return self._request("GET", path) + + def post(self, path: str, body: dict) -> dict: + if self.dry_run: + print(f" [dry-run] POST {path} {json.dumps(body)}") + return {"data": {"id": "dry-run"}} + return self._request("POST", path, body) + + def role_id(self, name: str) -> str: + q = f"/roles?filter[name][_eq]={urllib.parse.quote(name)}&fields=id&limit=1" + data = self.get(q).get("data", []) + if not data: + raise RuntimeError(f"role not found: {name}") + return data[0]["id"] + + def find_policy(self, name: str) -> str | None: + q = f"/policies?filter[name][_eq]={urllib.parse.quote(name)}&fields=id&limit=1" + data = self.get(q).get("data", []) + return data[0]["id"] if data else None + + def has_access(self, policy_id: str, role_id: str) -> bool: + q = ( + f"/access?filter[policy][_eq]={policy_id}" + f"&filter[role][_eq]={role_id}&fields=id&limit=1" + ) + return bool(self.get(q).get("data", [])) + + def has_permission(self, policy_id: str, collection: str, action: str) -> bool: + q = ( + f"/permissions?filter[policy][_eq]={policy_id}" + f"&filter[collection][_eq]={collection}" + f"&filter[action][_eq]={action}&fields=id&limit=1" + ) + return bool(self.get(q).get("data", [])) + + +def login(base_url: str, email: str, password: str) -> str: + url = f"{base_url.rstrip('/')}/auth/login" + body = json.dumps({"email": email, "password": password}).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode())["data"]["access_token"] + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-u", "--url", required=True) + ap.add_argument("-e", "--email", required=True) + ap.add_argument("-p", "--password", required=True) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + try: + print(f"Logging in to {args.url} as {args.email}...") + tok = login(args.url, args.email, args.password) + dx = Directus(args.url, tok, dry_run=args.dry_run) + + print(f"Resolving roles: {', '.join(TARGET_ROLE_NAMES)}...") + role_ids = {name: dx.role_id(name) for name in TARGET_ROLE_NAMES} + + policy_id = dx.find_policy(POLICY_NAME) + if policy_id: + print(f" policy {POLICY_NAME!r}: exists ({policy_id}), reusing") + else: + print(f" policy {POLICY_NAME!r}: creating") + policy_id = dx.post( + "/policies", + { + "name": POLICY_NAME, + "icon": "campaign", + "description": "Read dembrane announcements and track read state.", + "ip_access": None, + "enforce_tfa": False, + "admin_access": False, + "app_access": False, + }, + )["data"]["id"] + + for name, rid in role_ids.items(): + if dx.has_access(policy_id, rid): + print(f" access {name}: exists, skipping") + else: + print(f" access {name}: attaching policy") + dx.post("/access", {"policy": policy_id, "role": rid}) + + for row in permission_rows(policy_id): + key = f"{row['collection']}:{row['action']}" + if dx.has_permission(policy_id, row["collection"], row["action"]): + print(f" permission {key}: exists, skipping") + else: + print(f" permission {key}: creating") + dx.post("/permissions", row) + + print("Migration complete!") + return 0 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/echo/directus/sync/collections/permissions.json b/echo/directus/sync/collections/permissions.json index b3ce517d..28a76ac3 100644 --- a/echo/directus/sync/collections/permissions.json +++ b/echo/directus/sync/collections/permissions.json @@ -1077,5 +1077,130 @@ ], "policy": "abf8a154-5b1c-4a46-ac9c-7300570f4f17", "_syncId": "996d6fa1-0ec8-4f52-8627-77b3648e065e" + }, + { + "collection": "announcement", + "action": "read", + "permissions": { + "_or": [ + { + "expires_at": { + "_gte": "$NOW" + } + }, + { + "expires_at": { + "_null": true + } + } + ] + }, + "validation": null, + "presets": null, + "fields": [ + "*" + ], + "policy": "67aeb60d-5454-4217-8af5-2f22f96032e6", + "_syncId": "c63b2854-c443-491e-bf1a-5afde2523d10" + }, + { + "collection": "announcement_translations", + "action": "read", + "permissions": null, + "validation": null, + "presets": null, + "fields": [ + "id", + "message", + "title", + "languages_code", + "announcement_id" + ], + "policy": "67aeb60d-5454-4217-8af5-2f22f96032e6", + "_syncId": "57e05b93-c5ca-45d7-bfea-6174751e2859" + }, + { + "collection": "announcement_activity", + "action": "read", + "permissions": { + "_and": [ + { + "user_id": { + "_eq": "$CURRENT_USER" + } + } + ] + }, + "validation": null, + "presets": null, + "fields": [ + "id", + "user_created", + "created_at", + "sort", + "updated_at", + "user_updated", + "user_id", + "read", + "announcement_activity" + ], + "policy": "67aeb60d-5454-4217-8af5-2f22f96032e6", + "_syncId": "24c07d4b-fa6b-44b6-aebf-0cbda5fa3b4a" + }, + { + "collection": "announcement_activity", + "action": "create", + "permissions": null, + "validation": { + "_and": [ + { + "user_id": { + "_eq": "$CURRENT_USER" + } + } + ] + }, + "presets": null, + "fields": [ + "id", + "user_created", + "created_at", + "sort", + "updated_at", + "user_updated", + "user_id", + "read", + "announcement_activity" + ], + "policy": "67aeb60d-5454-4217-8af5-2f22f96032e6", + "_syncId": "d2369e3e-cca1-4e68-b432-898d183bda1d" + }, + { + "collection": "announcement_activity", + "action": "update", + "permissions": { + "_and": [ + { + "user_id": { + "_eq": "$CURRENT_USER" + } + } + ] + }, + "validation": null, + "presets": null, + "fields": [ + "id", + "user_created", + "created_at", + "sort", + "updated_at", + "user_updated", + "user_id", + "read", + "announcement_activity" + ], + "policy": "67aeb60d-5454-4217-8af5-2f22f96032e6", + "_syncId": "e84b10d7-04aa-430a-9bbd-6e64df141505" } ] diff --git a/echo/directus/sync/collections/policies.json b/echo/directus/sync/collections/policies.json index 278746a9..535393fd 100644 --- a/echo/directus/sync/collections/policies.json +++ b/echo/directus/sync/collections/policies.json @@ -44,6 +44,28 @@ ], "_syncId": "37a60e48-dd00-4867-af07-1fb22ac89078" }, + { + "name": "Announcements", + "icon": "campaign", + "description": "Read dembrane announcements and track read state.", + "ip_access": null, + "enforce_tfa": false, + "admin_access": false, + "app_access": false, + "roles": [ + { + "role": "7aab6a7c-e630-407d-88b2-6b95a94ff1f1", + "user": null, + "sort": 1 + }, + { + "role": "d49f0a90-9ea8-429f-aa56-6f3e6ae0dd87", + "user": null, + "sort": 2 + } + ], + "_syncId": "67aeb60d-5454-4217-8af5-2f22f96032e6" + }, { "name": "Can read current user activity", "icon": "assignment", diff --git a/echo/frontend/src/components/announcement/hooks/index.ts b/echo/frontend/src/components/announcement/hooks/index.ts index 32af57ab..75150dc0 100644 --- a/echo/frontend/src/components/announcement/hooks/index.ts +++ b/echo/frontend/src/components/announcement/hooks/index.ts @@ -598,58 +598,6 @@ export const useUnreadAnnouncements = () => export const useTopUrgentUnreadAnnouncement = () => useAnnouncementSummary(selectTopUrgentUnread); -export const useWhatsNewAnnouncements = ({ - enabled = true, -}: { - enabled?: boolean; -} = {}) => { - const { data: currentUser } = useCurrentUser(); - - return useQuery({ - enabled, - queryFn: async () => { - try { - const response: Announcement[] = await directus.request( - readItems("announcement", { - deep: { - activity: { - _filter: { - user_id: { - _eq: currentUser?.id, - }, - }, - }, - }, - fields: [ - "id", - "created_at", - "expires_at", - "level", - { - translations: ["id", "languages_code", "title", "message"], - }, - { - activity: ["id", "user_id", "announcement_activity", "read"], - }, - ], - limit: 50, - sort: ["-created_at"], - }), - ); - - return response; - } catch (error) { - posthog.captureException(error); - console.error("Error fetching what's new announcements:", error); - throw error; - } - }, - queryKey: ["announcements", "whats-new"], - retry: 2, - staleTime: 1000 * 60 * 5, - }); -}; - export const useAnnouncementDrawer = () => { const [isOpen, setIsOpen] = useSessionStorageState( "announcement-drawer-open",