diff --git a/buzz/api/teams/__init__.py b/buzz/api/teams/__init__.py
index 7bc2885c..7f31d627 100644
--- a/buzz/api/teams/__init__.py
+++ b/buzz/api/teams/__init__.py
@@ -1,4 +1,5 @@
import frappe
+from frappe.rate_limiter import rate_limit
from buzz.api.teams import invitations, services
from buzz.api.teams.schemas import InviteOutcome, TeamOption, TeamOverview
@@ -39,3 +40,11 @@ def remove_member(team: str, user: str) -> None:
@frappe.whitelist(methods=["POST"])
def invite_members(team: str, invites: list[dict]) -> list[InviteOutcome]:
return invitations.invite_members(team, invites)
+
+
+# Rate limited by IP: the limiter buckets on `frappe.form_dict.cmd`, which only the
+# /api/method path sets, so this endpoint is called from there rather than over v2.
+@frappe.whitelist(methods=["POST"])
+@rate_limit(limit=5, seconds=60 * 60)
+def create_team(team_name: str, logo: str | None = None) -> TeamOption:
+ return services.create_team(team_name, logo)
diff --git a/buzz/api/teams/services.py b/buzz/api/teams/services.py
index c30df12f..8518def8 100644
--- a/buzz/api/teams/services.py
+++ b/buzz/api/teams/services.py
@@ -2,7 +2,7 @@
from frappe.query_builder import Case
from buzz.api.teams.exceptions import CannotManageMembers, NotATeamMember
-from buzz.api.teams.schemas import TeamInvite, TeamMember, TeamOverview
+from buzz.api.teams.schemas import TeamInvite, TeamMember, TeamOption, TeamOverview
from buzz.permissions import can_manage_members, team_role_of
TEAM_FIELDS = ("name", "team_name", "slug", "logo")
@@ -86,3 +86,16 @@ def remove_member(team: str, user: str) -> None:
# Event Manager holds no write permission on the membership doctype, so the guard above
# is the authorization — the same shape as the Desk add-members flow.
membership.save(ignore_permissions=True)
+
+
+def create_team(team_name: str, logo: str | None = None) -> TeamOption:
+ """Start a team with the session user as its Owner.
+
+ Inserted past permissions like the reads above: Buzz Team's create right belongs to
+ System Manager, while any signed-in user may start a team of their own. The controller
+ derives the slug and lays down the Owner membership and the team settings.
+ """
+ team = frappe.get_doc({"doctype": "Buzz Team", "team_name": team_name, "logo": logo})
+ team.insert(ignore_permissions=True)
+
+ return TeamOption(name=team.name, team_name=team.team_name, logo=team.logo, team_role="Owner")
diff --git a/buzz/api/teams/test_teams.py b/buzz/api/teams/test_teams.py
index 72c75f8a..b619a90d 100644
--- a/buzz/api/teams/test_teams.py
+++ b/buzz/api/teams/test_teams.py
@@ -1,7 +1,7 @@
import frappe
from frappe.tests import IntegrationTestCase
-from buzz.api.teams import get_my_teams, get_team_overview, remove_member
+from buzz.api.teams import create_team, get_my_teams, get_team_overview, remove_member
from buzz.api.teams.exceptions import CannotManageMembers, NotATeamMember
from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user
from buzz.events.doctype.buzz_team_membership.buzz_team_membership import upsert_membership
@@ -212,3 +212,51 @@ def test_the_desk_role_survives_while_another_team_still_earns_it(self):
remove_member(second, member)
self.assertNotIn("Event Manager", frappe.get_roles(member))
+
+
+class TestCreateTeam(IntegrationTestCase):
+ def setUp(self):
+ frappe.set_user("Administrator")
+ self.addCleanup(frappe.set_user, "Administrator")
+
+ def test_makes_the_creator_the_owner(self):
+ user = create_user("create-team-owner@example.com", "Founder")
+
+ frappe.set_user(user)
+ option = create_team("Create Team Owned")
+
+ self.assertEqual(option.team_name, "Create Team Owned")
+ self.assertEqual(option.team_role, "Owner")
+ self.assertTrue(
+ frappe.db.exists(
+ "Buzz Team Membership",
+ {"team": option.name, "user": user, "team_role": "Owner", "enabled": 1},
+ )
+ )
+ self.assertTrue(frappe.db.exists("Buzz Team Settings", {"team": option.name}))
+
+ def test_appears_in_the_creators_teams(self):
+ user = create_user("create-team-switcher@example.com", "Founder")
+
+ frappe.set_user(user)
+ option = create_team("Create Team Switcher")
+
+ self.assertIn(option.name, [team.name for team in get_my_teams()])
+
+ def test_a_repeated_name_gets_a_team_of_its_own(self):
+ user = create_user("create-team-twice@example.com", "Founder")
+
+ frappe.set_user(user)
+ first = create_team("Create Team Twice")
+ second = create_team("Create Team Twice")
+
+ self.assertNotEqual(first.name, second.name)
+ slugs = frappe.get_all("Buzz Team", filters={"name": ["in", [first.name, second.name]]}, pluck="slug")
+ self.assertEqual(len(set(slugs)), 2)
+
+ def test_refuses_a_nameless_team(self):
+ user = create_user("create-team-nameless@example.com", "Founder")
+
+ frappe.set_user(user)
+ with self.assertRaises(frappe.MandatoryError):
+ create_team("")
diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts
index 913c2b24..3d146748 100644
--- a/dashboard/components.d.ts
+++ b/dashboard/components.d.ts
@@ -26,6 +26,7 @@ declare module 'vue' {
BuzzLogo: typeof import('./src/components/common/BuzzLogo.vue')['default']
CancellationRequestDialog: typeof import('./src/components/CancellationRequestDialog.vue')['default']
CancellationRequestNotice: typeof import('./src/components/CancellationRequestNotice.vue')['default']
+ CreateTeamDialog: typeof import('./src/components/dashboard/teams/CreateTeamDialog.vue')['default']
CustomFieldInput: typeof import('./src/components/CustomFieldInput.vue')['default']
CustomFieldsSection: typeof import('./src/components/CustomFieldsSection.vue')['default']
EventBanner: typeof import('./src/components/dashboard/events/EventBanner.vue')['default']
@@ -43,6 +44,7 @@ declare module 'vue' {
FormFieldSections: typeof import('./src/components/FormFieldSections.vue')['default']
GuestRegistrationDialog: typeof import('./src/components/dashboard/events/GuestRegistrationDialog.vue')['default']
LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default']
+ LoadingPanel: typeof import('./src/components/common/LoadingPanel.vue')['default']
LoginDialog: typeof import('./src/components/LoginDialog.vue')['default']
LoginRequired: typeof import('./src/components/LoginRequired.vue')['default']
Navbar: typeof import('./src/components/Navbar.vue')['default']
diff --git a/dashboard/src/components/TeamSwitcher.vue b/dashboard/src/components/TeamSwitcher.vue
index 91c02b98..05cd752b 100644
--- a/dashboard/src/components/TeamSwitcher.vue
+++ b/dashboard/src/components/TeamSwitcher.vue
@@ -1,10 +1,12 @@
@@ -89,6 +97,7 @@ function switchTeam(team: TeamOption, closePanel: () => void) {
+
+
diff --git a/dashboard/src/components/common/LoadingPanel.vue b/dashboard/src/components/common/LoadingPanel.vue
new file mode 100644
index 00000000..cc7e872f
--- /dev/null
+++ b/dashboard/src/components/common/LoadingPanel.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
{{ text }}
+
+
diff --git a/dashboard/src/components/dashboard/teams/CreateTeamDialog.vue b/dashboard/src/components/dashboard/teams/CreateTeamDialog.vue
new file mode 100644
index 00000000..33b31a37
--- /dev/null
+++ b/dashboard/src/components/dashboard/teams/CreateTeamDialog.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
diff --git a/dashboard/src/data/teams.ts b/dashboard/src/data/teams.ts
index 70560d3e..401cf6db 100644
--- a/dashboard/src/data/teams.ts
+++ b/dashboard/src/data/teams.ts
@@ -51,10 +51,26 @@ const teamOverview = useCall({
* first fetch, so `loading` is true before the page paints.
*/
export function useTeamOverview() {
- watch(currentTeam, (team) => team && teamOverview.reload(), { immediate: true })
+ // Watched by name rather than by the object: every refresh of the teams list builds
+ // fresh TeamOption objects, so watching `currentTeam` itself re-fired on the same team
+ // and the second request aborted the first one mid-flight.
+ watch(selectedTeamName, (team) => team && teamOverview.reload(), { immediate: true })
return teamOverview
}
+/**
+ * The overview's error, minus the ones no one should see.
+ *
+ * A superseded request is aborted, and the rejection lands in `error` after the newer
+ * request has already succeeded — "signal is aborted without reason" over a page whose
+ * data is sitting right there.
+ */
+export const teamOverviewError = computed(() =>
+ teamOverview.error && teamOverview.error.name !== "AbortError"
+ ? teamOverview.error.message
+ : ""
+)
+
export const removeMember = createResource({
url: "buzz.api.teams.remove_member",
})
@@ -63,6 +79,32 @@ export const inviteMembers = createResource({
url: "buzz.api.teams.invite_members",
})
+// The endpoint is rate limited, and the limiter buckets on `cmd`, which only the
+// /api/method path sets — hence createResource here rather than a v2 useCall.
+export const createTeam = createResource({
+ url: "buzz.api.teams.create_team",
+})
+
+/**
+ * Create a team and switch to it. Resolves once it is the selected team.
+ *
+ * The selection happens here rather than in an `onSuccess`, which createResource does
+ * not await: the caller would navigate first, the team page would load the old team,
+ * and the switch that followed would abort that request mid-flight — leaving "signal is
+ * aborted without reason" on a page whose data had already arrived.
+ */
+export async function createAndSelectTeam(params: {
+ team_name: string
+ logo: string | null
+}): Promise {
+ const team = (await createTeam.submit(params)) as TeamOption | null
+ if (createTeam.error || !team) return null
+
+ await teamsResource.reload()
+ selectTeam(team.name)
+ return team
+}
+
export function selectTeam(name: string) {
selectedTeamName.value = name
localStorage.setItem(STORAGE_KEY, name)
diff --git a/dashboard/src/layouts/ManagerLayout.vue b/dashboard/src/layouts/ManagerLayout.vue
index 5a8aab69..f972889f 100644
--- a/dashboard/src/layouts/ManagerLayout.vue
+++ b/dashboard/src/layouts/ManagerLayout.vue
@@ -1,4 +1,5 @@