Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4e3995c
fix(ui): settings description line height
siduck Sep 6, 2026
8d911ad
refactor(ui): shorten settings sidebar labels
siduck Sep 6, 2026
7e44a2e
feat(ui): theme switcher with preview cards
siduck Sep 6, 2026
7201e83
fix(ui): settings dialog spacing and typography
siduck Sep 6, 2026
c0fb1cf
chore(ui): remove useless comment for profileform pfp uploader
siduck Sep 6, 2026
3c6c774
fix(ui): remove useless padding in settingsDialog teams form
siduck Sep 6, 2026
709fe15
fix(ui): make appearanceForm responsive
siduck Sep 7, 2026
fc15fc4
refactor(ui): move shortcuts to frappe-ui v1 keyboard api
siduck Sep 7, 2026
1ae2bd6
refactor(ui): team switcher out of settings, preferences tab
siduck Sep 7, 2026
b6d2f9f
feat(ui): table component
siduck Sep 8, 2026
1c55820
fix(ui): darker dialog surface
siduck Sep 8, 2026
7383a31
feat(ui): leave team from the switcher
siduck Sep 8, 2026
2aca017
fix(ui): tint the settings sidebar in dark mode only
siduck Sep 8, 2026
7bbbf13
feat(ui): upload and delete controls for photo and logo
siduck Sep 8, 2026
a6911b4
feat(teams): let a member leave a team
siduck Sep 8, 2026
71d8900
feat(teams): return role, member count and created in my_teams
siduck Sep 8, 2026
acfbf38
fix(ui): Temporarily add settingsDialog styles override for now
siduck Sep 8, 2026
4f276b1
refactor(ui): drop props that repeat frappe-ui defaults
siduck Sep 8, 2026
d430eb9
refactor(ui): drop wrapper divs around single elements
siduck Sep 9, 2026
257514f
refactor(ui): responsive page padding and fewer layout wrappers
siduck Sep 10, 2026
2b02649
fix(teams): gate leave_team on team membership
siduck Sep 11, 2026
36aad38
fix(teams): scope the leave bypass to self-removal
siduck Sep 11, 2026
0cc0ebf
test(teams): leaving twice raises a permission error
siduck Sep 11, 2026
7735fb2
Merge pull request #317 from frappe/refactor-ui
siduck Sep 13, 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
62 changes: 51 additions & 11 deletions central/api/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 []
Expand All @@ -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")
Expand All @@ -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"])
Expand Down
7 changes: 7 additions & 0 deletions central/api/teams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +240 to +241

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Team input lacks validation

The new whitelisted endpoint passes the client-controlled team value into membership and document lookups without confirming it is a string. This violates the repository directive to explicitly validate input types at every whitelisted trust boundary and can allow complex values to reach Frappe’s ORM.

How this was verified: The endpoint forwards team into membership and document lookups without an explicit string check.

Context Used: Guidelines for reviewing Frappe Framework applications. (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: central/api/teams.py
Line: 240-241

Comment:
**Team input lacks validation**

The new whitelisted endpoint passes the client-controlled `team` value into membership and document lookups without confirming it is a string. This violates the repository directive to explicitly validate input types at every whitelisted trust boundary and can allow complex values to reach Frappe’s ORM.

**How this was verified:** The endpoint forwards `team` into membership and document lookups without an explicit string check.

**Context Used:** Guidelines for reviewing Frappe Framework applications. ([source](https://github.com/frappe/skills/blob/main/skills/quality-code-review/SKILL.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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:
Expand Down
31 changes: 30 additions & 1 deletion central/central/doctype/team/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down
43 changes: 42 additions & 1 deletion central/tests/test_team_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
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,
decline_invitation,
delete_custom_role,
delete_team,
invite_team_member,
leave_team,
list_team_invitations,
rename_team,
resend_invitation,
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 1 addition & 17 deletions dashboard/src/api/methods.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 2 additions & 6 deletions dashboard/src/components/AddMethodDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,7 @@ watch(open, (isOpen) => {
:show-close-button="!stripeSubmitting"
>
<template #default>
<div v-if="options.loading && !options.data" class="space-y-2">
<LoadingText :lines="3" />
</div>
<LoadingText v-if="options.loading && !options.data" :lines="3" />

<!-- Stripe card entry: Element renders inside the iframe Stripe hosts. -->
<div v-else-if="stripeMode" class="space-y-3">
Expand Down Expand Up @@ -310,7 +308,6 @@ watch(open, (isOpen) => {
>
<FormControl
v-model="phone"
type="text"
label="Phone number"
placeholder="Mobile number"
description="A recurring card on this rail needs a contact number. Saved to your billing profile."
Expand All @@ -330,7 +327,7 @@ watch(open, (isOpen) => {

<div v-else class="space-y-3">
<p class="text-p-sm text-ink-gray-5">Couldn't load payment options.</p>
<Button variant="subtle" label="Retry" @click="options.reload()" />
<Button label="Retry" @click="options.reload()" />
</div>
</template>

Expand All @@ -353,7 +350,6 @@ watch(open, (isOpen) => {
<div v-else class="flex items-center gap-2">
<Button
v-if="options.data?.note"
variant="subtle"
label="Add credit"
@click="goToTopup"
/>
Expand Down
1 change: 0 additions & 1 deletion dashboard/src/components/TopupDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,6 @@ watch(open, (isOpen) => {
<Button
class="ml-auto"
variant="ghost"
size="sm"
label="Change"
@click="fixed = false"
/>
Expand Down
7 changes: 0 additions & 7 deletions dashboard/src/components/addons/AIApiKeys.vue
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,6 @@ const confirmRevoke = async (): Promise<void> => {

<Button
v-if="canManage"
variant="subtle"
size="sm"
label="Generate key"
icon-left="lucide-plus"
class="shrink-0"
Expand Down Expand Up @@ -200,7 +198,6 @@ const confirmRevoke = async (): Promise<void> => {
>
<Badge
:theme="key.status === 'Active' ? 'green' : 'gray'"
variant="subtle"
size="sm"
:label="key.status"
/>
Expand Down Expand Up @@ -245,8 +242,6 @@ const confirmRevoke = async (): Promise<void> => {
>
<template v-if="canManage" #action>
<Button
variant="subtle"
size="sm"
label="Generate key"
icon-left="lucide-plus"
@click="openGenerate"
Expand Down Expand Up @@ -350,7 +345,6 @@ const confirmRevoke = async (): Promise<void> => {
v-if="models.length"
v-model="selectedModel"
:options="modelOptions"
size="sm"
variant="outline"
/>
</div>
Expand All @@ -364,7 +358,6 @@ const confirmRevoke = async (): Promise<void> => {

<Button
icon="lucide-copy"
size="sm"
class="sticky top-0 right-0 ml-auto"
label="Copy command"
@click="copyCurl"
Expand Down
Loading
Loading