From f452b1967381a113e07fda9c13000d784a39da43 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 12:40:36 +0530 Subject: [PATCH 01/20] feat(perms): add require_permission decorator --- forms_pro/utils/permissions.py | 46 ++++++++++++++++++++++++++ forms_pro/utils/test_permissions.py | 51 +++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 forms_pro/utils/permissions.py create mode 100644 forms_pro/utils/test_permissions.py diff --git a/forms_pro/utils/permissions.py b/forms_pro/utils/permissions.py new file mode 100644 index 0000000..5af1333 --- /dev/null +++ b/forms_pro/utils/permissions.py @@ -0,0 +1,46 @@ +import functools +from collections.abc import Callable + +import frappe +from frappe import _ + + +def require_permission(doctype: str, ptype: str = "read", param: str = "name") -> Callable: + """Enforce frappe.has_permission(doctype, ptype, kwargs[param]) before fn runs. + + Raises: + frappe.DoesNotExistError (HTTP 404): when ptype != "create" and the + referenced document does not exist. + frappe.PermissionError (HTTP 403): when the user lacks the permission. + + Args: + doctype: DocType to check against. + ptype: Permission type ("read", "write", "share", "delete", "create"). + param: Keyword-argument name carrying the docname. Ignored for "create". + """ + + def decorator(fn: Callable) -> Callable: + @functools.wraps(fn) + def wrapper(**kwargs): + doc_name = kwargs.get(param) if ptype != "create" else None + + if ptype != "create" and doc_name and not frappe.db.exists(doctype, doc_name): + frappe.throw( + _("{0} {1} not found").format(_(doctype), doc_name), + frappe.DoesNotExistError, + title=_("Not Found"), + ) + + allowed = frappe.has_permission(doctype=doctype, ptype=ptype, doc=doc_name) + if not allowed: + frappe.throw( + _("You do not have {0} permission on {1}").format(_(ptype), _(doctype)), + frappe.PermissionError, + title=_("Access Denied"), + ) + + return fn(**kwargs) + + return wrapper + + return decorator diff --git a/forms_pro/utils/test_permissions.py b/forms_pro/utils/test_permissions.py new file mode 100644 index 0000000..700d3c7 --- /dev/null +++ b/forms_pro/utils/test_permissions.py @@ -0,0 +1,51 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from forms_pro.tests import FORMS_PRO_TEST_USER +from forms_pro.tests.factories.form_factory import FormFactory +from forms_pro.utils.permissions import require_permission + + +class TestRequirePermission(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_allows_when_permission_granted(self) -> None: + @require_permission("Form", "read", param="form_id") + def fn(form_id: str) -> str: + return form_id + + self.assertEqual(fn(form_id=self.form.name), self.form.name) + + def test_raises_permission_error_when_denied(self) -> None: + @require_permission("Form", "write", param="form_id") + def fn(form_id: str) -> str: + return form_id + + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + fn(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_does_not_exist_when_doc_missing(self) -> None: + @require_permission("Form", "read", param="form_id") + def fn(form_id: str) -> str: + return form_id + + with self.assertRaises(frappe.DoesNotExistError) as ctx: + fn(form_id="NON_EXISTENT_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) + + def test_create_ptype_skips_existence_and_docname(self) -> None: + @require_permission("Form", "create") + def fn() -> str: + return "ok" + + self.assertEqual(fn(), "ok") + + def test_param_kwarg_routing(self) -> None: + @require_permission("Form", "read", param="my_id") + def fn(my_id: str) -> str: + return my_id + + self.assertEqual(fn(my_id=self.form.name), self.form.name) From 29913d1df6848e7dee96f17452e1b8bff9a714ee Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 12:47:34 +0530 Subject: [PATCH 02/20] refactor(api): split form.py into form/ package --- forms_pro/api/form/__init__.py | 25 ++++++++++++++++++++ forms_pro/api/{form.py => form/endpoints.py} | 11 +-------- forms_pro/api/form/schema.py | 11 +++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 forms_pro/api/form/__init__.py rename forms_pro/api/{form.py => form/endpoints.py} (97%) create mode 100644 forms_pro/api/form/schema.py diff --git a/forms_pro/api/form/__init__.py b/forms_pro/api/form/__init__.py new file mode 100644 index 0000000..511f2de --- /dev/null +++ b/forms_pro/api/form/__init__.py @@ -0,0 +1,25 @@ +from .endpoints import ( + add_form_access, + get_doctype_fields, + get_doctype_list, + get_form, + get_form_by_route, + get_form_shared_with, + get_link_field_options, + is_login_required, + remove_form_access, + set_form_permission, +) + +__all__ = [ + "add_form_access", + "get_doctype_fields", + "get_doctype_list", + "get_form", + "get_form_by_route", + "get_form_shared_with", + "get_link_field_options", + "is_login_required", + "remove_form_access", + "set_form_permission", +] diff --git a/forms_pro/api/form.py b/forms_pro/api/form/endpoints.py similarity index 97% rename from forms_pro/api/form.py rename to forms_pro/api/form/endpoints.py index cd64e19..fc70d0f 100644 --- a/forms_pro/api/form.py +++ b/forms_pro/api/form/endpoints.py @@ -1,21 +1,12 @@ import frappe from frappe import _ from frappe.share import add_docshare, remove -from pydantic import BaseModel, Field from forms_pro.api.user import get_user from forms_pro.forms_pro.doctype.form.form import Form from forms_pro.utils.constants import FORMS_PRO_SYSTEM_FIELDNAMES, UNSUPPORTED_FRAPPE_FIELDTYPES - -class FormSharedWithResponse(BaseModel): - full_name: str - user_image: str | None - email: str = Field(alias="user") - read: bool - write: bool - share: bool - submit: bool +from .schema import FormSharedWithResponse @frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method diff --git a/forms_pro/api/form/schema.py b/forms_pro/api/form/schema.py new file mode 100644 index 0000000..8a4fd5e --- /dev/null +++ b/forms_pro/api/form/schema.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, Field + + +class FormSharedWithResponse(BaseModel): + full_name: str + user_image: str | None + email: str = Field(alias="user") + read: bool + write: bool + share: bool + submit: bool From 26dd8ca2513ecbcdc8b902fee6926502d85e1a89 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 12:51:11 +0530 Subject: [PATCH 03/20] refactor(api): split team/submission/user/export/settings into packages Extract pydantic schemas into per-package schema.py modules where applicable (submission, user). team/export/settings have no inline schemas. Each package __init__.py explicitly re-exports endpoints so the forms_pro.api.. whitelist URLs continue to resolve. Also update test_submission_validation.py to import private helpers from forms_pro.api.submission.endpoints (the public __init__.py exposes only whitelisted endpoints). --- forms_pro/api/export/__init__.py | 3 ++ .../api/{export.py => export/endpoints.py} | 0 forms_pro/api/settings/__init__.py | 3 ++ .../{settings.py => settings/endpoints.py} | 0 forms_pro/api/submission/__init__.py | 15 +++++++++ .../endpoints.py} | 24 +------------- forms_pro/api/submission/schema.py | 29 ++++++++++++++++ forms_pro/api/team/__init__.py | 23 +++++++++++++ forms_pro/api/{team.py => team/endpoints.py} | 0 forms_pro/api/user/__init__.py | 3 ++ forms_pro/api/{user.py => user/endpoints.py} | 33 +------------------ forms_pro/api/user/schema.py | 33 +++++++++++++++++++ forms_pro/tests/test_submission_validation.py | 6 +++- 13 files changed, 116 insertions(+), 56 deletions(-) create mode 100644 forms_pro/api/export/__init__.py rename forms_pro/api/{export.py => export/endpoints.py} (100%) create mode 100644 forms_pro/api/settings/__init__.py rename forms_pro/api/{settings.py => settings/endpoints.py} (100%) create mode 100644 forms_pro/api/submission/__init__.py rename forms_pro/api/{submission.py => submission/endpoints.py} (91%) create mode 100644 forms_pro/api/submission/schema.py create mode 100644 forms_pro/api/team/__init__.py rename forms_pro/api/{team.py => team/endpoints.py} (100%) create mode 100644 forms_pro/api/user/__init__.py rename forms_pro/api/{user.py => user/endpoints.py} (56%) create mode 100644 forms_pro/api/user/schema.py diff --git a/forms_pro/api/export/__init__.py b/forms_pro/api/export/__init__.py new file mode 100644 index 0000000..c6b2652 --- /dev/null +++ b/forms_pro/api/export/__init__.py @@ -0,0 +1,3 @@ +from .endpoints import export_submissions + +__all__ = ["export_submissions"] diff --git a/forms_pro/api/export.py b/forms_pro/api/export/endpoints.py similarity index 100% rename from forms_pro/api/export.py rename to forms_pro/api/export/endpoints.py diff --git a/forms_pro/api/settings/__init__.py b/forms_pro/api/settings/__init__.py new file mode 100644 index 0000000..7b65385 --- /dev/null +++ b/forms_pro/api/settings/__init__.py @@ -0,0 +1,3 @@ +from .endpoints import get_brand_logo, get_website_settings + +__all__ = ["get_brand_logo", "get_website_settings"] diff --git a/forms_pro/api/settings.py b/forms_pro/api/settings/endpoints.py similarity index 100% rename from forms_pro/api/settings.py rename to forms_pro/api/settings/endpoints.py diff --git a/forms_pro/api/submission/__init__.py b/forms_pro/api/submission/__init__.py new file mode 100644 index 0000000..f3fe576 --- /dev/null +++ b/forms_pro/api/submission/__init__.py @@ -0,0 +1,15 @@ +from .endpoints import ( + get_all_submissions, + get_submission, + get_submission_response, + get_user_submissions, + submit_form_response, +) + +__all__ = [ + "get_all_submissions", + "get_submission", + "get_submission_response", + "get_user_submissions", + "submit_form_response", +] diff --git a/forms_pro/api/submission.py b/forms_pro/api/submission/endpoints.py similarity index 91% rename from forms_pro/api/submission.py rename to forms_pro/api/submission/endpoints.py index 73abfdb..1f21e82 100644 --- a/forms_pro/api/submission.py +++ b/forms_pro/api/submission/endpoints.py @@ -1,37 +1,15 @@ import json -from datetime import datetime from typing import Any import frappe from frappe import _ from frappe.share import add_docshare -from pydantic import BaseModel, Field, field_validator from forms_pro.forms_pro.doctype.form.form import Form from forms_pro.forms_pro.doctype.form_field.form_field import _DISPLAY_ONLY_FIELDTYPES from forms_pro.utils.form_generator import SubmissionStatus - -class UserSubmissionResponse(BaseModel): - name: str = Field(description="Name of the submission") - creation: datetime = Field(description="Creation date of the submission") - modified: datetime = Field(description="Last modified date of the submission") - submission_status: str = Field( - description="Status of the submission", - alias="fp_submission_status", - default=SubmissionStatus.SUBMITTED.value, - ) - owner: str = Field(description="Owner of the submission") - - @field_validator("creation", "modified", mode="before") - @classmethod - def parse_datetime(cls, v: Any) -> datetime: - """Convert datetime string to datetime object.""" - if isinstance(v, str): - return frappe.utils.get_datetime(v) - if isinstance(v, datetime): - return v - raise ValueError(f"Invalid datetime value: {v}") +from .schema import UserSubmissionResponse def _coerce_field_value(value: Any, fieldtype: str) -> Any: diff --git a/forms_pro/api/submission/schema.py b/forms_pro/api/submission/schema.py new file mode 100644 index 0000000..e6f37c5 --- /dev/null +++ b/forms_pro/api/submission/schema.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Any + +import frappe +from pydantic import BaseModel, Field, field_validator + +from forms_pro.utils.form_generator import SubmissionStatus + + +class UserSubmissionResponse(BaseModel): + name: str = Field(description="Name of the submission") + creation: datetime = Field(description="Creation date of the submission") + modified: datetime = Field(description="Last modified date of the submission") + submission_status: str = Field( + description="Status of the submission", + alias="fp_submission_status", + default=SubmissionStatus.SUBMITTED.value, + ) + owner: str = Field(description="Owner of the submission") + + @field_validator("creation", "modified", mode="before") + @classmethod + def parse_datetime(cls, v: Any) -> datetime: + """Convert datetime string to datetime object.""" + if isinstance(v, str): + return frappe.utils.get_datetime(v) + if isinstance(v, datetime): + return v + raise ValueError(f"Invalid datetime value: {v}") diff --git a/forms_pro/api/team/__init__.py b/forms_pro/api/team/__init__.py new file mode 100644 index 0000000..90e64a8 --- /dev/null +++ b/forms_pro/api/team/__init__.py @@ -0,0 +1,23 @@ +from .endpoints import ( + add_member_to_team_via_invitation, + create_team, + get_team_forms, + get_team_members, + invite_team_members, + remove_member_from_team, + save, + switch_team, + toggle_can_edit_team, +) + +__all__ = [ + "add_member_to_team_via_invitation", + "create_team", + "get_team_forms", + "get_team_members", + "invite_team_members", + "remove_member_from_team", + "save", + "switch_team", + "toggle_can_edit_team", +] diff --git a/forms_pro/api/team.py b/forms_pro/api/team/endpoints.py similarity index 100% rename from forms_pro/api/team.py rename to forms_pro/api/team/endpoints.py diff --git a/forms_pro/api/user/__init__.py b/forms_pro/api/user/__init__.py new file mode 100644 index 0000000..e81dc8b --- /dev/null +++ b/forms_pro/api/user/__init__.py @@ -0,0 +1,3 @@ +from .endpoints import get_current_user, get_user, get_user_teams + +__all__ = ["get_current_user", "get_user", "get_user_teams"] diff --git a/forms_pro/api/user.py b/forms_pro/api/user/endpoints.py similarity index 56% rename from forms_pro/api/user.py rename to forms_pro/api/user/endpoints.py index 20adfba..d98d11b 100644 --- a/forms_pro/api/user.py +++ b/forms_pro/api/user/endpoints.py @@ -1,40 +1,9 @@ import frappe -from frappe.core.doctype.has_role.has_role import HasRole from frappe.core.doctype.user.user import User -from pydantic import BaseModel, Field, field_validator from forms_pro.utils.teams import get_user_teams as get_user_teams_utils - -class GetUserTeamsResponseSchema(BaseModel): - name: str = Field(description="ID of the team") - team_name: str = Field(description="The name of the team") - logo: str | None = Field(description="Logo of the team") - is_current: bool = Field(description="Whether this is the current team") - - -class GetUserResponseSchema(BaseModel): - email: str - first_name: str - last_name: str | None = None - full_name: str - username: str - desk_theme: str - roles: list[str] - has_desk_access: bool - - @field_validator("roles", mode="before") - @classmethod - def extract_roles(cls, v: list[HasRole]) -> list[str]: - if not v: - return [] - - return [role.role for role in v] - - -class GetUserBasicResponse(BaseModel): - full_name: str - user_image: str | None = None +from .schema import GetUserBasicResponse, GetUserResponseSchema, GetUserTeamsResponseSchema @frappe.whitelist() diff --git a/forms_pro/api/user/schema.py b/forms_pro/api/user/schema.py new file mode 100644 index 0000000..77061ce --- /dev/null +++ b/forms_pro/api/user/schema.py @@ -0,0 +1,33 @@ +from frappe.core.doctype.has_role.has_role import HasRole +from pydantic import BaseModel, Field, field_validator + + +class GetUserTeamsResponseSchema(BaseModel): + name: str = Field(description="ID of the team") + team_name: str = Field(description="The name of the team") + logo: str | None = Field(description="Logo of the team") + is_current: bool = Field(description="Whether this is the current team") + + +class GetUserResponseSchema(BaseModel): + email: str + first_name: str + last_name: str | None = None + full_name: str + username: str + desk_theme: str + roles: list[str] + has_desk_access: bool + + @field_validator("roles", mode="before") + @classmethod + def extract_roles(cls, v: list[HasRole]) -> list[str]: + if not v: + return [] + + return [role.role for role in v] + + +class GetUserBasicResponse(BaseModel): + full_name: str + user_image: str | None = None diff --git a/forms_pro/tests/test_submission_validation.py b/forms_pro/tests/test_submission_validation.py index 610dc31..320f71d 100644 --- a/forms_pro/tests/test_submission_validation.py +++ b/forms_pro/tests/test_submission_validation.py @@ -5,7 +5,11 @@ import unittest from types import SimpleNamespace -from forms_pro.api.submission import _coerce_field_value, _evaluate_conditions, _validate_form_response +from forms_pro.api.submission.endpoints import ( + _coerce_field_value, + _evaluate_conditions, + _validate_form_response, +) from forms_pro.forms_pro.doctype.form_field.form_field import _DISPLAY_ONLY_FIELDTYPES from forms_pro.utils.constants import FORMS_PRO_SYSTEM_FIELDNAMES, UNSUPPORTED_FRAPPE_FIELDTYPES from forms_pro.utils.form_generator import LINKED_FORM_FIELDOPTIONS, SUBMISSION_STATUS_FIELDOPTIONS From 97c2aab6ad5da3f91dd2ae3233e8433ddc566cb5 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 12:52:54 +0530 Subject: [PATCH 04/20] test: relocate tests to per-package and doctype folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_form_field.py → doctype/form_field/ (Frappe doctype-test convention). - test_invitations.py → api/team/, test_submission_validation.py → api/submission/, test_export.py → api/export/ (co-located with their endpoint families). - test_roles.py and tests/factories/ stay central (cross-cutting). Also update test_export.py's monkeypatch path to forms_pro.api.export.endpoints.DataExporter — the public __init__.py no longer re-exports the imported symbol. --- forms_pro/{tests => api/export}/test_export.py | 2 +- .../{tests => api/submission}/test_submission_validation.py | 0 forms_pro/{tests => api/team}/test_invitations.py | 0 .../{tests => forms_pro/doctype/form_field}/test_form_field.py | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename forms_pro/{tests => api/export}/test_export.py (99%) rename forms_pro/{tests => api/submission}/test_submission_validation.py (100%) rename forms_pro/{tests => api/team}/test_invitations.py (100%) rename forms_pro/{tests => forms_pro/doctype/form_field}/test_form_field.py (100%) diff --git a/forms_pro/tests/test_export.py b/forms_pro/api/export/test_export.py similarity index 99% rename from forms_pro/tests/test_export.py rename to forms_pro/api/export/test_export.py index ca10ce0..b7636f9 100644 --- a/forms_pro/tests/test_export.py +++ b/forms_pro/api/export/test_export.py @@ -301,7 +301,7 @@ def test_session_user_restored_when_exporter_raises(self) -> None: frappe.set_user(FORMS_PRO_TEST_USER) with patch( - "forms_pro.api.export.DataExporter.build_response", + "forms_pro.api.export.endpoints.DataExporter.build_response", side_effect=RuntimeError("boom"), ): with self.assertRaises(RuntimeError): diff --git a/forms_pro/tests/test_submission_validation.py b/forms_pro/api/submission/test_submission_validation.py similarity index 100% rename from forms_pro/tests/test_submission_validation.py rename to forms_pro/api/submission/test_submission_validation.py diff --git a/forms_pro/tests/test_invitations.py b/forms_pro/api/team/test_invitations.py similarity index 100% rename from forms_pro/tests/test_invitations.py rename to forms_pro/api/team/test_invitations.py diff --git a/forms_pro/tests/test_form_field.py b/forms_pro/forms_pro/doctype/form_field/test_form_field.py similarity index 100% rename from forms_pro/tests/test_form_field.py rename to forms_pro/forms_pro/doctype/form_field/test_form_field.py From 11d241edb31f252bc13e99daa959951eabdc6679 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 12:53:43 +0530 Subject: [PATCH 05/20] feat(api): add get_form_for_view with read permission gate --- forms_pro/api/form/__init__.py | 2 ++ forms_pro/api/form/endpoints.py | 12 ++++++++++++ forms_pro/api/form/test_form.py | 26 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 forms_pro/api/form/test_form.py diff --git a/forms_pro/api/form/__init__.py b/forms_pro/api/form/__init__.py index 511f2de..09a5400 100644 --- a/forms_pro/api/form/__init__.py +++ b/forms_pro/api/form/__init__.py @@ -4,6 +4,7 @@ get_doctype_list, get_form, get_form_by_route, + get_form_for_view, get_form_shared_with, get_link_field_options, is_login_required, @@ -17,6 +18,7 @@ "get_doctype_list", "get_form", "get_form_by_route", + "get_form_for_view", "get_form_shared_with", "get_link_field_options", "is_login_required", diff --git a/forms_pro/api/form/endpoints.py b/forms_pro/api/form/endpoints.py index fc70d0f..d63e33e 100644 --- a/forms_pro/api/form/endpoints.py +++ b/forms_pro/api/form/endpoints.py @@ -5,6 +5,7 @@ from forms_pro.api.user import get_user from forms_pro.forms_pro.doctype.form.form import Form from forms_pro.utils.constants import FORMS_PRO_SYSTEM_FIELDNAMES, UNSUPPORTED_FRAPPE_FIELDTYPES +from forms_pro.utils.permissions import require_permission from .schema import FormSharedWithResponse @@ -56,6 +57,17 @@ def get_form(form_id: str) -> dict: } +@frappe.whitelist() +@require_permission("Form", "read", param="form_id") +def get_form_for_view(form_id: str) -> dict: + """Return the form document for the Manage Form page. + + Requires ``read`` permission on the Form. Returns HTTP 404 when the + form does not exist and HTTP 403 when the user lacks permission. + """ + return get_form(form_id) + + @frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method def get_link_field_options( doctype: str, diff --git a/forms_pro/api/form/test_form.py b/forms_pro/api/form/test_form.py new file mode 100644 index 0000000..ea95bb6 --- /dev/null +++ b/forms_pro/api/form/test_form.py @@ -0,0 +1,26 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from forms_pro.api.form import get_form_for_view +from forms_pro.tests import FORMS_PRO_TEST_USER +from forms_pro.tests.factories.form_factory import FormFactory + + +class TestGetFormForView(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_returns_form_when_user_has_read(self) -> None: + result = get_form_for_view(form_id=self.form.name) + self.assertEqual(result["name"], self.form.name) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + get_form_for_view(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_form_for_view(form_id="MISSING_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) From eecbbc9bd7f5247393c356bd2fb4b23e661ea1a2 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 12:54:32 +0530 Subject: [PATCH 06/20] feat(api): add get_form_for_edit with write permission gate --- forms_pro/api/form/__init__.py | 2 ++ forms_pro/api/form/endpoints.py | 10 ++++++++++ forms_pro/api/form/test_form.py | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/forms_pro/api/form/__init__.py b/forms_pro/api/form/__init__.py index 09a5400..5828597 100644 --- a/forms_pro/api/form/__init__.py +++ b/forms_pro/api/form/__init__.py @@ -4,6 +4,7 @@ get_doctype_list, get_form, get_form_by_route, + get_form_for_edit, get_form_for_view, get_form_shared_with, get_link_field_options, @@ -18,6 +19,7 @@ "get_doctype_list", "get_form", "get_form_by_route", + "get_form_for_edit", "get_form_for_view", "get_form_shared_with", "get_link_field_options", diff --git a/forms_pro/api/form/endpoints.py b/forms_pro/api/form/endpoints.py index d63e33e..18f7040 100644 --- a/forms_pro/api/form/endpoints.py +++ b/forms_pro/api/form/endpoints.py @@ -68,6 +68,16 @@ def get_form_for_view(form_id: str) -> dict: return get_form(form_id) +@frappe.whitelist() +@require_permission("Form", "write", param="form_id") +def get_form_for_edit(form_id: str) -> dict: + """Return the form document for the Edit Form page. + + Requires ``write`` permission on the Form. Returns HTTP 404/403 accordingly. + """ + return get_form(form_id) + + @frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method def get_link_field_options( doctype: str, diff --git a/forms_pro/api/form/test_form.py b/forms_pro/api/form/test_form.py index ea95bb6..af13dbf 100644 --- a/forms_pro/api/form/test_form.py +++ b/forms_pro/api/form/test_form.py @@ -1,7 +1,8 @@ import frappe +from frappe.share import add_docshare from frappe.tests import IntegrationTestCase -from forms_pro.api.form import get_form_for_view +from forms_pro.api.form import get_form_for_edit, get_form_for_view from forms_pro.tests import FORMS_PRO_TEST_USER from forms_pro.tests.factories.form_factory import FormFactory @@ -24,3 +25,33 @@ def test_raises_404_when_form_missing(self) -> None: with self.assertRaises(frappe.DoesNotExistError) as ctx: get_form_for_view(form_id="MISSING_FORM_XYZ") self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestGetFormForEdit(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_returns_form_when_user_has_write(self) -> None: + result = get_form_for_edit(form_id=self.form.name) + self.assertEqual(result["name"], self.form.name) + + def test_raises_403_when_user_has_read_but_not_write(self) -> None: + # Share read-only with the low-privilege user, then assert write is denied. + add_docshare( + doctype="Form", + name=self.form.name, + user=FORMS_PRO_TEST_USER, + read=1, + write=0, + share=0, + flags={"ignore_share_permission": True}, + ) + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + get_form_for_edit(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_form_for_edit(form_id="MISSING_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) From 32efea78f7c25de8e1a8a58fe445375dce44690c Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 12:56:52 +0530 Subject: [PATCH 07/20] feat(api): add get_team_for_manage with read permission gate --- forms_pro/api/team/__init__.py | 2 ++ forms_pro/api/team/endpoints.py | 17 +++++++++++++++++ forms_pro/api/team/test_team.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 forms_pro/api/team/test_team.py diff --git a/forms_pro/api/team/__init__.py b/forms_pro/api/team/__init__.py index 90e64a8..b64ac86 100644 --- a/forms_pro/api/team/__init__.py +++ b/forms_pro/api/team/__init__.py @@ -1,6 +1,7 @@ from .endpoints import ( add_member_to_team_via_invitation, create_team, + get_team_for_manage, get_team_forms, get_team_members, invite_team_members, @@ -13,6 +14,7 @@ __all__ = [ "add_member_to_team_via_invitation", "create_team", + "get_team_for_manage", "get_team_forms", "get_team_members", "invite_team_members", diff --git a/forms_pro/api/team/endpoints.py b/forms_pro/api/team/endpoints.py index 574795b..d6b8a11 100644 --- a/forms_pro/api/team/endpoints.py +++ b/forms_pro/api/team/endpoints.py @@ -5,6 +5,7 @@ from frappe.share import get_share_name from forms_pro.forms_pro.doctype.fp_team.fp_team import FPTeam, GetTeamMembersResponse +from forms_pro.utils.permissions import require_permission from forms_pro.utils.teams import ( GetTeamFormsResponseSchema, set_current_team, @@ -14,6 +15,22 @@ ) +@frappe.whitelist() +@require_permission("FP Team", "read", param="team_id") +def get_team_for_manage(team_id: str) -> dict: + """Return team details for the Manage Team page. + + Requires ``read`` permission on the FP Team. Returns HTTP 404/403 + accordingly. + """ + team: FPTeam = frappe.get_doc("FP Team", team_id) + return { + "name": team.name, + "team_name": team.team_name, + "logo": team.logo, + } + + @frappe.whitelist() def get_team_forms(team_id: str) -> list[GetTeamFormsResponseSchema]: """ diff --git a/forms_pro/api/team/test_team.py b/forms_pro/api/team/test_team.py new file mode 100644 index 0000000..70700a5 --- /dev/null +++ b/forms_pro/api/team/test_team.py @@ -0,0 +1,29 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from forms_pro.api.team import get_team_for_manage +from forms_pro.tests.factories.fp_team_factory import FPTeamFactory +from forms_pro.tests.factories.user_factory import UserFactory + + +class TestGetTeamForManage(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + # Fresh user with no Forms Pro role → no doctype-level perm on FP Team. + self.outsider = UserFactory.create() + + def test_returns_team_when_user_has_read(self) -> None: + result = get_team_for_manage(team_id=self.team.name) + self.assertEqual(result["name"], self.team.name) + self.assertEqual(result["team_name"], self.team.team_name) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + get_team_for_manage(team_id=self.team.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_team_for_manage(team_id="MISSING_TEAM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) From 468e0225b9cd5b7dd015bc1bd040a0f8bfd65001 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 12:59:47 +0530 Subject: [PATCH 08/20] refactor(api/form): retrofit endpoints with require_permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply @require_permission decorator to get_form_shared_with, remove_form_access, add_form_access, set_form_permission. Removes the manual frappe.has_permission + frappe.throw block in each. Behaviour diffs: - Missing form now raises DoesNotExistError (HTTP 404). Previously the endpoint would fall through to a downstream LinkValidationError or a ValidationError-as-deny via has_permission(missing). - get_form_shared_with and remove_form_access now raise PermissionError (HTTP 403) on deny. Previously both raised the default ValidationError (HTTP 417) because frappe.throw was called without an exc class. Same Form, same ptype, same docname source — the decorator is a strict behaviour-preserving wrapper save for the two corrections above. --- forms_pro/api/form/endpoints.py | 60 +++++--------- forms_pro/api/form/test_form.py | 138 +++++++++++++++++++++++++++++++- 2 files changed, 159 insertions(+), 39 deletions(-) diff --git a/forms_pro/api/form/endpoints.py b/forms_pro/api/form/endpoints.py index 18f7040..e9efd0e 100644 --- a/forms_pro/api/form/endpoints.py +++ b/forms_pro/api/form/endpoints.py @@ -97,19 +97,13 @@ def get_link_field_options( @frappe.whitelist() +@require_permission("Form", "read", param="form_id") def get_form_shared_with(form_id: str) -> list[frappe.Any]: - """ - Get list of users with which a form is shared. + """Get list of users with whom a form is shared. - We validate the current user has read access to the form. + Requires ``read`` permission on the Form (HTTP 403 otherwise, HTTP 404 + when the form does not exist). """ - if not frappe.has_permission( - "Form", - "read", - form_id, - ): - frappe.throw(_("You do not have read access to this form")) - form: Form = frappe.get_doc("Form", form_id) shared_with = form.shared_with() @@ -126,25 +120,22 @@ def get_form_shared_with(form_id: str) -> list[frappe.Any]: @frappe.whitelist() +@require_permission("Form", "write", param="form_id") def remove_form_access(form_id: str, user_email: str) -> None: - """ - Remove access to a form for a user. - - We validate the current user has write access to the form. + """Remove access to a form for a user. - args: - form_id: str - The ID of the form to remove access to. - user_email: str - The email of the user to remove access to. + Requires ``write`` permission on the Form (HTTP 403 otherwise, HTTP 404 + when the form does not exist). + Args: + form_id: The ID of the form to remove access from. + user_email: The email of the user whose access is being removed. """ - - if not frappe.has_permission("Form", "write", form_id): - frappe.throw(_("You do not have write access to this form")) - return remove(doctype="Form", name=form_id, user=user_email, flags={"ignore_permissions": True}) @frappe.whitelist() +@require_permission("Form", "share", param="form_id") def add_form_access( form_id: str, user: str, @@ -153,13 +144,12 @@ def add_form_access( share: bool = False, submit: bool = False, ) -> None: - """ - Grant a user access to a form with the specified permissions. + """Grant a user access to a form with the specified permissions. Uses ``ignore_share_permission`` so the record can be shared regardless of - the caller's role-level DocShare permissions — the explicit - ``frappe.has_permission`` check below enforces that only users with share - access on this particular form can invoke this endpoint. + the caller's role-level DocShare permissions — the ``@require_permission`` + decorator enforces that only users with share access on this particular form + can invoke this endpoint. Args: form_id: Name of the Form document to share. @@ -170,12 +160,9 @@ def add_form_access( submit: Allow the user to submit the form (default False). Raises: - frappe.PermissionError: If the calling user does not have share access - on the specified form. + frappe.PermissionError: HTTP 403, when the caller lacks share access. + frappe.DoesNotExistError: HTTP 404, when the form does not exist. """ - if not frappe.has_permission("Form", "share", form_id): - frappe.throw(_("You do not have share access to this form"), frappe.PermissionError) - add_docshare( doctype="Form", name=form_id, @@ -189,14 +176,14 @@ def add_form_access( @frappe.whitelist() +@require_permission("Form", "share", param="form_id") def set_form_permission( form_id: str, user: str, permission_to: str, value: bool, ) -> None: - """ - Toggle a single permission bit for a user on a form. + """Toggle a single permission bit for a user on a form. Designed for per-toggle updates from the sharing UI — only the specified permission field is changed; all other existing permissions are preserved by @@ -210,14 +197,11 @@ def set_form_permission( value: ``True`` to grant the permission, ``False`` to revoke it. Raises: - frappe.PermissionError: If the calling user does not have share access - on the specified form. + frappe.PermissionError: HTTP 403, when the caller lacks share access. + frappe.DoesNotExistError: HTTP 404, when the form does not exist. frappe.ValidationError: If ``permission_to`` is not a recognised permission type. """ - if not frappe.has_permission("Form", "share", form_id): - frappe.throw(_("You do not have share access to this form"), frappe.PermissionError) - # Guard against arbitrary kwargs being forwarded to add_docshare allowed_permissions = {"read", "write", "share", "submit"} if permission_to not in allowed_permissions: diff --git a/forms_pro/api/form/test_form.py b/forms_pro/api/form/test_form.py index af13dbf..c88b60c 100644 --- a/forms_pro/api/form/test_form.py +++ b/forms_pro/api/form/test_form.py @@ -2,7 +2,14 @@ from frappe.share import add_docshare from frappe.tests import IntegrationTestCase -from forms_pro.api.form import get_form_for_edit, get_form_for_view +from forms_pro.api.form import ( + add_form_access, + get_form_for_edit, + get_form_for_view, + get_form_shared_with, + remove_form_access, + set_form_permission, +) from forms_pro.tests import FORMS_PRO_TEST_USER from forms_pro.tests.factories.form_factory import FormFactory @@ -55,3 +62,132 @@ def test_raises_404_when_form_missing(self) -> None: with self.assertRaises(frappe.DoesNotExistError) as ctx: get_form_for_edit(form_id="MISSING_FORM_XYZ") self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestGetFormSharedWith(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_admin_can_list_shares(self) -> None: + result = get_form_shared_with(form_id=self.form.name) + self.assertIsInstance(result, list) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + get_form_shared_with(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_form_shared_with(form_id="MISSING_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestAddFormAccess(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_admin_can_add_access(self) -> None: + add_form_access( + form_id=self.form.name, + user=FORMS_PRO_TEST_USER, + read=True, + ) + self.assertTrue( + frappe.db.exists( + "DocShare", + {"share_doctype": "Form", "share_name": self.form.name, "user": FORMS_PRO_TEST_USER}, + ) + ) + + def test_raises_403_when_user_lacks_share(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + add_form_access(form_id=self.form.name, user="x@y.z", read=True) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + add_form_access(form_id="MISSING_FORM_XYZ", user=FORMS_PRO_TEST_USER, read=True) + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestRemoveFormAccess(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + add_docshare( + doctype="Form", + name=self.form.name, + user=FORMS_PRO_TEST_USER, + read=1, + flags={"ignore_share_permission": True}, + ) + + def test_admin_can_remove_access(self) -> None: + remove_form_access(form_id=self.form.name, user_email=FORMS_PRO_TEST_USER) + self.assertFalse( + frappe.db.exists( + "DocShare", + {"share_doctype": "Form", "share_name": self.form.name, "user": FORMS_PRO_TEST_USER}, + ) + ) + + def test_raises_403_when_user_lacks_write(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + remove_form_access(form_id=self.form.name, user_email=FORMS_PRO_TEST_USER) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + remove_form_access(form_id="MISSING_FORM_XYZ", user_email=FORMS_PRO_TEST_USER) + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestSetFormPermission(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + add_docshare( + doctype="Form", + name=self.form.name, + user=FORMS_PRO_TEST_USER, + read=1, + flags={"ignore_share_permission": True}, + ) + + def test_admin_can_set_permission(self) -> None: + set_form_permission( + form_id=self.form.name, + user=FORMS_PRO_TEST_USER, + permission_to="write", + value=True, + ) + share = frappe.get_value( + "DocShare", + {"share_doctype": "Form", "share_name": self.form.name, "user": FORMS_PRO_TEST_USER}, + ["write"], + as_dict=True, + ) + self.assertEqual(share["write"], 1) + + def test_raises_403_when_user_lacks_share(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + set_form_permission( + form_id=self.form.name, + user=FORMS_PRO_TEST_USER, + permission_to="write", + value=True, + ) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + set_form_permission( + form_id="MISSING_FORM_XYZ", + user=FORMS_PRO_TEST_USER, + permission_to="write", + value=True, + ) + self.assertEqual(ctx.exception.http_status_code, 404) From b97a3ec23c27f4f4faad25d759f8c76737a6ff56 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 13:02:09 +0530 Subject: [PATCH 09/20] refactor(api/team): retrofit endpoints with require_permission Apply @require_permission to get_team_members, invite_team_members, toggle_can_edit_team, save, and remove_member_from_team. Each preserves its existing doctype + ptype semantics. Adds HTTP 404 on missing team via the decorator's existence check (previously a downstream Frappe LinkValidationError or DoesNotExistError from frappe.get_doc). Skipped (intentionally): create_team (no docname yet), switch_team (session mutation; existing read check kept inline), get_team_forms (no current gate), add_member_to_team_via_invitation (invitation-token auth model differs). --- forms_pro/api/team/endpoints.py | 83 ++++++++------------------ forms_pro/api/team/test_team.py | 102 +++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 60 deletions(-) diff --git a/forms_pro/api/team/endpoints.py b/forms_pro/api/team/endpoints.py index d6b8a11..96cc253 100644 --- a/forms_pro/api/team/endpoints.py +++ b/forms_pro/api/team/endpoints.py @@ -47,21 +47,13 @@ def get_team_forms(team_id: str) -> list[GetTeamFormsResponseSchema]: @frappe.whitelist() +@require_permission("FP Team", "read", param="team_id") def get_team_members(team_id: str) -> list[GetTeamMembersResponse]: - """ - - Get the list of team members in a FP Team. - This endpoint checks if the session user has the permission to read this FP Team DocType + """Get the list of team members in a FP Team. + Requires ``read`` permission on the FP Team (HTTP 403 otherwise, + HTTP 404 when the team does not exist). """ - frappe.has_permission( - doctype="FP Team", - ptype="read", - doc=team_id, - user=frappe.session.user, - throw=True, - ) - # Clear cache so we read fresh DocShare data (e.g. after toggle_can_edit_team) frappe.clear_document_cache("FP Team", team_id) @@ -109,21 +101,13 @@ def switch_team(team_id: str) -> None: @frappe.whitelist(methods=["POST"]) +@require_permission("FP Team", "write", param="team_id") def invite_team_members(team_id: str, emails: list[str]) -> None: - """ - Invite team members to a team - """ - - if not frappe.has_permission( - doctype="FP Team", - ptype="write", - doc=team_id, - user=frappe.session.user, - ): - raise frappe.PermissionError( - "You do not have write permission on this team; write access is required to invite members" - ) + """Invite team members to a team. + Requires ``write`` permission on the FP Team (HTTP 403 otherwise, + HTTP 404 when the team does not exist). + """ emails_str = ", ".join(emails) invite_by_email( @@ -174,21 +158,13 @@ def add_member_to_team_via_invitation(team_id: str, invite_id: str | None = None @frappe.whitelist(methods=["POST"]) +@require_permission("FP Team", "write", param="team_id") def toggle_can_edit_team(team_id: str, member_email: str) -> None: - """ - Toggle the can_edit_team permission for a team member - """ - - if not frappe.has_permission( - doctype="FP Team", - ptype="write", - doc=team_id, - user=frappe.session.user, - ): - raise frappe.PermissionError( - "You do not have permission to toggle the can_edit_team permission for this team member" - ) + """Toggle the can_edit_team permission for a team member. + Requires ``write`` permission on the FP Team (HTTP 403 otherwise, + HTTP 404 when the team does not exist). + """ team: FPTeam = frappe.get_doc("FP Team", team_id) if team.owner == member_email: raise frappe.PermissionError( @@ -208,18 +184,13 @@ def toggle_can_edit_team(team_id: str, member_email: str) -> None: @frappe.whitelist(methods=["POST"]) +@require_permission("FP Team", "write", param="team_id") def save(team_id: str, fields: dict) -> None: - """ - Update team fields. Only fields present in the dict are updated. - """ - frappe.has_permission( - doctype="FP Team", - ptype="write", - doc=team_id, - user=frappe.session.user, - throw=True, - ) + """Update team fields. Only fields present in the dict are updated. + Requires ``write`` permission on the FP Team (HTTP 403 otherwise, + HTTP 404 when the team does not exist). + """ ALLOWED_SAVE_FIELDS = ["team_name", "logo"] team: FPTeam = frappe.get_doc("FP Team", team_id) @@ -231,18 +202,12 @@ def save(team_id: str, fields: dict) -> None: @frappe.whitelist(methods=["POST"]) +@require_permission("FP Team", "write", param="team_id") def remove_member_from_team(team_id: str, member_email: str) -> None: - """ - Remove a member from a team - """ - - if not frappe.has_permission( - doctype="FP Team", - ptype="write", - doc=team_id, - user=frappe.session.user, - ): - raise frappe.PermissionError("You do not have permission to remove a member from this team") + """Remove a member from a team. + Requires ``write`` permission on the FP Team (HTTP 403 otherwise, + HTTP 404 when the team does not exist). + """ team: FPTeam = frappe.get_doc("FP Team", team_id) team.remove_from_team(member_email) diff --git a/forms_pro/api/team/test_team.py b/forms_pro/api/team/test_team.py index 70700a5..007fb2d 100644 --- a/forms_pro/api/team/test_team.py +++ b/forms_pro/api/team/test_team.py @@ -1,7 +1,14 @@ import frappe from frappe.tests import IntegrationTestCase -from forms_pro.api.team import get_team_for_manage +from forms_pro.api.team import ( + get_team_for_manage, + get_team_members, + invite_team_members, + remove_member_from_team, + save, + toggle_can_edit_team, +) from forms_pro.tests.factories.fp_team_factory import FPTeamFactory from forms_pro.tests.factories.user_factory import UserFactory @@ -27,3 +34,96 @@ def test_raises_404_when_team_missing(self) -> None: with self.assertRaises(frappe.DoesNotExistError) as ctx: get_team_for_manage(team_id="MISSING_TEAM_XYZ") self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestGetTeamMembers(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + self.outsider = UserFactory.create() + + def test_admin_can_list_members(self) -> None: + result = get_team_members(team_id=self.team.name) + self.assertIsInstance(result, list) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + get_team_members(team_id=self.team.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_team_members(team_id="MISSING_TEAM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestInviteTeamMembers(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + self.outsider = UserFactory.create() + + def test_raises_403_when_user_lacks_write(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + invite_team_members(team_id=self.team.name, emails=["x@y.z"]) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + invite_team_members(team_id="MISSING_TEAM_XYZ", emails=["x@y.z"]) + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestToggleCanEditTeam(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + self.outsider = UserFactory.create() + + def test_raises_403_when_user_lacks_write(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + toggle_can_edit_team(team_id=self.team.name, member_email="x@y.z") + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + toggle_can_edit_team(team_id="MISSING_TEAM_XYZ", member_email="x@y.z") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestSaveTeam(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + self.outsider = UserFactory.create() + + def test_admin_can_save_team(self) -> None: + save(team_id=self.team.name, fields={"team_name": "Renamed"}) + self.assertEqual(frappe.db.get_value("FP Team", self.team.name, "team_name"), "Renamed") + + def test_raises_403_when_user_lacks_write(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + save(team_id=self.team.name, fields={"team_name": "Hijacked"}) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + save(team_id="MISSING_TEAM_XYZ", fields={"team_name": "X"}) + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestRemoveMemberFromTeam(IntegrationTestCase): + def setUp(self) -> None: + self.team = FPTeamFactory.create() + self.outsider = UserFactory.create() + + def test_raises_403_when_user_lacks_write(self) -> None: + with self.set_user(self.outsider.name): + with self.assertRaises(frappe.PermissionError) as ctx: + remove_member_from_team(team_id=self.team.name, member_email="x@y.z") + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_team_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + remove_member_from_team(team_id="MISSING_TEAM_XYZ", member_email="x@y.z") + self.assertEqual(ctx.exception.http_status_code, 404) From 47f7c261d265195ea6e1787348bf79021a21e9e0 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 13:11:09 +0530 Subject: [PATCH 10/20] refactor(api/submission): retrofit list endpoints with require_permission Apply @require_permission("Form", "read", param="form_id") to get_user_submissions and get_all_submissions. Drop manual FP Team write check + 404 throw from get_all_submissions, and the dead Guest early-return from get_user_submissions (endpoint is not allow_guest). get_submission and get_submission_response retain their manual permission checks: their target doctype is dynamic (passed as a parameter), which the current decorator (fixed doctype string) cannot express. Adds forms_pro/api/submission/test_submission.py with allow/403/404 coverage for both retrofitted endpoints. --- forms_pro/api/submission/endpoints.py | 18 ++------ forms_pro/api/submission/test_submission.py | 46 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 forms_pro/api/submission/test_submission.py diff --git a/forms_pro/api/submission/endpoints.py b/forms_pro/api/submission/endpoints.py index 1f21e82..392d93d 100644 --- a/forms_pro/api/submission/endpoints.py +++ b/forms_pro/api/submission/endpoints.py @@ -8,6 +8,7 @@ from forms_pro.forms_pro.doctype.form.form import Form from forms_pro.forms_pro.doctype.form_field.form_field import _DISPLAY_ONLY_FIELDTYPES from forms_pro.utils.form_generator import SubmissionStatus +from forms_pro.utils.permissions import require_permission from .schema import UserSubmissionResponse @@ -178,6 +179,7 @@ def submit_form_response( @frappe.whitelist() +@require_permission("Form", "read", param="form_id") def get_user_submissions(form_id: str) -> list[UserSubmissionResponse]: """ Get the submissions for a user @@ -188,10 +190,6 @@ def get_user_submissions(form_id: str) -> list[UserSubmissionResponse]: Returns: A list of submissions for the user """ - - if frappe.session.user == "Guest": - return [] - form: Form = frappe.get_doc("Form", form_id) linked_doctype = form.linked_doctype @@ -262,6 +260,7 @@ def get_submission(submission_doctype: str, submission_name: str) -> dict[str, A @frappe.whitelist() +@require_permission("Form", "read", param="form_id") def get_all_submissions(form_id: str) -> list[UserSubmissionResponse]: """ Get all submissions for a form @@ -272,17 +271,6 @@ def get_all_submissions(form_id: str) -> list[UserSubmissionResponse]: Returns: A list of submissions for the form """ - linked_team = frappe.db.get_value("Form", form_id, "linked_team_id") - - if not linked_team: - frappe.throw(_("Form not found."), frappe.DoesNotExistError) - - if not frappe.has_permission(doctype="FP Team", ptype="write", doc=linked_team, user=frappe.session.user): - frappe.throw( - _("You do not have permission to read this form's submissions."), - frappe.PermissionError, - ) - form: Form = frappe.get_doc("Form", form_id) linked_doctype = form.linked_doctype diff --git a/forms_pro/api/submission/test_submission.py b/forms_pro/api/submission/test_submission.py new file mode 100644 index 0000000..6139740 --- /dev/null +++ b/forms_pro/api/submission/test_submission.py @@ -0,0 +1,46 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from forms_pro.api.submission import get_all_submissions, get_user_submissions +from forms_pro.tests import FORMS_PRO_TEST_USER +from forms_pro.tests.factories.form_factory import FormFactory + + +class TestGetUserSubmissions(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_admin_can_list_user_submissions(self) -> None: + result = get_user_submissions(form_id=self.form.name) + self.assertIsInstance(result, list) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + get_user_submissions(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_user_submissions(form_id="MISSING_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) + + +class TestGetAllSubmissions(IntegrationTestCase): + def setUp(self) -> None: + self.form = FormFactory.create() + + def test_admin_can_list_all_submissions(self) -> None: + result = get_all_submissions(form_id=self.form.name) + self.assertIsInstance(result, list) + + def test_raises_403_when_user_lacks_read(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + with self.assertRaises(frappe.PermissionError) as ctx: + get_all_submissions(form_id=self.form.name) + self.assertEqual(ctx.exception.http_status_code, 403) + + def test_raises_404_when_form_missing(self) -> None: + with self.assertRaises(frappe.DoesNotExistError) as ctx: + get_all_submissions(form_id="MISSING_FORM_XYZ") + self.assertEqual(ctx.exception.http_status_code, 404) From 1ecaebec3d48a252f9003ef0a2d0693d7f050d11 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 15:16:40 +0530 Subject: [PATCH 11/20] refactor(api/export): retrofit export_submissions with require_permission Apply @require_permission("Form", "write", param="form_id") to export_submissions and drop the manual frappe.has_permission block. Privilege-swap behavior, audit log, and session restoration remain unchanged. Tightens TestExportPermissions to assert http_status_code == 403 on the deny path and adds an explicit 404 case for a missing form. --- forms_pro/api/export/endpoints.py | 8 ++------ forms_pro/api/export/test_export.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/forms_pro/api/export/endpoints.py b/forms_pro/api/export/endpoints.py index f60d643..a0e9091 100644 --- a/forms_pro/api/export/endpoints.py +++ b/forms_pro/api/export/endpoints.py @@ -7,11 +7,13 @@ from werkzeug.utils import secure_filename from forms_pro.forms_pro.doctype.form.form import Form +from forms_pro.utils.permissions import require_permission _SUPPORTED_FILE_TYPES = ("CSV", "Excel") @frappe.whitelist() +@require_permission("Form", "write", param="form_id") def export_submissions(form_id: str, file_type: Literal["CSV", "Excel"] = "CSV") -> None: """ Export submissions for a form as a CSV or XLSX download. @@ -31,12 +33,6 @@ def export_submissions(form_id: str, file_type: Literal["CSV", "Excel"] = "CSV") frappe.ValidationError, ) - if not frappe.has_permission(doctype="Form", ptype="write", doc=form_id): - frappe.throw( - _("You do not have permission to export submissions for this form."), - frappe.PermissionError, - ) - form: Form = frappe.get_doc("Form", form_id) linked_doctype = form.linked_doctype diff --git a/forms_pro/api/export/test_export.py b/forms_pro/api/export/test_export.py index b7636f9..b1b1d66 100644 --- a/forms_pro/api/export/test_export.py +++ b/forms_pro/api/export/test_export.py @@ -213,11 +213,20 @@ def test_user_without_write_permission_raises_permission_error(self) -> None: frappe.set_user(FORMS_PRO_TEST_USER) try: - with self.assertRaises(frappe.PermissionError): + with self.assertRaises(frappe.PermissionError) as ctx: export_submissions(form_id=form.name, file_type="CSV") + self.assertEqual(ctx.exception.http_status_code, 403) finally: frappe.set_user("Administrator") + def test_raises_404_when_form_missing(self) -> None: + """A missing form_id must surface as DoesNotExistError (404) so the + frontend renders the Not Found state, not Access Denied.""" + frappe.set_user("Administrator") + with self.assertRaises(frappe.DoesNotExistError) as ctx: + export_submissions(form_id="MISSING_FORM_XYZ", file_type="CSV") + self.assertEqual(ctx.exception.http_status_code, 404) + class TestExportAccessLog(IntegrationTestCase): """An export must be recorded in the Access Log for audit.""" From 82c4bc870de8426bf378e9b55dde11b593224bc2 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 15:24:59 +0530 Subject: [PATCH 12/20] test(api/user): add coverage for current user + teams endpoints api/user endpoints (get_user, get_current_user, get_user_teams) gate on session state, not DocShare, so they take no @require_permission decorator. Adds an integration test file that pins their current behavior so future changes surface regressions: - get_user returns a basic payload for an existing user, None for a missing user. - get_current_user returns the session user's profile under set_user. - get_user_teams returns a list for a real user and [] for Guest. --- forms_pro/api/user/test_user.py | 43 +++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 forms_pro/api/user/test_user.py diff --git a/forms_pro/api/user/test_user.py b/forms_pro/api/user/test_user.py new file mode 100644 index 0000000..5a5a9ea --- /dev/null +++ b/forms_pro/api/user/test_user.py @@ -0,0 +1,43 @@ +""" +Audit coverage for forms_pro.api.user. + +These endpoints gate on session state, not DocShare, so they take no +`@require_permission` decorator. The tests below document current +behavior so future regressions are caught. +""" + +from frappe.tests import IntegrationTestCase + +from forms_pro.api.user import get_current_user, get_user, get_user_teams +from forms_pro.tests import FORMS_PRO_TEST_USER + + +class TestGetUser(IntegrationTestCase): + def test_returns_basic_payload_for_existing_user(self) -> None: + result = get_user(user=FORMS_PRO_TEST_USER) + self.assertIsNotNone(result) + self.assertIn("full_name", result) + self.assertIn("user_image", result) + + def test_returns_none_for_missing_user(self) -> None: + self.assertIsNone(get_user(user="missing_user_xyz@example.com")) + + +class TestGetCurrentUser(IntegrationTestCase): + def test_returns_session_user_payload(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + result = get_current_user() + self.assertEqual(result["email"], FORMS_PRO_TEST_USER) + self.assertIn("roles", result) + self.assertIn("has_desk_access", result) + + +class TestGetUserTeams(IntegrationTestCase): + def test_returns_list_for_real_user(self) -> None: + with self.set_user(FORMS_PRO_TEST_USER): + result = get_user_teams() + self.assertIsInstance(result, list) + + def test_returns_empty_for_guest(self) -> None: + with self.set_user("Guest"): + self.assertEqual(get_user_teams(), []) From f7ea01e92728404d32d6a280d5c1dd0e699bf732 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 15:28:36 +0530 Subject: [PATCH 13/20] feat(perms): add routeData store + useRouteData composable Introduces the frontend plumbing for route-level permission resolution: - src/types/router.d.ts augments vue-router's RouteMeta with optional allowGuest and fetch fields so per-route data resolvers are typed. - src/stores/routeData.ts is the single Pinia store driving navigation: state {status, data, error}, plus resolve(route) which awaits the meta.fetch resource and normalizes Frappe errors (exc_type, HTTP status, messages) into a uniform RouteError shape. - src/composables/useRouteData.ts exposes typed, computed accessors so pages can read status/data/error without touching the store directly. No router/guard wiring yet, that lands in D2. --- frontend/src/composables/useRouteData.ts | 12 +++++ frontend/src/stores/routeData.ts | 58 ++++++++++++++++++++++++ frontend/src/types/router.d.ts | 13 ++++++ 3 files changed, 83 insertions(+) create mode 100644 frontend/src/composables/useRouteData.ts create mode 100644 frontend/src/stores/routeData.ts create mode 100644 frontend/src/types/router.d.ts diff --git a/frontend/src/composables/useRouteData.ts b/frontend/src/composables/useRouteData.ts new file mode 100644 index 0000000..69aff34 --- /dev/null +++ b/frontend/src/composables/useRouteData.ts @@ -0,0 +1,12 @@ +import { computed } from "vue"; +import { useRouteData as useStore } from "@/stores/routeData"; + +export function useRouteData() { + const store = useStore(); + return { + status: computed(() => store.state.status), + data: computed(() => store.state.data as T | undefined), + error: computed(() => store.state.error), + isNavigating: computed(() => store.isNavigating), + }; +} diff --git a/frontend/src/stores/routeData.ts b/frontend/src/stores/routeData.ts new file mode 100644 index 0000000..17fbbe1 --- /dev/null +++ b/frontend/src/stores/routeData.ts @@ -0,0 +1,58 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { RouteLocationNormalized } from "vue-router"; + +type Status = "idle" | "loading" | "ok" | "error"; + +interface RouteError { + excType?: string; + httpStatus?: number; + messages: string[]; +} + +interface RouteState { + status: Status; + data: unknown; + error: RouteError | null; +} + +export const useRouteData = defineStore("routeData", () => { + const state = ref({ status: "idle", data: null, error: null }); + const isNavigating = ref(false); + + async function resolve(route: RouteLocationNormalized): Promise { + const fetchFn = route.meta.fetch; + state.value = { status: "loading", data: null, error: null }; + isNavigating.value = true; + + try { + if (!fetchFn) { + state.value = { status: "ok", data: null, error: null }; + return; + } + const resource = fetchFn(route); + const data = await resource.fetch(); + state.value = { status: "ok", data, error: null }; + } catch (err: any) { + state.value = { + status: "error", + data: null, + error: { + excType: err?.exc_type, + httpStatus: err?.response?.status, + messages: err?.messages?.length + ? err.messages + : [err?.message ?? "Something went wrong"], + }, + }; + } finally { + isNavigating.value = false; + } + } + + function clear(): void { + state.value = { status: "idle", data: null, error: null }; + } + + return { state, isNavigating, resolve, clear }; +}); diff --git a/frontend/src/types/router.d.ts b/frontend/src/types/router.d.ts new file mode 100644 index 0000000..8e3b0b0 --- /dev/null +++ b/frontend/src/types/router.d.ts @@ -0,0 +1,13 @@ +import "vue-router"; +import type { RouteLocationNormalized } from "vue-router"; + +declare module "vue-router" { + interface RouteMeta { + allowGuest?: boolean; + fetch?: (route: RouteLocationNormalized) => { + fetch: () => Promise; + }; + } +} + +export {}; From 1c5aaa2d85e54b5e7f8d5c2b15c07933dfac9dd6 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 15:30:17 +0530 Subject: [PATCH 14/20] feat(perms): add per-route fetch meta + route data guard Wires the gated pages to the routeData store introduced in D1: - Three resource factories (formForView, formForEdit, teamForManage) point at the new whitelisted endpoints. cache keys per id keep intra-session refetches cheap. - meta.fetch attached to "Manage Team", "Manage Form" (parent), and "Edit Form". For nested Manage Form children (Overview, Submissions) vue-router merges parent meta into the matched route, so the parent resolver applies to every sub-tab. - beforeEach simplified to return-style, gains a call to useRouteData().resolve(to) after the auth check. Login redirect and allowGuest paths preserved. Public submission routes keep their existing beforeEnter and stay guest-friendly; no fetch attached. Manual error UX (RouteError component, page integration) lands in D3; until then a denied page will surface the store's error state but without a styled fallback. --- frontend/src/router.ts | 62 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 673331a..f27fb59 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -1,7 +1,34 @@ import { userResource } from "@/data/user"; -import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"; -import { session } from "./data/session"; +import { session } from "@/data/session"; +import { useRouteData } from "@/stores/routeData"; +import { useUser } from "@/stores/user"; import { isLoginRequired } from "@/utils/form"; +import { createResource } from "frappe-ui"; +import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"; + +const formForViewResource = (id: string) => + createResource({ + url: "forms_pro.api.form.get_form_for_view", + params: { form_id: id }, + cache: ["FormView", id], + auto: false, + }); + +const formForEditResource = (id: string) => + createResource({ + url: "forms_pro.api.form.get_form_for_edit", + params: { form_id: id }, + cache: ["FormEdit", id], + auto: false, + }); + +const teamForManageResource = (teamId: string) => + createResource({ + url: "forms_pro.api.team.get_team_for_manage", + params: { team_id: teamId }, + cache: ["TeamManage", teamId], + auto: false, + }); const routes: RouteRecordRaw[] = [ { @@ -18,6 +45,12 @@ const routes: RouteRecordRaw[] = [ path: "team", name: "Manage Team", component: () => import("@/pages/team/ManageTeam.vue"), + meta: { + fetch: () => { + const user = useUser(); + return teamForManageResource(user.currentTeam?.name ?? ""); + }, + }, }, ], }, @@ -26,6 +59,9 @@ const routes: RouteRecordRaw[] = [ name: "Manage Form", component: () => import("@/pages/manage/ManageForm.vue"), redirect: { name: "Manage Form Overview" }, + meta: { + fetch: (route) => formForViewResource(route.params.id as string), + }, children: [ { path: "overview", @@ -43,6 +79,9 @@ const routes: RouteRecordRaw[] = [ path: "/edit-form/:id", name: "Edit Form", component: () => import("@/pages/EditForm.vue"), + meta: { + fetch: (route) => formForEditResource(route.params.id as string), + }, }, { path: "/p/:route(.*)", @@ -71,7 +110,7 @@ const router = createRouter({ routes, }); -router.beforeEach(async (to, _from, next) => { +router.beforeEach(async (to, _from) => { let isLoggedIn = session.isLoggedIn; try { await userResource.promise; @@ -79,17 +118,16 @@ router.beforeEach(async (to, _from, next) => { isLoggedIn = false; } - if (to.name === "Login" && isLoggedIn) { - next({ name: "Home" }); - } else if ( - to.name !== "Login" && - !isLoggedIn && - to.meta.allowGuest !== true - ) { + if (to.name === "Login" && isLoggedIn) return { name: "Home" }; + if (to.meta.allowGuest) return true; + if (!isLoggedIn) { window.location.href = `/login?redirect-to=/forms${to.fullPath}`; - } else { - next(); + return false; } + + const store = useRouteData(); + await store.resolve(to); + return true; }); export default router; From 7c44414f74abde0de07a59e57a943109664baa2e Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 23 May 2026 15:34:40 +0530 Subject: [PATCH 15/20] feat(perms): RouteError component + page integration + nav indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the user-visible half of the permission system. - components/RouteError.vue: one component, titled per exc_type (PermissionError → Access Denied, DoesNotExistError → Not Found, AuthenticationError → Login Required, fallback → Something Went Wrong). Carries an optional first-line message and HTTP status, plus a Go to Dashboard button. - App.vue: global LoadingIndicator (top-right) bound to routeData.isNavigating so users see in-flight perm resolution. - pages/manage/ManageForm.vue, pages/EditForm.vue, pages/team/ManageTeam.vue: render when status === 'error', existing layout otherwise. - stores/form/manageForm.ts: dropped the primary useDoc fetch. The Form document now flows in via routeData (resolved by the guard against get_form_for_view); manageFormStore.formData / formFields / formOwner read from there. Sharing mutations and formAccessResource untouched. - pages/manage/overview/Overview.vue: removed the now-dead formResource.loading branch (loading is handled globally). --- frontend/src/App.vue | 5 +++ frontend/src/components/RouteError.vue | 39 +++++++++++++++++++ frontend/src/pages/EditForm.vue | 11 +++++- frontend/src/pages/manage/ManageForm.vue | 11 +++++- .../src/pages/manage/overview/Overview.vue | 7 +--- frontend/src/pages/team/ManageTeam.vue | 11 +++++- frontend/src/stores/form/manageForm.ts | 20 +++++----- 7 files changed, 86 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/RouteError.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 511fbf0..767970f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,17 +1,22 @@ diff --git a/frontend/src/components/RouteError.vue b/frontend/src/components/RouteError.vue new file mode 100644 index 0000000..05e09cd --- /dev/null +++ b/frontend/src/components/RouteError.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/pages/EditForm.vue b/frontend/src/pages/EditForm.vue index ee7b7e9..f862f95 100644 --- a/frontend/src/pages/EditForm.vue +++ b/frontend/src/pages/EditForm.vue @@ -1,15 +1,24 @@ diff --git a/frontend/src/pages/manage/overview/Overview.vue b/frontend/src/pages/manage/overview/Overview.vue index d4f79c1..49b3769 100644 --- a/frontend/src/pages/manage/overview/Overview.vue +++ b/frontend/src/pages/manage/overview/Overview.vue @@ -4,7 +4,7 @@ import DescriptionSection from "@/components/form/manage/DescriptionSection.vue" import { useManageForm } from "@/stores/form/manageForm"; import { FileText, CaseLower, Lock } from "@lucide/vue"; import { formatPrettyDate } from "@/utils/date"; -import { TabButtons, LoadingText, Badge, Breadcrumbs } from "frappe-ui"; +import { TabButtons, Badge, Breadcrumbs } from "frappe-ui"; import Avatar from "@/components/ui/Avatar.vue"; import { useQueryParam } from "@/composables/useQueryParam"; import { computed } from "vue"; @@ -38,10 +38,7 @@ const selectedTab = useQueryParam("tab", "description", validTabValues