diff --git a/central/api/identity.py b/central/api/identity.py
index e95ddc302..ebf879d21 100644
--- a/central/api/identity.py
+++ b/central/api/identity.py
@@ -4,6 +4,7 @@
import frappe
from frappe.query_builder import Order
+from frappe.query_builder.functions import Count
from frappe.utils import escape_html
from central.iam import (
@@ -40,7 +41,8 @@ def my_capabilities(team: str | None = None) -> list[str]:
@frappe.whitelist(methods=["GET"])
def my_teams() -> list[dict[str, Any]]:
"""Teams the signed-in user can switch between in the console — the teams they
- are an active member of, each with a display label + the owner email."""
+ are an active member of, each with a display label, the owner email, the
+ caller's own role, how many people are in it, and when it was created."""
user = frappe.session.user
if not user or user == "Guest":
return []
@@ -52,7 +54,7 @@ def my_teams() -> list[dict[str, Any]]:
frappe.qb.from_(member)
.join(team)
.on(team.name == member.parent)
- .select(team.name, team.team_name, team.team_logo, team.owner_user)
+ .select(team.name, team.team_name, team.team_logo, team.owner_user, team.creation, member.role)
.where(
(member.parenttype == "Team")
& (member.parentfield == "members")
@@ -63,15 +65,53 @@ def my_teams() -> list[dict[str, Any]]:
.orderby(team.team_name)
).run(as_dict=True)
- return [
- {
- "name": r.name,
- "label": r.team_name or r.owner_user or r.name,
- "logo": r.team_logo,
- "owner": r.owner_user,
- }
- for r in rows
- ]
+ # One row per role grant, so a member holding two roles in a team lands here
+ # twice. Fold to one entry per team, keeping the strongest role.
+ teams: dict[str, dict[str, Any]] = {}
+ for r in rows:
+ entry = teams.setdefault(
+ r.name,
+ {
+ "name": r.name,
+ "label": r.team_name or r.owner_user or r.name,
+ "logo": r.team_logo,
+ "owner": r.owner_user,
+ "role": r.role,
+ "members": 0,
+ "created": r.creation,
+ },
+ )
+ if _role_rank(r.role) < _role_rank(entry["role"]):
+ entry["role"] = r.role
+
+ if not teams:
+ return []
+
+ counts = (
+ frappe.qb.from_(member)
+ .select(member.parent, Count(member.user).distinct().as_("members"))
+ .where(
+ (member.parenttype == "Team")
+ & (member.parentfield == "members")
+ & (member.status == "Active")
+ & member.parent.isin(list(teams))
+ )
+ .groupby(member.parent)
+ ).run(as_dict=True)
+ for row in counts:
+ teams[row.parent]["members"] = row.members
+
+ return list(teams.values())
+
+
+# Owner outranks Admin, and any named role outranks none.
+_ROLE_ORDER = ["Owner", "Admin"]
+
+
+def _role_rank(role: str | None) -> int:
+ if role in _ROLE_ORDER:
+ return _ROLE_ORDER.index(role)
+ return len(_ROLE_ORDER)
@frappe.whitelist(methods=["GET"])
diff --git a/central/api/teams.py b/central/api/teams.py
index 6250af242..cf876dae3 100644
--- a/central/api/teams.py
+++ b/central/api/teams.py
@@ -235,6 +235,13 @@ def remove_team_member(team: str, user: str) -> dict:
return {"team": team, "user": user, "removed": True}
+@frappe.whitelist(methods=["POST"])
+@require_team_member
+def leave_team(team: str) -> dict:
+ frappe.get_doc("Team", team).leave()
+ return {"team": team, "left": True}
+
+
@frappe.whitelist(methods=["POST"])
@require_capability("team:manage_members", "You can't manage roles for this team.")
def create_custom_role(team: str, role_name: str, capabilities: list | str) -> dict:
diff --git a/central/central/doctype/team/team.py b/central/central/doctype/team/team.py
index 893b5384e..a70b983f0 100644
--- a/central/central/doctype/team/team.py
+++ b/central/central/doctype/team/team.py
@@ -143,6 +143,22 @@ def remove_member(self, user: str) -> None:
self.remove(row)
self.save()
+ # Internal; the HTTP surface is central.api.teams.leave_team.
+ def leave(self) -> None:
+ """Drop your own membership. Leaving is yours to do, so it needs no
+ capability — but the owner can't: transfer ownership or delete the team."""
+ user = frappe.session.user
+ if user == self.owner_user:
+ frappe.throw(_("Transfer ownership before leaving this team."))
+ rows = self._get_member_rows(user)
+ if not rows:
+ frappe.throw(_("You are not a member of this team."))
+ for row in rows:
+ self.remove(row)
+ # A plain member holds no write permission on the Team doc; _validate_changes
+ # is the real gate and allows this diff only because it is a self-removal.
+ self.save(ignore_permissions=True)
+
# Internal; the HTTP surface is central.api.teams.transfer_team_ownership.
def transfer_ownership(self, user: str) -> None:
"""Owner is exclusive: promoting `user` drops every role grant they held
@@ -266,7 +282,7 @@ def _validate_changes(self) -> None:
if self._metadata_changed(previous):
self._require_capability("team:edit")
- if self._members_changed(previous):
+ if self._members_changed(previous) and not self._is_self_removal(previous):
self._require_capability("team:manage_members")
self._validate_sensitive_member_changes(previous)
@@ -278,6 +294,19 @@ def _members_changed(self, previous) -> bool:
previous
)
+ def _is_self_removal(self, previous) -> bool:
+ """The entire member diff is the caller dropping their own rows: leaving.
+ Anything else rides the normal team:manage_members gate."""
+ user = frappe.session.user
+ if self.owner_user != previous.owner_user or user == previous.owner_user:
+ return False
+ before = self._grants_by_user(previous)
+ after = self._grants_by_user(self)
+ if user not in before or user in after:
+ return False
+ del before[user]
+ return before == after
+
def _validate_sensitive_member_changes(self, previous) -> None:
before = self._grants_by_user(previous)
after = self._grants_by_user(self)
diff --git a/central/tests/test_team_management.py b/central/tests/test_team_management.py
index 78e914ac8..19fd8ae84 100644
--- a/central/tests/test_team_management.py
+++ b/central/tests/test_team_management.py
@@ -4,7 +4,7 @@
from frappe.tests import IntegrationTestCase
from frappe.utils import add_days, today
-from central.api.identity import my_invitations
+from central.api.identity import my_invitations, my_teams
from central.api.teams import (
create_custom_role,
create_team,
@@ -12,6 +12,7 @@
delete_custom_role,
delete_team,
invite_team_member,
+ leave_team,
list_team_invitations,
rename_team,
resend_invitation,
@@ -314,6 +315,46 @@ def test_only_owner_can_transfer_ownership(self):
self.assertEqual(team._get_member(self.admin).role, "Owner")
self.assertEqual(team._get_member(self.owner).role, "Admin")
+ def test_my_teams_carries_role_member_count_and_created(self):
+ frappe.set_user(self.admin)
+ rows = [row for row in my_teams() if row["name"] == self.team.name]
+
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(rows[0]["role"], "Admin")
+ self.assertEqual(rows[0]["members"], 3)
+ self.assertTrue(rows[0]["created"])
+
+ def test_leave_team_rejects_a_non_member_before_reading_the_team(self):
+ outsider = create_user("team.outsider@example.test")
+ frappe.set_user(outsider)
+
+ with self.assertRaises(frappe.PermissionError):
+ leave_team(self.team.name)
+
+ def test_leaving_cannot_carry_other_member_changes(self):
+ frappe.set_user(self.viewer)
+ team = frappe.get_doc("Team", self.team.name)
+ for row in team._get_member_rows(self.viewer) + team._get_member_rows(self.admin):
+ team.remove(row)
+
+ with self.assertRaises(frappe.PermissionError):
+ team.save(ignore_permissions=True)
+
+ def test_member_leaves_but_owner_cannot(self):
+ frappe.set_user(self.viewer)
+ leave_team(self.team.name)
+
+ team = frappe.get_doc("Team", self.team.name)
+ self.assertFalse(team._get_member_rows(self.viewer))
+ self.assertFalse(can(self.viewer, team.name, "server:view"))
+
+ with self.assertRaises(frappe.PermissionError):
+ leave_team(self.team.name)
+
+ frappe.set_user(self.owner)
+ with self.assertRaises(frappe.ValidationError):
+ leave_team(self.team.name)
+
# --- API endpoints (central.api.teams / central.api.identity) ----------------
def test_create_team_makes_caller_the_owner(self):
diff --git a/dashboard/package.json b/dashboard/package.json
index 83516ee34..3d78ed0cd 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -12,7 +12,7 @@
"dependencies": {
"@stripe/stripe-js": "^9.10.0",
"@tanstack/vue-table": "^8.21.3",
- "frappe-ui": "github:frappe/frappe-ui#v1.0.0-beta.53",
+ "frappe-ui": "github:frappe/frappe-ui#v1.0.0-beta.56",
"reka-ui": "^2.10.1",
"socket.io-client": "^4.8.1",
"vue": "^3.5.39",
diff --git a/dashboard/src/api/methods.ts b/dashboard/src/api/methods.ts
index 21ea07d4e..de0616649 100644
--- a/dashboard/src/api/methods.ts
+++ b/dashboard/src/api/methods.ts
@@ -1,18 +1,13 @@
-// Whitelisted method paths in one place. `method(path)` builds the v2 method URL
-// the data-fetching composables call. These are the live, capability-gated,
-// team-scoped endpoints under central/api/.
export function method(path: string): string {
return `/api/v2/method/${path}`
}
export const API = {
- // ── Identity / capability IAM (central.api.identity) ──
myTeams: 'central.api.identity.my_teams',
myCapabilities: 'central.api.identity.my_capabilities',
myInvitations: 'central.api.identity.my_invitations',
- // ── Team roster, roles & invitations (central.api.teams) ──
listTeamMembers: 'central.api.teams.list_team_members',
listTeamRoles: 'central.api.teams.list_team_roles',
listCapabilities: 'central.api.teams.list_capabilities',
@@ -24,6 +19,7 @@ export const API = {
changePassword: 'central.api.auth.change_password',
transferOwnership: 'central.api.teams.transfer_team_ownership',
deleteTeam: 'central.api.teams.delete_team',
+ leaveTeam: 'central.api.teams.leave_team',
inviteTeamMember: 'central.api.teams.invite_team_member',
setTeamMemberRoles: 'central.api.teams.set_team_member_roles',
setTeamMemberStatus: 'central.api.teams.set_team_member_status',
@@ -35,7 +31,6 @@ export const API = {
acceptInvitation: 'central.api.teams.accept_invitation',
declineInvitation: 'central.api.teams.decline_invitation',
- // ── Servers (central.api.servers) ──
registry: 'central.api.servers.registry',
listInstances: 'central.api.servers.list_instances',
refreshAssets: 'central.api.servers.refresh_assets',
@@ -47,9 +42,6 @@ export const API = {
terminateServer: 'central.api.servers.terminate_server',
serverOverview: 'central.api.servers.server_overview',
- // ── Managed add-on services (central.services.api.dashboard) ──
- // service:view for the reads, service:manage for the mutations + key reveal.
- // Per-site enable/disable is a bench (Pilot) surface, not a console method.
listOffers: 'central.services.api.dashboard.list_offers',
serviceInstance: 'central.services.api.dashboard.get_instance',
activateService: 'central.services.api.dashboard.activate_service',
@@ -62,31 +54,24 @@ export const API = {
revealBucketKey: 'central.services.api.dashboard.reveal_bucket_key',
revokeBucketKey: 'central.services.api.dashboard.revoke_bucket_key',
- // ── Auth / SMB signup (central.api.auth) ──
signUp: 'central.api.auth.sign_up',
verifySignup: 'central.api.auth.verify_signup',
resendSignupCode: 'central.api.auth.resend_signup_code',
- // ── Self-serve sites (central.api.sites) ──
checkSubdomain: 'central.api.sites.check_subdomain',
siteDomain: 'central.api.sites.site_domain',
createSite: 'central.api.sites.create_site',
getSite: 'central.api.sites.get_site',
terminateSite: 'central.api.sites.terminate_site',
- // ── SSO open-in-bench (central.api.sso) ──
getBenchLink: 'central.api.sso.get_bench_link',
- // ── Billing catalog (central.billing.api.dashboard.catalog) ──
eligiblePlans: 'central.billing.api.dashboard.catalog.get_eligible_plans',
composedConfig: 'central.billing.api.dashboard.catalog.get_composed_config',
resizeComposedConfig:
'central.billing.api.dashboard.catalog.resize_composed_config',
resizeServer: 'central.billing.api.dashboard.catalog.resize_server',
- // ── Billing: reads (central.billing.api.dashboard.*, billing:view) ──
- // The dashboard package re-exports every submodule fn, so these flat paths are
- // stable regardless of which module (account/invoices/methods) owns them.
teamOverview: 'central.billing.api.dashboard.get_team_overview',
forecast: 'central.billing.api.dashboard.get_forecast',
trustTier: 'central.billing.api.dashboard.get_trust_tier',
@@ -117,7 +102,6 @@ export const API = {
notificationBadge: 'central.notification.api.notification_badge',
notificationPreferences: 'central.notification.api.get_user_preferences',
- // ── Billing: mutations (POST, billing:manage) ──
payInvoice: 'central.billing.api.dashboard.pay_invoice',
payInvoiceCheckout: 'central.billing.api.dashboard.pay_invoice_checkout',
confirmInvoiceCheckout:
diff --git a/dashboard/src/components/AddMethodDialog.vue b/dashboard/src/components/AddMethodDialog.vue
index ab937d68c..89c2b7b5d 100644
--- a/dashboard/src/components/AddMethodDialog.vue
+++ b/dashboard/src/components/AddMethodDialog.vue
@@ -221,9 +221,7 @@ watch(open, (isOpen) => {
:show-close-button="!stripeSubmitting"
>
- Couldn't load payment options.