- Introduce `YaoAuthSource` and `OAuthEmail` fields in various structures to capture authentication source and OAuth email during user login and registration processes. - Update `SignIDToken`, `GetInfo`, and `Map` functions to include new claims for Yao authentication source. - Modify login context to preserve authentication source and OAuth email across different user flows. - Enhance token issuance to include authentication source and OAuth email in claims for better tracking and user context. - Ensure proper handling of OAuth email for third-party logins without affecting user profile email. This change improves the user experience by providing clearer context on authentication methods used during login and registration processes.
1645 lines
54 KiB
Go
1645 lines
54 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/yaoapp/gou/model"
|
|
"github.com/yaoapp/gou/process"
|
|
"github.com/yaoapp/kun/exception"
|
|
"github.com/yaoapp/kun/log"
|
|
"github.com/yaoapp/kun/maps"
|
|
"github.com/yaoapp/yao/attachment"
|
|
"github.com/yaoapp/yao/messenger"
|
|
messengertypes "github.com/yaoapp/yao/messenger/types"
|
|
"github.com/yaoapp/yao/openapi/oauth"
|
|
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
|
"github.com/yaoapp/yao/openapi/response"
|
|
"github.com/yaoapp/yao/openapi/utils"
|
|
"github.com/yaoapp/yao/share"
|
|
)
|
|
|
|
// User Team Invitation Management Handlers
|
|
|
|
// GinTeamInvitationList handles GET /teams/:team_id/invitations - Get team invitations
|
|
func GinTeamInvitationList(c *gin.Context) {
|
|
// Get authorized user info
|
|
authInfo := oauth.GetAuthorizedInfo(c)
|
|
if authInfo == nil || authInfo.UserID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidClient.Code,
|
|
ErrorDescription: "User not authenticated",
|
|
}
|
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
|
return
|
|
}
|
|
|
|
teamID := c.Param("id")
|
|
if teamID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team ID is required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Parse pagination parameters
|
|
page := 1
|
|
pagesize := 20
|
|
|
|
if p := c.Query("page"); p != "" {
|
|
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
|
page = parsed
|
|
}
|
|
}
|
|
|
|
if ps := c.Query("pagesize"); ps != "" {
|
|
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 {
|
|
pagesize = parsed
|
|
}
|
|
}
|
|
|
|
// Call business logic
|
|
result, err := teamInvitationList(c.Request.Context(), authInfo.UserID, teamID, page, pagesize, c.Query("status"))
|
|
if err != nil {
|
|
log.Error("Failed to get team invitations: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team not found",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else if strings.Contains(err.Error(), "access denied") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrAccessDenied.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to retrieve team invitations",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Return the paginated result
|
|
response.RespondWithSuccess(c, http.StatusOK, result)
|
|
}
|
|
|
|
// GinTeamInvitationGetPublic handles GET /user/teams/invitations/:invitation_id - Get invitation details (public)
|
|
// This is a public endpoint that doesn't require authentication
|
|
// Supports ?locale=zh-CN query parameter for internationalization
|
|
func GinTeamInvitationGetPublic(c *gin.Context) {
|
|
invitationID := c.Param("invitation_id")
|
|
if invitationID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation ID is required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Get locale from query parameter or Accept-Language header
|
|
locale := c.Query("locale")
|
|
if locale == "" {
|
|
// Try to get from Accept-Language header
|
|
acceptLang := c.GetHeader("Accept-Language")
|
|
if acceptLang != "" {
|
|
// Parse Accept-Language header (e.g., "zh-CN,zh;q=0.9,en;q=0.8")
|
|
parts := strings.Split(acceptLang, ",")
|
|
if len(parts) > 0 {
|
|
langParts := strings.Split(parts[0], ";")
|
|
if len(langParts) > 0 {
|
|
locale = strings.TrimSpace(langParts[0])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Default to "en" if no locale specified
|
|
if locale == "" {
|
|
locale = "en"
|
|
}
|
|
|
|
// Call business logic (no user authentication required for public access)
|
|
publicInvitation, err := teamInvitationGetPublic(c.Request.Context(), invitationID, locale)
|
|
if err != nil {
|
|
log.Error("Failed to get invitation details: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "expired") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation not found or expired",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to retrieve invitation details",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
response.RespondWithSuccess(c, http.StatusOK, publicInvitation)
|
|
}
|
|
|
|
// GinTeamInvitationGet handles GET /teams/:team_id/invitations/:invitation_id - Get invitation details (admin only)
|
|
func GinTeamInvitationGet(c *gin.Context) {
|
|
// Get authorized user info
|
|
authInfo := oauth.GetAuthorizedInfo(c)
|
|
if authInfo == nil || authInfo.UserID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidClient.Code,
|
|
ErrorDescription: "User not authenticated",
|
|
}
|
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
|
return
|
|
}
|
|
|
|
teamID := c.Param("id")
|
|
invitationID := c.Param("invitation_id")
|
|
if teamID == "" || invitationID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team ID and Invitation ID are required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Extract base URL from request
|
|
requestBaseURL := getRequestBaseURL(c)
|
|
|
|
// Call business logic
|
|
invitationData, err := teamInvitationGet(c.Request.Context(), authInfo.UserID, teamID, invitationID)
|
|
if err != nil {
|
|
log.Error("Failed to get invitation details: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation not found",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else if strings.Contains(err.Error(), "access denied") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrAccessDenied.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to retrieve invitation details",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Convert to response format (with requestBaseURL for building full invitation link)
|
|
invitation := mapToTeamInvitationDetailResponse(invitationData, requestBaseURL)
|
|
response.RespondWithSuccess(c, http.StatusOK, invitation)
|
|
}
|
|
|
|
// GinTeamInvitationCreate handles POST /teams/:team_id/invitations - Send team invitation
|
|
func GinTeamInvitationCreate(c *gin.Context) {
|
|
// Get authorized user info
|
|
authInfo := authorized.GetInfo(c)
|
|
if authInfo.Constraints.OwnerOnly || authInfo.Constraints.TeamOnly {
|
|
if authInfo == nil || authInfo.UserID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidClient.Code,
|
|
ErrorDescription: "User not authenticated",
|
|
}
|
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
|
return
|
|
}
|
|
}
|
|
|
|
teamID := c.Param("id")
|
|
if teamID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team ID is required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var req CreateInvitationRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invalid request body: " + err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Extract base URL from request
|
|
requestBaseURL := getRequestBaseURL(c)
|
|
|
|
// Prepare invitation data
|
|
invitationData := authInfo.WithCreateScope(maps.MapStrAny{
|
|
"user_id": req.UserID,
|
|
"email": req.Email,
|
|
"member_type": req.MemberType,
|
|
"role_id": req.RoleID,
|
|
"message": req.Message,
|
|
"expiry": req.Expiry,
|
|
"request_base_url": requestBaseURL,
|
|
})
|
|
|
|
// Prepare settings
|
|
settings := &InvitationSettings{}
|
|
if req.Settings != nil {
|
|
settings = req.Settings
|
|
}
|
|
|
|
// Add send_email from top-level field (for backward compatibility)
|
|
if req.SendEmail != nil {
|
|
settings.SendEmail = *req.SendEmail
|
|
}
|
|
|
|
// Add locale from top-level field (for backward compatibility)
|
|
if req.Locale != "" {
|
|
settings.Locale = req.Locale
|
|
}
|
|
|
|
// Add settings to invitation data
|
|
if settings.SendEmail || settings.Locale != "" {
|
|
invitationData["settings"] = settings
|
|
}
|
|
|
|
// Call business logic
|
|
invitationID, err := teamInvitationCreate(c.Request.Context(), authInfo.UserID, teamID, invitationData)
|
|
if err != nil {
|
|
log.Error("Failed to create invitation: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team not found",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else if strings.Contains(err.Error(), "access denied") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrAccessDenied.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
} else if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "already invited") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusConflict, errorResp)
|
|
} else if strings.Contains(err.Error(), "email is required") || strings.Contains(err.Error(), "is required") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to send invitation",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Get the created invitation to return complete data
|
|
invitation, err := teamInvitationGet(c.Request.Context(), authInfo.UserID, teamID, invitationID)
|
|
if err != nil {
|
|
log.Error("Failed to retrieve created invitation: %v", err)
|
|
// Fallback to returning just the ID if retrieval fails
|
|
response.RespondWithSuccess(c, http.StatusCreated, gin.H{"invitation_id": invitationID})
|
|
return
|
|
}
|
|
|
|
// Convert to InvitationResponse (with requestBaseURL for building full invitation link)
|
|
invitationResp := convertToTeamInvitationResponse(invitation, requestBaseURL)
|
|
|
|
// Return created invitation with full details (including token)
|
|
response.RespondWithSuccess(c, http.StatusCreated, invitationResp)
|
|
}
|
|
|
|
// GinTeamInvitationResend handles PUT /teams/:team_id/invitations/:invitation_id/resend - Resend invitation
|
|
func GinTeamInvitationResend(c *gin.Context) {
|
|
// Get authorized user info
|
|
authInfo := oauth.GetAuthorizedInfo(c)
|
|
if authInfo == nil || authInfo.UserID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidClient.Code,
|
|
ErrorDescription: "User not authenticated",
|
|
}
|
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
|
return
|
|
}
|
|
|
|
teamID := c.Param("id")
|
|
invitationID := c.Param("invitation_id")
|
|
if teamID == "" || invitationID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team ID and Invitation ID are required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Parse request body for locale
|
|
var requestBody struct {
|
|
Locale string `json:"locale"`
|
|
}
|
|
if err := c.ShouldBindJSON(&requestBody); err != nil {
|
|
// If no body or invalid JSON, try query parameter as fallback
|
|
requestBody.Locale = c.Query("locale")
|
|
}
|
|
|
|
// Get locale from request body or query parameter, default to "en"
|
|
locale := requestBody.Locale
|
|
if locale == "" {
|
|
locale = c.Query("locale")
|
|
}
|
|
if locale == "" {
|
|
locale = "en"
|
|
}
|
|
|
|
// Get request base URL for invitation link generation
|
|
requestBaseURL := getRequestBaseURL(c)
|
|
|
|
// Call business logic
|
|
err := teamInvitationResend(c.Request.Context(), authInfo.UserID, teamID, invitationID, requestBaseURL, locale)
|
|
if err != nil {
|
|
log.Error("Failed to resend invitation: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation not found",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else if strings.Contains(err.Error(), "access denied") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrAccessDenied.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
} else if strings.Contains(err.Error(), "already accepted") || strings.Contains(err.Error(), "invalid status") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to resend invitation",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Invitation resent successfully"})
|
|
}
|
|
|
|
// GinTeamInvitationDelete handles DELETE /teams/:team_id/invitations/:invitation_id - Cancel invitation
|
|
func GinTeamInvitationDelete(c *gin.Context) {
|
|
// Get authorized user info
|
|
authInfo := oauth.GetAuthorizedInfo(c)
|
|
if authInfo == nil || authInfo.UserID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidClient.Code,
|
|
ErrorDescription: "User not authenticated",
|
|
}
|
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
|
return
|
|
}
|
|
|
|
teamID := c.Param("id")
|
|
invitationID := c.Param("invitation_id")
|
|
if teamID == "" || invitationID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Team ID and Invitation ID are required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Call business logic
|
|
err := teamInvitationDelete(c.Request.Context(), authInfo.UserID, teamID, invitationID)
|
|
if err != nil {
|
|
log.Error("Failed to cancel invitation: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation not found",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else if strings.Contains(err.Error(), "access denied") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrAccessDenied.Code,
|
|
ErrorDescription: err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to cancel invitation",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Invitation cancelled successfully"})
|
|
}
|
|
|
|
// GinTeamInvitationAccept handles POST /user/teams/invitations/:invitation_id/accept - Accept invitation and login to team
|
|
func GinTeamInvitationAccept(c *gin.Context) {
|
|
// Get authorized user info
|
|
authInfo := authorized.GetInfo(c)
|
|
if authInfo == nil || authInfo.UserID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidClient.Code,
|
|
ErrorDescription: "User not authenticated",
|
|
}
|
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Use authInfo.UserID directly - it might be OAuth subject, but LoginByTeamID will handle user creation
|
|
userID := authInfo.UserID
|
|
|
|
invitationID := c.Param("invitation_id")
|
|
if invitationID == "" {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation ID is required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Parse request body to get token
|
|
var req struct {
|
|
Token string `json:"token" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invalid request body: token is required",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
return
|
|
}
|
|
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
log.Error("Failed to get user provider: %v", err)
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to process invitation",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// Get invitation details first to retrieve team_id
|
|
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
|
|
if err != nil {
|
|
log.Error("Failed to get invitation: %v", err)
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation not found",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
return
|
|
}
|
|
|
|
// Get team_id from invitation
|
|
teamID := utils.ToString(invitationData["team_id"])
|
|
if teamID == "" {
|
|
log.Error("Invalid invitation: missing team_id")
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Invalid invitation data",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// Accept the invitation (will update user_id if invitation doesn't have one)
|
|
err = provider.AcceptInvitation(ctx, invitationID, req.Token, userID)
|
|
if err != nil {
|
|
log.Error("Failed to accept invitation: %v", err)
|
|
// Check error type for appropriate response
|
|
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "already accepted") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation not found or already accepted",
|
|
}
|
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
|
} else if strings.Contains(err.Error(), "expired") {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrInvalidRequest.Code,
|
|
ErrorDescription: "Invitation has expired",
|
|
}
|
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
|
} else {
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Failed to accept invitation",
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Prepare login context with full device/platform information
|
|
loginCtx := makeLoginContext(c)
|
|
|
|
// Login with the team that was just joined
|
|
// Note: userID must exist in database (user table)
|
|
loginResponse, err := LoginByTeamID(userID, teamID, loginCtx)
|
|
if err != nil {
|
|
log.Error("Failed to login with team: %v", err)
|
|
errorResp := &response.ErrorResponse{
|
|
Code: response.ErrServerError.Code,
|
|
ErrorDescription: "Invitation accepted but failed to login: " + err.Error(),
|
|
}
|
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
return
|
|
}
|
|
|
|
// Revoke the current token if it exists (similar to team selection)
|
|
currentToken := oauth.OAuth.GetAccessToken(c)
|
|
if currentToken != "" {
|
|
if err := oauth.OAuth.Revoke(ctx, currentToken, "access_token"); err != nil {
|
|
// Log the error but don't fail the request
|
|
log.Warn("Failed to revoke previous token: %v", err)
|
|
}
|
|
}
|
|
|
|
// Send secure cookies (access token, refresh token, and session ID)
|
|
SendLoginCookies(c, loginResponse, "")
|
|
|
|
// Return the new tokens in response body
|
|
response.RespondWithSuccess(c, http.StatusOK, loginResponse)
|
|
}
|
|
|
|
// Yao Process Handlers (for Yao application calls)
|
|
|
|
// ProcessTeamInvitationList user.team.invitation.list Team invitation list processor
|
|
// Args[0] string: team_id
|
|
// Args[1] map: Query parameters {"status": "pending", "page": 1, "pagesize": 20}
|
|
// Return: map: Paginated invitation list
|
|
func ProcessTeamInvitationList(process *process.Process) interface{} {
|
|
process.ValidateArgNums(2)
|
|
|
|
// Get user_id from session
|
|
userIDStr := GetUserIDFromSession(process)
|
|
|
|
teamID := process.ArgsString(0)
|
|
if teamID == "" {
|
|
exception.New("team_id is required", 400).Throw()
|
|
}
|
|
|
|
// Parse query parameters
|
|
queryMap := process.ArgsMap(1)
|
|
|
|
// Parse pagination
|
|
page := 1
|
|
pagesize := 20
|
|
|
|
if p := int(utils.ToInt64(queryMap["page"])); p > 0 {
|
|
page = p
|
|
}
|
|
|
|
if ps := int(utils.ToInt64(queryMap["pagesize"])); ps > 0 && ps <= 100 {
|
|
pagesize = ps
|
|
}
|
|
|
|
// Get status filter
|
|
status := utils.ToString(queryMap["status"])
|
|
|
|
// Get context
|
|
ctx := process.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Call business logic
|
|
result, err := teamInvitationList(ctx, userIDStr, teamID, page, pagesize, status)
|
|
if err != nil {
|
|
exception.New("failed to list team invitations: %s", 500, err.Error()).Throw()
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ProcessTeamInvitationGet user.team.invitation.get Team invitation get processor
|
|
// Args[0] string: team_id
|
|
// Args[1] string: invitation_id
|
|
// Return: map: Invitation details
|
|
func ProcessTeamInvitationGet(process *process.Process) interface{} {
|
|
process.ValidateArgNums(2)
|
|
|
|
// Get user_id from session
|
|
userIDStr := GetUserIDFromSession(process)
|
|
|
|
teamID := process.ArgsString(0)
|
|
invitationID := process.ArgsString(1)
|
|
|
|
if teamID == "" || invitationID == "" {
|
|
exception.New("team_id and invitation_id are required", 400).Throw()
|
|
}
|
|
|
|
// Get context
|
|
ctx := process.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Call business logic
|
|
result, err := teamInvitationGet(ctx, userIDStr, teamID, invitationID)
|
|
if err != nil {
|
|
exception.New("failed to get team invitation: %s", 500, err.Error()).Throw()
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ProcessTeamInvitationCreate user.team.invitation.create Team invitation create processor
|
|
// Args[0] string: team_id
|
|
// Args[1] map: Invitation data {"user_id": "user123", "member_type": "user", "role_id": "member", "message": "...", "settings": {...}}
|
|
// Return: map: {"invitation_id": "created_invitation_id"}
|
|
func ProcessTeamInvitationCreate(process *process.Process) interface{} {
|
|
process.ValidateArgNums(2)
|
|
|
|
// Get user_id from session
|
|
userIDStr := GetUserIDFromSession(process)
|
|
|
|
teamID := process.ArgsString(0)
|
|
invitationData := maps.MapStrAny(process.ArgsMap(1))
|
|
|
|
if teamID == "" {
|
|
exception.New("team_id is required", 400).Throw()
|
|
}
|
|
|
|
// Validate required fields
|
|
if _, ok := invitationData["user_id"]; !ok {
|
|
exception.New("user_id is required", 400).Throw()
|
|
}
|
|
if _, ok := invitationData["role_id"]; !ok {
|
|
exception.New("role_id is required", 400).Throw()
|
|
}
|
|
|
|
// Get context
|
|
ctx := process.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Call business logic
|
|
invitationID, err := teamInvitationCreate(ctx, userIDStr, teamID, invitationData)
|
|
if err != nil {
|
|
exception.New("failed to create team invitation: %s", 500, err.Error()).Throw()
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"invitation_id": invitationID,
|
|
}
|
|
}
|
|
|
|
// ProcessTeamInvitationResend user.team.invitation.resend Team invitation resend processor
|
|
// Args[0] string: team_id
|
|
// Args[1] string: invitation_id
|
|
// Return: map: {"message": "success"}
|
|
func ProcessTeamInvitationResend(process *process.Process) interface{} {
|
|
process.ValidateArgNums(2)
|
|
|
|
// Get user_id from session
|
|
userIDStr := GetUserIDFromSession(process)
|
|
|
|
teamID := process.ArgsString(0)
|
|
invitationID := process.ArgsString(1)
|
|
|
|
if teamID == "" || invitationID == "" {
|
|
exception.New("team_id and invitation_id are required", 400).Throw()
|
|
}
|
|
|
|
// Get context
|
|
ctx := process.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Get locale from Args[2] if provided, default to "en"
|
|
locale := "en"
|
|
if process.NumOfArgsIs(3) {
|
|
locale = process.ArgsString(2)
|
|
}
|
|
|
|
// Call business logic (no requestBaseURL available in process context)
|
|
err := teamInvitationResend(ctx, userIDStr, teamID, invitationID, "", locale)
|
|
if err != nil {
|
|
exception.New("failed to resend team invitation: %s", 500, err.Error()).Throw()
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"message": "success",
|
|
}
|
|
}
|
|
|
|
// ProcessTeamInvitationDelete user.team.invitation.delete Team invitation delete processor
|
|
// Args[0] string: team_id
|
|
// Args[1] string: invitation_id
|
|
// Return: map: {"message": "success"}
|
|
func ProcessTeamInvitationDelete(process *process.Process) interface{} {
|
|
process.ValidateArgNums(2)
|
|
|
|
// Get user_id from session
|
|
userIDStr := GetUserIDFromSession(process)
|
|
|
|
teamID := process.ArgsString(0)
|
|
invitationID := process.ArgsString(1)
|
|
|
|
if teamID == "" || invitationID == "" {
|
|
exception.New("team_id and invitation_id are required", 400).Throw()
|
|
}
|
|
|
|
// Get context
|
|
ctx := process.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Call business logic
|
|
err := teamInvitationDelete(ctx, userIDStr, teamID, invitationID)
|
|
if err != nil {
|
|
exception.New("failed to delete team invitation: %s", 500, err.Error()).Throw()
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"message": "success",
|
|
}
|
|
}
|
|
|
|
// Private Business Logic Functions (internal use only)
|
|
|
|
// getAdminRoot returns the admin root path from share.App configuration
|
|
// Similar to service.setupAdminRoot but without caching to avoid circular dependencies
|
|
func getAdminRoot() string {
|
|
adminRoot := "/yao/"
|
|
if share.App.AdminRoot != "" {
|
|
root := strings.TrimPrefix(share.App.AdminRoot, "/")
|
|
root = strings.TrimSuffix(root, "/")
|
|
adminRoot = fmt.Sprintf("/%s/", root)
|
|
}
|
|
return adminRoot
|
|
}
|
|
|
|
// getRequestBaseURL extracts the base URL from the gin context request
|
|
// Returns: scheme://host (e.g., "https://example.com" or "http://localhost:8000")
|
|
func getRequestBaseURL(c *gin.Context) string {
|
|
if c == nil || c.Request == nil {
|
|
return ""
|
|
}
|
|
|
|
scheme := "http"
|
|
if c.Request.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
// Check X-Forwarded-Proto header
|
|
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
|
|
scheme = proto
|
|
}
|
|
|
|
host := c.Request.Host
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
|
|
return fmt.Sprintf("%s://%s", scheme, host)
|
|
}
|
|
|
|
// buildTeamInvitationLink constructs a full invitation link from invitation_id, token and team configuration
|
|
// This is a centralized function to ensure consistency across email sending and link generation
|
|
// Format:
|
|
// - With team config baseURL: {base_url}/{invitation_id}/{token}
|
|
// - With requestBaseURL (from HTTP request): {scheme}://{host}{AdminRoot}team/invite/{invitation_id}/{token}
|
|
// - Without any baseURL (fallback): {AdminRoot}team/invite/{invitation_id}/{token}
|
|
func buildTeamInvitationLink(invitationID, token string, teamConfig *TeamConfig, requestBaseURL string) string {
|
|
// Priority 1: Use team config baseURL if specified
|
|
if teamConfig != nil && teamConfig.Invite != nil && teamConfig.Invite.BaseURL != "" {
|
|
baseURL := teamConfig.Invite.BaseURL
|
|
// Ensure baseURL ends with /
|
|
if !strings.HasSuffix(baseURL, "/") {
|
|
baseURL = baseURL + "/"
|
|
}
|
|
return fmt.Sprintf("%s%s/%s", baseURL, invitationID, token)
|
|
}
|
|
|
|
// Get admin root from configuration
|
|
adminRoot := getAdminRoot()
|
|
// Ensure adminRoot doesn't end with / for URL construction
|
|
adminRoot = strings.TrimSuffix(adminRoot, "/")
|
|
|
|
// Priority 2: Use request baseURL with AdminRoot
|
|
if requestBaseURL != "" {
|
|
// Ensure requestBaseURL doesn't end with /
|
|
requestBaseURL = strings.TrimSuffix(requestBaseURL, "/")
|
|
return fmt.Sprintf("%s%s/team/invite/%s/%s", requestBaseURL, adminRoot, invitationID, token)
|
|
}
|
|
|
|
// Priority 3: Fallback to relative path with AdminRoot
|
|
return fmt.Sprintf("%s/team/invite/%s/%s", adminRoot, invitationID, token)
|
|
}
|
|
|
|
// teamInvitationList handles the business logic for listing team invitations
|
|
func teamInvitationList(ctx context.Context, userID, teamID string, page, pagesize int, status string) (maps.MapStr, error) {
|
|
// Check if user has access to the team (read permission: owner or member)
|
|
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Allow access if user is owner or member
|
|
if !isOwner && !isMember {
|
|
return nil, fmt.Errorf("access denied: user is not a member of this team")
|
|
}
|
|
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Build query parameters for pending invitations
|
|
param := model.QueryParam{
|
|
Wheres: []model.QueryWhere{
|
|
{Column: "team_id", Value: teamID},
|
|
{Column: "status", Value: "pending"}, // Only show pending invitations
|
|
},
|
|
Orders: []model.QueryOrder{
|
|
{Column: "invited_at", Option: "desc"},
|
|
{Column: "created_at", Option: "desc"},
|
|
},
|
|
}
|
|
|
|
// Add additional status filter if provided
|
|
if status != "" && status != "pending" {
|
|
// Replace the default pending status filter
|
|
param.Wheres[1] = model.QueryWhere{
|
|
Column: "status",
|
|
Value: status,
|
|
}
|
|
}
|
|
|
|
// Get paginated invitations (pending members)
|
|
result, err := provider.PaginateMembers(ctx, param, page, pagesize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve invitations: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// teamInvitationGetPublic handles the business logic for getting a specific team invitation (public access)
|
|
// This function doesn't require authentication and is used for invitation recipients
|
|
// locale parameter is used to get localized role labels
|
|
func teamInvitationGetPublic(ctx context.Context, invitationID, locale string) (*PublicInvitationResponse, error) {
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Get invitation details using invitation_id (business key)
|
|
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invitation not found: %w", err)
|
|
}
|
|
|
|
// Only return if it's a pending invitation
|
|
if utils.ToString(invitationData["status"]) != "pending" {
|
|
return nil, fmt.Errorf("invitation not found or no longer pending")
|
|
}
|
|
|
|
// Check if invitation has expired
|
|
expiresAt := invitationData["invitation_expires_at"]
|
|
if expiresAt != nil {
|
|
var expiryTime time.Time
|
|
switch v := expiresAt.(type) {
|
|
case time.Time:
|
|
expiryTime = v
|
|
case string:
|
|
parsed, err := time.Parse(time.RFC3339, v)
|
|
if err == nil {
|
|
expiryTime = parsed
|
|
}
|
|
}
|
|
if !expiryTime.IsZero() && time.Now().After(expiryTime) {
|
|
return nil, fmt.Errorf("invitation has expired")
|
|
}
|
|
}
|
|
|
|
// Get team information
|
|
teamID := utils.ToString(invitationData["team_id"])
|
|
team, err := provider.GetTeam(ctx, teamID)
|
|
if err != nil {
|
|
log.Warn("Failed to get team information: %v", err)
|
|
team = maps.MapStrAny{"name": "Team"}
|
|
}
|
|
|
|
// Get inviter information
|
|
inviterID := utils.ToString(invitationData["invited_by"])
|
|
var inviterInfo *InviterInfo
|
|
if inviterID != "" {
|
|
inviter, err := provider.GetUser(ctx, inviterID)
|
|
if err == nil {
|
|
inviterInfo = &InviterInfo{
|
|
UserID: inviterID,
|
|
Name: utils.ToString(inviter["name"]),
|
|
Picture: utils.ToString(inviter["picture"]),
|
|
}
|
|
// Fallback to masked email if name is empty (for privacy protection)
|
|
if inviterInfo.Name == "" {
|
|
inviterInfo.Name = MaskEmail(utils.ToString(inviter["email"]))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get role label from team config using provided locale
|
|
roleID := utils.ToString(invitationData["role_id"])
|
|
roleLabel := ""
|
|
teamConfig := GetTeamConfig(locale)
|
|
if teamConfig != nil && teamConfig.Roles != nil {
|
|
for _, role := range teamConfig.Roles {
|
|
if role.RoleID == roleID {
|
|
roleLabel = role.Label
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process team_logo if it's a wrapper - use Data URI format for direct display in img src
|
|
teamLogo := utils.ToString(team["logo"])
|
|
if teamLogo != "" {
|
|
teamLogo = attachment.Base64(ctx, teamLogo, true)
|
|
}
|
|
|
|
// Process inviter_info.picture if it's a wrapper - use Data URI format for direct display in img src
|
|
if inviterInfo != nil && inviterInfo.Picture != "" {
|
|
inviterInfo.Picture = attachment.Base64(ctx, inviterInfo.Picture, true)
|
|
}
|
|
|
|
// Build public response (exclude sensitive data like IDs)
|
|
publicResponse := &PublicInvitationResponse{
|
|
InvitationID: utils.ToString(invitationData["invitation_id"]),
|
|
TeamName: utils.ToString(team["name"]),
|
|
TeamLogo: teamLogo,
|
|
TeamDescription: utils.ToString(team["description"]),
|
|
RoleLabel: roleLabel,
|
|
Status: utils.ToString(invitationData["status"]),
|
|
InvitedAt: utils.ToTimeString(invitationData["invited_at"]),
|
|
InvitationExpiresAt: utils.ToTimeString(invitationData["invitation_expires_at"]),
|
|
Message: utils.ToString(invitationData["message"]),
|
|
InviterInfo: inviterInfo,
|
|
}
|
|
|
|
return publicResponse, nil
|
|
}
|
|
|
|
// teamInvitationGet handles the business logic for getting a specific team invitation (admin access)
|
|
func teamInvitationGet(ctx context.Context, userID, teamID, invitationID string) (maps.MapStrAny, error) {
|
|
// Check if user has access to the team (read permission: owner or member)
|
|
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Allow access if user is owner or member
|
|
if !isOwner && !isMember {
|
|
return nil, fmt.Errorf("access denied: user is not a member of this team")
|
|
}
|
|
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Get invitation details using invitation_id (business key)
|
|
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invitation not found: %w", err)
|
|
}
|
|
|
|
// Verify invitation belongs to this team
|
|
if utils.ToString(invitationData["team_id"]) != teamID {
|
|
return nil, fmt.Errorf("invitation not found in this team")
|
|
}
|
|
|
|
// Only return if it's a pending invitation
|
|
if utils.ToString(invitationData["status"]) != "pending" {
|
|
return nil, fmt.Errorf("invitation not found or no longer pending")
|
|
}
|
|
|
|
return invitationData, nil
|
|
}
|
|
|
|
// teamInvitationCreate handles the business logic for creating a team invitation
|
|
// Supports two scenarios:
|
|
// 1. Email invitation: provide email and role, send invitation link via email
|
|
// 2. Link invitation: create invitation link for display in frontend, customizable expiry
|
|
func teamInvitationCreate(ctx context.Context, userID, teamID string, invitationData maps.MapStrAny) (string, error) {
|
|
// Remove empty string fields (should not be inserted to database)
|
|
for _, field := range []string{"user_id", "email", "message", "display_name", "bio"} {
|
|
if invitationData[field] == "" {
|
|
delete(invitationData, field)
|
|
}
|
|
}
|
|
|
|
// Check if user has access to the team (write permission: owner only)
|
|
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Only allow access if user is owner
|
|
if !isOwner {
|
|
return "", fmt.Errorf("access denied: only team owner can send invitations")
|
|
}
|
|
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Get team information for email template
|
|
team, err := provider.GetTeam(ctx, teamID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get team information: %w", err)
|
|
}
|
|
teamName := utils.ToString(team["name"])
|
|
|
|
// Get inviter information for email template
|
|
inviter, err := provider.GetUser(ctx, userID)
|
|
if err != nil {
|
|
log.Warn("Failed to get inviter information: %v", err)
|
|
inviter = maps.MapStrAny{"name": "Team Admin"}
|
|
}
|
|
inviterName := utils.ToString(inviter["name"])
|
|
if inviterName == "" {
|
|
inviterName = utils.ToString(inviter["email"])
|
|
}
|
|
|
|
// Check if user is already a member or has pending invitation (if user_id is provided)
|
|
var inviteeUserID string
|
|
var inviteeEmail string
|
|
|
|
// Get email from invitation data first
|
|
inviteeEmail = utils.ToString(invitationData["email"])
|
|
|
|
if invitationData["user_id"] != nil && invitationData["user_id"] != "" {
|
|
inviteeUserID = utils.ToString(invitationData["user_id"])
|
|
exists, err := provider.MemberExists(ctx, teamID, inviteeUserID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to check member existence: %w", err)
|
|
}
|
|
if exists {
|
|
return "", fmt.Errorf("user is already a member or has a pending invitation")
|
|
}
|
|
|
|
// If email not provided, get it from user profile
|
|
if inviteeEmail == "" {
|
|
user, err := provider.GetUser(ctx, inviteeUserID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get user information: %w", err)
|
|
}
|
|
inviteeEmail = utils.ToString(user["email"])
|
|
|
|
// Update invitation data with email from user profile
|
|
if inviteeEmail != "" {
|
|
invitationData["email"] = inviteeEmail
|
|
}
|
|
}
|
|
} else {
|
|
// For invitations without user_id (general invitation link or unregistered users)
|
|
// Set user_id to nil (NULL in database)
|
|
invitationData["user_id"] = nil
|
|
}
|
|
|
|
// Check send_email requirement early
|
|
shouldSendEmail := false
|
|
if settings, ok := invitationData["settings"].(*InvitationSettings); ok && settings != nil {
|
|
shouldSendEmail = settings.SendEmail
|
|
} else if settingsMap, ok := invitationData["settings"].(map[string]interface{}); ok {
|
|
// Fallback for map format (for backward compatibility)
|
|
shouldSendEmail = utils.ToBool(settingsMap["send_email"])
|
|
}
|
|
|
|
// If send_email is true, email must be provided
|
|
if shouldSendEmail && inviteeEmail == "" {
|
|
return "", fmt.Errorf("email is required when send_email is true")
|
|
}
|
|
|
|
// Generate invitation token
|
|
token, err := generateTeamInvitationToken()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate invitation token: %w", err)
|
|
}
|
|
|
|
// Calculate expiry duration
|
|
expiryDuration, err := getTeamInvitationExpiry(invitationData)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse expiry duration: %w", err)
|
|
}
|
|
|
|
// Save request_base_url and settings before database operation (they will be lost in DB)
|
|
requestBaseURL := utils.ToString(invitationData["request_base_url"])
|
|
savedSettings := invitationData["settings"] // Save settings reference
|
|
|
|
// Set invitation-specific fields
|
|
invitationData["team_id"] = teamID
|
|
if invitationData["member_type"] == nil || invitationData["member_type"] == "" {
|
|
invitationData["member_type"] = "user"
|
|
}
|
|
invitationData["status"] = "pending"
|
|
invitationData["invited_by"] = userID
|
|
invitationData["invited_at"] = time.Now()
|
|
invitationData["invitation_token"] = token
|
|
invitationData["invitation_expires_at"] = time.Now().Add(expiryDuration)
|
|
invitationData["created_at"] = time.Now()
|
|
invitationData["updated_at"] = time.Now()
|
|
|
|
// Create invitation (as a pending member)
|
|
businessMemberID, err := provider.CreateMember(ctx, invitationData)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create invitation: %w", err)
|
|
}
|
|
|
|
// Get the created member to retrieve the generated invitation_id
|
|
createdMember, err := provider.GetMemberByMemberID(ctx, businessMemberID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to retrieve created invitation: %w", err)
|
|
}
|
|
|
|
// Get the generated invitation_id
|
|
invitationID := utils.ToString(createdMember["invitation_id"])
|
|
|
|
// Send email if requested (shouldSendEmail was already determined earlier)
|
|
if shouldSendEmail {
|
|
// Use the saved requestBaseURL and settings (not from invitationData, as they were lost in DB operation)
|
|
// Send email asynchronously to improve user experience
|
|
go func() {
|
|
// Use background context for async operation
|
|
bgCtx := context.Background()
|
|
|
|
// Use createdMember data (from database) for email sending
|
|
// This ensures we have the actual stored values including properly formatted timestamps
|
|
emailData := maps.MapStrAny{}
|
|
for k, v := range createdMember {
|
|
emailData[k] = v
|
|
}
|
|
emailData["request_base_url"] = requestBaseURL
|
|
emailData["settings"] = savedSettings // Restore settings
|
|
|
|
err := sendTeamInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, token, invitationID, emailData)
|
|
if err != nil {
|
|
log.Error("Failed to send invitation email: %v", err)
|
|
} else {
|
|
log.Info("Invitation email sent to %s for team %s (invitation_id: %s)", inviteeEmail, teamName, invitationID)
|
|
}
|
|
}()
|
|
}
|
|
|
|
return invitationID, nil
|
|
}
|
|
|
|
// teamInvitationResend handles the business logic for resending a team invitation
|
|
func teamInvitationResend(ctx context.Context, userID, teamID, invitationID, requestBaseURL, locale string) error {
|
|
// Check if user has access to the team (write permission: owner only)
|
|
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only allow access if user is owner
|
|
if !isOwner {
|
|
return fmt.Errorf("access denied: only team owner can resend invitations")
|
|
}
|
|
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Get existing invitation using invitation_id (business key)
|
|
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
|
|
if err != nil {
|
|
return fmt.Errorf("invitation not found: %w", err)
|
|
}
|
|
|
|
// Verify invitation belongs to this team
|
|
if utils.ToString(invitationData["team_id"]) != teamID {
|
|
return fmt.Errorf("invitation not found in this team")
|
|
}
|
|
|
|
// Check if invitation is still pending
|
|
if utils.ToString(invitationData["status"]) != "pending" {
|
|
return fmt.Errorf("invitation is no longer pending and cannot be resent")
|
|
}
|
|
|
|
// Get email directly from member record's email field
|
|
inviteeEmail := utils.ToString(invitationData["email"])
|
|
if inviteeEmail == "" {
|
|
return fmt.Errorf("invitation has no email address, cannot resend")
|
|
}
|
|
|
|
// Get team information for email template
|
|
team, err := provider.GetTeam(ctx, teamID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get team information: %w", err)
|
|
}
|
|
teamName := utils.ToString(team["name"])
|
|
|
|
// Get inviter information for email template
|
|
inviter, err := provider.GetUser(ctx, userID)
|
|
if err != nil {
|
|
log.Warn("Failed to get inviter information: %v", err)
|
|
inviter = maps.MapStrAny{"name": "Team Admin"}
|
|
}
|
|
inviterName := utils.ToString(inviter["name"])
|
|
if inviterName == "" {
|
|
inviterName = utils.ToString(inviter["email"])
|
|
}
|
|
|
|
// Generate new invitation token
|
|
newToken, err := generateTeamInvitationToken()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate new invitation token: %w", err)
|
|
}
|
|
|
|
// Get team config for expiry duration
|
|
teamConfig := GetTeamConfig(locale)
|
|
if teamConfig == nil || teamConfig.Invite == nil {
|
|
return fmt.Errorf("team configuration not found for locale: %s", locale)
|
|
}
|
|
|
|
// Calculate expiry duration from config or use default
|
|
expiryDuration := 7 * 24 * time.Hour // Default 7 days
|
|
if teamConfig.Invite.Expiry != "" {
|
|
normalizedDuration, err := normalizeDuration(teamConfig.Invite.Expiry)
|
|
if err != nil {
|
|
log.Warn("Invalid expiry format in team config: %v, using default", err)
|
|
} else {
|
|
duration, err := time.ParseDuration(normalizedDuration)
|
|
if err != nil {
|
|
log.Warn("Failed to parse expiry duration %s: %v, using default", teamConfig.Invite.Expiry, err)
|
|
} else {
|
|
expiryDuration = duration
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update invitation with new token and extended expiry
|
|
newExpiryTime := time.Now().Add(expiryDuration)
|
|
updateData := maps.MapStrAny{
|
|
"invitation_token": newToken,
|
|
"invitation_expires_at": newExpiryTime,
|
|
"invited_at": time.Now(), // Update invitation time
|
|
"updated_at": time.Now(),
|
|
}
|
|
|
|
// Update invitation using invitation_id
|
|
err = provider.UpdateMemberByInvitationID(ctx, invitationID, updateData)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update invitation: %w", err)
|
|
}
|
|
|
|
// Prepare invitation data for email sending
|
|
invitationData["invitation_token"] = newToken
|
|
invitationData["invitation_expires_at"] = newExpiryTime
|
|
invitationData["request_base_url"] = requestBaseURL
|
|
|
|
// Set locale in invitation settings for email template
|
|
if settings, ok := invitationData["settings"].(*InvitationSettings); ok && settings != nil {
|
|
settings.Locale = locale
|
|
} else {
|
|
// Create settings if not exists
|
|
invitationData["settings"] = &InvitationSettings{
|
|
Locale: locale,
|
|
}
|
|
}
|
|
|
|
// Send new invitation email (asynchronously)
|
|
go func() {
|
|
// Use background context for async operation
|
|
bgCtx := context.Background()
|
|
err := sendTeamInvitationEmail(bgCtx, inviteeEmail, inviterName, teamName, newToken, invitationID, invitationData)
|
|
if err != nil {
|
|
log.Error("Failed to resend invitation email: %v", err)
|
|
} else {
|
|
log.Info("Invitation email resent to %s for team %s (invitation_id: %s)", inviteeEmail, teamName, invitationID)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// teamInvitationDelete handles the business logic for cancelling a team invitation
|
|
func teamInvitationDelete(ctx context.Context, userID, teamID, invitationID string) error {
|
|
// Check if user has access to the team (write permission: owner only)
|
|
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only allow access if user is owner
|
|
if !isOwner {
|
|
return fmt.Errorf("access denied: only team owner can cancel invitations")
|
|
}
|
|
|
|
// Get user provider instance
|
|
provider, err := getUserProvider()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get user provider: %w", err)
|
|
}
|
|
|
|
// Get existing invitation using invitation_id (business key)
|
|
invitationData, err := provider.GetMemberByInvitationID(ctx, invitationID)
|
|
if err != nil {
|
|
return fmt.Errorf("invitation not found: %w", err)
|
|
}
|
|
|
|
// Verify invitation belongs to this team
|
|
if utils.ToString(invitationData["team_id"]) != teamID {
|
|
return fmt.Errorf("invitation not found in this team")
|
|
}
|
|
|
|
// Check if invitation is still pending
|
|
if utils.ToString(invitationData["status"]) != "pending" {
|
|
return fmt.Errorf("invitation is no longer pending and cannot be cancelled")
|
|
}
|
|
|
|
// Remove the pending invitation (delete the member record)
|
|
err = provider.RemoveMemberByInvitationID(ctx, invitationID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to cancel invitation: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Private Helper Functions (internal use only)
|
|
|
|
// generateTeamInvitationToken generates a secure random token for invitations
|
|
func generateTeamInvitationToken() (string, error) {
|
|
bytes := make([]byte, 32) // 32 bytes = 256 bits
|
|
_, err := rand.Read(bytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Use URL-safe base64 encoding and remove padding
|
|
return strings.TrimRight(base64.URLEncoding.EncodeToString(bytes), "="), nil
|
|
}
|
|
|
|
// getTeamInvitationExpiry calculates the expiry duration for an invitation
|
|
// Priority: 1. Request expiry parameter, 2. Team config, 3. Default (7 days)
|
|
func getTeamInvitationExpiry(invitationData maps.MapStrAny) (time.Duration, error) {
|
|
// Default expiry: 7 days
|
|
defaultExpiry := 7 * 24 * time.Hour
|
|
|
|
// Check if expiry is provided in request
|
|
expiry := utils.ToString(invitationData["expiry"])
|
|
if expiry != "" {
|
|
normalizedDuration, err := normalizeDuration(expiry)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid expiry format: %w", err)
|
|
}
|
|
duration, err := time.ParseDuration(normalizedDuration)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to parse expiry duration: %w", err)
|
|
}
|
|
return duration, nil
|
|
}
|
|
|
|
// Get team config expiry (from global config)
|
|
// Try to get locale from invitation data settings
|
|
locale := "en"
|
|
if settings, ok := invitationData["settings"].(*InvitationSettings); ok && settings != nil {
|
|
if settings.Locale != "" {
|
|
locale = settings.Locale
|
|
}
|
|
} else if settingsMap, ok := invitationData["settings"].(map[string]interface{}); ok {
|
|
// Fallback for map format (for backward compatibility)
|
|
if loc := utils.ToString(settingsMap["locale"]); loc != "" {
|
|
locale = loc
|
|
}
|
|
}
|
|
|
|
teamConfig := GetTeamConfig(locale)
|
|
if teamConfig != nil && teamConfig.Invite != nil && teamConfig.Invite.Expiry != "" {
|
|
normalizedDuration, err := normalizeDuration(teamConfig.Invite.Expiry)
|
|
if err != nil {
|
|
log.Warn("Invalid expiry format in team config: %v, using default", err)
|
|
return defaultExpiry, nil
|
|
}
|
|
duration, err := time.ParseDuration(normalizedDuration)
|
|
if err != nil {
|
|
log.Warn("Failed to parse team config expiry: %v, using default", err)
|
|
return defaultExpiry, nil
|
|
}
|
|
return duration, nil
|
|
}
|
|
|
|
return defaultExpiry, nil
|
|
}
|
|
|
|
// sendTeamInvitationEmail sends an invitation email using messenger service
|
|
func sendTeamInvitationEmail(ctx context.Context, email, inviterName, teamName, token, invitationID string, invitationData maps.MapStrAny) error {
|
|
// Check if messenger is available
|
|
if messenger.Instance == nil {
|
|
return fmt.Errorf("messenger service not available")
|
|
}
|
|
|
|
// Get locale from invitation data settings
|
|
locale := "en"
|
|
if settings, ok := invitationData["settings"].(*InvitationSettings); ok && settings != nil {
|
|
if settings.Locale != "" {
|
|
locale = settings.Locale
|
|
}
|
|
} else if settingsMap, ok := invitationData["settings"].(map[string]interface{}); ok {
|
|
// Fallback for map format (for backward compatibility)
|
|
if loc := utils.ToString(settingsMap["locale"]); loc != "" {
|
|
locale = loc
|
|
}
|
|
}
|
|
|
|
// Get team config for email template and channel
|
|
// Note: GetTeamConfig will normalize locale internally (trim, lowercase, etc.)
|
|
teamConfig := GetTeamConfig(locale)
|
|
if teamConfig == nil || teamConfig.Invite == nil {
|
|
return fmt.Errorf("team configuration not found for locale: %s", locale)
|
|
}
|
|
|
|
// Get email template ID from team config
|
|
emailTemplate := ""
|
|
if teamConfig.Invite.Templates != nil {
|
|
if tpl, ok := teamConfig.Invite.Templates["mail"]; ok {
|
|
emailTemplate = tpl
|
|
}
|
|
}
|
|
if emailTemplate == "" {
|
|
return fmt.Errorf("email template not configured in team config")
|
|
}
|
|
|
|
// Get channel from team config (default to "default")
|
|
channel := "default"
|
|
if teamConfig.Invite.Channel != "" {
|
|
channel = teamConfig.Invite.Channel
|
|
}
|
|
|
|
// Get custom message from invitation data
|
|
customMessage := utils.ToString(invitationData["message"])
|
|
|
|
// Get request base URL from invitation data (if provided)
|
|
requestBaseURL := utils.ToString(invitationData["request_base_url"])
|
|
|
|
// Build invitation link using centralized helper function
|
|
invitationLink := buildTeamInvitationLink(invitationID, token, teamConfig, requestBaseURL)
|
|
|
|
// Get time format based on locale
|
|
timeFormat := utils.GetTimeFormat(locale)
|
|
|
|
// Format expires_at with locale-specific format
|
|
expiresAtFormatted := utils.FormatTimeWithLocale(invitationData["invitation_expires_at"], timeFormat)
|
|
|
|
// Prepare template data for messenger
|
|
templateData := messengertypes.TemplateData{
|
|
"to": email,
|
|
"inviter_name": inviterName,
|
|
"team_name": teamName,
|
|
"invitation_id": invitationID,
|
|
"invitation_link": invitationLink, // Full invitation link
|
|
"token": token, // Keep token for backward compatibility
|
|
"message": customMessage,
|
|
"role_id": utils.ToString(invitationData["role_id"]),
|
|
"expires_at": expiresAtFormatted,
|
|
}
|
|
|
|
// Send email using messenger template
|
|
err := messenger.Instance.SendT(ctx, channel, emailTemplate, templateData, messengertypes.MessageTypeEmail)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send invitation email: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// convertToTeamInvitationResponse converts a map to InvitationResponse (alias for mapToTeamInvitationResponse)
|
|
func convertToTeamInvitationResponse(data maps.MapStrAny, requestBaseURL string) InvitationResponse {
|
|
return mapToTeamInvitationResponse(maps.MapStr(data), requestBaseURL)
|
|
}
|
|
|
|
// mapToTeamInvitationResponse converts a map to InvitationResponse
|
|
func mapToTeamInvitationResponse(data maps.MapStr, requestBaseURL string) InvitationResponse {
|
|
invitation := InvitationResponse{
|
|
ID: utils.ToInt64(data["id"]),
|
|
InvitationID: utils.ToString(data["invitation_id"]),
|
|
TeamID: utils.ToString(data["team_id"]),
|
|
UserID: utils.ToString(data["user_id"]),
|
|
MemberType: utils.ToString(data["member_type"]),
|
|
RoleID: utils.ToString(data["role_id"]),
|
|
Status: utils.ToString(data["status"]),
|
|
InvitedBy: utils.ToString(data["invited_by"]),
|
|
InvitedAt: utils.ToTimeString(data["invited_at"]),
|
|
InvitationToken: utils.ToString(data["invitation_token"]),
|
|
InvitationExpiresAt: utils.ToTimeString(data["invitation_expires_at"]),
|
|
Message: utils.ToString(data["message"]),
|
|
CreatedAt: utils.ToTimeString(data["created_at"]),
|
|
UpdatedAt: utils.ToTimeString(data["updated_at"]),
|
|
}
|
|
|
|
// Add settings if available
|
|
locale := "en" // Default locale
|
|
if settings, ok := data["settings"]; ok {
|
|
if invSettings, ok := settings.(*InvitationSettings); ok {
|
|
invitation.Settings = invSettings
|
|
if invSettings.Locale != "" {
|
|
locale = invSettings.Locale
|
|
}
|
|
} else if settingsMap, ok := settings.(map[string]interface{}); ok {
|
|
// Convert map to InvitationSettings
|
|
invSettings := &InvitationSettings{
|
|
SendEmail: utils.ToBool(settingsMap["send_email"]),
|
|
Locale: utils.ToString(settingsMap["locale"]),
|
|
}
|
|
invitation.Settings = invSettings
|
|
if invSettings.Locale != "" {
|
|
locale = invSettings.Locale
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build invitation link if token is available
|
|
if invitation.InvitationToken != "" && invitation.InvitationID != "" {
|
|
teamConfig := GetTeamConfig(locale)
|
|
invitation.InvitationLink = buildTeamInvitationLink(invitation.InvitationID, invitation.InvitationToken, teamConfig, requestBaseURL)
|
|
}
|
|
|
|
return invitation
|
|
}
|
|
|
|
// mapToTeamInvitationDetailResponse converts a map to InvitationDetailResponse
|
|
func mapToTeamInvitationDetailResponse(data maps.MapStr, requestBaseURL string) InvitationDetailResponse {
|
|
invitation := InvitationDetailResponse{
|
|
InvitationResponse: mapToTeamInvitationResponse(data, requestBaseURL),
|
|
}
|
|
|
|
// Add user info if available (could be joined from user table)
|
|
if userInfo, ok := data["user_info"]; ok {
|
|
if userInfoMap, ok := userInfo.(map[string]interface{}); ok {
|
|
invitation.UserInfo = userInfoMap
|
|
}
|
|
}
|
|
|
|
// Add team info if available (could be joined from team table)
|
|
if teamInfo, ok := data["team_info"]; ok {
|
|
if teamInfoMap, ok := teamInfo.(map[string]interface{}); ok {
|
|
invitation.TeamInfo = teamInfoMap
|
|
}
|
|
}
|
|
|
|
return invitation
|
|
}
|