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" > @@ -353,7 +350,6 @@ watch(open, (isOpen) => {