"""Mobile app-grid catalog (visible at ``GET /m/apps/catalog``). The grid is the navigation backbone of the mobile UX: the 5 bottom tabs handle the very-high-frequency destinations (home / todos / assets / notifications / me), and every other in-scope page lives in this catalog, filtered by the caller's role set. Each :data:`APPS` entry declares: - ``key`` — stable slug, exposed to the client and used as the identifier for pinning / sorting. NEVER renamed once shipped; renaming orphans every user's pin list. - ``category`` — must match one of :data:`CATEGORIES`. - ``title_key`` / ``subtitle_key`` — i18next keys; frontend translates against ``mobile.apps..{title,subtitle}``. - ``icon`` — frontend resolves this string against a registered icon map (M-Mobile-1.3). - ``path`` — mobile route the grid chip navigates to. MUST start with ``/m/``. - ``gate`` — predicate ``(role_names) -> bool``. If False the entry is filtered out for that caller. - ``pinned_default`` — True iff the chip should appear on the workspace home as a default shortcut for users who pass ``gate``. The user can override their pins later (M-Mobile-1.3). M-Mobile-1.1 ships an empty :data:`APPS` list. The 5 bottom-tab destinations are NOT registered here — they are always-on. Subsequent M-Mobile milestones extend the list as each page is ported. """ from __future__ import annotations from collections.abc import Callable, Iterable from typing import TypedDict from app.core.rbac import ( ASSET_ADMIN_ROLE, AUDITOR_ROLE, CLOUD_ADMIN_ROLE, FINANCE_ROLE, GLOBAL_ADMIN_ROLE, HR_ROLE, MANAGEMENT_ROLE, has_role, ) RoleGate = Callable[[Iterable[str]], bool] def _any_user(_: Iterable[str]) -> bool: """Visible to anyone authenticated.""" return True def _has(*roles: str) -> RoleGate: """Build a gate that passes iff the user holds any of ``roles``.""" def _gate(role_names: Iterable[str]) -> bool: return has_role(role_names, *roles) return _gate class _AppEntry(TypedDict, total=False): key: str category: str title_key: str subtitle_key: str | None icon: str path: str gate: RoleGate pinned_default: bool class _CategoryEntry(TypedDict): key: str title_key: str order: int # Canonical category order in the grid. Frontend renders sections in # ascending ``order``. New categories MUST pick a fresh ``order`` — # never reuse a removed value, since users' pin lists may reference # the old grouping. CATEGORIES: list[_CategoryEntry] = [ {"key": "my", "title_key": "mobile.apps.category.my", "order": 10}, # 管理 (Administration) — global directory / HRIS sync / cross-org admin # tools. Distinct from ``team`` (a manager's own subordinates), which # previously mis-housed 用户管理 / HRIS 同步 and confused the admin grid. {"key": "admin", "title_key": "mobile.apps.category.admin", "order": 15}, {"key": "team", "title_key": "mobile.apps.category.team", "order": 20}, {"key": "asset", "title_key": "mobile.apps.category.asset", "order": 30}, {"key": "approval", "title_key": "mobile.apps.category.approval", "order": 40}, {"key": "cloud", "title_key": "mobile.apps.category.cloud", "order": 50}, {"key": "software", "title_key": "mobile.apps.category.software", "order": 60}, {"key": "audit", "title_key": "mobile.apps.category.audit", "order": 70}, {"key": "report", "title_key": "mobile.apps.category.report", "order": 80}, ] # Each M-Mobile milestone appends entries as it ports the matching # desktop page. Order within the list does not matter (the client # sorts by category then by title at render time). APPS: list[_AppEntry] = [ # ── M-Mobile-PR4 (2026-05-22) — employee bills surface first wave. # User direction: "员工看帐单也要有移动端 这个是属于高频使用场景". # All three default-pin onto the workspace home so the user sees # them without first opening the apps grid. { "key": "my_bills", "category": "my", "title_key": "mobile.apps.my_bills.title", "subtitle_key": "mobile.apps.my_bills.subtitle", "icon": "dollar", "path": "/m/bills", "gate": _any_user, "pinned_default": True, }, { "key": "my_api_keys", "category": "my", "title_key": "mobile.apps.my_api_keys.title", "subtitle_key": "mobile.apps.my_api_keys.subtitle", "icon": "key", "path": "/m/api-keys", "gate": _any_user, "pinned_default": True, }, { "key": "my_cloud", "category": "my", "title_key": "mobile.apps.my_cloud.title", "subtitle_key": "mobile.apps.my_cloud.subtitle", "icon": "cloud", "path": "/m/cloud", "gate": _any_user, "pinned_default": True, }, # ── M-Mobile-PR5 (2026-05-22) — second wave: requests history, # inventory cycles, help. ``my_requests`` / ``my_inventory`` are # pinned-default because they appear in everyday workflows; help # lives in the grid only (lower frequency). # 发起申请 — the create-side entry. Listed first + pinned so employees # have an obvious "file a request" action, not just "我的申请" (view). # Routes to the categorised launcher (asset/software/network/email/ # general/API) which the create-request engine drives. { "key": "new_request", "category": "my", "title_key": "mobile.apps.new_request.title", "subtitle_key": "mobile.apps.new_request.subtitle", "icon": "plus", "path": "/m/requests/new", "gate": _any_user, "pinned_default": True, }, { "key": "my_requests", "category": "my", "title_key": "mobile.apps.my_requests.title", "subtitle_key": "mobile.apps.my_requests.subtitle", "icon": "file-text", "path": "/m/requests", "gate": _any_user, "pinned_default": True, }, { "key": "my_inventory", "category": "my", "title_key": "mobile.apps.my_inventory.title", "subtitle_key": "mobile.apps.my_inventory.subtitle", "icon": "audit", "path": "/m/inventory", "gate": _any_user, "pinned_default": True, }, { "key": "help", "category": "my", "title_key": "mobile.apps.help.title", "subtitle_key": "mobile.apps.help.subtitle", "icon": "question-circle", "path": "/m/help", "gate": _any_user, "pinned_default": False, }, # ── M-Mobile-PR6 (2026-05-22) — third wave: AI tools / mail groups. { # AI 实验室 — pinned by default: the whole point of the surface is # that all ~500 employees actually reach it, and a tile buried in # a category nobody opens does not achieve that. "key": "ai_labs", "category": "my", "title_key": "mobile.apps.ai_labs.title", "subtitle_key": "mobile.apps.ai_labs.subtitle", "icon": "experiment", "path": "/m/ai-labs", "gate": _any_user, "pinned_default": True, }, { "key": "ai_tools", "category": "my", "title_key": "mobile.apps.ai_tools.title", "subtitle_key": "mobile.apps.ai_tools.subtitle", "icon": "robot", "path": "/m/ai-tools", "gate": _any_user, "pinned_default": False, }, { "key": "mail_groups", "category": "my", "title_key": "mobile.apps.mail_groups.title", "subtitle_key": "mobile.apps.mail_groups.subtitle", "icon": "mail", "path": "/m/mail-groups", "gate": _any_user, "pinned_default": False, }, # ── M-Mobile-PR17 (2026-06-02) — 动态广场 mobile surface. # Desktop ``/feed`` shipped 2026-06-01 but had no mobile route. # Added now so Feishu H5 users can read the feed + open # celebration cards + send blessings without falling back to desktop. { "key": "feed", "category": "my", "title_key": "mobile.apps.feed.title", "subtitle_key": "mobile.apps.feed.subtitle", "icon": "team", "path": "/m/feed", "gate": _any_user, "pinned_default": True, }, # ── 放松一下 (Security Arcade, 2026-07-18) — quiz points buy game # rounds. Pinned by default: mirrors the desktop first-level menu # entry the user chose for this surface. The mobile daily-quiz page # ships alongside so the earn→spend loop closes on H5. { "key": "arcade", "category": "my", "title_key": "mobile.apps.arcade.title", "subtitle_key": "mobile.apps.arcade.subtitle", "icon": "smile", "path": "/m/arcade", "gate": _any_user, "pinned_default": True, }, { "key": "security_quiz", "category": "my", "title_key": "mobile.apps.security_quiz.title", "subtitle_key": "mobile.apps.security_quiz.subtitle", "icon": "safety-certificate", "path": "/m/security-quiz", "gate": _any_user, "pinned_default": False, }, # ── 员工知识面上手机 (2026-07-28) — four all-staff reading surfaces that # existed on desktop only: the system handbook, company announcements, # the policy library, and the office directory. Every one of them is # something an employee looks up *away from a laptop* (mid-conversation # in Feishu, or on the way to an office), so ``_any_user`` and no role # gate — matching the backend, where /handbook/*, /feed/announcements, # /policy-notices/library and /assets/office-addresses are all-staff # reads. Only 公告 pins by default; the other three are lookup surfaces # people go to deliberately, and the home shortcut row is finite. { # 安全 FAQ — the security team's published answers. All-staff and # unpinned by default for the same reason as the handbook: it is a # lookup surface people go to deliberately, and the home shortcut # row is finite. Sits immediately before the handbook because the # two answer adjacent questions ("what is the rule" vs "how does # the platform behave"). "key": "security_faq", "category": "my", "title_key": "mobile.apps.security_faq.title", "subtitle_key": "mobile.apps.security_faq.subtitle", "icon": "question-circle", "path": "/m/security-faq", "gate": _any_user, "pinned_default": False, }, { # 安全学习 — the awareness library (comics / articles / links the # security team publishes). Sits next to the FAQ because the two # are the pull half of the same lane: the FAQ answers "what is # the rule", this is "here is what we want you to know". Pinned # by default, unlike its neighbours — the whole point of the # domain is reaching people who were not already looking, and an # entry buried in the app grid reaches nobody. "key": "security_learning", "category": "my", "title_key": "mobile.apps.security_learning.title", "subtitle_key": "mobile.apps.security_learning.subtitle", "icon": "read", "path": "/m/security-learning", "gate": _any_user, "pinned_default": True, }, { "key": "handbook", "category": "my", "title_key": "mobile.apps.handbook.title", "subtitle_key": "mobile.apps.handbook.subtitle", "icon": "book", "path": "/m/handbook", "gate": _any_user, "pinned_default": False, }, { "key": "announcements", "category": "my", "title_key": "mobile.apps.announcements.title", "subtitle_key": "mobile.apps.announcements.subtitle", "icon": "notification", "path": "/m/announcements", "gate": _any_user, "pinned_default": True, }, { "key": "policy_center", "category": "my", "title_key": "mobile.apps.policy_center.title", "subtitle_key": "mobile.apps.policy_center.subtitle", "icon": "read", "path": "/m/policy-center", "gate": _any_user, "pinned_default": False, }, { "key": "office_addresses", "category": "my", "title_key": "mobile.apps.office_addresses.title", "subtitle_key": "mobile.apps.office_addresses.subtitle", "icon": "environment", "path": "/m/company/offices", "gate": _any_user, "pinned_default": False, }, # ── 个人域上手机 (2026-07-28, mobile-prod-grade PR6) — five personal # self-service surfaces that had no mobile route at all. Every backing # endpoint is caller-scoped (`/hr/leave/me`, `/notifications/preferences`, # `/me/todos/done`, `/auth/me/tokens`, `/me/email-aliases`, # `/me/email-groups`), so all five are ``_any_user`` — there is no role # to gate on, only "your own records". # # Only 假期 pins by default: it is the one people check repeatedly and # from anywhere. The other four are deliberate errands (mute a # notification, look up what you already approved, mint a CLI token, # leave a mail group) and the home shortcut row is finite. { "key": "my_leave", "category": "my", "title_key": "mobile.apps.my_leave.title", "subtitle_key": "mobile.apps.my_leave.subtitle", "icon": "calendar", "path": "/m/leave", "gate": _any_user, "pinned_default": True, }, { "key": "notification_preferences", "category": "my", "title_key": "mobile.apps.notification_preferences.title", "subtitle_key": "mobile.apps.notification_preferences.subtitle", "icon": "bell", "path": "/m/notification-preferences", "gate": _any_user, "pinned_default": False, }, { # 成就徽章墙 — 只读、零打扰。不 pin:它是"想起来才翻一下"的页面, # 不是每天要用的差事,首页快捷行是有限的。 "key": "my_achievements", "category": "my", "title_key": "mobile.apps.my_achievements.title", "subtitle_key": "mobile.apps.my_achievements.subtitle", "icon": "trophy", "path": "/m/achievements", "gate": _any_user, "pinned_default": False, }, { "key": "my_done", "category": "my", "title_key": "mobile.apps.my_done.title", "subtitle_key": "mobile.apps.my_done.subtitle", "icon": "history", "path": "/m/done", "gate": _any_user, "pinned_default": False, }, { # NOT ``my_api_keys`` (that tile is LLM-vendor key assignments). # This is the personal access token used to drive Helix from the # CLI / an MCP client. "key": "my_api_tokens", "category": "my", "title_key": "mobile.apps.my_api_tokens.title", "subtitle_key": "mobile.apps.my_api_tokens.subtitle", "icon": "key", "path": "/m/api-tokens", "gate": _any_user, "pinned_default": False, }, { "key": "me_settings", "category": "my", "title_key": "mobile.apps.me_settings.title", "subtitle_key": "mobile.apps.me_settings.subtitle", "icon": "mail", "path": "/m/me/settings", "gate": _any_user, "pinned_default": False, }, # ── M-Mobile-PR7 (2026-05-22) — Manager + high-freq Admin queues. # Mobile RBAC: read + single-record action (approve / mark-done); # heavy operations (bulk, force, override) stay on desktop. Apps # grid catalog filters these out for non-authorized callers via # the per-entry gate. { "key": "team_assets", "category": "team", "title_key": "mobile.apps.team_assets.title", "subtitle_key": "mobile.apps.team_assets.subtitle", "icon": "team", "path": "/m/team-assets", # Backend `/assets/team` returns empty array for non-managers, # so we can't gate purely server-side at the catalog. We let # any user see the entry and the page renders empty for # non-managers — acceptable since the entry is in a `team` # category that managers naturally look at. "gate": _any_user, "pinned_default": False, }, { "key": "admin_approvals", "category": "approval", "title_key": "mobile.apps.admin_approvals.title", "subtitle_key": "mobile.apps.admin_approvals.subtitle", "icon": "safety-certificate", "path": "/m/admin/approvals", "gate": _has(GLOBAL_ADMIN_ROLE), "pinned_default": True, }, { # Cloud grant + scope approvals queue (2026-07-10, PR5). Was # reachable only via the action inbox / deep-links; surfacing it in # the apps grid makes it discoverable. cloud_admin decides both; # global_admin holds cloud authority too. "key": "admin_cloud_approvals", "category": "approval", "title_key": "mobile.apps.admin_cloud_approvals.title", "subtitle_key": "mobile.apps.admin_cloud_approvals.subtitle", "icon": "cloud", "path": "/m/admin/cloud/approvals", "gate": _has(GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_provisioning", "category": "approval", "title_key": "mobile.apps.admin_provisioning.title", "subtitle_key": "mobile.apps.admin_provisioning.subtitle", "icon": "cluster", "path": "/m/admin/provisioning", "gate": _has(GLOBAL_ADMIN_ROLE, ASSET_ADMIN_ROLE), "pinned_default": True, }, { "key": "admin_offboarding", "category": "approval", "title_key": "mobile.apps.admin_offboarding.title", "subtitle_key": "mobile.apps.admin_offboarding.subtitle", "icon": "user-delete", "path": "/m/admin/offboarding", "gate": _has(GLOBAL_ADMIN_ROLE, HR_ROLE), "pinned_default": True, }, # ── M-Mobile-PR8 (2026-05-22) — asset admin + outbox + cloud servers. { "key": "admin_assets", "category": "asset", "title_key": "mobile.apps.admin_assets.title", "subtitle_key": "mobile.apps.admin_assets.subtitle", "icon": "laptop", "path": "/m/admin/assets", "gate": _has(GLOBAL_ADMIN_ROLE, ASSET_ADMIN_ROLE), "pinned_default": True, }, { "key": "admin_outbox", "category": "approval", "title_key": "mobile.apps.admin_outbox.title", "subtitle_key": "mobile.apps.admin_outbox.subtitle", "icon": "bell", "path": "/m/admin/outbox", "gate": _has(GLOBAL_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_cloud_servers", "category": "cloud", "title_key": "mobile.apps.admin_cloud_servers.title", "subtitle_key": "mobile.apps.admin_cloud_servers.subtitle", "icon": "cloud-server", "path": "/m/admin/cloud-servers", "gate": _has(GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE), "pinned_default": False, }, # ── M-Mobile-PR9 (2026-05-22) — cloud admin inventories. { "key": "admin_cloud_domains", "category": "cloud", "title_key": "mobile.apps.admin_cloud_domains.title", "subtitle_key": "mobile.apps.admin_cloud_domains.subtitle", "icon": "global", "path": "/m/admin/cloud-domains", "gate": _has(GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_cloud_dns", "category": "cloud", "title_key": "mobile.apps.admin_cloud_dns.title", "subtitle_key": "mobile.apps.admin_cloud_dns.subtitle", "icon": "cluster", "path": "/m/admin/cloud-dns", "gate": _has(GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_cloud_load_balancers", "category": "cloud", "title_key": "mobile.apps.admin_cloud_load_balancers.title", "subtitle_key": "mobile.apps.admin_cloud_load_balancers.subtitle", "icon": "link", "path": "/m/admin/cloud-load-balancers", "gate": _has(GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_cloud_cdn", "category": "cloud", "title_key": "mobile.apps.admin_cloud_cdn.title", "subtitle_key": "mobile.apps.admin_cloud_cdn.subtitle", "icon": "rocket", "path": "/m/admin/cloud-cdn", "gate": _has(GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_cloud_object_storage", "category": "cloud", "title_key": "mobile.apps.admin_cloud_object_storage.title", "subtitle_key": "mobile.apps.admin_cloud_object_storage.subtitle", "icon": "database", "path": "/m/admin/cloud-object-storage", "gate": _has(GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE), "pinned_default": False, }, # ── M-Mobile-PR13 (2026-05-24) — Employee self-service cloud # resource lists (8 read-only inventories). All gated to any # authenticated user — the backend ``GET /cmdb/*`` endpoints # auto-demote non-admin callers to owner-scope (or, for IAM, # apply ``?mine=true``), so an employee with zero cloud # resources just sees the empty state. Category 'cloud' so they # cluster beneath the admin-only inventories in the apps grid. { "key": "my_cloud_servers", "category": "cloud", "title_key": "mobile.apps.my_cloud_servers.title", "subtitle_key": "mobile.apps.my_cloud_servers.subtitle", "icon": "cloud-server", "path": "/m/cloud/servers", "gate": _any_user, "pinned_default": False, }, { "key": "my_cloud_domains", "category": "cloud", "title_key": "mobile.apps.my_cloud_domains.title", "subtitle_key": "mobile.apps.my_cloud_domains.subtitle", "icon": "global", "path": "/m/cloud/domains", "gate": _any_user, "pinned_default": False, }, { "key": "my_cloud_dns", "category": "cloud", "title_key": "mobile.apps.my_cloud_dns.title", "subtitle_key": "mobile.apps.my_cloud_dns.subtitle", "icon": "cluster", "path": "/m/cloud/dns", "gate": _any_user, "pinned_default": False, }, { "key": "my_cloud_load_balancers", "category": "cloud", "title_key": "mobile.apps.my_cloud_load_balancers.title", "subtitle_key": "mobile.apps.my_cloud_load_balancers.subtitle", "icon": "link", "path": "/m/cloud/load-balancers", "gate": _any_user, "pinned_default": False, }, { "key": "my_cloud_cdn", "category": "cloud", "title_key": "mobile.apps.my_cloud_cdn.title", "subtitle_key": "mobile.apps.my_cloud_cdn.subtitle", "icon": "rocket", "path": "/m/cloud/cdn", "gate": _any_user, "pinned_default": False, }, { "key": "my_cloud_object_storage", "category": "cloud", "title_key": "mobile.apps.my_cloud_object_storage.title", "subtitle_key": "mobile.apps.my_cloud_object_storage.subtitle", "icon": "database", "path": "/m/cloud/object-storage", "gate": _any_user, "pinned_default": False, }, { "key": "my_cloud_iam", "category": "cloud", "title_key": "mobile.apps.my_cloud_iam.title", "subtitle_key": "mobile.apps.my_cloud_iam.subtitle", "icon": "idcard", "path": "/m/cloud/iam", "gate": _any_user, "pinned_default": False, }, { "key": "my_cloud_grants", "category": "cloud", "title_key": "mobile.apps.my_cloud_grants.title", "subtitle_key": "mobile.apps.my_cloud_grants.subtitle", "icon": "safety-certificate", "path": "/m/cloud/grants", "gate": _any_user, "pinned_default": False, }, # ── M-Mobile-PR13 — employee lifecycle self-view. # Backend ``GET /api/v1/lifecycle/me`` is open to every # authenticated person (returns their own onboarding / offboarding # flow). The page surfaces ``provisioning_tasks`` so a new hire / # leaver can track their grant / revoke pipeline without having to # ask IT. { "key": "my_lifecycle", "category": "my", "title_key": "mobile.apps.my_lifecycle.title", "subtitle_key": "mobile.apps.my_lifecycle.subtitle", "icon": "user-switch", "path": "/m/lifecycle", "gate": _any_user, "pinned_default": False, }, # ── M-Mobile-PR11 hotfix (2026-05-22) — cloud cost dashboard. # User feedback: "也看不到云的帐单现在". Backend # /cmdb/billing/summary is gated to finance / cloud_admin / # global_admin / management. { "key": "cloud_cost", "category": "report", "title_key": "mobile.apps.cloud_cost.title", "subtitle_key": "mobile.apps.cloud_cost.subtitle", "icon": "dollar", "path": "/m/cloud-cost", "gate": _has( GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE, FINANCE_ROLE, MANAGEMENT_ROLE, ), "pinned_default": True, }, # ── M-Mobile-PR10 (2026-05-22) — Auditor list pages (7). { "key": "auditor_mfa", "category": "audit", "title_key": "mobile.apps.auditor_mfa.title", "subtitle_key": "mobile.apps.auditor_mfa.subtitle", "icon": "safety", "path": "/m/auditor/mfa", "gate": _has(GLOBAL_ADMIN_ROLE, AUDITOR_ROLE), "pinned_default": False, }, { "key": "auditor_privileged_review", "category": "audit", "title_key": "mobile.apps.auditor_privileged_review.title", "subtitle_key": "mobile.apps.auditor_privileged_review.subtitle", "icon": "audit", "path": "/m/auditor/privileged-review", "gate": _has(GLOBAL_ADMIN_ROLE, AUDITOR_ROLE), "pinned_default": False, }, { "key": "auditor_public_exposure", "category": "audit", "title_key": "mobile.apps.auditor_public_exposure.title", "subtitle_key": "mobile.apps.auditor_public_exposure.subtitle", "icon": "warning", "path": "/m/auditor/public-exposure", "gate": _has(GLOBAL_ADMIN_ROLE, AUDITOR_ROLE), "pinned_default": False, }, { "key": "auditor_secret_rotation", "category": "audit", "title_key": "mobile.apps.auditor_secret_rotation.title", "subtitle_key": "mobile.apps.auditor_secret_rotation.subtitle", "icon": "key", "path": "/m/auditor/secret-rotation", "gate": _has(GLOBAL_ADMIN_ROLE, AUDITOR_ROLE), "pinned_default": False, }, { "key": "auditor_signin_anomaly", "category": "audit", "title_key": "mobile.apps.auditor_signin_anomaly.title", "subtitle_key": "mobile.apps.auditor_signin_anomaly.subtitle", "icon": "alert", "path": "/m/auditor/signin-anomaly", "gate": _has(GLOBAL_ADMIN_ROLE, AUDITOR_ROLE), "pinned_default": False, }, { "key": "auditor_pat_audit", "category": "audit", "title_key": "mobile.apps.auditor_pat_audit.title", "subtitle_key": "mobile.apps.auditor_pat_audit.subtitle", "icon": "key", "path": "/m/auditor/pat-audit", "gate": _has(GLOBAL_ADMIN_ROLE, AUDITOR_ROLE), "pinned_default": False, }, { "key": "auditor_cert_expiry", "category": "audit", "title_key": "mobile.apps.auditor_cert_expiry.title", "subtitle_key": "mobile.apps.auditor_cert_expiry.subtitle", "icon": "clock-circle", "path": "/m/auditor/cert-expiry", "gate": _has(GLOBAL_ADMIN_ROLE, AUDITOR_ROLE), "pinned_default": False, }, # ── M-Mobile-PR14 (2026-05-24) — admin high-frequency pages (10). # User direction: surface the top-10 admin destinations on mobile # so an on-call admin can triage from their phone without opening # a laptop. Active operations (role assign, batch confirm, owner # transfer) stay on desktop; mobile shows status + escape hatch. { "key": "admin_users", "category": "admin", "title_key": "mobile.apps.admin_users.title", "subtitle_key": "mobile.apps.admin_users.subtitle", "icon": "team", "path": "/m/admin/users", # Backend ``GET /users`` is gated by ``require_hr_read`` which # admits global_admin + hr + auditor + management (see # ``app/core/dependencies.py::require_hr_read``). Catalog gate # must match so authorized non-admin roles see the grid entry — # otherwise the page is reachable by URL but invisible in the # apps grid for those users. PR-audit fix. "gate": _has( GLOBAL_ADMIN_ROLE, HR_ROLE, AUDITOR_ROLE, MANAGEMENT_ROLE, ), "pinned_default": False, }, { "key": "admin_hr_sync", "category": "admin", "title_key": "mobile.apps.admin_hr_sync.title", "subtitle_key": "mobile.apps.admin_hr_sync.subtitle", "icon": "sync", "path": "/m/admin/hr-sync", "gate": _has(GLOBAL_ADMIN_ROLE, HR_ROLE), "pinned_default": False, }, { "key": "admin_asset_operations", "category": "asset", "title_key": "mobile.apps.admin_asset_operations.title", "subtitle_key": "mobile.apps.admin_asset_operations.subtitle", "icon": "history", "path": "/m/admin/asset-operations", "gate": _has(GLOBAL_ADMIN_ROLE, ASSET_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_google_orphans", "category": "approval", "title_key": "mobile.apps.admin_google_orphans.title", "subtitle_key": "mobile.apps.admin_google_orphans.subtitle", "icon": "google", "path": "/m/admin/google-orphans", "gate": _has(GLOBAL_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_leaver_overrides", "category": "approval", "title_key": "mobile.apps.admin_leaver_overrides.title", "subtitle_key": "mobile.apps.admin_leaver_overrides.subtitle", "icon": "safety", "path": "/m/admin/leaver-overrides", "gate": _has(GLOBAL_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_insights", "category": "report", "title_key": "mobile.apps.admin_insights.title", "subtitle_key": "mobile.apps.admin_insights.subtitle", "icon": "bar-chart", "path": "/m/admin/insights", "gate": _has(GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE), "pinned_default": False, }, { "key": "admin_api_keys", "category": "asset", "title_key": "mobile.apps.admin_api_keys.title", "subtitle_key": "mobile.apps.admin_api_keys.subtitle", "icon": "key", "path": "/m/admin/api-keys", "gate": _has(GLOBAL_ADMIN_ROLE, ASSET_ADMIN_ROLE), "pinned_default": False, }, # ── M-Mobile-PR15 (2026-05-24) — cloud admin + auditor pages (8 # grid entries; the scope detail + privileged-review detail pages # are reached from list rows, not the grid). # # Cloud admin (5): fleet KPI overview, IAM users, scopes list, # Bailian workspaces, per-integration role catalog. All gated to # global_admin / cloud_admin — backend RBAC also enforces this on # each underlying endpoint. # # Auditor (3): coverage+control-status dashboard, risk score # roster, evidence-pack archive. Gated to global_admin / auditor. { "key": "admin_cloud_overview", "category": "cloud", "title_key": "mobile.apps.admin_cloud_overview.title", "subtitle_key": "mobile.apps.admin_cloud_overview.subtitle", "icon": "cloud", "path": "/m/admin/cloud/overview", # Backend ``GET /cmdb/overview/admin`` is gated by # ``can_read_cloud`` which admits global_admin + cloud_admin + # auditor + finance + management. Catalog gate must match so # auditors / finance / management see the grid entry. PR-audit # fix. "gate": _has( GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE, AUDITOR_ROLE, FINANCE_ROLE, MANAGEMENT_ROLE, ), "pinned_default": False, }, { "key": "admin_cloud_iam_users", "category": "cloud", "title_key": "mobile.apps.admin_cloud_iam_users.title", "subtitle_key": "mobile.apps.admin_cloud_iam_users.subtitle", "icon": "idcard", "path": "/m/admin/cloud/iam-users", "gate": _has(GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_cloud_scopes", "category": "cloud", "title_key": "mobile.apps.admin_cloud_scopes.title", "subtitle_key": "mobile.apps.admin_cloud_scopes.subtitle", "icon": "cloud-server", "path": "/m/admin/cloud/scopes", "gate": _has(GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_bailian", "category": "cloud", "title_key": "mobile.apps.admin_bailian.title", "subtitle_key": "mobile.apps.admin_bailian.subtitle", "icon": "robot", "path": "/m/admin/cloud/bailian", "gate": _has(GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE), "pinned_default": False, }, { "key": "admin_cloud_role_catalog", "category": "cloud", "title_key": "mobile.apps.admin_cloud_role_catalog.title", "subtitle_key": "mobile.apps.admin_cloud_role_catalog.subtitle", "icon": "safety-certificate", "path": "/m/admin/cloud/role-catalog", "gate": _has(GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE), "pinned_default": False, }, { "key": "auditor_dashboard", "category": "audit", "title_key": "mobile.apps.auditor_dashboard.title", "subtitle_key": "mobile.apps.auditor_dashboard.subtitle", "icon": "safety", "path": "/m/auditor", "gate": _has(GLOBAL_ADMIN_ROLE, AUDITOR_ROLE), "pinned_default": False, }, { "key": "auditor_risk_score", "category": "audit", "title_key": "mobile.apps.auditor_risk_score.title", "subtitle_key": "mobile.apps.auditor_risk_score.subtitle", "icon": "alert", "path": "/m/auditor/risk-score", "gate": _has(GLOBAL_ADMIN_ROLE, AUDITOR_ROLE), "pinned_default": False, }, { "key": "auditor_evidence_packs", "category": "audit", "title_key": "mobile.apps.auditor_evidence_packs.title", "subtitle_key": "mobile.apps.auditor_evidence_packs.subtitle", "icon": "file-text", "path": "/m/auditor/evidence-packs", "gate": _has(GLOBAL_ADMIN_ROLE, AUDITOR_ROLE), "pinned_default": False, }, # 法定假期 (2026-08-12). ``/m/holidays`` shipped with the holiday # calendar but was never registered here, so the page existed and was # reachable only by URL — invisible to anyone browsing the phone, which # is how employees actually look for "when is the next break". Company- # wide published data, so ``_any_user``; pinned because "下一个假期是 # 哪天" is a glance question, not a search-the-grid one. { "key": "my_holidays", "category": "my", "title_key": "mobile.apps.my_holidays.title", "subtitle_key": "mobile.apps.my_holidays.subtitle", "icon": "calendar", "path": "/m/holidays", "gate": _any_user, "pinned_default": True, }, ] # Suppress unused-import lint while these role constants are needed by # future appends to APPS (M2 onwards). Importing them here keeps the # gate-author surface stable and centralised. _ROLES_USED_BY_FUTURE_GATES = ( ASSET_ADMIN_ROLE, AUDITOR_ROLE, CLOUD_ADMIN_ROLE, FINANCE_ROLE, GLOBAL_ADMIN_ROLE, HR_ROLE, MANAGEMENT_ROLE, ) def visible_apps_for(role_names: Iterable[str]) -> list[_AppEntry]: """Return the subset of :data:`APPS` whose ``gate`` accepts ``role_names``.""" cached = list(role_names) return [a for a in APPS if a["gate"](cached)] def default_pinned_keys_for(role_names: Iterable[str]) -> list[str]: """App keys to seed the workspace home shortcut row for this caller. Filters by ``gate`` first so a role-default pin that the user can't actually see never leaks into the response. """ cached = list(role_names) return [ a["key"] for a in APPS if a.get("pinned_default") and a["gate"](cached) ] def pinned_apps_for(role_names: Iterable[str]) -> list[_AppEntry]: """Full pinned-default app ENTRIES visible to this caller — the home shortcut row renders these directly (title/icon/path) so it needs no separate ``/m/apps/catalog`` round-trip. Same gate+pin filter as :func:`default_pinned_keys_for`, but returns the entry, not the key. """ cached = list(role_names) return [ a for a in APPS if a.get("pinned_default") and a["gate"](cached) ]