Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f452b19
feat(perms): add require_permission decorator
harshtandiya May 23, 2026
29913d1
refactor(api): split form.py into form/ package
harshtandiya May 23, 2026
26dd8ca
refactor(api): split team/submission/user/export/settings into packages
harshtandiya May 23, 2026
97c2aab
test: relocate tests to per-package and doctype folders
harshtandiya May 23, 2026
11d241e
feat(api): add get_form_for_view with read permission gate
harshtandiya May 23, 2026
eecbbc9
feat(api): add get_form_for_edit with write permission gate
harshtandiya May 23, 2026
32efea7
feat(api): add get_team_for_manage with read permission gate
harshtandiya May 23, 2026
468e022
refactor(api/form): retrofit endpoints with require_permission
harshtandiya May 23, 2026
b97a3ec
refactor(api/team): retrofit endpoints with require_permission
harshtandiya May 23, 2026
47f7c26
refactor(api/submission): retrofit list endpoints with require_permis…
harshtandiya May 23, 2026
1ecaebe
refactor(api/export): retrofit export_submissions with require_permis…
harshtandiya May 23, 2026
82c4bc8
test(api/user): add coverage for current user + teams endpoints
harshtandiya May 23, 2026
f7ea01e
feat(perms): add routeData store + useRouteData composable
harshtandiya May 23, 2026
1c5aaa2
feat(perms): add per-route fetch meta + route data guard
harshtandiya May 23, 2026
7c44414
feat(perms): RouteError component + page integration + nav indicator
harshtandiya May 23, 2026
2c8ad6d
chore: refactor page loading indicator
harshtandiya May 23, 2026
10d06e0
fix(fp_team): coerce can_edit_team to bool when no DocShare exists
harshtandiya May 23, 2026
ce20b69
fix(perms): await full user init before resolving route fetch
harshtandiya May 23, 2026
bb7ba2f
test(e2e): cover route-level permission gates
harshtandiya May 23, 2026
1c6d6eb
fix(perms): render allowed inline tags in RouteError message
harshtandiya May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions forms_pro/api/export/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .endpoints import export_submissions

__all__ = ["export_submissions"]
8 changes: 2 additions & 6 deletions forms_pro/api/export.py → forms_pro/api/export/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -301,7 +310,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):
Expand Down
29 changes: 29 additions & 0 deletions forms_pro/api/form/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from .endpoints import (
add_form_access,
get_doctype_fields,
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,
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_for_edit",
"get_form_for_view",
"get_form_shared_with",
"get_link_field_options",
"is_login_required",
"remove_form_access",
"set_form_permission",
]
93 changes: 45 additions & 48 deletions forms_pro/api/form.py → forms_pro/api/form/endpoints.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
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
from forms_pro.utils.permissions import require_permission


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
Expand Down Expand Up @@ -65,6 +57,27 @@ 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()
@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,
Expand All @@ -84,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()

Expand All @@ -113,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.
"""Remove access to a form for a user.

We validate the current user has write access to the form.

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,
Expand All @@ -140,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.
Expand All @@ -157,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,
Expand All @@ -176,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
Expand All @@ -197,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:
Expand Down
11 changes: 11 additions & 0 deletions forms_pro/api/form/schema.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading