diff --git a/backend/api-files/openapi.yaml b/backend/api-files/openapi.yaml index 599ec82..f86e66e 100644 --- a/backend/api-files/openapi.yaml +++ b/backend/api-files/openapi.yaml @@ -627,6 +627,54 @@ paths: schema: $ref: "#/components/schemas/Error" + /api/auth/teammates/{userId}: + delete: + summary: Remove a teammate from your team + description: Removes a user from the team and automatically creates a new solo team for them. The removed user becomes an admin of their new team and can continue using Hopp. Admins cannot remove themselves. + security: + - BearerAuth: [] + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the user to remove from the team + responses: + "204": + description: Teammate removed successfully + "400": + description: Bad request - missing userId, cannot remove yourself, or cannot remove last admin + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Forbidden - user is not an admin or target user is not in your team + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: User not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/auth/billing/subscription: get: summary: Get current subscription status diff --git a/backend/internal/email/email.go b/backend/internal/email/email.go index d3f8d1d..725f24b 100644 --- a/backend/internal/email/email.go +++ b/backend/internal/email/email.go @@ -15,6 +15,7 @@ type EmailClient interface { SendAsync(toEmail, subject, htmlBody string) SendWelcomeEmail(user *models.User) SendTeamInvitationEmail(inviterName, teamName, inviteLink, toEmail string) + SendTeamRemovalEmail(user *models.User, oldTeamName, newTeamName string) SendSubscriptionConfirmationEmail(user *models.User) SendSubscriptionCancellationEmail(user *models.User) } @@ -110,6 +111,32 @@ func (c *ResendEmailClient) SendTeamInvitationEmail(inviterName, teamName, invit c.SendAsync(toEmail, subject, htmlBody) } +// SendTeamRemovalEmail sends an email to a user who has been removed from a team +func (c *ResendEmailClient) SendTeamRemovalEmail(user *models.User, oldTeamName, newTeamName string) { + if user == nil { + c.logger.Error("Cannot send team removal email to nil user") + return + } + + // Read the template file + templateBytes, err := os.ReadFile("web/emails/hopp-team-removed.html") + if err != nil { + c.logger.Errorf("Failed to read team removal email template: %v", err) + return + } + + replacer := strings.NewReplacer( + "{first_name}", user.FirstName, + "{old_team_name}", oldTeamName, + "{new_team_name}", newTeamName, + ) + htmlBody := replacer.Replace(string(templateBytes)) + + subject := fmt.Sprintf("Hopp: You've been removed from %s", oldTeamName) + + c.SendAsync(user.Email, subject, htmlBody) +} + // SendSubscriptionConfirmationEmail sends a subscription confirmation email func (c *ResendEmailClient) SendSubscriptionConfirmationEmail(user *models.User) { if user == nil { diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go index fd64a0e..45caee9 100644 --- a/backend/internal/handlers/handlers.go +++ b/backend/internal/handlers/handlers.go @@ -860,6 +860,96 @@ func (h *AuthHandler) ChangeTeam(c echo.Context) error { }) } +// RemoveTeammate removes a user from a team and creates a new solo team for them +// removed user will also receive an email notification +func (h *AuthHandler) RemoveTeammate(c echo.Context) error { + user, isAuthenticated := h.getAuthenticatedUserFromJWT(c) + if !isAuthenticated { + return c.String(http.StatusUnauthorized, "Unauthorized request") + } + + if user.TeamID == nil { + return echo.NewHTTPError(http.StatusBadRequest, "User is not part of any team") + } + + // Preload team to avoid extra query for email + if err := h.DB.Preload("Team").Where("id = ?", user.ID).First(user).Error; err != nil { + c.Logger().Error("Failed to load user team:", err) + return echo.NewHTTPError(http.StatusInternalServerError, "failed to load user") + } + + if user.Team == nil { + return echo.NewHTTPError(http.StatusBadRequest, "User team not found") + } + + teammateID := c.Param("userId") + if teammateID == "" { + return echo.NewHTTPError(http.StatusBadRequest, "userId is required") + } + + if user.ID == teammateID { + return echo.NewHTTPError(http.StatusBadRequest, "cannot remove yourself") + } + + if !user.IsAdmin { + return echo.NewHTTPError(http.StatusForbidden, "admin required") + } + + var teammate models.User + if err := h.DB.Select("id, team_id, is_admin, first_name, last_name, email").Where("id = ?", teammateID).First(&teammate).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "user not found") + } + return echo.NewHTTPError(http.StatusInternalServerError, "failed to load user") + } + + if teammate.TeamID == nil || *teammate.TeamID != *user.TeamID { + return echo.NewHTTPError(http.StatusForbidden, "user not in your team") + } + + oldTeamName := user.Team.Name + var newTeamName string + + if err := h.DB.Transaction(func(tx *gorm.DB) error { + + // Create new team + newTeamName := fmt.Sprintf("team-%s", uuid.NewString()[:8]) + newTeam := models.Team{ + Name: newTeamName, + } + if err := tx.Create(&newTeam).Error; err != nil { + return err + } + + // assign new team to removed user + if err := tx.Model(&models.User{}). + Where("id = ?", teammate.ID). + Updates(map[string]any{ + "team_id": newTeam.ID, + "is_admin": true, + }).Error; err != nil { + return err + } + + // Update subscription quantity if there is a subscription for the old team + if err := models.UpdateSubscriptionQuantity(tx, *user.TeamID); err != nil { + return err + } + + return nil + }); err != nil { + c.Logger().Error("RemoveTeammate error:", err) + return echo.NewHTTPError(http.StatusInternalServerError, "failed to remove teammate") + } + + // Send email to removed user + if h.EmailClient != nil { + h.EmailClient.SendTeamRemovalEmail(&teammate, oldTeamName, newTeamName) + } + + return c.NoContent(http.StatusNoContent) +} + // UnsubscribeUser handles both GET and POST requests for unsubscribing users. // Follows instructions from: // https://resend.com/docs/dashboard/emails/add-unsubscribe-to-transactional-emails diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index c5c4eee..6f85ac7 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -178,7 +178,7 @@ func (u *User) UnsubscribeFromAllEmails(db *gorm.DB) error { return db.Save(u).Error } -func updateSubscriptionQuantity(tx *gorm.DB, teamID uint) error { +func UpdateSubscriptionQuantity(tx *gorm.DB, teamID uint) error { teamMembers, err := GetTeamMembersByTeamID(tx, teamID) if err != nil { return fmt.Errorf("failed to get team members: %w", err) @@ -221,12 +221,12 @@ func updateSubscriptionQuantity(tx *gorm.DB, teamID uint) error { } func (u *User) AfterCreate(tx *gorm.DB) (err error) { - _ = updateSubscriptionQuantity(tx, *u.TeamID) + _ = UpdateSubscriptionQuantity(tx, *u.TeamID) return nil } func (u *User) AfterDelete(tx *gorm.DB) (err error) { - _ = updateSubscriptionQuantity(tx, *u.TeamID) + _ = UpdateSubscriptionQuantity(tx, *u.TeamID) return nil } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 1be01b0..1c8356f 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -286,6 +286,8 @@ func (s *Server) setupRoutes() { protectedAPI.GET("/user", auth.User) protectedAPI.PUT("/update-user-name", auth.UpdateName) protectedAPI.GET("/teammates", auth.Teammates) + protectedAPI.DELETE("/teammates/:userId", auth.RemoveTeammate) + protectedAPI.GET("/websocket", handlers.CreateWSHandler(&s.ServerState)) protectedAPI.GET("/get-invite-uuid", auth.GetInviteUUID) protectedAPI.POST("/change-team/:uuid", auth.ChangeTeam) diff --git a/backend/web/emails/hopp-team-removed.html b/backend/web/emails/hopp-team-removed.html new file mode 100644 index 0000000..c4bd305 --- /dev/null +++ b/backend/web/emails/hopp-team-removed.html @@ -0,0 +1,288 @@ + + + + + + + + + +
+ You've been removed from {old_team_name} and assigned to your personal team +
+  ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ +
+
+ + + + + + +
+ + + + + + +
+ Hopp Team Update +
+

+ Team Update +

+ + + + + + +
+ + + + + + + +
+ + + + Star us on GitHub
+
+

+ Hi + {first_name}, +

+

+ You've been removed from {old_team_name}. +

+

+ Don't worry! You've been automatically assigned to your new personal team + {new_team_name} as an admin. +

+ + + + + + +
+ Continue to Hopp +
+
+ + + + + + +
+

+ Hopp is build from 🇪🇺 by + Costa + and + Iason, a team of two engineers trying to + bring you the best remote pair programming experience. Thank you for supporting us ❤️ +

+
+
+ + + diff --git a/tauri/src/openapi.d.ts b/tauri/src/openapi.d.ts index ae70371..b28af60 100644 --- a/tauri/src/openapi.d.ts +++ b/tauri/src/openapi.d.ts @@ -971,6 +971,91 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/teammates/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Remove a teammate from your team + * @description Removes a user from the team and automatically creates a new solo team for them. The removed user becomes an admin of their new team and can continue using Hopp. Admins cannot remove themselves. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description UUID of the user to remove from the team */ + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Teammate removed successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad request - missing userId, cannot remove yourself, or cannot remove last admin */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden - user is not an admin or target user is not in your team */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/billing/subscription": { parameters: { query?: never; diff --git a/web-app/src/components/RemoveTeammateDialog.tsx b/web-app/src/components/RemoveTeammateDialog.tsx new file mode 100644 index 0000000..2414874 --- /dev/null +++ b/web-app/src/components/RemoveTeammateDialog.tsx @@ -0,0 +1,51 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Trash2 } from "lucide-react"; + +interface RemoveTeammateDialogProps { + teammate: { + id: string; + first_name: string; + last_name: string; + }; + onRemove: (teammateId: string) => Promise; + isPending?: boolean; +} + +export function RemoveTeammateDialog({ teammate, onRemove, isPending }: RemoveTeammateDialogProps) { + return ( + + + + + + + Remove Teammate + + Are you sure you want to remove {teammate.first_name} {teammate.last_name} from your team? This action + cannot be undone. + + + + + + + + + + + ); +} diff --git a/web-app/src/components/ui/dialog.tsx b/web-app/src/components/ui/dialog.tsx index 4013583..bdfe998 100644 --- a/web-app/src/components/ui/dialog.tsx +++ b/web-app/src/components/ui/dialog.tsx @@ -36,7 +36,7 @@ const DialogContent = React.forwardRef< store.authToken); const { data: user } = useQuery("get", "/api/auth/user", undefined, { @@ -12,11 +14,26 @@ export function Teammates() { select: (data) => data, }); - const { data: teammates } = useQuery("get", "/api/auth/teammates", undefined, { + const { data: teammates, refetch: refetchTeammates } = useQuery("get", "/api/auth/teammates", undefined, { queryHash: `teammates-${authToken}`, select: (data) => data, }); + const removeTeammateMutation = useMutation("delete", "/api/auth/teammates/{userId}"); + + const handleRemoveTeammate = async (teammateId: string) => { + try { + await removeTeammateMutation.mutateAsync({ + params: { path: { userId: teammateId } }, + }); + await refetchTeammates(); + toast.success("Teammate removed successfully"); + } catch (error) { + console.error("Failed to remove teammate:", error); + toast.error("Failed to remove teammate"); + } + }; + // Combine current user with teammates const allMembers = user ? [user, ...(teammates || [])] : teammates || []; @@ -32,7 +49,7 @@ export function Teammates() { firstName={member.first_name} lastName={member.last_name} /> -
+
{member.first_name} {member.last_name} @@ -40,11 +57,16 @@ export function Teammates() {
{member.email}
- {member.is_admin && ( - - Admin - - )} +
+ {member.is_admin && Admin} + {member.id !== user?.id && user?.is_admin && ( + + )} +
))}